feat(assets): a bundle carries the images its sources declare (0.10.0)
Until now no reader in this package fetched, named, described or copied a single image. `<img>`'s attributes were never read, a NISO-STS `<graphic>` was walked past, a PDF was opened for its text alone, the converter's markdown writer dropped every picture, and the only writer into a bundle took `content: str`. The two lossiness warnings said so on every run, which made the loss honest and did not make it smaller. Measured on R761 Prosesskoden:2025, published as a 701-page PDF and as a NISO-STS delivery: the process text is carried in full while 12 `Tabell N-N` and 9 `Figur N-N` captions stand over nothing, because that publisher ships those tables as raster pictures in both. Process 84's "toleranseklasse ... er gitt i tabell 84-2" points at empty space. THE GATE WAS WRITTEN FIRST AND RED. `tests/test_asset_gate.py` reads its denominator out of the source (`page.images`, `word/media/`, `ppt/media/`, `<img`, `<graphic`), never from a constant here. Measured at332961a, built from `git archive` and not from the editable tree: carried 0 of 8 local images across 5 documents (9 declared), and no `assets/` at all. After: 8 of 8, with the ninth a remote source carried as a pointer without a file. FIVE READERS PLACE, ONE MODULE DECIDES. `assets.py` owns what an image is (sniffed from the bytes, never from the claimed extension), what it is called (`<sha256[:12]>-<the source's own basename>`) and how it is pointed at (one two-line block, one regex). `.xlsx` is deliberately not a row: a block inside its pipe tables would break the `source_rows` locator, and 0 of 4 K2 workbooks hold media. A PDF stream that is already a file is carried VERBATIM (29 of R761's 50 objects are DCTDecode); raw samples are encoded to PNG with stdlib zlib, so no new dependency. Rendering the page region was the alternative and was felled on determinism: a rasterised crop's bytes, and therefore the asset's content-addressed name and the bundle's digest, would depend on the installed rasteriser. What the encoder cannot express exactly is refused with a code and counted, never approximated. NO SIZE FLOOR, and that is a measurement: over the 4 828 image objects of the K2 corpus the size distribution is a broad spread with no gap, unlike OCR_CID_SHARE's bimodal one, so a threshold would be a number we chose. ON BY DEFAULT, AND THE CONTROL IS TWO WHOLE BUILDS. The 43-document reference corpus at332961aversus rebuilt at HEAD with `--no-assets`: 865 files on both sides, `diff -rq` reports ONE difference, the added `Images: NOT CARRIED` line in log.md. Every concept byte-identical. Against the default: 453 -> 454 concepts, 865 -> 867 md, 0 -> 2 964 assets (2 964 carried of 3 145 found, 4 622 pointers), 4.7 MB -> 115 MB, 2 414 s -> 3 088 s, peak RSS 6.26 -> 8.74 GB, 422 of 865 md files differ. The one new concept has a measured cause: the pointers are body text, so a section holding 146 of that document's images grew from 19.0 % to 30.6 % of the extracted text and crossed `--outline-gate`'s 0.20 share clause. THE IMAGE BYTES ARE NOT SCREENED. The guard is text-only, the pointer block passes the gate as body text, the picture beside it passes nothing, and log.md says so on every run. Also fixed, both found by measuring rather than by reading: - a markdown image is no longer read as a cross-reference. `structure._LINK` never looked at the character in front of the bracket, so every pointer would have arrived in the index as an edge to a concept that cannot exist. - Door C carries the assets its merged concepts point at. Before this, importing a bundle built with `--assets` merged 6 of 6 concepts and wrote no `assets/` at all, so every pointer named a missing file. Report: docs/2026-09-17-bilder-i-bundlen-trinn1.md Spec proposal: docs/plan/okf-assets-section-6-4.md Suite 1 955 passed / 1 skipped (from 1 896), ruff and mypy --strict clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
332961a19c
commit
bc39e8091f
33 changed files with 3638 additions and 64 deletions
|
|
@ -62,7 +62,14 @@ from pathlib import Path
|
|||
from typing import Any
|
||||
|
||||
from .errors import IngestError
|
||||
from .extract import OutlineMark, extract_text, strip_converter_attribute, xml_outline
|
||||
from .extract import (
|
||||
OutlineMark,
|
||||
Resolver,
|
||||
directory_resolver,
|
||||
extract_text,
|
||||
strip_converter_attribute,
|
||||
xml_outline,
|
||||
)
|
||||
from .extract import pdf_outline as extract_pdf_outline
|
||||
from .materialize import reduce_to_id_grammar
|
||||
from .segmentation import heading_only, observed_extractor_version
|
||||
|
|
@ -1613,6 +1620,8 @@ def build_plan(
|
|||
bold_title: bool = False,
|
||||
outline_marks: Sequence[OutlineMark] | None = None,
|
||||
shell_parent: bool = False,
|
||||
assets: bool = False,
|
||||
resolve: Resolver | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""The artifact. Every entry PROPOSED, the plan itself never adjudicated."""
|
||||
taken: set[str] = set()
|
||||
|
|
@ -1626,7 +1635,12 @@ def build_plan(
|
|||
# is the document.
|
||||
outline_rule = RULE_PDF_OUTLINE
|
||||
if extractor_id in DECLARED_STRUCTURE_IDS:
|
||||
outline_marks = xml_outline(source.name, source_bytes)
|
||||
# The SAME rendering the text was extracted under. A mark is a line
|
||||
# index into that exact string, and carrying an image inserts lines:
|
||||
# marks read with the images off would name the right sections at the
|
||||
# wrong line numbers, silently, on every document with a figure above a
|
||||
# heading.
|
||||
outline_marks = xml_outline(source.name, source_bytes, assets=assets, resolve=resolve)
|
||||
outline_rule = RULE_XML_SECTION
|
||||
entries: list[dict[str, Any]] = []
|
||||
candidates = find_candidates(
|
||||
|
|
@ -1755,6 +1769,7 @@ def run(
|
|||
ocr: bool = False,
|
||||
pdf_outline: bool = False,
|
||||
shell_parent: bool = False,
|
||||
assets: bool = False,
|
||||
) -> int:
|
||||
if max_segment_chars < 0:
|
||||
raise ProposerError(
|
||||
|
|
@ -1798,7 +1813,19 @@ def run(
|
|||
# exact string -- a plan proposed against one rendering and replayed
|
||||
# against another is refused by `assert_plan_applies`, which is the
|
||||
# right outcome and a confusing one to debug.
|
||||
text = extract_text(source.name, source_bytes, pdf_headings=pdf_headings, ocr=ocr)
|
||||
# Rooted at the document's own directory, which is what Door B derives
|
||||
# for the same file. The two sides never exchange the root; they compute
|
||||
# it, so a plan and the run that replays it read the same bytes for
|
||||
# every `<img src>` and every `<graphic xlink:href>`.
|
||||
resolve = directory_resolver(source.parent) if assets else None
|
||||
text = extract_text(
|
||||
source.name,
|
||||
source_bytes,
|
||||
pdf_headings=pdf_headings,
|
||||
ocr=ocr,
|
||||
assets=assets,
|
||||
resolve=resolve,
|
||||
)
|
||||
reading_fonts = pdf_headings
|
||||
# The reserve, and the reason it re-extracts rather than post-processes:
|
||||
# the font reader works on the PDF's glyph geometry, which the joined
|
||||
|
|
@ -1807,7 +1834,14 @@ def run(
|
|||
# never a pair to combine.
|
||||
if pdf_headings_reserve and not pdf_headings:
|
||||
if heading_reserve_applies(text, outline_run=outline_run):
|
||||
text = extract_text(source.name, source_bytes, pdf_headings=True, ocr=ocr)
|
||||
text = extract_text(
|
||||
source.name,
|
||||
source_bytes,
|
||||
pdf_headings=True,
|
||||
ocr=ocr,
|
||||
assets=assets,
|
||||
resolve=resolve,
|
||||
)
|
||||
reading_fonts = True
|
||||
# LAST, and against the text that is final: a plan indexes one exact
|
||||
# string, so marks bridged onto the pre-reserve rendering would name
|
||||
|
|
@ -1815,7 +1849,7 @@ def run(
|
|||
# reserve may have changed, and the marks follow it.
|
||||
marks = (
|
||||
extract_pdf_outline(
|
||||
source.name, source_bytes, pdf_headings=reading_fonts, ocr=ocr
|
||||
source.name, source_bytes, pdf_headings=reading_fonts, ocr=ocr, assets=assets
|
||||
).marks
|
||||
if pdf_outline
|
||||
else ()
|
||||
|
|
@ -1844,6 +1878,8 @@ def run(
|
|||
bold_title=bold_title,
|
||||
outline_marks=marks,
|
||||
shell_parent=shell_parent,
|
||||
assets=assets,
|
||||
resolve=resolve,
|
||||
)
|
||||
# Nothing to propose is an OUTCOME, and it is not an artifact. An empty
|
||||
# plan cannot be replayed -- `process_inbox` refuses one, because a plan
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue