diff --git a/CONTEXT.md b/CONTEXT.md index c4ce60e..8206fc0 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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**: diff --git a/docs/spec.md b/docs/spec.md index 2da54c3..eae591f 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -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. diff --git a/noteman_slicer/bundle.py b/noteman_slicer/bundle.py index 85ed6da..fd20223 100644 --- a/noteman_slicer/bundle.py +++ b/noteman_slicer/bundle.py @@ -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 diff --git a/noteman_slicer/cli.py b/noteman_slicer/cli.py index 9b9a089..ee1ad57 100644 --- a/noteman_slicer/cli.py +++ b/noteman_slicer/cli.py @@ -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) diff --git a/noteman_slicer/editor.py b/noteman_slicer/editor.py index 81e3888..7fa3881 100644 --- a/noteman_slicer/editor.py +++ b/noteman_slicer/editor.py @@ -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) diff --git a/noteman_slicer/project.py b/noteman_slicer/project.py index 1884324..7acbc6c 100644 --- a/noteman_slicer/project.py +++ b/noteman_slicer/project.py @@ -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) diff --git a/tests/test_editor.py b/tests/test_editor.py index ead83ec..d0bd49d 100644 --- a/tests/test_editor.py +++ b/tests/test_editor.py @@ -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() diff --git a/tests/test_project.py b/tests/test_project.py index 9c93497..068c53d 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -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() diff --git a/tests/test_render.py b/tests/test_render.py index 639bec3..a6130ad 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -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