Files
Esa Kataja 61b8ec8301 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.
2026-07-29 14:30:54 +03:00

197 lines
7.5 KiB
Python

"""Runnable check for LilyPond slice replacement.
Skips cleanly when LilyPond is not installed — that is the point of the
availability gate, so the check has to honour it.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pymupdf
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from noteman_slicer import lilypond # 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.detect import staff_count # noqa: E402
from noteman_slicer.project import ( # noqa: E402
Cut,
Project,
Replacement,
Voice,
default_path,
)
from noteman_slicer.render import cut_slice, render_slices, scale_song, slice_mask # noqa: E402
# Notes are relative, so no octave marks except where a leap needs one.
SATB = Replacement(
voices=[
Voice("treble", "c4 d e f | g2 e2", "la la la la la la"),
Voice("bass", "c4 d e f | g2 c2", "la la la la la la"),
]
)
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:
# Bar aliases are string work, so they are checked whether or not LilyPond
# is installed. The old repeat names draw nothing at all in 2.24 — silently,
# which is how a missing repeat reaches a tablet.
aliased = lilypond.generate(
Replacement(voices=[Voice("treble", 'c4 d \\bar ":|" e f \\bar "|:" g', "")]), "c", "4/4"
)
assert '\\bar ":|."' in aliased and '\\bar ".|:"' in aliased, aliased
kept = lilypond.generate(
Replacement(voices=[Voice("treble", 'c4 \\bar "|." d', "")]), "c", "4/4"
)
assert '\\bar "|."' in kept, "a name LilyPond still knows is left alone"
# An octavated clef name must be quoted. Unquoted, `\clef treble_8` is a
# plain treble with a stray "8" markup under the first note, an octave off.
tenor = lilypond.generate(
Replacement(voices=[Voice("treble_8", "c4 d", "")]), "c", "4/4"
)
assert '\\clef "treble_8"' in tenor, tenor
# A bar number is set once, on the first staff, since it is a Score
# 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", "")]),
"c",
"4/4",
33,
)
assert numbered.count("currentBarNumber = #33") == 1, numbered
assert "break-visibility = #'#(#f #f #t)" in numbered
assert "currentBarNumber" not in lilypond.generate(
Replacement(voices=[Voice("treble", "c4 d", "")]), "c", "4/4"
), "an unnumbered system prints no number"
if not lilypond.available():
print("ok (skipped: LilyPond not installed)")
return 0
tmp = Path(__file__).with_name("_tmp")
tmp.mkdir(exist_ok=True)
pdf = tmp / "scan.pdf"
_scan_pdf(pdf)
# A syntax error must come back readable rather than as a stack trace.
try:
lilypond.render("\\score { this is not lilypond }")
except lilypond.LilypondError as error:
assert str(error), "the error must carry LilyPond's own message"
else:
raise AssertionError("bad source should raise")
# The generator: key at slice level, time used but not printed.
source = lilypond.generate(SATB, "aes", "4/4")
assert source.count("\\new Staff") == 2
assert source.count("\\key aes \\major") == 2, "every staff carries the key"
assert "\\omit Staff.TimeSignature" in source, "a middle system prints no time signature"
assert "\\addlyrics" in source
# Relative entry, referenced to the middle of each clef's staff, so notes
# carry no octave marks.
assert "\\relative c'' { c4 d e f | g2 e2 }" in source
assert "\\relative c { c4 d e f | g2 c2 }" in source
printed = lilypond.generate(
Replacement(voices=SATB.voices, print_time=True), "aes", "4/4"
)
assert "\\omit Staff.TimeSignature" not in printed
override = lilypond.generate(Replacement(voices=SATB.voices, key="d"), "aes", "4/4")
assert "\\key d \\major" in override, "a slice-level key must win over the song's"
# Every key signature and clef the form offers must be real LilyPond.
assert len(lilypond.KEY_SIGNATURES) == 15
assert ("4♭ — A♭ major / F minor", "aes") in lilypond.KEY_SIGNATURES
assert [v for _, v in lilypond.CLEFS] == ["treble", "treble_8", "bass", "alto"]
engraved = lilypond.render(source, dpi=200)
assert engraved.ndim == 2 and engraved.dtype == np.uint8
# -dcrop trims to the ink, so the result is far smaller than a page.
assert engraved.shape[0] < 1200, engraved.shape
assert engraved.min() == 0 and engraved.max() == 255
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.replacements) == page.slice_count
kept = project.kept_slices()
(_, first), (_, second) = kept
# Voice count is seeded from the slice: the fixture draws two staves.
preview = cut_slice(gray, slice_mask(project, 0, second, gray.shape))
assert staff_count(preview) == 2, staff_count(preview)
page.replacements[second] = SATB
slices = render_slices(project, source)
assert len(slices) == 2
scanned, replaced = slices
assert scanned.staff and replaced.staff
# The whole point: after normalisation both sit at the same staff height,
# with no manual scaling, even though the sources differ wildly in scale.
factors = [target / s.staff for s, target in ((scanned, 1.0), (replaced, 1.0))]
assert factors # keep the intent readable
out = scale_song(slices)
heights = []
for image, original in zip(out, slices):
k = image.shape[0] / original.gray.shape[0]
heights.append(original.staff * k)
assert abs(heights[0] - heights[1]) < 2.0, f"staff heights should match: {heights}"
# Cut edits keep the replacement aligned with its slice.
index = page.add_cut(Cut.straight(0.97))
assert len(page.replacements) == page.slice_count
assert page.replacements[second] is SATB
page.remove_cut(index)
assert page.replacements[second] is SATB
# Round-trip, including the song-level engraving defaults.
page.bars[second] = 33
project.key, project.time, project.clefs = "aes", "3/4", ["treble", "bass"]
saved = project.save()
reloaded = Project.load(saved)
assert (reloaded.key, reloaded.time, reloaded.clefs) == ("aes", "3/4", ["treble", "bass"])
restored = reloaded.pages[0].replacements[second]
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 reloaded.pages[0].bars[second] == 33, "the slice's bar number survives a save"
assert reloaded.pages[0].replacements[first] is None
source.close()
for f in (pdf, saved, default_path(pdf)):
f.unlink(missing_ok=True)
tmp.rmdir()
print("ok")
return 0
if __name__ == "__main__":
sys.exit(main())