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.
479 lines
18 KiB
Python
479 lines
18 KiB
Python
"""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** (0–1 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 statistics import median
|
||
|
||
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 measure this system starts at, printed above its first bar the way a
|
||
# score numbers its systems. Per slice and nothing else: it is the one thing
|
||
# about a replacement that cannot be inherited or guessed.
|
||
bar: int | 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)
|
||
# Shrink the archival PDF carried in the bundle by converting its scanned
|
||
# pages to bilevel. Off by default: it is lossy on the copy kept for
|
||
# printing, and on some scans it breaks staff lines.
|
||
optimise_pdf: bool = False
|
||
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,
|
||
)
|
||
)
|
||
# Levels per song, not per page: a scanner's contrast does not change
|
||
# between sheets, and one pair of sliders for the whole song is what a
|
||
# user actually wants to nudge. The median keeps a near-blank page —
|
||
# where the ink/paper split is guesswork — from setting them.
|
||
proposals = [d.levels for d in detections] or [(0, 255)]
|
||
levels = (
|
||
int(median(b for b, _ in proposals)),
|
||
int(median(w for _, w in proposals)),
|
||
)
|
||
return cls(
|
||
source=source, source_hash=hash_file(source), pages=pages, levels=levels
|
||
)
|
||
|
||
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,
|
||
"optimise_pdf": self.optimise_pdf,
|
||
"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 {}),
|
||
**({"bar": r.bar} if r.bar 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),
|
||
bar=r.get("bar"),
|
||
)
|
||
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", []),
|
||
optimise_pdf=data.get("optimise_pdf", False),
|
||
)
|
||
|
||
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()
|