Files
noteman-slicer/noteman_slicer/project.py
T
Esa Kataja b6847a06ee Treat a project as spent once its song has been exported
Opening an exported song starts a fresh session from detection instead
of resuming: cuts, discards and metadata do not carry over, so a re-cut
never inherits decisions that have already shipped. --resume overrides
it on edit, export and project.

This reverses what was agreed in planning and written into docs/spec.md
and CONTEXT.md, which promised resume-across-sessions and re-export.
Both are corrected. The cost is deliberate and worth stating: changing
the width cap or adding the SVG renderer later now means re-cutting each
song by hand rather than regenerating every bundle from its project
file.

Export records the flag in bundle.write, so no caller can forget it.

Also removed --refit and the Auto-fit buttons, which were added without
being asked for and whose only purpose - migrating projects made before
the content rectangle was proposed - disappears once exported projects
start fresh. Reset now restores detection's proposal rather than the
whole page: clearing to full width would undo the thing the rectangle
exists for, so one button covers it.

open_project() replaces four copies of load-or-detect across the CLI
and the editor.
2026-07-28 23:49:03 +03:00

276 lines
9.9 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)
@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
# 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,
# 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,
"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,
exported=data.get("exported", 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()