OCR with Granite Docling
Answer from the text layer at once, then OCR only the pages with scans, tables or figures, in-process or on a remote processor.
Separately from method, the processor option decides whether pages go through the Granite Docling OCR model, which keeps layout, tables, charts, code and formulas at the cost of model inference per page:
processor | What happens |
|---|---|
"frontend" (default) | Every page parsed from the text layer. No OCR, no model, instant. |
"hybrid" | The text layer everywhere, plus a regex scan (scanPagesForOCR) that flags pages with figures, tables or no usable text; only those are rasterized and OCR'd. |
"docling" | Every page rasterized and OCR'd. |
"https://…" | Like "docling", but each page is POSTed to that processor instead of running the model in-process. |
// Hybrid: fast parse, OCR only the pages that need it
const { html, ocrScan } = await convertPDFToHTML(buffer, { processor: 'hybrid' });
console.log(ocrScan.pagesNeedingOcr); // e.g. [3, 7]
// Hybrid, with the OCR on a remote processor
const result = await convertPDFToHTML(buffer, {
processor: 'hybrid',
processorUrl: 'https://YOUR_HF_USERNAME-extract-pdf-docling.hf.space',
doclingOptions: {
processorHeaders: {
'X-Docling-Token': process.env.DOCLING_API_TOKEN,
Authorization: `Bearer ${process.env.HF_SPACE_TOKEN}`, // private Space only
},
maxTokens: 1500,
},
});OCR'd pages come back as <section class="ocr-page" id="page-N">, with real <table> markup, <figcaption> captions and <code class="formula"> formulas. A page whose OCR fails keeps its text-layer HTML.
The scan's reasons per page are "table-caption", "figure-caption", "table-rows", "numeric-grid", "no-text", "sparse-text" and "garbled-text".
Answer first, enhance after
Hybrid mode still waits for every flagged page before it returns. To answer at once, run processor: "frontend", return its HTML, and OCR ocrScan.pagesNeedingOcr as a follow-up. The live demo does exactly that: the browser renders the flagged pages and the Worker forwards each image to the processor.
Running the model in-process
In-process Docling needs the optional @huggingface/transformers dependency. The ONNX model (onnx-community/granite-docling-258M-ONNX, about 1 GB) downloads on first use. In Node.js, page rasterization also needs @napi-rs/canvas; browsers use OffscreenCanvas.
The scanner and the OCR helpers are exported on their own:
import { doctagsToHtml, ocrImageWithDocling, scanPagesForOCR } from 'extract-pdf';
const scan = scanPagesForOCR(pageTexts); // string[] of per-page textHosting the processor
packages/extract-pdf/docling-space packages the model as a Hugging Face Docker Space: token auth, one generation at a time with a bounded queue, the model loaded as soon as the Space boots, and optional sanitized HTML output. It takes one page image per request, never sees a whole PDF, and keeps no image: each one is decoded in memory and dropped with the request.
- Create a Space named
extract-pdf-docling, SDK Docker, hardware CPU Basic to start. Make it private if people upload their own documents. - Add the
DOCLING_API_TOKENsecret (openssl rand -hex 32). - Copy the folder into the Space's repository and push.
| Route | Auth | Does |
|---|---|---|
GET /health | none | { status, modelLoaded, loading, busy, queueDepth, uptime, limits } |
GET or POST /api/v1/warmup | none | Starts loading the model and answers at once: 202 while loading, 200 once ready. |
POST /api/v1/convert | token | The image itself as the body (Content-Type: image/png, options in the query), or JSON { imageBase64 | imageUrl, prompt?, maxTokens?, output? }. /api/v1/convert-base64, the path processorUrl calls, is the same handler. |
curl -X POST "$SPACE/api/v1/convert?output=html" \
-H "X-Docling-Token: $DOCLING_API_TOKEN" -H "Content-Type: image/png" --data-binary @page-3.pngLimits
| Limit | Default | When it is hit |
|---|---|---|
| Image size | 10 MB (DOCLING_MAX_IMAGE_MB) | 413 |
| Pages per request | 1 | the caller loops |
| Generations at once | 1 | later requests queue |
| Queue, running plus waiting | 8 (DOCLING_MAX_QUEUE) | 503 BUSY with Retry-After |
| Output tokens per page | 1500, capped at 4096 (DOCLING_MAX_TOKENS) | output is cut off |
On CPU Basic (2 vCPUs) expect tens of seconds or more per page, and a free Space sleeps after 48 hours idle, so the first request after that waits for a boot and a model load. The queue and image caps are worth keeping: a fast 503 the caller can retry beats a pile of requests that time out. The Space has no per-caller rate limit because only the token holder can convert; a public front end such as the demo Worker is where a per-visitor limit would go. The Space README has the full list.
The package's own server/ folder has the same model behind a Hono API (bun run serve:docling, Swagger UI at /docs) for running it on your own machine.