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/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. diff --git a/docs/spec.md b/docs/spec.md index d074de6..eae591f 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.) @@ -242,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 6f565e6..ee1ad57 100644 --- a/noteman_slicer/cli.py +++ b/noteman_slicer/cli.py @@ -51,24 +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") - 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): @@ -84,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) @@ -114,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: @@ -145,17 +132,28 @@ 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( + "--resume", + action="store_true", + 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) 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/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..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 @@ -357,6 +357,7 @@ class Editor(QMainWindow): discard.clicked.connect(self.view.toggle_discard) form.addRow(discard) reset = QPushButton("Reset content rectangle") + reset.setToolTip("Back to the rectangle detection proposed for this page") reset.clicked.connect(self._reset_rect) form.addRow(reset) box.addWidget(page_box) @@ -475,7 +476,15 @@ class Editor(QMainWindow): self.autosave.start() 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() @@ -499,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: @@ -507,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 3375cca..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 ------------------------------------------------- @@ -170,6 +175,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) @@ -181,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, @@ -226,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: @@ -233,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