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:
- If the adapter yields a
ParsedPagewith non-emptyboxes→ the page receives an invisible overlay (subject tomin_confidence). - 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
liteparseadapter avoids this by filtering onfontName.
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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6308ac6d45cdbac71c54ea3eb3f193c382af836dbc8aea924072946fae2dca12
|
|
| MD5 |
13517cb8fcf9f763fb079866b64aa153
|
|
| BLAKE2b-256 |
98119cf975e397e1931667b652b807c09bdb1a5f76e3ec3ce3abefdd678776f2
|
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4b230ba9759e6700ad1dcd4697738ad44d9f6faa39b78f07a8d39c208ca7f6af
|
|
| MD5 |
3ab6eaa9c964f4ec89d266d18a8a1189
|
|
| BLAKE2b-256 |
1cfae91b02e05f0281e5c6d9aa6f7efd84a9df8f0dead1d4801ab186e411bd3a
|