The bundle is documented as a standalone format rather than as a note between two programs: producers and consumers are generic, the marker vocabulary is defined musically rather than by what a viewer does with it, and image properties are stated as guarantees with the reasoning where it is not obvious. Anything that reads scores can implement it without knowing this tool exists. Taking that view changed the substance in three places. Marker types now carry their musical meaning rather than a UI mapping. The rule against re-importing became a statement about identity - indices mean something only within one bundle, so two bundles of a piece are independent documents. And forward-compatibility rules were added, which a protocol needs and a handover note did not: ignore unknown fields and marker types, refuse an unknown version. Tempo is now an integer, beats per minute, exported as a JSON number and omitted when blank; the editor accepts digits only. A figure can drive a metronome or a click track where a verbal marking cannot, and readers do not agree on what Andante means. This needs the consumer's column changed from free-form text, which the format document flags. Every figure in the document comes from a real export.
124 lines
4.3 KiB
Python
124 lines
4.3 KiB
Python
"""Runnable check for markers: model, cut edits, and export resolution."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import pymupdf
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
|
|
from noteman_slicer import bundle # noqa: E402
|
|
from noteman_slicer.bundle import song_json # noqa: E402
|
|
from noteman_slicer.detect import detect_page # noqa: E402
|
|
from noteman_slicer.pdf import open_source, page_raster # noqa: E402
|
|
from noteman_slicer.project import ( # noqa: E402
|
|
JUMP_TYPES,
|
|
MARKER_TYPES,
|
|
Cut,
|
|
Marker,
|
|
Project,
|
|
default_path,
|
|
)
|
|
|
|
W, H = 1200, 1600
|
|
|
|
|
|
def _scan_pdf(path: Path) -> None:
|
|
art = np.full((H, W), 255, np.uint8)
|
|
for top in (300, 800):
|
|
art[top : top + 200, 100:104] = 0
|
|
for staff in (top, top + 140):
|
|
for i in range(5):
|
|
art[staff + i * 15 : staff + i * 15 + 2, 110:1100] = 0
|
|
pix = pymupdf.Pixmap(pymupdf.csGRAY, W, H, bytearray(art.tobytes()), False)
|
|
doc = pymupdf.open()
|
|
doc.new_page(width=595, height=842).insert_image(pymupdf.Rect(0, 0, 595, 842), pixmap=pix)
|
|
doc.save(path)
|
|
|
|
|
|
def main() -> int:
|
|
tmp = Path(__file__).with_name("_tmp")
|
|
tmp.mkdir(exist_ok=True)
|
|
pdf = tmp / "scan.pdf"
|
|
_scan_pdf(pdf)
|
|
|
|
# noteman's enum, verbatim — this is real coupling between two repos.
|
|
assert len(MARKER_TYPES) == 14, MARKER_TYPES
|
|
assert len(JUMP_TYPES) == 6
|
|
assert "generic_jump" in JUMP_TYPES and "segno" not in JUMP_TYPES
|
|
|
|
source = open_source(pdf)
|
|
gray = page_raster(source, 0)
|
|
project = Project.from_detection(pdf, [detect_page(gray)], [gray.shape[0]])
|
|
page = project.pages[0]
|
|
assert len(page.markers) == page.slice_count
|
|
|
|
kept = project.kept_slices()
|
|
assert len(kept) == 2, kept
|
|
(_, first), (_, second) = kept
|
|
|
|
page.markers[first].append(Marker("rehearsal_letter", label="A"))
|
|
page.markers[second].append(Marker("coda"))
|
|
page.markers[first].append(Marker("to_coda", destination=(0, second)))
|
|
|
|
# Cut edits keep markers aligned with their slices.
|
|
before = list(page.markers[first])
|
|
index = page.add_cut(Cut.straight(0.95))
|
|
assert len(page.markers) == page.slice_count
|
|
assert page.markers[first] == before, "markers must not move when a later slice splits"
|
|
page.remove_cut(index)
|
|
assert len(page.markers) == page.slice_count
|
|
|
|
# Export resolves (page, slot) to the slice's index in the bundle.
|
|
names = [f"{i + 1:03}.webp" for i in range(len(project.kept_slices()))]
|
|
project.metadata.update({"title": "Test song", "tempo": "92", "composer": ""})
|
|
payload = song_json(project, names)
|
|
# Tempo is a number, not a string; empty fields are absent, not "".
|
|
assert payload["tempo"] == 92, payload["tempo"]
|
|
assert "composer" not in payload
|
|
|
|
project.metadata["tempo"] = "Andante"
|
|
assert "tempo" not in song_json(project, names), "words are not a tempo"
|
|
project.metadata["tempo"] = "92"
|
|
slices = payload["slices"]
|
|
assert [s["file"] for s in slices] == names
|
|
assert slices[0]["markers"][0] == {"type": "rehearsal_letter", "label": "A"}
|
|
assert slices[1]["markers"][0] == {"type": "coda"}
|
|
assert slices[0]["markers"][1] == {"type": "to_coda", "destination": 1}
|
|
|
|
# A jump whose target got discarded is dropped, not exported dangling.
|
|
project.pages[0].discards[second] = True
|
|
dropped = song_json(project, ["001.webp"])
|
|
assert all(m["type"] != "to_coda" for m in dropped["slices"][0].get("markers", []))
|
|
project.pages[0].discards[second] = False
|
|
|
|
# Round-trip through the project file.
|
|
saved = project.save()
|
|
reloaded = Project.load(saved)
|
|
assert reloaded.pages[0].markers[first][0].label == "A"
|
|
assert reloaded.pages[0].markers[first][1].destination == (0, second)
|
|
assert reloaded.pages[0].markers[second][0].type == "coda"
|
|
|
|
# And through a real bundle.
|
|
reloaded.metadata["title"] = "Test song"
|
|
out = bundle.write(reloaded, source, tmp / "song.zip")
|
|
with zipfile.ZipFile(out) as zf:
|
|
meta = json.loads(zf.read("song.json"))
|
|
assert meta["slices"][0]["markers"][1]["destination"] == 1, meta["slices"]
|
|
|
|
source.close()
|
|
for f in (pdf, out, saved, default_path(pdf)):
|
|
f.unlink(missing_ok=True)
|
|
tmp.rmdir()
|
|
print("ok")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|