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):