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.
- fetchpdf/__init__.py +49 -0
- fetchpdf/__main__.py +6 -0
- fetchpdf/_env.py +63 -0
- fetchpdf/_http.py +40 -0
- fetchpdf/fetch_abstract_from_doi.py +549 -0
- fetchpdf/fetch_metadata_from_doi.py +225 -0
- fetchpdf/fetchpdf.py +7339 -0
- fetchpdf/retrieval/__init__.py +36 -0
- fetchpdf/retrieval/_util.py +150 -0
- fetchpdf/retrieval/artifact.py +87 -0
- fetchpdf/retrieval/backends/__init__.py +109 -0
- fetchpdf/retrieval/backends/claude_cli.py +111 -0
- fetchpdf/retrieval/backends/openrouter.py +244 -0
- fetchpdf/retrieval/cache.py +123 -0
- fetchpdf/retrieval/chunk.py +195 -0
- fetchpdf/retrieval/classify.py +110 -0
- fetchpdf/retrieval/context.py +69 -0
- fetchpdf/retrieval/corresponding.py +349 -0
- fetchpdf/retrieval/engine.py +666 -0
- fetchpdf/retrieval/extract_images.py +593 -0
- fetchpdf/retrieval/fulltext_scan.py +472 -0
- fetchpdf/retrieval/goals.py +145 -0
- fetchpdf/retrieval/http.py +669 -0
- fetchpdf/retrieval/identifiers.py +149 -0
- fetchpdf/retrieval/ladder.json +362 -0
- fetchpdf/retrieval/linked_artifacts.py +144 -0
- fetchpdf/retrieval/llm_adjudicate.py +278 -0
- fetchpdf/retrieval/llm_agent_retrieval.py +495 -0
- fetchpdf/retrieval/normalize.py +301 -0
- fetchpdf/retrieval/pdf_identity.py +533 -0
- fetchpdf/retrieval/pdf_text.py +242 -0
- fetchpdf/retrieval/provenance.py +185 -0
- fetchpdf/retrieval/ratelimit.py +113 -0
- fetchpdf/retrieval/repository_waf.py +152 -0
- fetchpdf/retrieval/request_drafts.py +304 -0
- fetchpdf/retrieval/resolve.py +481 -0
- fetchpdf/retrieval/sources/__init__.py +15 -0
- fetchpdf/retrieval/sources/arxiv.py +102 -0
- fetchpdf/retrieval/sources/core_text.py +64 -0
- fetchpdf/retrieval/sources/crossref_tdm.py +193 -0
- fetchpdf/retrieval/sources/epmc.py +133 -0
- fetchpdf/retrieval/sources/landing.py +34 -0
- fetchpdf/retrieval/sources/legacy_pdf.py +99 -0
- fetchpdf/retrieval/sources/pmc.py +59 -0
- fetchpdf/retrieval/sources/preprint.py +101 -0
- fetchpdf/retrieval/sources/publisher_html.py +154 -0
- fetchpdf/retrieval/sources/supplements.py +183 -0
- fetchpdf/retrieval/supplement_atypon.py +269 -0
- fetchpdf/retrieval/supplement_dspace.py +187 -0
- fetchpdf/retrieval/supplement_graph.py +842 -0
- fetchpdf/retrieval/supplement_index.py +820 -0
- fetchpdf/retrieval/supplement_pmc.py +351 -0
- fetchpdf/retrieval/supplement_publishers.py +374 -0
- fetchpdf/retrieval/supplementary.py +1770 -0
- fetchpdf/retrieval/tiers.py +213 -0
- fetchpdf/retrieval/to_markdown.py +661 -0
- fetchpdf/retrieval/validate.py +368 -0
- fetchpdf/retrieval/verify.py +149 -0
- fetchpdf-0.1.0.dist-info/METADATA +1392 -0
- fetchpdf-0.1.0.dist-info/RECORD +64 -0
- fetchpdf-0.1.0.dist-info/WHEEL +5 -0
- fetchpdf-0.1.0.dist-info/entry_points.txt +5 -0
- fetchpdf-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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())
|