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
+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())