Files
noteman-slicer/noteman_slicer/project.py
T
Esa Kataja 97c8e8a709 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
2026-07-29 01:29:43 +03:00

455 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Project state: everything the human decided, on disk beside the PDF.
The bundle is generated from this, so export is a pure function of the project
plus the PDF. That buys crash safety, resume across sessions, and re-export —
change the width cap or fix one cut and every song regenerates without
repeating any human work.
All geometry is stored in **normalised page coordinates** (01 of the deskewed
page), so the file is independent of DPI and of which renderer produced it.
"""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass, field
from pathlib import Path
from .detect import PageDetection
FORMAT_VERSION = 1
SUFFIX = ".slicer.json"
Point = tuple[float, float]
@dataclass
class Cut:
"""A boundary splitting one slice into two, spanning the page left to right.
A polyline, not a line. Two points is the ordinary straight case; extra
vertices handle a section label printed in the left margin at the same
height as the previous system's lyrics, where no horizontal line separates
the two (see docs/spec.md).
"""
points: list[Point]
@classmethod
def straight(cls, y: float) -> Cut:
return cls([(0.0, y), (1.0, y)])
@property
def straight_y(self) -> float | None:
"""The single y of a straight cut, or None if it steps."""
ys = {y for _, y in self.points}
return self.points[0][1] if len(ys) == 1 else None
def y_at(self, x: float) -> float:
"""Height of the boundary at a horizontal position."""
pts = self.points
if x <= pts[0][0]:
return pts[0][1]
for (x0, y0), (x1, y1) in zip(pts, pts[1:]):
if x <= x1:
if x1 == x0:
return y1
return y0 + (y1 - y0) * (x - x0) / (x1 - x0)
return pts[-1][1]
@property
def lowest(self) -> float:
return max(y for _, y in self.points)
@property
def highest(self) -> float:
return min(y for _, y in self.points)
# noteman's enum, verbatim. Real coupling between two repos: adding a type
# means changing both. Order is the order they appear in the editor's picker.
MARKER_TYPES = (
"rehearsal_letter",
"section_label",
"segno",
"coda",
"fine",
"repeat_start",
"repeat_end",
"volta",
"to_coda",
"ds_al_coda",
"ds_al_fine",
"dc_al_coda",
"dc_al_fine",
"generic_jump",
)
# The types that carry free text.
LABELLED_TYPES = frozenset({"rehearsal_letter", "section_label", "volta"})
# The types that send the reader elsewhere. Every one stores its target
# explicitly rather than resolving by type at read time, so the bundle is
# self-describing and a score with two codas simply works.
JUMP_TYPES = frozenset(
{"to_coda", "ds_al_coda", "ds_al_fine", "dc_al_coda", "dc_al_fine", "generic_jump"}
)
@dataclass
class Marker:
"""A semantic tag on a slice, used by noteman's navigation."""
type: str
label: str | None = None
# (page, slot) of the target slice, for jump sources. Positional like the
# slices themselves; resolved to a bundle index at export.
destination: tuple[int, int] | None = None
@property
def is_jump(self) -> bool:
return self.type in JUMP_TYPES
def describe(self) -> str:
text = self.type
if self.label:
text += f" “{self.label}”"
if self.destination:
text += f" → p{self.destination[0] + 1}s{self.destination[1] + 1}"
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."""
skew: float = 0.0
cuts: list[Cut] = field(default_factory=list)
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
@property
def slice_count(self) -> int:
return len(self.cuts) + 1
def bounds(self, index: int) -> tuple[Cut | None, Cut | None]:
"""The cuts above and below a slice; None means the page edge."""
above = self.cuts[index - 1] if index > 0 else None
below = self.cuts[index] if index < len(self.cuts) else None
return above, below
def add_cut(self, cut: Cut) -> int:
"""Insert a cut, splitting the slice it lands in. Returns its index."""
y = cut.points[0][1]
index = sum(1 for c in self.cuts if c.points[0][1] < y)
self.cuts.insert(index, cut)
# The split slice keeps its flag on both halves. Its markers stay with
# the upper half: a marker sits on a printed symbol, and splitting a
# slice cannot say which side that symbol landed on — leaving them put
# 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:
"""Drop a cut, merging the two slices it separated."""
self.cuts.pop(index)
merged = self.discards[index] and self.discards[index + 1]
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
class Project:
source: Path
source_hash: str
pages: list[Page]
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
# resuming, so a re-cut never begins from stale decisions. `--resume`
# overrides it when the old state really is wanted.
exported: bool = False
# -- geometry helpers -------------------------------------------------
def page_content_rect(self, index: int) -> tuple[float, float, float, float]:
return self.pages[index].content_rect or self.content_rect
def page_levels(self, index: int) -> tuple[int, int]:
return self.pages[index].levels or self.levels
def kept_slices(self) -> list[tuple[int, int]]:
"""(page, slice) of every slice that will be exported, in song order."""
return [
(p, s)
for p, page in enumerate(self.pages)
for s in range(page.slice_count)
if not page.discards[s]
]
# -- persistence ------------------------------------------------------
@classmethod
def from_detection(
cls, source: Path, detections: list[PageDetection], heights: list[int]
) -> Project:
"""Seed a project from detection. Every value here is a suggestion.
Detection emits cuts only *between* systems, so a page would otherwise
have exactly as many slices as it has systems, with the header and
footer inside the first and last. The boundary cuts that isolate them —
and the discard flags that drop them — are a slicing decision, not a
detection result, so they are added here.
"""
pages = []
for detection, height in zip(detections, heights):
ys = list(detection.cuts)
leading = trailing = False
if detection.systems:
first, last = detection.systems[0], detection.systems[-1]
if first.top > 0:
ys.insert(0, first.top // 2)
leading = True
if last.bottom < height:
ys.append((last.bottom + height) // 2)
trailing = True
discards = [False] * (len(ys) + 1)
if leading:
discards[0] = True
if trailing:
discards[-1] = True
pages.append(
Page(
skew=detection.skew,
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,
)
)
return cls(source=source, source_hash=hash_file(source), pages=pages)
def save(self, path: Path | None = None) -> Path:
"""Atomic write, so a crash mid-save cannot destroy the previous state."""
target = Path(path or self.path or default_path(self.source))
payload = {
"v": FORMAT_VERSION,
"source": self.source.name,
"source_hash": self.source_hash,
"exported": self.exported,
"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,
"cuts": [[list(p) for p in cut.points] for cut in page.cuts],
"discards": page.discards,
"markers": [
[
{
"type": m.type,
**({"label": m.label} if m.label else {}),
**(
{"destination": list(m.destination)}
if m.destination
else {}
),
}
for m in slot
]
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,
}
for page in self.pages
],
}
tmp = target.with_suffix(target.suffix + ".tmp")
tmp.write_text(json.dumps(payload, indent=2, ensure_ascii=False))
tmp.replace(target)
self.path = target
return target
@classmethod
def load(cls, path: Path, source: Path | None = None) -> Project:
path = Path(path)
data = json.loads(path.read_text())
if data.get("v") != FORMAT_VERSION:
raise ValueError(f"unsupported project version {data.get('v')!r}")
pdf = Path(source) if source else path.parent / data["source"]
pages = [
Page(
skew=page["skew"],
cuts=[Cut([tuple(p) for p in cut]) for cut in page["cuts"]],
discards=page["discards"],
markers=[
[
Marker(
type=m["type"],
label=m.get("label"),
destination=tuple(m["destination"]) if m.get("destination") else None,
)
for m in slot
]
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,
)
for page in data["pages"]
]
return cls(
source=pdf,
source_hash=data["source_hash"],
pages=pages,
content_rect=tuple(data["content_rect"]),
levels=tuple(data["levels"]),
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:
"""True when the PDF no longer matches what these decisions were made on."""
return self.source.exists() and hash_file(self.source) != self.source_hash
def open_project(source, *, resume: bool = False) -> Project:
"""The project for a PDF: resumed, or a fresh session from detection.
A project that has been exported is spent. Opening the PDF again starts
over from detection rather than resuming, so a re-cut never inherits stale
decisions. `resume` overrides that when the old state really is wanted.
"""
from .detect import detect_page
from .pdf import page_raster
path = default_path(source.path)
if path.exists():
existing = Project.load(path)
if resume or not existing.exported:
return existing
detections, heights = [], []
for i in range(len(source)):
gray = page_raster(source, i)
detections.append(detect_page(gray))
heights.append(gray.shape[0])
return Project.from_detection(source.path, detections, heights)
def default_path(source: Path) -> Path:
return Path(source).with_suffix(SUFFIX)
def hash_file(path: Path) -> str:
return hashlib.sha256(Path(path).read_bytes()).hexdigest()