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>
336 lines
13 KiB
Python
336 lines
13 KiB
Python
"""Binary assets: the layer that lets a bundle carry an image at all.
|
|
|
|
Until 0.10.0 no reader in this package fetched, named, described or copied a
|
|
single image, and the only writer into a bundle was
|
|
`materialize.write_bytes(bundle_dir, name, content: str)` -- UTF-8, text, no
|
|
binary path anywhere. A document whose table is a raster picture therefore
|
|
reached a concept as an absence with no denominator: measured on R761
|
|
Prosesskoden:2025, the process text is carried in full while 12 `Tabell N-N`
|
|
and 9 `Figur N-N` captions stand over nothing, and process 84 says "toleranse-
|
|
klasse ... er gitt i tabell 84-2" above an empty space.
|
|
|
|
THIS MODULE IS THE ONE PLACE THAT DECIDES WHAT AN IMAGE IS. Every reader hands
|
|
it bytes and gets back either a carried image or a coded rejection, so a
|
|
format's own quirks never reach the bundle layout, and "N images carried of M
|
|
found" has one definition for `pdf`, `docx`, `html` and `xml` alike.
|
|
|
|
Three properties are pinned here because a bundle is downstream of all of them:
|
|
|
|
- **the type is SNIFFED, never claimed.** A `.jpg` that is really a PNG would
|
|
otherwise be written under a name whose extension lies, and a consumer that
|
|
dispatches on the extension reads it wrong with full confidence.
|
|
- **the name is CONTENT-ADDRESSED.** `<sha256[:12]>-<reduced original name>`
|
|
makes two drops of one image one file, and makes a rebuild of the same
|
|
corpus produce the same bundle -- the byte-determinism rule this package
|
|
already holds for text.
|
|
- **the pointer is one GRAMMAR.** Step 2 (`okf describe`) has to find every
|
|
pointer mechanically in order to write a transcription under it, so the
|
|
block is a regex this module owns rather than a shape each reader invents.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import re
|
|
import zlib
|
|
|
|
import pytest
|
|
|
|
from llm_ingestion_okf import assets
|
|
from llm_ingestion_okf.errors import ExtractionError
|
|
|
|
# --- hand-laid image bytes -------------------------------------------------
|
|
#
|
|
# Written out here rather than committed as files: every byte is visible in the
|
|
# test that depends on it, and the readers below are header readers, so a
|
|
# header is the whole input they have. The PNG is a REAL image (zlib from the
|
|
# stdlib); the JPEG is a structurally valid header sequence and not a decodable
|
|
# photograph, which is exactly what the JPEG path needs -- it copies the bytes
|
|
# through and reads nothing but the SOF marker.
|
|
|
|
|
|
def _png(width: int, height: int) -> bytes:
|
|
def chunk(kind: bytes, payload: bytes) -> bytes:
|
|
return (
|
|
len(payload).to_bytes(4, "big")
|
|
+ kind
|
|
+ payload
|
|
+ zlib.crc32(kind + payload).to_bytes(4, "big")
|
|
)
|
|
|
|
ihdr = width.to_bytes(4, "big") + height.to_bytes(4, "big") + bytes([8, 0, 0, 0, 0])
|
|
raw = b"".join(b"\x00" + bytes([0x40] * width) for _ in range(height))
|
|
return (
|
|
b"\x89PNG\r\n\x1a\n"
|
|
+ chunk(b"IHDR", ihdr)
|
|
+ chunk(b"IDAT", zlib.compress(raw, 9))
|
|
+ chunk(b"IEND", b"")
|
|
)
|
|
|
|
|
|
def _jpeg(width: int, height: int) -> bytes:
|
|
sof = bytes([8, height >> 8, height & 0xFF, width >> 8, width & 0xFF, 1, 1, 0x11, 0])
|
|
return (
|
|
b"\xff\xd8"
|
|
b"\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00"
|
|
+ b"\xff\xc0"
|
|
+ (len(sof) + 2).to_bytes(2, "big")
|
|
+ sof
|
|
+ b"\xff\xd9"
|
|
)
|
|
|
|
|
|
def _gif(width: int, height: int) -> bytes:
|
|
return (
|
|
b"GIF89a"
|
|
+ width.to_bytes(2, "little")
|
|
+ height.to_bytes(2, "little")
|
|
+ b"\x00\x00\x00"
|
|
+ b";"
|
|
)
|
|
|
|
|
|
def _bmp(width: int, height: int) -> bytes:
|
|
header = b"BM" + (54).to_bytes(4, "little") + b"\x00" * 4 + (54).to_bytes(4, "little")
|
|
dib = (
|
|
(40).to_bytes(4, "little")
|
|
+ width.to_bytes(4, "little", signed=True)
|
|
+ height.to_bytes(4, "little", signed=True)
|
|
+ b"\x01\x00\x18\x00"
|
|
+ b"\x00" * 24
|
|
)
|
|
return header + dib
|
|
|
|
|
|
# --- the type is sniffed, never claimed ------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("data", "media_type", "suffix"),
|
|
[
|
|
(_png(4, 3), "image/png", ".png"),
|
|
(_jpeg(360, 269), "image/jpeg", ".jpg"),
|
|
(_gif(7, 5), "image/gif", ".gif"),
|
|
(_bmp(9, 2), "image/bmp", ".bmp"),
|
|
],
|
|
)
|
|
def test_sniff_reads_the_bytes(data: bytes, media_type: str, suffix: str) -> None:
|
|
assert assets.sniff(data) == (media_type, suffix)
|
|
|
|
|
|
def test_sniff_refuses_what_is_not_an_image() -> None:
|
|
assert assets.sniff(b"%PDF-1.7\n") is None
|
|
assert assets.sniff(b"") is None
|
|
|
|
|
|
def test_the_claimed_extension_never_decides() -> None:
|
|
"""A PNG named `.jpg` is carried as a PNG, under a `.png` name."""
|
|
image = assets.read_image(_png(4, 3), name="tabell-84-2.jpg")
|
|
assert image.media_type == "image/png"
|
|
assert assets.asset_name(image).endswith(".png")
|
|
|
|
|
|
def test_unknown_bytes_are_a_coded_rejection_not_a_crash() -> None:
|
|
with pytest.raises(ExtractionError) as excinfo:
|
|
assets.read_image(b"not an image at all", name="x.png")
|
|
assert excinfo.value.code == "asset_type_unknown"
|
|
|
|
|
|
# --- dimensions, from the header of each format ----------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("data", "size"),
|
|
[
|
|
(_png(4, 3), (4, 3)),
|
|
(_jpeg(360, 269), (360, 269)),
|
|
(_gif(7, 5), (7, 5)),
|
|
(_bmp(9, 2), (9, 2)),
|
|
],
|
|
)
|
|
def test_dimensions_from_the_header(data: bytes, size: tuple[int, int]) -> None:
|
|
assert assets.dimensions(data) == size
|
|
|
|
|
|
def test_dimensions_absent_is_absent_never_zero() -> None:
|
|
"""A format whose size this module does not read says so, rather than 0x0.
|
|
|
|
`0x0 px` in a concept is a measurement nobody took, printed as a fact.
|
|
"""
|
|
tiff = b"II\x2a\x00" + b"\x00" * 16
|
|
assert assets.sniff(tiff) == ("image/tiff", ".tiff")
|
|
assert assets.dimensions(tiff) is None
|
|
image = assets.read_image(tiff, name="scan.tiff")
|
|
assert image.width is None and image.height is None
|
|
assert "dimensions unknown" in assets.render_block(image)
|
|
|
|
|
|
# --- the name is content-addressed -----------------------------------------
|
|
|
|
|
|
def test_asset_name_is_digest_plus_a_reduced_original() -> None:
|
|
data = _jpeg(360, 269)
|
|
image = assets.read_image(data, name="25-0143 - Tabeller - Prosesskoden (R761-R762).jpg")
|
|
name = assets.asset_name(image)
|
|
assert name.startswith(hashlib.sha256(data).hexdigest()[: assets.DIGEST_PREFIX] + "-")
|
|
assert name.endswith(".jpg")
|
|
# The link target sits inside `](...)`, which a space or a parenthesis
|
|
# terminates -- and `structure._LINK` reads exactly that grammar. A name
|
|
# carrying either would produce a pointer no reader can follow.
|
|
assert re.fullmatch(r"[a-z0-9][a-z0-9.-]*", name), name
|
|
|
|
|
|
def test_two_drops_of_one_image_are_one_asset() -> None:
|
|
data = _png(4, 3)
|
|
first = assets.read_image(data, name="figur.png")
|
|
second = assets.read_image(data, name="figur.png")
|
|
assert assets.asset_name(first) == assets.asset_name(second)
|
|
|
|
|
|
def test_one_name_over_two_contents_stays_two_assets() -> None:
|
|
a = assets.read_image(_png(4, 3), name="figur.png")
|
|
b = assets.read_image(_png(5, 3), name="figur.png")
|
|
assert assets.asset_name(a) != assets.asset_name(b)
|
|
|
|
|
|
def test_a_nameless_image_still_gets_a_name() -> None:
|
|
image = assets.read_image(_png(4, 3), name="")
|
|
assert re.fullmatch(r"[0-9a-f]{12}\.png", assets.asset_name(image))
|
|
|
|
|
|
# --- the pointer is one grammar --------------------------------------------
|
|
|
|
|
|
def test_block_names_the_original_the_size_and_the_label() -> None:
|
|
image = assets.read_image(
|
|
_jpeg(360, 269),
|
|
name="25-0143 - Tabeller - Prosesskoden (R761-R762).jpg",
|
|
label="Tabell 84-2",
|
|
)
|
|
block = assets.render_block(image)
|
|
first, second = block.split("\n")
|
|
assert first == f"})"
|
|
assert second.startswith("Image: 25-0143 - Tabeller - Prosesskoden (R761-R762).jpg")
|
|
assert "360x269 px" in second
|
|
assert second.endswith("Tabell 84-2")
|
|
|
|
|
|
def test_the_label_falls_back_to_the_original_name() -> None:
|
|
"""Two of the four readers have no caption element at all.
|
|
|
|
A PDF image object and an STS `<graphic>` carry no caption: the caption on
|
|
the page is running text the extractor already emitted. Inventing one from
|
|
the nearest line would be an unmarked heuristic, so the alt slot carries
|
|
the name the source gave the file.
|
|
"""
|
|
image = assets.read_image(_png(4, 3), name="graphic_0003.jpg")
|
|
assert assets.render_block(image).startswith("
|
|
|
|
|
|
def test_a_bracket_in_a_label_cannot_break_the_link() -> None:
|
|
image = assets.read_image(_png(4, 3), name="f.png", label="Tabell [84-2] jf. pkt (3)")
|
|
first = assets.render_block(image).split("\n")[0]
|
|
assert first == f"})"
|
|
|
|
|
|
def test_a_newline_in_a_label_cannot_open_a_third_line() -> None:
|
|
image = assets.read_image(_png(4, 3), name="f.png", label="Tabell\n84-2")
|
|
assert len(assets.render_block(image).split("\n")) == 2
|
|
|
|
|
|
def test_the_pointer_regex_finds_every_shipped_block() -> None:
|
|
"""Step 2 has to find these mechanically; the finder ships with the writer."""
|
|
images = [
|
|
assets.read_image(_png(4, 3), name="a.png", label="Figur 1"),
|
|
assets.read_image(_jpeg(9, 9), name="b.jpg"),
|
|
]
|
|
text = "Prosess 84\n\n" + "\n\n".join(assets.render_block(i) for i in images) + "\n\nSlutt\n"
|
|
found = assets.IMAGE_POINTER.findall(text)
|
|
assert [match[1] for match in found] == [assets.asset_name(i) for i in images]
|
|
|
|
|
|
def test_href_is_bundle_absolute() -> None:
|
|
"""SPEC SS 6.2 allows a bundle-relative path with a leading `/`.
|
|
|
|
A concept can sit at any depth under a segmented bundle, so a relative
|
|
`assets/...` would resolve differently from two concepts of one document.
|
|
"""
|
|
image = assets.read_image(_png(4, 3), name="f.png")
|
|
assert assets.asset_href(image) == f"/{assets.ASSETS_DIR}/{assets.asset_name(image)}"
|
|
|
|
|
|
# --- PNG encoding, for samples that arrive without a container -------------
|
|
#
|
|
# A PDF image is usually not a file: `FlateDecode` hands back raw samples with
|
|
# the colour model in the dictionary beside them. Encoding those is the only
|
|
# way to carry them at all, and it is stdlib (`zlib`), so the `pdf` row does
|
|
# not gain a dependency and the output is not bound to a renderer's version.
|
|
|
|
|
|
def test_png_from_gray_samples_round_trips_the_header() -> None:
|
|
encoded = assets.encode_png(3, 2, bytes([0, 64, 128, 192, 255, 32]), channels=1)
|
|
assert assets.sniff(encoded) == ("image/png", ".png")
|
|
assert assets.dimensions(encoded) == (3, 2)
|
|
|
|
|
|
def test_png_from_rgb_samples_round_trips_the_header() -> None:
|
|
encoded = assets.encode_png(2, 1, bytes(range(6)), channels=3)
|
|
assert assets.dimensions(encoded) == (2, 1)
|
|
|
|
|
|
def test_png_from_indexed_samples_carries_the_palette() -> None:
|
|
palette = bytes([255, 0, 0, 0, 255, 0])
|
|
encoded = assets.encode_png(2, 1, bytes([0, 1]), channels=1, palette=palette)
|
|
assert assets.dimensions(encoded) == (2, 1)
|
|
assert b"PLTE" in encoded
|
|
|
|
|
|
def test_png_refuses_a_sample_count_that_does_not_fit() -> None:
|
|
"""Refused rather than padded: a short buffer is a misread dictionary."""
|
|
with pytest.raises(ExtractionError) as excinfo:
|
|
assets.encode_png(4, 4, b"\x00\x01", channels=1)
|
|
assert excinfo.value.code == "asset_samples_invalid"
|
|
|
|
|
|
def test_png_encoding_is_byte_stable() -> None:
|
|
first = assets.encode_png(3, 2, bytes([0, 64, 128, 192, 255, 32]), channels=1)
|
|
second = assets.encode_png(3, 2, bytes([0, 64, 128, 192, 255, 32]), channels=1)
|
|
assert first == second
|
|
|
|
|
|
def test_the_restated_reduction_is_materializes_own() -> None:
|
|
"""`assets` cannot import `materialize`, so the rule is held equal instead.
|
|
|
|
`extract.py`'s registry must not import the contract layer (its own
|
|
docstring says so, and `materialize` reaches `manifest` and `profiles`), so
|
|
the id-grammar reduction is written out a second time. This is what stops
|
|
the second copy becoming a second grammar.
|
|
"""
|
|
from llm_ingestion_okf.materialize import reduce_to_id_grammar
|
|
|
|
for value in (
|
|
"25-0143 - Tabeller - Prosesskoden (R761-R762)",
|
|
"Figur 11.1 Toleransekrav",
|
|
"grafikk_med_æøå",
|
|
"---",
|
|
"",
|
|
):
|
|
assert assets._reduce(value) == reduce_to_id_grammar(value), value
|
|
|
|
|
|
def test_one_image_reached_by_two_paths_is_one_asset() -> None:
|
|
"""The asset name reads the BASENAME, never the path the document used.
|
|
|
|
Measured on the fixture inbox before this rule existed: the HTML document
|
|
points at `graphics/figur-84-1.png` and the STS document at
|
|
`figur-84-1.png` (resolved through the `graphics/` sibling convention), so
|
|
one image was written twice, under two names, from one run -- with the
|
|
digest in both of them announcing that the bytes were identical. The path a
|
|
document happened to use is not a property of the picture.
|
|
"""
|
|
data = _png(6, 4)
|
|
through_directory = assets.read_image(data, name="graphics/figur-84-1.png")
|
|
bare = assets.read_image(data, name="figur-84-1.png")
|
|
assert assets.asset_name(through_directory) == assets.asset_name(bare)
|
|
# The full original is not lost -- it moves to the line a person reads.
|
|
assert "graphics/figur-84-1.png" in assets.render_block(through_directory)
|