pdf-textlayer 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vivek Patel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,170 @@
1
+ Metadata-Version: 2.4
2
+ Name: pdf-textlayer
3
+ Version: 0.1.0
4
+ Summary: Add an invisible, searchable text layer to a PDF from OCR JSON (PyMuPDF).
5
+ Keywords: pdf,ocr,searchable,textlayer,pymupdf,liteparse,accessibility
6
+ Author: Vivek Patel
7
+ Author-email: Vivek Patel <me@patelvivek.dev>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Scientific/Engineering :: Image Processing
18
+ Classifier: Topic :: Text Processing
19
+ Requires-Dist: pymupdf>=1.27.2.3
20
+ Requires-Python: >=3.11
21
+ Project-URL: Homepage, https://github.com/patelvivekdev/pdf-textlayer
22
+ Project-URL: Issues, https://github.com/patelvivekdev/pdf-textlayer/issues
23
+ Project-URL: Repository, https://github.com/patelvivekdev/pdf-textlayer
24
+ Description-Content-Type: text/markdown
25
+
26
+ # pdf-textlayer
27
+
28
+ 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](https://pymupdf.readthedocs.io/)**.
29
+
30
+ Published on **[PyPI](https://pypi.org/project/pdf-textlayer/)** · Developed on **[GitHub](https://github.com/patelvivekdev/pdf-textlayer)**.
31
+
32
+ ## What it does
33
+
34
+ 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.
35
+
36
+ - **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.
37
+ - **Adapter-based.** Out of the box it understands [liteparse](https://github.com/run-llama/liteparse) JSON. Other sources (Azure Document Intelligence, Textract, hOCR, MinerU, your own pipeline) can be added by writing a small adapter — see [Adapters](#adapters).
38
+ - **Minimal dependencies.** Only `pymupdf`.
39
+
40
+ ## Installation
41
+
42
+ Python **3.11+**.
43
+
44
+ ```bash
45
+ pip install pdf-textlayer
46
+ # or
47
+ uv add pdf-textlayer
48
+ ```
49
+
50
+ This installs the `pdf-textlayer` CLI and the `pdf_textlayer` Python package.
51
+
52
+ ## CLI
53
+
54
+ ```bash
55
+ # 1. Produce OCR JSON for your PDF (example: liteparse)
56
+ liteparse input.pdf --format json -o parsed.json
57
+
58
+ # 2. Build the searchable PDF
59
+ pdf-textlayer input.pdf parsed.json output.pdf
60
+ ```
61
+
62
+ Options:
63
+
64
+ ```bash
65
+ pdf-textlayer input.pdf parsed.json output.pdf \
66
+ --from liteparse \ # adapter name (default: liteparse)
67
+ --min-confidence 0.5 \ # drop low-confidence OCR items
68
+ --font china-s # CJK font for Asian-language documents
69
+ ```
70
+
71
+ ## Python API
72
+
73
+ ```python
74
+ from pdf_textlayer import make_searchable_pdf, Options
75
+
76
+ stats = make_searchable_pdf(
77
+ "input.pdf",
78
+ "parsed.json", # path, or already-loaded dict
79
+ "output.pdf",
80
+ adapter="liteparse", # default
81
+ options=Options(min_confidence=0.5, font="helv"),
82
+ )
83
+ print(stats)
84
+ # {'pages': 19, 'ocr_pages': [1, 2, ..., 19], 'items_drawn': 7643}
85
+ ```
86
+
87
+ If you already have the OCR results in memory (e.g. from another library), build `ParsedPage` objects directly and skip the adapter:
88
+
89
+ ```python
90
+ from pdf_textlayer import ParsedPage, TextBox, write_textlayer
91
+
92
+ pages = [
93
+ ParsedPage(
94
+ page_number=1,
95
+ boxes=[
96
+ TextBox(text="Hello", x=72.0, y=108.0, width=46.0, height=12.0, confidence=0.98),
97
+ TextBox(text="world", x=124.0, y=108.0, width=48.0, height=12.0, confidence=0.97),
98
+ ],
99
+ ),
100
+ ]
101
+ write_textlayer("input.pdf", pages, "output.pdf")
102
+ ```
103
+
104
+ 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.
105
+
106
+ ## How mixed pages are handled
107
+
108
+ For each page in the input PDF, the adapter decides whether the page has OCR content to overlay:
109
+
110
+ 1. If the adapter yields a `ParsedPage` with non-empty `boxes` → the page receives an invisible overlay (subject to `min_confidence`).
111
+ 2. If the adapter omits the page, or yields it with no boxes → the page is left exactly as-is.
112
+
113
+ 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.
114
+
115
+ ## Adapters
116
+
117
+ An adapter is a small class that turns a source's JSON into normalized `ParsedPage` objects. The protocol is one method:
118
+
119
+ ```python
120
+ from collections.abc import Iterable
121
+ from pdf_textlayer import ParsedPage, register_adapter
122
+
123
+ class MyAdapter:
124
+ def parse(self, data) -> Iterable[ParsedPage]:
125
+ for page in data["pages"]:
126
+ yield ParsedPage(
127
+ page_number=page["index"] + 1, # 1-indexed
128
+ boxes=[...], # list[TextBox]
129
+ )
130
+
131
+ register_adapter("mine", MyAdapter())
132
+ # pdf-textlayer in.pdf in.json out.pdf --from mine
133
+ ```
134
+
135
+ Bundled adapters:
136
+
137
+ | Name | Source | Notes |
138
+ |------|--------|-------|
139
+ | `liteparse` | [liteparse](https://github.com/run-llama/liteparse) `--format json` | Items with `fontName == "OCR"` per the [OCR API spec](https://github.com/run-llama/liteparse/blob/main/OCR_API_SPEC.md). |
140
+
141
+ PRs adding more adapters (Azure DI, Textract, hOCR, …) are welcome.
142
+
143
+ ## Fonts and non-Latin text
144
+
145
+ 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.
146
+
147
+ For CJK documents, pass a bundled PyMuPDF font:
148
+
149
+ ```bash
150
+ pdf-textlayer in.pdf parsed.json out.pdf --font china-s
151
+ # china-s, china-t, japan, korea — bundled with PyMuPDF
152
+ ```
153
+
154
+ ## How the overlay is drawn
155
+
156
+ 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.
157
+
158
+ ## Limitations
159
+
160
+ - **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.
161
+ - **Invisible text uses PDF render mode 3.** All modern viewers and search indexers handle this correctly; very old tooling may not extract it.
162
+ - **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`.
163
+
164
+ ## Contributing
165
+
166
+ Bug reports, docs improvements, and new adapters are welcome — open an issue or PR on [GitHub](https://github.com/patelvivekdev/pdf-textlayer/issues).
167
+
168
+ ## License
169
+
170
+ Released under the **MIT License**. See [`LICENSE`](LICENSE) for the full text.
@@ -0,0 +1,145 @@
1
+ # pdf-textlayer
2
+
3
+ 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](https://pymupdf.readthedocs.io/)**.
4
+
5
+ Published on **[PyPI](https://pypi.org/project/pdf-textlayer/)** · Developed on **[GitHub](https://github.com/patelvivekdev/pdf-textlayer)**.
6
+
7
+ ## What it does
8
+
9
+ 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.
10
+
11
+ - **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.
12
+ - **Adapter-based.** Out of the box it understands [liteparse](https://github.com/run-llama/liteparse) JSON. Other sources (Azure Document Intelligence, Textract, hOCR, MinerU, your own pipeline) can be added by writing a small adapter — see [Adapters](#adapters).
13
+ - **Minimal dependencies.** Only `pymupdf`.
14
+
15
+ ## Installation
16
+
17
+ Python **3.11+**.
18
+
19
+ ```bash
20
+ pip install pdf-textlayer
21
+ # or
22
+ uv add pdf-textlayer
23
+ ```
24
+
25
+ This installs the `pdf-textlayer` CLI and the `pdf_textlayer` Python package.
26
+
27
+ ## CLI
28
+
29
+ ```bash
30
+ # 1. Produce OCR JSON for your PDF (example: liteparse)
31
+ liteparse input.pdf --format json -o parsed.json
32
+
33
+ # 2. Build the searchable PDF
34
+ pdf-textlayer input.pdf parsed.json output.pdf
35
+ ```
36
+
37
+ Options:
38
+
39
+ ```bash
40
+ pdf-textlayer input.pdf parsed.json output.pdf \
41
+ --from liteparse \ # adapter name (default: liteparse)
42
+ --min-confidence 0.5 \ # drop low-confidence OCR items
43
+ --font china-s # CJK font for Asian-language documents
44
+ ```
45
+
46
+ ## Python API
47
+
48
+ ```python
49
+ from pdf_textlayer import make_searchable_pdf, Options
50
+
51
+ stats = make_searchable_pdf(
52
+ "input.pdf",
53
+ "parsed.json", # path, or already-loaded dict
54
+ "output.pdf",
55
+ adapter="liteparse", # default
56
+ options=Options(min_confidence=0.5, font="helv"),
57
+ )
58
+ print(stats)
59
+ # {'pages': 19, 'ocr_pages': [1, 2, ..., 19], 'items_drawn': 7643}
60
+ ```
61
+
62
+ If you already have the OCR results in memory (e.g. from another library), build `ParsedPage` objects directly and skip the adapter:
63
+
64
+ ```python
65
+ from pdf_textlayer import ParsedPage, TextBox, write_textlayer
66
+
67
+ pages = [
68
+ ParsedPage(
69
+ page_number=1,
70
+ boxes=[
71
+ TextBox(text="Hello", x=72.0, y=108.0, width=46.0, height=12.0, confidence=0.98),
72
+ TextBox(text="world", x=124.0, y=108.0, width=48.0, height=12.0, confidence=0.97),
73
+ ],
74
+ ),
75
+ ]
76
+ write_textlayer("input.pdf", pages, "output.pdf")
77
+ ```
78
+
79
+ 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.
80
+
81
+ ## How mixed pages are handled
82
+
83
+ For each page in the input PDF, the adapter decides whether the page has OCR content to overlay:
84
+
85
+ 1. If the adapter yields a `ParsedPage` with non-empty `boxes` → the page receives an invisible overlay (subject to `min_confidence`).
86
+ 2. If the adapter omits the page, or yields it with no boxes → the page is left exactly as-is.
87
+
88
+ 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.
89
+
90
+ ## Adapters
91
+
92
+ An adapter is a small class that turns a source's JSON into normalized `ParsedPage` objects. The protocol is one method:
93
+
94
+ ```python
95
+ from collections.abc import Iterable
96
+ from pdf_textlayer import ParsedPage, register_adapter
97
+
98
+ class MyAdapter:
99
+ def parse(self, data) -> Iterable[ParsedPage]:
100
+ for page in data["pages"]:
101
+ yield ParsedPage(
102
+ page_number=page["index"] + 1, # 1-indexed
103
+ boxes=[...], # list[TextBox]
104
+ )
105
+
106
+ register_adapter("mine", MyAdapter())
107
+ # pdf-textlayer in.pdf in.json out.pdf --from mine
108
+ ```
109
+
110
+ Bundled adapters:
111
+
112
+ | Name | Source | Notes |
113
+ |------|--------|-------|
114
+ | `liteparse` | [liteparse](https://github.com/run-llama/liteparse) `--format json` | Items with `fontName == "OCR"` per the [OCR API spec](https://github.com/run-llama/liteparse/blob/main/OCR_API_SPEC.md). |
115
+
116
+ PRs adding more adapters (Azure DI, Textract, hOCR, …) are welcome.
117
+
118
+ ## Fonts and non-Latin text
119
+
120
+ 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.
121
+
122
+ For CJK documents, pass a bundled PyMuPDF font:
123
+
124
+ ```bash
125
+ pdf-textlayer in.pdf parsed.json out.pdf --font china-s
126
+ # china-s, china-t, japan, korea — bundled with PyMuPDF
127
+ ```
128
+
129
+ ## How the overlay is drawn
130
+
131
+ 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.
132
+
133
+ ## Limitations
134
+
135
+ - **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.
136
+ - **Invisible text uses PDF render mode 3.** All modern viewers and search indexers handle this correctly; very old tooling may not extract it.
137
+ - **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`.
138
+
139
+ ## Contributing
140
+
141
+ Bug reports, docs improvements, and new adapters are welcome — open an issue or PR on [GitHub](https://github.com/patelvivekdev/pdf-textlayer/issues).
142
+
143
+ ## License
144
+
145
+ Released under the **MIT License**. See [`LICENSE`](LICENSE) for the full text.
@@ -0,0 +1,68 @@
1
+ [project]
2
+ name = "pdf-textlayer"
3
+ version = "0.1.0"
4
+ description = "Add an invisible, searchable text layer to a PDF from OCR JSON (PyMuPDF)."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [
9
+ { name = "Vivek Patel", email = "me@patelvivek.dev" }
10
+ ]
11
+ requires-python = ">=3.11"
12
+ keywords = ["pdf", "ocr", "searchable", "textlayer", "pymupdf", "liteparse", "accessibility"]
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Topic :: Scientific/Engineering :: Image Processing",
22
+ "Topic :: Text Processing",
23
+ ]
24
+ dependencies = [
25
+ "pymupdf>=1.27.2.3",
26
+ ]
27
+
28
+ [project.urls]
29
+ Homepage = "https://github.com/patelvivekdev/pdf-textlayer"
30
+ Repository = "https://github.com/patelvivekdev/pdf-textlayer"
31
+ Issues = "https://github.com/patelvivekdev/pdf-textlayer/issues"
32
+
33
+ [project.scripts]
34
+ pdf-textlayer = "pdf_textlayer:main"
35
+
36
+ [build-system]
37
+ requires = ["uv_build>=0.9.18,<0.10.0"]
38
+ build-backend = "uv_build"
39
+
40
+ [dependency-groups]
41
+ dev = [
42
+ "pytest>=9.0.3",
43
+ "ruff>=0.9.0",
44
+ ]
45
+
46
+ [tool.ruff]
47
+ target-version = "py311"
48
+ line-length = 88
49
+ src = ["src"]
50
+
51
+ [tool.ruff.format]
52
+ quote-style = "double"
53
+ indent-style = "space"
54
+
55
+ [tool.ruff.lint]
56
+ select = [
57
+ "E", # pycodestyle errors
58
+ "W", # pycodestyle warnings
59
+ "F", # Pyflakes
60
+ "I", # isort
61
+ "UP", # pyupgrade
62
+ "B", # flake8-bugbear
63
+ "SIM", # flake8-simplify
64
+ ]
65
+ ignore = []
66
+
67
+ [tool.ruff.lint.isort]
68
+ known-first-party = ["pdf_textlayer"]
@@ -0,0 +1,28 @@
1
+ """Add an invisible, searchable text layer to a PDF from OCR JSON."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .adapters import Adapter, LiteparseAdapter, get_adapter, register_adapter
6
+ from .cli import main
7
+ from .core import (
8
+ Options,
9
+ ParsedPage,
10
+ Stats,
11
+ TextBox,
12
+ make_searchable_pdf,
13
+ write_textlayer,
14
+ )
15
+
16
+ __all__ = [
17
+ "Adapter",
18
+ "LiteparseAdapter",
19
+ "Options",
20
+ "ParsedPage",
21
+ "Stats",
22
+ "TextBox",
23
+ "get_adapter",
24
+ "main",
25
+ "make_searchable_pdf",
26
+ "register_adapter",
27
+ "write_textlayer",
28
+ ]
@@ -0,0 +1,131 @@
1
+ """Adapters that translate OCR-source JSON into :class:`ParsedPage` objects.
2
+
3
+ An adapter is anything that implements the :class:`Adapter` protocol —
4
+ typically a small class with a ``parse(data) -> Iterable[ParsedPage]``
5
+ method. New adapters can be plugged in at runtime with
6
+ :func:`register_adapter`.
7
+
8
+ Currently bundled:
9
+
10
+ * ``"liteparse"`` — :class:`LiteparseAdapter`, for JSON produced by
11
+ `liteparse <https://github.com/run-llama/liteparse>`_'s ``--format json``
12
+ output. liteparse tags OCR-derived text items with
13
+ ``fontName == "OCR"``; native-text items keep the real font name and
14
+ are skipped here so the original PDF text stays untouched.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections.abc import Iterable
20
+ from typing import Any, Protocol, runtime_checkable
21
+
22
+ from .core import ParsedPage, TextBox
23
+
24
+
25
+ @runtime_checkable
26
+ class Adapter(Protocol):
27
+ """Protocol for OCR-source adapters.
28
+
29
+ Implementations take a deserialized JSON document and yield one
30
+ :class:`ParsedPage` per page that carries OCR text. Pages with no OCR
31
+ text may be omitted (or yielded with an empty ``boxes`` list — both
32
+ are treated identically by the core).
33
+ """
34
+
35
+ def parse(self, data: Any) -> Iterable[ParsedPage]: ...
36
+
37
+
38
+ class LiteparseAdapter:
39
+ """Adapter for `liteparse <https://github.com/run-llama/liteparse>`_ JSON.
40
+
41
+ Expected shape (simplified)::
42
+
43
+ {
44
+ "pages": [
45
+ {
46
+ "page": 1,
47
+ "textItems": [
48
+ {
49
+ "text": "Hello",
50
+ "x": 10, "y": 20,
51
+ "width": 60, "height": 14,
52
+ "fontName": "OCR",
53
+ "confidence": 0.95
54
+ },
55
+ ...
56
+ ]
57
+ },
58
+ ...
59
+ ]
60
+ }
61
+
62
+ Only items with ``fontName == "OCR"`` are emitted — native-text items
63
+ are left to the original PDF.
64
+ """
65
+
66
+ OCR_FONT_MARKER = "OCR"
67
+
68
+ def parse(self, data: dict[str, Any]) -> Iterable[ParsedPage]:
69
+ for page in data.get("pages", []):
70
+ page_no = page.get("page")
71
+ if not isinstance(page_no, int):
72
+ continue
73
+ boxes = list(self._page_boxes(page))
74
+ if boxes:
75
+ yield ParsedPage(page_number=page_no, boxes=boxes)
76
+
77
+ def _page_boxes(self, page: dict[str, Any]) -> Iterable[TextBox]:
78
+ for item in page.get("textItems", []):
79
+ if item.get("fontName") != self.OCR_FONT_MARKER:
80
+ continue
81
+ text = (item.get("text") or "").strip()
82
+ if not text:
83
+ continue
84
+ try:
85
+ width = float(item.get("width") or 0)
86
+ height = float(item.get("height") or 0)
87
+ x = float(item["x"])
88
+ y = float(item["y"])
89
+ except (KeyError, TypeError, ValueError):
90
+ continue
91
+ if width <= 0 or height <= 0:
92
+ continue
93
+ yield TextBox(
94
+ text=text,
95
+ x=x,
96
+ y=y,
97
+ width=width,
98
+ height=height,
99
+ confidence=float(item.get("confidence") or 1.0),
100
+ )
101
+
102
+
103
+ _REGISTRY: dict[str, Adapter] = {
104
+ "liteparse": LiteparseAdapter(),
105
+ }
106
+
107
+
108
+ def get_adapter(name: str) -> Adapter:
109
+ """Look up a registered adapter by name.
110
+
111
+ Raises:
112
+ KeyError: If ``name`` is not registered.
113
+ """
114
+ try:
115
+ return _REGISTRY[name]
116
+ except KeyError:
117
+ known = ", ".join(sorted(_REGISTRY)) or "(none)"
118
+ raise KeyError(f"Unknown adapter {name!r}. Registered: {known}") from None
119
+
120
+
121
+ def register_adapter(name: str, adapter: Adapter) -> None:
122
+ """Register a custom adapter under ``name``.
123
+
124
+ Overwrites any existing entry with the same name.
125
+ """
126
+ _REGISTRY[name] = adapter
127
+
128
+
129
+ def available_adapters() -> list[str]:
130
+ """Return the names of all registered adapters."""
131
+ return sorted(_REGISTRY)
@@ -0,0 +1,89 @@
1
+ """CLI: take a raw PDF + OCR JSON, write a PDF with an invisible text layer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from .adapters import available_adapters
10
+ from .core import DEFAULT_FONT, Options, make_searchable_pdf
11
+
12
+
13
+ def build_parser() -> argparse.ArgumentParser:
14
+ p = argparse.ArgumentParser(
15
+ prog="pdf-textlayer",
16
+ description=(
17
+ "Add an invisible, searchable text layer to a PDF using OCR JSON. "
18
+ "Pages without OCR text are left untouched."
19
+ ),
20
+ )
21
+ p.add_argument(
22
+ "input_pdf",
23
+ type=Path,
24
+ help="Source PDF (scanned, native, or mixed).",
25
+ )
26
+ p.add_argument(
27
+ "parsed_json",
28
+ type=Path,
29
+ help="OCR JSON for the source PDF (see --from for supported formats).",
30
+ )
31
+ p.add_argument(
32
+ "output_pdf",
33
+ type=Path,
34
+ help="Path to write the resulting PDF.",
35
+ )
36
+ p.add_argument(
37
+ "--from",
38
+ dest="adapter",
39
+ default="liteparse",
40
+ choices=available_adapters(),
41
+ help="JSON adapter to use. Default: liteparse.",
42
+ )
43
+ p.add_argument(
44
+ "--min-confidence",
45
+ type=float,
46
+ default=0.0,
47
+ help="Drop OCR items below this confidence (0–1). Default: 0.",
48
+ )
49
+ p.add_argument(
50
+ "--font",
51
+ default=DEFAULT_FONT,
52
+ help=(
53
+ "PyMuPDF font name. Use a CJK font (china-s, china-t, japan, "
54
+ f"korea) for Asian docs. Default: {DEFAULT_FONT}."
55
+ ),
56
+ )
57
+ return p
58
+
59
+
60
+ def main(argv: list[str] | None = None) -> int:
61
+ args = build_parser().parse_args(argv)
62
+
63
+ if not args.input_pdf.exists():
64
+ print(f"Input PDF not found: {args.input_pdf}", file=sys.stderr)
65
+ return 1
66
+ if not args.parsed_json.exists():
67
+ print(f"Parsed JSON not found: {args.parsed_json}", file=sys.stderr)
68
+ return 1
69
+
70
+ stats = make_searchable_pdf(
71
+ args.input_pdf,
72
+ args.parsed_json,
73
+ args.output_pdf,
74
+ adapter=args.adapter,
75
+ options=Options(min_confidence=args.min_confidence, font=args.font),
76
+ )
77
+
78
+ print(f"Wrote: {args.output_pdf}")
79
+ print(f"Pages: {stats['pages']}")
80
+ print(
81
+ f"Text layer added on {len(stats['ocr_pages'])} page(s): "
82
+ f"{', '.join(map(str, stats['ocr_pages'])) or '(none)'}"
83
+ )
84
+ print(f"Items drawn: {stats['items_drawn']}")
85
+ return 0
86
+
87
+
88
+ if __name__ == "__main__":
89
+ raise SystemExit(main())
@@ -0,0 +1,277 @@
1
+ """Draw an invisible text layer over a PDF.
2
+
3
+ The package separates two concerns:
4
+
5
+ * **Adapters** (see :mod:`pdf_textlayer.adapters`) translate a source-specific
6
+ JSON shape into :class:`ParsedPage` objects.
7
+ * **Core** (this module) takes those :class:`ParsedPage` objects and draws an
8
+ invisible PDF text layer at each box's location, so the page becomes
9
+ selectable and searchable in any viewer.
10
+
11
+ Pages without OCR boxes are left exactly as in the input PDF — native text and
12
+ embedded searchability are preserved. This handles mixed scanned / native
13
+ documents without re-rasterizing.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import logging
20
+ from collections.abc import Iterable
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+ from typing import TYPE_CHECKING, Any, TypedDict
24
+
25
+ import pymupdf
26
+
27
+ if TYPE_CHECKING:
28
+ from .adapters import Adapter
29
+
30
+ log = logging.getLogger(__name__)
31
+
32
+ # PyMuPDF render mode 3 = "neither fill nor stroke". The glyphs are present in
33
+ # the content stream (so text extraction, search, and accessibility tools find
34
+ # them) but never painted.
35
+ _INVISIBLE_RENDER_MODE = 3
36
+
37
+ # Base-14 font available in every PDF reader without embedding. CJK and other
38
+ # non-Latin characters get transliterated to "?" unless an embedded font is
39
+ # selected — the text layer is for search, not visual fidelity.
40
+ DEFAULT_FONT = "helv"
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class TextBox:
45
+ """A single piece of OCR text to draw, in PDF user-space points.
46
+
47
+ Coordinates use PyMuPDF's convention: origin at the top-left of the
48
+ page, X increases right, Y increases down. ``(x, y)`` is the top-left
49
+ corner of the box.
50
+ """
51
+
52
+ text: str
53
+ x: float
54
+ y: float
55
+ width: float
56
+ height: float
57
+ confidence: float = 1.0
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class ParsedPage:
62
+ """OCR boxes for a single page of the source PDF.
63
+
64
+ ``page_number`` is 1-indexed and matches the page in the input PDF
65
+ that should receive the overlay. Pages not present in the iterable
66
+ are left untouched.
67
+ """
68
+
69
+ page_number: int
70
+ boxes: list[TextBox] = field(default_factory=list)
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class Options:
75
+ """Drawing options for :func:`write_textlayer` / :func:`make_searchable_pdf`."""
76
+
77
+ min_confidence: float = 0.0
78
+ """Drop boxes whose confidence is below this threshold (0.0–1.0).
79
+
80
+ ``0.0`` keeps everything.
81
+ """
82
+
83
+ font: str = DEFAULT_FONT
84
+ """PyMuPDF font name.
85
+
86
+ Built-in: ``helv``, ``cour``, ``tiro``. Bundled CJK fonts: ``china-s``,
87
+ ``china-t``, ``japan``, ``korea``.
88
+ """
89
+
90
+ sanitize_unsupported: bool = True
91
+ """If True, replace glyphs the font cannot encode with ``?``.
92
+
93
+ If False, items containing unsupported glyphs are dropped so they
94
+ don't silently emit empty content streams.
95
+ """
96
+
97
+
98
+ class Stats(TypedDict):
99
+ """Summary returned from a successful overlay pass."""
100
+
101
+ pages: int
102
+ ocr_pages: list[int]
103
+ items_drawn: int
104
+
105
+
106
+ def _sanitize(text: str, font: pymupdf.Font) -> str:
107
+ """Replace characters the font can't encode with ``?``.
108
+
109
+ ``page.insert_text`` silently drops unsupported glyphs, which means
110
+ a single bad codepoint can erase the entire item. Sanitizing
111
+ preserves the rest of the string so search still hits the word.
112
+ """
113
+ out: list[str] = []
114
+ for ch in text:
115
+ try:
116
+ keep = font.glyph_advance(ord(ch)) > 0
117
+ except Exception:
118
+ keep = False
119
+ out.append(ch if keep else "?")
120
+ return "".join(out)
121
+
122
+
123
+ def _draw_box(
124
+ page: pymupdf.Page,
125
+ font: pymupdf.Font,
126
+ box: TextBox,
127
+ opts: Options,
128
+ ) -> bool:
129
+ """Draw a single invisible text box. Returns True if anything was drawn."""
130
+ text = box.text
131
+ if opts.sanitize_unsupported:
132
+ text = _sanitize(text, font)
133
+ if not text:
134
+ return False
135
+
136
+ # Scale font so the string's rendered width matches the OCR bbox width.
137
+ # This is what makes word-level highlights line up when a user
138
+ # searches inside Acrobat / Preview / browser PDF viewers.
139
+ width_at_1pt = font.text_length(text, fontsize=1)
140
+ if width_at_1pt <= 0:
141
+ return False
142
+ font_size = box.width / width_at_1pt
143
+ # Sanity cap: if the bbox is wide-but-short (single tall glyph or an
144
+ # unhandled rotation), don't blow the font size up absurdly.
145
+ font_size = min(font_size, max(box.height * 1.2, 1.0))
146
+
147
+ # The insertion point is the glyph baseline, so add `height` to drop
148
+ # from the top of the bbox down to where the baseline should sit.
149
+ page.insert_text(
150
+ (box.x, box.y + box.height),
151
+ text,
152
+ fontname=opts.font,
153
+ fontsize=font_size,
154
+ render_mode=_INVISIBLE_RENDER_MODE,
155
+ # Color is irrelevant under render_mode=3, but PyMuPDF requires it.
156
+ color=(0, 0, 0),
157
+ )
158
+ return True
159
+
160
+
161
+ def write_textlayer(
162
+ input_pdf: str | Path,
163
+ pages: Iterable[ParsedPage],
164
+ output_pdf: str | Path,
165
+ *,
166
+ options: Options | None = None,
167
+ ) -> Stats:
168
+ """Draw an invisible text layer onto ``input_pdf`` and save it to ``output_pdf``.
169
+
170
+ This is the low-level entry point: it takes already-normalized
171
+ :class:`ParsedPage` objects, so any OCR source can be plugged in by
172
+ writing an adapter (see :mod:`pdf_textlayer.adapters`) or by
173
+ constructing :class:`ParsedPage` instances directly.
174
+
175
+ Pages whose ``page_number`` does not appear in ``pages`` — or that
176
+ contribute no drawable boxes after filtering — are left untouched.
177
+
178
+ Args:
179
+ input_pdf: Path to the source PDF.
180
+ pages: Iterable of :class:`ParsedPage`. Order does not matter.
181
+ output_pdf: Where to write the result. Overwritten if it exists.
182
+ options: Drawing options.
183
+
184
+ Returns:
185
+ :class:`Stats` summarizing what was written.
186
+ """
187
+ opts = options or Options()
188
+ pages_by_num: dict[int, ParsedPage] = {p.page_number: p for p in pages}
189
+
190
+ doc = pymupdf.open(str(input_pdf))
191
+ try:
192
+ font = pymupdf.Font(opts.font)
193
+ ocr_page_nums: list[int] = []
194
+ items_drawn = 0
195
+
196
+ for idx in range(doc.page_count):
197
+ pageno = idx + 1
198
+ parsed = pages_by_num.get(pageno)
199
+ if parsed is None or not parsed.boxes:
200
+ continue
201
+
202
+ page = doc[idx]
203
+ drawn_on_page = 0
204
+ for box in parsed.boxes:
205
+ if box.confidence < opts.min_confidence:
206
+ continue
207
+ if box.width <= 0 or box.height <= 0:
208
+ continue
209
+ if not box.text.strip():
210
+ continue
211
+ if _draw_box(page, font, box, opts):
212
+ drawn_on_page += 1
213
+
214
+ if drawn_on_page:
215
+ ocr_page_nums.append(pageno)
216
+ items_drawn += drawn_on_page
217
+
218
+ # Plain save keeps the input PDF's structure intact (image streams,
219
+ # fonts, annotations) and just appends the overlay objects.
220
+ doc.save(str(output_pdf))
221
+
222
+ return Stats(
223
+ pages=doc.page_count,
224
+ ocr_pages=ocr_page_nums,
225
+ items_drawn=items_drawn,
226
+ )
227
+ finally:
228
+ doc.close()
229
+
230
+
231
+ def make_searchable_pdf(
232
+ input_pdf: str | Path,
233
+ source: str | Path | dict[str, Any] | Iterable[ParsedPage],
234
+ output_pdf: str | Path,
235
+ *,
236
+ adapter: Adapter | str = "liteparse",
237
+ options: Options | None = None,
238
+ ) -> Stats:
239
+ """Generate a searchable PDF from a raw PDF plus an OCR JSON source.
240
+
241
+ This is the convenience entry point: it accepts a JSON path / dict /
242
+ pre-built iterable of :class:`ParsedPage`, runs it through the chosen
243
+ adapter, and writes the overlay.
244
+
245
+ Args:
246
+ input_pdf: Path to the source PDF (scanned, native, or mixed).
247
+ source: One of —
248
+
249
+ * a path (``str`` / ``Path``) to a JSON file,
250
+ * an already-loaded ``dict``,
251
+ * or an iterable of :class:`ParsedPage` (in which case
252
+ ``adapter`` is ignored).
253
+ output_pdf: Where to write the searchable PDF.
254
+ adapter: An :class:`~pdf_textlayer.adapters.Adapter` instance or
255
+ the name of a registered adapter. Defaults to ``"liteparse"``.
256
+ options: Drawing options.
257
+
258
+ Returns:
259
+ :class:`Stats` summarizing what was written.
260
+ """
261
+ if _looks_like_pages(source):
262
+ return write_textlayer(input_pdf, source, output_pdf, options=options) # type: ignore[arg-type]
263
+
264
+ from .adapters import get_adapter
265
+
266
+ adp = get_adapter(adapter) if isinstance(adapter, str) else adapter
267
+ data = source if isinstance(source, dict) else json.loads(Path(source).read_text()) # type: ignore[arg-type]
268
+ return write_textlayer(input_pdf, adp.parse(data), output_pdf, options=options)
269
+
270
+
271
+ def _looks_like_pages(source: Any) -> bool:
272
+ """True if ``source`` is already an iterable of ParsedPage."""
273
+ if isinstance(source, (str, Path, dict, bytes)):
274
+ return False
275
+ if isinstance(source, list):
276
+ return all(isinstance(p, ParsedPage) for p in source)
277
+ return False
File without changes