Files
noteman-slicer/tests/test_lilypond.py
T
Esa Kataja 8f670cf7db Engrave window: bar numbers, and two silent LilyPond faults
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.
2026-07-29 13:44:42 +03:00

196 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", "")], bar=33),
"c",
"4/4",
)
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.
SATB.bar = 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 restored.bar == 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())