Recipes

Skip OCR when the PDF already has text

Most PDFs carry their own text. Lifting it is instant and exact — run OCR only on the pages that need it.

OCR is the expensive part of any PDF pipeline, and most PDFs do not need it. PdfToDocument reads the embedded text layer with word-level boxes, in the same pixel space PdfToImage renders at.

One pass to find out

import { Pipeline } from '@stabrise/scaledp'
import { PdfToDocument, hasUsableTextLayer } from '@stabrise/scaledp/pdf'

const probe = new Pipeline([new PdfToDocument({ resolution: 300 })])
const pages = await probe.transform(file)

const withText = pages.filter((row) => hasUsableTextLayer(row.document))
const needsOcr = pages.filter((row) => !hasUsableTextLayer(row.document))
Inspect the text layer in the builder

hasUsableTextLayer(document, minimumBoxes = 1) is the whole decision. Raise minimumBoxes if your corpus contains PDFs whose "text layer" is a single stamped watermark.

Then OCR only what is left

import { PdfToImage } from '@stabrise/scaledp/pdf'
import { PaddleTextRecognizer } from '@stabrise/scaledp/ocr'

const ocr = new Pipeline([
    new PdfToImage({ resolution: 300, pageLimit: 0 }),
    new PaddleTextRecognizer({ keepFormatting: true }),
])

const scanned = needsOcr.length > 0 ? await ocr.transform(file) : []

Because both stages use the same resolution, the boxes from the text layer and the boxes from OCR are in one coordinate space — so the two sets of pages can be merged, drawn together, or fed to the same NER stage without conversion.

Match the resolution

PdfToDocument's resolution is not a render DPI — it is the pixel space its boxes are expressed in. Set it to the same number as PdfToImage or the two box sets will not line up.

One pipeline, OCR only where it is needed

when moves the branch into the pipeline. A page with a usable text layer passes the OCR stages untouched, and a document whose every page has one never loads an OCR model at all:

import { pipelineFromDescriptors } from '@stabrise/scaledp/registry'
import { usableTextLayer } from '@stabrise/scaledp/pdf'

const needsOcr = { any: [{ not: usableTextLayer() }, { var: 'forceOcr', truthy: true }] }

const pipeline = pipelineFromDescriptors([
    { type: 'PdfToDocument', options: { resolution: 300 } },
    { type: 'PdfToImage', options: { resolution: 300, keepInputData: true }, when: needsOcr },
    { type: 'PaddleTextRecognizer', options: { outputCol: 'document' }, when: needsOcr },
])

const rows = await pipeline.run(file, { vars: { forceOcr: false } })

usableTextLayer(col, minimumBoxes) is hasUsableTextLayer as a condition. Run with run rather than transform and the text-layer pages come back while the scanned ones are still being read. See Streaming runs.

Both columns, decided afterwards

If you would rather keep both readings, run both readers and decide per row afterwards:

const pipeline = new Pipeline([
    new PdfToDocument({ resolution: 300, outputCol: 'pdf_text' }),
    new PdfToImage({ resolution: 300 }),
])

PdfToDocument first, because it keeps content and sets the page index; PdfToImage then renders only the page the row already names, so a five-page file stays five rows. The reverse order does not work — PdfToImage deletes content by default, leaving PdfToDocument nothing to read. See Columns are the wiring.

For most corpora the conditional pipeline above is cheaper and clearer.

When a page is both

A page with a vector-text header and a scanned table is not an either-or case, and neither branch above reads all of it. See A PDF's text layer and its scanned images.

On this page