Criar uma ferramenta de OCR de documentos com GCP OCR e Node.js
Extraia texto de um PNG, JPEG ou PDF local com um único programa de linha de comando em Node.js. As imagens vão diretamente para o Google Cloud Vision; os PDFs são enviados por upload para um bucket privado do Cloud Storage, processados de forma assíncrona e retornados como JSON com uma entrada de texto por página. O comando para PDFs verifica se todas as páginas estão presentes e confirma a identidade do trabalho antes de imprimir o resultado JSON.
Pré-requisitos
- Node.js 26.8.2 ou uma versão mais recente da linha Node.js 26, com suporte nativo a TypeScript. Instale a versão de segurança atual disponível no Node.js.
- Corepack instalado separadamente, fornecendo o Yarn 4.12.0 por meio do gerenciador de pacotes com versão fixada no projeto.
- A Google Cloud CLI.
- Um PNG ou JPEG que você tenha permissão para enviar ao Google Cloud. Coloque-o em
sample.png, ao lado deocr-project, ou ajuste o caminho no comando para imagens. - Um projeto do Google Cloud com faturamento, Vision API e Cloud Storage habilitados. Sua conta precisa de permissão para usar a cota do projeto, criar e excluir o bucket do exemplo e criar, listar, ler e excluir os objetos dele. Se necessário, peça acesso ao administrador do projeto.
Os comandos usam Bash no macOS. O exemplo foi testado com Node.js 26.8.2, Yarn 4.12.0,
@google-cloud/vision 6.1.1 e @google-cloud/storage 8.2.0. Este é um tutorial local
sequencial: use primeiro documentos sintéticos e não compartilhe um prefixo de trabalho com outro
processo.
Configurar a Google Cloud Vision API
Selecione seu projeto no Google Cloud Console, confirme que o
faturamento está habilitado e habilite a Cloud Vision API. O
guia de configuração da Vision do Google explica a configuração do
projeto. Substitua PROJECT_ID de forma consistente nos comandos abaixo.
Configurar a autenticação
Para o desenvolvimento local, use Application Default Credentials (ADC) com uma conta de usuário autorizada. Se tanto a CLI quanto as ADC já usarem a conta e o projeto de cota desejados, pule estes comandos de login:
gcloud auth login &&
gcloud auth application-default login &&
gcloud auth application-default set-quota-project PROJECT_ID
As ADC são independentes do login normal da CLI. O projeto de cota exige
serviceusage.services.use; consulte o
guia de autenticação da Vision do Google.
Uma configuração existente de GOOGLE_APPLICATION_CREDENTIALS tem precedência sobre o arquivo local
de ADC, então verifique se ela seleciona as credenciais desejadas. Não baixe uma chave de conta de
serviço para este tutorial. Para uma implantação posterior, use uma identidade associada ou
Workload Identity Federation com ADC.
Instalar a biblioteca cliente do Google Cloud Vision
Cole este bloco a partir do diretório onde você quer criar o exemplo. Ele verifica as ferramentas
antes de gravar arquivos, recusa um ocr-project existente e mantém seu shell no
diretório 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
)
O arquivo de lock do subprojeto e a versão fixada do gerenciador de pacotes mantêm a instalação
separada de um projeto Yarn que o contenha; a configuração local seleciona
node_modules. Se a instalação falhar após a criação do projeto, preserve o
diretório e tente novamente apenas a instalação:
(cd ocr-project && YARN_IGNORE_PATH=1 corepack yarn install)
Se a criação do projeto falhou, escolha outro diretório pai antes de repetir a configuração.
Salve os dois arquivos TypeScript abaixo em ocr-project; os comandos posteriores
continuam sendo executados a partir do diretório pai.
Escrever o código Node.js
Salve este programa completo como ocr-project/ocr.ts. Ele aceita o caminho de uma imagem
ou o caminho de um PDF junto com um nome de bucket e um ID de trabalho. O upload do PDF faz parte do
comando. Um prefixo de trabalho preexistente é recusado, e o upload do arquivo de entrada também usa
uma pré-condição de geração para impedir a substituição.
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
})
Execute o modo de imagem com uma imagem local que você tenha permissão para enviar ao Google Cloud:
GOOGLE_CLOUD_PROJECT=PROJECT_ID node ocr-project/ocr.ts image ./sample.png
Ele imprime uma página com o texto reconhecido, sem espaços em branco no final. Uma imagem em
branco retorna uma string text vazia. Um arquivo ausente, uma imagem
rejeitada ou uma solicitação malsucedida faz o processo terminar com status um e sem resultado
JSON. A verificação do cabeçalho da imagem seleciona o formato; a Vision ainda precisa decodificar
a imagem. Confira o texto do OCR com o original antes de usá-lo como transcrição de referência confiável.
Processar arquivos PDF
Salve este gerador de documento de teste como ocr-project/make-pdf.ts. Ele cria duas páginas
com textos diferentes e se recusa a sobrescrever um 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' })
Gere o documento e crie um novo bucket privado. Substitua YOUR_NEW_BUCKET por um nome
de bucket globalmente único que pertença a você. A conta usada por
gcloud também precisa estar autorizada para este projeto:
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
A CLI faz o upload de node-ocr/trial-1/input.pdf e solicita resultados JSON sob o prefixo
node-ocr/trial-1/output/. Ela aguarda a operação, verifica o destino retornado e o URI de
origem de cada página, rejeita páginas ausentes ou duplicadas e ordena o texto pelo número da
página. Listar os objetos em ordem alfabética não basta para ordenar as páginas. O
guia de OCR para PDF/TIFF do Google explica o encaminhamento
assíncrono e o formato da resposta JSON.
Para este documento de teste, o resultado JSON deve ser:
{
"pages": [
{ "number": 1, "text": "DEVTIPS FIRST PAGE 2026" },
{ "number": 2, "text": "DEVTIPS SECOND PAGE 2026" }
]
}
Quando o comando é bem-sucedido, ele remove o arquivo de entrada enviado por upload e os objetos de OCR antes de imprimir o resultado. Erros de operação em estado terminal e resultados inválidos também acionam essa limpeza. Uma falha na limpeza retorna status um em vez de publicar um resultado bem-sucedido. O conteúdo existente no prefixo é preservado quando o comando recusa um trabalho; escolha um novo ID de trabalho em vez de excluir objetos de outra pessoa.
Recuperar um trabalho pendente e remover o bucket
A CLI consulta o status por até três minutos; uma solicitação de status em andamento pode acrescentar até 30 segundos. Um tempo limite excedido ou uma conexão perdida não cancela a Vision. O comando imprime o nome da operação e mantém os objetos do trabalho quando não se sabe se ele foi concluído. Não envie outra solicitação de OCR para recuperá-lo. Copie o nome da operação impresso para este comando, usando o mesmo bucket e ID de trabalho:
GOOGLE_CLOUD_PROJECT=PROJECT_ID node ocr-project/ocr.ts cleanup \
'projects/PROJECT_ID/locations/eu/operations/OPERATION_ID' YOUR_NEW_BUCKET trial-1
A limpeza verifica o registro salvo da operação, aguarda um status terminal e então remove os objetos desse trabalho, inclusive após uma operação malsucedida. Se o tempo limite continuar sendo excedido, mantenha os objetos e repita a limpeza mais tarde. Se a resposta ao envio foi perdida antes de chegar um nome de operação, ou se houve falha ao salvar o registro da operação, mantenha o prefixo e peça ao administrador do projeto que verifique a operação antes de excluir seus objetos. Use a limpeza apenas em um prefixo que você criou.
Depois que seu próprio bucket de exemplo estiver vazio, remova-o:
gcloud storage buckets delete gs://YOUR_NEW_BUCKET --project=PROJECT_ID --quiet
Se a geração do documento de teste ou a criação do bucket falhou, preserve seus arquivos e tente novamente apenas a etapa que falhou. O gerador recusa um PDF existente; reutilize esse arquivo para OCR ou remova deliberadamente apenas esse documento de teste gerado antes de gerá-lo novamente. Não execute a exclusão do bucket a menos que a etapa de criação tenha sido bem-sucedida. Guarde o JSON separadamente se precisar dele; este exemplo não mantém um acervo de resultados.
Limitações da API e preços
A Vision aceita arquivos PDF/TIFF com até 2.000 páginas; esta CLI aceita deliberadamente apenas PDFs com uma a 20 páginas. Ela não implementa TIFF. Cada página do PDF é cobrada como uma imagem para o recurso solicitado, portanto o documento de teste de duas páginas usa duas unidades de OCR. Consulte os preços do Cloud Vision para ver as tarifas atuais; as cobranças do Storage são separadas. O envio de solicitações não tem novas tentativas automáticas, para evitar reenviar silenciosamente um trabalho pago após uma resposta incerta.
Configuração regional
Este exemplo usa o endpoint da Vision na UE, um recurso pai de solicitação na UE e um bucket do Storage na UE. O endpoint controla o processamento da Vision; ele não muda a localização de um bucket existente. Consulte a documentação de OCR multirregional do Google antes de adaptar a localização. O tutorial foi validado com a configuração da UE.
Solução de problemas
- Erros de autenticação ou cota: confirme a conta ADC desejada, o projeto de cota e a habilitação
da API. Um login funcional com
gcloud, por si só, não comprova a autenticação do SDK. - Erros de acesso ao bucket: verifique as permissões de leitura do arquivo de entrada, de criação dos objetos de saída e de listagem, leitura e exclusão. Um download negado não deve ser tratado como um resultado de OCR vazio.
- Resultado de PDF incompleto ou inconsistente: mantenha a falha visível. Não afrouxe as verificações de contagem de páginas ou de origem para publicar um texto parcial.
- Um trabalho interrompido ou com tempo limite excedido: recupere sua operação conforme descrito acima antes de remover recursos ou considerar um novo envio.
Conclusão
Para um fluxo de processamento de uploads gerenciado pela Transloadit, consulte o Document OCR Robot. Sua configuração e seu contrato de resultados são independentes desta CLI que acessa diretamente o Google Cloud.
