Créer un outil OCR documentaire avec GCP OCR et Node.js
Extrayez le texte d’un fichier PNG, JPEG ou PDF local avec un seul programme Node.js en ligne de commande. Les images sont envoyées directement à Google Cloud Vision ; les PDF sont téléversés dans un bucket Cloud Storage privé, traités de manière asynchrone, puis renvoyés au format JSON avec une entrée de texte par page. La commande PDF vérifie que toutes les pages sont présentes et que la tâche correspond bien à celle demandée avant d’afficher son résultat JSON.
Prérequis
- Node.js 26.8.2 ou une version plus récente de Node.js 26, avec prise en charge native de TypeScript. Installez la version actuelle intégrant les correctifs de sécurité depuis Node.js.
- Corepack installé séparément, fournissant Yarn 4.12.0 grâce au gestionnaire de paquets dont la version est fixée dans le projet.
- L’interface en ligne de commande Google Cloud.
- Un fichier PNG ou JPEG que vous êtes autorisé à envoyer à Google Cloud. Placez-le à l’emplacement
sample.png, à côté deocr-project, ou adaptez le chemin dans la commande de traitement d’image. - Un projet Google Cloud avec la facturation, l’API Vision et Cloud Storage activés. Votre compte doit être autorisé à utiliser le quota du projet, à créer et supprimer le bucket de l’exemple, ainsi qu’à créer, lister, lire et supprimer ses objets. Demandez l’accès à l’administrateur de votre projet si nécessaire.
Les commandes utilisent Bash sur macOS. L’exemple a été testé avec Node.js 26.8.2, Yarn 4.12.0,
@google-cloud/vision 6.1.1 et @google-cloud/storage 8.2.0. Ce tutoriel local s’exécute
séquentiellement : utilisez d’abord des documents synthétiques et ne partagez pas un préfixe de tâche
avec un autre processus.
Configurer l’API Google Cloud Vision
Sélectionnez votre projet dans la console Google Cloud, vérifiez que
la facturation est activée, puis activez l’API Cloud Vision. Le
guide de configuration de Vision de Google explique comment configurer
le projet. Remplacez PROJECT_ID de manière cohérente dans les commandes ci-dessous.
Configurer l’authentification
Pour le développement local, utilisez Application Default Credentials (ADC) avec un compte utilisateur autorisé. Si la CLI et ADC utilisent déjà le compte et le projet de quota souhaités, ignorez ces commandes de connexion :
gcloud auth login &&
gcloud auth application-default login &&
gcloud auth application-default set-quota-project PROJECT_ID
ADC est distinct de la connexion habituelle à la CLI. Le projet de quota nécessite
serviceusage.services.use ; consultez le
guide d’authentification de Vision de Google.
Une configuration existante de GOOGLE_APPLICATION_CREDENTIALS prévaut sur le fichier ADC local :
assurez-vous donc qu’elle sélectionne les informations d’accès souhaitées. Ne téléchargez pas de clé
de compte de service pour ce tutoriel. Pour un déploiement ultérieur, utilisez une identité associée
ou Workload Identity Federation avec ADC.
Installer la bibliothèque cliente Google Cloud Vision
Collez ce bloc depuis le répertoire où vous souhaitez créer l’exemple. Il vérifie les outils avant
toute écriture, refuse un ocr-project existant et laisse votre shell dans son
répertoire d’origine :
(
set -eu
node --version
corepack --version
gcloud --version
test ! -e ocr-project && test ! -L ocr-project
mkdir ocr-project
cd ocr-project
cat > package.json <<'JSON'
{
"name": "node-document-ocr",
"private": true,
"type": "module",
"packageManager": "yarn@4.12.0",
"dependencies": {
"@google-cloud/storage": "8.2.0",
"@google-cloud/vision": "6.1.1",
"pdf-lib": "1.17.1",
"zod": "4.3.6"
}
}
JSON
touch yarn.lock
printf 'nodeLinker: node-modules\n' > .yarnrc.yml
YARN_IGNORE_PATH=1 corepack yarn install
)
Le fichier de verrouillage du sous-projet et la version fixée du gestionnaire de paquets isolent
l’installation d’un éventuel projet Yarn englobant ; la configuration locale sélectionne
node_modules. Si l’installation échoue après la création du projet, conservez le
répertoire et relancez uniquement l’installation :
(cd ocr-project && YARN_IGNORE_PATH=1 corepack yarn install)
Si la création du projet a échoué, choisissez un autre répertoire parent avant de recommencer la
configuration. Enregistrez les deux fichiers TypeScript ci-dessous dans ocr-project ;
les commandes suivantes s’exécutent toujours depuis le répertoire parent.
Écrire le code Node.js
Enregistrez ce programme complet sous ocr-project/ocr.ts. Il accepte un chemin d’image ou
un chemin de PDF accompagné d’un nom de bucket et d’un identifiant de tâche. La commande inclut le
téléversement du PDF. Un préfixe de tâche préexistant est refusé, et le téléversement du fichier
d’entrée utilise aussi une précondition de génération pour empêcher son remplacement.
import type { Bucket } from '@google-cloud/storage'
import { readFile } from 'node:fs/promises'
import { setTimeout as delay } from 'node:timers/promises'
import { Storage } from '@google-cloud/storage'
import { ImageAnnotatorClient, protos } from '@google-cloud/vision'
import { PDFDocument } from 'pdf-lib'
import { z } from 'zod'
const rpcOptions = { timeout: 30_000, retry: null }
const statusSchema = z.object({ code: z.number().optional() })
const pageSchema = z.object({
error: statusSchema.optional(),
context: z.object({ uri: z.string(), pageNumber: z.number().int() }),
fullTextAnnotation: z.object({ text: z.string().default('') }).optional(),
})
const fileSchema = z.object({
error: statusSchema.optional(),
inputConfig: z.object({ gcsSource: z.object({ uri: z.string() }) }),
responses: z.array(pageSchema),
})
const operationResultSchema = z.object({
responses: z.array(z.object({
outputConfig: z.object({ gcsDestination: z.object({ uri: z.string() }) }),
})).length(1),
})
class OcrError extends Error {}
function jobPrefix(job: string): string {
if (!/^[a-z0-9][a-z0-9-]{0,63}$/u.test(job)) {
throw new OcrError('Use a job ID containing lowercase letters, numbers, and hyphens')
}
return `node-ocr/${job}/`
}
async function removeJob(bucket: Bucket, prefix: string): Promise<void> {
const [files] = await bucket.getFiles({ prefix })
for (const file of files) await file.delete()
}
async function waitForOperation(
client: ImageAnnotatorClient,
name: string,
): Promise<protos.google.longrunning.IOperation> {
const deadline = Date.now() + 180_000
while (Date.now() < deadline) {
const [status] = await client.operationsClient.getOperation({ name }, rpcOptions)
if (status.done) return status
await delay(1000)
}
throw new OcrError('Wait expired; the cloud operation may still be running')
}
async function extractTextFromImage(
client: ImageAnnotatorClient,
bytes: Buffer,
): Promise<{ number: number; text: string }[]> {
const png = bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
const jpeg = bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255
if (!png && !jpeg) throw new OcrError('Image input must be a PNG or JPEG')
const [result] = await client.documentTextDetection({ image: { content: bytes } }, rpcOptions)
if (result.error?.code) {
throw Object.assign(new OcrError('Vision rejected the image', { cause: result.error }), {
code: result.error.code,
})
}
return [{ number: 1, text: (result.fullTextAnnotation?.text ?? '').trimEnd() }]
}
async function extractTextFromPDF(
client: ImageAnnotatorClient,
bucket: Bucket,
bytes: Buffer,
job: string,
project: string,
): Promise<{ number: number; text: string }[]> {
const pdf = await PDFDocument.load(bytes)
const pageCount = pdf.getPageCount()
if (pageCount < 1 || pageCount > 20) throw new OcrError('This example accepts 1–20 PDF pages')
const prefix = jobPrefix(job)
const outputPrefix = `${prefix}output/`
const source = `gs://${bucket.name}/${prefix}input.pdf`
const destination = `gs://${bucket.name}/${outputPrefix}`
const [existing] = await bucket.getFiles({ prefix })
if (existing.length > 0) throw new OcrError('Job prefix already exists; choose a new job ID')
let uploaded = false
let safeToClean = true
try {
await bucket.file(`${prefix}input.pdf`).save(bytes, {
resumable: false,
contentType: 'application/pdf',
preconditionOpts: { ifGenerationMatch: 0 },
})
uploaded = true
// A lost submission response can leave an operation running without a known name.
safeToClean = false
const [operation] = await client.asyncBatchAnnotateFiles({
parent: `projects/${project}/locations/eu`,
requests: [{
inputConfig: { mimeType: 'application/pdf', gcsSource: { uri: source } },
features: [{ type: 'DOCUMENT_TEXT_DETECTION' }],
outputConfig: { batchSize: 1, gcsDestination: { uri: destination } },
}],
}, rpcOptions)
if (!operation.name) throw new OcrError('Vision did not return an operation name')
console.error(`Operation: ${operation.name}`)
await bucket.file(`${prefix}operation.json`).save(`${JSON.stringify({ name: operation.name })}\n`, {
resumable: false,
contentType: 'application/json',
preconditionOpts: { ifGenerationMatch: 0 },
})
const status = await waitForOperation(client, operation.name)
safeToClean = true
if (status.error?.code) {
throw Object.assign(new OcrError('Vision operation failed', { cause: status.error }), {
code: status.error.code,
})
}
if (!(status.response?.value instanceof Uint8Array)) throw new OcrError('Operation has no result')
const decoded = protos.google.cloud.vision.v1.AsyncBatchAnnotateFilesResponse.decode(
status.response.value,
)
const result = operationResultSchema.parse(decoded)
if (result.responses[0].outputConfig.gcsDestination.uri !== destination) {
throw new OcrError('Operation returned a different output prefix')
}
const [files] = await bucket.getFiles({ prefix: outputPrefix })
const pages = new Map<number, string>()
for (const file of files) {
if (!file.name.startsWith(outputPrefix) || !file.name.endsWith('.json')) {
throw new OcrError('Unexpected output object')
}
const [contents] = await file.download()
const data = fileSchema.parse(JSON.parse(contents.toString('utf8')))
if (data.error?.code) throw new OcrError('Vision file failed')
if (data.inputConfig.gcsSource.uri !== source) throw new OcrError('Wrong input in result')
for (const page of data.responses) {
if (page.error?.code) throw new OcrError('Vision page failed')
const number = page.context.pageNumber
if (page.context.uri !== source || number < 1 || number > pageCount || pages.has(number)) {
throw new OcrError('Wrong source, duplicate page, or invalid page number')
}
pages.set(number, (page.fullTextAnnotation?.text ?? '').trimEnd())
}
}
if (pages.size !== pageCount) throw new OcrError('Incomplete PDF result')
return Array.from({ length: pageCount }, (_, index) => ({
number: index + 1,
text: pages.get(index + 1) ?? '',
}))
} finally {
if (uploaded && safeToClean) await removeJob(bucket, prefix)
if (uploaded && !safeToClean) {
console.error(`Retained pending job: gs://${bucket.name}/${prefix}`)
}
}
}
async function main(): Promise<void> {
const [mode, input, bucketName, job, ...extra] = process.argv.slice(2)
if (!input || extra.length > 0 ||
(mode !== 'image' && mode !== 'pdf' && mode !== 'cleanup') ||
(mode === 'image' && (bucketName !== undefined || job !== undefined))) {
throw new OcrError('Usage: image FILE | pdf FILE BUCKET JOB | cleanup OPERATION BUCKET JOB')
}
const project = z.string().min(1).parse(process.env.GOOGLE_CLOUD_PROJECT)
const client = new ImageAnnotatorClient({ projectId: project, apiEndpoint: 'eu-vision.googleapis.com' })
const storage = new Storage({ projectId: project, retryOptions: { autoRetry: false }, timeout: 30_000 })
let pages: { number: number; text: string }[] | undefined
try {
if (mode === 'image') {
pages = await extractTextFromImage(client, await readFile(input))
} else {
if (!bucketName || !job) throw new OcrError('PDF and cleanup modes require BUCKET and JOB')
if (mode === 'cleanup') {
if (!input.startsWith(`projects/${project}/locations/eu/operations/`)) {
throw new OcrError('Use the operation name printed by this project’s PDF command')
}
const prefix = jobPrefix(job)
const bucket = storage.bucket(bucketName)
const [receipt] = await bucket.file(`${prefix}operation.json`).download()
const record = z.object({ name: z.string() }).parse(JSON.parse(receipt.toString('utf8')))
if (record.name !== input) throw new OcrError('Operation does not belong to this job prefix')
await waitForOperation(client, input)
await removeJob(bucket, prefix)
console.error('Job objects removed')
} else {
pages = await extractTextFromPDF(client, storage.bucket(bucketName), await readFile(input), job, project)
}
}
} finally {
await client.close()
}
if (pages !== undefined) console.log(JSON.stringify({ pages }, null, 2))
}
main().catch((error: unknown) => {
const code = statusSchema.safeParse(error)
const hint = code.success && code.data.code === 7 ? 'Permission denied; check project and bucket access'
: code.success && code.data.code === 8 ? 'Quota exceeded; check Vision quota'
: error instanceof OcrError ? error.message : 'Check input, ADC, bucket access, and result format'
console.error(`OCR failed: ${hint}`)
process.exitCode = 1
})
Exécutez le mode image avec une image locale que vous êtes autorisé à envoyer à Google Cloud :
GOOGLE_CLOUD_PROJECT=PROJECT_ID node ocr-project/ocr.ts image ./sample.png
Il affiche une page contenant le texte reconnu, sans espaces blancs à la fin. Une image vierge
renvoie une chaîne text vide. Un fichier manquant, une image rejetée ou une
requête ayant échoué entraîne une sortie avec le code un, sans résultat JSON. La vérification de
l’en-tête de l’image détermine le format ; Vision doit encore décoder l’image. Comparez le texte OCR
à l’original avant de l’utiliser comme transcription faisant autorité.
Traiter les fichiers PDF
Enregistrez ce générateur de fichier de test sous ocr-project/make-pdf.ts. Il crée deux pages
avec des textes différents et refuse d’écraser un sample.pdf existant :
import { writeFile } from 'node:fs/promises'
import { PDFDocument, StandardFonts } from 'pdf-lib'
const pdf = await PDFDocument.create()
const font = await pdf.embedFont(StandardFonts.Helvetica)
for (const text of ['DEVTIPS FIRST PAGE 2026', 'DEVTIPS SECOND PAGE 2026']) {
const page = pdf.addPage([1000, 400])
page.drawText(text, { x: 50, y: 250, size: 32, font })
}
await writeFile(new URL('./sample.pdf', import.meta.url), await pdf.save(), { flag: 'wx' })
Générez le fichier, puis créez un nouveau bucket privé. Remplacez YOUR_NEW_BUCKET par
un nom de bucket unique à l’échelle mondiale qui vous appartient. Le compte utilisé par
gcloud doit également être autorisé à accéder à ce projet :
node ocr-project/make-pdf.ts &&
gcloud storage buckets create gs://YOUR_NEW_BUCKET --project=PROJECT_ID --location=EU \
--uniform-bucket-level-access --public-access-prevention &&
GOOGLE_CLOUD_PROJECT=PROJECT_ID node ocr-project/ocr.ts pdf \
ocr-project/sample.pdf YOUR_NEW_BUCKET trial-1
La CLI téléverse node-ocr/trial-1/input.pdf et demande des sorties JSON sous
node-ocr/trial-1/output/. Elle attend la fin de l’opération, vérifie la destination renvoyée et
l’URI source de chaque page, rejette les pages manquantes ou en double, puis ordonne le texte par
numéro de page. Lister les objets par ordre alphabétique ne suffit pas à ordonner les pages. Le
guide OCR PDF/TIFF de Google explique le passage au traitement
asynchrone et le format de réponse JSON.
Pour ce fichier de test, le résultat JSON devrait être le suivant :
{
"pages": [
{ "number": 1, "text": "DEVTIPS FIRST PAGE 2026" },
{ "number": 2, "text": "DEVTIPS SECOND PAGE 2026" }
]
}
Lorsque la commande réussit, elle supprime le fichier d’entrée téléversé et les objets OCR avant d’afficher le résultat. Les erreurs mettant fin à l’opération et les résultats invalides déclenchent également ce nettoyage. Un échec du nettoyage renvoie le code un au lieu de publier un résultat indiquant une réussite. Le contenu existant du préfixe est conservé lorsque la commande refuse une tâche ; choisissez un nouvel identifiant de tâche plutôt que de supprimer les objets d’une autre personne.
Récupérer une tâche en attente et supprimer le bucket
La CLI interroge l’état pendant trois minutes au maximum ; une requête d’état en cours peut ajouter jusqu’à 30 secondes. Un délai dépassé ou une connexion perdue n’annule pas le traitement par Vision. La commande affiche le nom de l’opération et conserve les objets de la tâche lorsque son achèvement est inconnu. Ne soumettez pas une autre requête OCR pour la récupérer. Copiez le nom de l’opération affiché dans cette commande, en utilisant le même bucket et le même identifiant de tâche :
GOOGLE_CLOUD_PROJECT=PROJECT_ID node ocr-project/ocr.ts cleanup \
'projects/PROJECT_ID/locations/eu/operations/OPERATION_ID' YOUR_NEW_BUCKET trial-1
Le nettoyage vérifie l’enregistrement sauvegardé de l’opération, attend un état terminal, puis supprime les objets de cette tâche, y compris après l’échec de l’opération. Si le délai est encore dépassé, conservez les objets et relancez le nettoyage plus tard. Si la réponse à la soumission a été perdue avant la réception du nom de l’opération, ou si la sauvegarde de l’enregistrement de l’opération a échoué, conservez le préfixe et demandez à l’administrateur de votre projet de vérifier l’opération avant de supprimer ses objets. N’utilisez le nettoyage que sur un préfixe que vous avez créé.
Une fois votre propre bucket d’exemple vide, supprimez-le :
gcloud storage buckets delete gs://YOUR_NEW_BUCKET --project=PROJECT_ID --quiet
Si la génération du fichier de test ou la création du bucket a échoué, conservez vos fichiers et relancez uniquement l’étape ayant échoué. Le générateur refuse un PDF existant ; réutilisez ce fichier pour l’OCR, ou supprimez délibérément ce seul fichier de test généré avant de le régénérer. Ne lancez pas la suppression du bucket si votre étape de création n’a pas réussi. Conservez le JSON séparément si vous en avez besoin ; cet exemple ne maintient pas d’archive.
Limites et tarification de l’API
Vision accepte les fichiers PDF/TIFF jusqu’à 2 000 pages ; cette CLI n’accepte délibérément que les PDF d’une à 20 pages. Elle ne prend pas en charge TIFF. Chaque page PDF est facturée comme une image pour la fonctionnalité demandée : le fichier de test de deux pages utilise donc deux unités OCR. Consultez la tarification de Cloud Vision pour connaître les tarifs actuels ; les frais de Storage sont distincts. La soumission des requêtes n’est pas relancée automatiquement, afin d’éviter de soumettre à nouveau, sans vous en avertir, une tâche payante après une réponse incertaine.
Configuration régionale
Ce guide utilise le point de terminaison Vision de l’UE, un parent de requête situé dans l’UE et un bucket Storage situé dans l’UE. Le point de terminaison détermine le lieu de traitement par Vision ; il ne déplace pas un bucket existant. Consultez la documentation OCR multirégionale de Google avant d’adapter la localisation. Le tutoriel a été vérifié avec la configuration UE.
Dépannage
- Erreurs d’authentification ou de quota : vérifiez le compte ADC souhaité, le projet de quota et
l’activation de l’API. Une connexion
gcloudfonctionnelle ne suffit pas à prouver que l’authentification du SDK fonctionne. - Erreurs d’accès au bucket : vérifiez les autorisations de lecture des entrées, de création des sorties, de listage, de lecture et de suppression. Un téléchargement refusé ne doit pas être traité comme un résultat OCR vide.
- Sortie PDF incomplète ou non concordante : laissez l’échec visible. N’assouplissez pas les vérifications du nombre de pages ou des sources pour publier un texte partiel.
- Tâche interrompue ou dont le délai est dépassé : récupérez son opération comme indiqué ci-dessus avant de supprimer des ressources ou d’envisager une nouvelle soumission.
Conclusion
Pour un flux de traitement des téléversements géré par Transloadit, consultez le Document OCR Robot. Sa configuration et son contrat de résultat sont distincts de ceux de cet outil en ligne de commande qui utilise directement Google Cloud.
