Extrair dados de faturas com OCR da GCP e Java
Uma resposta de OCR fornece texto; seu fluxo de trabalho com faturas ainda precisa de campos que você possa inspecionar. Esta CLI Maven envia uma fatura de exemplo gerada ao Google Cloud Vision, analisa suas linhas rotuladas e grava um JSON com o número da fatura, a data, o fornecedor e o total em USD. O resultado exige revisão humana antes de qualquer envio para fins financeiros.
Pré-requisitos
Os comandos abaixo usam um shell Bash no Linux ou macOS. Use o JDK 21.0.12.1 e o Maven 3.10.0 neste
exemplo, além da Google Cloud CLI. As bibliotecas cliente Java do Google
oferecem suporte ao Java 21.
Instale o Maven seguindo as instruções oficiais de instalação
se mvn não estiver disponível.
Você também precisa de um projeto com o faturamento e a Cloud Vision API já ativados, além de uma conta com permissão para usar a API e a cota desse projeto. Siga o guia de configuração do Vision do Google se esses pré-requisitos não estiverem atendidos. Comece com a fatura gerada abaixo, que não contém dados sensíveis; cada chamada de OCR envia a imagem ao Google e pode gerar uma cobrança.
Definir o layout da fatura
Este analisador aceita quatro linhas rotuladas, com datas no formato dos EUA (mês/dia/ano) e um valor explicitamente em USD:
Invoice No: INV-123
Date: 03/19/2025
Vendor: Example Ltd
Total: USD 1,234.56
Ele verifica a presença dos campos, rótulos repetidos, a validade das datas no calendário e a sintaxe dos valores. Ele não consegue determinar se o OCR leu corretamente um valor plausível. Compare o JSON com a imagem antes de usá-lo. Para faturas com outros layouts ou moedas, altere e teste o analisador com esses documentos, em vez de presumir que estas regras se aplicam.
Autenticar o SDK Java
Para desenvolvimento local, o SDK usa
Application Default Credentials.
Substitua YOUR_PROJECT_ID pelo ID do seu projeto nos dois comandos:
gcloud auth application-default login --project=YOUR_PROJECT_ID &&
gcloud auth application-default set-quota-project YOUR_PROJECT_ID
O projeto de cota determina qual projeto fornece a cota e é usado para faturamento. Uma sessão de
gcloud auth login, por si só, não fornece as ADC do SDK. Se você usar ADC locais de
usuário, remova do shell uma configuração de sobrescrita desatualizada de
GOOGLE_APPLICATION_CREDENTIALS para que o SDK não selecione um arquivo de credenciais diferente.
Nenhuma chave de conta de serviço é necessária para este exemplo local.
Criar o projeto Maven
Execute este bloco em um diretório fora de um projeto Maven existente. Ele verifica as ferramentas
e recusa configurações de pom.xml ou .mvn no
diretório atual ou em qualquer diretório ancestral antes de criar arquivos:
(
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
)
Um diretório invoice-ocr existente é preservado e recusado. Escolha um novo
diretório pai em vez de excluir um projeto existente. Se a criação parar após criar um diretório
vazio, corrija a causa e conclua src/main/java nesse diretório antes de salvar os
arquivos abaixo.
Salve este pom.xml completo como invoice-ocr/pom.xml. O
Google Cloud BOM gerencia as versões das bibliotecas.
Esta versão fixada resolve Vision 3.97.0 e Gson 2.14.0. A etapa de empacotamento compila as classes e
copia as dependências de execução para target/dependency, para uso pelo inicializador
Java.
<?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>
Gerar uma fatura de exemplo
Salve GenerateInvoice.java em invoice-ocr/src/main/java. Ele cria um PNG a partir
dos campos conhecidos acima, sem precisar de um documento de cliente nem do download de uma
imagem. Ele recusa um destino existente.
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);
}
}
}
Analisar e validar os campos rotulados
Salve InvoiceParser.java junto ao gerador. Os rótulos completos distinguem
Total de Subtotal.
Rótulos alternativos, como Invoice Date e Amount Due, são
aceitos, mas dois rótulos para o mesmo campo são rejeitados mesmo quando seus valores coincidem.
As datas usam um analisador com validação rigorosa de calendário; 03/04/2025
significa 4 de março neste formato explicitamente dos EUA. Valores com vírgula decimal, outras
moedas e texto adicional no valor ou na data são rejeitados. Os valores permanecem como strings
decimais, para que um consumidor do JSON não precise usar aritmética binária de ponto flutuante
para valores monetários.
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;
}
}
Conectar o OCR do Vision ao resultado em JSON
Salve InvoiceOCR.java no mesmo diretório. A CLI lê uma imagem local, chama o Vision,
passa o texto retornado diretamente para InvoiceParser e serializa os campos
analisados com Gson. Ela usa DOCUMENT_TEXT_DETECTION, cuja resposta é
otimizada para texto denso e documentos.
A resposta do Vision inclui informações de layout; este pequeno analisador usa apenas o texto.
O código seleciona o endpoint de OCR na UE do Google e um recurso pai de solicitação explicitamente na UE. Ele desativa as novas tentativas automáticas de reconhecimento e define um tempo limite de 20 segundos para RPC. Uma solicitação que falhou ou excedeu o tempo limite ainda pode ter chegado ao Google; outra chamada pode gerar outra cobrança.
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);
}
}
}
O exemplo usa uma imagem rasterizada. Entradas PDF/TIFF precisam de uma
solicitação de anotação de arquivo, em vez de colocar os bytes do
documento em Image. O analisador também espera que cada rótulo e seu
valor estejam na mesma linha do OCR. Ele ignora linhas não reconhecidas, como descrições de itens,
em vez de interpretar uma tabela.
Compilar e executar o exemplo completo
No diretório pai de invoice-ocr, substitua YOUR_PROJECT_ID e cole:
(
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
)
O separador de classpath aqui é :, como usado no Linux e macOS. Uma
falha na compilação impede a inicialização, inclusive quando existe uma classe compilada mais
antiga. Uma execução bem-sucedida cria invoice.png e
invoice.json. O objeto invoice do JSON deve conter estes
valores, independentemente da ordem das chaves:
{
"invoiceNumber": "INV-123",
"date": "2025-03-19",
"vendor": "Example Ltd",
"totalAmount": "1234.56",
"currency": "USD"
}
O objeto externo contém status: "needs_review" e o ocrText efetivo,
para que você possa inspecionar o que o analisador consumiu. Confira todos os cinco valores com
invoice.png; campos sintaticamente válidos ainda podem estar errados. O JSON
usa UTF-8 e termina com uma quebra de linha.
O gerador e a CLI recusam caminhos de saída existentes. Portanto, repetir o bloco completo faz a
execução parar em um invoice.png existente, antes de outra solicitação de
reconhecimento. Mantenha a imagem e o JSON que você já tem. Para tentar novamente apenas o OCR após
corrigir uma falha, use um novo destino para o JSON:
(
cd invoice-ocr &&
java -cp 'target/classes:target/dependency/*' InvoiceOCR YOUR_PROJECT_ID invoice.png invoice-retry.json &&
cat invoice-retry.json
)
Se a CLI encerrar com um código diferente de zero, verifique se existe um arquivo de saída antes de tentar novamente. Falhas de argumentos, decodificação, provedor, texto vazio e análise ocorrem antes da criação do JSON. Uma falha de gravação local pode deixar um novo arquivo parcial; mantenha-o para inspeção e escolha outro destino. As saídas que já existiam são preservadas. Para uma imagem em branco, inspecione a digitalização. Se um campo for rejeitado, verifique as quatro linhas rotuladas e seus formatos. Para uma falha do Vision, verifique as ADC, o acesso à API, a cota e a conectividade de rede. A CLI omite os erros brutos do provedor e os detalhes das credenciais.
Revisar antes de usar dados financeiros
Este fluxo de trabalho termina em um artefato local para revisão. Ele não envia nada a um serviço contábil. Após você conferir os campos com a imagem, uma integração separada pode mapeá-los para o esquema documentado do destino e usar seu mecanismo de idempotência ao enviar dados ou reconciliar novas tentativas. A validação de presença e formato não autoriza o pagamento nem comprova que uma fatura é autêntica.
