Add LilyPond slice replacement with a structured engrave window
Re-engraving is a rescue path for the handful of systems a scan cannot deliver, so the window is an editing surface rather than an automation project. Three full-width rows - the scanned system, the render, the form - because a system is wide and short and the job is comparing one against the other bar by bar. The render is shown scaled to the scan's staff height, which is what export does anyway, so it previews the real thing. A form rather than a text box. Key and time are slice-level, clef, notes and lyrics per voice: every staff in a system carries the same key signature, and Kaipaava proves it across five-staff and two-staff systems alike. Notes and lyrics stay raw LilyPond, so slurs, dynamics, tuplets and the laissezVibrer/repeatTie idiom for ties crossing into the next slice all work untouched. Notes are entered in \relative mode, referenced to the middle of each clef's staff, so a part needs no octave marks at all in the common case. The time signature is used for spacing and bar checks but not printed: the printed score repeats the key at every system and the time only at the first, so a re-engraved middle slice showing one would stand out. Seeded from what can be known reliably. Voice count comes from counting staves in the slice; key, time and clefs are inherited from the song, because the slices being re-engraved are the illegible ones and reading a key signature off them is exactly the measurement that fails. After the first replacement in a song only the notes need typing. Staff counting needed two corrections against the corpus: compare gaps against line spacing rather than staff height, since adjacent staves can sit closer together than one staff is tall; and require five lines in a group, since Engel's 'uh______' lyric extenders are long horizontal runs too and each counted as a staff. Kaipaava now reads 2,2,2,2,5 on page 1, Ketun 6, Engel 4. Also in this change: - Title is required for export, every other metadata field optional, enforced in bundle.write so the CLI and the editor both get it. Tempo added; noteman already has a free-form column for it. - The panel is a splitter rather than a fixed width, sections collapse under bold grey disclosure headers, and it scrolls. - A re-engraved slice is washed amber with an ENGRAVED badge, and markers get badges too. Thin coloured text was invisible against a scan. Closes #31 Closes #32 Closes #33 Closes #34
This commit is contained in:
@@ -120,6 +120,37 @@ class Marker:
|
||||
return text
|
||||
|
||||
|
||||
@dataclass
|
||||
class Voice:
|
||||
"""One staff of a re-engraved system.
|
||||
|
||||
`notes` and `lyrics` are raw LilyPond, so slurs, dynamics, tuplets and the
|
||||
`\\laissezVibrer` / `\\repeatTie` idiom for ties crossing a slice boundary
|
||||
all work without the form knowing anything about them.
|
||||
"""
|
||||
|
||||
clef: str = "treble"
|
||||
notes: str = ""
|
||||
lyrics: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Replacement:
|
||||
"""A system engraved with LilyPond in place of the scanned one.
|
||||
|
||||
Key and time are per song in practice — Kaipaava is 4♭ and 4/4 from first
|
||||
system to last — so they live on the project and are only set here when a
|
||||
slice genuinely differs.
|
||||
"""
|
||||
|
||||
voices: list[Voice] = field(default_factory=list)
|
||||
key: str | None = None
|
||||
time: str | None = None
|
||||
# The printed score repeats the key signature at every system but not the
|
||||
# time signature, so a re-engraved middle slice must not show one.
|
||||
print_time: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class Page:
|
||||
"""One page's decisions. `cuts` are ordered top to bottom."""
|
||||
@@ -129,6 +160,9 @@ class Page:
|
||||
discards: list[bool] = field(default_factory=lambda: [False])
|
||||
# One list per slice, parallel to `discards`.
|
||||
markers: list[list[Marker]] = field(default_factory=lambda: [[]])
|
||||
# A re-engraved system per slice, when the scan is past saving. None for
|
||||
# the ordinary case, which is nearly all of them.
|
||||
replacements: list[Replacement | None] = field(default_factory=lambda: [None])
|
||||
content_rect: tuple[float, float, float, float] | None = None
|
||||
levels: tuple[int, int] | None = None
|
||||
|
||||
@@ -153,6 +187,7 @@ class Page:
|
||||
# is at least predictable, and moving one is a click.
|
||||
self.discards.insert(index, self.discards[index])
|
||||
self.markers.insert(index + 1, [])
|
||||
self.replacements.insert(index + 1, None)
|
||||
return index
|
||||
|
||||
def remove_cut(self, index: int) -> None:
|
||||
@@ -162,6 +197,15 @@ class Page:
|
||||
self.discards.pop(index + 1)
|
||||
self.discards[index] = merged
|
||||
self.markers[index].extend(self.markers.pop(index + 1))
|
||||
# Two engraved halves cannot be merged, so the upper one wins.
|
||||
below = self.replacements.pop(index + 1)
|
||||
self.replacements[index] = self.replacements[index] or below
|
||||
|
||||
def remember_clefs(self, project: Project, slot: int) -> None:
|
||||
"""Carry this slice's clefs forward as the song's defaults."""
|
||||
replacement = self.replacements[slot]
|
||||
if replacement and replacement.voices:
|
||||
project.clefs = [v.clef for v in replacement.voices]
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -172,6 +216,12 @@ class Project:
|
||||
content_rect: tuple[float, float, float, float] = (0.0, 0.0, 1.0, 1.0)
|
||||
levels: tuple[int, int] = (0, 255)
|
||||
metadata: dict[str, str] = field(default_factory=dict)
|
||||
# Engraving defaults for the song. Key and time are set once and inherited
|
||||
# by every replacement; `clefs` remembers what each voice position was last
|
||||
# given, so the second re-engraved system in a song opens already filled in.
|
||||
key: str = "c"
|
||||
time: str = "4/4"
|
||||
clefs: list[str] = field(default_factory=list)
|
||||
path: Path | None = None
|
||||
# Set once the song has been exported. A project is spent at that point:
|
||||
# opening the PDF again starts a fresh session from detection rather than
|
||||
@@ -236,6 +286,7 @@ class Project:
|
||||
cuts=[Cut.straight(y / height) for y in ys],
|
||||
discards=discards,
|
||||
markers=[[] for _ in discards],
|
||||
replacements=[None] * len(discards),
|
||||
# Per page, not per song: scans drift, so the margin junk
|
||||
# sits in a different place on each one.
|
||||
content_rect=detection.content,
|
||||
@@ -254,6 +305,9 @@ class Project:
|
||||
"content_rect": list(self.content_rect),
|
||||
"levels": list(self.levels),
|
||||
"metadata": self.metadata,
|
||||
"key": self.key,
|
||||
"time": self.time,
|
||||
"clefs": self.clefs,
|
||||
"pages": [
|
||||
{
|
||||
"skew": page.skew,
|
||||
@@ -274,6 +328,20 @@ class Project:
|
||||
]
|
||||
for slot in page.markers
|
||||
],
|
||||
"replacements": [
|
||||
None
|
||||
if r is None
|
||||
else {
|
||||
"voices": [
|
||||
{"clef": v.clef, "notes": v.notes, "lyrics": v.lyrics}
|
||||
for v in r.voices
|
||||
],
|
||||
**({"key": r.key} if r.key else {}),
|
||||
**({"time": r.time} if r.time else {}),
|
||||
**({"print_time": True} if r.print_time else {}),
|
||||
}
|
||||
for r in page.replacements
|
||||
],
|
||||
"content_rect": list(page.content_rect) if page.content_rect else None,
|
||||
"levels": list(page.levels) if page.levels else None,
|
||||
}
|
||||
@@ -309,6 +377,27 @@ class Project:
|
||||
]
|
||||
for slot in page.get("markers", [[] for _ in page["discards"]])
|
||||
],
|
||||
replacements=[
|
||||
# A bare string is the short-lived raw-source form, which
|
||||
# never shipped: dropped rather than migrated, so the rest
|
||||
# of the project still opens.
|
||||
None
|
||||
if not isinstance(r, dict)
|
||||
else Replacement(
|
||||
voices=[
|
||||
Voice(
|
||||
clef=v.get("clef", "treble"),
|
||||
notes=v.get("notes", ""),
|
||||
lyrics=v.get("lyrics", ""),
|
||||
)
|
||||
for v in r.get("voices", [])
|
||||
],
|
||||
key=r.get("key"),
|
||||
time=r.get("time"),
|
||||
print_time=r.get("print_time", False),
|
||||
)
|
||||
for r in page.get("replacements", [None] * len(page["discards"]))
|
||||
],
|
||||
content_rect=tuple(page["content_rect"]) if page["content_rect"] else None,
|
||||
levels=tuple(page["levels"]) if page["levels"] else None,
|
||||
)
|
||||
@@ -323,6 +412,9 @@ class Project:
|
||||
metadata=data.get("metadata", {}),
|
||||
path=path,
|
||||
exported=data.get("exported", False),
|
||||
key=data.get("key", "c"),
|
||||
time=data.get("time", "4/4"),
|
||||
clefs=data.get("clefs", []),
|
||||
)
|
||||
|
||||
def source_changed(self) -> bool:
|
||||
|
||||
Reference in New Issue
Block a user