citegraph 0.2.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.
@@ -0,0 +1,394 @@
1
+ """PDF -> markdown conversion using docling, with idempotent caching."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import tempfile
8
+ from collections import Counter
9
+ from pathlib import Path
10
+ from typing import Literal
11
+
12
+ from citegraph.io import OutLayout, cache_input_fingerprint, read_json, write_json
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # ``ocr`` flag accepted by :func:`convert_directory` and the ``Pipeline``:
17
+ # ``False`` = never OCR, ``True`` = force OCR everywhere, ``"auto"`` = try
18
+ # without OCR first and only re-run the image-only outputs with OCR.
19
+ OCRMode = bool | Literal["auto"]
20
+
21
+
22
+ def list_pdfs(pdf_dir: Path | str, *, recursive: bool = False) -> list[Path]:
23
+ """Return all ``*.pdf`` files (case-insensitive) under ``pdf_dir``.
24
+
25
+ With ``recursive=False`` (default), looks at ``pdf_dir`` only — the
26
+ historical behaviour. With ``recursive=True``, walks the whole tree
27
+ but skips dotted directories (``.git``, ``.venv``, etc.) so that
28
+ pointing at a project root doesn't drag in third-party PDFs.
29
+ """
30
+ pdf_dir = Path(pdf_dir)
31
+ if not pdf_dir.is_dir():
32
+ raise NotADirectoryError(f"PDF directory not found: {pdf_dir}")
33
+
34
+ if not recursive:
35
+ return sorted(p for p in pdf_dir.iterdir() if p.suffix.lower() == ".pdf")
36
+
37
+ found: list[Path] = []
38
+ for path in pdf_dir.rglob("*"):
39
+ if path.suffix.lower() != ".pdf" or not path.is_file():
40
+ continue
41
+ rel_parts = path.relative_to(pdf_dir).parts
42
+ if any(part.startswith(".") for part in rel_parts[:-1]):
43
+ continue
44
+ found.append(path)
45
+ return sorted(found)
46
+
47
+
48
+ def cache_stem_for(pdf_path: Path | str, pdf_dir: Path | str) -> str:
49
+ """Cache filename stem for ``pdf_path`` relative to ``pdf_dir``.
50
+
51
+ Flat layouts produce the historical ``pdf.stem`` (so existing caches
52
+ stay valid). Nested layouts encode the relative path — e.g.
53
+ ``journal_X/foo.pdf`` becomes ``journal_X__foo`` — to keep two PDFs
54
+ with the same filename in different subdirectories from clobbering
55
+ each other's cache files.
56
+ """
57
+ pdf_path = Path(pdf_path)
58
+ pdf_dir = Path(pdf_dir)
59
+ try:
60
+ rel = pdf_path.relative_to(pdf_dir)
61
+ except ValueError:
62
+ return pdf_path.stem
63
+ parts = list(rel.with_suffix("").parts)
64
+ # Strip filesystem separators that survived (defensive — Path.parts
65
+ # already splits them) and join with a double underscore.
66
+ sanitized = ["".join(c if c not in "/\\:" else "_" for c in p) for p in parts]
67
+ return "__".join(sanitized)
68
+
69
+
70
+ def _detect_stem_collisions(pdfs: list[Path], pdf_dir: Path) -> None:
71
+ """Raise if ``cache_stem_for`` would collide for two distinct PDFs."""
72
+ stems = [cache_stem_for(p, pdf_dir) for p in pdfs]
73
+ dupes = sorted(s for s, n in Counter(stems).items() if n > 1)
74
+ if not dupes:
75
+ return
76
+ examples = []
77
+ for d in dupes:
78
+ offenders = [
79
+ str(p.relative_to(pdf_dir)) for p, s in zip(pdfs, stems, strict=True) if s == d
80
+ ]
81
+ examples.append(f" {d!r} <- {offenders}")
82
+ raise ValueError(
83
+ "Cache-key collisions in recursive scan:\n"
84
+ + "\n".join(examples)
85
+ + "\nRename one of the conflicting PDFs to disambiguate."
86
+ )
87
+
88
+
89
+ def _is_image_only(path: Path, min_text_chars: int = 200) -> bool:
90
+ """Return True if the markdown appears to contain only images with no real text.
91
+
92
+ Scanned PDFs converted by docling without OCR produce files that are mostly
93
+ ``<!-- image -->`` tags. This heuristic strips those tags and markdown
94
+ headers to check if any substantive text survives.
95
+ """
96
+ text = path.read_text(encoding="utf-8")
97
+ stripped = text.replace("<!-- image -->", "")
98
+ lines = [ln for ln in stripped.splitlines() if not ln.lstrip().startswith("#")]
99
+ return len("".join(lines).strip()) < min_text_chars
100
+
101
+
102
+ _GLYPH_PLACEHOLDER = "glyph<UNKNOWN>"
103
+
104
+
105
+ def _conversion_entries(markdown_dir: Path) -> tuple[dict[str, dict], bool]:
106
+ path = OutLayout(markdown_dir.parent).conversion_cache_json
107
+ if not path.exists():
108
+ return {}, True
109
+ try:
110
+ data = read_json(path)
111
+ except (OSError, UnicodeError, ValueError):
112
+ return {}, False
113
+ if not isinstance(data, dict) or data.get("version") != 1:
114
+ return {}, False
115
+ entries = data.get("entries") if isinstance(data, dict) else None
116
+ return (entries, True) if isinstance(entries, dict) else ({}, False)
117
+
118
+
119
+ def _write_conversion_entry(
120
+ markdown_dir: Path, pdf_path: Path, out_path: Path, *, ocr: bool
121
+ ) -> None:
122
+ layout = OutLayout(markdown_dir.parent)
123
+ entries, _ = _conversion_entries(markdown_dir)
124
+ entries[out_path.name] = {
125
+ "input_sha256": cache_input_fingerprint(pdf_path),
126
+ "output_sha256": cache_input_fingerprint(out_path),
127
+ "ocr": ocr,
128
+ }
129
+ write_json(layout.conversion_cache_json, {"version": 1, "entries": entries})
130
+
131
+
132
+ def _valid_conversion(
133
+ entry: object, pdf_hash: str, markdown_path: Path, *, ocr: bool
134
+ ) -> bool:
135
+ return (
136
+ isinstance(entry, dict)
137
+ and entry.get("input_sha256") == pdf_hash
138
+ and (not ocr or entry.get("ocr") is True)
139
+ and markdown_path.exists()
140
+ and entry.get("output_sha256") == cache_input_fingerprint(markdown_path)
141
+ )
142
+
143
+
144
+ def _copy_atomic(source: Path, target: Path) -> None:
145
+ fd, temporary = tempfile.mkstemp(dir=target.parent, prefix=f".{target.name}.")
146
+ try:
147
+ with os.fdopen(fd, "wb") as stream:
148
+ stream.write(source.read_bytes())
149
+ os.replace(temporary, target)
150
+ finally:
151
+ if os.path.exists(temporary):
152
+ os.unlink(temporary)
153
+
154
+
155
+ def _ocr_already_attempted(pdf_path: Path, markdown_path: Path) -> bool:
156
+ entries, _ = _conversion_entries(markdown_path.parent)
157
+ entry = entries.get(markdown_path.name)
158
+ return _valid_conversion(
159
+ entry,
160
+ cache_input_fingerprint(pdf_path),
161
+ markdown_path,
162
+ ocr=True,
163
+ )
164
+
165
+
166
+ def _has_unmappable_glyphs(
167
+ path: Path, min_glyphs: int = 20, min_rate: float = 0.01
168
+ ) -> bool:
169
+ """Return True if the conversion lost a meaningful share of its characters.
170
+
171
+ A PDF whose embedded font carries no ToUnicode map converts to one
172
+ ``glyph<UNKNOWN>`` per unmappable character. The output is *long*, so
173
+ :func:`_is_image_only` — which looks for too few characters — never fires,
174
+ and the paper goes on to yield empty metadata with no warning anywhere.
175
+
176
+ Corruption is often concentrated (a title page in a different font can be
177
+ fully unmappable while the body reads fine), so the rate is deliberately
178
+ low. ``min_glyphs`` keeps a handful of stray math symbols from crying wolf.
179
+ """
180
+ text = path.read_text(encoding="utf-8")
181
+ n_glyphs = text.count(_GLYPH_PLACEHOLDER)
182
+ if n_glyphs < min_glyphs:
183
+ return False
184
+ mapped = sum(ch.isalnum() for ch in text.replace(_GLYPH_PLACEHOLDER, ""))
185
+ return n_glyphs / (n_glyphs + mapped) >= min_rate
186
+
187
+
188
+ def convert_pdf_to_markdown(
189
+ pdf_path: Path | str,
190
+ markdown_dir: Path | str,
191
+ *,
192
+ overwrite: bool = False,
193
+ cache_stem: str | None = None,
194
+ ocr: bool = False,
195
+ ) -> Path:
196
+ """Convert a single PDF to markdown and write it to ``markdown_dir``.
197
+
198
+ With ``overwrite=False``, a recorded conversion is reused only while its
199
+ PDF, markdown output, and OCR setting still match. A content-identical PDF
200
+ can also reuse a recorded sibling after a rename. Existing stem caches
201
+ without provenance retain the historical reuse behavior.
202
+
203
+ ``cache_stem`` overrides the output filename stem; without it the PDF's
204
+ own ``stem`` is used. Pass an explicit stem (e.g. one produced by
205
+ :func:`cache_stem_for`) to disambiguate same-named PDFs in different
206
+ subdirectories.
207
+
208
+ With ``ocr=True``, configures docling to force full-page OCR via EasyOCR,
209
+ which is needed for scanned PDFs where each page is a bitmap image.
210
+ """
211
+ pdf_path = Path(pdf_path)
212
+ markdown_dir = Path(markdown_dir)
213
+ markdown_dir.mkdir(parents=True, exist_ok=True)
214
+
215
+ stem = cache_stem if cache_stem is not None else pdf_path.stem
216
+ out_path = markdown_dir / f"{stem}.md"
217
+ if not overwrite:
218
+ entries, allow_legacy = _conversion_entries(markdown_dir)
219
+ pdf_hash = cache_input_fingerprint(pdf_path)
220
+ entry = entries.get(out_path.name)
221
+ if _valid_conversion(entry, pdf_hash, out_path, ocr=ocr):
222
+ logger.debug("Skipping %s (cached at %s)", pdf_path.name, out_path)
223
+ return out_path
224
+ if out_path.exists() and entry is None and allow_legacy and not ocr:
225
+ _write_conversion_entry(markdown_dir, pdf_path, out_path, ocr=ocr)
226
+ logger.debug("Skipping %s (legacy cache at %s)", pdf_path.name, out_path)
227
+ return out_path
228
+ for cached_name, cached_entry in sorted(entries.items()):
229
+ cached_path = markdown_dir / cached_name
230
+ if (
231
+ cached_path.name == cached_name
232
+ and cached_path.suffix == ".md"
233
+ and _valid_conversion(cached_entry, pdf_hash, cached_path, ocr=ocr)
234
+ ):
235
+ _copy_atomic(cached_path, out_path)
236
+ _write_conversion_entry(
237
+ markdown_dir,
238
+ pdf_path,
239
+ out_path,
240
+ ocr=bool(cached_entry.get("ocr")),
241
+ )
242
+ logger.debug("Reusing %s by content at %s", pdf_path.name, out_path)
243
+ return out_path
244
+
245
+ logger.info("Converting %s -> markdown%s", pdf_path.name, " (OCR)" if ocr else "")
246
+ # Imported lazily so ``import citegraph`` stays cheap and so docling is
247
+ # only required when this function is actually called.
248
+ from docling.document_converter import DocumentConverter
249
+
250
+ if ocr:
251
+ from docling.datamodel.base_models import InputFormat
252
+ from docling.datamodel.pipeline_options import EasyOcrOptions, PdfPipelineOptions
253
+ from docling.document_converter import PdfFormatOption
254
+
255
+ pipeline_options = PdfPipelineOptions(
256
+ do_ocr=True,
257
+ ocr_options=EasyOcrOptions(force_full_page_ocr=True),
258
+ )
259
+ converter = DocumentConverter(
260
+ format_options={InputFormat.PDF: PdfFormatOption(pipeline_options=pipeline_options)}
261
+ )
262
+ else:
263
+ converter = DocumentConverter()
264
+
265
+ result = converter.convert(str(pdf_path))
266
+ fd, temporary = tempfile.mkstemp(dir=markdown_dir, prefix=f".{out_path.name}.")
267
+ try:
268
+ with os.fdopen(fd, "w", encoding="utf-8") as stream:
269
+ stream.write(result.document.export_to_markdown())
270
+ os.replace(temporary, out_path)
271
+ finally:
272
+ if os.path.exists(temporary):
273
+ os.unlink(temporary)
274
+ _write_conversion_entry(markdown_dir, pdf_path, out_path, ocr=ocr)
275
+ return out_path
276
+
277
+
278
+ def _convert_loop(
279
+ pdfs: list[Path],
280
+ pdf_dir: Path,
281
+ markdown_dir: Path | str,
282
+ *,
283
+ overwrite: bool,
284
+ recursive: bool,
285
+ show_progress: bool,
286
+ ocr: bool,
287
+ description: str = "Converting PDFs",
288
+ ) -> list[Path]:
289
+ """Inner per-PDF loop shared by the single-pass and two-pass paths."""
290
+ from citegraph._progress import iter_with_progress
291
+
292
+ out_paths: list[Path] = []
293
+ for pdf in iter_with_progress(
294
+ pdfs,
295
+ show_progress=show_progress,
296
+ description=description,
297
+ item_label=lambda p: p.name,
298
+ ):
299
+ stem = cache_stem_for(pdf, pdf_dir) if recursive else None
300
+ out_paths.append(
301
+ convert_pdf_to_markdown(
302
+ pdf, markdown_dir, overwrite=overwrite, cache_stem=stem, ocr=ocr
303
+ )
304
+ )
305
+ return out_paths
306
+
307
+
308
+ def convert_directory(
309
+ pdf_dir: Path | str,
310
+ markdown_dir: Path | str,
311
+ *,
312
+ overwrite: bool = False,
313
+ recursive: bool = False,
314
+ show_progress: bool = True,
315
+ ocr: OCRMode = False,
316
+ ) -> list[Path]:
317
+ """Convert every PDF under ``pdf_dir`` to markdown.
318
+
319
+ With ``recursive=True``, walks subdirectories and uses path-aware cache
320
+ stems via :func:`cache_stem_for`. Raises ``ValueError`` upfront if two
321
+ distinct PDFs would map to the same cache stem — better to fail loudly
322
+ than to silently overwrite each other's caches mid-run.
323
+
324
+ With ``show_progress=True`` (default) a rich progress bar is rendered
325
+ while the per-PDF loop runs. Pass ``False`` for headless runs / tests
326
+ where the extra stderr output is unwanted.
327
+
328
+ ``ocr`` controls how OCR is applied (see :data:`OCRMode`):
329
+
330
+ * ``False`` — never OCR.
331
+ * ``True`` — force full-page OCR via EasyOCR for every PDF.
332
+ * ``"auto"`` — two-pass: convert everything without OCR first, run the
333
+ :func:`_is_image_only` and :func:`_has_unmappable_glyphs` heuristics
334
+ on each output, then re-run only the flagged PDFs with OCR (their
335
+ stub markdown is atomically replaced only after a successful retry).
336
+ OCR is the remedy for both failure modes:
337
+ a scanned page with no text layer, and a page whose font carries no
338
+ ToUnicode map.
339
+
340
+ Returns the list of written markdown files in stable (sorted) order.
341
+ """
342
+ pdf_dir = Path(pdf_dir)
343
+ markdown_dir = Path(markdown_dir)
344
+ pdfs = list_pdfs(pdf_dir, recursive=recursive)
345
+ logger.info(
346
+ "Found %d PDFs under %s (recursive=%s, ocr=%r)", len(pdfs), pdf_dir, recursive, ocr
347
+ )
348
+
349
+ if recursive:
350
+ _detect_stem_collisions(pdfs, pdf_dir)
351
+
352
+ if ocr == "auto":
353
+ # Pass 1: try without OCR — cheap on text PDFs, fast on cached ones.
354
+ out_paths = _convert_loop(
355
+ pdfs,
356
+ pdf_dir,
357
+ markdown_dir,
358
+ overwrite=overwrite,
359
+ recursive=recursive,
360
+ show_progress=show_progress,
361
+ ocr=False,
362
+ )
363
+ retry_pairs = [
364
+ (pdf, md)
365
+ for pdf, md in zip(pdfs, out_paths, strict=True)
366
+ if (_is_image_only(md) or _has_unmappable_glyphs(md))
367
+ and not _ocr_already_attempted(pdf, md)
368
+ ]
369
+ if retry_pairs:
370
+ logger.info(
371
+ "Re-running %d poorly-converted PDF(s) with OCR (auto fallback)",
372
+ len(retry_pairs),
373
+ )
374
+ _convert_loop(
375
+ [pdf for pdf, _ in retry_pairs],
376
+ pdf_dir,
377
+ markdown_dir,
378
+ overwrite=True,
379
+ recursive=recursive,
380
+ show_progress=show_progress,
381
+ ocr=True,
382
+ description=f"Re-converting {len(retry_pairs)} with OCR",
383
+ )
384
+ return out_paths
385
+
386
+ return _convert_loop(
387
+ pdfs,
388
+ pdf_dir,
389
+ markdown_dir,
390
+ overwrite=overwrite,
391
+ recursive=recursive,
392
+ show_progress=show_progress,
393
+ ocr=bool(ocr),
394
+ )