From 2a22fc469f25fe5a62098c54a47c68ba1b80eccf Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Wed, 29 Jul 2026 12:08:45 +0300 Subject: [PATCH 1/4] Honour a page's /Rotate when extracting a scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A scan fed sideways stores its image landscape and sets /Rotate 90 so a viewer turns it upright. Extracting the image by xref — which is how a raster source is read, to keep the scan's native resolution — bypasses that, so every system ran down the page and detection found nothing. Apply the page rotation to the extracted raster. Quarter turns only; nothing produces anything else. --- noteman_slicer/pdf.py | 17 ++++++++++++++++- tests/test_pdf.py | 30 ++++++++++++++++++++++++++---- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/noteman_slicer/pdf.py b/noteman_slicer/pdf.py index 6ec6263..14a7d0e 100644 --- a/noteman_slicer/pdf.py +++ b/noteman_slicer/pdf.py @@ -90,13 +90,28 @@ def page_raster(source: Source, index: int) -> np.ndarray: # Pixmap(doc, xref) rather than decoding extract_image() bytes: # MuPDF handles JBIG2 and CCITT, which no image library will. pix = pymupdf.Pixmap(source.doc, xref) - return _to_gray(pix) + # The embedded image is in its own orientation, not the page's: a + # scanner that fed the sheet sideways stores it landscape and the + # PDF sets /Rotate so viewers turn it upright. Extracting by xref + # bypasses that, so apply it here — otherwise every system runs + # down the page and detection finds nothing. + return _rotate(_to_gray(pix), page.rotation) # A scanned PDF whose page has no embedded image (a blank, or a # cover typeset in vector). Rendering is the only option left. return _to_gray(page.get_pixmap(dpi=source.render_dpi, colorspace=pymupdf.csGRAY)) +def _rotate(gray: np.ndarray, degrees: int) -> np.ndarray: + """Turn a page raster clockwise by a multiple of 90°, as /Rotate means it. + + ponytail: quarter turns only. A page rotated by anything else would need + resampling, and no scanner produces one. + """ + turns = round(degrees / 90) % 4 + return np.ascontiguousarray(np.rot90(gray, -turns)) if turns else gray + + def _to_gray(pix: pymupdf.Pixmap) -> np.ndarray: if pix.alpha or pix.colorspace is None or pix.colorspace.n != 1: pix = pymupdf.Pixmap(pymupdf.csGRAY, pix) diff --git a/tests/test_pdf.py b/tests/test_pdf.py index 0711c9e..e945422 100644 --- a/tests/test_pdf.py +++ b/tests/test_pdf.py @@ -28,15 +28,21 @@ def _vector_pdf(path: Path, pages: int = 2) -> None: doc.save(path) -def _scan_pdf(path: Path, pages: int = 2, w: int = 1653, h: int = 2332) -> None: - """Each page is one full-page grayscale image — what a real scan looks like.""" +def _scan_pdf(path: Path, pages: int = 2, w: int = 1653, h: int = 2332, rotation: int = 0) -> None: + """Each page is one full-page grayscale image — what a real scan looks like. + + `rotation` reproduces a sheet fed sideways: the image is stored in its own + orientation and /Rotate turns it upright for a viewer. + """ art = np.full((h, w), 255, np.uint8) art[500:505, 100 : w - 100] = 0 # a staff line, so it isn't uniform + art[:60, :60] = 0 # a corner mark, so orientation is checkable pix = pymupdf.Pixmap(pymupdf.csGRAY, w, h, bytearray(art.tobytes()), False) doc = pymupdf.open() for _ in range(pages): - page = doc.new_page(width=A4.width, height=A4.height) + page = doc.new_page(width=A4.width, height=A4.width * h / w) page.insert_image(page.rect, pixmap=pix) + page.set_rotation(rotation) doc.save(path) @@ -64,6 +70,22 @@ def main() -> int: assert page.min() == 0 and page.max() == 255, (page.min(), page.max()) src.close() + # The corner mark sits top-left in an upright scan. + assert page[:60, :60].max() == 0 and page[:60, -60:].min() == 255 + + # A sideways scan comes back upright: the page's /Rotate applies to the + # image extracted by xref, which bypasses it. Okular gets this right and + # the slicer used to not. + sideways = tmp / "sideways.pdf" + _scan_pdf(sideways, pages=1, w=2332, h=1653, rotation=90) + src = open_source(sideways) + assert src.type is SourceType.RASTER + turned = page_raster(src, 0) + assert turned.shape == (2332, 1653), turned.shape + # Turned clockwise, so the mark that was top-left is now top-right. + assert turned[:60, -60:].max() == 0 and turned[:60, :60].min() == 255 + src.close() + # An override must win over detection, and say so. src = open_source(scan, SourceType.VECTOR) assert src.type is SourceType.VECTOR and src.detected is SourceType.RASTER @@ -71,7 +93,7 @@ def main() -> int: assert page_raster(src, 0).shape[1] > 4000, "override must force a render" src.close() - for f in (vec, scan): + for f in (vec, scan, sideways): f.unlink() tmp.rmdir() print("ok") From 38cb6ce09a4285d2ba3661a9e7a3c13bb69d86f6 Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Wed, 29 Jul 2026 12:22:08 +0300 Subject: [PATCH 2/4] Stop an edge artefact and a speck filter from destroying a song MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Olukainen juomukainen came out unusable, from two separate faults. The scanner left a dark line down the sheet edge, running the full height of every page but the first. Being taller than any bracket it won every overlap in anchor selection and swallowed the page into one system, so five pages of six proposed no cuts at all. A page's brackets and barlines are all about one system tall, so a stroke far taller than the typical one is not notation — relative to the page's own strokes, since a page holding one big system is legitimate. The trim then removed each system's bottom line of lyrics. It judged ink blobs by area, and a letter is nowhere near the threshold; a whole line of them is dozens of blobs, none of which qualifies. Measure ink per row and per column instead — a line of text carries plenty in total, and a fleck's row carries almost none, which is the case the filter was for. Detection now finds three systems on every page of that score, and every slice keeps all four voices' words. --- noteman_slicer/detect.py | 12 ++++++++++++ noteman_slicer/render.py | 37 +++++++++++++++++++------------------ tests/test_detect.py | 8 ++++++++ tests/test_render.py | 14 ++++++++++++++ 4 files changed, 53 insertions(+), 18 deletions(-) diff --git a/noteman_slicer/detect.py b/noteman_slicer/detect.py index 7e389e1..01f9ccb 100644 --- a/noteman_slicer/detect.py +++ b/noteman_slicer/detect.py @@ -26,6 +26,7 @@ _SKEW_WORK_SCALE = 0.25 _INK = 128 # below this is ink, above is paper _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 +_ANCHOR_MAX_RATIO = 2.0 # a stroke this much taller than the typical one is an artefact _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 @@ -123,6 +124,17 @@ def system_anchors(gray: np.ndarray) -> list[Anchor]: if stats[i, cv2.CC_STAT_HEIGHT] > h * _ANCHOR_MIN ] + # A scanner leaves a dark line down the sheet edge — the binder shadow, the + # glass, the page next to it — and it runs the whole height of the scan. + # Being the tallest stroke on the page it wins every overlap below and + # swallows every system into one. A page's brackets and barlines are all + # about one system tall, so anything wildly taller than the typical stroke + # is not notation. Relative, not an absolute fraction of the page: a page + # holding one big system is legitimate and must survive. + if len(tall) > 1: + limit = float(np.median([a.bottom - a.top for a in tall])) * _ANCHOR_MAX_RATIO + tall = [a for a in tall if a.bottom - a.top <= limit] or tall + # 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. # The kept stroke is the tallest, which is the bracket rather than a barline. diff --git a/noteman_slicer/render.py b/noteman_slicer/render.py index 7659194..85d75cc 100644 --- a/noteman_slicer/render.py +++ b/noteman_slicer/render.py @@ -26,7 +26,10 @@ from .project import Cut, Project MAX_WIDTH = 1920 ALPHA_LEVELS = 16 # quantising alpha costs nothing visible and ~32% of the bytes -_SPECK_AREA = 300 # ink blobs smaller than this don't anchor a trim +# A row or column carrying less ink than this is a fleck, not content: at least +# this many pixels, and at least this share of the slice's own size. +_SPECK_INK = 8 +_SPECK_SHARE = 0.005 @dataclass @@ -105,26 +108,24 @@ def _ink_bbox(gray: np.ndarray) -> tuple[int, int, int, int] | None: One scan fleck at the far left would otherwise anchor the trim and shift that slice relative to every other one. + + Measured per row and per column rather than per blob. Judging each blob on + its own area throws away a whole line of lyrics — every letter is its own + small component, and no single one is big enough to keep — which is how a + slice loses its bottom voice's words. A row carrying a line of text carries + plenty of ink *in total*, and a fleck's row carries almost none. """ - ink = (gray < 200).astype(np.uint8) - count, _, stats, _ = cv2.connectedComponentsWithStats(ink, 8) - boxes = [ - ( - stats[i, cv2.CC_STAT_LEFT], - stats[i, cv2.CC_STAT_TOP], - stats[i, cv2.CC_STAT_LEFT] + stats[i, cv2.CC_STAT_WIDTH], - stats[i, cv2.CC_STAT_TOP] + stats[i, cv2.CC_STAT_HEIGHT], - ) - for i in range(1, count) - if stats[i, cv2.CC_STAT_AREA] >= _SPECK_AREA - ] - if not boxes: + ink = gray < 200 + rows, cols = ink.sum(axis=1), ink.sum(axis=0) + kept_rows = np.where(rows >= max(_SPECK_INK, ink.shape[1] * _SPECK_SHARE))[0] + kept_cols = np.where(cols >= max(_SPECK_INK, ink.shape[0] * _SPECK_SHARE))[0] + if not kept_rows.size or not kept_cols.size: return None return ( - min(b[0] for b in boxes), - min(b[1] for b in boxes), - max(b[2] for b in boxes), - max(b[3] for b in boxes), + int(kept_cols[0]), + int(kept_rows[0]), + int(kept_cols[-1]) + 1, + int(kept_rows[-1]) + 1, ) diff --git a/tests/test_detect.py b/tests/test_detect.py index 7d13706..58f06fd 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -71,6 +71,14 @@ def main() -> int: found = deskew_angle(deskew(page, angle)) assert abs(found + angle) <= 0.15, f"skew {angle}: got {found}" + # A scanner's edge line runs the whole height of the sheet. Being taller + # than every bracket it used to win each overlap and swallow the page into + # one system — Olukainen juomukainen, where five pages of six came out as a + # single slice each. + scanned = _page() + scanned[10 : H - 10, W - 8 : W - 4] = 0 + assert len(detect_page(scanned).systems) == 2, "an edge artefact is not a bracket" + # No brackets: every ink run is its own system. bare = np.full((H, W), 255, np.uint8) for y in (200, 500, 800): diff --git a/tests/test_render.py b/tests/test_render.py index 2ba1510..55d4cbe 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -19,6 +19,7 @@ from noteman_slicer.pdf import open_source, page_raster # noqa: E402 from noteman_slicer.project import Cut, Project, default_path # noqa: E402 from noteman_slicer.render import ( # noqa: E402 ALPHA_LEVELS, + _ink_bbox, apply_levels, encode, pad_right, @@ -87,6 +88,19 @@ def main() -> int: assert rgba[:, :, 3].min() == 0, "paper must be fully transparent" assert len(np.unique(rgba[:, :, 3])) <= ALPHA_LEVELS + # Trim keeps a line of lyrics and drops a fleck. Each letter is its own + # small blob, so judging blobs by area threw the whole line away and the + # bottom voice lost its words; a fleck's row carries almost no ink at all. + art = np.full((300, 800), 255, np.uint8) + art[100:150, 50:750] = 0 # a staff + for x in range(60, 700, 30): # lyrics: many small glyphs, one row + art[200:220, x : x + 14] = 0 + art[5:9, 10:14] = 0 # a fleck in the far corner + x0, y0, x1, y1 = _ink_bbox(art) + assert (y0, y1) == (100, 220), f"lyrics kept, fleck dropped: {(y0, y1)}" + assert (x0, x1) == (50, 750), (x0, x1) + assert _ink_bbox(np.full((50, 50), 255, np.uint8)) is None, "blank slice has no box" + # Levels: a white point below the paper value wipes the paper out entirely. faint = np.full((10, 10), 200, np.uint8) assert apply_levels(faint, 0, 180).max() == 255 From 19f28f4da8060643c9575226f93a166f79629aee Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Wed, 29 Jul 2026 12:50:37 +0300 Subject: [PATCH 3/4] Propose black and white points from the scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Levels shipped at 0–255 unless someone moved the sliders, and Bicycle Race showed what that costs. Its ink is grey, not black — a scanned engraving, ink at 2–95, paper at 163–255 — and with alpha = 255 − luminance that greyness becomes transparency. No pixel in the exported bundle was even fully opaque, and the downscale to the song's width blended every stroke edge further. Nothing downstream can rescue it. So detection proposes levels too, like it proposes cuts and skew. Notation is two-tone, which makes Otsu's split the measurement wanted; the points sit halfway from it to each end of the range, so the ramp between them survives as antialiasing rather than going jagged. A page already scanned bilevel has no interior split — Otsu degenerates to 0 — and is left alone. Per page, with the median becoming the song's, so a near-blank page cannot set them. --- docs/guide.md | 9 +++++---- docs/spec.md | 8 ++++++++ noteman_slicer/detect.py | 27 ++++++++++++++++++++++++++- noteman_slicer/project.py | 14 +++++++++++++- tests/test_detect.py | 16 +++++++++++++++- 5 files changed, 67 insertions(+), 7 deletions(-) diff --git a/docs/guide.md b/docs/guide.md index 0a04e4c..435787c 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -44,10 +44,11 @@ off. one back. 4. **Set the content rectangle.** Drag the blue edges so they hold the music and nothing else. This is the horizontal crop for every slice on the page. -5. **Set black and white points.** Pull the *White point* down until the paper - goes pure white and its texture disappears; pull *Black point* up until the - notes are solid black rather than grey mush. Scans need this; clean digital - PDFs usually don't. +5. **Check black and white points.** These arrive proposed from the scan, like + the cuts do. If the paper still shows texture, pull *White point* down; if the + notes look grey rather than solid, pull *Black point* up. Getting this wrong + is the one mistake you cannot see until the bundle is on the tablet — grey ink + becomes half-transparent ink, and nothing downstream can rescue it. Page Up / Page Down move between pages. Levels carry over from the previous page, so a consistent scan only needs setting once. diff --git a/docs/spec.md b/docs/spec.md index 49e05a3..65b4531 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -236,6 +236,14 @@ point just under the paper's luminance and the paper vanishes completely; set th black point at the ink's darkest and notes go solid. It is also the single biggest lever on output size. +**Detection proposes both**, like it proposes cuts and skew, because the default +0–255 is the one setting whose harm is invisible until the bundle is on a tablet. +Notation is two-tone, so Otsu's split between ink and paper is the measurement; +the points sit halfway from it to each end of the range, leaving the ramp between +them as the antialiasing. A page already scanned bilevel has no interior split — +Otsu degenerates to 0 there — and is left at 0–255. The proposal is per page and +the median becomes the song's, so a near-blank page cannot set it. + Adaptive methods (CLAHE, adaptive thresholding) are the trap — tuned for text, they eat the thin stuff on notation: hairpin tips, slur ends, ledger lines, tapered beams. A global LUT whose effect you can see beats a local algorithm you diff --git a/noteman_slicer/detect.py b/noteman_slicer/detect.py index 01f9ccb..bb57b6e 100644 --- a/noteman_slicer/detect.py +++ b/noteman_slicer/detect.py @@ -65,6 +65,7 @@ class PageDetection: 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) + levels: tuple[int, int] = (0, 255) @property def bracketless(self) -> bool: @@ -292,6 +293,26 @@ def staff_height(gray: np.ndarray, top: int, bottom: int) -> float | None: return float(np.median(intra) * 4) # 5 lines, 4 spaces +def ink_levels(gray: np.ndarray) -> tuple[int, int]: + """Black and white points that put the ink on black and the paper on white. + + Left at 0–255 a slice ships whatever grey the scanner produced, and the + downscale to the song's width then blends every stroke edge further, so a + fine engraving arrives on the tablet as a wash. Notation is two-tone by + nature — ink and paper, nothing in between — so Otsu's split is exactly the + measurement wanted, and the points sit halfway to each end of the range from + it. Halfway rather than at the split itself: the ramp between them is the + antialiasing, and collapsing it would leave the notes jagged. + """ + split = float(cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)[0]) + if split < 1: + # A page already scanned bilevel has no interior split to find, and + # Otsu degenerates to 0. There is nothing between ink and paper to + # stretch, so leave the sliders where they are. + return 0, 255 + return int(split / 2), int(split + (255 - split) / 2) + + 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 @@ -387,5 +408,9 @@ def detect_page(gray: np.ndarray, skew: float | None = None) -> PageDetection: # 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) + skew=angle, + systems=systems, + cuts=cuts, + content=(left, 0.0, right, 1.0), + levels=ink_levels(straight), ) diff --git a/noteman_slicer/project.py b/noteman_slicer/project.py index 4ef00cb..cd3d804 100644 --- a/noteman_slicer/project.py +++ b/noteman_slicer/project.py @@ -15,6 +15,7 @@ import hashlib import json from dataclasses import dataclass, field from pathlib import Path +from statistics import median from .detect import PageDetection @@ -296,7 +297,18 @@ class Project: content_rect=detection.content, ) ) - return cls(source=source, source_hash=hash_file(source), pages=pages) + # Levels per song, not per page: a scanner's contrast does not change + # between sheets, and one pair of sliders for the whole song is what a + # user actually wants to nudge. The median keeps a near-blank page — + # where the ink/paper split is guesswork — from setting them. + proposals = [d.levels for d in detections] or [(0, 255)] + levels = ( + int(median(b for b, _ in proposals)), + int(median(w for _, w in proposals)), + ) + return cls( + source=source, source_hash=hash_file(source), pages=pages, levels=levels + ) def save(self, path: Path | None = None) -> Path: """Atomic write, so a crash mid-save cannot destroy the previous state.""" diff --git a/tests/test_detect.py b/tests/test_detect.py index 58f06fd..8afc0cc 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -14,7 +14,12 @@ import numpy as np sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -from noteman_slicer.detect import deskew, deskew_angle, detect_page # noqa: E402 +from noteman_slicer.detect import ( # noqa: E402 + deskew, + deskew_angle, + detect_page, + ink_levels, +) W, H = 1000, 1400 STAFF_GAP = 15 # → staff height 60, so expansion reaches 90px past a bracket @@ -71,6 +76,15 @@ def main() -> int: found = deskew_angle(deskew(page, angle)) assert abs(found + angle) <= 0.15, f"skew {angle}: got {found}" + # Levels are proposed too. A grey scan left at 0–255 ships its wash to the + # tablet, and the downscale to the song's width only blends it further. + grey = np.full((H, W), 210, np.uint8) # paper, not white + grey[200:400, 100:900] = 70 # ink, not black + black, white = ink_levels(grey) + assert black < 70 < white < 210, (black, white) + # A page already bilevel has nothing between ink and paper to stretch. + assert ink_levels(_page()) == (0, 255) + # A scanner's edge line runs the whole height of the sheet. Being taller # than every bracket it used to win each overlap and swallow the page into # one system — Olukainen juomukainen, where five pages of six came out as a From 24b12214bbd17ff4479fea111d8a33bfb275a348 Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Wed, 29 Jul 2026 13:09:27 +0300 Subject: [PATCH 4/4] Fit the page on startup, name the bundle, show the selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things a first session trips over. The editor opened at an arbitrary zoom. show_page does fit the page, but it runs before the window has been laid out, when the viewport is still its default size; redo it once when the real size arrives. The bundle was named after the PDF, which is whatever the download was called. Take the song's title instead — unsafe characters dropped, then whitespace collapsed to dashes. Accented letters stay, since ä and ö are not a filesystem's problem, but a leading dot would hide the file. And the selected slice was drawn as an outline whose top and bottom edges run under the cut lines painted over them, leaving two thin verticals in the margins and no way to tell what was selected. Wash the slice, as the discard and engraved states already do, and keep the outline for the trim anomalies it exists to show. --- docs/guide.md | 3 ++- noteman_slicer/bundle.py | 14 ++++++++++++++ noteman_slicer/cli.py | 2 +- noteman_slicer/editor.py | 28 ++++++++++++++++++++++++---- tests/test_editor.py | 23 +++++++++++++++++++++++ 5 files changed, 64 insertions(+), 6 deletions(-) diff --git a/docs/guide.md b/docs/guide.md index 435787c..5c19595 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -78,7 +78,8 @@ and "Andante" cannot. ## Export -**Export bundle…**, choose where the `.zip` goes, done. Inside are the slice +**Export bundle…**, choose where the `.zip` goes, done. It is named after the +song's title — *Bicycle Race* becomes `Bicycle-Race.zip`. Inside are the slice images in order, their markers, the song metadata, and the original PDF as the archive copy. That zip is the whole interface to noteman; hand it over and open it there. diff --git a/noteman_slicer/bundle.py b/noteman_slicer/bundle.py index 38a534a..74d55d4 100644 --- a/noteman_slicer/bundle.py +++ b/noteman_slicer/bundle.py @@ -13,6 +13,7 @@ a jump source's `destination`. from __future__ import annotations import json +import re import zipfile from pathlib import Path @@ -39,6 +40,19 @@ METADATA_FIELDS = ( NUMERIC_FIELDS = frozenset({"tempo"}) +def filename(project: Project) -> str: + """The bundle's name, from the song's title. + + Spaces become dashes and anything that is not a letter, digit, dash, dot or + underscore goes. Letters keep their accents — ä and ö are not a filesystem's + problem — but a leading dot would make the bundle invisible. + """ + # Drop the unsafe characters before collapsing whitespace, not after, or + # "Sävel & Ääni" keeps the dash the ampersand left behind. + title = re.sub(r"[^\w\s.-]", "", (project.metadata.get("title") or "")) + return f"{re.sub(r'\s+', '-', title.strip()).lstrip('.-') or 'song'}.zip" + + def _engraving(project: Project, page: int, slot: int) -> dict | None: """The notation behind a re-engraved slice, or None for a scanned one. diff --git a/noteman_slicer/cli.py b/noteman_slicer/cli.py index a5cb66e..4dbe8ee 100644 --- a/noteman_slicer/cli.py +++ b/noteman_slicer/cli.py @@ -87,7 +87,7 @@ def _export(args: argparse.Namespace) -> int: 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") + out = Path(args.out) if args.out else source.path.with_name(bundle.filename(project)) try: bundle.write(project, source, out) except ValueError as error: diff --git a/noteman_slicer/editor.py b/noteman_slicer/editor.py index f7cfd3d..308c8fe 100644 --- a/noteman_slicer/editor.py +++ b/noteman_slicer/editor.py @@ -75,6 +75,8 @@ _CUT = QColor(220, 40, 40) _CUT_ACTIVE = QColor(255, 120, 0) _VERTEX = QColor(255, 200, 0) _DISCARD = QColor(120, 120, 140, 90) +_SELECT = QColor(0, 170, 0) +_SELECT_WASH = QColor(0, 200, 60, 40) _RECT = QColor(40, 140, 220) _MARKER = QColor(150, 60, 190) _ENGRAVED = QColor(200, 120, 0) @@ -104,6 +106,7 @@ class PageView(QGraphicsView): self.selected_slice = 0 self.picking = False self._drag: tuple[str, int, int] | None = None + self._fitted = False # -- state ------------------------------------------------------------ @@ -137,10 +140,15 @@ class PageView(QGraphicsView): self._slice_polygon(slot, w, h), QPen(Qt.NoPen), QBrush(_DISCARD) ) - # The selected slice, outlined so trim anomalies are visible. - pen = QPen(QColor(0, 170, 0), 2) + # The selected slice. The outline alone is nearly invisible: its top and + # bottom edges run under the cut lines drawn over them, leaving two thin + # verticals at the page margins. A wash says which slice is selected at + # a glance; the outline stays, because it is what shows trim anomalies. + selected = self._slice_polygon(self.selected_slice, w, h) + scene.addPolygon(selected, QPen(Qt.NoPen), QBrush(_SELECT_WASH)) + pen = QPen(_SELECT, 3) pen.setCosmetic(True) - scene.addPolygon(self._slice_polygon(self.selected_slice, w, h), pen) + scene.addPolygon(selected, pen) x0, y0, x1, y1 = self.project.page_content_rect(self.page_index) pen = QPen(_RECT, 2, Qt.DashLine) @@ -361,6 +369,15 @@ class PageView(QGraphicsView): self.redraw() self.changed.emit() + def resizeEvent(self, event) -> None: + super().resizeEvent(event) + # The fit in show_page runs before the window has been laid out, when + # the viewport is still its default size, so the first page opens at + # some arbitrary zoom. Redo it once, when the real size arrives. + if not self._fitted and self.pixmap is not None: + self._fitted = True + self.fitInView(self.scene().sceneRect(), Qt.KeepAspectRatio) + def wheelEvent(self, event) -> None: factor = 1.15 if event.angleDelta().y() > 0 else 1 / 1.15 self.scale(factor, factor) @@ -844,7 +861,10 @@ class Editor(QMainWindow): self.metadata["title"].setFocus() return target, _ = QFileDialog.getSaveFileName( - self, "Export bundle", str(self.source.path.with_suffix(".zip")), "Bundle (*.zip)" + self, + "Export bundle", + str(self.source.path.with_name(bundle.filename(self.project))), + "Bundle (*.zip)", ) if not target: return diff --git a/tests/test_editor.py b/tests/test_editor.py index d0bd49d..08e4ff5 100644 --- a/tests/test_editor.py +++ b/tests/test_editor.py @@ -19,6 +19,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from PySide6.QtWidgets import QApplication # noqa: E402 +from noteman_slicer import bundle # noqa: E402 from noteman_slicer.detect import detect_page # noqa: E402 from noteman_slicer.editor import Editor # noqa: E402 from noteman_slicer.pdf import open_source, page_raster # noqa: E402 @@ -105,6 +106,28 @@ def main() -> int: assert reloaded.pages[0].levels == (40, 210) assert [c.points for c in reloaded.pages[0].cuts] == [c.points for c in page.cuts] + # The page fits the viewport once the window has a real size. show_page's + # own fit runs before layout, when the viewport is still its default. + editor.resize(900, 700) + editor.show() + app.processEvents() + scene = editor.view.sceneRect() + scale = editor.view.transform().m11() + viewport = editor.view.viewport() + fill = max( + scale * scene.width() / viewport.width(), + scale * scene.height() / viewport.height(), + ) + # Fit means nearly touching one edge — Qt leaves a small margin of its own. + # A "≤ 1" check alone would pass a page zoomed down to a dot. + assert 0.9 <= fill <= 1.02, f"page is not fitted to the window: {fill:.3f}" + + # The bundle is named after the song, not the PDF. + assert bundle.filename(project) == "Ketun-joululaulu.zip" + project.metadata["title"] = "AC/DC: T.N.T. (live)" + assert bundle.filename(project) == "ACDC-T.N.T.-live.zip" + project.metadata["title"] = "Ketun joululaulu" + editor.close() source.close() for f in (pdf, saved):