From c00a00bb2a9264f71ad682b2acba4c3f5f6c3a2d Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Tue, 28 Jul 2026 23:28:45 +0300 Subject: [PATCH 1/4] Propose the content rectangle from the staff lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both scan problems reported from testing came from the same place: the content rectangle defaulted to the whole page, so the mechanism meant to handle margin junk never engaged. Ketun joululaulu has a vertical scan streak down the right margin and Engel a shadow, and because trim is tight and per slice, either one sets that slice's width, which sets the song's widest slice, which scales the whole song down. Detection can propose it. Staff lines are long horizontal runs; scan shadows, spine darkening and glass streaks are vertical, so a wide flat opening keeps one and erases the other. Three corrections were needed against the corpus: - Search only rows inside detected systems. A horizontal artefact above or below the music is itself a long horizontal run reaching the paper edge, which put Engel's left bound at 0. - Take a percentile of the staff-line extents, not the maximum. Where an artefact touches a staff line the two merge into one component: on Ketun p2 the merged line ends at 1575px against 1544px on the clean page. - Take the left bound from the brackets too. A bracket sits left of every staff line, so a staff-line bound crops it off — visible immediately when comparing exported slices. Anchors are now a dataclass carrying their left edge rather than a (top, bottom) tuple. Engel now drops 12-14% of page width and its music fills 1920px instead of leaving the shadow's dead space; Ketun drops 12%. Existing projects keep their saved rectangle; the editor's new Auto-fit and Auto-fit all buttons re-propose it without disturbing cuts. --- docs/spec.md | 21 +++++++ noteman_slicer/detect.py | 120 ++++++++++++++++++++++++++++++++------ noteman_slicer/editor.py | 29 ++++++++- noteman_slicer/project.py | 3 + 4 files changed, 152 insertions(+), 21 deletions(-) diff --git a/docs/spec.md b/docs/spec.md index d074de6..2da54c3 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -194,6 +194,27 @@ fall back to row-profile runs, which is correct there. **Staff height** — peak-to-peak spacing in the row profile. +**Content rectangle** — proposed per page from the staff lines. Staff lines are +long *horizontal* runs, while a scan-edge shadow, a spine darkening and the +streak a dirty scanner glass leaves are all *vertical*, so opening with a wide +flat kernel keeps the music and erases the artefacts. Three details make it +work: + +- Only rows inside detected systems are searched. Otherwise a horizontal scan + artefact above or below the music is itself a long horizontal run, and it + reaches the paper edge. +- The horizontal bounds come from a *percentile* of the staff-line extents, not + their maximum. Where an artefact touches the end of a staff line the two merge + into one component; a page has dozens of staff lines and only a few are + contaminated. +- The left bound also considers the **brackets**, which sit left of every staff + line. A bound taken from staff lines alone crops the bracket off, and a + bracket is notation. + +Only the horizontal bounds are proposed. Vertically the cuts and discard flags +already isolate the header and footer, and cropping the top would risk clipping +a tempo mark or a section label above the first staff. + **Source type** — `get_images(full=True)` / `get_drawings()` proposes bitmap or vector per PDF; the tool asks the user to confirm before routing. (`full=True` is required, or `get_image_bbox` rejects the item.) diff --git a/noteman_slicer/detect.py b/noteman_slicer/detect.py index 23d1102..44bbcf9 100644 --- a/noteman_slicer/detect.py +++ b/noteman_slicer/detect.py @@ -28,6 +28,19 @@ _ANCHOR_KERNEL = 0.03 # vertical open kernel, as a fraction of page height _ANCHOR_MIN = 0.04 # a bracket is at least this tall, as a fraction of page _PROFILE_FLOOR = 0.02 # ink-run threshold, as a fraction of the profile peak _EXPAND_REACH = 1.5 # how far past the bracket a system's ink reaches, in staff heights +_STAFF_KERNEL = 0.05 # horizontal open kernel, as a fraction of page width +_STAFF_MIN_WIDTH = 0.2 # a staff line spans at least this share of the page +_CONTENT_MARGIN = 0.01 # slack past the staff ends, for ledger lines and lyrics +_EDGE_PERCENTILE = 15 # tolerate this share of staff lines merged into scan artefacts + + +@dataclass +class Anchor: + """A system's vertical bracket: where it is, and how far left it reaches.""" + + top: int + bottom: int + left: int @dataclass @@ -48,6 +61,7 @@ class PageDetection: skew: float systems: list[System] = field(default_factory=list) cuts: list[int] = field(default_factory=list) + content: tuple[float, float, float, float] = (0.0, 0.0, 1.0, 1.0) @property def bracketless(self) -> bool: @@ -89,8 +103,8 @@ def deskew(gray: np.ndarray, angle: float) -> np.ndarray: return _rotate(gray, angle) -def system_anchors(gray: np.ndarray) -> list[tuple[int, int]]: - """y-extents of the vertical brackets, one per system.""" +def system_anchors(gray: np.ndarray) -> list[Anchor]: + """The vertical brackets, one per system.""" h = gray.shape[0] binary = (gray < _INK).astype(np.uint8) kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (1, max(3, int(h * _ANCHOR_KERNEL)))) @@ -98,19 +112,82 @@ def system_anchors(gray: np.ndarray) -> list[tuple[int, int]]: count, _, stats, _ = cv2.connectedComponentsWithStats(strokes, 8) tall = [ - (stats[i, cv2.CC_STAT_TOP], stats[i, cv2.CC_STAT_TOP] + stats[i, cv2.CC_STAT_HEIGHT]) + Anchor( + int(stats[i, cv2.CC_STAT_TOP]), + int(stats[i, cv2.CC_STAT_TOP] + stats[i, cv2.CC_STAT_HEIGHT]), + int(stats[i, cv2.CC_STAT_LEFT]), + ) for i in range(1, count) if stats[i, cv2.CC_STAT_HEIGHT] > h * _ANCHOR_MIN ] # Tallest first, keeping only strokes that don't overlap one already kept: # a system's barlines all overlap its bracket, so each system yields one. - anchors: list[tuple[int, int]] = [] - for top, bottom in sorted(tall, key=lambda s: s[1] - s[0], reverse=True): - if any(not (bottom < a[0] or top > a[1]) for a in anchors): + # The kept stroke is the tallest, which is the bracket rather than a barline. + anchors: list[Anchor] = [] + for candidate in sorted(tall, key=lambda a: a.bottom - a.top, reverse=True): + if any(not (candidate.bottom < a.top or candidate.top > a.bottom) for a in anchors): continue - anchors.append((top, bottom)) - return sorted(anchors) + anchors.append(candidate) + return sorted(anchors, key=lambda a: a.top) + + +def content_columns( + gray: np.ndarray, anchors: list[Anchor] | None = None +) -> tuple[float, float]: + """Where the music is horizontally, as normalised x bounds. + + Staff lines are long *horizontal* runs; a scan-edge shadow, a spine + darkening and the vertical line a dirty scanner glass leaves are all + *vertical*. Opening with a wide flat kernel keeps the first and erases the + others, so the staff lines' own bounding box is the music area. + + `anchors` does two jobs. It restricts the search to rows known to hold + systems — without that, a horizontal scan artefact above or below the music + is itself a long horizontal run reaching the paper edge, which is exactly + the measurement being avoided. And its brackets give the true left bound: + a bracket sits *left of every staff line*, so a bound taken from staff + lines alone crops it off, and a bracket is notation, not artefact. + + This matters more than it looks: trim is tight and per slice, so one dark + band down the margin sets that slice's width, which sets the song's widest + slice, which scales the whole song down. + """ + height, width = gray.shape + binary = (gray < _INK).astype(np.uint8) + if anchors: + keep = np.zeros(height, bool) + for anchor in anchors: + keep[max(0, anchor.top) : min(height, anchor.bottom)] = True + binary[~keep] = 0 + kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (max(3, int(width * _STAFF_KERNEL)), 1)) + lines = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel) + + count, _, stats, _ = cv2.connectedComponentsWithStats(lines, 8) + runs = [ + (stats[i, cv2.CC_STAT_LEFT], stats[i, cv2.CC_STAT_LEFT] + stats[i, cv2.CC_STAT_WIDTH]) + for i in range(1, count) + if stats[i, cv2.CC_STAT_WIDTH] > width * _STAFF_MIN_WIDTH + ] + if not runs: + return 0.0, 1.0 + + # Percentiles, not the extremes. Where a scan-edge band happens to touch + # the end of a staff line the two merge into one component, and that + # component then reaches into the artefact — on Ketun joululaulu p2 the + # merged line ends at 1575px against 1544px on the clean page. A page has + # dozens of staff lines and only a few are contaminated, so a percentile + # lands on the true edge while the extreme lands on the worst artefact. + lefts = np.array([r[0] for r in runs], float) + rights = np.array([r[1] for r in runs], float) + margin = width * _CONTENT_MARGIN + + left = float(np.percentile(lefts, _EDGE_PERCENTILE)) + if anchors: + left = min(left, min(a.left for a in anchors)) + right = float(np.percentile(rights, 100 - _EDGE_PERCENTILE)) + + return max(0.0, left - margin) / width, min(float(width), right + margin) / width def ink_runs(gray: np.ndarray) -> list[tuple[int, int]]: @@ -161,18 +238,17 @@ def staff_height(gray: np.ndarray, top: int, bottom: int) -> float | None: return float(np.median(intra) * 4) # 5 lines, 4 spaces -def _gap(run: tuple[int, int], span: tuple[int, int]) -> int: - """Vertical distance between an ink run and a bracket span; 0 if they overlap.""" +def _gap(run: tuple[int, int], anchor: Anchor) -> int: + """Vertical distance between an ink run and a bracket; 0 if they overlap.""" start, end = run - top, bottom = span - if end > top and start < bottom: + if end > anchor.top and start < anchor.bottom: return 0 - return top - end if end <= top else start - bottom + return anchor.top - end if end <= anchor.top else start - anchor.bottom def _assign( runs: list[tuple[int, int]], - anchors: list[tuple[int, int]], + anchors: list[Anchor], reaches: list[float], ) -> list[tuple[int, int]]: """Give every ink run to one system, and return each system's extent. @@ -196,7 +272,7 @@ def _assign( to the system below and the cut lands high. Dragging the cut is the fix; separating them needs a signal this pass doesn't have. """ - bounds = [list(a) for a in anchors] + bounds = [[a.top, a.bottom] for a in anchors] def claim(index: int, run: tuple[int, int]) -> None: bounds[index][0] = min(bounds[index][0], run[0]) @@ -208,7 +284,7 @@ def _assign( # Ink overlapping a bracket belongs to it — to the one it overlaps most, # whatever else is in reach. inside = [ - (min(run[1], anchors[i][1]) - max(run[0], anchors[i][0]), i) + (min(run[1], anchors[i].bottom) - max(run[0], anchors[i].top), i) for i, g in enumerate(gaps) if g == 0 ] @@ -221,7 +297,7 @@ def _assign( continue # a title block or a footer: too far from any system # Otherwise the system above wins, and only failing that the one below. - above = [i for i in within if anchors[i][1] <= run[0]] + above = [i for i in within if anchors[i].bottom <= run[0]] claim(above[-1] if above else within[0], run) return [(lo, hi) for lo, hi in bounds] @@ -238,7 +314,7 @@ def detect_page(gray: np.ndarray, skew: float | None = None) -> PageDetection: if anchors: # Staff height is measured on the bracket span, before expansion, so a # swallowed title block can't distort it. - heights = [staff_height(straight, top, bottom) for top, bottom in anchors] + heights = [staff_height(straight, a.top, a.bottom) for a in anchors] reaches = [(h or gray.shape[0] * 0.02) * _EXPAND_REACH for h in heights] systems = [ System(top=lo, bottom=hi, staff_height=h) @@ -252,4 +328,10 @@ def detect_page(gray: np.ndarray, skew: float | None = None) -> PageDetection: cuts = [ (systems[i].bottom + systems[i + 1].top) // 2 for i in range(len(systems) - 1) ] - return PageDetection(skew=angle, systems=systems, cuts=cuts) + # Only the horizontal bounds are proposed. Vertically the cuts and the + # discard flags already isolate the header and footer, and cropping the top + # would risk clipping a tempo mark or a section label above the first staff. + left, right = content_columns(straight, anchors) + return PageDetection( + skew=angle, systems=systems, cuts=cuts, content=(left, 0.0, right, 1.0) + ) diff --git a/noteman_slicer/editor.py b/noteman_slicer/editor.py index 39dc129..81e3888 100644 --- a/noteman_slicer/editor.py +++ b/noteman_slicer/editor.py @@ -356,9 +356,17 @@ class Editor(QMainWindow): discard = QPushButton("Toggle discard (D)") discard.clicked.connect(self.view.toggle_discard) form.addRow(discard) - reset = QPushButton("Reset content rectangle") + 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.clicked.connect(self._reset_rect) - form.addRow(reset) + for button in (fit, fit_all, reset): + rect_row.addWidget(button) + form.addRow("Content rect", rect_row) box.addWidget(page_box) meta_box = QGroupBox("Song") @@ -474,6 +482,23 @@ 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 self.view.redraw() diff --git a/noteman_slicer/project.py b/noteman_slicer/project.py index 3375cca..1884324 100644 --- a/noteman_slicer/project.py +++ b/noteman_slicer/project.py @@ -170,6 +170,9 @@ class Project: 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) From 54b8e37657229b068fc431fedc7b4d1ad635eb9b Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Tue, 28 Jul 2026 23:35:10 +0300 Subject: [PATCH 2/4] Add project --refit to re-propose the content rectangle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saved state always wins over a fresh proposal, which is what the project file is for — but it also means a project made before detection proposed a content rectangle keeps the old whole-page one forever, and reopening or re-exporting changes nothing. --refit re-runs the proposal over every page while leaving cuts, discards, stepped cuts and metadata untouched. Verified on the real Engel project: rectangles updated on all 6 pages, all 4 cuts per page kept including both stepped ones, metadata intact, and exported slices scale larger now that the margin shadow no longer pads the width. --force remains the destructive option that re-detects everything. --- noteman_slicer/cli.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/noteman_slicer/cli.py b/noteman_slicer/cli.py index 6f565e6..9b9a089 100644 --- a/noteman_slicer/cli.py +++ b/noteman_slicer/cli.py @@ -61,6 +61,14 @@ def _project(args: argparse.Namespace) -> int: print(f"{path.name}: loaded") 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)): @@ -145,6 +153,11 @@ def main(argv: list[str] | None = None) -> int: 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( + "--refit", + action="store_true", + help="re-propose the content rectangle, keeping cuts and metadata", + ) proj.add_argument("--type", choices=[t.value for t in SourceType]) proj.set_defaults(func=_project) From b6847a06ee8861bb16e4061aa6231268ee3cda5b Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Tue, 28 Jul 2026 23:49:03 +0300 Subject: [PATCH 3/4] 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. --- CONTEXT.md | 9 +++-- docs/spec.md | 16 +++++++-- noteman_slicer/bundle.py | 6 ++++ noteman_slicer/cli.py | 61 ++++++++++++------------------- noteman_slicer/editor.py | 75 ++++++++++++++------------------------- noteman_slicer/project.py | 31 ++++++++++++++++ tests/test_editor.py | 8 +++-- tests/test_project.py | 6 ++++ tests/test_render.py | 7 ++-- 9 files changed, 122 insertions(+), 97 deletions(-) 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 From 15f64e41319543e1d8db503f8acd1bfac70997c8 Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Tue, 28 Jul 2026 23:51:53 +0300 Subject: [PATCH 4/4] 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. --- README.md | 1 + .../0002-raster-only-svg-renderer-deferred.md | 7 ++- .../0007-a-project-is-spent-once-exported.md | 45 +++++++++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 docs/adr/0007-a-project-is-spent-once-exported.md diff --git a/README.md b/README.md index ab9936b..44e9eab 100644 --- a/README.md +++ b/README.md @@ -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. | diff --git a/docs/adr/0002-raster-only-svg-renderer-deferred.md b/docs/adr/0002-raster-only-svg-renderer-deferred.md index 1400311..e46f772 100644 --- a/docs/adr/0002-raster-only-svg-renderer-deferred.md +++ b/docs/adr/0002-raster-only-svg-renderer-deferred.md @@ -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. diff --git a/docs/adr/0007-a-project-is-spent-once-exported.md b/docs/adr/0007-a-project-is-spent-once-exported.md new file mode 100644 index 0000000..7af3641 --- /dev/null +++ b/docs/adr/0007-a-project-is-spent-once-exported.md @@ -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.