From 24b12214bbd17ff4479fea111d8a33bfb275a348 Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Wed, 29 Jul 2026 13:09:27 +0300 Subject: [PATCH] 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):