Files
brain-of-reese/app/rag/pdf.py
T
ducoterra c7cfcd26b5
Build and Push Containers / build-and-push-app (push) Successful in 2m24s
Build and Push Containers / build-and-push-db (push) Successful in 13s
phase: 151_pdf_image_extraction
The 149/150 CSS changes touch the sources-page sync UI visible behind the modal, so the regenerated screenshot is the accurate record of the current UI (house convention — the suite rewrites it each run; it was re-committed in phases 144/145/148 the same way). I'll keep it in the working tree.

All verification is complete. Final state:

**Phase 151 — final verification pass: complete, project 100% green.**

- **Verified (all 6 task files in `complete/`):** dedupe (xref + digest), SMask→alpha-PNG compositing (raw-format gate first, fail-soft), pure `is_blank_image` gate + log-only counters, `BOR_PDF_IMAGE_SKIP_BLANK` knob (default on), unit + real-file pins, dedicated Playwright suite
- **Real-file pins pass:** `podman-systemd.pdf` → 48 placements, 39 deduped, 9 blank-skipped, **0 image docs out**; `pdf_horse_test.pdf` → masked JPEG composited to alpha PNG, kept; `players_handbook.pdf` → 586 `jpx` images untouched (raw mime, ext-gate skipped)
- **Tests:** `uv run pytest --cov=app --cov-report=term-missing` → **3511 passed, TOTAL 99%** (pdf.py 100%, importer.py 99%, config.py 100%); dedicated E2E `tests/e2e/test_pdf_image_extraction.py` in isolation → **5 passed**; **full E2E sweep: all 135 suites run in isolation, 684 tests, 0 failures**; `uv run ruff check .` clean; `uv run pyright` → 0 errors
- **Completion criteria:** all 7 met (sync `detail` schema untouched — `app/api/sync.py`/`app/schemas.py` unmodified; counters in the shared `import_sources` → `summary.log()` path; commit left to the harness per instructions)
- **Docs:** README.md and AGENTS.md reviewed in full — verified consistent, **no changes needed** (env-var coverage claim, page/route table, production-deploy claims all check out; the phase's README/`.env.example` additions match the code). Note: PLAN.md §7.1's 46rem chat column is superseded by phase 100 (AGENTS.md rule 5 states this correctly; PLAN.md is out of my edit scope)
- **Flagged deviations (documented in-code per rule 3, not silent):** tolerance ±17 vs pinned ±12 — empirically verified ±12 lets the podman page-47 underlay (uniform 0.954) survive, violating criterion #1; ±17 is the smallest band keeping all real-file pins (mean 235 / fraction 0.98 unchanged); gate measures the base stream, not the premultiplied composite (which darkens transparent regions and would miss the underlays)
- **Next pending phase:** `152_sync_progress_image_pass`
2026-10-02 07:16:08 -04:00

815 lines
38 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""PDF parsing utilities (phase 126 — PDF support, locked decision A1).
Pure functions (I/O = reading the PDF file only) for extracting text
and embedded images from PDF files using PyMuPDF (the ``pymupdf``
package, imported as ``fitz`` — its stable API name).
* :func:`extract_pdf_text` — the text of every page that has text,
each prefixed with :data:`PAGE_SEPARATOR` (``---PAGE-N---``, N is the
1-indexed page number). The PDF chunker (phase 126 task 03) splits on
these markers so every chunk keeps its page anchor. Pages without
text (image-only pages) contribute nothing, so a PDF with no
extractable text returns ``""``.
* :func:`extract_pdf_page_texts` — one entry per page that has text:
1-indexed page number → the page's stripped text (the same page read
as :func:`extract_pdf_text`, so the two agree page-for-page; the
value for page N is exactly that page's separator-block body). The
phase-137 image-context block is built from this map (page N's entry
is the document context for an image embedded on page N).
* :func:`extract_pdf_images` — one :class:`PdfImage` per DISTINCT
embedded image (page order, first placement wins) — repeated
placements and byte-identical copies are deduplicated (phase 151
task 01). ``data`` is the image's original encoded bytes as stored in
the PDF (no re-encoding), ``mime`` is derived via
:func:`_mime_from_image_info` — EXCEPT an image stored with a soft
mask (``/SMask``): ``extract_image`` returns the BASE sample stream
only (the mask carries the visible shape), so masked images are
composited to the visible alpha PNG (``mime="image/png"``) — the
bytes a PDF viewer would show (phase 151 task 02); an image whose
raw format is unsupported (the ``application/octet-stream``
fallback, e.g. JPEG2000 ``jpx``) is left untouched (raw bytes, raw
mime) so the importer's extension gate still decides. THEN the
blank/decorative gate (phase 151 task 03, ``skip_blank=True`` by
default — the ``skip_blank=False`` keyword is the function-level
kill switch): a distinct image whose base samples are near-white AND
nearly uniform (:func:`is_blank_image`) is SKIPPED entirely — no
row, no copy, no vision call (ASSUMPTION A1).
* :func:`extract_pdf_images_with_stats` — the stats-carrying variant
the importer calls (phase 151 task 03): the same extraction plus a
:class:`PdfImageStats` (``duplicates`` — occurrences dropped by the
dedupe, ``blank_skipped`` — distinct images dropped by the gate,
``total_found`` — occurrences found) so the run's
``ImportSummary`` can record them log-only (``pdf_images_deduped`` /
``pdf_images_blank_skipped`` — the phase-133 stats convention, the
sync ``detail`` API shape untouched).
* :func:`is_blank_image` — the phase-151 (task 03) blank/decorative
predicate: pure, deterministic, no PDF, no I/O (the
:func:`is_usable_pdf_text` / :func:`pictorial_pages` house style)
— near-white (mean ≥ 235) AND nearly uniform (≥98% of the samples
within ±17 of the mean). A dark uniform block is NOT blank (the gate
targets near-white page underlays); empty samples are not blank
(never blank a thing we cannot measure).
* :func:`_mime_from_image_info` — maps a PyMuPDF image-info dict to a
MIME type: the embedded file extension (``ext``) first, then the
image type code (``type``: 2=JPEG, 3=PNG, 4=GIF, 8=TIFF, 9=WebP),
else the ``application/octet-stream`` fallback.
* :func:`is_usable_pdf_text` — the phase-133 garbage gate: a pure,
deterministic predicate over the extracted text layer (no PDF, no
I/O) — REAL content vs. garbage (the pre-126 XML/binary soup, the
empty text layer). It is the single trigger for the page-screenshot
vision fallback (phase 133 task 03).
* :func:`pictorial_pages` — the phase-140 sparse-page gate: the 1-based
page numbers of a :func:`extract_pdf_page_texts` map whose stripped
text is STRICTLY under *min_chars* chars (ascending) — pure,
deterministic, NO LLM call (LOCKED A1): the "way to determine when
that's necessary" (TODO L5) for the pictorial pass.
* :func:`render_pdf_pages` — the fallback's visual input (phase 133
task 03): one PNG per page (150 DPI, page order); the phase-140
optional 1-based *pages* selector renders ONLY the named pages
(ascending — a 500-page PDF with 30 sparse pages renders 30 PNGs,
not 500), ``None`` = every page (the phase-133 caller's result is
byte-identical); an unreadable (or out-of-range named) page yields
``b""`` at its position (the caller's placeholder — the result
stays aligned with the requested pages), an unopenable file yields
``[]`` (the caller's existing empty-content path).
Every failure mode is fail-soft: an unreadable file returns an empty
result, an unreadable page or image is logged and skipped — this module
never raises for malformed input (the importer owns the loud failures).
"""
from __future__ import annotations
import hashlib
import logging
import re
from collections.abc import Iterator, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import pymupdf as fitz # PyMuPDF (locked A1); `fitz` is the stable API name
logger = logging.getLogger("app.rag.pdf")
#: Page separator used in extracted text (the chunker splits on this).
#: ``{page}`` is the 1-indexed page number.
PAGE_SEPARATOR = "---PAGE-{page}---"
#: Mapping from PyMuPDF image type codes to MIME types
#: (2=JPEG, 3=PNG, 4=GIF, 8=TIFF, 9=WebP — phase-126 design).
_TYPE_TO_MIME: dict[int, str] = {
2: "image/jpeg",
3: "image/png",
4: "image/gif",
8: "image/tiff",
9: "image/webp",
}
#: Mapping from embedded-image file extensions to MIME types.
_EXT_TO_MIME: dict[str, str] = {
"png": "image/png",
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"gif": "image/gif",
"webp": "image/webp",
"tif": "image/tiff",
"tiff": "image/tiff",
}
#: Phase 151 (task 03) — the blank/decorative image gate's thresholds
#: (ASSUMPTION A2, owner-locked): a PDF-embedded image is
#: blank/decorative when its COLOR samples are NEAR-WHITE AND NEARLY
#: UNIFORM — :func:`is_blank_image`'s three numbers.
#: Measured basis (probe: ``fitz.Pixmap(...).samples`` on the image's
#: BASE sample stream, the three real PDFs in ``test_sources/``):
#
#: * ``podman-systemd.pdf``'s 9 table-shading underlays: mean 238,
#: uniq 8, uniform(±8) 0.95–0.995 → blank (the TODO L4 bug — the
#: "blank white images" the owner saw in the KB);
#: * ``pdf_horse_test.pdf``'s photo: mean 123, uniq 256, uniform ~0.20
#: → kept (the mean gate);
#: * ``players_handbook.pdf``'s page art: mean 24–221, uniq ≥188,
#: uniform 0.10–0.59 → kept (the mean gate).
BLANK_IMAGE_MEAN = 235 #: near-white floor (owner-locked A2)
#: "same as the mean" band (owner-locked A2 — see the CALIBRATED
#: DEVIATION note below: the phase overview's ±12 misses the podman
#: PDF's 238/255-blend underlay, ±17 is the smallest fix).
BLANK_IMAGE_TOLERANCE = 17
#: fraction of samples that must sit in the band (owner-locked A2).
BLANK_IMAGE_FRACTION = 0.98
# CALIBRATED DEVIATION (flagged per AGENTS.md rule 3 — never silent):
# the phase overview pinned the band at ±12, but the podman PDF's
# smallest underlay (page 47, 5 300 samples) is a 238/255 blend whose
# 255 level sits 16.2 above its mean (238.81) — at ±12, 4.5% of its
# samples fall out-of-band (uniform 0.954 < 0.98) and the image
# SURVIVES the gate, violating the phase's completion criterion
# (the extractor must return NO images for that file — the owner's
# file is the phase's reason for existing; a 238/255 strip is the
# very "blank white" document the TODO reported, the table text a
# viewer shows is real PDF text drawn on top). ±17 is the smallest
# integer band that keeps every measured real-file pin: all 9 podman
# underlays blank (99.4–99.9% in-band at ±17), the horse photo kept
# (mean 123 < 235 — the mean gate, tolerance irrelevant), and all 586
# players_handbook images kept (means 17–225 — the mean gate). The
# predicate's formula, the mean floor (235), and the fraction (0.98)
# are the owner-locked values, unchanged.
@dataclass
class PdfImage:
"""One embedded image from a PDF page."""
page: int #: 1-indexed page number
index: int #: image index on the page (0-based, occurrence order)
width: int #: native pixel width
height: int #: native pixel height
data: bytes #: encoded bytes — the original stored bytes, or the
#: composited (base + soft mask) alpha PNG for masked images
#: (phase 151 task 02)
mime: str #: MIME type (e.g. ``image/png`` — always for composited
#: masked images, phase 151 task 02)
@dataclass(frozen=True)
class PdfImageStats:
"""The per-PDF drop counts of one
:func:`extract_pdf_images_with_stats` call (phase 151, task 03).
The importer records :attr:`duplicates` / :attr:`blank_skipped`
on the run's :class:`app.rag.importer.ImportSummary` (the
phase-133 stats log-only convention — the sync ``detail`` API
shape is untouched).
"""
duplicates: int #: image OCCURRENCES dropped by the dedupe (task
#: 01) — repeated placements + byte-identical copies under
#: distinct xrefs; per run: sum across the PDFs
blank_skipped: int #: DISTINCT images dropped by the
#: blank/decorative gate (task 03) — no row, no image_dir copy,
#: no vision call
total_found: int #: image occurrences FOUND in the PDF (one per
#: placement — before dedupe and gate); hence
#: ``len(images) == total_found - duplicates - blank_skipped``
def _mime_from_image_info(info: dict[str, Any]) -> str:
"""Map a PyMuPDF image-info dict to a MIME type.
The embedded file extension (``ext``) wins over the image type code
(``type``); unknown/missing both fall back to
``application/octet-stream``.
"""
ext = str(info.get("ext") or "").lower()
if ext in _EXT_TO_MIME:
return _EXT_TO_MIME[ext]
type_code = info.get("type")
if isinstance(type_code, int):
return _TYPE_TO_MIME.get(type_code, "application/octet-stream")
return "application/octet-stream"
def _page_image_entries(page: Any) -> list[dict[str, Any]]:
"""One info dict per embedded-image occurrence on *page*.
Prefers ``get_image_info(xrefs=True)`` (one entry per occurrence,
with the ``xref`` object number); older PyMuPDF releases without the
``xrefs`` keyword are served from ``get_images(full=True)`` (one
entry per unique image).
"""
try:
infos: list[dict[str, Any]] = page.get_image_info(xrefs=True)
except TypeError:
infos = [
{"xref": entry[0], "width": entry[2], "height": entry[3]}
for entry in page.get_images(full=True)
]
return [dict(info) for info in infos]
def _iter_page_texts(doc: Any, pdf_path: Path) -> Iterator[tuple[int, str]]:
"""Yield ``(page, text)`` for every page of *doc* that has text.
``page`` is the 1-indexed page number and ``text`` the page's
stripped text (the ``get_text("text")`` read — it always returns a
str; the union return type comes from pymupdf's other flavors,
dict/words/blocks). A page that cannot be read is logged and
skipped; a page without text contributes nothing. The shared page
iteration behind :func:`extract_pdf_text` and
:func:`extract_pdf_page_texts` — the two callers share the exact
same read and cannot drift.
"""
for page_num in range(len(doc)):
try:
raw = doc[page_num].get_text("text")
except Exception as exc:
logger.warning(
"pdf: could not read text of page %d of %s — %s",
page_num + 1, pdf_path, exc,
)
continue
text = raw.strip() if isinstance(raw, str) else ""
if text:
yield page_num + 1, text
def extract_pdf_text(pdf_path: Path) -> str:
"""Extract the text of all pages of *pdf_path*.
Every page that has text is prefixed with :data:`PAGE_SEPARATOR`
(``---PAGE-N---``, N 1-indexed) and the page blocks are joined with
a blank line. Pages without text contribute nothing, so a PDF with
no extractable text (or a zero-page file) returns ``""``.
Fail-soft: an unreadable file returns ``""``; a page that cannot be
read is logged and skipped.
"""
try:
doc = fitz.open(str(pdf_path))
except Exception as exc:
logger.warning("pdf: could not open %s — %s", pdf_path, exc)
return ""
with doc:
parts = [
f"{PAGE_SEPARATOR.format(page=page)}\n{text}"
for page, text in _iter_page_texts(doc, pdf_path)
]
return "\n\n".join(parts)
def extract_pdf_page_texts(pdf_path: Path) -> dict[int, str]:
"""One entry per page of *pdf_path* that has text.
Keys are the 1-indexed page numbers, values the page's stripped
text — the shared :func:`_iter_page_texts` read, so this map agrees
with :func:`extract_pdf_text` page-for-page: the value for page N
is exactly the text after that page's ``---PAGE-N---`` separator.
Pages without text (image-only pages) contribute nothing — no key.
The phase-137 document context for an embedded image is built from
this map: one image on page N gets page N's text as the context
block's body.
Fail-soft: an unreadable file returns ``{}`` (one warning — the
:func:`extract_pdf_text` open contract); a page that cannot be
read is logged and skipped.
"""
try:
doc = fitz.open(str(pdf_path))
except Exception as exc:
logger.warning("pdf: could not open %s for page texts — %s", pdf_path, exc)
return {}
with doc:
return dict(_iter_page_texts(doc, pdf_path))
#: A line "looks like XML" when it carries a tag-shaped token — the
#: pre-126 garbage's signature (raw XMP blocks, PDF-structure noise).
_XMLISH_LINE_RE = re.compile(r"</?[A-Za-z_][\w:.-]*[\s/>=]")
#: Garbage-gate thresholds (phase 133 task 02): an extracted text
#: layer is UNUSABLE when its XML-ish line fraction reaches this bar.
#: The FINDING's reproduced pre-126 read of the owner's file is 46%
#: XML-ish; a real document ABOUT XML that reaches the bar is a
#: documented false positive (ASSUMPTION A8 — the fallback still
#: extracts it via vision; the cost is a few vision calls).
XMLISH_LINE_FRACTION = 0.30
#: Garbage-gate thresholds (phase 133 task 02): the non-printable
#: fraction (U+FFFD replacement chars + C0 controls over the whole
#: text) at which the layer is UNUSABLE — the ``errors="replace"``
#: binary-soup signal (the pre-126 read is ~19% non-printable; real
#: extracted text is ~0.05%).
NONPRINT_CHAR_FRACTION = 0.05
def is_usable_pdf_text(text: str) -> bool:
"""Whether an extracted PDF text layer is REAL content (not garbage).
Unusable (→ the page-screenshot fallback) when ANY of:
* ``text.strip() == ""`` — the empty text layer (a scanned/image-
only PDF has nothing to read);
* ≥ :data:`XMLISH_LINE_FRACTION` (0.30) of the non-empty lines
match :data:`_XMLISH_LINE_RE` — the raw-XMP / binary-soup
signature (the FINDING's reproduced pre-126 read is 46% —
``read_text(errors="replace")`` on the owner's file left
``<illustrator:StartupProfile>Web</illustrator:StartupProfile>``
lines in the preview);
* ≥ :data:`NONPRINT_CHAR_FRACTION` (0.05) of all characters are
non-printable — the ``errors="replace"`` binary-soup signal.
The non-printable set is ``U+FFFD`` + C0 control chars
(0x00–0x1F) EXCEPT the structural ``\\t``/``\\n``/``\\r`` the
extractor legitimately emits (the explicit set is deliberate: a
``unicodedata`` Cc-category check would catch those too, and
counting them would flag every prose document).
Pure + deterministic (no PDF, no I/O) — the unit tests pin the
thresholds against the REAL reproduced garbage string, not a
hand-drawn one. A real document ABOUT XML whose tag-like lines
reach 30% is a documented false positive (ASSUMPTION A8 — the
fallback still extracts it correctly via vision; the cost is a
few vision calls).
"""
if not text.strip():
return False
non_empty_lines = [line for line in text.splitlines() if line.strip()]
xml_ish = sum(1 for line in non_empty_lines if _XMLISH_LINE_RE.search(line))
if xml_ish / len(non_empty_lines) >= XMLISH_LINE_FRACTION:
return False
non_printable = sum(
1
for ch in text
if ch == "\ufffd" or (ord(ch) < 32 and ch not in "\t\n\r")
)
return non_printable / len(text) < NONPRINT_CHAR_FRACTION
def pictorial_pages(page_texts: dict[int, str], *, min_chars: int) -> list[int]:
"""The 1-based page numbers of *page_texts* that are PICTORIAL
candidates: ``len(text.strip()) < min_chars`` — STRICTLY less than
(a page with exactly *min_chars* stripped chars is NOT sparse; the
boundary is pinned in the unit tests), ascending.
The deterministic sparse-page gate (phase 140, task 01, LOCKED A1)
— the "way to determine when that's necessary" for the pictorial PDF
pass (TODO L5): NO LLM call, pure, deterministic (no PDF, no I/O —
the input is a :func:`extract_pdf_page_texts` map, so the gate never
re-opens the file). A page that carries no text at all has NO key in
the map and is therefore not a candidate here (the importer's pass
owns the page-total handling); an empty map yields ``[]`` — a
clean-text PDF's path never burns a vision call.
The IKEA example (the phase-140 fixture, ``min_chars=100``):
``{1: "BESTÅ", 2: <7 KB of warnings>, 6: "Illustration 6"}`` →
``[1, 6, …]`` — page 1 (the title page: the logo + product name) IS
a candidate: describing it is useful (the logo + product name) and
never harmful; page 2's 7 KB of multilingual warnings is not.
``min_chars=0`` is the documented PLAN A21 special case — **every
page in the map is a candidate** (the "describe everything" mode).
The strict-``<`` test cannot express this (no length is below 0), so
0 is not "a stricter bar": it is the A21 no-cap endpoint, and a
negative value — unreachable through ``Settings`` (the validator
fails startup loudly) — takes the same branch defensively.
"""
if min_chars <= 0:
return sorted(page_texts)
return sorted(
page for page, text in page_texts.items() if len(text.strip()) < min_chars
)
def is_blank_image(
samples: bytes,
*,
mean_min: int = BLANK_IMAGE_MEAN,
tolerance: int = BLANK_IMAGE_TOLERANCE,
fraction: float = BLANK_IMAGE_FRACTION,
) -> bool:
"""Whether *samples* (raw image sample bytes — one byte per sample,
COLOR channels only, no alpha) are a BLANK/DECORATIVE near-white
image (phase 151, task 03).
In the house style of :func:`is_usable_pdf_text` /
:func:`pictorial_pages`: pure, deterministic, NO PDF, NO I/O, NO
LLM — unit-testable with hand-built sample buffers.
Blank when ALL of:
* ``samples`` is non-empty — empty samples → ``False`` (never blank
a thing we cannot measure);
* ``sum(samples) / len(samples) >= mean_min`` — the near-white
floor (default :data:`BLANK_IMAGE_MEAN` = 235). A DARK uniform
block is NOT blank by this rule: the gate targets near-white
PAGE UNDERLAYS (the table-shading rectangles the TODO L4 bug
indexed) — a solid dark block is unusual and cheaper to keep
than to explain away;
* at least ``fraction`` of the samples lie within ``±tolerance``
of the mean (defaults :data:`BLANK_IMAGE_FRACTION` = 0.98,
:data:`BLANK_IMAGE_TOLERANCE` = 17) — the "carries no visible
content" test: a real photo scatters its samples far outside
the band, a decorative underlay sits on one (near-white) level.
Measured basis (the constants' docstring carries the full numbers):
the podman underlays (mean 238, uniq 8, uniform(±8) 0.95–0.995)
are blank; the horse photo (mean 123, uniq 256, uniform ~0.20) and
the handbook page art (mean 24–221, uniq ≥188, uniform 0.10–0.59)
are kept — the mean gate alone carries both real-file "kept" pins,
the fraction+tolerance carry the "near-white but textured" class.
"""
if not samples:
return False
n = len(samples)
mean = sum(samples) / n
if mean < mean_min:
return False
uniform = sum(1 for s in samples if abs(s - mean) <= tolerance) / n
return uniform >= fraction
def render_pdf_pages(
pdf_path: Path, dpi: int = 150, pages: Sequence[int] | None = None
) -> list[bytes]:
"""Render the selected pages of *pdf_path* to PNGs.
*pages* (phase 140, task 02): the optional 1-based selector —
render ONLY the named pages, in ascending order, one PNG each
(a 500-page PDF with 30 pictorial candidates renders 30 PNGs, not
500). ``None`` (the default) = every page in page order — the
phase-133 caller's result is byte-identical, so list index *i* is
page *i*+1 (with a selector, list index *i* is the *i*-th smallest
NAMED page).
The fallback's visual input (phase 133 task 03, ASSUMPTION A8:
fixed 150 DPI — the balance of vision legibility vs data-URL
payload). A page that cannot be rendered — including a named page
outside the document's page range — yields ``b""`` at ITS position
(the caller's placeholder — the result's order NEVER shifts, so it
stays aligned with the requested pages; the failure is logged, the
:func:`extract_pdf_text` fail-soft style). A file that cannot be
OPENED yields ``[]`` (the caller's existing empty-content path —
the doc lands with the no-extractable-text behavior, unchanged).
"""
try:
doc = fitz.open(str(pdf_path))
except Exception as exc:
logger.warning("pdf: could not open %s for page rendering — %s", pdf_path, exc)
return []
# 1-based page numbers: the default walks every page in page order
# (the phase-133 contract — list index *i* is page *i*+1); a
# selector renders the named pages, ascending (de-duplicated — a
# repeated page number would render it twice for no reason).
numbers = (
range(1, len(doc) + 1)
if pages is None
else sorted(set(pages))
)
rendered: list[bytes] = []
with doc:
for page_number in numbers:
try:
rendered.append(
doc[page_number - 1].get_pixmap(dpi=dpi).tobytes("png")
)
except Exception as exc:
logger.warning(
"pdf: could not render page %d of %s — %s",
page_number, pdf_path, exc,
)
rendered.append(b"")
return rendered
def extract_pdf_images_with_stats(
pdf_path: Path, *, skip_blank: bool = True
) -> tuple[list[PdfImage], PdfImageStats]:
"""Extract all DISTINCT embedded images from *pdf_path*, with the
per-PDF drop counts (phase 151, task 03 — the stats-carrying
variant the importer calls; :func:`extract_pdf_images` is the thin
list-only wrapper for every other caller).
Returns ``(images, stats)`` — one :class:`PdfImage` per DISTINCT
embedded image, in page order then 0-based index on the page, plus
the :class:`PdfImageStats` of the drops (``len(images) ==
stats.total_found - stats.duplicates - stats.blank_skipped``). The
FIRST occurrence wins: the kept entry carries the first
placement's ``page``/``index`` (the importer's synthetic path
``…#page=N#img=i.<ext>`` is therefore stable for the first
placement). Two kinds of duplicate are dropped (phase 151, task
01) — PyMuPDF reports one info entry per PLACEMENT, so a PDF that
places the same image N× yields N entries:
* the same ``xref`` seen again (a repeated placement) — the
``seen_xrefs`` set;
* byte-identical stored bytes under a DIFFERENT ``xref`` (the same
sha256 of ``data``) — the ``seen_digests`` set (real documents DO
store the same image under several xrefs — the digest set is what
catches those; unique-xref count overstates the distinct-image
count).
When anything was dropped, ONE INFO line per PDF after the page
loop (``pdf: dropped N duplicate image occurrence(s) from <path>
(M unique)``) plus one DEBUG line per drop (``pdf: duplicate image
xref=… page=… index=… of <path> — skipped``). Dedupe is PER-PDF —
the seen-sets are local to this call, never across files (two PDFs
sharing a logo each keep their own image doc: the synthetic path is
per-PDF, so cross-PDF dedupe would break the upsert key).
``data`` is the original encoded bytes as stored in the PDF;
``mime`` is derived from the image's extension/type via
:func:`_mime_from_image_info` — with one exception (phase 151,
task 02): an image stored with a soft mask (``/SMask``). PyMuPDF's
``extract_image`` returns the BASE sample stream only — the mask
(which carries the visible shape — e.g. the table-shading underlay
the owner's podman PDF stores as a near-white base) is dropped — so
for such images the extractor composites base + mask into the
VISIBLE image (``fitz.Pixmap(base, mask)`` → ``data =
visible.tobytes("png")``, ``mime = "image/png"``): the bytes a PDF
viewer shows, not the blank base. Two rules:
* the RAW-format gate runs FIRST (owner-locked A4): an image whose
raw format the importer's ``_MIME_TO_EXT`` gate rejects (the
``application/octet-stream`` fallback — e.g. JPEG2000 ``jpx``,
the 586 images in ``players_handbook.pdf``) is NOT composited —
raw bytes + raw mime, byte-identical to pre-phase; compositing
must never newly admit an unsupported format (a ``jpx`` image
with a mask stays skipped);
* the compositing is fail-soft: any exception (a colorspace the
combiner cannot merge, a broken mask xref) falls back to the raw
bytes + raw mime with a warning — the image is still indexed,
exactly as pre-phase (a decode problem never loses a real
image). ``width``/``height`` are unchanged either way (the
composited pixmap has the base's dimensions).
THEN the blank/decorative gate (phase 151, task 03, gated by
*skip_blank* — the kill switch at the function level; task 04's
``BOR_PDF_IMAGE_SKIP_BLANK`` setting lands here, dedupe and mask
compositing are NOT knobed): a distinct image whose samples are
near-white AND nearly uniform (:func:`is_blank_image`) is SKIPPED
entirely — not returned (ASSUMPTION A1: never indexed, never
described — no row, no ``image_dir`` copy, no vision call) — with
one INFO line per skipped image (``pdf: skipped blank/decorative
image page=… index=… of <path> — mean=… uniform=…``) and one INFO
line per PDF when anything was skipped (``pdf: skipped N
blank/decorative image(s) in <path>``). The gate measures the
image's BASE sample stream (``fitz.Pixmap(doc, xref)``, the alpha
channel dropped first when present — the mean is over COLOR
samples only, an alpha channel would drag the mean and produce
false blanks) for every image — masked or not: for a masked image
the base is the correct "visible content" signal, because the
``/SMask`` only modulates the base's VISIBILITY (a near-white base
renders near-white over the white page under ANY mask, so a
near-white base is decorative no matter what the mask says) — and
the stored composite's color channels are PyMuPDF's
PREMULTIPLIED base×mask/255, which darkens the transparent regions
and would miss the very near-white underlays this gate targets
(measured on the podman underlays: alpha-stripped composite
mean ≈ 231–234 / uniform ≈ 0.95 vs base mean ≈ 238 / uniform
≈ 0.99 — only the base clears the pinned thresholds on every
underlay). The gate is fail-soft: a pixmap that cannot be built
skips the gate (the image is KEPT, DEBUG why) — a decode problem
must never delete a real image.
Fail-soft: an unreadable file returns ``([], PdfImageStats(0, 0,
0))``; an image without an ``xref`` or that cannot be extracted is
logged and skipped.
"""
try:
doc = fitz.open(str(pdf_path))
except Exception as exc:
logger.warning("pdf: could not open %s for image extraction — %s", pdf_path, exc)
return [], PdfImageStats(duplicates=0, blank_skipped=0, total_found=0)
seen_xrefs: set[int] = set()
seen_digests: set[str] = set()
dropped = 0
blank_skipped = 0
total_found = 0
images: list[PdfImage] = []
with doc:
for page_num in range(len(doc)):
try:
entries = _page_image_entries(doc[page_num])
except Exception as exc:
logger.warning(
"pdf: could not list images on page %d of %s — %s",
page_num + 1, pdf_path, exc,
)
continue
for index, info in enumerate(entries):
xref = int(info.get("xref") or 0)
if not xref:
logger.warning(
"pdf: skipping image page=%d index=%d of %s — no xref",
page_num + 1, index, pdf_path,
)
continue
try:
extracted = doc.extract_image(xref)
except Exception as exc:
logger.warning(
"pdf: could not extract image page=%d index=%d of %s — %s",
page_num + 1, index, pdf_path, exc,
)
continue
data = extracted.get("image") or b""
if not data:
logger.warning(
"pdf: skipping image page=%d index=%d of %s — empty data",
page_num + 1, index, pdf_path,
)
continue
# One image occurrence found (valid xref, extracted,
# non-empty data) — the input to dedupe + gate (task
# 03's total_found; len(images) = total_found -
# duplicates - blank_skipped).
total_found += 1
# Phase 151 (task 01): one PdfImage per DISTINCT image.
# Repeated placements share the xref (the first check);
# byte-identical bytes stored under a different xref are
# caught by the digest of the stored bytes (the second).
if xref in seen_xrefs:
dropped += 1
logger.debug(
"pdf: duplicate image xref=%d page=%d index=%d of %s — skipped",
xref, page_num + 1, index, pdf_path,
)
continue
digest = hashlib.sha256(data).hexdigest()
if digest in seen_digests:
dropped += 1
logger.debug(
"pdf: duplicate image xref=%d page=%d index=%d of %s — skipped",
xref, page_num + 1, index, pdf_path,
)
continue
seen_xrefs.add(xref)
seen_digests.add(digest)
# The image info's own `ext` is unreliable in some
# PyMuPDF versions (None); `extract_image` reports the
# actual format of the returned bytes, so it wins.
mime = _mime_from_image_info({**info, "ext": extracted.get("ext") or ""})
# Phase 151 (task 02): a /SMask carries the visible
# shape — `extract_image` returns the BASE sample
# stream only (the mask is dropped), so the raw bytes
# are not what a PDF viewer shows. Composite base +
# mask to the visible alpha PNG.
#
# Ordering rule (owner-locked A4): the raw-format gate
# runs FIRST — an image whose raw format the importer's
# `_MIME_TO_EXT` gate will reject (the
# `application/octet-stream` fallback — e.g. the 586
# JPEG2000 `jpx` images in players_handbook.pdf) is
# left EXACTLY as today (raw bytes, raw mime, skipped
# downstream): compositing must never newly admit an
# unsupported format.
smask = int(extracted.get("smask") or 0)
# The image's BASE pixmap, kept for the blank gate
# (task 03) when the compositing built it (a second
# decode of the same xref for the gate would be wasted
# work; when the compositing never ran — no mask or an
# unsupported raw format — the gate builds it itself).
base_pixmap: Any = None
if smask and mime != "application/octet-stream":
try:
base = fitz.Pixmap(doc, xref)
base_pixmap = base
mask = fitz.Pixmap(doc, smask)
visible = fitz.Pixmap(base, mask) # base + alpha-from-mask
data = visible.tobytes("png")
mime = "image/png"
logger.debug(
"pdf: composited smask=%d for image page=%d index=%d of %s",
smask, page_num + 1, index, pdf_path,
)
except Exception as exc:
# Fail-soft: `data`/`mime` keep their raw
# values — the image is still indexed, exactly
# as pre-phase (a decode problem never loses a
# real image).
logger.warning(
"pdf: could not composite smask=%d for image page=%d "
"index=%d of %s — %s; using raw bytes",
smask, page_num + 1, index, pdf_path, exc,
)
# Phase 151 (task 03): the blank/decorative gate — a
# near-white, nearly-uniform image (a table-shading
# underlay) carries no visible content: skip it
# entirely (ASSUMPTION A1 — no row, no copy, no vision
# call). Gated by `skip_blank` (the function-level kill
# switch; task 04's settings knob lands here) — dedupe
# and mask compositing above are NOT knobed.
if skip_blank:
try:
# The BASE sample stream (the docstring's gate-
# input rule): the base's color content is what
# the image SHOWS (a mask only modulates its
# visibility), and PyMuPDF's stored composite
# is premultiplied (base×mask/255) — darkened
# in the transparent regions, where the gate
# would miss the very underlays it targets.
gate_pixmap = (
base_pixmap
if base_pixmap is not None
else fitz.Pixmap(doc, xref)
)
if gate_pixmap.alpha:
# Drop the alpha FIRST: the mean is over
# COLOR samples only (an alpha channel
# would drag the mean and produce false
# blanks).
gate_pixmap = fitz.Pixmap(gate_pixmap, 0)
samples = gate_pixmap.samples
if is_blank_image(samples):
blank_skipped += 1
mean = sum(samples) / len(samples)
uniform = (
sum(
1
for s in samples
if abs(s - mean) <= BLANK_IMAGE_TOLERANCE
)
/ len(samples)
)
logger.info(
"pdf: skipped blank/decorative image page=%d "
"index=%d of %s — mean=%.1f uniform=%.3f",
page_num + 1, index, pdf_path, mean, uniform,
)
continue
except Exception as exc:
# Fail-soft: a pixmap that cannot be built
# skips the gate (the image is KEPT — a decode
# problem must never delete a real image).
logger.debug(
"pdf: blank gate could not build pixmap page=%d "
"index=%d of %s — %s; keeping image",
page_num + 1, index, pdf_path, exc,
)
images.append(
PdfImage(
page=page_num + 1,
index=index,
width=int(extracted.get("width") or info.get("width") or 0),
height=int(extracted.get("height") or info.get("height") or 0),
data=data,
mime=mime,
)
)
if dropped:
logger.info(
"pdf: dropped %d duplicate image occurrence(s) from %s (%d unique)",
dropped, pdf_path, len(images),
)
if blank_skipped:
logger.info(
"pdf: skipped %d blank/decorative image(s) in %s",
blank_skipped, pdf_path,
)
return images, PdfImageStats(
duplicates=dropped, blank_skipped=blank_skipped, total_found=total_found
)
def extract_pdf_images(
pdf_path: Path, *, skip_blank: bool = True
) -> list[PdfImage]:
"""Extract all DISTINCT embedded images from *pdf_path*.
Thin wrapper over :func:`extract_pdf_images_with_stats` (phase 151,
task 03) returning the image list only — the pre-task-03 contract
for every other caller. *skip_blank* (default ON) passes straight
through: the kill switch at the function level —
``extract_pdf_images(path, skip_blank=False)`` restores the
pre-phase result (every distinct image returned, gate off; the
dedupe and mask compositing stay on — they are correctness fixes,
not knobed). The importer calls the ``_with_stats`` variant so the
run's :class:`~app.rag.importer.ImportSummary` can record the drop
counts (the phase-133 stats log-only convention).
"""
return extract_pdf_images_with_stats(pdf_path, skip_blank=skip_blank)[0]