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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pdf-textlayer
3
- Version: 0.1.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 already-loaded dict
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 already-loaded dict
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pdf-textlayer"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Add an invisible, searchable text layer to a PDF from OCR JSON (PyMuPDF)."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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`` and save it to ``output_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. Overwritten if it exists.
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 written.
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 = pymupdf.open(str(input_pdf))
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
- # 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(
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 the chosen
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 (scanned, native, or mixed).
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 searchable PDF.
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` summarizing what was written.
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
- 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)
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