diff --git a/noteman_slicer/bundle.py b/noteman_slicer/bundle.py index fd20223..ee6289f 100644 --- a/noteman_slicer/bundle.py +++ b/noteman_slicer/bundle.py @@ -39,7 +39,35 @@ def song_json(project: Project, files: list[str]) -> dict: value = project.metadata.get(field) if 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 diff --git a/noteman_slicer/editor.py b/noteman_slicer/editor.py index 7fa3881..972f786 100644 --- a/noteman_slicer/editor.py +++ b/noteman_slicer/editor.py @@ -32,10 +32,12 @@ from PySide6.QtWidgets import ( QFormLayout, QGraphicsScene, QGraphicsView, + QComboBox, QGroupBox, QHBoxLayout, QLabel, QLineEdit, + QListWidget, QMainWindow, QMessageBox, QPushButton, @@ -48,7 +50,15 @@ from . import bundle from .bundle import METADATA_FIELDS from .detect import deskew, detect_page 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 PREVIEW_MAX = 1800 # display resolution; geometry stays normalised @@ -60,6 +70,7 @@ _CUT_ACTIVE = QColor(255, 120, 0) _VERTEX = QColor(255, 200, 0) _DISCARD = QColor(120, 120, 140, 90) _RECT = QColor(40, 140, 220) +_MARKER = QColor(150, 60, 190) class PageView(QGraphicsView): @@ -67,6 +78,7 @@ class PageView(QGraphicsView): changed = Signal() selection_changed = Signal() + picked = Signal(int, int) # page, slot — a jump target chosen by clicking def __init__(self) -> None: super().__init__() @@ -80,6 +92,7 @@ class PageView(QGraphicsView): 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 # -- state ------------------------------------------------------------ @@ -124,6 +137,17 @@ class PageView(QGraphicsView): 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): + 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): colour = _CUT_ACTIVE if i == self.selected_cut else _CUT pen = QPen(colour, 2) @@ -189,6 +213,15 @@ class PageView(QGraphicsView): 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: @@ -299,9 +332,11 @@ class Editor(QMainWindow): 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.autosave = QTimer(self) self.autosave.setSingleShot(True) @@ -362,6 +397,36 @@ class Editor(QMainWindow): form.addRow(reset) 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_form = QFormLayout(meta_box) self.metadata: dict[str, QLineEdit] = {} @@ -386,7 +451,8 @@ class Editor(QMainWindow): "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" + "Drag the blue edges: content rectangle\n" + "Jump markers: add, then click the target slice" ) help_text.setStyleSheet("color: palette(mid);") box.addWidget(help_text) @@ -444,6 +510,7 @@ class Editor(QMainWindow): widget.blockSignals(True) widget.setValue(value) widget.blockSignals(False) + self._sync_markers() 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" @@ -469,6 +536,63 @@ class Editor(QMainWindow): 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()) + def _metadata_changed(self) -> None: self.project.metadata = { field: edit.text().strip() for field, edit in self.metadata.items() if edit.text().strip() diff --git a/noteman_slicer/project.py b/noteman_slicer/project.py index 7acbc6c..69cf698 100644 --- a/noteman_slicer/project.py +++ b/noteman_slicer/project.py @@ -67,6 +67,59 @@ class Cut: 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 class Page: """One page's decisions. `cuts` are ordered top to bottom.""" @@ -74,6 +127,8 @@ class Page: skew: float = 0.0 cuts: list[Cut] = field(default_factory=list) 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 levels: tuple[int, int] | None = None @@ -92,8 +147,12 @@ class Page: y = cut.points[0][1] index = sum(1 for c in self.cuts if c.points[0][1] < y) 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.markers.insert(index + 1, []) return index def remove_cut(self, index: int) -> None: @@ -102,6 +161,7 @@ class Page: merged = self.discards[index] and self.discards[index + 1] self.discards.pop(index + 1) self.discards[index] = merged + self.markers[index].extend(self.markers.pop(index + 1)) @dataclass @@ -175,6 +235,7 @@ class Project: skew=detection.skew, cuts=[Cut.straight(y / height) for y in ys], discards=discards, + markers=[[] for _ in discards], # Per page, not per song: scans drift, so the margin junk # sits in a different place on each one. content_rect=detection.content, @@ -198,6 +259,21 @@ class Project: "skew": page.skew, "cuts": [[list(p) for p in cut.points] for cut in page.cuts], "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, "levels": list(page.levels) if page.levels else None, } @@ -222,6 +298,17 @@ class Project: skew=page["skew"], cuts=[Cut([tuple(p) for p in cut]) for cut in page["cuts"]], 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, levels=tuple(page["levels"]) if page["levels"] else None, ) diff --git a/tests/test_markers.py b/tests/test_markers.py new file mode 100644 index 0000000..e397866 --- /dev/null +++ b/tests/test_markers.py @@ -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())