Make a bundle reopenable, and number slices

A bundle was a one-way trip. The slice images are output and the cuts
that produced them lived only in the producer's own project file, so a
bundle someone handed you meant cutting the score again from scratch.

The manifest now carries the geometry, in a `source` block: per page the
cut polylines, skew, levels and content rectangle, all in normalised
coordinates so they survive any render resolution, and per slice the page
and slot it came from. Discards are stated by omission — a slot no slice
claims was discarded — since shipping a discarded slice's image would
defeat discarding it.

`noteman-slicer open song.zip` unpacks the archived PDF, rebuilds the
project from that geometry, restores markers, engravings and the title
block, and opens the editor. Jump destinations go back from an array
index to the (page, slot) the editor works in. The images in the zip are
discarded: the PDF is what the pipeline renders from. Re-exporting a
reopened bundle reproduces its manifest exactly. It refuses to overwrite
a PDF or project file already sitting there, because the obvious place to
unpack is where someone's unfinished cuts live.

Separately, every slice can now carry the measure it starts at, not just
a re-engraved one — a scanned system is numbered in the score the same
way, and noteman wants to answer "take it from bar 33" about either. It
moves off the replacement onto the page, alongside markers and discards,
and out of the bundle's engraving object onto the slice.
This commit is contained in:
Esa Kataja
2026-07-29 14:30:54 +03:00
parent 8f670cf7db
commit 61b8ec8301
12 changed files with 411 additions and 35 deletions
+154 -2
View File
@@ -69,7 +69,6 @@ def _engraving(project: Project, page: int, slot: int) -> dict | None:
"key": replacement.key or project.key,
"time": replacement.time or project.time,
"print_time": replacement.print_time,
**({"bar": replacement.bar} if replacement.bar else {}),
"voices": [
{"clef": v.clef, "notes": v.notes.strip()}
| ({"lyrics": v.lyrics.strip()} if v.lyrics.strip() else {})
@@ -78,6 +77,33 @@ def _engraving(project: Project, page: int, slot: int) -> dict | None:
}
def _source(project: Project) -> dict:
"""How the slices were cut from the archived PDF.
Without this a bundle is a one-way trip: the images are output and the cuts
that made them live only in the producer's own project file, so reopening
someone else's bundle would mean cutting the score again from scratch. It
is geometry in normalised page coordinates, so it survives the PDF being
rendered at any resolution.
Only the pages are here. Which slot on which page a slice came from is on
the slice itself, so that one ordering — the slices array — stays the only
one, and a slot no slice claims is a slot that was discarded.
"""
return {
"file": "original.pdf",
"pages": [
{
"skew": round(page.skew, 2),
"content": [round(v, 5) for v in project.page_content_rect(i)],
"levels": list(project.page_levels(i)),
"cuts": [[[round(x, 5), round(y, 5)] for x, y in cut.points] for cut in page.cuts],
}
for i, page in enumerate(project.pages)
],
}
def song_json(project: Project, files: list[str]) -> dict:
payload: dict = {"v": FORMAT_VERSION}
for field in METADATA_FIELDS:
@@ -100,7 +126,10 @@ def song_json(project: Project, files: list[str]) -> dict:
slices: list[dict] = []
for name, (page, slot) in zip(files, kept):
entry: dict = {"file": name}
entry: dict = {"file": name, "page": page, "slot": slot}
bar = project.pages[page].bars[slot]
if bar:
entry["bar"] = bar
engraving = _engraving(project, page, slot)
if engraving:
entry["engraving"] = engraving
@@ -123,6 +152,8 @@ def song_json(project: Project, files: list[str]) -> dict:
slices.append(entry)
payload["slices"] = slices
if project.source.exists():
payload["source"] = _source(project)
return payload
@@ -170,3 +201,124 @@ def write(project: Project, source: Source, path: Path) -> Path:
project.exported = True
project.save()
return path
def read(path: Path, into: Path | None = None, *, force: bool = False) -> tuple[Project, Path]:
"""Unpack a bundle back into an editable project. Returns it and its PDF.
The archived PDF is written out beside the bundle and becomes the project's
source again, because the PDF is what the pipeline renders from — the slice
images in the zip are output, and are discarded rather than re-imported.
The cuts, skew, levels and content rectangles come from the manifest's
`source` block, so this is a real round trip rather than a re-detection
that happens to land nearby. A bundle written without one cannot give them
back, and that is refused rather than guessed at.
"""
from .project import Cut, Marker, Page, Project, Replacement, Voice, default_path, hash_file
path = Path(path)
with zipfile.ZipFile(path) as zf:
names = set(zf.namelist())
if "song.json" not in names:
raise ValueError(f"{path.name} is not a bundle: no song.json")
manifest = json.loads(zf.read("song.json"))
if manifest.get("v") != FORMAT_VERSION:
raise ValueError(f"unsupported bundle version {manifest.get('v')!r}")
geometry = manifest.get("source")
if not geometry:
raise ValueError(
f"{path.name} carries no cuts — it was written by a producer that "
"does not record them, so the score would have to be cut again"
)
pdf_name = geometry.get("file", "original.pdf")
if pdf_name not in names:
raise ValueError(f"{path.name} names {pdf_name} but does not contain it")
pdf_bytes = zf.read(pdf_name)
pages_json = geometry["pages"]
slices = manifest["slices"]
# Every slot on a page exists; the ones no slice claims were discarded.
# That is the one thing the bundle states by omission rather than directly,
# since shipping a discarded slice's image would defeat discarding it.
claimed = {(s["page"], s["slot"]): i for i, s in enumerate(slices)}
pages = []
for i, page in enumerate(pages_json):
cuts = [Cut([tuple(p) for p in cut]) for cut in page["cuts"]]
count = len(cuts) + 1
pages.append(
Page(
skew=page.get("skew", 0.0),
cuts=cuts,
discards=[(i, slot) not in claimed for slot in range(count)],
markers=[[] for _ in range(count)],
replacements=[None] * count,
bars=[None] * count,
content_rect=tuple(page["content"]) if page.get("content") else None,
levels=tuple(page["levels"]) if page.get("levels") else None,
)
)
position_of = {index: position for position, index in claimed.items()}
for entry in slices:
page, slot = entry["page"], entry["slot"]
pages[page].bars[slot] = entry.get("bar")
pages[page].markers[slot] = [
Marker(
type=marker["type"],
label=marker.get("label"),
# Back from an array index to the (page, slot) the editor works
# in — the inverse of what export does.
destination=position_of.get(marker.get("destination")),
)
for marker in entry.get("markers", [])
]
engraving = entry.get("engraving")
if engraving and engraving.get("lang") == "lilypond":
pages[page].replacements[slot] = Replacement(
voices=[
Voice(
clef=v.get("clef", "treble"),
notes=v.get("notes", ""),
lyrics=v.get("lyrics", ""),
)
for v in engraving.get("voices", [])
],
print_time=engraving.get("print_time", False),
)
# Unpacking writes two files. Refuse to land on either if it is already
# there: the obvious place to open a bundle is next to the score it came
# from, and that is exactly where someone's unfinished cuts live.
target = Path(into) if into else path.with_suffix(".pdf")
existing = [f for f in (target, default_path(target)) if f.exists()]
if existing and not force:
raise ValueError(
f"{', '.join(f.name for f in existing)} already exists — "
"open it with --pdf elsewhere, or --force to overwrite"
)
target.write_bytes(pdf_bytes)
metadata = {
field: str(manifest[field]) for field in METADATA_FIELDS if manifest.get(field) is not None
}
first = pages_json[0] if pages_json else {}
project = Project(
source=target,
source_hash=hash_file(target),
pages=pages,
content_rect=tuple(first.get("content", (0.0, 0.0, 1.0, 1.0))),
levels=tuple(first.get("levels", (0, 255))),
metadata=metadata,
path=default_path(target),
)
# Key and time are per slice in the bundle and per song here; the first
# engraving that states them is as good a song default as exists.
for entry in slices:
engraving = entry.get("engraving") or {}
if engraving.get("key"):
project.key = engraving["key"]
project.time = engraving.get("time", project.time)
break
return project, target
+31
View File
@@ -102,6 +102,26 @@ def _export(args: argparse.Namespace) -> int:
return 0
def _open(args: argparse.Namespace) -> int:
from . import bundle
from .editor import launch
try:
project, pdf = bundle.read(
Path(args.zip), Path(args.pdf) if args.pdf else None, force=args.force
)
except (ValueError, KeyError) as error:
print(f"cannot open: {error}")
return 1
saved = project.save()
print(f"{pdf.name}: {len(project.pages)} pages, {len(project.kept_slices())} slices")
print(f" project written to {saved.name}")
if args.no_edit:
return 0
return launch(pdf, resume=True)
def _edit(args: argparse.Namespace) -> int:
from .editor import launch
@@ -153,6 +173,17 @@ def main(argv: list[str] | None = None) -> int:
exp.add_argument("--type", choices=[t.value for t in SourceType])
exp.set_defaults(func=_export)
opn = sub.add_parser("open", help="unpack a bundle back into an editable project")
opn.add_argument("zip")
opn.add_argument("--pdf", help="where to write the archived PDF (default: beside the bundle)")
opn.add_argument(
"--no-edit", action="store_true", help="write the project and stop, without the editor"
)
opn.add_argument(
"--force", action="store_true", help="overwrite an existing PDF or project file"
)
opn.set_defaults(func=_open)
ed = sub.add_parser("edit", help="open the editor")
ed.add_argument("pdf")
ed.add_argument(
+23
View File
@@ -516,6 +516,20 @@ class Editor(QMainWindow):
reset.clicked.connect(self._reset_rect)
form.addRow(reset)
slice_layout = section("This slice", box)
slice_form = QFormLayout()
slice_layout.addLayout(slice_form)
# Every slice can carry one, engraved or scanned: a scanned system has
# a bar number printed on it just the same, and noteman wants to be
# able to say "from bar 33" about either.
self.bar = QLineEdit()
self.bar.setValidator(QIntValidator(1, 9999, self.bar))
self.bar.setFixedWidth(90)
self.bar.setPlaceholderText("none")
self.bar.setToolTip("The measure this slice starts at, as printed in the score")
self.bar.textChanged.connect(self._bar_changed)
slice_form.addRow("First bar", self.bar)
marker_layout = section("Markers on this slice", box)
self.marker_list = QListWidget()
self.marker_list.setMaximumHeight(110)
@@ -657,6 +671,10 @@ class Editor(QMainWindow):
widget.blockSignals(True)
widget.setValue(value)
widget.blockSignals(False)
bar = page.bars[self.view.selected_slice]
self.bar.blockSignals(True)
self.bar.setText("" if bar is None else str(bar))
self.bar.blockSignals(False)
self._sync_markers()
self._sync_replacement()
kept = len(self.project.kept_slices())
@@ -674,6 +692,11 @@ class Editor(QMainWindow):
self._sync()
self.autosave.start()
def _bar_changed(self, text: str) -> None:
page = self.project.pages[self.index]
page.bars[self.view.selected_slice] = int(text) if text.strip().isdigit() else None
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))
+16 -11
View File
@@ -96,9 +96,9 @@ class EngraveWindow(QDialog):
self.setWindowTitle(f"Re-engrave — page {page + 1}, slice {slot + 1}")
self.setModal(False)
state = project.pages[page]
self.replacement = state.replacements[slot] or self._seed()
state.replacements[slot] = self.replacement
self.state = project.pages[page]
self.replacement = self.state.replacements[slot] or self._seed()
self.state.replacements[slot] = self.replacement
rows = QSplitter(Qt.Vertical)
rows.addWidget(self._image_panel("Scanned", _pixmap(original)))
@@ -182,9 +182,11 @@ class EngraveWindow(QDialog):
row.addWidget(self.print_time, 1)
top.addRow("Time", row)
# Per slice, unlike key and time: which measure a system starts at is
# the one thing that changes with every slice and cannot be inherited.
self.bar = QLineEdit("" if self.replacement.bar is None else str(self.replacement.bar))
# A property of the slice, not of the replacement — the same field the
# main panel shows for a scanned slice — so it survives discarding the
# engraving. Unlike key and time it cannot be inherited from the song.
current = self.state.bars[self.slot]
self.bar = QLineEdit("" if current is None else str(current))
self.bar.setValidator(QIntValidator(1, 9999, self.bar))
self.bar.setFixedWidth(70)
self.bar.setPlaceholderText("none")
@@ -256,7 +258,7 @@ class EngraveWindow(QDialog):
self._refresh_source()
def _bar_changed(self, text: str) -> None:
self.replacement.bar = int(text) if text.strip().isdigit() else None
self.state.bars[self.slot] = int(text) if text.strip().isdigit() else None
self._refresh_source()
def _settings_changed(self) -> None:
@@ -273,15 +275,18 @@ class EngraveWindow(QDialog):
self.project.pages[self.page_index].replacements[self.slot] = None
self.accept()
def _refresh_source(self) -> None:
self.generated.setPlainText(
lilypond.generate(self.replacement, self.project.key, self.project.time)
def _source(self) -> str:
return lilypond.generate(
self.replacement, self.project.key, self.project.time, self.state.bars[self.slot]
)
def _refresh_source(self) -> None:
self.generated.setPlainText(self._source())
# -- rendering --------------------------------------------------------
def render(self) -> None:
source = lilypond.generate(self.replacement, self.project.key, self.project.time)
source = self._source()
self.status.setStyleSheet("color: #808080;")
self.status.setText("rendering…")
self.repaint()
+3 -3
View File
@@ -94,7 +94,7 @@ _PREAMBLE = """\\version "2.24.0"
"""
def generate(replacement, key: str, time: str) -> str:
def generate(replacement, key: str, time: str, bar: int | None = None) -> str:
"""Build LilyPond source from a slice's structured replacement.
The time signature is used for spacing and bar checks but not printed
@@ -111,9 +111,9 @@ def generate(replacement, key: str, time: str) -> str:
# printed score numbers its systems. The empty bar line is what gives the
# number a line beginning to attach to.
number = ""
if replacement.bar:
if bar:
number = (
f" \\set Score.currentBarNumber = #{int(replacement.bar)}\n"
f" \\set Score.currentBarNumber = #{int(bar)}\n"
" \\override Score.BarNumber.break-visibility = #'#(#f #f #t)\n"
' \\bar ""\n'
)
+23 -6
View File
@@ -147,10 +147,6 @@ class Replacement:
voices: list[Voice] = field(default_factory=list)
key: str | None = None
time: str | None = None
# The measure this system starts at, printed above its first bar the way a
# score numbers its systems. Per slice and nothing else: it is the one thing
# about a replacement that cannot be inherited or guessed.
bar: int | None = None
# The printed score repeats the key signature at every system but not the
# time signature, so a re-engraved middle slice must not show one.
print_time: bool = False
@@ -168,6 +164,11 @@ class Page:
# A re-engraved system per slice, when the scan is past saving. None for
# the ordinary case, which is nearly all of them.
replacements: list[Replacement | None] = field(default_factory=lambda: [None])
# The measure each slice starts at, when it is known. A property of the
# slice rather than of a replacement: a scanned system has a bar number
# printed on it just as an engraved one does, and noteman wants to say
# "from bar 33" about either.
bars: list[int | None] = field(default_factory=lambda: [None])
content_rect: tuple[float, float, float, float] | None = None
levels: tuple[int, int] | None = None
@@ -193,6 +194,10 @@ class Page:
self.discards.insert(index, self.discards[index])
self.markers.insert(index + 1, [])
self.replacements.insert(index + 1, None)
# The upper half keeps the number: it still starts where the slice did.
# What bar the new lower half starts at needs counting, which is the
# user's job.
self.bars.insert(index + 1, None)
return index
def remove_cut(self, index: int) -> None:
@@ -205,6 +210,8 @@ class Page:
# Two engraved halves cannot be merged, so the upper one wins.
below = self.replacements.pop(index + 1)
self.replacements[index] = self.replacements[index] or below
# The merged slice starts where the upper half did.
self.bars.pop(index + 1)
def remember_clefs(self, project: Project, slot: int) -> None:
"""Carry this slice's clefs forward as the song's defaults."""
@@ -296,6 +303,7 @@ class Project:
discards=discards,
markers=[[] for _ in discards],
replacements=[None] * len(discards),
bars=[None] * len(discards),
# Per page, not per song: scans drift, so the margin junk
# sits in a different place on each one.
content_rect=detection.content,
@@ -360,10 +368,10 @@ class Project:
**({"key": r.key} if r.key else {}),
**({"time": r.time} if r.time else {}),
**({"print_time": True} if r.print_time else {}),
**({"bar": r.bar} if r.bar else {}),
}
for r in page.replacements
],
"bars": page.bars,
"content_rect": list(page.content_rect) if page.content_rect else None,
"levels": list(page.levels) if page.levels else None,
}
@@ -417,10 +425,19 @@ class Project:
key=r.get("key"),
time=r.get("time"),
print_time=r.get("print_time", False),
bar=r.get("bar"),
)
for r in page.get("replacements", [None] * len(page["discards"]))
],
bars=page.get(
"bars",
# Before bar numbers were a property of the slice they lived
# on the replacement, so an engraved slice is where an older
# project keeps one.
[
r.get("bar") if isinstance(r, dict) else None
for r in page.get("replacements", [None] * len(page["discards"]))
],
),
content_rect=tuple(page["content_rect"]) if page["content_rect"] else None,
levels=tuple(page["levels"]) if page["levels"] else None,
)
+3 -1
View File
@@ -161,7 +161,9 @@ def render_slices(project: Project, source: Source) -> list[SliceImage]:
# flows through staff-height normalisation and the rest exactly
# as a scanned one does.
gray = lilypond.render(
lilypond.generate(engraved, project.key, project.time)
lilypond.generate(
engraved, project.key, project.time, page_state.bars[slot]
)
)
else:
if page is None: