"""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) @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]) 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. self.discards.insert(index, self.discards[index]) 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 @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) path: Path | None = None # -- 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, ) ) 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, "content_rect": list(self.content_rect), "levels": list(self.levels), "metadata": self.metadata, "pages": [ { "skew": page.skew, "cuts": [[list(p) for p in cut.points] for cut in page.cuts], "discards": page.discards, "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"], 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, ) 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 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()