A bundle was a one-way trip. The slice images are output and the cuts that produced them lived only in the producer's own project file, so a bundle someone handed you meant cutting the score again from scratch. The manifest now carries the geometry, in a `source` block: per page the cut polylines, skew, levels and content rectangle, all in normalised coordinates so they survive any render resolution, and per slice the page and slot it came from. Discards are stated by omission — a slot no slice claims was discarded — since shipping a discarded slice's image would defeat discarding it. `noteman-slicer open song.zip` unpacks the archived PDF, rebuilds the project from that geometry, restores markers, engravings and the title block, and opens the editor. Jump destinations go back from an array index to the (page, slot) the editor works in. The images in the zip are discarded: the PDF is what the pipeline renders from. Re-exporting a reopened bundle reproduces its manifest exactly. It refuses to overwrite a PDF or project file already sitting there, because the obvious place to unpack is where someone's unfinished cuts live. Separately, every slice can now carry the measure it starts at, not just a re-engraved one — a scanned system is numbered in the score the same way, and noteman wants to answer "take it from bar 33" about either. It moves off the replacement onto the page, alongside markers and discards, and out of the bundle's engraving object onto the slice.
186 lines
7.4 KiB
Python
186 lines
7.4 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])
|
|
page.bars[first] = 5
|
|
index = page.add_cut(Cut.straight(0.95))
|
|
assert len(page.markers) == page.slice_count
|
|
assert len(page.bars) == page.slice_count
|
|
assert page.markers[first] == before, "markers must not move when a later slice splits"
|
|
assert page.bars[first] == 5, "the upper half still starts where the slice did"
|
|
page.remove_cut(index)
|
|
assert len(page.markers) == page.slice_count
|
|
assert len(page.bars) == page.slice_count and page.bars[first] == 5
|
|
page.bars[first] = None
|
|
|
|
# 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 ", " ")],
|
|
)
|
|
page.bars[second] = 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"]) == ("aes", "3/4", False)
|
|
# The bar number is on the slice, not the engraving: a scanned system is
|
|
# numbered in the score just the same.
|
|
assert engraved[1]["bar"] == 33 and "bar" not in ly, engraved[1]
|
|
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"]
|
|
|
|
# And back out again. The bundle carries the cuts, so reopening it rebuilds
|
|
# the project rather than re-cutting the score — and a jump goes back from
|
|
# an array index to the (page, slot) the editor works in.
|
|
reloaded.pages[0].replacements[second] = Replacement(
|
|
voices=[Voice("treble", "c4 d", "la la")]
|
|
)
|
|
reloaded.pages[0].bars[second] = 7
|
|
out = bundle.write(reloaded, source, tmp / "song.zip")
|
|
opened, unpacked = bundle.read(out, tmp / "reopened.pdf")
|
|
assert unpacked.exists() and unpacked.stat().st_size > 0
|
|
assert opened.metadata["title"] == "Test song"
|
|
assert len(opened.pages) == len(reloaded.pages)
|
|
back = opened.pages[0]
|
|
assert back.discards == reloaded.pages[0].discards
|
|
assert [len(c.points) for c in back.cuts] == [len(c.points) for c in reloaded.pages[0].cuts]
|
|
assert back.markers[first][1].destination == (0, second), back.markers[first][1].destination
|
|
assert back.markers[second][0].type == "coda"
|
|
assert back.bars[second] == 7
|
|
assert back.replacements[second].voices[0].lyrics == "la la"
|
|
|
|
# Unpacking never lands on files that are already there.
|
|
try:
|
|
bundle.read(out, tmp / "reopened.pdf")
|
|
except ValueError as error:
|
|
assert "already exists" in str(error), error
|
|
else:
|
|
raise AssertionError("reopening over an existing PDF should be refused")
|
|
|
|
source.close()
|
|
for f in (pdf, out, saved, unpacked, default_path(unpacked), default_path(pdf)):
|
|
f.unlink(missing_ok=True)
|
|
tmp.rmdir()
|
|
print("ok")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|