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:
Esa Kataja
2026-07-28 22:49:51 +03:00
parent 61dac44037
commit 9ed38323ef
3 changed files with 362 additions and 0 deletions
+82
View File
@@ -0,0 +1,82 @@
"""Runnable check for project state: round-trip, cut edits, discard pre-set."""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from noteman_slicer.detect import PageDetection, System # noqa: E402
from noteman_slicer.project import Cut, Project, default_path # noqa: E402
def main() -> int:
tmp = Path(__file__).with_name("_tmp")
tmp.mkdir(exist_ok=True)
pdf = tmp / "song.pdf"
pdf.write_bytes(b"%PDF-1.7 not really a pdf, only its bytes are hashed")
height = 1000
detection = PageDetection(
skew=-1.1,
systems=[System(200, 400, 60.0), System(600, 800, 60.0)],
cuts=[500],
)
project = Project.from_detection(pdf, [detection], [height])
page = project.pages[0]
# One cut between the systems, plus a boundary cut above the first and
# below the last — so the header and footer become their own slices.
assert len(page.cuts) == 3, [c.points for c in page.cuts]
assert page.discards == [True, False, False, True], page.discards
assert page.slice_count == 4
assert project.kept_slices() == [(0, 1), (0, 2)], project.kept_slices()
# Geometry is normalised, so it survives any change of resolution.
assert all(0.0 <= y <= 1.0 for cut in page.cuts for _, y in cut.points)
assert page.cuts[1].straight_y == 0.5
# A straight cut is flat; a stepped one is not, and interpolates.
step = Cut([(0.0, 0.20), (0.35, 0.20), (0.35, 0.40), (1.0, 0.40)])
assert step.straight_y is None
assert step.y_at(0.0) == 0.20
assert step.y_at(1.0) == 0.40
assert step.y_at(0.35) == 0.20 or step.y_at(0.35) == 0.40
assert step.highest == 0.20 and step.lowest == 0.40
# Adding a cut splits a slice and keeps that slice's flag on both halves.
before = page.slice_count
index = page.add_cut(Cut.straight(0.65))
assert page.slice_count == before + 1
assert index == 2, index
assert page.discards == [True, False, False, False, True], page.discards
# Removing it merges them again.
page.remove_cut(index)
assert page.slice_count == before
assert page.discards == [True, False, False, True], page.discards
# Round-trip.
saved = project.save()
assert saved == default_path(pdf), saved
reloaded = Project.load(saved)
assert reloaded.pages[0].skew == -1.1
assert reloaded.pages[0].discards == page.discards
assert [c.points for c in reloaded.pages[0].cuts] == [c.points for c in page.cuts]
assert reloaded.source_hash == project.source_hash
assert not reloaded.source_changed()
# A PDF edited underneath must be reported, not silently re-cut.
pdf.write_bytes(b"%PDF-1.7 different bytes entirely")
assert reloaded.source_changed()
for f in (pdf, saved):
f.unlink()
tmp.rmdir()
print("ok")
return 0
if __name__ == "__main__":
sys.exit(main())