From 35b6b79864ec4242298ec40a736a674db20fb6cc Mon Sep 17 00:00:00 2001 From: lepy Date: Mon, 29 Jun 2026 15:46:27 +0200 Subject: [PATCH] =?UTF-8?q?feat(image):=20native,=20format-=C3=BCbergreife?= =?UTF-8?q?nde=20sdata-Metadaten=20in=20Bildern=20(RFC=200005)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neuer Pillow-freier Layer sdata/imagemeta.py bettet sdata-Metadaten nativ in Bild-Bytes ein bzw. liest sie aus — einheitliche API über fünf Container: - PNG -> iTXt-Chunk (Keyword sdata) - JPEG -> APP1-Segment (Kennung sdata\0) - JP2 -> uuid-Box (feste sdata-UUID) vor jp2c - GIF -> Comment-Extension (Praefix sdata\0) - WebP -> eigener RIFF-Chunk sdAT (von Decodern ignoriert) Fassade: detect_format / embed / extract / supported_formats. Replace-Semantik (idempotent), lenient beim Lesen, Registry erweiterbar. Format-Erkennung ueber Magic-Bytes; reiner Standardbibliotheks-Code (struct/zlib). Image nutzt den Layer: save() waehlt den Container an der Endung, bettet ohne Re-Encoding ein wenn der Inhalt schon passt (sonst Pillow-Transkodierung); from_file/from_bytes lesen eingebettete Metadaten Pillow-frei zurueck; neu: Image.embedded_metadata(). imagemeta.py wird gemessen und ist zu 100% durch synthetische Container-Bytes abgedeckt (Pillow-frei); zusaetzliche Pillow-Round-Trips ueber alle fuenf Formate sichern die Decodier-Integritaet. image.py bleibt in der Coverage-omit. RFC 0005 dokumentiert Entwurf und WebP-Entscheidung (sdAT-Chunk statt VP8X+XMP); API-Referenz + mkdocs-Nav ergaenzt. --- docs/api.md | 8 + docs/rfc/0005-native-image-metadata.md | 126 +++++++ mkdocs.yml | 1 + sdata/imagemeta.py | 451 +++++++++++++++++++++++++ sdata/sclass/image.py | 106 ++++-- tests/test_image.py | 45 +++ tests/test_imagemeta.py | 324 ++++++++++++++++++ 7 files changed, 1037 insertions(+), 24 deletions(-) create mode 100644 docs/rfc/0005-native-image-metadata.md create mode 100644 sdata/imagemeta.py create mode 100644 tests/test_imagemeta.py diff --git a/docs/api.md b/docs/api.md index 8722eb4..4b9c766 100644 --- a/docs/api.md +++ b/docs/api.md @@ -16,6 +16,14 @@ omitted. ::: sdata.sclass.dataframe +## sdata.imagemeta + +::: sdata.imagemeta + +## sdata.sclass.image + +::: sdata.sclass.image + ## sdata.schema ::: sdata.schema diff --git a/docs/rfc/0005-native-image-metadata.md b/docs/rfc/0005-native-image-metadata.md new file mode 100644 index 0000000..4006ca2 --- /dev/null +++ b/docs/rfc/0005-native-image-metadata.md @@ -0,0 +1,126 @@ +# RFC 0005 — Native, format-übergreifende Bild-Metadaten + +| Feld | Wert | +|-------------|--------------------------------------------------------------| +| Status | Accepted — implementiert (PNG/JPEG/JP2/GIF/WebP) | +| Datum | 2026-06-29 | +| Autor | lepy | +| Komponente | `sdata/imagemeta.py`, `sdata/sclass/image.py` | +| Betrifft | Einbettung von sdata-Metadaten direkt in Bilddateien | +| Validierung | `imagemeta.py` 100 %; Pillow-Round-Trips für 5 Formate | + +> **Umsetzungsstand.** Implementiert. `sdata/imagemeta.py` bettet sdata-Metadaten +> **nativ und Pillow-frei** in PNG, JPEG, JP2, GIF und WebP ein; `Image` nutzt es +> für eine **einheitliche** `save`/`from_file`-API über alle Formate. + +## 1. Zusammenfassung + +sdata bekommt **eigenen** Code, um Metadaten (das sdata-Metadaten-JSON) direkt in +Bilddateien zu schreiben und zu lesen — mit **einer** API über alle Formate. Bisher +konnte `Image` Metadaten nur in **PNG** einbetten (über Pillows `PngInfo`); JPEG, +JPEG 2000 und andere Formate blieben außen vor. + +Die neue Schicht `sdata.imagemeta` ist **reiner Python-Code** (Standardbibliothek, +keine Pillow-Abhängigkeit). Sie erkennt das Format an den Magic-Bytes und schreibt +die Nutzlast in den **nativen** Metadaten-Träger des jeweiligen Containers: + +| Format | Träger der sdata-Nutzlast | Kennung | +| ------ | -------------------------------------------------- | ------------------ | +| PNG | `iTXt`-Chunk (UTF-8) vor `IEND` | Keyword `sdata` | +| JPEG | `APP1`-Segment direkt hinter SOI | `sdata\0`-Präfix | +| JP2 | `uuid`-Box (ISO BMFF) vor der `jp2c`-Codestream-Box| feste sdata-UUID | +| GIF | Comment-Extension hinter dem Header | `sdata\0`-Präfix | +| WebP | eigener RIFF-Chunk `sdAT` | FourCC `sdAT` | + +## 2. Motivation + +* **Format-Unabhängigkeit:** eine Tabelle, ein Bild, ein PDF — Metadaten gehören in + das Asset, nicht nur in eine Sidecar-Datei. Für Bilder soll das **format-agnostisch** + und mit identischer API funktionieren. +* **Kein Tool-Zwang:** keine Abhängigkeit von `exiftool` o. ä.; das Schreiben/Lesen + ist Teil von sdata. +* **Pillow-frei lesen:** das Auslesen eingebetteter Metadaten darf nicht von einem + optionalen Bild-Backend abhängen — `imagemeta.extract` arbeitet auf den rohen Bytes. + +## 3. Entwurf + +### 3.1 Fassade (`sdata.imagemeta`) + +```python +detect_format(data) -> "png"|"jpeg"|"jp2"|"gif"|"webp"|None +embed(data, payload, fmt=None) -> bytes # replace-Semantik +extract(data, fmt=None) -> str | None # lenient: unbekannt -> None +supported_formats() -> tuple[str, ...] +``` + +* **Replace-Semantik:** eine vorhandene sdata-Nutzlast wird **ersetzt**, nicht + dupliziert (idempotentes erneutes Einbetten). +* **Lenient lesen:** `extract` liefert für unbekannte Formate bzw. Bilder ohne + eingebettete Nutzlast `None` (kein Fehler). `embed` wirft + `UnsupportedImageFormatError` für nicht unterstützte Formate. +* **Registry:** `fmt -> (embed_fn, extract_fn)` — weitere Formate (TIFF, …) sind als + zwei kleine Funktionen + ein Registry-Eintrag nachrüstbar. + +### 3.2 Pro Format (Byte-Ebene) + +* **PNG** — Chunk-Struktur (`len|type|data|crc`). Ein unkomprimierter `iTXt`-Chunk mit + Keyword `sdata` wird vor `IEND` eingefügt; CRC-32 über `type+data`. +* **JPEG** — Marker-Segmente. Ein `APP1`-Segment (`sdata\0` + UTF-8) direkt hinter SOI; + der Marker-Walk stoppt bei SOS. Limit: 16-bit-Längenfeld → Nutzlast ≤ 65527 Byte + (`PayloadTooLargeError`). +* **JP2** — ISO-BMFF-Boxen (`LBox|TBox|DBox`, optional 64-bit `XLBox`). Eine `uuid`-Box + mit fester sdata-UUID wird vor der `jp2c`-Box eingefügt. +* **GIF** — Sub-Block-Streams. Eine Comment-Extension (`0x21 0xFE`) mit Präfix `sdata\0` + hinter Header + Logical Screen Descriptor (+ Global Color Table); Nutzlast in + 255-Byte-Sub-Blöcken. Der Block-Walker überspringt Bild- und sonstige Extension-Daten + korrekt. +* **WebP** — RIFF-Container. Ein eigener Chunk `sdAT` wird angehängt und die RIFF-Größe + aktualisiert. Begründung der Wahl s. u. + +### 3.3 `Image`-Integration + +* `Image.save(path)` wählt den Container an der Datei-Endung. Liegt der Inhalt bereits + in diesem Container vor, wird die Nutzlast **ohne Re-Encoding** eingebettet + (verlustfrei, Pillow-frei); sonst transkodiert Pillow zuerst. Formate ohne nativen + Handler werden via Pillow geschrieben (Warnung, keine Einbettung). +* `Image.from_file`/`from_bytes` lesen eingebettete Metadaten über `imagemeta.extract` + zurück (Pillow-frei) und mergen sie (`update_from_usermetadata`). +* `Image.embedded_metadata()` liefert die eingebettete `Metadata` (oder `None`). + +## 4. Designentscheidungen + +* **WebP: eigener `sdAT`-Chunk statt VP8X+XMP.** Empirisch behält ein zusätzlicher, + unbekannter RIFF-Chunk die Dekodier-Integrität (libwebp/Pillow ignorieren unbekannte + Chunks; Bildgröße/Pixel bleiben unverändert). Das ist robuster und einfacher als eine + VP8X-Promotion mit XMP-Verpackung. **Trade-off:** ein pedantischer Validator könnte + einen „simple"-WebP mit Zusatz-Chunk bemängeln; funktional (Dekodierung + sdata- + Round-Trip) ist es einwandfrei. VP8X+XMP bleibt als spätere Verfeinerung möglich. +* **Hash/Identität.** Das Einbetten verändert die Datei-Bytes (und damit deren Hash). + Wer einen stabilen Inhalts-Hash braucht, hasht **vor** dem Einbetten oder die reinen + Pixel — analog zum Daten-vs-Metadaten-Hash bei `DataFrame` (RFC 0004). +* **Sidecar bleibt komplementär.** Für Formate ohne Handler (oder bewusst externe + Metadaten) bleibt der JSON-LD-Sidecar (`semantic.write_sidecar`) verfügbar. + +## 5. Tests / Coverage + +* `tests/test_imagemeta.py`: **synthetische** Container-Bytes (Pillow-frei) decken + `imagemeta.py` zu **100 %** ab — inkl. Replace-Semantik, fehlender Nutzlast, JPEG- + Standalone-/Non-FF-Marker, JP2-XLBox/`LBox==0`/malformed-Guard, GIF mit/ohne (Local) + Color Table und Nicht-Comment-Extensions, WebP-Padding. Zusätzlich Pillow-Round-Trips + über PNG/JPEG/JP2/GIF/WebP (Decodier-Integrität). +* `tests/test_image.py`: einheitliche `Image`-API über alle fünf Formate + Transkodierung. + +## 6. Kompatibilität / Migration + +* Strikt additiv: `imagemeta` ist neu; `Image.from_file`/`from_bytes`/`save` behalten + ihre Signaturen. PNG-Round-Trips bleiben kompatibel (jetzt über `iTXt` statt + `PngInfo`, identisches `sdata`-Keyword). +* `imagemeta.py` ist **gemessen** (100 %); `image.py` bleibt wegen des optionalen + Pillow-Transkodier-Pfads in der Coverage-`omit`. + +## 7. Offene Punkte / Zukunft + +* Weitere Container über die Registry: **TIFF** (IFD-Tag), **BMP** (kein nativer Träger + → Sidecar). +* Optional: WebP **VP8X+XMP** für strikte Interop; PNG **`zTXt`** (komprimiert) für sehr + große Nutzlasten; JPEG **Multi-Segment-APP1** jenseits 64 KiB. diff --git a/mkdocs.yml b/mkdocs.yml index 6cac146..0c82ba7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -70,4 +70,5 @@ nav: - "0002 — HDF5 DataFrame serialization": rfc/0002-hdf5-dataframe-serialization.md - "0003 — Blob as data foundation": rfc/0003-blob-as-data-foundation.md - "0004 — DataFrame and Blob": rfc/0004-dataframe-and-blob.md + - "0005 — Native image metadata": rfc/0005-native-image-metadata.md - Releasing: releasing.md diff --git a/sdata/imagemeta.py b/sdata/imagemeta.py new file mode 100644 index 0000000..b3bfe9d --- /dev/null +++ b/sdata/imagemeta.py @@ -0,0 +1,451 @@ +# -*- coding: utf-8 -*- +"""Native, format-übergreifende Einbettung von sdata-Metadaten in Bild-Bytes. + +Reiner Python-Code (**keine** Pillow-Abhängigkeit für die Metadaten-Schicht): ein +Text-Payload (i. d. R. das sdata-Metadaten-JSON) wird über eine **einheitliche +API** verlustfrei in den jeweiligen Bildcontainer geschrieben bzw. daraus gelesen. + +Unterstützte Container und ihr nativer Träger: + +* **PNG** — ``iTXt``-Chunk mit Schlüsselwort ``sdata`` (UTF-8) +* **JPEG** — ``APP1``-Segment mit Kennung ``sdata\\0`` (UTF-8) +* **JP2** — ``uuid``-Box (JPEG 2000, ISO BMFF) mit fester sdata-UUID +* **GIF** — Comment-Extension mit Präfix ``sdata\\0`` +* **WebP** — eigener RIFF-Chunk ``sdAT`` (von Decodern als unbekannt ignoriert) + +Das Format wird an den Magic-Bytes erkannt (:func:`detect_format`); :func:`embed` +und :func:`extract` wählen den passenden Handler. Die Schreibsemantik ist +*replace* (eine vorhandene sdata-Nutzlast wird ersetzt, nicht dupliziert). Pillow +(optional) wird nur zum **Transkodieren der Pixel** benötigt, nicht für die +Metadaten — das Lesen funktioniert daher vollständig Pillow-frei. + +:Example: + +>>> from sdata import imagemeta +>>> png_with_meta = imagemeta.embed(png_bytes, '{"name": "probe"}') # doctest: +SKIP +>>> imagemeta.extract(png_with_meta) # doctest: +SKIP +'{"name": "probe"}' +""" +import struct +import zlib +import logging +from typing import Iterator, List, Optional, Tuple + +logger = logging.getLogger(__name__) + +#: Kennung/Schlüsselwort der sdata-Nutzlast in allen Containern. +SDATA_ID = "sdata" +_SID = SDATA_ID.encode("ascii") +#: JPEG-``APP1``-Kennung (``sdata\\0``) zur Unterscheidung von EXIF/XMP. +_JPEG_IDENT = _SID + b"\x00" +#: GIF-Comment-Präfix (``sdata\\0``) zur Erkennung der sdata-Comment-Extension. +_GIF_PREFIX = _SID + b"\x00" +#: 16-Byte-UUID für die JP2-``uuid``-Box (genau 16 Byte). +SDATA_JP2_UUID = b"SDATA-IMG-META\x00\x00" +#: WebP-FourCC des sdata-Chunks (unbekannt → von WebP-Decodern ignoriert). +_WEBP_FOURCC = b"sdAT" +#: Maximale JPEG-``APP1``-Nutzlast (Längenfeld 16 bit, inkl. 2 Längen-Bytes). +_JPEG_MAX_PAYLOAD = 0xFFFF - 2 - len(_JPEG_IDENT) + + +class ImageMetadataError(Exception): + """Basisfehler der Bild-Metadaten-Schicht.""" + + +class UnsupportedImageFormatError(ImageMetadataError): + """Das Bildformat wird (zum Schreiben) nicht unterstützt.""" + + +class PayloadTooLargeError(ImageMetadataError): + """Die Nutzlast passt nicht in ein einzelnes Format-Segment (z. B. JPEG ``APP1``).""" + + +# ====================================================================== PNG +_PNG_MAGIC = b"\x89PNG\r\n\x1a\n" + + +def _png_chunks(data: bytes) -> Iterator[Tuple[bytes, bytes]]: + """Iteriere ``(chunk_type, chunk_data)`` eines PNG (ohne 8-Byte-Signatur).""" + pos = len(_PNG_MAGIC) + while pos + 8 <= len(data): + (length,) = struct.unpack(">I", data[pos:pos + 4]) + ctype = data[pos + 4:pos + 8] + cdata = data[pos + 8:pos + 8 + length] + yield ctype, cdata + pos += 12 + length # length(4) + type(4) + data + crc(4) + + +def _png_make_chunk(ctype: bytes, cdata: bytes) -> bytes: + """Baue einen PNG-Chunk (Länge + Typ + Daten + CRC-32 über Typ+Daten).""" + crc = zlib.crc32(ctype + cdata) & 0xFFFFFFFF + return struct.pack(">I", len(cdata)) + ctype + cdata + struct.pack(">I", crc) + + +def _png_itxt(payload: str) -> bytes: + """``iTXt``-Chunk mit Schlüsselwort ``sdata`` (unkomprimiert, UTF-8).""" + body = (_SID + b"\x00" # keyword + null + + b"\x00\x00" # compression flag + method + + b"\x00" # (leerer) language tag + null + + b"\x00" # (leeres) translated keyword + null + + payload.encode("utf-8")) + return _png_make_chunk(b"iTXt", body) + + +def _png_embed(data: bytes, payload: str) -> bytes: + """Schreibe ``payload`` als ``iTXt``-Chunk vor ``IEND`` (ersetzt vorhandenen).""" + out = bytearray(_PNG_MAGIC) + for ctype, cdata in _png_chunks(data): + if ctype in (b"iTXt", b"tEXt") and cdata.split(b"\x00", 1)[0] == _SID: + continue # vorhandenen sdata-Text-Chunk verwerfen (replace) + if ctype == b"IEND": + out += _png_itxt(payload) + out += _png_make_chunk(ctype, cdata) + return bytes(out) + + +def _png_extract(data: bytes) -> Optional[str]: + """Lies die sdata-Nutzlast aus ``iTXt``/``tEXt`` mit Schlüsselwort ``sdata``.""" + for ctype, cdata in _png_chunks(data): + if ctype == b"iTXt": + keyword, _, rest = cdata.partition(b"\x00") + if keyword == _SID: + rest = rest[2:] # compflag + method + _, _, rest = rest.partition(b"\x00") # language tag + _, _, text = rest.partition(b"\x00") # translated keyword + return text.decode("utf-8") + elif ctype == b"tEXt": + keyword, _, text = cdata.partition(b"\x00") + if keyword == _SID: + return text.decode("latin-1") + return None + + +# ===================================================================== JPEG +_JPEG_MAGIC = b"\xff\xd8\xff" + + +def _jpeg_segments(data: bytes) -> Iterator[Tuple[int, int, Optional[int], Optional[bytes]]]: + """Iteriere ``(start, end, marker, segment_payload)`` ab SOI bis einschließlich SOS. + + ``segment_payload`` ist ``None`` für eigenständige Marker (RSTn/TEM) bzw. ab + SOS/EOI (dort umfasst die Spanne ``start..len(data)``, also den Rest). + """ + pos = 2 # nach SOI + n = len(data) + while pos < n: + if data[pos] != 0xFF: + yield pos, n, None, None # kein Marker mehr → Rest + return + start = pos + while pos < n and data[pos] == 0xFF: + pos += 1 # Füll-Bytes (0xFF) überspringen + marker = data[pos] + if marker in (0xDA, 0xD9): # SOS / EOI → Rest verbatim + yield start, n, marker, None + return + if 0xD0 <= marker <= 0xD7 or marker == 0x01: # RSTn / TEM: ohne Länge + yield start, pos + 1, marker, None + pos += 1 + continue + (length,) = struct.unpack(">H", data[pos + 1:pos + 3]) + end = pos + 1 + length + yield start, end, marker, data[pos + 3:end] + pos = end + + +def _jpeg_strip(data: bytes) -> bytes: + """Entferne ein vorhandenes sdata-``APP1``-Segment (SOI bleibt erhalten).""" + out = bytearray(data[:2]) # SOI + for start, end, marker, seg in _jpeg_segments(data): + if marker == 0xE1 and seg is not None and seg.startswith(_JPEG_IDENT): + continue + out += data[start:end] + return bytes(out) + + +def _jpeg_embed(data: bytes, payload: str) -> bytes: + """Schreibe ``payload`` als ``APP1``-Segment direkt hinter SOI (replace).""" + body = _JPEG_IDENT + payload.encode("utf-8") + if len(body) + 2 > 0xFFFF: + raise PayloadTooLargeError( + f"payload too large for a single JPEG APP1 segment " + f"(max {_JPEG_MAX_PAYLOAD} bytes)") + segment = b"\xff\xe1" + struct.pack(">H", len(body) + 2) + body + cleaned = _jpeg_strip(data) + return cleaned[:2] + segment + cleaned[2:] + + +def _jpeg_extract(data: bytes) -> Optional[str]: + """Lies die sdata-Nutzlast aus dem ``APP1``-Segment mit Kennung ``sdata\\0``.""" + for _start, _end, marker, seg in _jpeg_segments(data): + if marker == 0xE1 and seg is not None and seg.startswith(_JPEG_IDENT): + return seg[len(_JPEG_IDENT):].decode("utf-8") + return None + + +# ====================================================================== JP2 +_JP2_MAGIC = b"\x00\x00\x00\x0cjP \r\n\x87\n" + + +def _jp2_boxes(data: bytes) -> Iterator[Tuple[bytes, int, int, int]]: + """Iteriere Top-Level-Boxen als ``(type, box_start, content_start, box_end)``.""" + pos = 0 + n = len(data) + while pos + 8 <= n: + (lbox,) = struct.unpack(">I", data[pos:pos + 4]) + tbox = data[pos + 4:pos + 8] + content_start = pos + 8 + if lbox == 1: # 64-bit XLBox + (xlbox,) = struct.unpack(">Q", data[pos + 8:pos + 16]) + content_start = pos + 16 + end = pos + xlbox + elif lbox == 0: # bis Dateiende + end = n + else: + end = pos + lbox + yield tbox, pos, content_start, end + if end <= pos: # Schutz vor Endlosschleife + return + pos = end + + +def _jp2_uuid_box(payload: str) -> bytes: + """``uuid``-Box mit fester sdata-UUID + UTF-8-Nutzlast.""" + content = SDATA_JP2_UUID + payload.encode("utf-8") + return struct.pack(">I", len(content) + 8) + b"uuid" + content + + +def _jp2_embed(data: bytes, payload: str) -> bytes: + """Schreibe ``payload`` als ``uuid``-Box vor die ``jp2c``-Codestream-Box (replace).""" + out = bytearray() + for tbox, start, cstart, end in _jp2_boxes(data): + if tbox == b"uuid" and data[cstart:cstart + 16] == SDATA_JP2_UUID: + continue # vorhandene sdata-Box verwerfen (replace) + if tbox == b"jp2c": + out += _jp2_uuid_box(payload) + out += data[start:end] + return bytes(out) + + +def _jp2_extract(data: bytes) -> Optional[str]: + """Lies die sdata-Nutzlast aus der ``uuid``-Box mit der sdata-UUID.""" + for tbox, _start, cstart, end in _jp2_boxes(data): + if tbox == b"uuid" and data[cstart:cstart + 16] == SDATA_JP2_UUID: + return data[cstart + 16:end].decode("utf-8") + return None + + +# ====================================================================== GIF +_GIF_MAGICS = (b"GIF87a", b"GIF89a") + + +def _gif_body_start(data: bytes) -> int: + """Offset hinter Header + Logical Screen Descriptor + Global Color Table.""" + packed = data[10] + pos = 13 # 6 (Header) + 7 (Logical Screen Descriptor) + if packed & 0x80: # Global Color Table vorhanden + pos += 3 * (2 ** ((packed & 0x07) + 1)) + return pos + + +def _gif_skip_subblocks(data: bytes, pos: int) -> int: + """Position direkt hinter einem Sub-Block-Stream (Terminator ``0x00``).""" + n = len(data) + while pos < n: + size = data[pos] + pos += 1 + if size == 0: + break + pos += size + return pos + + +def _gif_read_subblocks(data: bytes, pos: int) -> Tuple[bytes, int]: + """Lies einen Sub-Block-Stream zu ``(bytes, end_pos)`` zusammen.""" + out = bytearray() + n = len(data) + while pos < n: + size = data[pos] + pos += 1 + if size == 0: + break + out += data[pos:pos + size] + pos += size + return bytes(out), pos + + +def _gif_comment_spans(data: bytes) -> Iterator[Tuple[int, int, bytes]]: + """Iteriere ``(start, end, text)`` jeder Comment-Extension auf Block-Ebene.""" + pos = _gif_body_start(data) + n = len(data) + while pos < n: + intro = data[pos] + if intro == 0x3B: # Trailer + return + if intro == 0x2C: # Image Descriptor + packed = data[pos + 9] + pos += 10 + if packed & 0x80: # Local Color Table + pos += 3 * (2 ** ((packed & 0x07) + 1)) + pos += 1 # LZW minimum code size + pos = _gif_skip_subblocks(data, pos) + continue + if intro == 0x21: # Extension + label = data[pos + 1] + sub_start = pos + 2 + if label == 0xFE: # Comment Extension + text, end = _gif_read_subblocks(data, sub_start) + yield pos, end, text + pos = end + else: # Graphic Control / Application / … + pos = _gif_skip_subblocks(data, sub_start) + continue + return # unbekannter Block → abbrechen + + +def _gif_subblocks(payload: bytes) -> bytes: + """Zerlege ``payload`` in 255-Byte-Sub-Blöcke mit ``0x00``-Terminator.""" + out = bytearray() + for i in range(0, len(payload), 255): + chunk = payload[i:i + 255] + out += bytes([len(chunk)]) + chunk + out += b"\x00" + return bytes(out) + + +def _gif_strip(data: bytes) -> bytes: + """Entferne vorhandene sdata-Comment-Extensions (replace).""" + spans = [(s, e) for s, e, t in _gif_comment_spans(data) + if t.startswith(_GIF_PREFIX)] + if not spans: + return data + out = bytearray() + prev = 0 + for start, end in spans: + out += data[prev:start] + prev = end + out += data[prev:] + return bytes(out) + + +def _gif_embed(data: bytes, payload: str) -> bytes: + """Schreibe ``payload`` als Comment-Extension hinter den Header (replace).""" + cleaned = _gif_strip(data) + at = _gif_body_start(cleaned) + ext = b"\x21\xfe" + _gif_subblocks(_GIF_PREFIX + payload.encode("utf-8")) + return cleaned[:at] + ext + cleaned[at:] + + +def _gif_extract(data: bytes) -> Optional[str]: + """Lies die sdata-Nutzlast aus der Comment-Extension mit Präfix ``sdata\\0``.""" + for _start, _end, text in _gif_comment_spans(data): + if text.startswith(_GIF_PREFIX): + return text[len(_GIF_PREFIX):].decode("utf-8") + return None + + +# ===================================================================== WebP +def _webp_chunks(data: bytes) -> Iterator[Tuple[bytes, bytes]]: + """Iteriere ``(fourcc, chunk_data)`` eines RIFF/WebP (ab dem ``WEBP``-Tag).""" + pos = 12 # 'RIFF' + size(4) + 'WEBP' + n = len(data) + while pos + 8 <= n: + fourcc = data[pos:pos + 4] + (size,) = struct.unpack(" bytes: + """Schreibe ``payload`` als eigenen ``sdAT``-Chunk ans Ende (replace).""" + chunks: List[Tuple[bytes, bytes]] = [ + (fourcc, body) for fourcc, body in _webp_chunks(data) + if fourcc != _WEBP_FOURCC + ] + chunks.append((_WEBP_FOURCC, payload.encode("utf-8"))) + out = bytearray() + for fourcc, body in chunks: + out += fourcc + struct.pack(" Optional[str]: + """Lies die sdata-Nutzlast aus dem ``sdAT``-Chunk.""" + for fourcc, body in _webp_chunks(data): + if fourcc == _WEBP_FOURCC: + return body.decode("utf-8") + return None + + +# ================================================================== Fassade +#: Registry: ``fmt -> (embed, extract)``. +_HANDLERS = { + "png": (_png_embed, _png_extract), + "jpeg": (_jpeg_embed, _jpeg_extract), + "jp2": (_jp2_embed, _jp2_extract), + "gif": (_gif_embed, _gif_extract), + "webp": (_webp_embed, _webp_extract), +} + + +def detect_format(data: bytes) -> Optional[str]: + """Erkenne das Bildformat an den Magic-Bytes. + + :param data: die Bild-Bytes. + :return: ``"png"``/``"jpeg"``/``"jp2"``/``"gif"``/``"webp"`` oder ``None``. + """ + if data.startswith(_PNG_MAGIC): + return "png" + if data.startswith(_JPEG_MAGIC): + return "jpeg" + if data.startswith(_JP2_MAGIC): + return "jp2" + if data[:6] in _GIF_MAGICS: + return "gif" + if data[:4] == b"RIFF" and data[8:12] == b"WEBP": + return "webp" + return None + + +def supported_formats() -> Tuple[str, ...]: + """Die unterstützten Format-Schlüssel (Reihenfolge der Registry).""" + return tuple(_HANDLERS) + + +def embed(data: bytes, payload: str, fmt: Optional[str] = None) -> bytes: + """Bette ``payload`` (Text) nativ in die Bild-Bytes ein (replace-Semantik). + + :param data: die Original-Bild-Bytes. + :param payload: der einzubettende Text (i. d. R. sdata-Metadaten-JSON). + :param fmt: Format-Schlüssel; ``None`` → automatische Erkennung. + :return: neue Bild-Bytes mit eingebetteter sdata-Nutzlast. + :raises UnsupportedImageFormatError: wenn das Format unbekannt/nicht unterstützt ist. + :raises PayloadTooLargeError: wenn die Nutzlast nicht in ein Segment passt (JPEG). + """ + fmt = fmt or detect_format(data) + if fmt not in _HANDLERS: + raise UnsupportedImageFormatError( + f"unsupported image format: {fmt!r} " + f"(supported: {', '.join(_HANDLERS)})") + result = _HANDLERS[fmt][0](data, payload) + logger.debug("embedded %d-byte sdata payload into %s image", len(payload), fmt) + return result + + +def extract(data: bytes, fmt: Optional[str] = None) -> Optional[str]: + """Lies eine eingebettete sdata-Nutzlast aus den Bild-Bytes (Pillow-frei). + + Lenient beim Lesen: unbekannte/nicht unterstützte Formate liefern ``None`` + (kein Fehler), ebenso Bilder ohne eingebettete sdata-Nutzlast. + + :param data: die Bild-Bytes. + :param fmt: Format-Schlüssel; ``None`` → automatische Erkennung. + :return: die eingebettete Nutzlast (Text) oder ``None``. + """ + fmt = fmt or detect_format(data) + if fmt not in _HANDLERS: + return None + return _HANDLERS[fmt][1](data) diff --git a/sdata/sclass/image.py b/sdata/sclass/image.py index 5a0693c..636e5a9 100644 --- a/sdata/sclass/image.py +++ b/sdata/sclass/image.py @@ -2,25 +2,26 @@ """Image — ein :class:`~sdata.sclass.blob.Blob` über Bild-Inhalt. Der Bild-Inhalt liegt als Blob-Content (``uri`` für Dateien, ``bytes`` für -In-Memory-Daten); Pillow wird nur lazy zum Dekodieren genutzt -(:attr:`Image.pil`/:meth:`Image.to_numpy`/:meth:`Image.save`). Pillow ist optional -(``pip install pillow``). sdata-Metadaten können in PNGs eingebettet werden. +In-Memory-Daten). sdata-Metadaten werden **format-übergreifend nativ** in die +Bilddatei eingebettet (PNG/JPEG/JP2/GIF/WebP) — über :mod:`sdata.imagemeta`, das +ohne Pillow auskommt. Pillow wird nur lazy zum **Dekodieren/Transkodieren** der +Pixel genutzt (:attr:`Image.pil`/:meth:`Image.to_numpy`/:meth:`Image.save` bei +Formatwechsel) und ist optional (``pip install pillow``). """ import io import os -import json import logging from pathlib import Path import numpy as np +from sdata import imagemeta from sdata.suuid import SUUID from sdata.metadata import Metadata from sdata.sclass.blob import Blob try: import PIL.Image - import PIL.PngImagePlugin except ImportError: # pragma: no cover - optionale Abhängigkeit (Pillow) PIL = None @@ -32,10 +33,23 @@ class Image(Blob): SDATA_CLS = "sdata.sclass.image.Image" + #: Datei-Endung → (Pillow-Format, :mod:`sdata.imagemeta`-Format) für :meth:`save`. + _SUFFIX_FORMATS = { + "png": ("PNG", "png"), + "jpg": ("JPEG", "jpeg"), "jpeg": ("JPEG", "jpeg"), "jpe": ("JPEG", "jpeg"), + "jp2": ("JPEG2000", "jp2"), "j2k": ("JPEG2000", "jp2"), + "jpf": ("JPEG2000", "jp2"), "jpx": ("JPEG2000", "jp2"), + "gif": ("GIF", "gif"), + "webp": ("WEBP", "webp"), + } + @classmethod def from_file(cls, filepath, project=None, ns_name=None, **kwargs): """Create an Image referencing an image file (kept as ``uri`` content). + Any sdata metadata embedded in the file (PNG/JPEG/JP2/GIF/WebP) is read + back and merged — independent of Pillow. + :param filepath: path to the image file. :param project: namespace for the deterministic SUUID (alias of ``ns_name``). :param ns_name: namespace for the deterministic SUUID. @@ -53,6 +67,8 @@ def from_file(cls, filepath, project=None, ns_name=None, **kwargs): def from_bytes(cls, name, image_data, project=None, **kwargs): """Create an Image from in-memory image bytes. + Any embedded sdata metadata is read back and merged (Pillow-free). + :param name: a name for the image (its suffix sets the filetype). :param image_data: the raw image bytes. :param project: namespace for the deterministic SUUID. @@ -81,32 +97,74 @@ def basename(self) -> str: """The image file base name (== ``name``).""" return self.name - def _load_embedded_metadata(self) -> None: - """Merge sdata metadata embedded in a PNG (``img.info['sdata']``), if present.""" - if PIL is None: - return + def embedded_metadata(self): + """Return the sdata metadata embedded in the image bytes, or ``None``. + + Reads the native sdata payload (PNG ``iTXt`` / JPEG ``APP1`` / JP2 ``uuid`` + box / GIF comment / WebP ``sdAT`` chunk) without Pillow. + + :return: a :class:`~sdata.metadata.Metadata`, or ``None`` if absent/invalid. + """ + raw = imagemeta.extract(self.content_bytes) + if not raw: + return None try: - raw = self.pil.info.get("sdata") - if raw: - self.metadata.update_from_usermetadata(Metadata.from_json(raw)) + return Metadata.from_json(raw) except Exception as exp: + logger.debug(f"embedded sdata metadata not parseable: {exp}") + return None + + def _load_embedded_metadata(self) -> None: + """Merge sdata metadata embedded in the image bytes (any supported format).""" + try: + embedded = self.embedded_metadata() + except Exception as exp: # e.g. unreadable uri content logger.debug(f"no embedded sdata metadata: {exp}") + return + if embedded is not None: + self.metadata.update_from_usermetadata(embedded) def save(self, filepath, **kwargs): - """Save the image to ``filepath``; for PNG the sdata metadata is embedded. - - :param filepath: destination path. - :raises ImportError: if Pillow is not installed. + """Save the image to ``filepath``; sdata metadata is embedded natively. + + The container is chosen from the file suffix. If the stored bytes already + use that container, the metadata is embedded **without** re-encoding + (lossless, Pillow-free); otherwise Pillow transcodes to the target format + first. Formats without a native handler are written via Pillow without + embedded metadata (a warning is logged). + + :param filepath: destination path (its suffix selects the format). + :param kwargs: forwarded to ``PIL.Image.save`` when transcoding. + :raises ImportError: if Pillow is required (transcode / unsupported format) + but not installed. :return: the destination ``filepath``. """ - if PIL is None: - raise ImportError("Pillow is required for Image.save (pip install pillow).") - img = self.pil - if str(filepath).lower().endswith(".png"): - info = PIL.PngImagePlugin.PngInfo() - info.add_text("sdata", self.metadata.to_json()) - img.save(filepath, "PNG", pnginfo=info) + suffix = Path(filepath).suffix.lstrip(".").lower() + target = self._SUFFIX_FORMATS.get(suffix) + + if target is None: + # Kein nativer Handler: Pillow schreiben lassen, ohne Einbettung. + if PIL is None: + raise ImportError("Pillow is required for Image.save (pip install pillow).") + self.pil.save(filepath, **kwargs) + logger.warning("Image.save: no native sdata-metadata handler for " + "'%s'; saved without embedded metadata", filepath) + return filepath + + pil_format, meta_fmt = target + src = self.content_bytes + if imagemeta.detect_format(src) == meta_fmt: + img_bytes = src # passender Container → kein Transkodieren else: - img.save(filepath, **kwargs) + if PIL is None: + raise ImportError("Pillow is required to transcode images " + "(pip install pillow).") + buffer = io.BytesIO() + self.pil.save(buffer, pil_format, **kwargs) + img_bytes = buffer.getvalue() + + img_bytes = imagemeta.embed(img_bytes, self.metadata.to_json(), meta_fmt) + with open(filepath, "wb") as fh: + fh.write(img_bytes) logger.info(f"Image saved to {filepath}") return filepath diff --git a/tests/test_image.py b/tests/test_image.py index 27a2e82..bf990ee 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -57,3 +57,48 @@ def test_image_from_bytes_and_png_metadata_roundtrip(tmp_path): reloaded = Image.from_file(out) assert reloaded.metadata.get("exposure").value == 1.5 assert reloaded.to_numpy().shape == (3, 4, 3) # (Höhe, Breite, Kanäle) + + +@pytest.mark.parametrize("ext,pil_format,kwargs", [ + ("png", "PNG", {}), + ("jpg", "JPEG", {}), + ("jp2", "JPEG2000", {}), + ("gif", "GIF", {}), + ("webp", "WEBP", {}), +]) +def test_image_metadata_roundtrip_all_formats(tmp_path, ext, pil_format, kwargs): + """Einheitliche API: Metadaten schreiben→lesen über PNG/JPEG/JP2/GIF/WebP.""" + import io + import PIL.Image + + buf = io.BytesIO() + PIL.Image.new("RGB", (6, 4), (30, 60, 90)).save(buf, pil_format, **kwargs) + + img = Image.from_bytes(f"pic.{ext}", buf.getvalue()) + img.metadata.add("operator", "ada", description="who acquired the image") + + out = str(tmp_path / f"out.{ext}") + img.save(out) # gleiche API für alle Formate + + reloaded = Image.from_file(out) + assert reloaded.metadata.get("operator").value == "ada" + assert reloaded.pil.size == (6, 4) # Pixel/Dimensionen intakt + # Metadaten sind nativ in der Datei (Pillow-frei lesbar) + assert reloaded.embedded_metadata() is not None + + +def test_image_save_transcodes_between_formats(tmp_path): + """save() in ein anderes Format transkodiert via Pillow und bettet ein.""" + import io + import PIL.Image + + buf = io.BytesIO() + PIL.Image.new("RGB", (5, 5), (1, 2, 3)).save(buf, "PNG") + img = Image.from_bytes("pic.png", buf.getvalue()) # PNG-Quelle + img.metadata.add("note", "transcoded") + + out = str(tmp_path / "out.webp") # Ziel: WebP (Formatwechsel) + img.save(out) + reloaded = Image.from_file(out) + assert reloaded.metadata.get("note").value == "transcoded" + assert reloaded.filetype == "webp" diff --git a/tests/test_imagemeta.py b/tests/test_imagemeta.py new file mode 100644 index 0000000..c577e86 --- /dev/null +++ b/tests/test_imagemeta.py @@ -0,0 +1,324 @@ +# -*- coding: utf-8 -*- +"""sdata.imagemeta — native, format-übergreifende Einbettung von sdata-Metadaten. + +Die Metadaten-Schicht ist reiner Python-Code (kein Pillow): die Coverage wird über +**synthetische** Container-Bytes erreicht (PNG/JPEG/JP2/GIF/WebP), die hier per Hand +gebaut werden. Zusätzliche Round-Trips gegen echte, mit Pillow erzeugte Bilder +(am Ende, ``importorskip('PIL')``) validieren die Decodier-Integrität. +""" +import struct +import zlib + +import pytest + +from sdata import imagemeta as im + +PAYLOAD = '{"name": "probe", "ü": "äöü€"}' + + +# ----------------------------------------------------------------- Builders +def _png_chunk(ctype, cdata): + crc = zlib.crc32(ctype + cdata) & 0xFFFFFFFF + return struct.pack(">I", len(cdata)) + ctype + cdata + struct.pack(">I", crc) + + +def _png(*chunks): + return im._PNG_MAGIC + b"".join(_png_chunk(ct, cd) for ct, cd in chunks) + + +def _png_minimal(*extra): + return _png((b"IHDR", b"\x00" * 13), *extra, (b"IDAT", b"x"), (b"IEND", b"")) + + +def _itxt(keyword, text, compflag=0): + body = (keyword + b"\x00" + bytes([compflag]) + b"\x00" + + b"\x00" + b"\x00" + text.encode("utf-8")) + return (b"iTXt", body) + + +def _text(keyword, text): + return (b"tEXt", keyword + b"\x00" + text.encode("latin-1")) + + +def _box(tbox, content): + return struct.pack(">I", len(content) + 8) + tbox + content + + +def _jp2(*boxes): + return im._JP2_MAGIC + b"".join(boxes) + + +def _riff_webp(*chunks): + body = b"" + for fourcc, data in chunks: + body += fourcc + struct.pack("H", len(payload) + 2) + payload + + +def test_jpeg_detect_embed_extract(): + src = _jpeg(_app(0xE0, b"JFIF\x00")) # APP0 like a real JPEG + assert im.detect_format(src) == "jpeg" + out = im.embed(src, PAYLOAD) + assert im.extract(out) == PAYLOAD + + +def test_jpeg_replace_existing(): + once = im.embed(_jpeg(_app(0xE0, b"JFIF\x00")), PAYLOAD) + twice = im.embed(once, "second") + assert im.extract(twice) == "second" + + +def test_jpeg_standalone_and_nonff_markers(): + # RST0 (standalone, no length) before SOS, then APP1 sdata + src = (b"\xff\xd8" + b"\xff\xd0" # SOI + RST0 (standalone) + + _app(0xE1, im._JPEG_IDENT + b"hi") # APP1 sdata + + b"\xff\xda\x00\x00scan") + assert im.extract(src) == "hi" + # a non-0xFF byte where a marker is expected terminates the walk (no sdata) + nonff = b"\xff\xd8" + _app(0xE0, b"X") + b"\x00\x01\x02" # JFIF magic, then junk + assert im.detect_format(nonff) == "jpeg" + assert im.extract(nonff) is None + + +def test_jpeg_extract_none_when_absent(): + assert im.extract(_jpeg(_app(0xE0, b"JFIF\x00"))) is None + + +def test_jpeg_payload_too_large(): + huge = "x" * (im._JPEG_MAX_PAYLOAD + 1) + with pytest.raises(im.PayloadTooLargeError): + im.embed(_jpeg(_app(0xE0, b"JFIF\x00")), huge) + + +# ====================================================================== JP2 +def test_jp2_detect_embed_extract(): + src = _jp2(_box(b"ftyp", b"jp2 "), _box(b"jp2h", b"\x00" * 4), + _box(b"jp2c", b"codestream")) + assert im.detect_format(src) == "jp2" + out = im.embed(src, PAYLOAD) + assert im.extract(out) == PAYLOAD + + +def test_jp2_replace_existing(): + src = _jp2(_box(b"jp2c", b"cs")) + once = im.embed(src, PAYLOAD) + twice = im.embed(once, "second") + assert im.extract(twice) == "second" + + +def test_jp2_xlbox_64bit_length(): + # jp2h as a 64-bit XLBox (LBox==1) exercises the XLBox branch + content = b"\x00" * 4 + xlbox = _box_xl(b"jp2h", content) + src = _jp2(_box(b"ftyp", b"jp2 "), xlbox, _box(b"jp2c", b"cs")) + out = im.embed(src, PAYLOAD) + assert im.extract(out) == PAYLOAD + + +def test_jp2_lbox_zero_to_eof(): + # final jp2c with LBox==0 extends to EOF + jp2c_eof = struct.pack(">I", 0) + b"jp2c" + b"codestream-to-eof" + src = _jp2(_box(b"jp2h", b"\x00" * 4)) + jp2c_eof + out = im.embed(src, PAYLOAD) + assert im.extract(out) == PAYLOAD + + +def test_jp2_malformed_box_guard(): + # LBox==1 with XLBox==0 -> end<=pos -> iteration guard returns + bad = struct.pack(">I", 1) + b"junk" + struct.pack(">Q", 0) + assert im.extract(im._JP2_MAGIC + bad) is None + + +def test_jp2_extract_none_when_absent(): + assert im.extract(_jp2(_box(b"jp2c", b"cs"))) is None + + +def _box_xl(tbox, content): + # 64-bit length box: LBox=1, then 8-byte XLBox = total length + total = len(content) + 16 + return struct.pack(">I", 1) + tbox + struct.pack(">Q", total) + content + + +# ====================================================================== GIF +def test_gif_detect_both_magics(): + assert im.detect_format(_gif()) == "gif" + assert im.detect_format(b"GIF87a" + b"\x00" * 20) == "gif" + + +def test_gif_embed_extract_roundtrip(): + src = _gif(blocks=_gif_image_block()) + out = im.embed(src, PAYLOAD) + assert im.detect_format(out) == "gif" + assert im.extract(out) == PAYLOAD + + +def test_gif_replace_existing(): + once = im.embed(_gif(blocks=_gif_image_block()), PAYLOAD) + twice = im.embed(once, "second") + assert im.extract(twice) == "second" + + +def test_gif_large_payload_subblocks(): + big = "y" * 600 # > 255 -> multiple sub-blocks + out = im.embed(_gif(blocks=_gif_image_block()), big) + assert im.extract(out) == big + + +def test_gif_no_global_color_table(): + src = _gif(packed=0x00, blocks=_gif_image_block()) # no GCT + out = im.embed(src, PAYLOAD) + assert im.extract(out) == PAYLOAD + + +def test_gif_local_color_table_and_other_extension(): + # image descriptor WITH local color table + a non-comment (graphic control) ext + gce = b"\x21\xf9\x04\x00\x00\x00\x00\x00" # graphic control extension + src = _gif(blocks=gce + _gif_image_block(local_ct=True)) + out = im.embed(src, PAYLOAD) + assert im.extract(out) == PAYLOAD + + +def test_gif_unknown_introducer_stops(): + src = _gif(blocks=b"\x99raw", trailer=False) # unknown block byte + assert im.extract(src) is None + + +def test_gif_extract_none_when_absent(): + assert im.extract(_gif(blocks=_gif_image_block())) is None + + +# ===================================================================== WebP +def test_webp_detect_embed_extract(): + src = _riff_webp((b"VP8 ", b"pixeldata")) + assert im.detect_format(src) == "webp" + out = im.embed(src, PAYLOAD) + assert im.extract(out) == PAYLOAD + + +def test_webp_replace_and_padding(): + # odd-length body exercises the RIFF pad byte; replace drops the old sdAT + once = im.embed(_riff_webp((b"VP8L", b"abc")), "odd") # 3-byte -> pad + twice = im.embed(once, "evenpad!") # 8-byte -> no pad + assert im.extract(twice) == "evenpad!" + + +def test_webp_extract_none_when_absent(): + assert im.extract(_riff_webp((b"VP8 ", b"pixeldata"))) is None + + +# ================================================================== Fassade +def test_detect_unknown_returns_none(): + assert im.detect_format(b"not an image") is None + + +def test_supported_formats(): + assert im.supported_formats() == ("png", "jpeg", "jp2", "gif", "webp") + + +def test_embed_unsupported_format_raises(): + with pytest.raises(im.UnsupportedImageFormatError): + im.embed(b"not an image", PAYLOAD) + with pytest.raises(im.UnsupportedImageFormatError): + im.embed(b"\x89PNG\r\n\x1a\n...", PAYLOAD, fmt="tiff") + + +def test_extract_unknown_format_is_lenient(): + assert im.extract(b"not an image") is None + + +def test_embed_explicit_fmt(): + out = im.embed(_png_minimal(), PAYLOAD, fmt="png") + assert im.extract(out, fmt="png") == PAYLOAD + + +# ----------------------------------------------- real images (Pillow round-trips) +@pytest.fixture +def _pil(): + return pytest.importorskip("PIL.Image") + + +def _encode(pil, fmt, **kwargs): + import io + buf = io.BytesIO() + pil.new("RGB", (7, 5), (10, 20, 30)).save(buf, fmt, **kwargs) + return buf.getvalue() + + +@pytest.mark.parametrize("fmt,kwargs", [ + ("PNG", {}), ("JPEG", {}), ("JPEG2000", {}), ("GIF", {}), + ("WEBP", {}), ("WEBP", {"lossless": True}), +]) +def test_real_image_roundtrip_keeps_pixels(_pil, fmt, kwargs): + import io + raw = _encode(_pil, fmt, **kwargs) + out = im.embed(raw, PAYLOAD) + assert im.extract(out) == PAYLOAD + # the image still decodes and keeps its dimensions (no corruption) + reopened = _pil.open(io.BytesIO(out)) + reopened.load() + assert reopened.size == (7, 5)