Compare commits

..
2 Commits
Author SHA1 Message Date
Esa Kataja 15f64e4131 Record the spent-project reversal as ADR 0007
The project file existed to persist state, so a future reader finding
the exported flag would otherwise re-litigate it. The ADR states plainly
that this reverses an earlier decision, what the original reasoning was,
and what changed in use.

ADR 0002 deferred the SVG renderer partly because re-export made it free
to add later. That no longer holds, so its 'not stranded' bullet is
struck through and pointed at ADR 0007 rather than left standing to
mislead whoever revisits the SVG question.
2026-07-28 23:51:53 +03:00
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
12 changed files with 173 additions and 99 deletions
+6 -3
View File
@@ -51,9 +51,12 @@ two repos.
**Project**:
The persistent state of slicing one song: the source PDF it points at, its cuts,
discards, content rectangle, levels, staff-height overrides, markers and
metadata. Autosaved beside the PDF; the bundle is generated from it, so any
export can be regenerated without repeating human work. One PDF, one song, one
project, one bundle — never a many-to-one in any direction.
metadata. Autosaved beside the PDF; the bundle is generated from it. One PDF,
one song, one project, one bundle — never a many-to-one in any direction.
**Spent** once its song has been exported: opening the PDF again begins a fresh
session from detection rather than resuming, so a re-cut never inherits
decisions that have already shipped.
_Avoid_: session, document, edit list
**Bundle**:
+1
View File
@@ -59,3 +59,4 @@ Decisions that were expensive to reach, each with the evidence behind it:
| [ADR 0004](docs/adr/0004-detection-proposes-the-human-disposes.md) | No unattended mode: detection suggests, a human confirms. |
| [ADR 0005](docs/adr/0005-pymupdf-for-all-pdf-access.md) | PyMuPDF for all PDF access, accepting AGPL. |
| [ADR 0006](docs/adr/0006-systems-are-found-by-brackets-not-row-gaps.md) | Systems are found by vertical brackets; row-darkness gaps get it wrong. |
| [ADR 0007](docs/adr/0007-a-project-is-spent-once-exported.md) | A project is spent once exported; reopening starts fresh. Reverses an earlier decision. |
@@ -54,8 +54,11 @@ degraded fallback.
- The geometry model stays **renderer-agnostic**, in normalised page coordinates,
so adding the SVG renderer later is an output stage rather than a redesign.
- **Re-export from the project file** regenerates every song's bundle without
- ~~**Re-export from the project file** regenerates every song's bundle without
repeating human work, so songs cut before the SVG renderer exists are not
stranded.
stranded.~~ **No longer true** — see
[ADR 0007](0007-a-project-is-spent-once-exported.md). A project is spent once
its song has been exported, so songs cut before the SVG renderer ships stay
WebP unless they are cut again by hand.
- noteman needs no SVG support (`image/svg+xml`, `.svg` content type, CSP header
on SVG responses) until the renderer ships.
@@ -0,0 +1,45 @@
# A project is spent once its song has been exported
Exporting a song marks its project file spent. Opening the PDF again starts a
**fresh session from detection** — no cuts, no discards, no metadata carried
over — rather than resuming. `--resume` on `edit`, `export` and `project`
overrides it when the old state really is wanted.
This **reverses an earlier decision**, which is the reason it needs recording:
the project file was introduced specifically so that state would persist, and
`docs/spec.md` and `CONTEXT.md` promised resume-across-sessions and re-export
until this ADR was written.
## What was decided before, and why it changed
The project file was chosen over "bundle only" for three benefits: crash safety,
resume across sessions, and re-export. The third was the strongest argument —
change the width cap, fix one cut, or add the SVG renderer later, and every
song's bundle regenerates without repeating any human work. ADR 0002 leans on it
explicitly when deferring the SVG renderer: "re-export from the project file
regenerates every song's bundle without repeating human work, so songs cut
before the SVG renderer exists are not stranded."
In use, persistence was the wrong default. Re-opening an exported song silently
resurrected old decisions, so a deliberate re-cut began from stale state instead
of a clean page — and because autosave writes that state straight back, closing
the window did not clear it either. An export is a natural end of a unit of
work; carrying its decisions past that point makes "start over" impossible to
express.
Crash safety and resume within a session are untouched, and those are what the
day-to-day authoring loop actually depends on: a session interrupted halfway
through a 12-page scan still picks up where it stopped.
## Consequences
- **Re-export is no longer free.** Changing the 1920px cap, changing the encoder,
or adding the SVG renderer means re-cutting each song by hand. ADR 0002's
"not stranded" reasoning no longer holds; if the SVG renderer ships, already
exported songs stay WebP unless they are cut again.
- The flag is written in `bundle.write`, not in its callers, so no export path
can forget it.
- The project file is kept rather than deleted, so `--resume` remains possible
and the state is still there to inspect after the fact.
- A CLI export from a PDF with no project file now writes one, marked spent.
That is the record that this PDF has already been exported.
+13 -3
View File
@@ -263,9 +263,19 @@ discards, content rectangle, skew angles, levels, staff-height overrides, marker
and metadata. The bundle is *generated* from it, so export is a pure function of
the project file plus the PDF.
It buys crash safety, resume across sessions (authoring is trickle-in), and
**re-export** — change the 1920 cap, fix one cut, or add the SVG renderer later,
and every song's bundle regenerates without repeating any human work.
It buys crash safety and resume across sessions, since authoring is trickle-in:
a session interrupted halfway through a 12-page scan picks up exactly where it
stopped.
**A project is spent once its song has been exported.** Export records that in
the file, and opening the PDF again starts a *fresh session from detection*
rather than resuming. A re-cut therefore never inherits decisions that have
already shipped. `--resume` overrides it on the `edit`, `export` and `project`
commands when the old state really is wanted.
The cost is deliberate: re-export is no longer free. Changing the width cap or
adding the SVG renderer later means re-cutting each song by hand rather than
regenerating every bundle from its project file.
The project file references the PDF and never contains it; the hash lets the
editor warn if the PDF changed underneath.
+6
View File
@@ -62,4 +62,10 @@ def write(project: Project, source: Source, path: Path) -> Path:
zf.write(project.source, "original.pdf")
for name, data in zip(names, images):
zf.writestr(name, data, zipfile.ZIP_STORED)
# The project is spent once its song has been exported: the next edit
# session starts fresh from detection rather than resuming these decisions.
# Recorded here so no caller can forget it.
project.exported = True
project.save()
return path
+23 -38
View File
@@ -51,32 +51,18 @@ def _detect(args: argparse.Namespace) -> int:
def _project(args: argparse.Namespace) -> int:
from .project import Project, default_path
from .project import default_path, open_project
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")
project = open_project(source, resume=args.resume and not args.force)
if project.path is None:
print(f"{path.name}: fresh session from detection")
else:
print(f"{path.name}: resumed")
if project.source_changed():
print(" WARNING: the PDF has changed since these cuts were made")
if args.refit:
# Re-propose the content rectangle without disturbing cuts,
# discards or metadata — for projects made before detection
# proposed one, or whose rectangle was dragged wrong.
for i in range(len(project.pages)):
gray = page_raster(source, i)
project.pages[i].content_rect = detect_page(gray).content
print(f" content rectangle re-fitted on {len(project.pages)} pages")
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):
@@ -92,23 +78,14 @@ def _project(args: argparse.Namespace) -> int:
def _export(args: argparse.Namespace) -> int:
from . import bundle
from .project import Project, default_path
from .project import open_project
source = open_source(args.pdf, SourceType(args.type) if args.type else None)
path = default_path(source.path)
if path.exists():
project = Project.load(path)
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("no project file; exporting straight from detection")
project = open_project(source, resume=args.resume)
if project.path is None:
print("no unspent project state; exporting straight from detection")
elif project.source_changed():
print("WARNING: the PDF has changed since these cuts were made")
out = Path(args.out) if args.out else source.path.with_suffix(".zip")
bundle.write(project, source, out)
@@ -122,7 +99,9 @@ def _export(args: argparse.Namespace) -> int:
def _edit(args: argparse.Namespace) -> int:
from .editor import launch
return launch(Path(args.pdf), SourceType(args.type) if args.type else None)
return launch(
Path(args.pdf), SourceType(args.type) if args.type else None, resume=args.resume
)
def main(argv: list[str] | None = None) -> int:
@@ -154,9 +133,9 @@ def main(argv: list[str] | None = None) -> int:
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(
"--refit",
"--resume",
action="store_true",
help="re-propose the content rectangle, keeping cuts and metadata",
help="reopen an already-exported project instead of starting fresh",
)
proj.add_argument("--type", choices=[t.value for t in SourceType])
proj.set_defaults(func=_project)
@@ -164,11 +143,17 @@ def main(argv: list[str] | None = None) -> int:
exp = sub.add_parser("export", help="render the song and write a bundle")
exp.add_argument("pdf")
exp.add_argument("--out", help="output zip (default: alongside the PDF)")
exp.add_argument("--resume", action="store_true", help="use already-exported project state")
exp.add_argument("--type", choices=[t.value for t in SourceType])
exp.set_defaults(func=_export)
ed = sub.add_parser("edit", help="open the editor")
ed.add_argument("pdf")
ed.add_argument(
"--resume",
action="store_true",
help="reopen an already-exported project instead of starting fresh",
)
ed.add_argument("--type", choices=[t.value for t in SourceType])
ed.set_defaults(func=_edit)
+27 -48
View File
@@ -48,7 +48,7 @@ from . import bundle
from .bundle import METADATA_FIELDS
from .detect import deskew, detect_page
from .pdf import Source, open_source, page_raster
from .project import Cut, Project, default_path
from .project import Cut, Project, open_project
from .render import apply_levels
PREVIEW_MAX = 1800 # display resolution; geometry stays normalised
@@ -356,17 +356,10 @@ class Editor(QMainWindow):
discard = QPushButton("Toggle discard (D)")
discard.clicked.connect(self.view.toggle_discard)
form.addRow(discard)
rect_row = QHBoxLayout()
fit = QPushButton("Auto-fit")
fit.setToolTip("Propose the content rectangle from the staff lines on this page")
fit.clicked.connect(lambda: self._autofit_rect(False))
fit_all = QPushButton("Auto-fit all")
fit_all.clicked.connect(lambda: self._autofit_rect(True))
reset = QPushButton("Reset")
reset = QPushButton("Reset content rectangle")
reset.setToolTip("Back to the rectangle detection proposed for this page")
reset.clicked.connect(self._reset_rect)
for button in (fit, fit_all, reset):
rect_row.addWidget(button)
form.addRow("Content rect", rect_row)
form.addRow(reset)
box.addWidget(page_box)
meta_box = QGroupBox("Song")
@@ -482,25 +475,16 @@ class Editor(QMainWindow):
}
self.autosave.start()
def _autofit_rect(self, every_page: bool) -> None:
"""Re-propose the content rectangle from the staff lines.
Detection does this for new projects; this is how a project made before
it existed, or one whose rectangle was dragged wrong, gets it back.
"""
pages = range(len(self.project.pages)) if every_page else [self.index]
for i in pages:
# The preview is already deskewed, so the sweep is skipped.
proposal = detect_page(self._preview(i), skew=0.0)
self.project.pages[i].content_rect = proposal.content
self.view.redraw()
self._touched()
self.statusBar().showMessage(
f"content rectangle fitted on {len(list(pages))} page(s)", 2000
)
def _reset_rect(self) -> None:
self.project.pages[self.index].content_rect = None
"""Back to what detection proposed for this page.
Not to the whole page: the proposal is what excludes the scan-edge
junk, so clearing to full width would undo the thing the rectangle
exists for. The preview is already deskewed, so the sweep is skipped.
"""
self.project.pages[self.index].content_rect = detect_page(
self._preview(self.index), skew=0.0
).content
self.view.redraw()
self._touched()
@@ -524,7 +508,9 @@ class Editor(QMainWindow):
QMessageBox.information(
self,
"Exported",
f"{out.name}\n{len(self.project.kept_slices())} slices, {size:.0f} KB",
f"{out.name}\n{len(self.project.kept_slices())} slices, {size:.0f} KB\n\n"
"This project is now spent — opening the PDF again starts a fresh "
"session from detection.",
)
def closeEvent(self, event) -> None:
@@ -532,27 +518,20 @@ class Editor(QMainWindow):
super().closeEvent(event)
def launch(pdf: Path, source_type=None) -> int:
def launch(pdf: Path, source_type=None, resume: bool = False) -> int:
app = QApplication(sys.argv[:1])
source = open_source(pdf, source_type)
path = default_path(source.path)
if path.exists():
project = Project.load(path)
if project.source_changed():
QMessageBox.warning(
None,
"Source changed",
"The PDF has changed since these cuts were made.\n"
"Cuts may no longer line up with the music.",
)
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)
# An exported project is spent: this opens a fresh session from detection
# rather than resuming decisions that have already been shipped.
project = open_project(source, resume=resume)
if project.path is not None and project.source_changed():
QMessageBox.warning(
None,
"Source changed",
"The PDF has changed since these cuts were made.\n"
"Cuts may no longer line up with the music.",
)
window = Editor(source, project)
window.resize(1500, 950)
+31
View File
@@ -113,6 +113,11 @@ class Project:
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 -------------------------------------------------
@@ -184,6 +189,7 @@ class Project:
"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,
@@ -229,6 +235,7 @@ class Project:
levels=tuple(data["levels"]),
metadata=data.get("metadata", {}),
path=path,
exported=data.get("exported", False),
)
def source_changed(self) -> bool:
@@ -236,6 +243,30 @@ class Project:
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)
+6 -2
View File
@@ -85,11 +85,15 @@ def main() -> int:
editor.metadata["composer"].setText("trad.")
assert project.metadata["title"] == "Ketun joululaulu"
# Content rectangle edits and reset.
# Content rectangle edits, and reset going back to detection's proposal for
# the page as it now stands — not to the whole page, which would undo the
# thing the rectangle exists for.
expected = detect_page(editor._preview(0), skew=0.0).content
page.content_rect = (0.05, 0.02, 0.95, 0.98)
assert project.page_content_rect(0) == (0.05, 0.02, 0.95, 0.98)
editor._reset_rect()
assert project.page_content_rect(0) == project.content_rect
assert project.page_content_rect(0) == expected, (project.page_content_rect(0), expected)
assert project.page_content_rect(0) != (0.0, 0.0, 1.0, 1.0)
# Autosave target, then a round-trip through disk.
editor._save()
+6
View File
@@ -67,6 +67,12 @@ def main() -> int:
assert reloaded.source_hash == project.source_hash
assert not reloaded.source_changed()
# A project is spent once exported: reopening starts fresh.
assert not reloaded.exported
reloaded.exported = True
reloaded.save()
assert Project.load(saved).exported
# 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()
+4 -3
View File
@@ -16,7 +16,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from noteman_slicer import bundle # noqa: E402
from noteman_slicer.detect import detect_page # noqa: E402
from noteman_slicer.pdf import open_source, page_raster # noqa: E402
from noteman_slicer.project import Cut, Project # noqa: E402
from noteman_slicer.project import Cut, Project, default_path # noqa: E402
from noteman_slicer.render import ( # noqa: E402
ALPHA_LEVELS,
apply_levels,
@@ -151,8 +151,9 @@ def main() -> int:
assert all(f in names for f in files)
source.close()
for f in (pdf, out):
f.unlink()
# Exporting marks the project spent, which writes the project file.
for f in (pdf, out, default_path(pdf)):
f.unlink(missing_ok=True)
tmp.rmdir()
print("ok")
return 0