A system can now be given the measure it starts at, in a First bar field beside key and time. Per slice, since it is the one thing about a replacement that cannot be inherited from the song. Bar numbering is a Score property, so it is set once on the first staff, and made visible only at a line beginning — that vector is fussy: #(#f #t #t) also prints a number mid-system and #(#f #t #f) prints the second bar's rather than the first's. It travels in the bundle's engraving object as `bar`. Two things LilyPond 2.24 was quietly refusing to draw: `\bar ":|"` and the other old repeat names produce nothing at all — no error, no warning, exit status 0, just a missing repeat that you find on the tablet. Every book and forum answer still uses them, so translate them to the modern spellings. `\clef treble_8` unquoted is not an octavated clef either. It parses as a plain treble plus a stray "8" markup that lands under the first note, and the staff then reads an octave off — a tenor line engraved at soprano pitch. Quote it. The source pane, which is where either of those would have been visible, is now a collapsed section at the bottom rather than a permanent slab. It uses the panel's own disclosure helper, lifted out of Editor so both can call it.
910 lines
34 KiB
Python
910 lines
34 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,
|
|
QSlider,
|
|
QSplitter,
|
|
QToolButton,
|
|
QVBoxLayout,
|
|
QWidget,
|
|
)
|
|
|
|
from . import bundle, lilypond
|
|
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.
|
|
"""
|
|
header = QToolButton()
|
|
header.setText(title)
|
|
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)
|
|
# Bold and greyed: a mid grey reads as a heading against both light and
|
|
# dark palettes without needing a second stylesheet.
|
|
header.setStyleSheet(
|
|
"QToolButton {"
|
|
" border: none;"
|
|
" font-weight: 700;"
|
|
" color: #808080;"
|
|
" padding: 7px 0 4px 0;"
|
|
" text-align: left;"
|
|
"}"
|
|
"QToolButton:hover { color: #a0a0a0; }"
|
|
)
|
|
|
|
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)
|
|
panel = QScrollArea()
|
|
panel.setWidget(inner)
|
|
panel.setWidgetResizable(True)
|
|
panel.setMinimumWidth(260)
|
|
|
|
nav = QHBoxLayout()
|
|
self.page_label = QLabel()
|
|
prev, nxt = QPushButton("◀"), QPushButton("▶")
|
|
prev.clicked.connect(lambda: self._load_page(self.index - 1))
|
|
nxt.clicked.connect(lambda: self._load_page(self.index + 1))
|
|
nav.addWidget(prev)
|
|
nav.addWidget(self.page_label, 1)
|
|
nav.addWidget(nxt)
|
|
box.addLayout(nav)
|
|
|
|
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.black = QSlider(Qt.Horizontal)
|
|
self.black.setRange(0, 255)
|
|
self.white = QSlider(Qt.Horizontal)
|
|
self.white.setRange(0, 255)
|
|
self.white.setValue(255)
|
|
for s in (self.black, self.white):
|
|
s.valueChanged.connect(self._levels_changed)
|
|
form.addRow("Black point", self.black)
|
|
form.addRow("White point", self.white)
|
|
|
|
discard = QPushButton("Toggle discard (D)")
|
|
discard.clicked.connect(self.view.toggle_discard)
|
|
form.addRow(discard)
|
|
reset = QPushButton("Reset content rectangle")
|
|
reset.setToolTip("Back to the rectangle detection proposed for this page")
|
|
reset.clicked.connect(self._reset_rect)
|
|
form.addRow(reset)
|
|
|
|
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()
|
|
self.marker_type.addItems(MARKER_TYPES)
|
|
self.marker_type.currentTextChanged.connect(self._marker_type_changed)
|
|
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.currentText())
|
|
|
|
# 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.setStyleSheet("color: #808080;")
|
|
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.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)
|
|
|
|
self.summary = QLabel()
|
|
self.summary.setWordWrap(True)
|
|
box.addWidget(self.summary)
|
|
|
|
export = QPushButton("Export bundle…")
|
|
export.clicked.connect(self._export)
|
|
box.addWidget(export)
|
|
box.addStretch(1)
|
|
|
|
help_text = QLabel(
|
|
"Double-click: add cut\n"
|
|
"Drag: move cut · Ctrl-click: add vertex\n"
|
|
"Right-click: delete cut or vertex\n"
|
|
"Click a slice, then D to discard\n"
|
|
"Drag the blue edges: content rectangle\n"
|
|
"Jump markers: add, then click the target slice\n"
|
|
"Shift-double-click a slice: re-engrave it"
|
|
)
|
|
help_text.setStyleSheet("color: palette(mid);")
|
|
box.addWidget(help_text)
|
|
return panel
|
|
|
|
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))
|
|
self._sync()
|
|
|
|
def _sync(self) -> None:
|
|
page = self.project.pages[self.index]
|
|
self.page_label.setText(f"Page {self.index + 1} / {len(self.project.pages)}")
|
|
for widget, value in ((self.skew, page.skew),):
|
|
widget.blockSignals(True)
|
|
widget.setValue(value)
|
|
widget.blockSignals(False)
|
|
black, white = self.project.page_levels(self.index)
|
|
for widget, value in ((self.black, black), (self.white, white)):
|
|
widget.blockSignals(True)
|
|
widget.setValue(value)
|
|
widget.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.slice_count} slices on this page · slice "
|
|
f"{self.view.selected_slice + 1} is {state}\n"
|
|
f"{kept} of {total} slices kept in the song"
|
|
)
|
|
|
|
# -- edits ------------------------------------------------------------
|
|
|
|
def _touched(self) -> None:
|
|
self._sync()
|
|
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) -> None:
|
|
self.project.pages[self.index].levels = (self.black.value(), self.white.value())
|
|
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.currentText()
|
|
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])
|
|
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()
|