llm-ingestion-okf/tests/test_assets.py
Kjell Tore Guttormsen b0b5e71658
feat(assets): every carried image is one a model can be shown
Chosen: a stdlib BMP reader, because `read_image` is on the CORE path and an
asset's name is its content digest. Measured first, as the order requires:
Pillow 12.3.0 IS in this tree (transitively under `pdfplumber`) and it DOES
decode RLE8 correctly -- a hand-written stdlib decoder and Pillow agree on
19 of 19 of R761's real files, RGB per pixel. So the choice does not rest on
capability. It rests on two properties of this package: `.html` and `.xml`
carry images with no `[extract]` extra installed, so a Pillow converter
either makes a core path depend on an optional binary wheel or buys the
second runtime dependency; and encoding through an installed library would
make a bundle's identity move with that library's version, which is the
property 0.10.0 felled page rasterisation over and `encode_png`'s docstring
already defends. Pillow keeps the job it is good for: the INDEPENDENT decoder
in the tests, on neither side of the conversion.

The defect, measured over the frozen R761 delivery's `assets/`, denominator
50: 29 JPEG, 2 PNG and 19 RLE8 BMP. The 19 are byte-correct files nothing
reads, so 19 figures were present and invisible while `images: N` reported
that they had arrived.

- `VIEWABLE_MEDIA_TYPES` is tested against every asset's SNIFFED type, so it
  is a property and not a list of formats we met. WebP is on it and `sniff`
  does not recognise one; the limit is stated, not implied.
- `bmp_to_png`: 8-bit uncompressed, 8-bit RLE8, 24-bit uncompressed. All five
  RLE8 opcodes. 19 of 19 real files convert with RGB identical to Pillow's
  decoding of the source, 2 366 365 pixels compared.
- `asset_not_viewable` and `asset_bmp_unsupported`, both published, both
  leaving the concept's "not carried" line.
- Traceability on the pointer's second line, where the rest of the asset
  metadata already lives: original media type, original sha256 in full, new
  sha256 in full. A converted asset is ONE asset.
- The ceiling is paid on the DECLARATION before a row is allocated, and an
  RLE run is one clipped slice -- painting pixel by pixel leaves the memory
  bounded and the CPU unbounded.

Two repairs the change forced, each measured rather than assumed:

- `tests/test_assets.py`'s "dimensions absent is absent" used a TIFF, which
  is now refused before `read_image` returns. The property still has a
  reachable case -- a JPEG whose frame header never arrives -- and uses it.
- `asset_holds` in the accounting gate proved a carry by hashing the SOURCE
  file, which a converted image's bundle cannot satisfy. It now also reads
  the two digests the bundle states and HASHES THE ASSET ITSELF, so a bundle
  claiming a conversion it did not perform still fails.

`tools/okf_asset_census.py` is the committed instrument for the
known-positive: one row per image, from two pinned trees. It was caught by
the rule it serves -- its first version handed `_pdf_images` the wrong page
object and reported 0 images over 67 PDFs with exit 0. The attribute is
asserted now and a known-positive runs before the sweep.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 08:07:04 +02:00

344 lines
14 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.
The vehicle used to be a TIFF. Since the viewable-asset round a TIFF never reaches
`read_image`'s return at all -- it is a format no model can be shown and
there is no lossless conversion for it, so it is refused as
`asset_not_viewable`. The property still has a reachable case, and this is
it: a JPEG whose frame header never arrives. `sniff` types it from the
first three bytes, `_jpeg_dimensions` walks to the end and finds no SOF,
and the pointer has to say so rather than print a size.
"""
headless = b"\xff\xd8\xff\xfe\x00\x04ab\xff\xd9"
assert assets.sniff(headless) == ("image/jpeg", ".jpg")
assert assets.dimensions(headless) is None
image = assets.read_image(headless, name="scan.jpg")
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"![Tabell 84-2]({assets.asset_href(image)})"
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("![graphic_0003.jpg](")
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"![Tabell (84-2) jf. pkt (3)]({assets.asset_href(image)})"
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)