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
66 lines
1.8 KiB
Python
66 lines
1.8 KiB
Python
"""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
|