Files
Esa Kataja 68e41c2470 Give the editor a visual identity and a real levels control
The panel now uses the same colours the page is drawn with, on dark
chrome so the scan is the brightest thing on screen. Levels move from two
anonymous sliders to the scan's own histogram with draggable black and
white points and a tone strip, since getting them wrong is the one
mistake that only shows up on the tablet. A page rail replaces the
stepper and carries each page's slice count; export is pinned below the
scroll instead of below the fold; marker types read as prose.
2026-07-29 15:39:24 +03:00

938 lines
36 KiB
Python

"""The editor: the human-in-the-loop half of the tool.
Detection proposes; everything here is how you dispose (ADR 0004). Cuts can be
authored entirely by hand with detection producing nothing.
Geometry is edited in normalised page coordinates, so what the screen shows and
what the renderer uses are the same numbers at a different zoom.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
from PySide6.QtCore import QPointF, QRectF, Qt, QTimer, Signal
from PySide6.QtGui import (
QAction,
QBrush,
QColor,
QImage,
QIntValidator,
QKeySequence,
QPainter,
QPen,
QPixmap,
QPolygonF,
)
from PySide6.QtWidgets import (
QApplication,
QDoubleSpinBox,
QFileDialog,
QFormLayout,
QGraphicsScene,
QGraphicsView,
QComboBox,
QDialog,
QHBoxLayout,
QLabel,
QLineEdit,
QListWidget,
QMainWindow,
QMessageBox,
QPushButton,
QScrollArea,
QSizePolicy,
QSplitter,
QToolButton,
QVBoxLayout,
QWidget,
)
from . import bundle, lilypond, panel as ui
from .bundle import METADATA_FIELDS, NUMERIC_FIELDS
from .detect import deskew, detect_page
from .pdf import Source, open_source, page_raster
from .project import (
JUMP_TYPES,
LABELLED_TYPES,
MARKER_TYPES,
Cut,
Marker,
Project,
open_project,
)
from .render import apply_levels
PREVIEW_MAX = 1800 # display resolution; geometry stays normalised
PANEL_WIDTH = 340 # starting width only; the splitter takes over from there
HIT = 6 # grab distance in screen pixels
AUTOSAVE_MS = 800
_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)
_ENGRAVED_WASH = QColor(230, 160, 30, 55)
_BADGE_Z = 10
def section(title: str, box: QVBoxLayout, *, expanded: bool = True) -> QVBoxLayout:
"""A collapsible section. Returns the layout its contents go into.
A disclosure arrow, not a checkable QGroupBox: a checkbox in a group
header reads as "enable this feature" rather than "expand this", and a
column of framed boxes with checkboxes is hard to scan. A hairline above
each one does the separating that the frames used to.
"""
box.addWidget(ui.Rule())
header = QToolButton()
header.setText(title.upper())
header.setCheckable(True)
header.setChecked(expanded)
header.setArrowType(Qt.DownArrow if expanded else Qt.RightArrow)
header.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
header.setAutoRaise(True)
header.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
header.setStyleSheet(ui.HEADING)
body = QWidget()
layout = QVBoxLayout(body)
layout.setContentsMargins(10, 6, 0, 10)
body.setVisible(expanded)
def toggled(open_: bool) -> None:
body.setVisible(open_)
header.setArrowType(Qt.DownArrow if open_ else Qt.RightArrow)
header.toggled.connect(toggled)
box.addWidget(header)
box.addWidget(body)
return layout
class PageView(QGraphicsView):
"""Pan, zoom, and direct manipulation of cuts and the content rectangle."""
changed = Signal()
selection_changed = Signal()
picked = Signal(int, int) # page, slot — a jump target chosen by clicking
engrave_requested = Signal()
def __init__(self) -> None:
super().__init__()
self.setScene(QGraphicsScene(self))
self.setRenderHint(QPainter.Antialiasing)
self.setDragMode(QGraphicsView.ScrollHandDrag)
self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse)
self.project: Project | None = None
self.page_index = 0
self.pixmap: QPixmap | None = None
self.selected_cut: int | None = None
self.selected_slice = 0
self.picking = False
self._drag: tuple[str, int, int] | None = None
self._fitted = False
# -- state ------------------------------------------------------------
def show_page(self, project: Project, index: int, image: np.ndarray) -> None:
self.project = project
self.page_index = index
h, w = image.shape
qimage = QImage(image.data, w, h, w, QImage.Format_Grayscale8).copy()
self.pixmap = QPixmap.fromImage(qimage)
self.selected_cut = None
self.selected_slice = 0
self.scene().setSceneRect(0, 0, w, h)
self.redraw()
self.fitInView(self.scene().sceneRect(), Qt.KeepAspectRatio)
@property
def page(self):
return self.project.pages[self.page_index]
def redraw(self) -> None:
scene = self.scene()
scene.clear()
if self.pixmap is None:
return
scene.addPixmap(self.pixmap)
w, h = self.pixmap.width(), self.pixmap.height()
for slot in range(self.page.slice_count):
if self.page.discards[slot]:
scene.addPolygon(
self._slice_polygon(slot, w, h), QPen(Qt.NoPen), QBrush(_DISCARD)
)
# 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(selected, pen)
x0, y0, x1, y1 = self.project.page_content_rect(self.page_index)
pen = QPen(_RECT, 2, Qt.DashLine)
pen.setCosmetic(True)
scene.addRect(QRectF(x0 * w, y0 * h, (x1 - x0) * w, (y1 - y0) * h), pen)
for slot in range(self.page.slice_count):
above, _ = self.page.bounds(slot)
top = 0 if above is None else int(above.lowest * h)
x = w * 0.015
if self.page.replacements[slot]:
# A wash over the whole slice, not just a label: this slice
# will not ship the pixels underneath it, which is worth
# noticing without hunting for small text.
scene.addPolygon(
self._slice_polygon(slot, w, h), QPen(Qt.NoPen), QBrush(_ENGRAVED_WASH)
)
x = self._badge(scene, x, top + h * 0.004, "ENGRAVED", _ENGRAVED, w)
markers = self.page.markers[slot]
if markers:
self._badge(
scene, x, top + h * 0.004, " · ".join(m.describe() for m in markers), _MARKER, w
)
for i, cut in enumerate(self.page.cuts):
colour = _CUT_ACTIVE if i == self.selected_cut else _CUT
pen = QPen(colour, 2)
pen.setCosmetic(True)
points = [QPointF(px * w, py * h) for px, py in cut.points]
for a, b in zip(points, points[1:]):
line = scene.addLine(a.x(), a.y(), b.x(), b.y(), pen)
line.setZValue(_BADGE_Z)
if i == self.selected_cut:
r = HIT * 1.5 / max(self.transform().m11(), 1e-6)
for p in points:
handle = scene.addEllipse(
p.x() - r, p.y() - r, r * 2, r * 2, QPen(Qt.NoPen), QBrush(_VERTEX)
)
handle.setZValue(_BADGE_Z)
def _badge(self, scene, x: float, y: float, label: str, colour: QColor, w: int) -> float:
"""A filled chip with light text. Returns the x to place the next one."""
text = scene.addText(label)
text.setDefaultTextColor(QColor(255, 255, 255))
scale = max(1.0, w / 900)
text.setScale(scale)
box = text.boundingRect()
pad = 4 * scale
plate = scene.addRect(
x - pad,
y - pad / 2,
box.width() * scale + pad * 2,
box.height() * scale + pad,
QPen(Qt.NoPen),
QBrush(colour),
)
# Above the page pixmap, which sits at z 0: a negative z would put the
# plate behind the scan and the white text with it.
plate.setZValue(_BADGE_Z - 1)
text.setZValue(_BADGE_Z)
text.setPos(x, y)
return x + box.width() * scale + pad * 3
def _slice_polygon(self, slot: int, w: int, h: int) -> QPolygonF:
above, below = self.page.bounds(slot)
top = [(0.0, 0.0), (1.0, 0.0)] if above is None else above.points
bottom = [(0.0, 1.0), (1.0, 1.0)] if below is None else below.points
pts = [QPointF(x * w, y * h) for x, y in top]
pts += [QPointF(x * w, y * h) for x, y in reversed(bottom)]
return QPolygonF(pts)
# -- hit testing ------------------------------------------------------
def _norm(self, pos) -> tuple[float, float]:
p = self.mapToScene(pos)
return p.x() / self.pixmap.width(), p.y() / self.pixmap.height()
def _tolerance(self) -> tuple[float, float]:
scale = max(self.transform().m11(), 1e-6)
return HIT / scale / self.pixmap.width(), HIT / scale / self.pixmap.height()
def _hit_cut(self, x: float, y: float) -> tuple[int, int | None] | None:
"""(cut index, vertex index or None) under the cursor."""
tx, ty = self._tolerance()
for i, cut in enumerate(self.page.cuts):
for v, (vx, vy) in enumerate(cut.points):
if abs(vx - x) <= tx * 2 and abs(vy - y) <= ty * 2:
return i, v
if abs(cut.y_at(x) - y) <= ty:
return i, None
return None
def _hit_rect_edge(self, x: float, y: float) -> str | None:
x0, y0, x1, y1 = self.project.page_content_rect(self.page_index)
tx, ty = self._tolerance()
if y0 - ty <= y <= y1 + ty:
if abs(x - x0) <= tx:
return "left"
if abs(x - x1) <= tx:
return "right"
if x0 - tx <= x <= x1 + tx:
if abs(y - y0) <= ty:
return "top"
if abs(y - y1) <= ty:
return "bottom"
return None
# -- interaction ------------------------------------------------------
def mousePressEvent(self, event) -> None:
if self.project is None or self.pixmap is None:
return super().mousePressEvent(event)
x, y = self._norm(event.position().toPoint())
if self.picking:
# Choosing a jump's target: click the slice it lands on. Cheaper
# than a thumbnail picker and it reads the score rather than a list.
if event.button() == Qt.LeftButton:
self.picked.emit(self.page_index, self._slice_at(x, y))
self.picking = False
self.setCursor(Qt.ArrowCursor)
return
if event.button() == Qt.RightButton:
hit = self._hit_cut(x, y)
if hit:
index, vertex = hit
if vertex is not None and len(self.page.cuts[index].points) > 2:
self.page.cuts[index].points.pop(vertex)
else:
self.page.remove_cut(index)
self.selected_cut = None
self.redraw()
self.changed.emit()
return
if event.button() == Qt.LeftButton:
edge = self._hit_rect_edge(x, y)
hit = self._hit_cut(x, y)
if hit and event.modifiers() & Qt.ControlModifier and hit[1] is None:
# Ctrl-click on a cut inserts a vertex: this is how a straight
# cut becomes a stepped one.
cut = self.page.cuts[hit[0]]
at = next(i for i, p in enumerate(cut.points) if p[0] > x)
cut.points.insert(at, (x, cut.y_at(x)))
self.selected_cut = hit[0]
self._drag = ("vertex", hit[0], at)
elif hit:
self.selected_cut = hit[0]
self._drag = ("vertex" if hit[1] is not None else "cut", hit[0], hit[1] or 0)
elif edge:
self._drag = ("rect", 0, 0)
self._edge = edge
else:
self.selected_cut = None
self.selected_slice = self._slice_at(x, y)
self.selection_changed.emit()
self.setDragMode(
QGraphicsView.NoDrag if self._drag else QGraphicsView.ScrollHandDrag
)
self.redraw()
super().mousePressEvent(event)
def mouseMoveEvent(self, event) -> None:
if self._drag and self.pixmap is not None:
x, y = self._norm(event.position().toPoint())
kind, index, vertex = self._drag
if kind == "cut":
cut = self.page.cuts[index]
shift = y - cut.y_at(x)
cut.points = [(px, min(1.0, max(0.0, py + shift))) for px, py in cut.points]
elif kind == "vertex":
cut = self.page.cuts[index]
lo = cut.points[vertex - 1][0] if vertex > 0 else 0.0
hi = cut.points[vertex + 1][0] if vertex + 1 < len(cut.points) else 1.0
px = cut.points[vertex][0] if vertex in (0, len(cut.points) - 1) else min(
max(x, lo), hi
)
cut.points[vertex] = (px, min(1.0, max(0.0, y)))
else:
x0, y0, x1, y1 = self.project.page_content_rect(self.page_index)
x, y = min(max(x, 0.0), 1.0), min(max(y, 0.0), 1.0)
box = {
"left": (x, y0, x1, y1),
"right": (x0, y0, x, y1),
"top": (x0, y, x1, y1),
"bottom": (x0, y0, x1, y),
}[self._edge]
self.project.pages[self.page_index].content_rect = box
self.redraw()
return
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event) -> None:
if self._drag:
self._drag = None
self.setDragMode(QGraphicsView.ScrollHandDrag)
self.page.cuts.sort(key=lambda c: c.points[0][1])
self.changed.emit()
super().mouseReleaseEvent(event)
def mouseDoubleClickEvent(self, event) -> None:
if self.project is None or self.pixmap is None:
return
x, y = self._norm(event.position().toPoint())
if self._hit_cut(x, y) is not None:
return
if event.modifiers() & Qt.ShiftModifier:
# Shift-double-click opens the engrave window on this slice; a
# plain double-click adds a cut, which is by far the commoner one.
self.selected_slice = self._slice_at(x, y)
self.selection_changed.emit()
self.engrave_requested.emit()
return
self.selected_cut = self.page.add_cut(Cut.straight(y))
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)
self.redraw()
def _slice_at(self, x: float, y: float) -> int:
return sum(1 for cut in self.page.cuts if cut.y_at(x) < y)
def toggle_discard(self) -> None:
self.page.discards[self.selected_slice] = not self.page.discards[self.selected_slice]
self.redraw()
self.changed.emit()
class Editor(QMainWindow):
def __init__(self, source: Source, project: Project) -> None:
super().__init__()
self.source = source
self.project = project
self.index = 0
self._raw: dict[int, np.ndarray] = {}
self.setWindowTitle(f"noteman-slicer — {source.path.name}")
self._targeting = 0
self.view = PageView()
self.view.changed.connect(self._touched)
self.view.selection_changed.connect(self._sync)
self.view.picked.connect(self._target_picked)
self.view.engrave_requested.connect(self._open_engrave)
self.autosave = QTimer(self)
self.autosave.setSingleShot(True)
self.autosave.setInterval(AUTOSAVE_MS)
self.autosave.timeout.connect(self._save)
splitter = QSplitter(Qt.Horizontal)
splitter.addWidget(self.view)
splitter.addWidget(self._panel())
splitter.setStretchFactor(0, 1) # the page takes the slack when resized
splitter.setStretchFactor(1, 0)
splitter.setSizes([1100, PANEL_WIDTH])
splitter.setCollapsible(0, False)
self.setCentralWidget(splitter)
self._shortcuts()
self._load_page(0)
# -- ui ---------------------------------------------------------------
def _panel(self) -> QWidget:
inner = QWidget()
box = QVBoxLayout(inner)
box.setContentsMargins(14, 12, 14, 14)
box.setSpacing(0)
scroller = QScrollArea()
scroller.setWidget(inner)
scroller.setWidgetResizable(True)
scroller.setMinimumWidth(280)
self.rail = ui.PageRail()
self.rail.picked.connect(self._load_page)
box.addWidget(self.rail)
page_section = section("Page", box)
form = QFormLayout()
page_section.addLayout(form)
self.skew = QDoubleSpinBox()
self.skew.setRange(-15.0, 15.0)
self.skew.setSingleStep(0.1)
self.skew.setDecimals(2)
self.skew.setSuffix("°")
self.skew.valueChanged.connect(self._skew_changed)
form.addRow("Skew", self.skew)
self.levels = ui.LevelsBar()
self.levels.changed.connect(self._levels_changed)
self.levels.setToolTip(
"Drag the white dot to the foot of the paper hump and the light one "
"to the foot of the ink hump. The strip below is the resulting tone."
)
page_section.addWidget(self.levels)
buttons = QHBoxLayout()
discard = QPushButton("Discard slice")
discard.setToolTip("Or press D. Discarded slices never reach the tablet.")
discard.clicked.connect(self.view.toggle_discard)
reset = QPushButton("Reset crop")
reset.setToolTip("Back to the content rectangle detection proposed for this page")
reset.clicked.connect(self._reset_rect)
buttons.addWidget(discard)
buttons.addWidget(reset)
page_section.addLayout(buttons)
slice_layout = section("This slice", box)
slice_form = QFormLayout()
slice_layout.addLayout(slice_form)
# Every slice can carry one, engraved or scanned: a scanned system has
# a bar number printed on it just the same, and noteman wants to be
# able to say "from bar 33" about either.
self.bar = QLineEdit()
self.bar.setValidator(QIntValidator(1, 9999, self.bar))
self.bar.setProperty("role", "number")
self.bar.setFixedWidth(90)
self.bar.setPlaceholderText("none")
self.bar.setToolTip("The measure this slice starts at, as printed in the score")
self.bar.textChanged.connect(self._bar_changed)
slice_form.addRow("First bar", self.bar)
marker_layout = section("Markers on this slice", box)
self.marker_list = QListWidget()
self.marker_list.setMaximumHeight(110)
marker_layout.addWidget(self.marker_list)
add_row = QHBoxLayout()
self.marker_type = QComboBox()
# Shown as prose, sent as the enum: "D.S. al coda" is what a musician
# reads off the page, `ds_al_coda` is what noteman parses.
for kind in MARKER_TYPES:
self.marker_type.addItem(kind.replace("_", " ").capitalize(), kind)
self.marker_type.currentIndexChanged.connect(
lambda: self._marker_type_changed(self.marker_type.currentData())
)
add_row.addWidget(self.marker_type, 1)
self.marker_label = QLineEdit()
self.marker_label.setPlaceholderText("label")
self.marker_label.setFixedWidth(70)
add_row.addWidget(self.marker_label)
marker_layout.addLayout(add_row)
button_row = QHBoxLayout()
add = QPushButton("Add")
add.clicked.connect(self._add_marker)
remove = QPushButton("Remove")
remove.clicked.connect(self._remove_marker)
self.retarget = QPushButton("Set target…")
self.retarget.clicked.connect(self._pick_target)
for button in (add, remove, self.retarget):
button_row.addWidget(button)
marker_layout.addLayout(button_row)
self._marker_type_changed(self.marker_type.currentData())
# Optional feature: without LilyPond installed the pane never appears,
# and nothing else about the tool changes. Collapsed by default — most
# slices are never re-engraved, and it is the tallest block here.
self.ly_status = None
if lilypond.available():
ly_layout = section("Re-engrave this slice", box, expanded=False)
open_engrave = QPushButton("Open engrave window…")
open_engrave.setToolTip("Or double-click the slice on the page")
open_engrave.clicked.connect(self._open_engrave)
ly_layout.addWidget(open_engrave)
self.ly_status = QLabel()
self.ly_status.setWordWrap(True)
self.ly_status.setProperty("role", "hint")
ly_layout.addWidget(self.ly_status)
meta_layout = section("Song", box)
meta_form = QFormLayout()
meta_layout.addLayout(meta_form)
self.metadata: dict[str, QLineEdit] = {}
for field in METADATA_FIELDS:
edit = QLineEdit(self.project.metadata.get(field, ""))
edit.textChanged.connect(self._metadata_changed)
self.metadata[field] = edit
required = field == "title"
if required:
edit.setPlaceholderText("required")
if field in NUMERIC_FIELDS:
# Beats per minute, and only that: a number can drive a
# metronome where "Andante" cannot.
edit.setValidator(QIntValidator(20, 400, edit))
edit.setPlaceholderText("BPM")
edit.setProperty("role", "number")
edit.setFixedWidth(90)
meta_form.addRow(f"{field.replace('_', ' ').title()}{' *' if required else ''}", edit)
self.optimise = QPushButton("Shrink the original PDF…")
self.optimise.setToolTip("Convert scanned pages to bilevel in the archived PDF")
self.optimise.clicked.connect(self._optimise_pdf)
meta_layout.addWidget(self.optimise)
# Open by default: the first thing a new user needs is to know that a
# double-click adds a cut, and a collapsed section does not tell them.
keys_layout = section("Keys and mouse", box)
keys = QLabel(ui.shortcut_html())
keys.setTextFormat(Qt.RichText)
keys_layout.addWidget(keys)
box.addStretch(1)
# Where you are and the way out, pinned below the scroll. Export is the
# one thing that must never be hidden by however far the panel is
# scrolled, and the count beside it is what says whether it is ready.
footer = QWidget()
column = QVBoxLayout(footer)
column.setContentsMargins(14, 0, 14, 12)
column.addWidget(ui.Rule())
self.summary = QLabel()
self.summary.setWordWrap(True)
self.summary.setProperty("role", "reading")
self.summary.setContentsMargins(0, 10, 0, 6)
column.addWidget(self.summary)
export = QPushButton("Export bundle…")
export.setProperty("role", "primary")
export.clicked.connect(self._export)
column.addWidget(export)
holder = QWidget()
stack = QVBoxLayout(holder)
stack.setContentsMargins(0, 0, 0, 0)
stack.setSpacing(0)
stack.addWidget(scroller, 1)
stack.addWidget(footer)
holder.setMinimumWidth(300)
return holder
def _shortcuts(self) -> None:
for key, slot in (
(QKeySequence("D"), self.view.toggle_discard),
(QKeySequence(Qt.Key_PageDown), lambda: self._load_page(self.index + 1)),
(QKeySequence(Qt.Key_PageUp), lambda: self._load_page(self.index - 1)),
(QKeySequence.Save, self._save),
):
action = QAction(self)
action.setShortcut(key)
action.triggered.connect(slot)
self.addAction(action)
# -- page handling ----------------------------------------------------
def _raster(self, index: int) -> np.ndarray:
"""Page pixels at preview resolution, cached — the PDF is slow to read."""
if index not in self._raw:
import cv2
gray = page_raster(self.source, index)
if gray.shape[1] > PREVIEW_MAX:
k = PREVIEW_MAX / gray.shape[1]
gray = cv2.resize(gray, None, fx=k, fy=k, interpolation=cv2.INTER_AREA)
self._raw[index] = gray
return self._raw[index]
def _preview(self, index: int) -> np.ndarray:
page = self.project.pages[index]
black, white = self.project.page_levels(index)
return np.ascontiguousarray(
apply_levels(deskew(self._raster(index), page.skew), black, white)
)
def _load_page(self, index: int) -> None:
if not 0 <= index < len(self.project.pages):
return
self.index = index
self.view.show_page(self.project, index, self._preview(index))
# The histogram is of the raw scan, not the levelled preview: it has to
# keep showing where the ink is while you drag the points over it.
self.levels.set_page(self._raster(index))
self._sync()
def _sync(self) -> None:
page = self.project.pages[self.index]
self.rail.build([p.slice_count for p in self.project.pages], self.index)
self.skew.blockSignals(True)
self.skew.setValue(page.skew)
self.skew.blockSignals(False)
self.levels.set_levels(*self.project.page_levels(self.index))
bar = page.bars[self.view.selected_slice]
self.bar.blockSignals(True)
self.bar.setText("" if bar is None else str(bar))
self.bar.blockSignals(False)
self._sync_markers()
self._sync_replacement()
kept = len(self.project.kept_slices())
total = sum(p.slice_count for p in self.project.pages)
state = "discarded" if page.discards[self.view.selected_slice] else "kept"
self.summary.setText(
f"page {self.index + 1}/{len(self.project.pages)} · "
f"slice {self.view.selected_slice + 1}/{page.slice_count} is {state}\n"
f"{kept} of {total} slices ship"
)
# -- edits ------------------------------------------------------------
def _touched(self) -> None:
self._sync()
self.autosave.start()
def _bar_changed(self, text: str) -> None:
page = self.project.pages[self.index]
page.bars[self.view.selected_slice] = int(text) if text.strip().isdigit() else None
self.autosave.start()
def _skew_changed(self, value: float) -> None:
self.project.pages[self.index].skew = value
self.view.show_page(self.project, self.index, self._preview(self.index))
self._touched()
def _levels_changed(self, black: int, white: int) -> None:
self.project.pages[self.index].levels = (black, white)
self.view.show_page(self.project, self.index, self._preview(self.index))
self._touched()
# -- markers ----------------------------------------------------------
def _slot_markers(self) -> list[Marker]:
return self.project.pages[self.index].markers[self.view.selected_slice]
def _marker_type_changed(self, kind: str) -> None:
self.marker_label.setEnabled(kind in LABELLED_TYPES)
self.retarget.setEnabled(kind in JUMP_TYPES)
def _add_marker(self) -> None:
kind = self.marker_type.currentData()
label = self.marker_label.text().strip() or None
marker = Marker(type=kind, label=label if kind in LABELLED_TYPES else None)
self._slot_markers().append(marker)
self.marker_label.clear()
self.view.redraw()
self._touched()
if marker.is_jump:
# A jump is useless without a target, so ask for it immediately
# rather than leaving it to be noticed at export.
self._pick_target()
def _remove_marker(self) -> None:
row = self.marker_list.currentRow()
markers = self._slot_markers()
if 0 <= row < len(markers):
markers.pop(row)
self.view.redraw()
self._touched()
def _pick_target(self) -> None:
"""Arm click-to-pick for the selected jump marker."""
markers = self._slot_markers()
row = self.marker_list.currentRow()
candidates = [i for i, m in enumerate(markers) if m.is_jump]
if not candidates:
return
self._targeting = row if row in candidates else candidates[-1]
self.view.picking = True
self.view.setCursor(Qt.CrossCursor)
self.statusBar().showMessage(
"Click the slice this jump goes to — any page, PageUp/PageDown to move"
)
def _target_picked(self, page: int, slot: int) -> None:
markers = self._slot_markers()
if 0 <= self._targeting < len(markers):
markers[self._targeting].destination = (page, slot)
self.view.redraw()
self._touched()
self.statusBar().showMessage(f"target set to p{page + 1} slice {slot + 1}", 2000)
def _sync_markers(self) -> None:
self.marker_list.clear()
for marker in self._slot_markers():
self.marker_list.addItem(marker.describe())
# -- re-engraving -----------------------------------------------------
def _open_engrave(self) -> None:
"""Open the engrave window on the selected slice, showing its pixels."""
from .engrave import EngraveWindow
from .render import cut_slice, slice_mask
slot = self.view.selected_slice
page = self._preview(self.index)
original = cut_slice(page, slice_mask(self.project, self.index, slot, page.shape))
if original is None:
self.statusBar().showMessage("this slice has no ink to replace", 3000)
return
window = EngraveWindow(self.project, self.index, slot, original, self)
window.finished.connect(lambda _: (self.view.redraw(), self._touched()))
window.show()
def _sync_replacement(self) -> None:
if self.ly_status is None:
return
replacement = self.project.pages[self.index].replacements[self.view.selected_slice]
if replacement is None:
self.ly_status.setText("scanned — not re-engraved")
else:
voices = len(replacement.voices)
self.ly_status.setText(f"re-engraved · {voices} voice{'s' if voices != 1 else ''}")
def _metadata_changed(self) -> None:
self.project.metadata = {
field: edit.text().strip() for field, edit in self.metadata.items() if edit.text().strip()
}
self.autosave.start()
def _optimise_pdf(self) -> None:
"""Offer to shrink the archived PDF, showing the result before agreeing.
A before/after crop rather than a checkbox: the failure this can produce
— broken staff lines on a coarse scan — is obvious at a glance and
invisible in a byte count.
"""
import pymupdf
from .pdfopt import Report, optimise, preview
self.statusBar().showMessage("examining the PDF…")
QApplication.processEvents()
source = self.project.source
data, report = optimise(pymupdf.open(source), source.stat().st_size)
self.statusBar().clearMessage()
if not data:
QMessageBox.information(self, "Nothing to shrink", report.summary())
self.project.optimise_pdf = False
return
dialog = QDialog(self)
dialog.setWindowTitle("Shrink the original PDF")
layout = QVBoxLayout(dialog)
text = QLabel(report.summary() + "\n\nThe slices are unaffected — only the archived PDF.")
text.setWordWrap(True)
layout.addWidget(text)
crop = preview(pymupdf.open(source), pymupdf.open(stream=data, filetype="pdf"))
crop = np.ascontiguousarray(crop)
h, w, _ = crop.shape
image = QImage(crop.data, w, h, w * 3, QImage.Format_BGR888).copy()
label = QLabel()
label.setPixmap(QPixmap.fromImage(image))
area = QScrollArea()
area.setWidget(label)
area.setWidgetResizable(True)
area.setMinimumHeight(420)
layout.addWidget(area)
layout.addWidget(QLabel("Original above, shrunk below. Check the staff lines."))
buttons = QHBoxLayout()
use = QPushButton("Use the smaller PDF")
use.clicked.connect(dialog.accept)
keep = QPushButton("Keep the original")
keep.clicked.connect(dialog.reject)
buttons.addWidget(use)
buttons.addWidget(keep)
layout.addLayout(buttons)
dialog.resize(1100, 700)
self.project.optimise_pdf = dialog.exec() == QDialog.Accepted
self._touched()
self.statusBar().showMessage(
"the bundle will carry the shrunk PDF"
if self.project.optimise_pdf
else "the bundle will carry the original PDF",
4000,
)
def _reset_rect(self) -> 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()
def _save(self) -> None:
path = self.project.save()
self.statusBar().showMessage(f"saved {path.name}", 2000)
def _export(self) -> None:
self._save()
if not self.project.metadata.get("title", "").strip():
QMessageBox.warning(
self, "Title required", "A song needs a title before it can be exported."
)
self.metadata["title"].setFocus()
return
target, _ = QFileDialog.getSaveFileName(
self,
"Export bundle",
str(self.source.path.with_name(bundle.filename(self.project))),
"Bundle (*.zip)",
)
if not target:
return
try:
out = bundle.write(self.project, self.source, Path(target))
except Exception as error: # noqa: BLE001 - surfaced to the user
QMessageBox.critical(self, "Export failed", str(error))
return
size = out.stat().st_size / 1024
QMessageBox.information(
self,
"Exported",
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:
self._save()
super().closeEvent(event)
def launch(pdf: Path, source_type=None, resume: bool = False) -> int:
app = QApplication(sys.argv[:1])
app.setStyleSheet(ui.STYLESHEET)
source = open_source(pdf, source_type)
# 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)
window.show()
return app.exec()