Files
CookIT/tools/excel-import/cookit_import/bundle.py
T
2026-10-28 22:55:09 +01:00

59 lines
2.1 KiB
Python

"""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))