Extraire les données de factures avec GCP OCR et Java
Une réponse OCR fournit du texte ; votre flux de travail de traitement des factures a encore besoin de champs que vous pouvez examiner. Cet outil en ligne de commande Maven envoie un exemple de facture généré à Google Cloud Vision, analyse ses lignes libellées et écrit un JSON contenant le numéro de facture, la date, le fournisseur et le total en USD. Le résultat nécessite une vérification humaine avant toute transmission à un système financier.
Prérequis
Les commandes ci-dessous utilisent un shell Bash sous Linux ou macOS. Pour cet exemple, utilisez
JDK 21.0.12.1 et Maven 3.10.0, ainsi que la
Google Cloud CLI. Les bibliothèques clientes Java de Google
prennent en charge Java 21.
Installez Maven en suivant les
instructions d’installation officielles
si mvn n’est pas disponible.
Vous avez également besoin d’un projet pour lequel la facturation et l’API Cloud Vision sont déjà activées, ainsi que d’un compte autorisé à utiliser l’API et le quota de ce projet. Suivez le guide de configuration de Vision de Google si ces prérequis ne sont pas remplis. Commencez par la facture générée ci-dessous, sans données sensibles ; chaque appel OCR envoie son image à Google et peut entraîner des frais.
Définir la mise en page de la facture
Cet analyseur traite quatre lignes libellées, avec des dates au format américain mois/jour/année et un montant explicitement exprimé en USD :
Invoice No: INV-123
Date: 03/19/2025
Vendor: Example Ltd
Total: USD 1,234.56
Il vérifie la présence des champs, les libellés répétés, la validité des dates et la syntaxe des montants. Il ne peut pas déterminer si l’OCR a correctement lu une valeur plausible. Comparez le JSON à l’image avant de l’utiliser. Pour les factures présentant d’autres mises en page ou devises, modifiez l’analyseur et testez-le sur ces documents plutôt que de supposer que ces règles s’appliquent.
Authentifier le SDK Java
Pour le développement local, le SDK utilise les
informations d’authentification par défaut de l’application (ADC).
Remplacez YOUR_PROJECT_ID par l’identifiant de votre projet dans les deux commandes :
gcloud auth application-default login --project=YOUR_PROJECT_ID &&
gcloud auth application-default set-quota-project YOUR_PROJECT_ID
Le projet de quota détermine quel projet fournit le quota et prend en charge la facturation. Une
session gcloud auth login seule ne fournit pas les ADC du SDK. Si vous utilisez des ADC
utilisateur locales, supprimez de votre shell toute surcharge obsolète via
GOOGLE_APPLICATION_CREDENTIALS afin que le SDK ne sélectionne pas un autre fichier d’informations
d’authentification. Aucune clé de compte de service n’est nécessaire pour cet exemple local.
Créer le projet Maven
Exécutez ce bloc depuis un répertoire situé en dehors d’un projet Maven existant. Il vérifie les
outils et refuse toute configuration pom.xml ou
.mvn dans un répertoire englobant avant de créer les fichiers :
(
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 un répertoire invoice-ocr existe déjà, il est conservé et l’opération est
refusée. Choisissez un nouveau répertoire parent au lieu de supprimer un projet existant. Si la
création s’arrête après avoir créé un répertoire vide, corrigez la cause et terminez
src/main/java dans ce répertoire avant d’enregistrer les fichiers ci-dessous.
Enregistrez ce pom.xml complet sous invoice-ocr/pom.xml. Le
BOM Google Cloud gère les versions des bibliothèques.
Cette version fixée résout Vision 3.97.0 et Gson 2.14.0. La phase de création du paquet compile les
classes et copie les dépendances d’exécution dans target/dependency pour le lanceur 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>
Générer un exemple de facture
Enregistrez GenerateInvoice.java dans invoice-ocr/src/main/java. Il crée un PNG à partir
des champs connus ci-dessus, sans nécessiter de document client ni de téléchargement d’image.
Il refuse toute destination existante.
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);
}
}
}
Analyser et valider les champs libellés
Enregistrez InvoiceParser.java à côté du générateur. Les libellés complets permettent de
distinguer Total de Subtotal.
Les alias tels que Invoice Date et Amount Due sont acceptés,
mais deux libellés pour le même champ sont rejetés, même si leurs valeurs sont identiques. Les dates
sont traitées par un analyseur de calendrier strict ; 03/04/2025 signifie le
4 mars dans ce format explicitement américain. Les montants avec une virgule décimale, les autres
devises et tout texte supplémentaire dans les montants ou les dates sont rejetés. Les montants
restent des chaînes décimales afin qu’un consommateur du JSON n’ait pas besoin d’utiliser
l’arithmétique binaire à virgule flottante pour les valeurs monétaires.
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;
}
}
Relier l’OCR Vision à la sortie JSON
Enregistrez InvoiceOCR.java dans le même répertoire. L’outil en ligne de commande lit
une seule image locale, appelle Vision, transmet directement le texte renvoyé à
InvoiceParser et sérialise les champs analysés avec Gson. Il utilise
DOCUMENT_TEXT_DETECTION, dont la réponse est
optimisée pour les textes denses et les documents.
La réponse de Vision comprend des informations de mise en page ; ce petit analyseur utilise
uniquement son texte.
Le code sélectionne le point de terminaison OCR de Google pour l’UE et un parent de requête explicitement situé dans l’UE. Il désactive les nouvelles tentatives automatiques de reconnaissance et définit un délai d’expiration RPC de 20 secondes. Une requête ayant échoué ou dépassé ce délai peut tout de même être parvenue à Google ; un nouvel appel peut entraîner de nouveaux frais.
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);
}
}
}
L’exemple utilise une image matricielle. Les entrées PDF/TIFF nécessitent une
requête d’annotation de fichier au lieu de placer
les octets du document dans Image. L’analyseur attend également chaque
libellé et sa valeur sur la même ligne OCR. Il ignore les lignes non reconnues, telles que les
descriptions d’articles, plutôt que d’interpréter un tableau.
Compiler et exécuter l’exemple complet
Depuis le répertoire parent de invoice-ocr, remplacez
YOUR_PROJECT_ID et collez :
(
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
)
Le séparateur du chemin de classes est ici :, comme sous Linux et macOS.
Un échec de compilation empêche le lancement, même lorsqu’une ancienne classe compilée est
présente. Une exécution réussie crée invoice.png et
invoice.json. L’objet invoice du JSON doit contenir ces
valeurs, quel que soit l’ordre des clés :
{
"invoiceNumber": "INV-123",
"date": "2025-03-19",
"vendor": "Example Ltd",
"totalAmount": "1234.56",
"currency": "USD"
}
L’objet englobant contient status: "needs_review" et l’ocrText
effectivement reçu, afin que vous puissiez examiner ce que l’analyseur a traité. Vérifiez les cinq
valeurs par rapport à invoice.png ; des champs syntaxiquement valides peuvent
tout de même être incorrects. Le JSON est encodé en UTF-8 et se termine par un saut de ligne.
Le générateur et l’outil en ligne de commande refusent les chemins de sortie existants. Répéter le
bloc complet provoque donc un arrêt au niveau d’un invoice.png existant, avant
une nouvelle requête de reconnaissance. Conservez l’image et le JSON que vous avez déjà. Pour
relancer uniquement l’OCR après avoir corrigé un échec, utilisez une nouvelle destination JSON :
(
cd invoice-ocr &&
java -cp 'target/classes:target/dependency/*' InvoiceOCR YOUR_PROJECT_ID invoice.png invoice-retry.json &&
cat invoice-retry.json
)
Si l’outil en ligne de commande se termine avec un code de sortie non nul, vérifiez si un fichier de sortie existe avant de réessayer. Les erreurs d’arguments, de décodage, du fournisseur, de texte vide et d’analyse surviennent avant la création du JSON. Un échec d’écriture locale peut laisser un nouveau fichier partiel ; conservez-le pour l’examiner et choisissez une autre destination. Les sorties déjà existantes sont conservées. Pour une image vide, examinez la numérisation. Si un champ est rejeté, vérifiez les quatre lignes libellées et leurs formats. En cas d’échec de Vision, vérifiez les ADC, l’accès à l’API, le quota et la connectivité réseau. L’outil en ligne de commande masque les erreurs brutes du fournisseur et les détails des informations d’authentification.
Vérifier les données financières avant de les utiliser
Ce flux de travail se termine par un fichier local à vérifier. Il n’envoie rien à un service de comptabilité. Après que vous avez vérifié les champs par rapport à l’image, une intégration distincte peut les faire correspondre au schéma documenté de la destination et utiliser son mécanisme d’idempotence lors de la transmission ou de la vérification de l’état des ressources après de nouvelles tentatives. La validation de la présence et du format des champs n’autorise pas le paiement et n’établit pas l’authenticité d’une facture.
