Skip to content

Transcribe scanned PDFs with readPdf(fileId, { ocr: true }) on Google Drive bindings - #1079

Draft
Maximo-Guk wants to merge 4 commits into
mainfrom
maximo/drive-pdf-ocr
Draft

Maximo-Guk wants to merge 4 commits into
mainfrom
maximo/drive-pdf-ocr

Conversation

@Maximo-Guk

Copy link
Copy Markdown
Member

WIP - <Proposal/Not ready for review>

Adds an opt-in readPdf(fileId, { ocr: true }) to both Drive sessions. A scanned or image-only PDF has no text layer, so readPdf() (#843) returns hasText: false and nothing else. With ocr set, such a PDF's pages are transcribed by the Workers AI vision model @cf/google/gemma-4-26b-a4b-it and returned as text with transcribed: true. A PDF with any text of its own is returned as before, and no model is called.

Like readPdf(), nothing is held: not the PDF, not a page image, not decoded pixels. Only packages/gatekeeper-google changes; there is no kernel or workshop-shared change.

How it works

  • Locating pages. As the PDF streams into the Markdown conversion, a scanner (src/pdf-images.ts) notes where each page-sized image sits in the file, its length and its encoding. It walks objects from the front, takes lengths given directly, by reference or not at all, skips masks and small images, and notes an encrypted file. Stream data is counted past, never kept.
  • Transcribing. When the conversion finds no text, each page image is read from Drive by range (downloadRange + exactRange, from readFile()), base64-encoded into the model's request as it arrives, and the model's SSE tokens are passed on under the same ### Page N headings (src/workers-ai-vision.ts). The request is what Ai.run() posts, sent through the binding's fetch so the body can stream, as the conversion already does.
  • Lazily. A page is read and transcribed only as text is read, one at a time. Cancelling text stops the page in progress and reads no more.
  • Each page read is a content read. It goes through nativeRead(..., { content: true }): checked against the binding's scope, re-proved on a folder binding, reported as drive/content/<fileId>. It fails if the file is no longer the size it was converted at or, on a folder binding, if its modification time has moved.
  • Encodings. JPEG page images are sent as stored. Losslessly compressed grey or RGB pages are wrapped as PNG on the way without decoding a pixel (src/pdf-png.ts): the PDF's own bytes inside PNG framing when the rows carry PNG filter bytes, re-deflated with one added per row when they do not.
  • Refusals, before any model call. An encrypted PDF; pages stored as fax (CCITT), JBIG2 or JPEG 2000, or as an image PNG cannot hold; a page image over 12 MiB or not where the file says; more than 2,000 page images.

Commits

  1. Share the streamed base64-in-JSON request body builder (no behaviour change)
  2. The streamed vision call
  3. The page-image scanner and the PNG wrapper
  4. The option, types, README and session wiring

What was measured (fixture pages with known text, 12 to 6 pt, a table; probes on a scratch Worker)

  • The conversion's own pdf.images.convert option returns captions ("a heavily distorted image of a document"), not a transcription, so it is not used.
  • A whole page is read at 98 to 100% of words. The misses are codes, numbers and names in 6 to 8 pt type (HL/2291 read as HL/229). 150 and 300 dpi read the same: the model sees about 280 image tokens either way. The types tell the agent a transcription is a model's reading and may misread.
  • Thinking is off: it tripled a page's tokens and time (about 1,100 against 340) and held the text back, without reading a poor scan any better.
  • The streamed body works directly and through AI Gateway. Workers AI refuses a request somewhere between a 12 MB and a 16 MB image.

Checked

  • Unit and workerd tests for the scanner (every chunk split), the PNG wrapper, the vision call, pdfToText and the session layer (observations, folder re-proof per page, refused page reads, RPC validation).
  • Against the real model, with only Drive stood in for: image-only PDFs written by macOS and by Chrome (one with a lossless page), through a gateway and without.
  • End to end on pnpm dev-server -- --use-workers-ai-binding with a Drive folder binding: an agent turn read a three-page scan with and without ocr, a text PDF, a fax-encoded and an encrypted PDF, and cancelled a transcription at the second page heading. The gatekeeper logged one page transcribed for the cancelled read.

Not in this PR

  • Fax (CCITT), JBIG2 and JPEG 2000 pages, which most black-and-white office scans use.
  • Reading a page in bands for small print. A band of the same page read with no errors where the whole page had two or three; cutting a JPEG into bands without decoding it is being sized as a follow-up.
  • Page order from the page tree: pages are numbered in the order the file stores their images.
  • A budget or approval for the model calls: they are treated like today's conversion calls.
  • Gateway mode on a deployment is still to be checked on this PR's preview, since the dev server does not pass CF_AI_GATEWAY to this gatekeeper. Through a gateway that stores its logs, page images and transcriptions land in them.

🤖 Generated with Claude Code

Maximo-Guk and others added 4 commits November 9, 2026 16:17
convertToMarkdown() built its request body, a JSON document holding the file as
one base64 string encoded chunk by chunk, in a private helper tied to the
conversion's own JSON. The helper now takes the JSON around the string, so
another streamed Workers AI request can use it. No behaviour change.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
transcribeImage() posts what Ai.run() would, through the binding's fetch, so
the image is base64-encoded into the request as it arrives instead of being
held as one string, and reads the model's answer as server-sent events only as
the text is asked for. It mirrors convertToMarkdown(): the same failure shape,
logged by status and error code only, and the same release of both streams on
failure or cancel.

The model is @cf/google/gemma-4-26b-a4b-it at temperature 0 with thinking off.
Measured on scanned pages, thinking tripled a page's tokens and time and held
the text back until it was over, without reading a poor scan any better.

Nothing calls it yet.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A scanner writes each page of a PDF as one image. PdfImageScan reads a PDF fed
to it in chunks cut anywhere and reports where each page-sized image's data
sits, how long it is and how it is encoded, holding only the object it is
reading: stream data is counted past, never kept. It walks objects from the
front, since the cross-reference table is at the end, takes lengths given
directly, by a reference before or after the stream, or not at all, passes
over masks and small images, and notes an encrypted file.

A JPEG page is a JPEG file as it is stored. A losslessly compressed grey or
RGB page is deflated rows of samples, which is what a PNG's image data is, so
pngFromDeflated() wraps it as a PNG while it streams: the PDF's own bytes
inside PNG's framing when the rows carry PNG filter bytes, re-deflated with
one added per row when they do not. No pixel is decoded.

Nothing calls either yet.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A scanned or image-only PDF has no text layer, so readPdf() returned
hasText: false and nothing else. With ocr set, the PDF's page images are
located as it streams into the conversion, and when the conversion finds no
text the pages are transcribed by the vision model instead, under the same
"### Page N" headings, with transcribed: true.

Nothing is held and nothing is done early: a page's image is read from Drive
by range and transcribed only as the text is read, one page at a time, and
cancelling the text stops it. Each page read is a content read of its own,
checked against the binding's scope, re-proved on a folder binding, reported
as drive/content/<fileId>, and refused if the file's size, or on a folder
binding its modification time, has moved since it was converted.

A PDF with text of its own is returned as before and no model is called. A
scanned PDF that is encrypted, or whose pages are fax, JBIG2 or JPEG 2000
images, is refused in our words before any model call.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
@github-actions github-actions Bot added the gatekeeper Changes to a gatekeeper integration label Nov 9, 2026
let { asked, readRange } = ranges(pdf);
await expect(pdfToText(ai, undefined, byteStream(pdf.bytes, [65_536]).stream, readRange))
.rejects.toThrow(new RegExp(`^The PDF has no text of its own, and .*${
why.replace(/[()]/g, "\\$&")}.*, so it cannot be transcribed\\.$`));
@github-actions

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown

Preview: pr1079-maximo-drive-pdf-ocr

https://pr1079-maximo-drive-pdf-ocr-router.cloudflare-os-previews.workers.dev

Dashboard · deleted when this PR closes

Comment on lines +402 to +405
let parms = dict.get("/DecodeParms");
if (Array.isArray(parms)) parms = parms[0];
let predictor = parms instanceof Map ? parms.get("/Predictor") ?? 1 : 1;
let filtered = predictor !== 1;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Refuse unresolved decoding parameters instead of assuming no predictor

/DecodeParms can be an indirect reference, but every non-Map here defaults to Predictor = 1, and the scanner never retains dictionary objects to resolve it. A page using /DecodeParms 7 0 R, with object 7 containing << /Predictor 15 /Colors 1 /Columns 200 >>, is therefore accepted as lossless with filtered: false. pngFromDeflated() then inserts filter bytes into rows that already contain them. I reproduced valid 200×200 grayscale rows expanding from 40,200 to 40,401 bytes under the same PNG dimensions, corrupting the image sent for OCR. Resolve referenced parameter dictionaries, or classify unresolved parameters as unsupported and refuse before invoking the model.

Comment on lines +261 to +263
cancel: reason => {
cancelled = true;
return current?.cancel(reason);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Cancel the page request while its model response is still pending

When cancellation arrives after begin() starts transcribeImage() but before ai.fetch() returns, current is still undefined, so this handler never cancels the image reader or the model request. I reproduced this by holding the vision fetch pending: reader.cancel() resolved, but the image's cancellation callback ran only after I released the model response; the fetch also receives no abort signal. A slow or stalled request thus continues downloading/uploading and potentially generating billed output after the caller cancels. Keep a cancellation handle for the pending page before awaiting the response, and propagate cancellation to both its source and its fetch.

@ask-bonk

ask-bonk Bot commented Nov 9, 2026

Copy link
Copy Markdown

Review: 2 findings.

Posted two actionable inline comments, both verified with targeted runtime probes.

github run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gatekeeper Changes to a gatekeeper integration

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants