Make a bundle reopenable, and number slices

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.
This commit is contained in:
Esa Kataja
2026-07-29 14:30:54 +03:00
parent 8f670cf7db
commit 61b8ec8301
12 changed files with 411 additions and 35 deletions
+4 -3
View File
@@ -75,9 +75,10 @@ def main() -> int:
# property, and is visible only at a line beginning — one number above the
# first bar, as a printed score numbers its systems.
numbered = lilypond.generate(
Replacement(voices=[Voice("treble", "c4 d", ""), Voice("bass", "c4 d", "")], bar=33),
Replacement(voices=[Voice("treble", "c4 d", ""), Voice("bass", "c4 d", "")]),
"c",
"4/4",
33,
)
assert numbered.count("currentBarNumber = #33") == 1, numbered
assert "break-visibility = #'#(#f #f #t)" in numbered
@@ -171,7 +172,7 @@ def main() -> int:
assert page.replacements[second] is SATB
# Round-trip, including the song-level engraving defaults.
SATB.bar = 33
page.bars[second] = 33
project.key, project.time, project.clefs = "aes", "3/4", ["treble", "bass"]
saved = project.save()
reloaded = Project.load(saved)
@@ -180,7 +181,7 @@ def main() -> int:
assert restored is not None
assert [v.clef for v in restored.voices] == ["treble", "bass"]
assert restored.voices[0].lyrics == "la la la la la la"
assert restored.bar == 33, "the slice's bar number survives a save"
assert reloaded.pages[0].bars[second] == 33, "the slice's bar number survives a save"
assert reloaded.pages[0].replacements[first] is None
source.close()
+39 -3
View File
@@ -70,11 +70,16 @@ def main() -> int:
# 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()))]
@@ -99,14 +104,17 @@ def main() -> int:
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,
)
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"], ly["bar"]) == ("aes", "3/4", False, 33)
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"
@@ -137,8 +145,36 @@ def main() -> int:
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, default_path(pdf)):
for f in (pdf, out, saved, unpacked, default_path(unpacked), default_path(pdf)):
f.unlink(missing_ok=True)
tmp.rmdir()
print("ok")