pdf-textlayer 0.1.0__tar.gz → 0.2.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.
- {pdf_textlayer-0.1.0 → pdf_textlayer-0.2.0}/PKG-INFO +16 -2
- {pdf_textlayer-0.1.0 → pdf_textlayer-0.2.0}/README.md +15 -1
- {pdf_textlayer-0.1.0 → pdf_textlayer-0.2.0}/pyproject.toml +1 -1
- {pdf_textlayer-0.1.0 → pdf_textlayer-0.2.0}/src/pdf_textlayer/core.py +81 -24
- {pdf_textlayer-0.1.0 → pdf_textlayer-0.2.0}/LICENSE +0 -0
- {pdf_textlayer-0.1.0 → pdf_textlayer-0.2.0}/src/pdf_textlayer/__init__.py +0 -0
- {pdf_textlayer-0.1.0 → pdf_textlayer-0.2.0}/src/pdf_textlayer/adapters.py +0 -0
- {pdf_textlayer-0.1.0 → pdf_textlayer-0.2.0}/src/pdf_textlayer/cli.py +0 -0
- {pdf_textlayer-0.1.0 → pdf_textlayer-0.2.0}/src/pdf_textlayer/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pdf-textlayer
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Add an invisible, searchable text layer to a PDF from OCR JSON (PyMuPDF).
|
|
5
5
|
Keywords: pdf,ocr,searchable,textlayer,pymupdf,liteparse,accessibility
|
|
6
6
|
Author: Vivek Patel
|
|
@@ -75,7 +75,7 @@ from pdf_textlayer import make_searchable_pdf, Options
|
|
|
75
75
|
|
|
76
76
|
stats = make_searchable_pdf(
|
|
77
77
|
"input.pdf",
|
|
78
|
-
"parsed.json", # path, or
|
|
78
|
+
"parsed.json", # path, dict, raw JSON bytes, or ParsedPage[]
|
|
79
79
|
"output.pdf",
|
|
80
80
|
adapter="liteparse", # default
|
|
81
81
|
options=Options(min_confidence=0.5, font="helv"),
|
|
@@ -84,6 +84,20 @@ print(stats)
|
|
|
84
84
|
# {'pages': 19, 'ocr_pages': [1, 2, ..., 19], 'items_drawn': 7643}
|
|
85
85
|
```
|
|
86
86
|
|
|
87
|
+
### Bytes in, bytes out (server / API use)
|
|
88
|
+
|
|
89
|
+
Both the input PDF and the result can be raw `bytes`, so no temp files are needed:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
# In a FastAPI/Flask handler — uploaded_pdf is bytes, ocr is a parsed dict
|
|
93
|
+
pdf_bytes, stats = make_searchable_pdf(uploaded_pdf, ocr)
|
|
94
|
+
return Response(pdf_bytes, media_type="application/pdf")
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
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`.
|
|
98
|
+
|
|
99
|
+
### Working from ParsedPage objects directly
|
|
100
|
+
|
|
87
101
|
If you already have the OCR results in memory (e.g. from another library), build `ParsedPage` objects directly and skip the adapter:
|
|
88
102
|
|
|
89
103
|
```python
|
|
@@ -50,7 +50,7 @@ from pdf_textlayer import make_searchable_pdf, Options
|
|
|
50
50
|
|
|
51
51
|
stats = make_searchable_pdf(
|
|
52
52
|
"input.pdf",
|
|
53
|
-
"parsed.json", # path, or
|
|
53
|
+
"parsed.json", # path, dict, raw JSON bytes, or ParsedPage[]
|
|
54
54
|
"output.pdf",
|
|
55
55
|
adapter="liteparse", # default
|
|
56
56
|
options=Options(min_confidence=0.5, font="helv"),
|
|
@@ -59,6 +59,20 @@ print(stats)
|
|
|
59
59
|
# {'pages': 19, 'ocr_pages': [1, 2, ..., 19], 'items_drawn': 7643}
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
+
### Bytes in, bytes out (server / API use)
|
|
63
|
+
|
|
64
|
+
Both the input PDF and the result can be raw `bytes`, so no temp files are needed:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
# In a FastAPI/Flask handler — uploaded_pdf is bytes, ocr is a parsed dict
|
|
68
|
+
pdf_bytes, stats = make_searchable_pdf(uploaded_pdf, ocr)
|
|
69
|
+
return Response(pdf_bytes, media_type="application/pdf")
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
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`.
|
|
73
|
+
|
|
74
|
+
### Working from ParsedPage objects directly
|
|
75
|
+
|
|
62
76
|
If you already have the OCR results in memory (e.g. from another library), build `ParsedPage` objects directly and skip the adapter:
|
|
63
77
|
|
|
64
78
|
```python
|
|
@@ -20,7 +20,7 @@ import logging
|
|
|
20
20
|
from collections.abc import Iterable
|
|
21
21
|
from dataclasses import dataclass, field
|
|
22
22
|
from pathlib import Path
|
|
23
|
-
from typing import TYPE_CHECKING, Any, TypedDict
|
|
23
|
+
from typing import TYPE_CHECKING, Any, TypedDict, overload
|
|
24
24
|
|
|
25
25
|
import pymupdf
|
|
26
26
|
|
|
@@ -158,14 +158,37 @@ def _draw_box(
|
|
|
158
158
|
return True
|
|
159
159
|
|
|
160
160
|
|
|
161
|
+
def _open_pdf(input_pdf: str | Path | bytes) -> pymupdf.Document:
|
|
162
|
+
"""Open a PDF from a path or raw bytes."""
|
|
163
|
+
if isinstance(input_pdf, bytes):
|
|
164
|
+
return pymupdf.open(stream=input_pdf, filetype="pdf")
|
|
165
|
+
return pymupdf.open(str(input_pdf))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@overload
|
|
161
169
|
def write_textlayer(
|
|
162
|
-
input_pdf: str | Path,
|
|
170
|
+
input_pdf: str | Path | bytes,
|
|
163
171
|
pages: Iterable[ParsedPage],
|
|
164
172
|
output_pdf: str | Path,
|
|
165
173
|
*,
|
|
174
|
+
options: Options | None = ...,
|
|
175
|
+
) -> Stats: ...
|
|
176
|
+
@overload
|
|
177
|
+
def write_textlayer(
|
|
178
|
+
input_pdf: str | Path | bytes,
|
|
179
|
+
pages: Iterable[ParsedPage],
|
|
180
|
+
output_pdf: None = ...,
|
|
181
|
+
*,
|
|
182
|
+
options: Options | None = ...,
|
|
183
|
+
) -> tuple[bytes, Stats]: ...
|
|
184
|
+
def write_textlayer(
|
|
185
|
+
input_pdf: str | Path | bytes,
|
|
186
|
+
pages: Iterable[ParsedPage],
|
|
187
|
+
output_pdf: str | Path | None = None,
|
|
188
|
+
*,
|
|
166
189
|
options: Options | None = None,
|
|
167
|
-
) -> Stats:
|
|
168
|
-
"""Draw an invisible text layer onto ``input_pdf
|
|
190
|
+
) -> Stats | tuple[bytes, Stats]:
|
|
191
|
+
"""Draw an invisible text layer onto ``input_pdf``.
|
|
169
192
|
|
|
170
193
|
This is the low-level entry point: it takes already-normalized
|
|
171
194
|
:class:`ParsedPage` objects, so any OCR source can be plugged in by
|
|
@@ -176,18 +199,20 @@ def write_textlayer(
|
|
|
176
199
|
contribute no drawable boxes after filtering — are left untouched.
|
|
177
200
|
|
|
178
201
|
Args:
|
|
179
|
-
input_pdf: Path to the source PDF.
|
|
202
|
+
input_pdf: Path to the source PDF, or raw PDF bytes.
|
|
180
203
|
pages: Iterable of :class:`ParsedPage`. Order does not matter.
|
|
181
|
-
output_pdf: Where to write the result.
|
|
204
|
+
output_pdf: Where to write the result. If ``None`` (default), the
|
|
205
|
+
PDF bytes are returned instead of written to disk.
|
|
182
206
|
options: Drawing options.
|
|
183
207
|
|
|
184
208
|
Returns:
|
|
185
|
-
:class:`Stats` summarizing what was
|
|
209
|
+
If ``output_pdf`` is a path, :class:`Stats` summarizing what was
|
|
210
|
+
written. If ``output_pdf`` is ``None``, a tuple ``(pdf_bytes, stats)``.
|
|
186
211
|
"""
|
|
187
212
|
opts = options or Options()
|
|
188
213
|
pages_by_num: dict[int, ParsedPage] = {p.page_number: p for p in pages}
|
|
189
214
|
|
|
190
|
-
doc =
|
|
215
|
+
doc = _open_pdf(input_pdf)
|
|
191
216
|
try:
|
|
192
217
|
font = pymupdf.Font(opts.font)
|
|
193
218
|
ocr_page_nums: list[int] = []
|
|
@@ -215,57 +240,89 @@ def write_textlayer(
|
|
|
215
240
|
ocr_page_nums.append(pageno)
|
|
216
241
|
items_drawn += drawn_on_page
|
|
217
242
|
|
|
218
|
-
|
|
219
|
-
# fonts, annotations) and just appends the overlay objects.
|
|
220
|
-
doc.save(str(output_pdf))
|
|
221
|
-
|
|
222
|
-
return Stats(
|
|
243
|
+
stats = Stats(
|
|
223
244
|
pages=doc.page_count,
|
|
224
245
|
ocr_pages=ocr_page_nums,
|
|
225
246
|
items_drawn=items_drawn,
|
|
226
247
|
)
|
|
248
|
+
|
|
249
|
+
# Plain save keeps the input PDF's structure intact (image streams,
|
|
250
|
+
# fonts, annotations) and just appends the overlay objects.
|
|
251
|
+
if output_pdf is None:
|
|
252
|
+
return doc.tobytes(), stats
|
|
253
|
+
doc.save(str(output_pdf))
|
|
254
|
+
return stats
|
|
227
255
|
finally:
|
|
228
256
|
doc.close()
|
|
229
257
|
|
|
230
258
|
|
|
259
|
+
@overload
|
|
231
260
|
def make_searchable_pdf(
|
|
232
|
-
input_pdf: str | Path,
|
|
233
|
-
source: str | Path | dict[str, Any] | Iterable[ParsedPage],
|
|
261
|
+
input_pdf: str | Path | bytes,
|
|
262
|
+
source: str | Path | dict[str, Any] | bytes | Iterable[ParsedPage],
|
|
234
263
|
output_pdf: str | Path,
|
|
235
264
|
*,
|
|
265
|
+
adapter: Adapter | str = ...,
|
|
266
|
+
options: Options | None = ...,
|
|
267
|
+
) -> Stats: ...
|
|
268
|
+
@overload
|
|
269
|
+
def make_searchable_pdf(
|
|
270
|
+
input_pdf: str | Path | bytes,
|
|
271
|
+
source: str | Path | dict[str, Any] | bytes | Iterable[ParsedPage],
|
|
272
|
+
output_pdf: None = ...,
|
|
273
|
+
*,
|
|
274
|
+
adapter: Adapter | str = ...,
|
|
275
|
+
options: Options | None = ...,
|
|
276
|
+
) -> tuple[bytes, Stats]: ...
|
|
277
|
+
def make_searchable_pdf(
|
|
278
|
+
input_pdf: str | Path | bytes,
|
|
279
|
+
source: str | Path | dict[str, Any] | bytes | Iterable[ParsedPage],
|
|
280
|
+
output_pdf: str | Path | None = None,
|
|
281
|
+
*,
|
|
236
282
|
adapter: Adapter | str = "liteparse",
|
|
237
283
|
options: Options | None = None,
|
|
238
|
-
) -> Stats:
|
|
284
|
+
) -> Stats | tuple[bytes, Stats]:
|
|
239
285
|
"""Generate a searchable PDF from a raw PDF plus an OCR JSON source.
|
|
240
286
|
|
|
241
287
|
This is the convenience entry point: it accepts a JSON path / dict /
|
|
242
|
-
pre-built iterable of :class:`ParsedPage`, runs it through
|
|
243
|
-
adapter, and writes the overlay.
|
|
288
|
+
raw bytes / pre-built iterable of :class:`ParsedPage`, runs it through
|
|
289
|
+
the chosen adapter, and writes the overlay.
|
|
244
290
|
|
|
245
291
|
Args:
|
|
246
|
-
input_pdf: Path to the source PDF
|
|
292
|
+
input_pdf: Path to the source PDF, or raw PDF bytes.
|
|
247
293
|
source: One of —
|
|
248
294
|
|
|
249
295
|
* a path (``str`` / ``Path``) to a JSON file,
|
|
250
296
|
* an already-loaded ``dict``,
|
|
297
|
+
* raw JSON ``bytes``,
|
|
251
298
|
* or an iterable of :class:`ParsedPage` (in which case
|
|
252
299
|
``adapter`` is ignored).
|
|
253
|
-
output_pdf: Where to write the
|
|
300
|
+
output_pdf: Where to write the result. If ``None`` (default), the
|
|
301
|
+
PDF bytes are returned instead of written to disk.
|
|
254
302
|
adapter: An :class:`~pdf_textlayer.adapters.Adapter` instance or
|
|
255
303
|
the name of a registered adapter. Defaults to ``"liteparse"``.
|
|
256
304
|
options: Drawing options.
|
|
257
305
|
|
|
258
306
|
Returns:
|
|
259
|
-
:class:`Stats
|
|
307
|
+
If ``output_pdf`` is a path, :class:`Stats`. If ``output_pdf`` is
|
|
308
|
+
``None``, a tuple ``(pdf_bytes, stats)``.
|
|
260
309
|
"""
|
|
261
310
|
if _looks_like_pages(source):
|
|
262
|
-
return write_textlayer(input_pdf, source, output_pdf, options=options) # type: ignore[arg-type]
|
|
311
|
+
return write_textlayer(input_pdf, source, output_pdf, options=options) # type: ignore[arg-type,call-overload]
|
|
263
312
|
|
|
264
313
|
from .adapters import get_adapter
|
|
265
314
|
|
|
266
315
|
adp = get_adapter(adapter) if isinstance(adapter, str) else adapter
|
|
267
|
-
|
|
268
|
-
|
|
316
|
+
|
|
317
|
+
if isinstance(source, dict):
|
|
318
|
+
data: dict[str, Any] = source
|
|
319
|
+
elif isinstance(source, bytes):
|
|
320
|
+
data = json.loads(source)
|
|
321
|
+
else:
|
|
322
|
+
# str | Path → filesystem path containing JSON.
|
|
323
|
+
data = json.loads(Path(source).read_text()) # type: ignore[arg-type]
|
|
324
|
+
|
|
325
|
+
return write_textlayer(input_pdf, adp.parse(data), output_pdf, options=options) # type: ignore[call-overload]
|
|
269
326
|
|
|
270
327
|
|
|
271
328
|
def _looks_like_pages(source: Any) -> bool:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|