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:
+79
-5
@@ -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. |
|
| `translator` | string | optional | Who translated the words. |
|
||||||
| `tempo` | integer | optional | Beats per minute. |
|
| `tempo` | integer | optional | Beats per minute. |
|
||||||
| `voices` | string | optional | The parts in this arrangement, as free text. |
|
| `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
|
**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.
|
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 | | |
|
| Field | Type | | |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `file` | string | required | Name of the image entry in the archive. |
|
| `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. |
|
| `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). |
|
| `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
|
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.
|
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
|
## Slice images
|
||||||
|
|
||||||
Every slice image in a bundle satisfies the following. A consumer can rely on
|
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
|
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.
|
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
|
## Engraving
|
||||||
|
|
||||||
Most slices are photographs of print: an image and nothing more. A slice that
|
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
|
```json
|
||||||
{
|
{
|
||||||
"file": "007.webp",
|
"file": "007.webp",
|
||||||
|
"bar": 33,
|
||||||
"engraving": {
|
"engraving": {
|
||||||
"lang": "lilypond",
|
"lang": "lilypond",
|
||||||
"key": "aes",
|
"key": "aes",
|
||||||
"time": "4/4",
|
"time": "4/4",
|
||||||
"print_time": false,
|
"print_time": false,
|
||||||
"bar": 33,
|
|
||||||
"voices": [
|
"voices": [
|
||||||
{ "clef": "treble", "notes": "c4 des ees f | ees2. r4", "lyrics": "Kai -- paa -- va sy -- dän" },
|
{ "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" },
|
{ "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"`. |
|
| `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"`. |
|
| `time` | string | optional | Time signature, as `"4/4"`. |
|
||||||
| `print_time` | boolean | optional | Whether the time signature is printed on this system. Default `false`. |
|
| `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`:
|
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.
|
`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
|
Additions that a consumer can safely ignore — new optional fields, new marker
|
||||||
types, new `engraving` languages — do not increase it. `engraving` was added
|
types, new `engraving` languages — do not increase it. `engraving` and `source`
|
||||||
this way: a bundle carrying one is still a version 1 bundle, and a consumer that
|
were both added this way: a bundle carrying them is still a version 1 bundle,
|
||||||
has never heard of it presents the score unchanged.
|
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
|
A consumer should refuse a bundle whose `v` it does not recognise rather than
|
||||||
attempt to interpret it.
|
attempt to interpret it.
|
||||||
@@ -305,6 +372,9 @@ A consumer is advised to check:
|
|||||||
- Every `destination` is within the bounds of `slices`.
|
- Every `destination` is within the bounds of `slices`.
|
||||||
- Every `engraving` has a `lang` and a non-empty `voices`; unknown `lang` values
|
- Every `engraving` has a `lang` and a non-empty `voices`; unknown `lang` values
|
||||||
are ignored rather than rejected.
|
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
|
- Archive entry names contain no path separators, no `..`, and no absolute
|
||||||
paths, as with any archive from an untrusted source.
|
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
|
previously read bundle: there are no stable identifiers, and a slice's index is
|
||||||
meaningful only within the bundle that contains it.
|
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
|
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
|
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
|
should treat a second bundle as a new score rather than merging it into an
|
||||||
|
|||||||
+27
-1
@@ -53,6 +53,14 @@ off.
|
|||||||
Page Up / Page Down move between pages. Levels carry over from the previous
|
Page Up / Page Down move between pages. Levels carry over from the previous
|
||||||
page, so a consistent scan only needs setting once.
|
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
|
||||||
|
|
||||||
Markers are the navigation symbols noteman uses to jump around the score:
|
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
|
detection rather than resuming decisions that already shipped. If you really
|
||||||
want the old cuts back, `noteman-slicer edit my-song.pdf --resume`.
|
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)
|
## Re-engraving a slice (optional, needs LilyPond)
|
||||||
|
|
||||||
When a system is beyond rescue — a bad scan, a wrong transposition, a passage
|
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 detect my-song.pdf # detection results + debug overlays
|
||||||
noteman-slicer project my-song.pdf # what the project file currently holds
|
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 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.
|
||||||
|
|||||||
@@ -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
|
MP3s are planned for a later phase, and bundles are archived artifacts that may be
|
||||||
re-imported a year later.
|
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.
|
Otherwise: plain zip, no manifest beyond this, no checksums, hand-fixable.
|
||||||
Python's `zipfile` is stdlib; the import side needs one zero-dep library
|
Python's `zipfile` is stdlib; the import side needs one zero-dep library
|
||||||
(`fflate`), since Bun has zlib but no zip reader.
|
(`fflate`), since Bun has zlib but no zip reader.
|
||||||
|
|||||||
+154
-2
@@ -69,7 +69,6 @@ def _engraving(project: Project, page: int, slot: int) -> dict | None:
|
|||||||
"key": replacement.key or project.key,
|
"key": replacement.key or project.key,
|
||||||
"time": replacement.time or project.time,
|
"time": replacement.time or project.time,
|
||||||
"print_time": replacement.print_time,
|
"print_time": replacement.print_time,
|
||||||
**({"bar": replacement.bar} if replacement.bar else {}),
|
|
||||||
"voices": [
|
"voices": [
|
||||||
{"clef": v.clef, "notes": v.notes.strip()}
|
{"clef": v.clef, "notes": v.notes.strip()}
|
||||||
| ({"lyrics": v.lyrics.strip()} if v.lyrics.strip() else {})
|
| ({"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:
|
def song_json(project: Project, files: list[str]) -> dict:
|
||||||
payload: dict = {"v": FORMAT_VERSION}
|
payload: dict = {"v": FORMAT_VERSION}
|
||||||
for field in METADATA_FIELDS:
|
for field in METADATA_FIELDS:
|
||||||
@@ -100,7 +126,10 @@ def song_json(project: Project, files: list[str]) -> dict:
|
|||||||
|
|
||||||
slices: list[dict] = []
|
slices: list[dict] = []
|
||||||
for name, (page, slot) in zip(files, kept):
|
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)
|
engraving = _engraving(project, page, slot)
|
||||||
if engraving:
|
if engraving:
|
||||||
entry["engraving"] = engraving
|
entry["engraving"] = engraving
|
||||||
@@ -123,6 +152,8 @@ def song_json(project: Project, files: list[str]) -> dict:
|
|||||||
slices.append(entry)
|
slices.append(entry)
|
||||||
|
|
||||||
payload["slices"] = slices
|
payload["slices"] = slices
|
||||||
|
if project.source.exists():
|
||||||
|
payload["source"] = _source(project)
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@@ -170,3 +201,124 @@ def write(project: Project, source: Source, path: Path) -> Path:
|
|||||||
project.exported = True
|
project.exported = True
|
||||||
project.save()
|
project.save()
|
||||||
return path
|
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
|
||||||
|
|||||||
@@ -102,6 +102,26 @@ def _export(args: argparse.Namespace) -> int:
|
|||||||
return 0
|
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:
|
def _edit(args: argparse.Namespace) -> int:
|
||||||
from .editor import launch
|
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.add_argument("--type", choices=[t.value for t in SourceType])
|
||||||
exp.set_defaults(func=_export)
|
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 = sub.add_parser("edit", help="open the editor")
|
||||||
ed.add_argument("pdf")
|
ed.add_argument("pdf")
|
||||||
ed.add_argument(
|
ed.add_argument(
|
||||||
|
|||||||
@@ -516,6 +516,20 @@ class Editor(QMainWindow):
|
|||||||
reset.clicked.connect(self._reset_rect)
|
reset.clicked.connect(self._reset_rect)
|
||||||
form.addRow(reset)
|
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)
|
marker_layout = section("Markers on this slice", box)
|
||||||
self.marker_list = QListWidget()
|
self.marker_list = QListWidget()
|
||||||
self.marker_list.setMaximumHeight(110)
|
self.marker_list.setMaximumHeight(110)
|
||||||
@@ -657,6 +671,10 @@ class Editor(QMainWindow):
|
|||||||
widget.blockSignals(True)
|
widget.blockSignals(True)
|
||||||
widget.setValue(value)
|
widget.setValue(value)
|
||||||
widget.blockSignals(False)
|
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_markers()
|
||||||
self._sync_replacement()
|
self._sync_replacement()
|
||||||
kept = len(self.project.kept_slices())
|
kept = len(self.project.kept_slices())
|
||||||
@@ -674,6 +692,11 @@ class Editor(QMainWindow):
|
|||||||
self._sync()
|
self._sync()
|
||||||
self.autosave.start()
|
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:
|
def _skew_changed(self, value: float) -> None:
|
||||||
self.project.pages[self.index].skew = value
|
self.project.pages[self.index].skew = value
|
||||||
self.view.show_page(self.project, self.index, self._preview(self.index))
|
self.view.show_page(self.project, self.index, self._preview(self.index))
|
||||||
|
|||||||
+16
-11
@@ -96,9 +96,9 @@ class EngraveWindow(QDialog):
|
|||||||
self.setWindowTitle(f"Re-engrave — page {page + 1}, slice {slot + 1}")
|
self.setWindowTitle(f"Re-engrave — page {page + 1}, slice {slot + 1}")
|
||||||
self.setModal(False)
|
self.setModal(False)
|
||||||
|
|
||||||
state = project.pages[page]
|
self.state = project.pages[page]
|
||||||
self.replacement = state.replacements[slot] or self._seed()
|
self.replacement = self.state.replacements[slot] or self._seed()
|
||||||
state.replacements[slot] = self.replacement
|
self.state.replacements[slot] = self.replacement
|
||||||
|
|
||||||
rows = QSplitter(Qt.Vertical)
|
rows = QSplitter(Qt.Vertical)
|
||||||
rows.addWidget(self._image_panel("Scanned", _pixmap(original)))
|
rows.addWidget(self._image_panel("Scanned", _pixmap(original)))
|
||||||
@@ -182,9 +182,11 @@ class EngraveWindow(QDialog):
|
|||||||
row.addWidget(self.print_time, 1)
|
row.addWidget(self.print_time, 1)
|
||||||
top.addRow("Time", row)
|
top.addRow("Time", row)
|
||||||
|
|
||||||
# Per slice, unlike key and time: which measure a system starts at is
|
# A property of the slice, not of the replacement — the same field the
|
||||||
# the one thing that changes with every slice and cannot be inherited.
|
# main panel shows for a scanned slice — so it survives discarding the
|
||||||
self.bar = QLineEdit("" if self.replacement.bar is None else str(self.replacement.bar))
|
# 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.setValidator(QIntValidator(1, 9999, self.bar))
|
||||||
self.bar.setFixedWidth(70)
|
self.bar.setFixedWidth(70)
|
||||||
self.bar.setPlaceholderText("none")
|
self.bar.setPlaceholderText("none")
|
||||||
@@ -256,7 +258,7 @@ class EngraveWindow(QDialog):
|
|||||||
self._refresh_source()
|
self._refresh_source()
|
||||||
|
|
||||||
def _bar_changed(self, text: str) -> None:
|
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()
|
self._refresh_source()
|
||||||
|
|
||||||
def _settings_changed(self) -> None:
|
def _settings_changed(self) -> None:
|
||||||
@@ -273,15 +275,18 @@ class EngraveWindow(QDialog):
|
|||||||
self.project.pages[self.page_index].replacements[self.slot] = None
|
self.project.pages[self.page_index].replacements[self.slot] = None
|
||||||
self.accept()
|
self.accept()
|
||||||
|
|
||||||
def _refresh_source(self) -> None:
|
def _source(self) -> str:
|
||||||
self.generated.setPlainText(
|
return lilypond.generate(
|
||||||
lilypond.generate(self.replacement, self.project.key, self.project.time)
|
self.replacement, self.project.key, self.project.time, self.state.bars[self.slot]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _refresh_source(self) -> None:
|
||||||
|
self.generated.setPlainText(self._source())
|
||||||
|
|
||||||
# -- rendering --------------------------------------------------------
|
# -- rendering --------------------------------------------------------
|
||||||
|
|
||||||
def render(self) -> None:
|
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.setStyleSheet("color: #808080;")
|
||||||
self.status.setText("rendering…")
|
self.status.setText("rendering…")
|
||||||
self.repaint()
|
self.repaint()
|
||||||
|
|||||||
@@ -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.
|
"""Build LilyPond source from a slice's structured replacement.
|
||||||
|
|
||||||
The time signature is used for spacing and bar checks but not printed
|
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
|
# printed score numbers its systems. The empty bar line is what gives the
|
||||||
# number a line beginning to attach to.
|
# number a line beginning to attach to.
|
||||||
number = ""
|
number = ""
|
||||||
if replacement.bar:
|
if bar:
|
||||||
number = (
|
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"
|
" \\override Score.BarNumber.break-visibility = #'#(#f #f #t)\n"
|
||||||
' \\bar ""\n'
|
' \\bar ""\n'
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -147,10 +147,6 @@ class Replacement:
|
|||||||
voices: list[Voice] = field(default_factory=list)
|
voices: list[Voice] = field(default_factory=list)
|
||||||
key: str | None = None
|
key: str | None = None
|
||||||
time: 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
|
# 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.
|
# time signature, so a re-engraved middle slice must not show one.
|
||||||
print_time: bool = False
|
print_time: bool = False
|
||||||
@@ -168,6 +164,11 @@ class Page:
|
|||||||
# A re-engraved system per slice, when the scan is past saving. None for
|
# A re-engraved system per slice, when the scan is past saving. None for
|
||||||
# the ordinary case, which is nearly all of them.
|
# the ordinary case, which is nearly all of them.
|
||||||
replacements: list[Replacement | None] = field(default_factory=lambda: [None])
|
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
|
content_rect: tuple[float, float, float, float] | None = None
|
||||||
levels: tuple[int, int] | None = None
|
levels: tuple[int, int] | None = None
|
||||||
|
|
||||||
@@ -193,6 +194,10 @@ class Page:
|
|||||||
self.discards.insert(index, self.discards[index])
|
self.discards.insert(index, self.discards[index])
|
||||||
self.markers.insert(index + 1, [])
|
self.markers.insert(index + 1, [])
|
||||||
self.replacements.insert(index + 1, None)
|
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
|
return index
|
||||||
|
|
||||||
def remove_cut(self, index: int) -> None:
|
def remove_cut(self, index: int) -> None:
|
||||||
@@ -205,6 +210,8 @@ class Page:
|
|||||||
# Two engraved halves cannot be merged, so the upper one wins.
|
# Two engraved halves cannot be merged, so the upper one wins.
|
||||||
below = self.replacements.pop(index + 1)
|
below = self.replacements.pop(index + 1)
|
||||||
self.replacements[index] = self.replacements[index] or below
|
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:
|
def remember_clefs(self, project: Project, slot: int) -> None:
|
||||||
"""Carry this slice's clefs forward as the song's defaults."""
|
"""Carry this slice's clefs forward as the song's defaults."""
|
||||||
@@ -296,6 +303,7 @@ class Project:
|
|||||||
discards=discards,
|
discards=discards,
|
||||||
markers=[[] for _ in discards],
|
markers=[[] for _ in discards],
|
||||||
replacements=[None] * len(discards),
|
replacements=[None] * len(discards),
|
||||||
|
bars=[None] * len(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,
|
||||||
@@ -360,10 +368,10 @@ class Project:
|
|||||||
**({"key": r.key} if r.key else {}),
|
**({"key": r.key} if r.key else {}),
|
||||||
**({"time": r.time} if r.time else {}),
|
**({"time": r.time} if r.time else {}),
|
||||||
**({"print_time": True} if r.print_time else {}),
|
**({"print_time": True} if r.print_time else {}),
|
||||||
**({"bar": r.bar} if r.bar else {}),
|
|
||||||
}
|
}
|
||||||
for r in page.replacements
|
for r in page.replacements
|
||||||
],
|
],
|
||||||
|
"bars": page.bars,
|
||||||
"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,
|
||||||
}
|
}
|
||||||
@@ -417,10 +425,19 @@ class Project:
|
|||||||
key=r.get("key"),
|
key=r.get("key"),
|
||||||
time=r.get("time"),
|
time=r.get("time"),
|
||||||
print_time=r.get("print_time", False),
|
print_time=r.get("print_time", False),
|
||||||
bar=r.get("bar"),
|
|
||||||
)
|
)
|
||||||
for r in page.get("replacements", [None] * len(page["discards"]))
|
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,
|
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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -161,7 +161,9 @@ def render_slices(project: Project, source: Source) -> list[SliceImage]:
|
|||||||
# flows through staff-height normalisation and the rest exactly
|
# flows through staff-height normalisation and the rest exactly
|
||||||
# as a scanned one does.
|
# as a scanned one does.
|
||||||
gray = lilypond.render(
|
gray = lilypond.render(
|
||||||
lilypond.generate(engraved, project.key, project.time)
|
lilypond.generate(
|
||||||
|
engraved, project.key, project.time, page_state.bars[slot]
|
||||||
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
if page is None:
|
if page is None:
|
||||||
|
|||||||
@@ -75,9 +75,10 @@ def main() -> int:
|
|||||||
# property, and is visible only at a line beginning — one number above the
|
# property, and is visible only at a line beginning — one number above the
|
||||||
# first bar, as a printed score numbers its systems.
|
# first bar, as a printed score numbers its systems.
|
||||||
numbered = lilypond.generate(
|
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",
|
"c",
|
||||||
"4/4",
|
"4/4",
|
||||||
|
33,
|
||||||
)
|
)
|
||||||
assert numbered.count("currentBarNumber = #33") == 1, numbered
|
assert numbered.count("currentBarNumber = #33") == 1, numbered
|
||||||
assert "break-visibility = #'#(#f #f #t)" in numbered
|
assert "break-visibility = #'#(#f #f #t)" in numbered
|
||||||
@@ -171,7 +172,7 @@ def main() -> int:
|
|||||||
assert page.replacements[second] is SATB
|
assert page.replacements[second] is SATB
|
||||||
|
|
||||||
# Round-trip, including the song-level engraving defaults.
|
# 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"]
|
project.key, project.time, project.clefs = "aes", "3/4", ["treble", "bass"]
|
||||||
saved = project.save()
|
saved = project.save()
|
||||||
reloaded = Project.load(saved)
|
reloaded = Project.load(saved)
|
||||||
@@ -180,7 +181,7 @@ def main() -> int:
|
|||||||
assert restored is not None
|
assert restored is not None
|
||||||
assert [v.clef for v in restored.voices] == ["treble", "bass"]
|
assert [v.clef for v in restored.voices] == ["treble", "bass"]
|
||||||
assert restored.voices[0].lyrics == "la la la la la la"
|
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
|
assert reloaded.pages[0].replacements[first] is None
|
||||||
|
|
||||||
source.close()
|
source.close()
|
||||||
|
|||||||
+39
-3
@@ -70,11 +70,16 @@ def main() -> int:
|
|||||||
|
|
||||||
# Cut edits keep markers aligned with their slices.
|
# Cut edits keep markers aligned with their slices.
|
||||||
before = list(page.markers[first])
|
before = list(page.markers[first])
|
||||||
|
page.bars[first] = 5
|
||||||
index = page.add_cut(Cut.straight(0.95))
|
index = page.add_cut(Cut.straight(0.95))
|
||||||
assert len(page.markers) == page.slice_count
|
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.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)
|
page.remove_cut(index)
|
||||||
assert len(page.markers) == page.slice_count
|
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.
|
# 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()))]
|
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"
|
project.key, project.time = "aes", "3/4"
|
||||||
page.replacements[second] = Replacement(
|
page.replacements[second] = Replacement(
|
||||||
voices=[Voice("treble", "c4 d e f", "la la la la"), Voice("bass", " c4 d e f ", " ")],
|
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"]
|
engraved = song_json(project, names)["slices"]
|
||||||
assert "engraving" not in engraved[0], "a scanned slice has no notation"
|
assert "engraving" not in engraved[0], "a scanned slice has no notation"
|
||||||
ly = engraved[1]["engraving"]
|
ly = engraved[1]["engraving"]
|
||||||
assert ly["lang"] == "lilypond"
|
assert ly["lang"] == "lilypond"
|
||||||
# Song defaults are resolved per slice: reading one slice needs no context.
|
# 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 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 "lyrics" not in ly["voices"][1], "an empty field is absent, not empty"
|
||||||
assert ly["voices"][1]["notes"] == "c4 d e f"
|
assert ly["voices"][1]["notes"] == "c4 d e f"
|
||||||
@@ -137,8 +145,36 @@ def main() -> int:
|
|||||||
meta = json.loads(zf.read("song.json"))
|
meta = json.loads(zf.read("song.json"))
|
||||||
assert meta["slices"][0]["markers"][1]["destination"] == 1, meta["slices"]
|
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()
|
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)
|
f.unlink(missing_ok=True)
|
||||||
tmp.rmdir()
|
tmp.rmdir()
|
||||||
print("ok")
|
print("ok")
|
||||||
|
|||||||
Reference in New Issue
Block a user