Add LilyPond slice replacement with a structured engrave window
Re-engraving is a rescue path for the handful of systems a scan cannot deliver, so the window is an editing surface rather than an automation project. Three full-width rows - the scanned system, the render, the form - because a system is wide and short and the job is comparing one against the other bar by bar. The render is shown scaled to the scan's staff height, which is what export does anyway, so it previews the real thing. A form rather than a text box. Key and time are slice-level, clef, notes and lyrics per voice: every staff in a system carries the same key signature, and Kaipaava proves it across five-staff and two-staff systems alike. Notes and lyrics stay raw LilyPond, so slurs, dynamics, tuplets and the laissezVibrer/repeatTie idiom for ties crossing into the next slice all work untouched. Notes are entered in \relative mode, referenced to the middle of each clef's staff, so a part needs no octave marks at all in the common case. The time signature is used for spacing and bar checks but not printed: the printed score repeats the key at every system and the time only at the first, so a re-engraved middle slice showing one would stand out. Seeded from what can be known reliably. Voice count comes from counting staves in the slice; key, time and clefs are inherited from the song, because the slices being re-engraved are the illegible ones and reading a key signature off them is exactly the measurement that fails. After the first replacement in a song only the notes need typing. Staff counting needed two corrections against the corpus: compare gaps against line spacing rather than staff height, since adjacent staves can sit closer together than one staff is tall; and require five lines in a group, since Engel's 'uh______' lyric extenders are long horizontal runs too and each counted as a staff. Kaipaava now reads 2,2,2,2,5 on page 1, Ketun 6, Engel 4. Also in this change: - Title is required for export, every other metadata field optional, enforced in bundle.write so the CLI and the editor both get it. Tempo added; noteman already has a free-form column for it. - The panel is a splitter rather than a fixed width, sections collapse under bold grey disclosure headers, and it scrolls. - A re-engraved slice is washed amber with an ENGRAVED badge, and markers get badges too. Thin coloured text was invisible against a scan. Closes #31 Closes #32 Closes #33 Closes #34
This commit is contained in:
+197
-32
@@ -33,7 +33,6 @@ from PySide6.QtWidgets import (
|
||||
QGraphicsScene,
|
||||
QGraphicsView,
|
||||
QComboBox,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
@@ -41,12 +40,16 @@ from PySide6.QtWidgets import (
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QSlider,
|
||||
QSplitter,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from . import bundle
|
||||
from . import bundle, lilypond
|
||||
from .bundle import METADATA_FIELDS
|
||||
from .detect import deskew, detect_page
|
||||
from .pdf import Source, open_source, page_raster
|
||||
@@ -62,6 +65,7 @@ from .project import (
|
||||
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
|
||||
|
||||
@@ -71,6 +75,9 @@ _VERTEX = QColor(255, 200, 0)
|
||||
_DISCARD = QColor(120, 120, 140, 90)
|
||||
_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
|
||||
|
||||
|
||||
class PageView(QGraphicsView):
|
||||
@@ -79,6 +86,7 @@ class PageView(QGraphicsView):
|
||||
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__()
|
||||
@@ -138,15 +146,63 @@ class PageView(QGraphicsView):
|
||||
scene.addRect(QRectF(x0 * w, y0 * h, (x1 - x0) * w, (y1 - y0) * h), pen)
|
||||
|
||||
for slot in range(self.page.slice_count):
|
||||
markers = self.page.markers[slot]
|
||||
if not markers:
|
||||
continue
|
||||
above, _ = self.page.bounds(slot)
|
||||
top = 0 if above is None else int(above.lowest * h)
|
||||
text = scene.addText(" · ".join(m.describe() for m in markers))
|
||||
text.setDefaultTextColor(_MARKER)
|
||||
text.setScale(max(1.0, w / 900))
|
||||
text.setPos(w * 0.02, top + h * 0.004)
|
||||
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
|
||||
|
||||
for i, cut in enumerate(self.page.cuts):
|
||||
colour = _CUT_ACTIVE if i == self.selected_cut else _CUT
|
||||
@@ -304,10 +360,18 @@ class PageView(QGraphicsView):
|
||||
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 None:
|
||||
self.selected_cut = self.page.add_cut(Cut.straight(y))
|
||||
self.redraw()
|
||||
self.changed.emit()
|
||||
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 wheelEvent(self, event) -> None:
|
||||
factor = 1.15 if event.angleDelta().y() > 0 else 1 / 1.15
|
||||
@@ -337,26 +401,75 @@ class Editor(QMainWindow):
|
||||
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)
|
||||
|
||||
central = QWidget()
|
||||
layout = QHBoxLayout(central)
|
||||
layout.addWidget(self.view, 1)
|
||||
layout.addWidget(self._panel())
|
||||
self.setCentralWidget(central)
|
||||
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 _section(self, 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
|
||||
|
||||
def _panel(self) -> QWidget:
|
||||
panel = QWidget()
|
||||
panel.setFixedWidth(320)
|
||||
box = QVBoxLayout(panel)
|
||||
inner = QWidget()
|
||||
box = QVBoxLayout(inner)
|
||||
panel = QScrollArea()
|
||||
panel.setWidget(inner)
|
||||
panel.setWidgetResizable(True)
|
||||
panel.setMinimumWidth(260)
|
||||
|
||||
nav = QHBoxLayout()
|
||||
self.page_label = QLabel()
|
||||
@@ -368,8 +481,9 @@ class Editor(QMainWindow):
|
||||
nav.addWidget(nxt)
|
||||
box.addLayout(nav)
|
||||
|
||||
page_box = QGroupBox("Page")
|
||||
form = QFormLayout(page_box)
|
||||
page_section = self._section("Page", box)
|
||||
form = QFormLayout()
|
||||
page_section.addLayout(form)
|
||||
self.skew = QDoubleSpinBox()
|
||||
self.skew.setRange(-15.0, 15.0)
|
||||
self.skew.setSingleStep(0.1)
|
||||
@@ -395,10 +509,8 @@ class Editor(QMainWindow):
|
||||
reset.setToolTip("Back to the rectangle detection proposed for this page")
|
||||
reset.clicked.connect(self._reset_rect)
|
||||
form.addRow(reset)
|
||||
box.addWidget(page_box)
|
||||
|
||||
marker_box = QGroupBox("Markers on this slice")
|
||||
marker_layout = QVBoxLayout(marker_box)
|
||||
marker_layout = self._section("Markers on this slice", box)
|
||||
self.marker_list = QListWidget()
|
||||
self.marker_list.setMaximumHeight(110)
|
||||
marker_layout.addWidget(self.marker_list)
|
||||
@@ -424,18 +536,35 @@ class Editor(QMainWindow):
|
||||
for button in (add, remove, self.retarget):
|
||||
button_row.addWidget(button)
|
||||
marker_layout.addLayout(button_row)
|
||||
box.addWidget(marker_box)
|
||||
self._marker_type_changed(self.marker_type.currentText())
|
||||
|
||||
meta_box = QGroupBox("Song")
|
||||
meta_form = QFormLayout(meta_box)
|
||||
# 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 = self._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 = self._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
|
||||
meta_form.addRow(field.replace("_", " ").title(), edit)
|
||||
box.addWidget(meta_box)
|
||||
required = field == "title"
|
||||
if required:
|
||||
edit.setPlaceholderText("required")
|
||||
meta_form.addRow(f"{field.replace('_', ' ').title()}{' *' if required else ''}", edit)
|
||||
|
||||
self.summary = QLabel()
|
||||
self.summary.setWordWrap(True)
|
||||
@@ -452,7 +581,8 @@ class Editor(QMainWindow):
|
||||
"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"
|
||||
"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)
|
||||
@@ -511,6 +641,7 @@ class Editor(QMainWindow):
|
||||
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"
|
||||
@@ -593,6 +724,34 @@ class Editor(QMainWindow):
|
||||
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()
|
||||
@@ -618,6 +777,12 @@ class Editor(QMainWindow):
|
||||
|
||||
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_suffix(".zip")), "Bundle (*.zip)"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user