"""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 re 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", "tempo", "voices", ) # Beats per minute, exported as a JSON number. A figure is worth more than a # word here: "Andante" cannot drive a metronome and two people will not agree # what it means. NUMERIC_FIELDS = frozenset({"tempo"}) def filename(project: Project) -> str: """The bundle's name, from the song's title. Spaces become dashes and anything that is not a letter, digit, dash, dot or underscore goes. Letters keep their accents — ä and ö are not a filesystem's problem — but a leading dot would make the bundle invisible. """ # Drop the unsafe characters before collapsing whitespace, not after, or # "Sävel & Ääni" keeps the dash the ampersand left behind. title = re.sub(r"[^\w\s.-]", "", (project.metadata.get("title") or "")) return f"{re.sub(r'\s+', '-', title.strip()).lstrip('.-') or 'song'}.zip" def _engraving(project: Project, page: int, slot: int) -> dict | None: """The notation behind a re-engraved slice, or None for a scanned one. The slice image stays the presentation; this is the notation it was made from, carried so the music can be edited again or turned into sound. Key and time are resolved against the song defaults here — a consumer reading one slice should not have to know what the rest of the song inherited. """ replacement = project.pages[page].replacements[slot] if not replacement or not replacement.voices: return None return { "lang": "lilypond", "key": replacement.key or project.key, "time": replacement.time or project.time, "print_time": replacement.print_time, **({"bar": replacement.bar} if replacement.bar else {}), "voices": [ {"clef": v.clef, "notes": v.notes.strip()} | ({"lyrics": v.lyrics.strip()} if v.lyrics.strip() else {}) for v in replacement.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) or "").strip() if not value: continue if field in NUMERIC_FIELDS: try: payload[field] = int(value) except ValueError: continue # not a number, so not worth exporting as one else: payload[field] = value kept = project.kept_slices() # Markers reference slices by (page, slot) while editing, because that is # what survives adding and removing cuts. In the bundle they become the # array index, which is the only cross-reference the format has. index_of = {position: i for i, position in enumerate(kept)} slices: list[dict] = [] for name, (page, slot) in zip(files, kept): entry: dict = {"file": name} engraving = _engraving(project, page, slot) if engraving: entry["engraving"] = engraving markers = [] for marker in project.pages[page].markers[slot]: item: dict = {"type": marker.type} if marker.label: item["label"] = marker.label if marker.destination is not None: target = index_of.get(tuple(marker.destination)) # A jump whose target was discarded or re-cut away is dropped # rather than exported dangling: noteman would have nothing to # resolve it to. if target is None: continue item["destination"] = target markers.append(item) if markers: entry["markers"] = markers slices.append(entry) payload["slices"] = slices return payload def write(project: Project, source: Source, path: Path) -> Path: """Render the song and write the bundle. Returns the zip path. A title is required; every other metadata field is optional. noteman's own rule is that a song needs a title and at least one slice, and a bundle that cannot become a song is not worth writing. """ if not project.metadata.get("title", "").strip(): raise ValueError("a title is required before a song can be exported") images = render_song(project, source) if not images: raise ValueError("no slices to export — every slice is discarded") 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(): pdf = project.source.read_bytes() if project.optimise_pdf: import pymupdf from .pdfopt import optimise shrunk, _ = optimise(pymupdf.open(project.source), len(pdf)) pdf = shrunk or pdf # empty means it found no saving zf.writestr("original.pdf", pdf, zipfile.ZIP_STORED) 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