Crear una herramienta OCR documental con GCP OCR y Node.js
Extrae texto de un PNG, JPEG o PDF local con un único programa de línea de comandos en Node.js. Las imágenes se envían directamente a Google Cloud Vision; los PDF se suben a un bucket privado de Cloud Storage, se procesan de forma asíncrona y se devuelven como JSON con una entrada de texto por página. El comando para PDF comprueba que estén todas las páginas y verifica la identidad de la tarea antes de imprimir su resultado JSON.
Requisitos previos
- Node.js 26.8.2 o una versión posterior de Node.js 26, con soporte nativo para TypeScript. Instala la versión de seguridad actual desde Node.js.
- Corepack instalado por separado, que proporciona Yarn 4.12.0 mediante el gestor de paquetes fijado en el proyecto.
- La CLI de Google Cloud.
- Un PNG o JPEG que tengas permiso para enviar a Google Cloud. Colócalo en
sample.pngjunto aocr-project, o ajusta la ruta del comando para imágenes. - Un proyecto de Google Cloud con la facturación, la API de Vision y Cloud Storage habilitados. Tu cuenta necesita permiso para usar la cuota del proyecto, crear y eliminar el bucket de ejemplo, y crear, listar, leer y eliminar sus objetos. Solicita acceso al administrador de tu proyecto si lo necesitas.
Los comandos usan Bash en macOS. El ejemplo se probó con Node.js 26.8.2, Yarn 4.12.0,
@google-cloud/vision 6.1.1 y @google-cloud/storage 8.2.0. Este es un tutorial local
secuencial: usa primero documentos sintéticos y no compartas el prefijo de una tarea con otro proceso.
Configurar la API de Google Cloud Vision
Selecciona tu proyecto en Google Cloud Console, confirma que la
facturación esté habilitada y habilita la API de Cloud Vision. La
guía de configuración de Vision de Google explica cómo configurar
el proyecto. Sustituye PROJECT_ID de forma consistente en los siguientes comandos.
Configurar la autenticación
Para el desarrollo local, usa Application Default Credentials (ADC) con una cuenta de usuario autorizada. Si tanto la CLI como ADC ya usan la cuenta y el proyecto de cuota que deseas, omite estos comandos de inicio de sesión:
gcloud auth login &&
gcloud auth application-default login &&
gcloud auth application-default set-quota-project PROJECT_ID
ADC es independiente del inicio de sesión normal de la CLI. El proyecto de cuota requiere
serviceusage.services.use; consulta la
guía de autenticación de Vision de Google.
Una configuración existente de GOOGLE_APPLICATION_CREDENTIALS tiene prioridad sobre el archivo ADC
local, así que comprueba que seleccione las credenciales que deseas. No descargues una clave de cuenta
de servicio para este tutorial. Para un despliegue posterior, usa una identidad asociada o Workload
Identity Federation con ADC.
Instalar la biblioteca cliente de Google Cloud Vision
Pega este bloque desde el directorio donde quieras guardar el ejemplo. Comprueba las herramientas
antes de escribir, rechaza un ocr-project existente y deja tu shell en su directorio
original:
(
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
)
El archivo de bloqueo del proyecto hijo y la versión fijada del gestor de paquetes mantienen la
instalación separada de un proyecto Yarn que lo contenga; la configuración local selecciona
node_modules. Si la instalación falla después de crear el proyecto, conserva el
directorio y vuelve a intentar únicamente la instalación:
(cd ocr-project && YARN_IGNORE_PATH=1 corepack yarn install)
Si la creación del proyecto falló, elige otro directorio padre antes de repetir la configuración.
Guarda los dos archivos TypeScript siguientes dentro de ocr-project; los comandos
posteriores se siguen ejecutando desde el directorio padre.
Escribir el código de Node.js
Guarda este programa completo como ocr-project/ocr.ts. Acepta la ruta de una imagen o la
ruta de un PDF junto con un nombre de bucket y un ID de tarea. La subida del PDF forma parte del
comando. Se rechaza cualquier prefijo de tarea preexistente y la subida del archivo de entrada también
usa una precondición de generación para impedir su reemplazo.
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
})
Ejecuta el modo de imagen con una imagen local que tengas permiso para enviar a Google Cloud:
GOOGLE_CLOUD_PROJECT=PROJECT_ID node ocr-project/ocr.ts image ./sample.png
Imprime una página con el texto reconocido, sin espacios en blanco al final. Una imagen en blanco
devuelve una cadena text vacía. Si falta un archivo, se rechaza una imagen o
falla una solicitud, el programa termina con el código de salida uno y sin resultado JSON. La
comprobación de la cabecera de la imagen selecciona el formato; Vision aún debe decodificar la imagen.
Compara el texto OCR con el original antes de usarlo como transcripción de referencia.
Procesar archivos PDF
Guarda este generador de archivos de prueba como ocr-project/make-pdf.ts. Crea dos páginas con
textos diferentes y se niega a sobrescribir un sample.pdf existente:
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' })
Genera el archivo y luego crea un bucket privado nuevo. Sustituye YOUR_NEW_BUCKET por
un nombre de bucket de tu propiedad que sea único a nivel mundial. La cuenta que usa
gcloud también debe estar autorizada para este proyecto:
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 sube node-ocr/trial-1/input.pdf y solicita resultados JSON bajo
node-ocr/trial-1/output/. Espera a que termine la operación, comprueba el destino devuelto y el
URI de origen de cada página, rechaza las páginas faltantes o duplicadas y ordena el texto por número
de página. Listar los objetos alfabéticamente no basta para ordenar las páginas. La
guía de OCR para PDF/TIFF de Google explica la delegación asíncrona
y el formato de respuesta JSON.
Para este archivo de prueba, el resultado JSON debería ser:
{
"pages": [
{ "number": 1, "text": "DEVTIPS FIRST PAGE 2026" },
{ "number": 2, "text": "DEVTIPS SECOND PAGE 2026" }
]
}
Si el comando se ejecuta correctamente, elimina el archivo de entrada subido y los objetos OCR antes de imprimir el resultado. Los errores terminales de la operación y los resultados no válidos también activan esa limpieza. Si la limpieza falla, se devuelve el código de salida uno en lugar de publicar un resultado satisfactorio. El contenido existente del prefijo se conserva cuando el comando rechaza una tarea; elige un nuevo ID de tarea en lugar de eliminar los objetos de otra persona.
Recupera una tarea pendiente y elimina el bucket
La CLI consulta el estado durante un máximo de tres minutos; una solicitud de estado en curso puede añadir hasta 30 segundos. Un tiempo de espera agotado o una conexión perdida no cancelan Vision. El comando imprime el nombre de la operación y conserva los objetos de la tarea cuando no se sabe si ha finalizado. No envíes otra solicitud OCR para recuperarla. Copia el nombre de la operación impreso en este comando, usando el mismo bucket y el mismo ID de tarea:
GOOGLE_CLOUD_PROJECT=PROJECT_ID node ocr-project/ocr.ts cleanup \
'projects/PROJECT_ID/locations/eu/operations/OPERATION_ID' YOUR_NEW_BUCKET trial-1
La limpieza comprueba el registro guardado de la operación, espera a que alcance un estado terminal y luego elimina los objetos de esa tarea, incluso si la operación falló. Si el tiempo de espera vuelve a agotarse, conserva los objetos y repite la limpieza más tarde. Si la respuesta al envío se perdió antes de recibir un nombre de operación, o si no se pudo guardar el registro de la operación, conserva el prefijo y pide al administrador de tu proyecto que verifique la operación antes de eliminar sus objetos. Usa la limpieza únicamente con un prefijo que hayas creado tú.
Cuando tu propio bucket de ejemplo esté vacío, elimínalo:
gcloud storage buckets delete gs://YOUR_NEW_BUCKET --project=PROJECT_ID --quiet
Si falló la generación del archivo de prueba o la creación del bucket, conserva tus archivos y vuelve a intentar solo el paso que falló. El generador rechaza un PDF existente; reutiliza ese archivo para OCR o elimina deliberadamente solo ese archivo de prueba generado antes de volver a generarlo. No ejecutes la eliminación del bucket a menos que el paso de creación haya finalizado correctamente. Conserva el JSON por separado si lo necesitas; este ejemplo no mantiene un archivo histórico.
Limitaciones de la API y precios
Vision acepta archivos PDF/TIFF de hasta 2.000 páginas; esta CLI acepta deliberadamente solo PDF de una a 20 páginas. No implementa TIFF. Cada página del PDF se factura como una imagen para la función solicitada, por lo que el archivo de prueba de dos páginas usa dos unidades de OCR. Consulta los precios de Cloud Vision para conocer las tarifas actuales; los cargos de Storage son independientes. El envío de solicitudes no tiene reintentos automáticos, para evitar reenviar silenciosamente una tarea de pago después de una respuesta incierta.
Configuración regional
Esta guía usa el endpoint de Vision de la UE, un elemento padre de solicitud de la UE y un bucket de Storage de la UE. El endpoint controla el procesamiento de Vision; no cambia la ubicación de un bucket existente. Consulta la documentación de OCR multirregional de Google antes de adaptar la ubicación. El tutorial se verificó con la configuración de la UE.
Solución de problemas
- Errores de autenticación o cuota: confirma la cuenta ADC, el proyecto de cuota y la habilitación de
la API previstos. Un inicio de sesión correcto con
gcloudpor sí solo no demuestra que el SDK esté autenticado. - Errores de acceso al bucket: comprueba los permisos para leer la entrada y para crear, listar, leer y eliminar los objetos de salida. Una descarga denegada no debe tratarse como un resultado OCR vacío.
- Salida PDF incompleta o con discrepancias: mantén el fallo visible. No reduzcas el rigor de las comprobaciones del número de páginas o del origen para publicar texto parcial.
- Una tarea interrumpida o cuyo tiempo de espera se agotó: recupera su operación como se indicó antes de eliminar recursos o considerar un nuevo envío.
Conclusión
Para un flujo de procesamiento de subidas gestionado mediante Transloadit, consulta el Document OCR Robot. Su configuración y su contrato de resultados son independientes de los de esta CLI que accede directamente a Google Cloud.
