"""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 .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) # 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, ) ) 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, "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 {}), } 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", []), 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()