diff --git a/docs/bundle-format.md b/docs/bundle-format.md index 4ee874a..56de2db 100644 --- a/docs/bundle-format.md +++ b/docs/bundle-format.md @@ -95,6 +95,7 @@ which roughly 830 KB is the source PDF and the rest slice images at ~20 KB each. | `translator` | string | optional | Who translated the words. | | `tempo` | integer | optional | Beats per minute. | | `voices` | string | optional | The parts in this arrangement, as free text. | +| `source` | object | optional | How the slices were cut from the archived document. See [Source geometry](#source-geometry). | **Optional fields are omitted when they have no value.** A consumer will not encounter an empty string or a null in place of an absent field. @@ -112,6 +113,9 @@ Each entry of `slices` is an object: | Field | Type | | | |---|---|---|---| | `file` | string | required | Name of the image entry in the archive. | +| `page` | integer | optional | Index into `source.pages` — the page this slice was cut from. Present whenever `source` is. | +| `slot` | integer | optional | Which slice of that page this is, counting from 0 between its cuts. Present whenever `source` is. | +| `bar` | integer | optional | The measure this slice starts at, as numbered in the score. Omitted when unknown. | | `markers` | array | optional | Markers on this slice. Omitted when there are none. | | `engraving` | object | optional | The notation this slice's image was engraved from, when it was engraved rather than scanned. See [Engraving](#engraving). | @@ -122,6 +126,12 @@ must not derive order from them. A slice's **index** is its zero-based position in this array. Indices are the only identifiers the format has, and they are meaningful only within one bundle. +`bar` is the score's own numbering, not the format's: it says which measure this +system begins at, so a consumer can answer "take it from bar 33" by scrolling to +the right slice. It is independent of `index`, may be absent on any slice, and +carries no promise of being consecutive — a score numbers the systems it chooses +to, and pickup bars, repeats and voltas all break arithmetic on it. + ## Slice images Every slice image in a bundle satisfies the following. A consumer can rely on @@ -160,6 +170,64 @@ One specific hazard is worth naming, because it is silent: an image pipeline that *discards* the alpha channel rather than compositing it will turn every slice into a solid black rectangle, since the colour channels are all zero. +## Source geometry + +A bundle can say how its slices were cut, in a `source` object. With it a +consumer can reopen the score for editing; without it the bundle is a one-way +trip, since the slice images are output and the decisions that produced them +would live only in whatever tool made them. + +```json +"source": { + "file": "original.pdf", + "pages": [ + { + "skew": -0.4, + "content": [0.083, 0.0, 0.947, 1.0], + "levels": [46, 173], + "cuts": [ + [[0.0, 0.0449], [1.0, 0.0449]], + [[0.0, 0.3662], [0.35, 0.3662], [0.35, 0.3901], [1.0, 0.3901]] + ] + } + ] +} +``` + +| Field | Type | | | +|---|---|---|---| +| `file` | string | required | The archive entry the slices were cut from. `"original.pdf"` in practice. | +| `pages` | array | required | One entry per page of that document, in its own page order. | + +Each entry of `pages`: + +| Field | Type | | | +|---|---|---|---| +| `cuts` | array | required | The boundaries between slices, ordered top to bottom. May be empty: a page with no cuts is one slice. | +| `skew` | number | optional | Degrees the page was rotated by before cutting. Default `0`. | +| `content` | array | optional | `[x0, y0, x1, y1]` — the part of the page that is music. Default the whole page. | +| `levels` | array | optional | `[black, white]` — the black and white points applied. Default `[0, 255]`. | + +**Everything here is in normalised page coordinates**, `0.0` to `1.0` on each +axis, origin top-left. Nothing is in pixels, so the geometry holds however the +document is rendered and at whatever resolution. + +A **cut** is a polyline: a list of `[x, y]` points, left to right. Two points is +a straight cut; more steps around a system that interleaves with its neighbour — +a section label printed level with the previous system's lyrics. A slice's top +boundary is the cut above it and its bottom boundary the cut below it, with the +page edge standing in at either end. + +A page with *n* cuts therefore has *n + 1* **slots**, numbered from 0 downward. +Each slice names the `page` and `slot` it came from. **A slot that no slice +claims was discarded** — a page header, a footer, a title block. That is stated +by omission rather than directly, because shipping a discarded slice's image +would defeat discarding it. + +The archived document is the source of truth for reopening: the slice images are +output, and a consumer that reopens a bundle re-renders them rather than +importing them. + ## Engraving Most slices are photographs of print: an image and nothing more. A slice that @@ -169,12 +237,12 @@ it came from, in an `engraving` object. ```json { "file": "007.webp", + "bar": 33, "engraving": { "lang": "lilypond", "key": "aes", "time": "4/4", "print_time": false, - "bar": 33, "voices": [ { "clef": "treble", "notes": "c4 des ees f | ees2. r4", "lyrics": "Kai -- paa -- va sy -- dän" }, { "clef": "treble_8", "notes": "aes,4 aes aes aes | aes2. r4" }, @@ -191,7 +259,6 @@ it came from, in an `engraving` object. | `key` | string | optional | Key signature, in `lang`'s spelling. For `lilypond`, the tonic of the major spelling: `"aes"`, `"c"`, `"fis"`. | | `time` | string | optional | Time signature, as `"4/4"`. | | `print_time` | boolean | optional | Whether the time signature is printed on this system. Default `false`. | -| `bar` | integer | optional | The measure this system starts at, as printed above its first bar. Omitted when the system is not numbered. | Each entry of `voices`: @@ -288,9 +355,9 @@ ignore markers it does not understand rather than reject the bundle. `v` is an integer that increases when a change would break an existing consumer. Additions that a consumer can safely ignore — new optional fields, new marker -types, new `engraving` languages — do not increase it. `engraving` was added -this way: a bundle carrying one is still a version 1 bundle, and a consumer that -has never heard of it presents the score unchanged. +types, new `engraving` languages — do not increase it. `engraving` and `source` +were both added this way: a bundle carrying them is still a version 1 bundle, +and a consumer that has never heard of them presents the score unchanged. A consumer should refuse a bundle whose `v` it does not recognise rather than attempt to interpret it. @@ -305,6 +372,9 @@ A consumer is advised to check: - Every `destination` is within the bounds of `slices`. - Every `engraving` has a `lang` and a non-empty `voices`; unknown `lang` values are ignored rather than rejected. +- If `source` is present: its `file` names an entry in the archive, every slice + carries a `page` within `source.pages` and a `slot` within that page's slot + count, and no two slices claim the same one. - Archive entry names contain no path separators, no `..`, and no absolute paths, as with any archive from an untrusted source. @@ -314,6 +384,10 @@ A bundle describes one complete score. The format has no notion of updating a previously read bundle: there are no stable identifiers, and a slice's index is meaningful only within the bundle that contains it. +Reopening a bundle for editing, via [`source`](#source-geometry), does not change +that. What comes out is a new document that happens to have been derived from an +old one, not a revision of it. + Two bundles of the same piece are therefore independent documents, not versions of one. A consumer that stores imported bundles and assigns its own identifiers should treat a second bundle as a new score rather than merging it into an diff --git a/docs/guide.md b/docs/guide.md index 5c19595..14cb118 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -53,6 +53,14 @@ off. Page Up / Page Down move between pages. Levels carry over from the previous page, so a consistent scan only needs setting once. +## Bar numbers + +Under *This slice*, **First bar** is the measure that slice starts at, as the +score numbers it. Optional, and only worth filling in where the printed score +shows a number — that is what lets noteman answer "take it from bar 33". A +re-engraved slice prints the number above its first bar, exactly as the scanned +systems around it do. + ## Markers Markers are the navigation symbols noteman uses to jump around the score: @@ -93,6 +101,22 @@ Two things worth knowing: detection rather than resuming decisions that already shipped. If you really want the old cuts back, `noteman-slicer edit my-song.pdf --resume`. +## Reopening a bundle + +``` +noteman-slicer open my-song.zip +``` + +Unpacks the archived PDF beside the bundle, rebuilds the project from it — the +cuts, skew, levels, discards, markers, title block and any re-engraved systems — +and opens the editor on it. Everything you changed re-renders from the PDF; the +slice images in the zip are output and are thrown away. + +This works on any bundle, not just one you made: the cuts travel in `song.json`. +It refuses to overwrite a PDF or project file that is already there, since the +obvious place to unpack is exactly where someone's unfinished work lives — pass +`--pdf elsewhere.pdf` or `--force` if you mean it. + ## Re-engraving a slice (optional, needs LilyPond) When a system is beyond rescue — a bad scan, a wrong transposition, a passage @@ -142,6 +166,8 @@ noteman-slicer info my-song.pdf # source type and page rasters noteman-slicer detect my-song.pdf # detection results + debug overlays noteman-slicer project my-song.pdf # what the project file currently holds noteman-slicer export my-song.pdf # export without opening the editor +noteman-slicer open my-song.zip # unpack a bundle; --no-edit to stop there ``` -Every command takes `--type raster|vector` to override source-type detection. +Every command that takes a PDF takes `--type raster|vector` to override +source-type detection. diff --git a/docs/spec.md b/docs/spec.md index 65b4531..f0544f7 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -347,6 +347,15 @@ slice they sit on, so indices appear in exactly one place: a jump source's MP3s are planned for a later phase, and bundles are archived artifacts that may be re-imported a year later. +The manifest also carries the cuts, in a `source` block: the polylines, skew, +levels and content rectangle per page, and the page and slot each slice came +from. That is what makes `noteman-slicer open song.zip` a real round trip rather +than a re-detection that happens to land nearby — it unpacks the archived PDF, +rebuilds the project from the geometry, and re-renders. The images in the zip +are output and are discarded on the way back in. Slots no slice claims were the +discarded ones; a bundle states that by omission, since shipping a discarded +slice's image would defeat discarding it. + Otherwise: plain zip, no manifest beyond this, no checksums, hand-fixable. Python's `zipfile` is stdlib; the import side needs one zero-dep library (`fflate`), since Bun has zlib but no zip reader. diff --git a/noteman_slicer/bundle.py b/noteman_slicer/bundle.py index c5cc4b7..e017ad6 100644 --- a/noteman_slicer/bundle.py +++ b/noteman_slicer/bundle.py @@ -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 diff --git a/noteman_slicer/cli.py b/noteman_slicer/cli.py index 4dbe8ee..2c0cd41 100644 --- a/noteman_slicer/cli.py +++ b/noteman_slicer/cli.py @@ -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( diff --git a/noteman_slicer/editor.py b/noteman_slicer/editor.py index 45ebf85..0126895 100644 --- a/noteman_slicer/editor.py +++ b/noteman_slicer/editor.py @@ -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)) diff --git a/noteman_slicer/engrave.py b/noteman_slicer/engrave.py index de69bb2..f06d8eb 100644 --- a/noteman_slicer/engrave.py +++ b/noteman_slicer/engrave.py @@ -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() diff --git a/noteman_slicer/lilypond.py b/noteman_slicer/lilypond.py index 56ce189..0787c7c 100644 --- a/noteman_slicer/lilypond.py +++ b/noteman_slicer/lilypond.py @@ -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' ) diff --git a/noteman_slicer/project.py b/noteman_slicer/project.py index 632874c..7ae6768 100644 --- a/noteman_slicer/project.py +++ b/noteman_slicer/project.py @@ -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, ) diff --git a/noteman_slicer/render.py b/noteman_slicer/render.py index 85d75cc..c17a103 100644 --- a/noteman_slicer/render.py +++ b/noteman_slicer/render.py @@ -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: diff --git a/tests/test_lilypond.py b/tests/test_lilypond.py index b1ec9fa..b437d23 100644 --- a/tests/test_lilypond.py +++ b/tests/test_lilypond.py @@ -75,9 +75,10 @@ def main() -> int: # property, and is visible only at a line beginning — one number above the # first bar, as a printed score numbers its systems. numbered = lilypond.generate( - Replacement(voices=[Voice("treble", "c4 d", ""), Voice("bass", "c4 d", "")], bar=33), + Replacement(voices=[Voice("treble", "c4 d", ""), Voice("bass", "c4 d", "")]), "c", "4/4", + 33, ) assert numbered.count("currentBarNumber = #33") == 1, numbered assert "break-visibility = #'#(#f #f #t)" in numbered @@ -171,7 +172,7 @@ def main() -> int: assert page.replacements[second] is SATB # Round-trip, including the song-level engraving defaults. - SATB.bar = 33 + page.bars[second] = 33 project.key, project.time, project.clefs = "aes", "3/4", ["treble", "bass"] saved = project.save() reloaded = Project.load(saved) @@ -180,7 +181,7 @@ def main() -> int: assert restored is not None assert [v.clef for v in restored.voices] == ["treble", "bass"] assert restored.voices[0].lyrics == "la la la la la la" - assert restored.bar == 33, "the slice's bar number survives a save" + assert reloaded.pages[0].bars[second] == 33, "the slice's bar number survives a save" assert reloaded.pages[0].replacements[first] is None source.close() diff --git a/tests/test_markers.py b/tests/test_markers.py index d8a09cf..52ee6c6 100644 --- a/tests/test_markers.py +++ b/tests/test_markers.py @@ -70,11 +70,16 @@ def main() -> int: # Cut edits keep markers aligned with their slices. before = list(page.markers[first]) + page.bars[first] = 5 index = page.add_cut(Cut.straight(0.95)) assert len(page.markers) == page.slice_count + assert len(page.bars) == page.slice_count assert page.markers[first] == before, "markers must not move when a later slice splits" + assert page.bars[first] == 5, "the upper half still starts where the slice did" page.remove_cut(index) assert len(page.markers) == page.slice_count + assert len(page.bars) == page.slice_count and page.bars[first] == 5 + page.bars[first] = None # 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()))] @@ -99,14 +104,17 @@ def main() -> int: project.key, project.time = "aes", "3/4" page.replacements[second] = Replacement( voices=[Voice("treble", "c4 d e f", "la la la la"), Voice("bass", " c4 d e f ", " ")], - bar=33, ) + page.bars[second] = 33 engraved = song_json(project, names)["slices"] assert "engraving" not in engraved[0], "a scanned slice has no notation" ly = engraved[1]["engraving"] assert ly["lang"] == "lilypond" # Song defaults are resolved per slice: reading one slice needs no context. - assert (ly["key"], ly["time"], ly["print_time"], ly["bar"]) == ("aes", "3/4", False, 33) + assert (ly["key"], ly["time"], ly["print_time"]) == ("aes", "3/4", False) + # The bar number is on the slice, not the engraving: a scanned system is + # numbered in the score just the same. + assert engraved[1]["bar"] == 33 and "bar" not in ly, engraved[1] assert ly["voices"][0] == {"clef": "treble", "notes": "c4 d e f", "lyrics": "la la la la"} assert "lyrics" not in ly["voices"][1], "an empty field is absent, not empty" assert ly["voices"][1]["notes"] == "c4 d e f" @@ -137,8 +145,36 @@ def main() -> int: meta = json.loads(zf.read("song.json")) assert meta["slices"][0]["markers"][1]["destination"] == 1, meta["slices"] + # And back out again. The bundle carries the cuts, so reopening it rebuilds + # the project rather than re-cutting the score — and a jump goes back from + # an array index to the (page, slot) the editor works in. + reloaded.pages[0].replacements[second] = Replacement( + voices=[Voice("treble", "c4 d", "la la")] + ) + reloaded.pages[0].bars[second] = 7 + out = bundle.write(reloaded, source, tmp / "song.zip") + opened, unpacked = bundle.read(out, tmp / "reopened.pdf") + assert unpacked.exists() and unpacked.stat().st_size > 0 + assert opened.metadata["title"] == "Test song" + assert len(opened.pages) == len(reloaded.pages) + back = opened.pages[0] + assert back.discards == reloaded.pages[0].discards + assert [len(c.points) for c in back.cuts] == [len(c.points) for c in reloaded.pages[0].cuts] + assert back.markers[first][1].destination == (0, second), back.markers[first][1].destination + assert back.markers[second][0].type == "coda" + assert back.bars[second] == 7 + assert back.replacements[second].voices[0].lyrics == "la la" + + # Unpacking never lands on files that are already there. + try: + bundle.read(out, tmp / "reopened.pdf") + except ValueError as error: + assert "already exists" in str(error), error + else: + raise AssertionError("reopening over an existing PDF should be refused") + source.close() - for f in (pdf, out, saved, default_path(pdf)): + for f in (pdf, out, saved, unpacked, default_path(unpacked), default_path(pdf)): f.unlink(missing_ok=True) tmp.rmdir() print("ok")