Skip to main content

Add an invisible, searchable text layer to a PDF from OCR JSON (PyMuPDF).

Project description

pdf-textlayer

Add an invisible, searchable text layer to a PDF from OCR JSON. Pages without OCR text are left exactly as in the input — native text, embedded fonts, and annotations are preserved. Built on PyMuPDF.

Published on PyPI · Developed on GitHub.

What it does

Given a PDF and a JSON file from an OCR pipeline, pdf-textlayer writes a new PDF where every OCR-detected word is drawn at its bounding box in PDF render mode 3 (invisible glyphs). The original page content is unchanged; the new layer is what Ctrl+F, screen readers, and indexers see.

  • Mixed PDFs are handled correctly. A 200-page document with two scanned exhibits gets the overlay only on the scanned pages. Pages with native text are passed through untouched.
  • Adapter-based. Out of the box it understands liteparse JSON. Other sources (Azure Document Intelligence, Textract, hOCR, MinerU, your own pipeline) can be added by writing a small adapter — see Adapters.
  • Minimal dependencies. Only pymupdf.

Installation

Python 3.11+.

pip install pdf-textlayer
# or
uv add pdf-textlayer

This installs the pdf-textlayer CLI and the pdf_textlayer Python package.

CLI

# 1. Produce OCR JSON for your PDF (example: liteparse)
liteparse input.pdf --format json -o parsed.json

# 2. Build the searchable PDF
pdf-textlayer input.pdf parsed.json output.pdf

Options:

pdf-textlayer input.pdf parsed.json output.pdf \
    --from liteparse \           # adapter name (default: liteparse)
    --min-confidence 0.5 \       # drop low-confidence OCR items
    --font china-s               # CJK font for Asian-language documents

Python API

from pdf_textlayer import make_searchable_pdf, Options

stats = make_searchable_pdf(
    "input.pdf",
    "parsed.json",                       # path, dict, raw JSON bytes, or ParsedPage[]
    "output.pdf",
    adapter="liteparse",                 # default
    options=Options(min_confidence=0.5, font="helv"),
)
print(stats)
# {'pages': 19, 'ocr_pages': [1, 2, ..., 19], 'items_drawn': 7643}

Bytes in, bytes out (server / API use)

Both the input PDF and the result can be raw bytes, so no temp files are needed:

# In a FastAPI/Flask handler — uploaded_pdf is bytes, ocr is a parsed dict
pdf_bytes, stats = make_searchable_pdf(uploaded_pdf, ocr)
return Response(pdf_bytes, media_type="application/pdf")

Omit output_pdf (or pass None) to get a (bytes, Stats) tuple back; pass a path to write directly. input_pdf and the JSON source each accept a path or bytes.

Working from ParsedPage objects directly

If you already have the OCR results in memory (e.g. from another library), build ParsedPage objects directly and skip the adapter:

from pdf_textlayer import ParsedPage, TextBox, write_textlayer

pages = [
    ParsedPage(
        page_number=1,
        boxes=[
            TextBox(text="Hello", x=72.0, y=108.0, width=46.0, height=12.0, confidence=0.98),
            TextBox(text="world", x=124.0, y=108.0, width=48.0, height=12.0, confidence=0.97),
        ],
    ),
]
write_textlayer("input.pdf", pages, "output.pdf")

Coordinates use PyMuPDF's convention: origin at the top-left of the page, x right, y down, in PDF user-space points. (x, y) is the top-left corner of the box.

How mixed pages are handled

For each page in the input PDF, the adapter decides whether the page has OCR content to overlay:

  1. If the adapter yields a ParsedPage with non-empty boxes → the page receives an invisible overlay (subject to min_confidence).
  2. If the adapter omits the page, or yields it with no boxes → the page is left exactly as-is.

For the bundled liteparse adapter, "has OCR content" means the page contains at least one textItems entry with fontName == "OCR". Native-text items keep the real font name and are skipped, so the original PDF text is never duplicated.

Adapters

An adapter is a small class that turns a source's JSON into normalized ParsedPage objects. The protocol is one method:

from collections.abc import Iterable
from pdf_textlayer import ParsedPage, register_adapter

class MyAdapter:
    def parse(self, data) -> Iterable[ParsedPage]:
        for page in data["pages"]:
            yield ParsedPage(
                page_number=page["index"] + 1,        # 1-indexed
                boxes=[...],                          # list[TextBox]
            )

register_adapter("mine", MyAdapter())
# pdf-textlayer in.pdf in.json out.pdf --from mine

Bundled adapters:

Name Source Notes
liteparse liteparse --format json Items with fontName == "OCR" per the OCR API spec.

PRs adding more adapters (Azure DI, Textract, hOCR, …) are welcome.

Fonts and non-Latin text

The default font (helv = Helvetica) covers Latin-1. Unsupported glyphs are replaced with ? so each item still emits a usable text run; set Options(sanitize_unsupported=False) to drop those items instead.

For CJK documents, pass a bundled PyMuPDF font:

pdf-textlayer in.pdf parsed.json out.pdf --font china-s
# china-s, china-t, japan, korea — bundled with PyMuPDF

How the overlay is drawn

For each box, the font is scaled so the rendered text width matches the box width. The insertion point is the glyph baseline, placed at (x, y + height). Text is written with PDF render mode 3 — present in the content stream, never painted — so search, copy/paste, and assistive tech work, but the visible page is unchanged.

Limitations

  • Rotated text is drawn upright. OCR sources that don't include rotation metadata can't be rendered rotated; in practice most pipelines de-rotate before emitting boxes.
  • Invisible text uses PDF render mode 3. All modern viewers and search indexers handle this correctly; very old tooling may not extract it.
  • No de-duplication across overlay and native text. If you point the tool at native-text pages but the adapter treats them as OCR, you'll get duplicated search hits. The bundled liteparse adapter avoids this by filtering on fontName.

Contributing

Bug reports, docs improvements, and new adapters are welcome — open an issue or PR on GitHub.

License

Released under the MIT License. See LICENSE for the full text.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pdf_textlayer-0.2.0.tar.gz (10.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

pdf_textlayer-0.2.0-py3-none-any.whl (12.9 kB view details)

Uploaded Python 3

File details

Details for the file pdf_textlayer-0.2.0.tar.gz.

File metadata

  • Download URL: pdf_textlayer-0.2.0.tar.gz
  • Upload date:
  • Size: 10.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.9.18 {"installer":{"name":"uv","version":"0.9.18","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for pdf_textlayer-0.2.0.tar.gz
Algorithm Hash digest
SHA256 6308ac6d45cdbac71c54ea3eb3f193c382af836dbc8aea924072946fae2dca12
MD5 13517cb8fcf9f763fb079866b64aa153
BLAKE2b-256 98119cf975e397e1931667b652b807c09bdb1a5f76e3ec3ce3abefdd678776f2

See more details on using hashes here.

File details

Details for the file pdf_textlayer-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: pdf_textlayer-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 12.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.9.18 {"installer":{"name":"uv","version":"0.9.18","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for pdf_textlayer-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 4b230ba9759e6700ad1dcd4697738ad44d9f6faa39b78f07a8d39c208ca7f6af
MD5 3ab6eaa9c964f4ec89d266d18a8a1189
BLAKE2b-256 1cfae91b02e05f0281e5c6d9aa6f7efd84a9df8f0dead1d4801ab186e411bd3a

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page