Add project state with cuts, discards and atomic save
Everything the human decides, in normalised page coordinates so the file is independent of DPI and of which renderer produced it. The bundle will be generated from this, which is what makes re-export possible without repeating human work. Cuts are polylines from the start, two points being the ordinary straight case, so the stepped cuts Engel needs are a data question rather than a migration. Adding a cut splits a slice and copies its discard flag to both halves; removing one merges them. Boundary cuts belong here rather than in detection: detection emits cuts only between systems, so a page would have exactly as many slices as systems, with the header and footer inside the first and last. Isolating and discarding them is a slicing decision. Saves are write-then-rename, so a crash mid-save cannot destroy the previous state. The PDF is hashed, not copied, so an edit underneath is reported rather than silently re-cut. Ketun joululaulu now yields 24 kept slices over 12 pages and Feliz Navidad 20 over 4, with headers and footers discarded on every page. Closes #10, #11, #13
This commit is contained in:
@@ -50,6 +50,38 @@ def _detect(args: argparse.Namespace) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _project(args: argparse.Namespace) -> int:
|
||||
from .project import Project, default_path
|
||||
|
||||
source = open_source(args.pdf, SourceType(args.type) if args.type else None)
|
||||
path = default_path(source.path)
|
||||
|
||||
if path.exists() and not args.force:
|
||||
project = Project.load(path)
|
||||
print(f"{path.name}: loaded")
|
||||
if project.source_changed():
|
||||
print(" WARNING: the PDF has changed since these cuts were made")
|
||||
else:
|
||||
detections, heights = [], []
|
||||
for i in range(len(source)):
|
||||
gray = page_raster(source, i)
|
||||
detections.append(detect_page(gray))
|
||||
heights.append(gray.shape[0])
|
||||
project = Project.from_detection(source.path, detections, heights)
|
||||
print(f"{path.name}: created from detection")
|
||||
|
||||
kept = project.kept_slices()
|
||||
for i, page in enumerate(project.pages):
|
||||
flags = "".join("." if d else "#" for d in page.discards)
|
||||
print(f" p{i + 1:<3} skew {page.skew:+.2f}° {page.slice_count} slices [{flags}]")
|
||||
print(f" {len(kept)} slices kept, {sum(p.slice_count for p in project.pages) - len(kept)} discarded")
|
||||
|
||||
if args.save:
|
||||
print(f" saved to {project.save(path)}")
|
||||
source.close()
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="noteman-slicer",
|
||||
@@ -74,6 +106,13 @@ def main(argv: list[str] | None = None) -> int:
|
||||
det.add_argument("--type", choices=[t.value for t in SourceType])
|
||||
det.set_defaults(func=_detect)
|
||||
|
||||
proj = sub.add_parser("project", help="create or inspect the project file for a PDF")
|
||||
proj.add_argument("pdf")
|
||||
proj.add_argument("--save", action="store_true", help="write the project file")
|
||||
proj.add_argument("--force", action="store_true", help="re-detect, discarding existing state")
|
||||
proj.add_argument("--type", choices=[t.value for t in SourceType])
|
||||
proj.set_defaults(func=_project)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
return args.func(args)
|
||||
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user