A system can now be given the measure it starts at, in a First bar field beside key and time. Per slice, since it is the one thing about a replacement that cannot be inherited from the song. Bar numbering is a Score property, so it is set once on the first staff, and made visible only at a line beginning — that vector is fussy: #(#f #t #t) also prints a number mid-system and #(#f #t #f) prints the second bar's rather than the first's. It travels in the bundle's engraving object as `bar`. Two things LilyPond 2.24 was quietly refusing to draw: `\bar ":|"` and the other old repeat names produce nothing at all — no error, no warning, exit status 0, just a missing repeat that you find on the tablet. Every book and forum answer still uses them, so translate them to the modern spellings. `\clef treble_8` unquoted is not an octavated clef either. It parses as a plain treble plus a stray "8" markup that lands under the first note, and the staff then reads an octave off — a tenor line engraved at soprano pitch. Quote it. The source pane, which is where either of those would have been visible, is now a collapsed section at the bottom rather than a permanent slab. It uses the panel's own disclosure helper, lifted out of Editor so both can call it.
173 lines
6.1 KiB
Python
173 lines
6.1 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 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
|