fetchpdf 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.
Files changed (64) hide show
  1. fetchpdf/__init__.py +49 -0
  2. fetchpdf/__main__.py +6 -0
  3. fetchpdf/_env.py +63 -0
  4. fetchpdf/_http.py +40 -0
  5. fetchpdf/fetch_abstract_from_doi.py +549 -0
  6. fetchpdf/fetch_metadata_from_doi.py +225 -0
  7. fetchpdf/fetchpdf.py +7339 -0
  8. fetchpdf/retrieval/__init__.py +36 -0
  9. fetchpdf/retrieval/_util.py +150 -0
  10. fetchpdf/retrieval/artifact.py +87 -0
  11. fetchpdf/retrieval/backends/__init__.py +109 -0
  12. fetchpdf/retrieval/backends/claude_cli.py +111 -0
  13. fetchpdf/retrieval/backends/openrouter.py +244 -0
  14. fetchpdf/retrieval/cache.py +123 -0
  15. fetchpdf/retrieval/chunk.py +195 -0
  16. fetchpdf/retrieval/classify.py +110 -0
  17. fetchpdf/retrieval/context.py +69 -0
  18. fetchpdf/retrieval/corresponding.py +349 -0
  19. fetchpdf/retrieval/engine.py +666 -0
  20. fetchpdf/retrieval/extract_images.py +593 -0
  21. fetchpdf/retrieval/fulltext_scan.py +472 -0
  22. fetchpdf/retrieval/goals.py +145 -0
  23. fetchpdf/retrieval/http.py +669 -0
  24. fetchpdf/retrieval/identifiers.py +149 -0
  25. fetchpdf/retrieval/ladder.json +362 -0
  26. fetchpdf/retrieval/linked_artifacts.py +144 -0
  27. fetchpdf/retrieval/llm_adjudicate.py +278 -0
  28. fetchpdf/retrieval/llm_agent_retrieval.py +495 -0
  29. fetchpdf/retrieval/normalize.py +301 -0
  30. fetchpdf/retrieval/pdf_identity.py +533 -0
  31. fetchpdf/retrieval/pdf_text.py +242 -0
  32. fetchpdf/retrieval/provenance.py +185 -0
  33. fetchpdf/retrieval/ratelimit.py +113 -0
  34. fetchpdf/retrieval/repository_waf.py +152 -0
  35. fetchpdf/retrieval/request_drafts.py +304 -0
  36. fetchpdf/retrieval/resolve.py +481 -0
  37. fetchpdf/retrieval/sources/__init__.py +15 -0
  38. fetchpdf/retrieval/sources/arxiv.py +102 -0
  39. fetchpdf/retrieval/sources/core_text.py +64 -0
  40. fetchpdf/retrieval/sources/crossref_tdm.py +193 -0
  41. fetchpdf/retrieval/sources/epmc.py +133 -0
  42. fetchpdf/retrieval/sources/landing.py +34 -0
  43. fetchpdf/retrieval/sources/legacy_pdf.py +99 -0
  44. fetchpdf/retrieval/sources/pmc.py +59 -0
  45. fetchpdf/retrieval/sources/preprint.py +101 -0
  46. fetchpdf/retrieval/sources/publisher_html.py +154 -0
  47. fetchpdf/retrieval/sources/supplements.py +183 -0
  48. fetchpdf/retrieval/supplement_atypon.py +269 -0
  49. fetchpdf/retrieval/supplement_dspace.py +187 -0
  50. fetchpdf/retrieval/supplement_graph.py +842 -0
  51. fetchpdf/retrieval/supplement_index.py +820 -0
  52. fetchpdf/retrieval/supplement_pmc.py +351 -0
  53. fetchpdf/retrieval/supplement_publishers.py +374 -0
  54. fetchpdf/retrieval/supplementary.py +1770 -0
  55. fetchpdf/retrieval/tiers.py +213 -0
  56. fetchpdf/retrieval/to_markdown.py +661 -0
  57. fetchpdf/retrieval/validate.py +368 -0
  58. fetchpdf/retrieval/verify.py +149 -0
  59. fetchpdf-0.1.0.dist-info/METADATA +1392 -0
  60. fetchpdf-0.1.0.dist-info/RECORD +64 -0
  61. fetchpdf-0.1.0.dist-info/WHEEL +5 -0
  62. fetchpdf-0.1.0.dist-info/entry_points.txt +5 -0
  63. fetchpdf-0.1.0.dist-info/licenses/LICENSE +21 -0
  64. fetchpdf-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,593 @@
1
+ """Embedded-image extraction: the authors' original bitstreams, with placements.
2
+
3
+ Why this exists at all: downstream byte-identity work (figure-duplication
4
+ forensics in particular) needs the image bytes AS THE AUTHORS EMBEDDED THEM.
5
+ Anything that renders and re-encodes -- screenshots, pdf2image, docling crops --
6
+ destroys exactly the evidence that matters. PyMuPDF's ``extract_image`` is the
7
+ one surveyed route that hands back the embedded stream with its xref identity,
8
+ so this module is a thin dump of that: one file per xref, every placement
9
+ recorded, hashes of both the extracted bytes and the raw xref stream.
10
+
11
+ Two hashes because ``extract_image`` is NOT always a passthrough. It returns
12
+ the original buffer only for recognized compressed formats (JPEG, JPX, and the
13
+ rare embedded BMP/GIF/PNG/TIFF); Flate/CCITT/JBIG2 streams are repacked to PNG,
14
+ and -- the trap -- CMYK JPEG is re-encoded LOSSILY at quality 95 with ``ext``
15
+ still reporting "jpeg". So ``ext`` cannot prove originality. Only
16
+ ``sha256(extracted) == sha256(doc.xref_stream_raw(xref))`` can, which is what
17
+ the ``passthrough`` flag records; the raw-stream hash is the byte-identity
18
+ anchor either way.
19
+
20
+ Deliberately NOT here: perceptual hashing, panel segmentation, figure/caption
21
+ joins, or any judgement about what an image IS. This module reports what is
22
+ embedded; deciding what it means belongs to the consumer.
23
+ """
24
+
25
+ import hashlib
26
+ import json
27
+ import os
28
+ import re
29
+ import tempfile
30
+ import time
31
+ from dataclasses import dataclass, field
32
+ from typing import Dict, Iterator, List, Optional, Tuple
33
+
34
+ from ._util import iso, unlink
35
+ from .http import apply_default_mode
36
+
37
+ IMAGES_DIR_SUFFIX = "_images"
38
+ MANIFEST_SUFFIX = "_images.json"
39
+ _SCHEMA_VERSION = 1
40
+ _MISSING_DEP_MSG = ("PyMuPDF not installed; install fetchpdf[images] "
41
+ "to extract embedded images")
42
+
43
+ #: Files this module owns inside {stem}_images/, and therefore the only files
44
+ #: the stale-cleanup pass may ever touch there.
45
+ _OWNED_FILE_RE = re.compile(r"^xref\d{6}\.")
46
+
47
+
48
+ def _pymupdf():
49
+ """PyMuPDF if installed, else None. Optional extra: fetchpdf[images]."""
50
+ try:
51
+ import pymupdf
52
+ return pymupdf
53
+ except ImportError:
54
+ try:
55
+ import fitz
56
+ return fitz
57
+ except ImportError:
58
+ return None
59
+
60
+
61
+ # -- paths ------------------------------------------------------------------
62
+
63
+
64
+ def _stem(pdf_path: str) -> str:
65
+ from .engine import _stem_for
66
+ return _stem_for(pdf_path)
67
+
68
+
69
+ def images_dir_for(pdf_path: str) -> str:
70
+ """`{stem}_images/`, beside the PDF."""
71
+ return _stem(pdf_path) + IMAGES_DIR_SUFFIX
72
+
73
+
74
+ def manifest_path_for(pdf_path: str) -> str:
75
+ """`{stem}_images.json`, beside the PDF."""
76
+ return _stem(pdf_path) + MANIFEST_SUFFIX
77
+
78
+
79
+ # -- data model -------------------------------------------------------------
80
+
81
+
82
+ @dataclass
83
+ class Placement:
84
+ page: int # 0-based
85
+ rect: Tuple[float, float, float, float]
86
+ matrix: Tuple[float, float, float, float, float, float]
87
+
88
+
89
+ @dataclass
90
+ class EmbeddedImage:
91
+ xref: int
92
+ ext: str # extract_image's ext, verbatim
93
+ width: int
94
+ height: int
95
+ bpc: int
96
+ colorspace_n: int
97
+ colorspace_name: str
98
+ filter: str
99
+ smask_xref: int # 0 when none
100
+ role: str # "image" | "smask"
101
+ smask_for: List[int] # xrefs this smask belongs to (role=="smask")
102
+ size: int # len(extracted bytes)
103
+ sha256: str # of the extracted bytes
104
+ raw_stream_size: int
105
+ raw_stream_sha256: str # of doc.xref_stream_raw(xref) -- byte identity
106
+ passthrough: bool # extracted bytes ARE the raw stream
107
+ notes: List[str] = field(default_factory=list)
108
+ placements: List[Placement] = field(default_factory=list)
109
+ image_bytes: Optional[bytes] = None # nulled after writing, to bound memory
110
+ file: str = "" # filename inside {stem}_images/, set by the writer
111
+
112
+
113
+ @dataclass
114
+ class ImagesResult:
115
+ status: str # ok | encrypted | corrupt | error | skipped | unavailable
116
+ detail: str = ""
117
+ pdf_path: str = ""
118
+ page_count: int = 0
119
+ images: List[EmbeddedImage] = field(default_factory=list)
120
+ errors: List[dict] = field(default_factory=list)
121
+ images_dir: str = ""
122
+ manifest_path: str = ""
123
+ written: int = 0
124
+
125
+
126
+ # -- enumeration core (no filesystem writes) --------------------------------
127
+
128
+
129
+ def _filter_of(doc, xref: int) -> str:
130
+ """The /Filter entry as a plain string, or ""."""
131
+ try:
132
+ kind, value = doc.xref_get_key(xref, "Filter")
133
+ except Exception:
134
+ return ""
135
+ if kind in ("null", "none") or not value or value == "null":
136
+ return ""
137
+ return value.strip().strip("/")
138
+
139
+
140
+ def _record_for(doc, xref: int, filt: str, role: str,
141
+ smask_xref: int, smask_for: List[int],
142
+ placements: List[Placement]) -> EmbeddedImage:
143
+ extracted = doc.extract_image(xref) or {}
144
+ data = extracted.get("image") or b""
145
+ raw = doc.xref_stream_raw(xref) or b""
146
+ sha = hashlib.sha256(data).hexdigest()
147
+ raw_sha = hashlib.sha256(raw).hexdigest()
148
+ passthrough = bool(data) and sha == raw_sha
149
+ ext = extracted.get("ext") or "bin"
150
+ colorspace_n = int(extracted.get("colorspace") or 0)
151
+
152
+ notes = []
153
+ # A PNG whose bytes differ from the raw stream means fitz converted a
154
+ # Flate/CCITT/JBIG2/unknown stream. Judged by bytes, not by /Filter,
155
+ # because a genuinely embedded PNG buffer passes through and earns no note.
156
+ if ext == "png" and not passthrough:
157
+ notes.append("repacked-to-png")
158
+ # The lossy trap: CMYK JPEG comes back re-encoded at q95, ext still "jpeg".
159
+ if ext == "jpeg" and colorspace_n == 4 and not passthrough:
160
+ notes.append("cmyk-jpeg-reencoded-lossy")
161
+ if role == "image" and not placements:
162
+ notes.append("never-drawn")
163
+
164
+ return EmbeddedImage(
165
+ xref=xref,
166
+ ext=ext,
167
+ width=int(extracted.get("width") or 0),
168
+ height=int(extracted.get("height") or 0),
169
+ bpc=int(extracted.get("bpc") or 0),
170
+ colorspace_n=colorspace_n,
171
+ colorspace_name=str(extracted.get("cs-name") or ""),
172
+ filter=filt,
173
+ smask_xref=smask_xref,
174
+ role=role,
175
+ smask_for=smask_for,
176
+ size=len(data),
177
+ sha256=sha,
178
+ raw_stream_size=len(raw),
179
+ raw_stream_sha256=raw_sha,
180
+ passthrough=passthrough,
181
+ notes=notes,
182
+ placements=placements,
183
+ image_bytes=data,
184
+ )
185
+
186
+
187
+ def iter_embedded_images(doc, errors: Optional[List[dict]] = None) -> Iterator[EmbeddedImage]:
188
+ """Every embedded image (then every smask) of an open Document, one per xref.
189
+
190
+ The caller owns the Document: opening, password handling and closing stay
191
+ outside so this stays importable as a pure enumeration core. A failing
192
+ xref is reported into `errors` (when given) and skipped -- one corrupt
193
+ stream is one lost image, never a lost document.
194
+ """
195
+ # Pass 1: which xrefs exist, on which pages. get_images(full=True) lists
196
+ # an xref once PER PLACEMENT NAME, so dedup is required even within a page.
197
+ seen: Dict[int, dict] = {}
198
+ for page_index in range(doc.page_count):
199
+ page = doc[page_index]
200
+ for entry in page.get_images(full=True):
201
+ xref, smask = int(entry[0]), int(entry[1])
202
+ filt = str(entry[8]) if len(entry) > 8 else ""
203
+ info = seen.setdefault(xref, {"pages": [], "smask": smask, "filter": filt})
204
+ if page_index not in info["pages"]:
205
+ info["pages"].append(page_index)
206
+
207
+ # Pass 2: one record per image xref, carrying every placement.
208
+ smask_map: Dict[int, List[int]] = {}
209
+ for xref in sorted(seen):
210
+ info = seen[xref]
211
+ try:
212
+ placements = []
213
+ for page_index in info["pages"]:
214
+ page = doc[page_index]
215
+ for rect, matrix in page.get_image_rects(xref, transform=True):
216
+ placements.append(Placement(
217
+ page=page_index,
218
+ rect=tuple(float(v) for v in rect),
219
+ matrix=tuple(float(v) for v in matrix),
220
+ ))
221
+ record = _record_for(doc, xref, info["filter"], "image",
222
+ info["smask"], [], placements)
223
+ except Exception as e:
224
+ if errors is not None:
225
+ errors.append({"xref": xref, "error": f"{type(e).__name__}: {str(e)[:120]}"})
226
+ continue
227
+ if info["smask"]:
228
+ smask_map.setdefault(info["smask"], []).append(xref)
229
+ yield record
230
+
231
+ # Pass 3: the smasks those images referenced. Not listed by get_images and
232
+ # never drawn, but they carry the original alpha -- without these bytes it
233
+ # is unrecoverable later. Never composited: compositing re-encodes.
234
+ for smask_xref in sorted(smask_map):
235
+ try:
236
+ yield _record_for(doc, smask_xref, _filter_of(doc, smask_xref),
237
+ "smask", 0, sorted(smask_map[smask_xref]), [])
238
+ except Exception as e:
239
+ if errors is not None:
240
+ errors.append({"xref": smask_xref,
241
+ "error": f"{type(e).__name__}: {str(e)[:120]}"})
242
+
243
+
244
+ def enumerate_pdf_images(pdf_path: str) -> ImagesResult:
245
+ """Open a PDF and enumerate its embedded images. Never raises.
246
+
247
+ Every non-answer is a recorded outcome ("encrypted", "corrupt", "error",
248
+ "unavailable"), mirroring the batch discipline elsewhere in this package:
249
+ one bad record must never take down a run.
250
+ """
251
+ result = ImagesResult(status="error", pdf_path=pdf_path)
252
+ fitz = _pymupdf()
253
+ if fitz is None:
254
+ result.status = "unavailable"
255
+ result.detail = _MISSING_DEP_MSG
256
+ return result
257
+ if not os.path.isfile(pdf_path):
258
+ result.detail = "no such file"
259
+ return result
260
+
261
+ doc = None
262
+ try:
263
+ try:
264
+ doc = fitz.open(pdf_path)
265
+ except Exception as e:
266
+ result.status = "corrupt"
267
+ result.detail = f"{type(e).__name__}: {str(e)[:120]}"
268
+ return result
269
+
270
+ # Empty-user-password PDFs auto-authenticate on open; only a real
271
+ # password leaves the document encrypted after this.
272
+ if doc.needs_pass:
273
+ doc.authenticate("")
274
+ if doc.is_encrypted:
275
+ result.status = "encrypted"
276
+ result.detail = "password-protected; no images readable"
277
+ return result
278
+
279
+ result.page_count = doc.page_count
280
+ result.images = list(iter_embedded_images(doc, errors=result.errors))
281
+ result.status = "ok"
282
+ return result
283
+ except Exception as e:
284
+ result.status = "error"
285
+ result.detail = f"{type(e).__name__}: {str(e)[:120]}"
286
+ return result
287
+ finally:
288
+ if doc is not None:
289
+ try:
290
+ doc.close()
291
+ except Exception:
292
+ pass
293
+
294
+
295
+ # -- fetchpdf-layout writer -------------------------------------------------
296
+
297
+
298
+ def _write_bytes_atomic(path: str, payload: bytes) -> None:
299
+ directory = os.path.dirname(os.path.abspath(path)) or "."
300
+ handle, temp_path = tempfile.mkstemp(prefix=".fetchpdf-img-", dir=directory)
301
+ try:
302
+ with os.fdopen(handle, "wb") as f:
303
+ f.write(payload)
304
+ apply_default_mode(temp_path)
305
+ os.replace(temp_path, path)
306
+ except BaseException:
307
+ unlink(temp_path)
308
+ raise
309
+
310
+
311
+ def _sha256_file(path: str) -> Tuple[int, str]:
312
+ digest = hashlib.sha256()
313
+ size = 0
314
+ with open(path, "rb") as f:
315
+ for block in iter(lambda: f.read(1 << 20), b""):
316
+ digest.update(block)
317
+ size += len(block)
318
+ return size, digest.hexdigest()
319
+
320
+
321
+ def _versions() -> Tuple[str, str]:
322
+ try:
323
+ from importlib.metadata import version
324
+ own = version("fetchpdf")
325
+ except Exception:
326
+ own = "unknown"
327
+ mod = _pymupdf()
328
+ lib = str(getattr(mod, "__version__", "unknown")) if mod else "absent"
329
+ return own, lib
330
+
331
+
332
+ def _manifest_is_current(manifest_path: str, source_size: int, source_sha256: str) -> bool:
333
+ """True when the manifest on disk already describes this exact PDF.
334
+
335
+ Keyed on the PDF's bytes, not its mtime: copies and Dropbox syncs shuffle
336
+ mtimes freely. "encrypted"/"corrupt" manifests count as current too --
337
+ "nothing there" is a fact worth recording once -- but "error" does not,
338
+ because those are usually environmental and deserve a retry.
339
+ """
340
+ try:
341
+ with open(manifest_path, "r", encoding="utf-8") as f:
342
+ manifest = json.load(f)
343
+ except (OSError, ValueError):
344
+ return False
345
+ return (
346
+ manifest.get("schema_version") == _SCHEMA_VERSION
347
+ and manifest.get("source_size") == source_size
348
+ and manifest.get("source_sha256") == source_sha256
349
+ and manifest.get("status") in ("ok", "encrypted", "corrupt")
350
+ )
351
+
352
+
353
+ def _manifest_payload(result: ImagesResult, pdf_path: str,
354
+ source_size: int, source_sha256: str) -> bytes:
355
+ own_version, lib_version = _versions()
356
+ images = []
357
+ n_placements = 0
358
+ for img in result.images:
359
+ n_placements += len(img.placements)
360
+ images.append({
361
+ "xref": img.xref,
362
+ "file": img.file,
363
+ "role": img.role,
364
+ "ext": img.ext,
365
+ "width": img.width,
366
+ "height": img.height,
367
+ "bpc": img.bpc,
368
+ "colorspace_n": img.colorspace_n,
369
+ "colorspace_name": img.colorspace_name,
370
+ "filter": img.filter,
371
+ "size_bytes": img.size,
372
+ "sha256": img.sha256,
373
+ "raw_stream_size": img.raw_stream_size,
374
+ "raw_stream_sha256": img.raw_stream_sha256,
375
+ "passthrough": img.passthrough,
376
+ "notes": img.notes,
377
+ "smask_xref": img.smask_xref,
378
+ "smask_for": img.smask_for,
379
+ "placements": [
380
+ {"page": p.page, "rect": list(p.rect), "matrix": list(p.matrix)}
381
+ for p in img.placements
382
+ ],
383
+ })
384
+ manifest = {
385
+ "schema_version": _SCHEMA_VERSION,
386
+ "generator": "fetchpdf.retrieval.extract_images",
387
+ "fetchpdf_version": own_version,
388
+ "pymupdf_version": lib_version,
389
+ "created_at": iso(time.time()),
390
+ "source_pdf": os.path.basename(pdf_path),
391
+ "source_size": source_size,
392
+ "source_sha256": source_sha256,
393
+ "status": result.status,
394
+ "detail": result.detail,
395
+ "page_count": result.page_count,
396
+ "images_dir": os.path.basename(images_dir_for(pdf_path)),
397
+ "n_images": len(result.images),
398
+ "n_placements": n_placements,
399
+ "images": images,
400
+ "errors": result.errors,
401
+ }
402
+ return (json.dumps(manifest, indent=2) + "\n").encode("utf-8")
403
+
404
+
405
+ def extract_images(pdf_path: str, overwrite: bool = False,
406
+ verbose: bool = False) -> ImagesResult:
407
+ """Extract one PDF's embedded images into `{stem}_images/` + manifest.
408
+
409
+ Idempotent: an existing manifest describing this exact PDF (by size and
410
+ sha256) short-circuits to "skipped". The manifest is written LAST and
411
+ atomically -- it is the commit point; a crash mid-write leaves stale
412
+ bitstreams a re-run will replace, never a manifest describing files that
413
+ are not there. Never raises.
414
+ """
415
+ try:
416
+ return _extract_images_inner(pdf_path, overwrite=overwrite, verbose=verbose)
417
+ except Exception as e:
418
+ return ImagesResult(status="error", pdf_path=pdf_path,
419
+ detail=f"{type(e).__name__}: {str(e)[:120]}")
420
+
421
+
422
+ def _extract_images_inner(pdf_path: str, overwrite: bool, verbose: bool) -> ImagesResult:
423
+ manifest_path = manifest_path_for(pdf_path)
424
+ if not os.path.isfile(pdf_path):
425
+ return ImagesResult(status="error", pdf_path=pdf_path, detail="no such file")
426
+ source_size, source_sha256 = _sha256_file(pdf_path)
427
+
428
+ if not overwrite and os.path.exists(manifest_path) and \
429
+ _manifest_is_current(manifest_path, source_size, source_sha256):
430
+ return ImagesResult(status="skipped", pdf_path=pdf_path,
431
+ manifest_path=manifest_path,
432
+ detail="manifest current for these PDF bytes")
433
+
434
+ result = enumerate_pdf_images(pdf_path)
435
+ if result.status == "unavailable":
436
+ # A missing dependency is run state, not a fact about the PDF. Writing
437
+ # a manifest here would satisfy skip-if-current forever after the user
438
+ # installs the extra -- so write nothing at all.
439
+ return result
440
+
441
+ images_dir = images_dir_for(pdf_path)
442
+ if result.images:
443
+ try:
444
+ os.makedirs(images_dir, exist_ok=True)
445
+ except OSError as e:
446
+ # No flat fallback (unlike SI, a re-run costs nothing but CPU),
447
+ # and flat xref-named files would lose their record association.
448
+ result.status = "error"
449
+ result.detail = f"could not create {os.path.basename(images_dir)}: {e}"
450
+ result.images = []
451
+
452
+ kept = set()
453
+ for img in result.images:
454
+ filename = "xref{:06d}.{}".format(img.xref, img.ext)
455
+ try:
456
+ _write_bytes_atomic(os.path.join(images_dir, filename), img.image_bytes or b"")
457
+ except OSError as e:
458
+ result.errors.append({"xref": img.xref, "error": f"write failed: {e}"})
459
+ img.image_bytes = None
460
+ continue
461
+ img.file = filename
462
+ img.image_bytes = None # keep batch memory bounded
463
+ kept.add(filename)
464
+ result.written += 1
465
+
466
+ # An upgraded PDF renumbers xrefs; orphaned bitstreams beside a fresh
467
+ # manifest would poison downstream byte-identity pooling. Scoped to the
468
+ # filename pattern this module owns, so nothing else is ever touched.
469
+ if os.path.isdir(images_dir):
470
+ for name in os.listdir(images_dir):
471
+ if _OWNED_FILE_RE.match(name) and name not in kept:
472
+ unlink(os.path.join(images_dir, name))
473
+
474
+ result.images_dir = images_dir if result.written else ""
475
+ result.manifest_path = manifest_path
476
+ _write_bytes_atomic(manifest_path,
477
+ _manifest_payload(result, pdf_path, source_size, source_sha256))
478
+ if verbose:
479
+ print(f" images: {os.path.basename(pdf_path)} -> "
480
+ f"{result.written} stream(s), status {result.status}")
481
+ return result
482
+
483
+
484
+ # -- directory mode + CLI ---------------------------------------------------
485
+
486
+
487
+ def extract_directory(directory: str, overwrite: bool = False, verbose: bool = False,
488
+ include_supplementary: bool = False) -> dict:
489
+ """Extract images for every record PDF under a directory. Returns a summary.
490
+
491
+ Recursive, unlike to_markdown's flat listing, because --make-subfolder
492
+ corpora put each record in its own folder. Supplementary-info PDFs are
493
+ siblings in the same directories and are skipped by default: they are a
494
+ different kind of artifact, and sweeping them in silently would double
495
+ counts for records whose SI happens to be a PDF.
496
+ """
497
+ from .supplementary import INFIX as _SI_INFIX
498
+ from .supplementary import DATA_ARTIFACT_INFIX as _DATA_INFIX
499
+
500
+ summary = {"extracted": 0, "skipped": 0, "zero_image": 0, "encrypted": 0,
501
+ "failed": 0, "supplementary_skipped": 0, "images": 0, "placements": 0}
502
+
503
+ for root, dirnames, filenames in os.walk(directory):
504
+ # Never descend into our own output or unpacked data deposits.
505
+ dirnames[:] = sorted(
506
+ d for d in dirnames
507
+ if not d.endswith(IMAGES_DIR_SUFFIX) and _DATA_INFIX not in d
508
+ )
509
+ for name in sorted(filenames):
510
+ if not name.endswith(".pdf"):
511
+ continue
512
+ if _SI_INFIX in name and not include_supplementary:
513
+ summary["supplementary_skipped"] += 1
514
+ continue
515
+ result = extract_images(os.path.join(root, name),
516
+ overwrite=overwrite, verbose=verbose)
517
+ if result.status == "skipped":
518
+ summary["skipped"] += 1
519
+ elif result.status == "ok" and not result.images:
520
+ summary["zero_image"] += 1
521
+ elif result.status == "ok":
522
+ summary["extracted"] += 1
523
+ summary["images"] += len(result.images)
524
+ summary["placements"] += sum(len(i.placements) for i in result.images)
525
+ elif result.status == "encrypted":
526
+ summary["encrypted"] += 1
527
+ else:
528
+ summary["failed"] += 1
529
+ if verbose:
530
+ print(f" images: {name} -> {result.status} ({result.detail})")
531
+ return summary
532
+
533
+
534
+ def main(argv=None):
535
+ """Extract embedded images from PDFs already on disk."""
536
+ import argparse
537
+
538
+ parser = argparse.ArgumentParser(
539
+ prog="fetchpdf-images",
540
+ description=(
541
+ "Dump every embedded image bitstream from retrieved PDFs into "
542
+ "{stem}_images/ with a {stem}_images.json manifest (xref identity, "
543
+ "sha256 of extracted and raw stream bytes, every page placement). "
544
+ "Authors' original streams -- nothing is rendered or re-encoded."
545
+ ),
546
+ )
547
+ parser.add_argument("path", help="Directory of artifacts (the -o dir from a run), or one PDF")
548
+ parser.add_argument("--overwrite", action="store_true",
549
+ help="Re-extract even when the manifest matches the PDF bytes")
550
+ parser.add_argument("--include-supplementary", action="store_true",
551
+ help="Also process *_supplementary_info_* PDFs (skipped by default)")
552
+ parser.add_argument("--verbose", "-v", action="store_true")
553
+ args = parser.parse_args(argv)
554
+
555
+ if _pymupdf() is None:
556
+ print(f"āŒ {_MISSING_DEP_MSG}")
557
+ return 1
558
+
559
+ if os.path.isfile(args.path):
560
+ result = extract_images(args.path, overwrite=args.overwrite, verbose=args.verbose)
561
+ print(f"\nšŸ–¼ļø {os.path.basename(args.path)}: {result.status}"
562
+ + (f" — {result.detail}" if result.detail else ""))
563
+ if result.status in ("ok", "skipped"):
564
+ print(f" streams written {result.written}")
565
+ print(f" manifest {result.manifest_path}")
566
+ return 0
567
+ return 1
568
+
569
+ if not os.path.isdir(args.path):
570
+ print(f"āŒ Not a file or directory: {args.path}")
571
+ return 1
572
+
573
+ summary = extract_directory(args.path, overwrite=args.overwrite,
574
+ verbose=args.verbose,
575
+ include_supplementary=args.include_supplementary)
576
+ print(f"\nšŸ–¼ļø Image extraction — {args.path}")
577
+ print(f" extracted {summary['extracted']} ({summary['images']} streams, "
578
+ f"{summary['placements']} placements)")
579
+ print(f" already current {summary['skipped']}")
580
+ print(f" zero images {summary['zero_image']}")
581
+ if summary["encrypted"]:
582
+ print(f" encrypted {summary['encrypted']}")
583
+ if summary["supplementary_skipped"]:
584
+ print(f" SI PDFs skipped {summary['supplementary_skipped']} "
585
+ f"(--include-supplementary to process)")
586
+ if summary["failed"]:
587
+ print(f" failed {summary['failed']}")
588
+ return 0
589
+
590
+
591
+ if __name__ == "__main__":
592
+ import sys
593
+ sys.exit(main())