pdf-textlayer 0.1.0__py3-none-any.whl
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.
- pdf_textlayer/__init__.py +28 -0
- pdf_textlayer/adapters.py +131 -0
- pdf_textlayer/cli.py +89 -0
- pdf_textlayer/core.py +277 -0
- pdf_textlayer/py.typed +0 -0
- pdf_textlayer-0.1.0.dist-info/METADATA +170 -0
- pdf_textlayer-0.1.0.dist-info/RECORD +10 -0
- pdf_textlayer-0.1.0.dist-info/WHEEL +4 -0
- pdf_textlayer-0.1.0.dist-info/entry_points.txt +3 -0
- pdf_textlayer-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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)
|
pdf_textlayer/cli.py
ADDED
|
@@ -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())
|
pdf_textlayer/core.py
ADDED
|
@@ -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
|
pdf_textlayer/py.typed
ADDED
|
File without changes
|
|
@@ -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,10 @@
|
|
|
1
|
+
pdf_textlayer/__init__.py,sha256=EAdB3ZBACtkQ774bplqWMlvC3q8mxyTDMMXQPnJS4zI,549
|
|
2
|
+
pdf_textlayer/adapters.py,sha256=mNu2lopXuBFmLEvyLr4_nR0ncSmZdIIe4j9FvhjZ3Fg,3949
|
|
3
|
+
pdf_textlayer/cli.py,sha256=iYTbhc_BC2sCwURUkKn1oLA2ZnlQcvBdPMArAWIzdLM,2480
|
|
4
|
+
pdf_textlayer/core.py,sha256=02Rf9lpfJVT3QH6UOyIF-ph-9DzsQAorSqCMAW9SLzs,8998
|
|
5
|
+
pdf_textlayer/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
pdf_textlayer-0.1.0.dist-info/licenses/LICENSE,sha256=H-6ZFoWB0VJZ8l9G0bs0thKChjrMJ5QYBDgigtWv9Q0,1068
|
|
7
|
+
pdf_textlayer-0.1.0.dist-info/WHEEL,sha256=ZyFSCYkV2BrxH6-HRVRg3R9Fo7MALzer9KiPYqNxSbo,79
|
|
8
|
+
pdf_textlayer-0.1.0.dist-info/entry_points.txt,sha256=htPCsQDcgqeI93WKJfmolhDGCxeCdiU9fjTJU38by0w,54
|
|
9
|
+
pdf_textlayer-0.1.0.dist-info/METADATA,sha256=puTGnGez85C0USEtNwWBCjtIWT9vfZPZAK0N8uPBTfA,7254
|
|
10
|
+
pdf_textlayer-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|