Extrae datos de facturas con GCP OCR y Java
Una respuesta de OCR te da texto; tu flujo de trabajo de facturas aún necesita campos que puedas inspeccionar. Esta CLI de Maven envía una factura de muestra generada a Google Cloud Vision, analiza sus líneas etiquetadas y escribe un JSON con el número de factura, la fecha, el proveedor y el total en USD. El resultado requiere revisión humana antes de cualquier envío con fines financieros.
Requisitos previos
Los comandos siguientes usan una shell Bash en Linux o macOS. Usa JDK 21.0.12.1 y Maven 3.10.0 para
este ejemplo, además de la CLI de Google Cloud. Las bibliotecas
cliente de Java de Google son compatibles con Java 21.
Instala Maven siguiendo las instrucciones oficiales de instalación
si mvn no está disponible.
También necesitas un proyecto con la facturación y la API de Cloud Vision ya habilitadas, y una cuenta autorizada para usar la API y la cuota de ese proyecto. Sigue la guía de configuración de Vision si no cumples estos requisitos. Empieza con la factura generada sin datos sensibles que aparece a continuación; cada invocación de OCR envía su imagen a Google y puede generar un cargo.
Define el diseño de la factura
Este analizador maneja cuatro líneas etiquetadas, con fechas en el formato estadounidense mes/día/año y un importe explícito en USD:
Invoice No: INV-123
Date: 03/19/2025
Vendor: Example Ltd
Total: USD 1,234.56
Comprueba la presencia de campos, las etiquetas repetidas, la validez de las fechas y la sintaxis del importe. No puede determinar si el OCR leyó correctamente un valor plausible. Compara el JSON con la imagen antes de usarlo. Para facturas con otros diseños o monedas, modifica el analizador y pruébalo con esos documentos en lugar de suponer que estas reglas se aplican.
Autentica el SDK de Java
Para el desarrollo local, el SDK usa
Application Default Credentials.
Reemplaza YOUR_PROJECT_ID por el ID de tu proyecto en ambos comandos:
gcloud auth application-default login --project=YOUR_PROJECT_ID &&
gcloud auth application-default set-quota-project YOUR_PROJECT_ID
El proyecto de cuota determina qué proyecto aporta la cuota y asume la facturación. Una sesión de
gcloud auth login por sí sola no proporciona las ADC del SDK. Si usas ADC de usuario
locales, elimina de tu shell cualquier anulación obsoleta de GOOGLE_APPLICATION_CREDENTIALS para
que el SDK no seleccione otro archivo de credenciales. Este ejemplo local no necesita una clave
de cuenta de servicio.
Crea el proyecto Maven
Ejecuta este bloque desde un directorio que esté fuera de un proyecto Maven existente. Comprueba
las herramientas y rechaza cualquier configuración pom.xml o
.mvn en los directorios que lo contienen antes de crear archivos:
(
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
)
Si ya existe un directorio invoice-ocr, se conserva y se rechaza su uso. Elige un
nuevo directorio padre en lugar de eliminar un proyecto existente. Si la creación se detiene tras
crear un directorio vacío, corrige la causa y completa src/main/java dentro de ese
directorio antes de guardar los archivos siguientes.
Guarda este pom.xml completo como invoice-ocr/pom.xml. La
BOM de Google Cloud gestiona las versiones de las bibliotecas.
Esta versión fijada resuelve las dependencias a Vision 3.97.0 y Gson 2.14.0. El paso de empaquetado
compila las clases y copia las dependencias de ejecución en target/dependency para
el lanzador de 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>
Genera una factura de muestra
Guarda GenerateInvoice.java en invoice-ocr/src/main/java. Crea un PNG a partir de
los campos conocidos anteriores, sin necesitar un documento de un cliente ni descargar una imagen.
Rechaza los destinos existentes.
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);
}
}
}
Analiza y valida los campos etiquetados
Guarda InvoiceParser.java junto al generador. Las etiquetas completas permiten distinguir
Total de Subtotal.
Se aceptan alias como Invoice Date y Amount Due, pero se
rechazan dos etiquetas para el mismo campo aunque sus valores coincidan. Las fechas usan un
analizador estricto que valida el calendario; 03/04/2025 significa 4 de marzo
en este formato explícitamente estadounidense. Se rechazan los importes con coma decimal, otras
monedas y el texto adicional en importes o fechas. Los importes se mantienen como cadenas decimales
para que quien consuma el JSON no necesite usar aritmética de punto flotante binario para el dinero.
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;
}
}
Conecta el OCR de Vision con la salida JSON
Guarda InvoiceOCR.java en el mismo directorio. La CLI lee una imagen local, llama a
Vision, pasa el texto devuelto directamente a InvoiceParser y serializa los campos
analizados con Gson. Usa DOCUMENT_TEXT_DETECTION, cuya respuesta está
optimizada para texto denso y documentos.
La respuesta de Vision incluye información de diseño; este pequeño analizador solo usa su texto.
El código selecciona el endpoint de OCR de Google para la UE y un recurso padre explícito de la UE para la solicitud. Desactiva los reintentos automáticos de reconocimiento y establece un tiempo de espera de RPC de 20 segundos. Una solicitud fallida o cuyo tiempo de espera se haya agotado puede haber llegado a Google; otra invocación puede generar otro cargo.
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);
}
}
}
El ejemplo usa una imagen rasterizada. Las entradas PDF/TIFF necesitan una
solicitud de anotación de archivos en lugar de colocar los bytes
del documento en Image. El analizador también espera que cada etiqueta
y su valor estén en la misma línea de OCR. Ignora las líneas no reconocidas, como las descripciones
de artículos, en lugar de interpretar una tabla.
Compila y ejecuta el ejemplo completo
Desde el directorio padre de invoice-ocr, reemplaza
YOUR_PROJECT_ID y pega lo siguiente:
(
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
)
Aquí el separador del classpath es :, como se usa en Linux y macOS.
Una compilación fallida detiene el lanzamiento, incluso si existe una clase compilada anterior.
Una ejecución exitosa crea invoice.png y invoice.json.
El objeto invoice del JSON debería contener estos valores, sin importar
el orden de las claves:
{
"invoiceNumber": "INV-123",
"date": "2025-03-19",
"vendor": "Example Ltd",
"totalAmount": "1234.56",
"currency": "USD"
}
El objeto contenedor tiene status: "needs_review" y el ocrText real,
para que puedas inspeccionar lo que consumió el analizador. Compara los cinco valores con
invoice.png; los campos con sintaxis válida aún pueden ser incorrectos.
El JSON usa UTF-8 y termina con un salto de línea.
El generador y la CLI rechazan las rutas de salida existentes. Por lo tanto, al repetir el bloque
completo, la ejecución se detiene al encontrar un invoice.png existente antes
de realizar otra solicitud de reconocimiento. Conserva la imagen y el JSON que ya tienes. Para
reintentar solo el OCR tras corregir un fallo, usa un nuevo destino para el JSON:
(
cd invoice-ocr &&
java -cp 'target/classes:target/dependency/*' InvoiceOCR YOUR_PROJECT_ID invoice.png invoice-retry.json &&
cat invoice-retry.json
)
Si la CLI termina con un código distinto de cero, comprueba si existe un archivo de salida antes de reintentarlo. Los fallos de argumentos, decodificación, proveedor, texto vacío y análisis ocurren antes de crear el JSON. Un fallo de escritura local puede dejar un archivo nuevo parcial; consérvalo para inspeccionarlo y elige otro destino. Las salidas que ya existían se conservan. Si la imagen está en blanco, inspecciona el escaneo. Si se rechaza un campo, revisa las cuatro líneas etiquetadas y sus formatos. Si Vision falla, revisa las ADC, el acceso a la API, la cuota y la conectividad de red. La CLI oculta los errores sin procesar del proveedor y los detalles de las credenciales.
Revisa los datos financieros antes de usarlos
Este flujo de trabajo termina con un artefacto local para revisión. No envía nada a un servicio de contabilidad. Después de comparar los campos con la imagen, una integración independiente puede mapearlos al esquema documentado del destino y usar su mecanismo de idempotencia al enviar o conciliar reintentos. La validación de presencia y formato no autoriza el pago ni demuestra que una factura sea auténtica.
