"""Writes a Recipe Bundle: a zip with bundle.json and downscaled photos (see docs/recipe-bundle-format.md).""" from __future__ import annotations import io import json import zipfile from datetime import datetime, timezone from PIL import Image, ImageOps, UnidentifiedImageError FORMAT = "cookit.recipe-bundle" VERSION = 1 MAX_PHOTO_EDGE = 1600 JPEG_QUALITY = 82 def downscale(data: bytes) -> bytes | None: """JPEG no larger than MAX_PHOTO_EDGE on its long side, or None if the picture can't be read.""" try: img = Image.open(io.BytesIO(data)) img = ImageOps.exif_transpose(img) except (UnidentifiedImageError, OSError, ValueError): return None if img.mode in ("RGBA", "LA", "P"): img = img.convert("RGBA") background = Image.new("RGB", img.size, "white") background.paste(img, mask=img.getchannel("A")) img = background elif img.mode != "RGB": img = img.convert("RGB") img.thumbnail((MAX_PHOTO_EDGE, MAX_PHOTO_EDGE), Image.Resampling.LANCZOS) out = io.BytesIO() img.save(out, "JPEG", quality=JPEG_QUALITY, optimize=True) return out.getvalue() def write_bundle(path: str, recipes: list[tuple[dict, list[bytes]]]) -> None: """recipes: (recipe dict, raw photo bytes in display order).""" with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as z: entries = [] for recipe, photos in recipes: recipe["photos"] = [] for raw in photos: jpeg = downscale(raw) if jpeg is None: continue name = f"photos/{recipe['id']}-{len(recipe['photos']) + 1}.jpg" z.writestr(zipfile.ZipInfo(name, date_time=(2026, 1, 1, 0, 0, 0)), jpeg, zipfile.ZIP_STORED) recipe["photos"].append(name) entries.append(recipe) manifest = { "format": FORMAT, "version": VERSION, "createdAt": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"), "recipes": entries, } z.writestr("bundle.json", json.dumps(manifest, ensure_ascii=False, indent=2))