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.
196 lines
7.5 KiB
Python
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())
|