A bundle without a source block cannot be round-tripped, but its PDF can still be cut from scratch. Offer that instead of refusing: confirm, run detection, and line the markers up by position when the slice counts match exactly.
228 lines
9.0 KiB
Python
228 lines
9.0 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"
|
|
|
|
# A bundle from a producer that records no cuts: refused by default, and
|
|
# cut from scratch by detection when the caller says so. The slices are in
|
|
# reading order either way, so markers can be lined up by position — but
|
|
# only when detection finds exactly as many.
|
|
plain = tmp / "plain.zip"
|
|
with zipfile.ZipFile(out) as src, zipfile.ZipFile(plain, "w") as dst:
|
|
for name in src.namelist():
|
|
data = src.read(name)
|
|
if name == "song.json":
|
|
manifest = json.loads(data)
|
|
manifest.pop("source")
|
|
for entry in manifest["slices"]:
|
|
entry.pop("page", None)
|
|
entry.pop("slot", None)
|
|
data = json.dumps(manifest).encode()
|
|
dst.writestr(name, data)
|
|
assert bundle.has_cuts(out) and not bundle.has_cuts(plain)
|
|
try:
|
|
bundle.read(plain, tmp / "nocuts.pdf")
|
|
except bundle.NoCuts as error:
|
|
assert "no cuts" in str(error), error
|
|
else:
|
|
raise AssertionError("a bundle without cuts should not open silently")
|
|
|
|
cut_again, again_pdf = bundle.read(plain, tmp / "nocuts.pdf", detect=True)
|
|
assert again_pdf.exists()
|
|
assert cut_again.metadata["title"] == "Test song", "the title block still comes back"
|
|
assert len(cut_again.kept_slices()) == len(reloaded.kept_slices())
|
|
placed = [m for page in cut_again.pages for slot in page.markers for m in slot]
|
|
assert len(placed) == 3, placed
|
|
assert placed[0].label == "A"
|
|
|
|
# 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,
|
|
plain,
|
|
saved,
|
|
unpacked,
|
|
again_pdf,
|
|
default_path(unpacked),
|
|
default_path(again_pdf),
|
|
default_path(pdf),
|
|
):
|
|
f.unlink(missing_ok=True)
|
|
tmp.rmdir()
|
|
print("ok")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|