"""Bundle export — the only channel to noteman (ADR 0001). song.zip song.json original.pdf 001.webp 002.webp … Array order in `song.json` *is* slice order: one ordering, not two. Markers nest inside the slice they sit on, so an index appears in exactly one place — a jump source's `destination`. """ from __future__ import annotations import json import zipfile from pathlib import Path from .pdf import Source from .project import Project from .render import render_song FORMAT_VERSION = 1 METADATA_FIELDS = ( "title", "subtitle", "composer", "original_artist", "arranger", "lyricist", "translator", "voices", ) def song_json(project: Project, files: list[str]) -> dict: payload: dict = {"v": FORMAT_VERSION} for field in METADATA_FIELDS: value = project.metadata.get(field) if value: payload[field] = value payload["slices"] = [{"file": name} for name in files] return payload def write(project: Project, source: Source, path: Path) -> Path: """Render the song and write the bundle. Returns the zip path.""" images = render_song(project, source) names = [f"{i + 1:03}.webp" for i in range(len(images))] path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) # ZIP_STORED for the images: WebP is already compressed, so deflating it # only costs time. The JSON is small enough not to care. with zipfile.ZipFile(path, "w") as zf: zf.writestr( "song.json", json.dumps(song_json(project, names), indent=2, ensure_ascii=False), zipfile.ZIP_DEFLATED, ) if project.source.exists(): zf.write(project.source, "original.pdf") for name, data in zip(names, images): zf.writestr(name, data, zipfile.ZIP_STORED) # The project is spent once its song has been exported: the next edit # session starts fresh from detection rather than resuming these decisions. # Recorded here so no caller can forget it. project.exported = True project.save() return path