Add markers: placement, labels and click-to-pick jump targets

Markers are stored per (page, slot), parallel to the discard flags, so
adding or removing a cut keeps them aligned with their slices. On a
split they stay with the upper half: a marker sits on a printed symbol
and nothing can say which side that symbol landed on, so predictable
beats clever.

Jump targets are chosen by clicking the slice rather than from the
thumbnail strip the plan called for. Less code, and it reads the score
instead of a list of thumbnails - which is what you want when hunting
for the Coda sign. Any page; PageUp/PageDown while picking.

Export resolves (page, slot) to the bundle's array index, the only
cross-reference the format has. A jump whose target was discarded or
re-cut away is dropped rather than exported dangling, since noteman
would have nothing to resolve it to.

tests/test_markers.py covers the enum size - that is the coupling
between two repos - along with cut-edit alignment, index resolution,
the dangling-target drop, and round-trips through both the project file
and a real bundle.

Closes #28
Closes #29
Closes #30
This commit is contained in:
Esa Kataja
2026-07-29 00:05:48 +03:00
parent 15f64e4131
commit ff1cc6740e
4 changed files with 357 additions and 4 deletions
+29 -1
View File
@@ -39,7 +39,35 @@ def song_json(project: Project, files: list[str]) -> dict:
value = project.metadata.get(field) value = project.metadata.get(field)
if value: if value:
payload[field] = value payload[field] = value
payload["slices"] = [{"file": name} for name in files]
kept = project.kept_slices()
# Markers reference slices by (page, slot) while editing, because that is
# what survives adding and removing cuts. In the bundle they become the
# array index, which is the only cross-reference the format has.
index_of = {position: i for i, position in enumerate(kept)}
slices: list[dict] = []
for name, (page, slot) in zip(files, kept):
entry: dict = {"file": name}
markers = []
for marker in project.pages[page].markers[slot]:
item: dict = {"type": marker.type}
if marker.label:
item["label"] = marker.label
if marker.destination is not None:
target = index_of.get(tuple(marker.destination))
# A jump whose target was discarded or re-cut away is dropped
# rather than exported dangling: noteman would have nothing to
# resolve it to.
if target is None:
continue
item["destination"] = target
markers.append(item)
if markers:
entry["markers"] = markers
slices.append(entry)
payload["slices"] = slices
return payload return payload
+126 -2
View File
@@ -32,10 +32,12 @@ from PySide6.QtWidgets import (
QFormLayout, QFormLayout,
QGraphicsScene, QGraphicsScene,
QGraphicsView, QGraphicsView,
QComboBox,
QGroupBox, QGroupBox,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
QLineEdit, QLineEdit,
QListWidget,
QMainWindow, QMainWindow,
QMessageBox, QMessageBox,
QPushButton, QPushButton,
@@ -48,7 +50,15 @@ from . import bundle
from .bundle import METADATA_FIELDS from .bundle import METADATA_FIELDS
from .detect import deskew, detect_page from .detect import deskew, detect_page
from .pdf import Source, open_source, page_raster from .pdf import Source, open_source, page_raster
from .project import Cut, Project, open_project from .project import (
JUMP_TYPES,
LABELLED_TYPES,
MARKER_TYPES,
Cut,
Marker,
Project,
open_project,
)
from .render import apply_levels from .render import apply_levels
PREVIEW_MAX = 1800 # display resolution; geometry stays normalised PREVIEW_MAX = 1800 # display resolution; geometry stays normalised
@@ -60,6 +70,7 @@ _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)
_RECT = QColor(40, 140, 220) _RECT = QColor(40, 140, 220)
_MARKER = QColor(150, 60, 190)
class PageView(QGraphicsView): class PageView(QGraphicsView):
@@ -67,6 +78,7 @@ class PageView(QGraphicsView):
changed = Signal() changed = Signal()
selection_changed = Signal() selection_changed = Signal()
picked = Signal(int, int) # page, slot — a jump target chosen by clicking
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
@@ -80,6 +92,7 @@ class PageView(QGraphicsView):
self.pixmap: QPixmap | None = None self.pixmap: QPixmap | None = None
self.selected_cut: int | None = None self.selected_cut: int | None = None
self.selected_slice = 0 self.selected_slice = 0
self.picking = False
self._drag: tuple[str, int, int] | None = None self._drag: tuple[str, int, int] | None = None
# -- state ------------------------------------------------------------ # -- state ------------------------------------------------------------
@@ -124,6 +137,17 @@ class PageView(QGraphicsView):
pen.setCosmetic(True) pen.setCosmetic(True)
scene.addRect(QRectF(x0 * w, y0 * h, (x1 - x0) * w, (y1 - y0) * h), pen) 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)
for i, cut in enumerate(self.page.cuts): for i, cut in enumerate(self.page.cuts):
colour = _CUT_ACTIVE if i == self.selected_cut else _CUT colour = _CUT_ACTIVE if i == self.selected_cut else _CUT
pen = QPen(colour, 2) pen = QPen(colour, 2)
@@ -189,6 +213,15 @@ class PageView(QGraphicsView):
return super().mousePressEvent(event) return super().mousePressEvent(event)
x, y = self._norm(event.position().toPoint()) 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: if event.button() == Qt.RightButton:
hit = self._hit_cut(x, y) hit = self._hit_cut(x, y)
if hit: if hit:
@@ -299,9 +332,11 @@ class Editor(QMainWindow):
self._raw: dict[int, np.ndarray] = {} self._raw: dict[int, np.ndarray] = {}
self.setWindowTitle(f"noteman-slicer — {source.path.name}") self.setWindowTitle(f"noteman-slicer — {source.path.name}")
self._targeting = 0
self.view = PageView() self.view = PageView()
self.view.changed.connect(self._touched) self.view.changed.connect(self._touched)
self.view.selection_changed.connect(self._sync) self.view.selection_changed.connect(self._sync)
self.view.picked.connect(self._target_picked)
self.autosave = QTimer(self) self.autosave = QTimer(self)
self.autosave.setSingleShot(True) self.autosave.setSingleShot(True)
@@ -362,6 +397,36 @@ class Editor(QMainWindow):
form.addRow(reset) form.addRow(reset)
box.addWidget(page_box) box.addWidget(page_box)
marker_box = QGroupBox("Markers on this slice")
marker_layout = QVBoxLayout(marker_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)
box.addWidget(marker_box)
self._marker_type_changed(self.marker_type.currentText())
meta_box = QGroupBox("Song") meta_box = QGroupBox("Song")
meta_form = QFormLayout(meta_box) meta_form = QFormLayout(meta_box)
self.metadata: dict[str, QLineEdit] = {} self.metadata: dict[str, QLineEdit] = {}
@@ -386,7 +451,8 @@ class Editor(QMainWindow):
"Drag: move cut · Ctrl-click: add vertex\n" "Drag: move cut · Ctrl-click: add vertex\n"
"Right-click: delete cut or vertex\n" "Right-click: delete cut or vertex\n"
"Click a slice, then D to discard\n" "Click a slice, then D to discard\n"
"Drag the blue edges: content rectangle" "Drag the blue edges: content rectangle\n"
"Jump markers: add, then click the target slice"
) )
help_text.setStyleSheet("color: palette(mid);") help_text.setStyleSheet("color: palette(mid);")
box.addWidget(help_text) box.addWidget(help_text)
@@ -444,6 +510,7 @@ class Editor(QMainWindow):
widget.blockSignals(True) widget.blockSignals(True)
widget.setValue(value) widget.setValue(value)
widget.blockSignals(False) widget.blockSignals(False)
self._sync_markers()
kept = len(self.project.kept_slices()) kept = len(self.project.kept_slices())
total = sum(p.slice_count for p in self.project.pages) total = sum(p.slice_count for p in self.project.pages)
state = "discarded" if page.discards[self.view.selected_slice] else "kept" state = "discarded" if page.discards[self.view.selected_slice] else "kept"
@@ -469,6 +536,63 @@ class Editor(QMainWindow):
self.view.show_page(self.project, self.index, self._preview(self.index)) self.view.show_page(self.project, self.index, self._preview(self.index))
self._touched() 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())
def _metadata_changed(self) -> None: def _metadata_changed(self) -> None:
self.project.metadata = { self.project.metadata = {
field: edit.text().strip() for field, edit in self.metadata.items() if edit.text().strip() field: edit.text().strip() for field, edit in self.metadata.items() if edit.text().strip()
+88 -1
View File
@@ -67,6 +67,59 @@ class Cut:
return min(y for _, y in self.points) return min(y for _, y in self.points)
# noteman's enum, verbatim. Real coupling between two repos: adding a type
# means changing both. Order is the order they appear in the editor's picker.
MARKER_TYPES = (
"rehearsal_letter",
"section_label",
"segno",
"coda",
"fine",
"repeat_start",
"repeat_end",
"volta",
"to_coda",
"ds_al_coda",
"ds_al_fine",
"dc_al_coda",
"dc_al_fine",
"generic_jump",
)
# The types that carry free text.
LABELLED_TYPES = frozenset({"rehearsal_letter", "section_label", "volta"})
# The types that send the reader elsewhere. Every one stores its target
# explicitly rather than resolving by type at read time, so the bundle is
# self-describing and a score with two codas simply works.
JUMP_TYPES = frozenset(
{"to_coda", "ds_al_coda", "ds_al_fine", "dc_al_coda", "dc_al_fine", "generic_jump"}
)
@dataclass
class Marker:
"""A semantic tag on a slice, used by noteman's navigation."""
type: str
label: str | None = None
# (page, slot) of the target slice, for jump sources. Positional like the
# slices themselves; resolved to a bundle index at export.
destination: tuple[int, int] | None = None
@property
def is_jump(self) -> bool:
return self.type in JUMP_TYPES
def describe(self) -> str:
text = self.type
if self.label:
text += f"{self.label}"
if self.destination:
text += f" → p{self.destination[0] + 1}s{self.destination[1] + 1}"
return text
@dataclass @dataclass
class Page: class Page:
"""One page's decisions. `cuts` are ordered top to bottom.""" """One page's decisions. `cuts` are ordered top to bottom."""
@@ -74,6 +127,8 @@ class Page:
skew: float = 0.0 skew: float = 0.0
cuts: list[Cut] = field(default_factory=list) cuts: list[Cut] = field(default_factory=list)
discards: list[bool] = field(default_factory=lambda: [False]) discards: list[bool] = field(default_factory=lambda: [False])
# One list per slice, parallel to `discards`.
markers: list[list[Marker]] = field(default_factory=lambda: [[]])
content_rect: tuple[float, float, float, float] | None = None content_rect: tuple[float, float, float, float] | None = None
levels: tuple[int, int] | None = None levels: tuple[int, int] | None = None
@@ -92,8 +147,12 @@ class Page:
y = cut.points[0][1] y = cut.points[0][1]
index = sum(1 for c in self.cuts if c.points[0][1] < y) index = sum(1 for c in self.cuts if c.points[0][1] < y)
self.cuts.insert(index, cut) self.cuts.insert(index, cut)
# The split slice keeps its flag on both halves. # The split slice keeps its flag on both halves. Its markers stay with
# the upper half: a marker sits on a printed symbol, and splitting a
# slice cannot say which side that symbol landed on — leaving them put
# is at least predictable, and moving one is a click.
self.discards.insert(index, self.discards[index]) self.discards.insert(index, self.discards[index])
self.markers.insert(index + 1, [])
return index return index
def remove_cut(self, index: int) -> None: def remove_cut(self, index: int) -> None:
@@ -102,6 +161,7 @@ class Page:
merged = self.discards[index] and self.discards[index + 1] merged = self.discards[index] and self.discards[index + 1]
self.discards.pop(index + 1) self.discards.pop(index + 1)
self.discards[index] = merged self.discards[index] = merged
self.markers[index].extend(self.markers.pop(index + 1))
@dataclass @dataclass
@@ -175,6 +235,7 @@ class Project:
skew=detection.skew, skew=detection.skew,
cuts=[Cut.straight(y / height) for y in ys], cuts=[Cut.straight(y / height) for y in ys],
discards=discards, discards=discards,
markers=[[] for _ in discards],
# Per page, not per song: scans drift, so the margin junk # Per page, not per song: scans drift, so the margin junk
# sits in a different place on each one. # sits in a different place on each one.
content_rect=detection.content, content_rect=detection.content,
@@ -198,6 +259,21 @@ class Project:
"skew": page.skew, "skew": page.skew,
"cuts": [[list(p) for p in cut.points] for cut in page.cuts], "cuts": [[list(p) for p in cut.points] for cut in page.cuts],
"discards": page.discards, "discards": page.discards,
"markers": [
[
{
"type": m.type,
**({"label": m.label} if m.label else {}),
**(
{"destination": list(m.destination)}
if m.destination
else {}
),
}
for m in slot
]
for slot in page.markers
],
"content_rect": list(page.content_rect) if page.content_rect else None, "content_rect": list(page.content_rect) if page.content_rect else None,
"levels": list(page.levels) if page.levels else None, "levels": list(page.levels) if page.levels else None,
} }
@@ -222,6 +298,17 @@ class Project:
skew=page["skew"], skew=page["skew"],
cuts=[Cut([tuple(p) for p in cut]) for cut in page["cuts"]], cuts=[Cut([tuple(p) for p in cut]) for cut in page["cuts"]],
discards=page["discards"], discards=page["discards"],
markers=[
[
Marker(
type=m["type"],
label=m.get("label"),
destination=tuple(m["destination"]) if m.get("destination") else None,
)
for m in slot
]
for slot in page.get("markers", [[] for _ in page["discards"]])
],
content_rect=tuple(page["content_rect"]) if page["content_rect"] else None, content_rect=tuple(page["content_rect"]) if page["content_rect"] else None,
levels=tuple(page["levels"]) if page["levels"] else None, levels=tuple(page["levels"]) if page["levels"] else None,
) )
+114
View File
@@ -0,0 +1,114 @@
"""Runnable check for markers: model, cut edits, and export resolution."""
from __future__ import annotations
import json
import sys
import zipfile
from pathlib import Path
import numpy as np
import pymupdf
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from noteman_slicer import bundle # noqa: E402
from noteman_slicer.bundle import song_json # noqa: E402
from noteman_slicer.detect import detect_page # noqa: E402
from noteman_slicer.pdf import open_source, page_raster # noqa: E402
from noteman_slicer.project import ( # noqa: E402
JUMP_TYPES,
MARKER_TYPES,
Cut,
Marker,
Project,
default_path,
)
W, H = 1200, 1600
def _scan_pdf(path: Path) -> None:
art = np.full((H, W), 255, np.uint8)
for top in (300, 800):
art[top : top + 200, 100:104] = 0
for staff in (top, top + 140):
for i in range(5):
art[staff + i * 15 : staff + i * 15 + 2, 110:1100] = 0
pix = pymupdf.Pixmap(pymupdf.csGRAY, W, H, bytearray(art.tobytes()), False)
doc = pymupdf.open()
doc.new_page(width=595, height=842).insert_image(pymupdf.Rect(0, 0, 595, 842), pixmap=pix)
doc.save(path)
def main() -> int:
tmp = Path(__file__).with_name("_tmp")
tmp.mkdir(exist_ok=True)
pdf = tmp / "scan.pdf"
_scan_pdf(pdf)
# noteman's enum, verbatim — this is real coupling between two repos.
assert len(MARKER_TYPES) == 14, MARKER_TYPES
assert len(JUMP_TYPES) == 6
assert "generic_jump" in JUMP_TYPES and "segno" not in JUMP_TYPES
source = open_source(pdf)
gray = page_raster(source, 0)
project = Project.from_detection(pdf, [detect_page(gray)], [gray.shape[0]])
page = project.pages[0]
assert len(page.markers) == page.slice_count
kept = project.kept_slices()
assert len(kept) == 2, kept
(_, first), (_, second) = kept
page.markers[first].append(Marker("rehearsal_letter", label="A"))
page.markers[second].append(Marker("coda"))
page.markers[first].append(Marker("to_coda", destination=(0, second)))
# Cut edits keep markers aligned with their slices.
before = list(page.markers[first])
index = page.add_cut(Cut.straight(0.95))
assert len(page.markers) == page.slice_count
assert page.markers[first] == before, "markers must not move when a later slice splits"
page.remove_cut(index)
assert len(page.markers) == page.slice_count
# Export resolves (page, slot) to the slice's index in the bundle.
names = [f"{i + 1:03}.webp" for i in range(len(project.kept_slices()))]
payload = song_json(project, names)
slices = payload["slices"]
assert [s["file"] for s in slices] == names
assert slices[0]["markers"][0] == {"type": "rehearsal_letter", "label": "A"}
assert slices[1]["markers"][0] == {"type": "coda"}
assert slices[0]["markers"][1] == {"type": "to_coda", "destination": 1}
# A jump whose target got discarded is dropped, not exported dangling.
project.pages[0].discards[second] = True
dropped = song_json(project, ["001.webp"])
assert all(m["type"] != "to_coda" for m in dropped["slices"][0].get("markers", []))
project.pages[0].discards[second] = False
# Round-trip through the project file.
saved = project.save()
reloaded = Project.load(saved)
assert reloaded.pages[0].markers[first][0].label == "A"
assert reloaded.pages[0].markers[first][1].destination == (0, second)
assert reloaded.pages[0].markers[second][0].type == "coda"
# And through a real bundle.
out = bundle.write(reloaded, source, tmp / "song.zip")
with zipfile.ZipFile(out) as zf:
meta = json.loads(zf.read("song.json"))
assert meta["slices"][0]["markers"][1]["destination"] == 1, meta["slices"]
source.close()
for f in (pdf, out, saved, default_path(pdf)):
f.unlink(missing_ok=True)
tmp.rmdir()
print("ok")
return 0
if __name__ == "__main__":
sys.exit(main())