Fit the page on startup, name the bundle, show the selection

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.
This commit is contained in:
Esa Kataja
2026-07-29 13:09:27 +03:00
parent 19f28f4da8
commit 24b12214bb
5 changed files with 64 additions and 6 deletions
+2 -1
View File
@@ -78,7 +78,8 @@ and "Andante" cannot.
## Export ## 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 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 archive copy. That zip is the whole interface to noteman; hand it over and open
it there. it there.
+14
View File
@@ -13,6 +13,7 @@ a jump source's `destination`.
from __future__ import annotations from __future__ import annotations
import json import json
import re
import zipfile import zipfile
from pathlib import Path from pathlib import Path
@@ -39,6 +40,19 @@ METADATA_FIELDS = (
NUMERIC_FIELDS = frozenset({"tempo"}) 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: def _engraving(project: Project, page: int, slot: int) -> dict | None:
"""The notation behind a re-engraved slice, or None for a scanned one. """The notation behind a re-engraved slice, or None for a scanned one.
+1 -1
View File
@@ -87,7 +87,7 @@ def _export(args: argparse.Namespace) -> int:
elif project.source_changed(): elif project.source_changed():
print("WARNING: the PDF has changed since these cuts were made") 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: try:
bundle.write(project, source, out) bundle.write(project, source, out)
except ValueError as error: except ValueError as error:
+24 -4
View File
@@ -75,6 +75,8 @@ _CUT = QColor(220, 40, 40)
_CUT_ACTIVE = QColor(255, 120, 0) _CUT_ACTIVE = QColor(255, 120, 0)
_VERTEX = QColor(255, 200, 0) _VERTEX = QColor(255, 200, 0)
_DISCARD = QColor(120, 120, 140, 90) _DISCARD = QColor(120, 120, 140, 90)
_SELECT = QColor(0, 170, 0)
_SELECT_WASH = QColor(0, 200, 60, 40)
_RECT = QColor(40, 140, 220) _RECT = QColor(40, 140, 220)
_MARKER = QColor(150, 60, 190) _MARKER = QColor(150, 60, 190)
_ENGRAVED = QColor(200, 120, 0) _ENGRAVED = QColor(200, 120, 0)
@@ -104,6 +106,7 @@ class PageView(QGraphicsView):
self.selected_slice = 0 self.selected_slice = 0
self.picking = False self.picking = False
self._drag: tuple[str, int, int] | None = None self._drag: tuple[str, int, int] | None = None
self._fitted = False
# -- state ------------------------------------------------------------ # -- state ------------------------------------------------------------
@@ -137,10 +140,15 @@ class PageView(QGraphicsView):
self._slice_polygon(slot, w, h), QPen(Qt.NoPen), QBrush(_DISCARD) self._slice_polygon(slot, w, h), QPen(Qt.NoPen), QBrush(_DISCARD)
) )
# The selected slice, outlined so trim anomalies are visible. # The selected slice. The outline alone is nearly invisible: its top and
pen = QPen(QColor(0, 170, 0), 2) # 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) 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) x0, y0, x1, y1 = self.project.page_content_rect(self.page_index)
pen = QPen(_RECT, 2, Qt.DashLine) pen = QPen(_RECT, 2, Qt.DashLine)
@@ -361,6 +369,15 @@ class PageView(QGraphicsView):
self.redraw() self.redraw()
self.changed.emit() 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: def wheelEvent(self, event) -> None:
factor = 1.15 if event.angleDelta().y() > 0 else 1 / 1.15 factor = 1.15 if event.angleDelta().y() > 0 else 1 / 1.15
self.scale(factor, factor) self.scale(factor, factor)
@@ -844,7 +861,10 @@ class Editor(QMainWindow):
self.metadata["title"].setFocus() self.metadata["title"].setFocus()
return return
target, _ = QFileDialog.getSaveFileName( 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: if not target:
return return
+23
View File
@@ -19,6 +19,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from PySide6.QtWidgets import QApplication # noqa: E402 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.detect import detect_page # noqa: E402
from noteman_slicer.editor import Editor # noqa: E402 from noteman_slicer.editor import Editor # noqa: E402
from noteman_slicer.pdf import open_source, page_raster # 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 reloaded.pages[0].levels == (40, 210)
assert [c.points for c in reloaded.pages[0].cuts] == [c.points for c in page.cuts] 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() editor.close()
source.close() source.close()
for f in (pdf, saved): for f in (pdf, saved):