"""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())