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.
150 lines
5.6 KiB
Python
150 lines
5.6 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,
|
|
Replacement,
|
|
Voice,
|
|
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 re-engraved slice carries its notation into the bundle; a scanned one
|
|
# carries none. This is what makes a later edit or a MIDI render possible
|
|
# from the bundle alone.
|
|
project.key, project.time = "aes", "3/4"
|
|
page.replacements[second] = Replacement(
|
|
voices=[Voice("treble", "c4 d e f", "la la la la"), Voice("bass", " c4 d e f ", " ")],
|
|
bar=33,
|
|
)
|
|
engraved = song_json(project, names)["slices"]
|
|
assert "engraving" not in engraved[0], "a scanned slice has no notation"
|
|
ly = engraved[1]["engraving"]
|
|
assert ly["lang"] == "lilypond"
|
|
# Song defaults are resolved per slice: reading one slice needs no context.
|
|
assert (ly["key"], ly["time"], ly["print_time"], ly["bar"]) == ("aes", "3/4", False, 33)
|
|
assert ly["voices"][0] == {"clef": "treble", "notes": "c4 d e f", "lyrics": "la la la la"}
|
|
assert "lyrics" not in ly["voices"][1], "an empty field is absent, not empty"
|
|
assert ly["voices"][1]["notes"] == "c4 d e f"
|
|
|
|
override = Replacement(voices=page.replacements[second].voices, key="d", print_time=True)
|
|
page.replacements[second] = override
|
|
ly = song_json(project, names)["slices"][1]["engraving"]
|
|
assert (ly["key"], ly["time"], ly["print_time"]) == ("d", "3/4", True)
|
|
page.replacements[second] = None
|
|
|
|
# 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())
|