Add the render pipeline and bundle export

Project state plus PDF in, finished slice images out. Slices are cut as
polygons rather than row ranges, so a stepped cut yields a slice with a
transparent notch instead of one that covers its neighbour.

Masking paints white, which the ink-to-alpha step turns into full
transparency — the same outcome the spec asks for, one step earlier.

Scale normalises every slice to the median staff height before fitting
the song to 1920px, so a rescanned page sits at the same note size as
its neighbours. The cap only ever shrinks: a song narrower than 1920
stays narrower.

Alpha quantisation rounds to 16 values spanning 0-255 inclusive.
Flooring, as first written, capped full ink at 240 and left every note
6% transparent — caught by decoding an exported slice rather than by
reading the code.

Ketun joululaulu exports 24 slices at a uniform 1489px, under the cap
and correctly not upscaled from its 200 DPI source; Feliz Navidad 20;
Elaman nalka 18.

Closes #21
Closes #22
Closes #23
Closes #24
Closes #25
Closes #27
This commit is contained in:
Esa Kataja
2026-07-28 23:00:53 +03:00
parent 9ed38323ef
commit 974c91a727
4 changed files with 496 additions and 0 deletions
+65
View File
@@ -0,0 +1,65 @@
"""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)
return path