59 lines
2.1 KiB
Python
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))
|