Extract invoice data with GCP OCR and Java
An OCR response gives you text; your invoice workflow still needs fields you can inspect. This Maven CLI sends a generated sample invoice to Google Cloud Vision, parses its labeled lines, and writes JSON containing the invoice number, date, vendor, and USD total. The output requires human review before any financial submission.
Prerequisites
The commands below use a Bash shell on Linux or macOS. Use JDK 21.0.12.1 and Maven 3.10.0 for this
example, plus the Google Cloud CLI. Google’s
Java client libraries support Java 21.
Install Maven using the official installation instructions
if mvn is unavailable.
You also need a project with billing and the Cloud Vision API already enabled, and an account allowed to use that project’s API and quota. Follow Google’s Vision setup guide if those prerequisites are missing. Start with the non-sensitive generated invoice below; every OCR invocation sends its image to Google and can incur a charge.
Define the invoice layout
This parser handles four labeled lines, with US month/day/year dates and an explicit USD amount:
Invoice No: INV-123
Date: 03/19/2025
Vendor: Example Ltd
Total: USD 1,234.56
It checks field presence, repeated labels, calendar validity, and amount syntax. It cannot determine whether OCR read a plausible value correctly. Compare the JSON with the image before using it. For invoices with other layouts or currencies, change and test the parser against those documents rather than assuming these rules apply.
Authenticate the Java SDK
For local development, the SDK uses
Application Default Credentials.
Replace YOUR_PROJECT_ID with your project ID in both commands:
gcloud auth application-default login --project=YOUR_PROJECT_ID &&
gcloud auth application-default set-quota-project YOUR_PROJECT_ID
The quota project determines which project supplies quota and billing. A gcloud auth login
session alone does not supply the SDK’s ADC. If you use local user ADC, remove a stale
GOOGLE_APPLICATION_CREDENTIALS override from your shell so the SDK does not select a different
credential file. No service-account key is needed for this local example.
Create the Maven project
Run this block from a directory outside an existing Maven project. It checks the tools and refuses
an enclosing pom.xml or .mvn configuration before creating files:
(
cd -P . || exit 1
parent="$PWD"
while :; do
if [ -e "$parent/pom.xml" ] || [ -e "$parent/.mvn" ]; then
printf '%s\n' 'Choose a directory outside an existing Maven project.' >&2
exit 1
fi
[ "$parent" = / ] && break
parent="${parent%/*}"
[ -n "$parent" ] || parent=/
done
java -version && javac -version && mvn -version && gcloud --version || exit 1
mkdir invoice-ocr && mkdir -p invoice-ocr/src/main/java
)
An existing invoice-ocr directory is preserved and refused. Choose a fresh parent directory
instead of deleting an existing project. If creation stops after making an empty directory, fix the
cause and finish src/main/java inside that directory before saving the files below.
Save this complete pom.xml as invoice-ocr/pom.xml. The
Google Cloud BOM manages the library versions.
This pin resolves Vision 3.97.0 and Gson 2.14.0. The package step compiles the classes and copies
runtime dependencies into target/dependency for the Java launcher.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>example</groupId>
<artifactId>invoice-ocr</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.google.cloud</groupId>
<artifactId>libraries-bom</artifactId>
<version>26.90.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>com.google.cloud</groupId>
<artifactId>google-cloud-vision</artifactId>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.1</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<version>3.9.0</version>
<executions>
<execution>
<id>runtime-dependencies</id>
<phase>package</phase>
<goals><goal>copy-dependencies</goal></goals>
<configuration><includeScope>runtime</includeScope></configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
Generate a sample invoice
Save GenerateInvoice.java in invoice-ocr/src/main/java. It creates a PNG from the known fields
above, without needing a customer document or an image download. It refuses an existing destination.
import java.awt.Color;
import java.awt.Font;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import javax.imageio.ImageIO;
public class GenerateInvoice {
public static void main(String[] args) {
try {
if (args.length != 1) {
throw new IllegalArgumentException("Expected one PNG destination");
}
BufferedImage image = new BufferedImage(1600, 600, BufferedImage.TYPE_INT_RGB);
Graphics2D graphics = image.createGraphics();
try {
graphics.setColor(Color.WHITE);
graphics.fillRect(0, 0, image.getWidth(), image.getHeight());
graphics.setColor(Color.BLACK);
graphics.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 48));
String[] lines = {
"Invoice No: INV-123", "Date: 03/19/2025",
"Vendor: Example Ltd", "Total: USD 1,234.56"
};
for (int index = 0; index < lines.length; index++) {
graphics.drawString(lines[index], 80, 120 + index * 110);
}
} finally {
graphics.dispose();
}
try (OutputStream output = Files.newOutputStream(Path.of(args[0]),
StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) {
if (!ImageIO.write(image, "png", output)) {
throw new IllegalStateException("PNG encoder unavailable");
}
}
} catch (Exception error) {
System.err.println("Sample creation failed (" + error.getClass().getSimpleName() + ")");
System.exit(1);
}
}
}
Parse and validate the labeled fields
Save InvoiceParser.java alongside the generator. Whole labels distinguish Total from Subtotal.
Aliases such as Invoice Date and Amount Due are accepted, but two labels for the same field are
rejected even when their values agree. Dates use a strict calendar parser; 03/04/2025 means
March 4 in this explicitly US format. Comma-decimal amounts, other currencies, and extra amount or
date text are rejected. Amounts stay decimal strings so a JSON consumer need not use binary
floating-point arithmetic for money.
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.ResolverStyle;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
public class InvoiceParser {
public static Map<String, String> parseInvoiceData(String text) {
Map<String, String> fields = new LinkedHashMap<>();
for (String line : text.split("\\R")) {
String[] parts = line.strip().split(":", 2);
if (parts.length != 2) continue;
String field = switch (parts[0].strip().toLowerCase(Locale.ROOT)) {
case "invoice no", "invoice no.", "invoice number", "invoice #" -> "invoiceNumber";
case "date", "invoice date" -> "date";
case "total", "amount due", "balance due" -> "totalAmount";
case "vendor", "supplier", "from", "company" -> "vendor";
default -> null;
};
if (field == null) continue;
if (fields.putIfAbsent(field, parts[1].strip()) != null) {
throw new IllegalArgumentException("Duplicate invoice field: " + field);
}
}
for (String field : new String[] {"invoiceNumber", "date", "vendor", "totalAmount"}) {
if (!fields.containsKey(field) || fields.get(field).isBlank()) {
throw new IllegalArgumentException("Missing invoice field: " + field);
}
}
if (!fields.get("invoiceNumber").matches("[A-Za-z0-9][A-Za-z0-9/-]*")) {
throw new IllegalArgumentException("Invalid invoice number");
}
if (!fields.get("date").matches("[0-9]{1,2}/[0-9]{1,2}/[0-9]{4}")) {
throw new IllegalArgumentException("Expected a US month/day/year date");
}
DateTimeFormatter dates = DateTimeFormatter.ofPattern("M/d/uuuu", Locale.US)
.withResolverStyle(ResolverStyle.STRICT);
fields.put("date", LocalDate.parse(fields.get("date"), dates).toString());
String total = fields.get("totalAmount");
if (!total.matches("USD[ \\t]+(?:[0-9]+|[0-9]{1,3}(?:,[0-9]{3})+)\\.[0-9]{2}")) {
throw new IllegalArgumentException("Expected one USD amount with two decimal places");
}
fields.put("totalAmount", new BigDecimal(total.substring(3).strip()
.replace(",", "")).toPlainString());
fields.put("currency", "USD");
return fields;
}
}
Connect Vision OCR to JSON output
Save InvoiceOCR.java in the same directory. The CLI reads one local image, calls Vision, passes
the returned text directly to InvoiceParser, and serializes the parsed fields with Gson. It uses
DOCUMENT_TEXT_DETECTION, whose response is
optimized for dense text and documents.
Vision’s response includes layout information; this small parser uses only its text.
The code selects Google’s EU OCR endpoint and an explicit EU request parent. It disables automatic recognition retries and sets a 20-second RPC timeout. A failed or timed-out request can still have reached Google; another invocation can incur another charge.
import com.google.api.gax.grpc.InstantiatingGrpcChannelProvider;
import com.google.api.gax.rpc.ApiException;
import com.google.cloud.vision.v1.AnnotateImageRequest;
import com.google.cloud.vision.v1.AnnotateImageResponse;
import com.google.cloud.vision.v1.BatchAnnotateImagesRequest;
import com.google.cloud.vision.v1.BatchAnnotateImagesResponse;
import com.google.cloud.vision.v1.Feature;
import com.google.cloud.vision.v1.Image;
import com.google.cloud.vision.v1.ImageAnnotatorClient;
import com.google.cloud.vision.v1.ImageAnnotatorSettings;
import com.google.gson.GsonBuilder;
import com.google.protobuf.ByteString;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.time.DateTimeException;
import java.time.Duration;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import javax.imageio.ImageIO;
public class InvoiceOCR {
public static void main(String[] args) {
try {
if (args.length != 3 || !args[0].matches("[a-z][a-z0-9-]{4,28}[a-z0-9]")) {
System.err.println("Usage: InvoiceOCR PROJECT_ID IMAGE_PATH NEW_JSON_PATH");
System.exit(1);
}
Path output = Path.of(args[2]);
if (Files.exists(output)) {
throw new InvoiceFailure("Output already exists; choose a fresh JSON destination.");
}
byte[] bytes = Files.readAllBytes(Path.of(args[1]));
if (ImageIO.read(new ByteArrayInputStream(bytes)) == null) {
throw new InvoiceFailure("Image cannot be decoded; check the input file.");
}
String text = recognize(args[0], bytes);
Map<String, String> invoice = InvoiceParser.parseInvoiceData(text);
Map<String, Object> review = new LinkedHashMap<>();
review.put("status", "needs_review");
review.put("invoice", invoice);
review.put("ocrText", text);
String json = new GsonBuilder().setPrettyPrinting().create().toJson(review) + "\n";
Files.writeString(output, json, StandardCharsets.UTF_8,
StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE);
System.out.println("Saved JSON for human review.");
} catch (InvoiceFailure error) {
System.err.println(error.getMessage());
System.exit(1);
} catch (IllegalArgumentException | DateTimeException error) {
System.err.println("Invoice rejected: check labels, duplicate fields, US dates, and USD amounts.");
System.exit(1);
} catch (ApiException error) {
System.err.println("Vision request failed (" + error.getStatusCode().getCode() + ").");
System.exit(1);
} catch (Exception error) {
System.err.println("Invoice processing failed (" + error.getClass().getSimpleName()
+ "). Check input, output, and ADC; no invoice was submitted.");
System.exit(1);
}
}
private static String recognize(String project, byte[] bytes) throws IOException {
ImageAnnotatorSettings.Builder settings = ImageAnnotatorSettings.newBuilder()
.setEndpoint("eu-vision.googleapis.com:443")
.setQuotaProjectId(project)
.setTransportChannelProvider(InstantiatingGrpcChannelProvider.newBuilder()
.setChannelConfigurator(channel -> channel.disableRetry()).build());
settings.batchAnnotateImagesSettings().setRetryableCodes(Collections.emptySet());
settings.batchAnnotateImagesSettings().setRetrySettings(
settings.batchAnnotateImagesSettings().getRetrySettings().toBuilder()
.setMaxAttempts(1)
.setInitialRpcTimeoutDuration(Duration.ofSeconds(20))
.setMaxRpcTimeoutDuration(Duration.ofSeconds(20))
.setTotalTimeoutDuration(Duration.ofSeconds(20)).build());
AnnotateImageRequest imageRequest = AnnotateImageRequest.newBuilder()
.setImage(Image.newBuilder().setContent(ByteString.copyFrom(bytes)).build())
.addFeatures(Feature.newBuilder().setType(Feature.Type.DOCUMENT_TEXT_DETECTION).build())
.build();
BatchAnnotateImagesRequest request = BatchAnnotateImagesRequest.newBuilder()
.setParent("projects/" + project + "/locations/eu")
.addRequests(imageRequest)
.build();
try (ImageAnnotatorClient client = ImageAnnotatorClient.create(settings.build())) {
BatchAnnotateImagesResponse batch = client.batchAnnotateImages(request);
if (batch.getResponsesCount() != 1) {
throw new InvoiceFailure("Expected one Vision response; output was not created.");
}
AnnotateImageResponse response = batch.getResponses(0);
if (response.hasError()) {
throw new InvoiceFailure("Vision returned an image error (code "
+ response.getError().getCode() + "); output was not created.");
}
String text = response.getFullTextAnnotation().getText();
if (text.isBlank()) {
throw new InvoiceFailure("No text found; output was not created.");
}
return text;
}
}
private static class InvoiceFailure extends IOException {
private static final long serialVersionUID = 1L;
InvoiceFailure(String message) {
super(message);
}
}
}
The example uses a raster image. PDF/TIFF inputs need a
file annotation request instead of putting
document bytes into Image. The parser also expects each label and value on the same OCR line.
It ignores unrecognized lines, such as item descriptions, rather than interpreting a table.
Build and run the complete example
From the parent of invoice-ocr, replace YOUR_PROJECT_ID and paste:
(
cd invoice-ocr &&
mvn -q package &&
java -Djava.awt.headless=true -cp 'target/classes:target/dependency/*' GenerateInvoice invoice.png &&
java -cp 'target/classes:target/dependency/*' InvoiceOCR YOUR_PROJECT_ID invoice.png invoice.json &&
cat invoice.json
)
The classpath separator here is :, as used on Linux and macOS. A failed build stops the launch,
including when an older compiled class is present. A successful run creates invoice.png and
invoice.json. The JSON’s invoice object should contain these values, regardless of key order:
{
"invoiceNumber": "INV-123",
"date": "2025-03-19",
"vendor": "Example Ltd",
"totalAmount": "1234.56",
"currency": "USD"
}
The surrounding object has status: "needs_review" and the actual ocrText, so you can inspect
what the parser consumed. Check all five values against invoice.png; syntactically valid fields
can still be wrong. The JSON is UTF-8 and ends with a newline.
The generator and CLI refuse existing output paths. Repeating the complete block therefore stops
at an existing invoice.png before another recognition request. Keep the image and JSON you already
have. To retry only OCR after correcting a failure, use a fresh JSON destination:
(
cd invoice-ocr &&
java -cp 'target/classes:target/dependency/*' InvoiceOCR YOUR_PROJECT_ID invoice.png invoice-retry.json &&
cat invoice-retry.json
)
If the CLI exits nonzero, check whether an output file exists before retrying. Argument, decoding, provider, blank-text, and parsing failures occur before JSON creation. A local write failure can leave a partial new file; keep it for inspection and choose another destination. Previously existing outputs are preserved. For a blank image, inspect the scan. For a field rejection, check the four labeled lines and their formats. For a Vision failure, check ADC, API access, quota, and network connectivity. The CLI suppresses raw provider errors and credential details.
Review before using financial data
This workflow ends at a local review artifact. It does not send anything to an accounting service. After checking the fields against the image, a separate integration can map them to the destination’s documented schema and use its idempotency mechanism when submitting or reconciling retries. Presence and format validation do not authorize payment or establish that an invoice is genuine.
