Compare commits

..
6 Commits
14 changed files with 152 additions and 1181 deletions
+3 -87
View File
@@ -95,7 +95,6 @@ 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.
@@ -113,9 +112,6 @@ 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). |
@@ -126,12 +122,6 @@ 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
@@ -170,72 +160,6 @@ 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.
`source` is optional, so a bundle without one is still valid — it is simply not
reopenable, and a consumer should say so rather than pretend otherwise. What it
can still recover from such a bundle is the title block, and, if it cuts the
document again and happens to find exactly as many slices, the markers: the
slices array is in reading order, so it lines up with any other list in reading
order. One slice more or fewer and it does not, which is why that is a fallback
and not the design.
## 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
@@ -245,7 +169,6 @@ 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",
@@ -363,9 +286,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` and `source` types, new `engraving` languages — do not increase it. `engraving` was added
were both added this way: a bundle carrying them is still a version 1 bundle, this way: a bundle carrying one is still a version 1 bundle, and a consumer that
and a consumer that has never heard of them presents the score unchanged. has never heard of it 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.
@@ -380,9 +303,6 @@ 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.
@@ -392,10 +312,6 @@ 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
+10 -50
View File
@@ -44,27 +44,14 @@ off.
one back. one back.
4. **Set the content rectangle.** Drag the blue edges so they hold the music and 4. **Set the content rectangle.** Drag the blue edges so they hold the music and
nothing else. This is the horizontal crop for every slice on the page. nothing else. This is the horizontal crop for every slice on the page.
5. **Check black and white points.** Under *Page* is the scan's own histogram: 5. **Check black and white points.** These arrive proposed from the scan, like
a hump of ink on the left, a hump of paper on the right, and two handles you the cuts do. If the paper still shows texture, pull *White point* down; if the
drag. Put the white handle at the foot of the paper hump and the black one at notes look grey rather than solid, pull *Black point* up. Getting this wrong
the foot of the ink hump. The strip underneath shows the tone that results. is the one mistake you cannot see until the bundle is on the tablet — grey ink
Getting this wrong is the one mistake you cannot see until the bundle is on becomes half-transparent ink, and nothing downstream can rescue it.
the tablet — grey ink becomes half-transparent ink, and nothing downstream
can rescue it. Arrow keys nudge the black point, Shift+arrows the white one.
The page rail across the top of the panel has one chip per page, with the number Page Up / Page Down move between pages. Levels carry over from the previous
of slices on it underneath. Click to go there, or use Page Up / Page Down. A page page, so a consistent scan only needs setting once.
whose count is far off its neighbours' is usually a page where detection missed
a system. 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
@@ -106,31 +93,6 @@ 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.
A bundle from a producer that does not record its cuts can still be opened, but
it is a fresh session rather than a round trip: it asks first, then cuts the PDF
from scratch with detection. The title block always comes back. Markers and
re-engraved systems land only if detection happens to find exactly as many
slices as the bundle has — the slices are in reading order on both sides, so
they can be lined up, but one system found or missed would shift every marker
onto the wrong slice, so in that case they are left off entirely and it says so.
`--detect` answers the question in advance, for scripts.
## 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
@@ -168,8 +130,8 @@ substitutes for it.
| Detection found no systems, or one giant one | The score has no bracket joining the staves; add the cuts by hand. | | Detection found no systems, or one giant one | The score has no bracket joining the staves; add the cuts by hand. |
| "The PDF has changed since these cuts were made" | The file was edited or replaced under an existing project. The cuts probably no longer line up — re-cut. | | "The PDF has changed since these cuts were made" | The file was edited or replaced under an existing project. The cuts probably no longer line up — re-cut. |
| Export says a title is required | Fill in *Song → Title*. | | Export says a title is required | Fill in *Song → Title*. |
| Slices look grey and washed out | The white point is too high — drag it down onto the paper hump. | | Slices look grey and washed out | The white point is too high. |
| Notes have holes in them | The black point is too high — drag it left, off the ink hump. | | Notes have holes in them | The black point is too high. |
## The command line ## The command line
@@ -180,8 +142,6 @@ 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 that takes a PDF takes `--type raster|vector` to override Every command takes `--type raster|vector` to override source-type detection.
source-type detection.
-9
View File
@@ -347,15 +347,6 @@ 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.
+1 -217
View File
@@ -77,33 +77,6 @@ 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:
@@ -126,10 +99,7 @@ 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, "page": page, "slot": slot} entry: dict = {"file": name}
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
@@ -152,8 +122,6 @@ 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
@@ -201,187 +169,3 @@ def write(project: Project, source: Source, path: Path) -> Path:
project.exported = True project.exported = True
project.save() project.save()
return path return path
class NoCuts(ValueError):
"""The bundle carries no `source` block, so its cuts cannot be restored."""
def _pages_from(geometry: dict, slices: list[dict]):
"""Rebuild the pages from a manifest's source geometry."""
from .project import Cut, Page
# 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"]) for s in slices}
pages = []
for i, page in enumerate(geometry["pages"]):
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,
)
)
return pages
def _pages_from_detection(pdf: Path):
"""Cut the PDF again from scratch, for a bundle that recorded no geometry."""
from .detect import detect_page
from .pdf import open_source, page_raster
from .project import Project
source = open_source(pdf)
detections, heights = [], []
try:
for i in range(len(source)):
gray = page_raster(source, i)
detections.append(detect_page(gray))
heights.append(gray.shape[0])
finally:
source.close()
return Project.from_detection(pdf, detections, heights)
def _restore(project, slices: list[dict], positions: list[tuple[int, int]]) -> None:
"""Put each slice's bar number, markers and engraving back on its slot."""
from .project import Marker, Replacement, Voice
for entry, (page, slot) in zip(slices, positions):
project.pages[page].bars[slot] = entry.get("bar")
project.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=(
positions[marker["destination"]]
if marker.get("destination") is not None
and marker["destination"] < len(positions)
else None
),
)
for marker in entry.get("markers", [])
]
engraving = entry.get("engraving")
if engraving and engraving.get("lang") == "lilypond":
project.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),
)
for entry in slices:
# 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.
engraving = entry.get("engraving") or {}
if engraving.get("key"):
project.key = engraving["key"]
project.time = engraving.get("time", project.time)
break
def has_cuts(path: Path) -> bool:
"""Whether this bundle records the geometry its slices were cut with.
Worth asking before unpacking, since the answer decides whether reopening
is a round trip or a fresh session with the same PDF.
"""
with zipfile.ZipFile(Path(path)) as zf:
if "song.json" not in zf.namelist():
return False
return bool(json.loads(zf.read("song.json")).get("source"))
def read(
path: Path, into: Path | None = None, *, force: bool = False, detect: 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 raises `NoCuts`;
`detect` says to cut the PDF from scratch instead, which is a different
thing and worth a caller asking about first.
"""
from .project import Project, 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 and not detect:
raise NoCuts(
f"{path.name} carries no cuts — it was written by a producer that "
"does not record them"
)
pdf_name = (geometry or {}).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)
# 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)
slices = manifest["slices"]
if geometry:
pages_json = geometry["pages"]
first = pages_json[0] if pages_json else {}
project = Project(
source=target,
source_hash=hash_file(target),
pages=_pages_from(geometry, slices),
content_rect=tuple(first.get("content", (0.0, 0.0, 1.0, 1.0))),
levels=tuple(first.get("levels", (0, 255))),
path=default_path(target),
)
positions = [(s["page"], s["slot"]) for s in slices]
else:
project = _pages_from_detection(target)
project.path = default_path(target)
# Detection's slices are in reading order and so are the bundle's, so
# they can be lined up — but only if there are exactly as many. One
# system found or missed shifts every marker onto the wrong slice,
# which is worse than not placing them at all.
positions = project.kept_slices()
if len(positions) != len(slices):
positions = []
project.metadata = {
field: str(manifest[field]) for field in METADATA_FIELDS if manifest.get(field) is not None
}
_restore(project, slices[: len(positions)], positions)
return project, target
-66
View File
@@ -102,56 +102,6 @@ def _export(args: argparse.Namespace) -> int:
return 0 return 0
def _confirm(question: str) -> bool:
"""Ask before doing something the caller did not ask for. No tty, no."""
if not sys.stdin.isatty():
print(f"{question} (not a terminal — pass --detect to say yes)")
return False
return input(f"{question} [y/N] ").strip().lower() in ("y", "yes")
def _open(args: argparse.Namespace) -> int:
from . import bundle
from .editor import launch
zip_path, where = Path(args.zip), Path(args.pdf) if args.pdf else None
try:
cuts = bundle.has_cuts(zip_path)
except (OSError, ValueError) as error:
print(f"cannot open: {error}")
return 1
if not cuts and not args.detect:
print(f"{zip_path.name} carries no cuts — it was written by a producer that")
print("does not record them. Its PDF can be cut again from scratch, but that")
print("is a fresh session: the cuts will be detection's, and the markers land")
print("only if detection happens to find the same number of slices.")
if not _confirm("Open it that way?"):
return 1
try:
project, pdf = bundle.read(zip_path, where, force=args.force, detect=not cuts)
except (ValueError, KeyError) as error:
print(f"cannot open: {error}")
return 1
saved = project.save()
kept = project.kept_slices()
print(f"{pdf.name}: {len(project.pages)} pages, {len(kept)} slices")
if not cuts:
marked = sum(len(m) for page in project.pages for m in page.markers)
print(" cut from scratch by detection — check every cut before exporting")
print(
f" {marked} markers placed by position"
if marked
else " markers not placed: detection found a different number of 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
@@ -203,22 +153,6 @@ 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.add_argument(
"--detect",
action="store_true",
help="for a bundle with no cuts: cut its PDF from scratch, without asking",
)
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(
+113 -142
View File
@@ -44,13 +44,14 @@ from PySide6.QtWidgets import (
QPushButton, QPushButton,
QScrollArea, QScrollArea,
QSizePolicy, QSizePolicy,
QSlider,
QSplitter, QSplitter,
QToolButton, QToolButton,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
) )
from . import bundle, lilypond, panel as ui from . import bundle, lilypond
from .bundle import METADATA_FIELDS, NUMERIC_FIELDS from .bundle import METADATA_FIELDS, NUMERIC_FIELDS
from .detect import deskew, detect_page from .detect import deskew, detect_page
from .pdf import Source, open_source, page_raster from .pdf import Source, open_source, page_raster
@@ -83,40 +84,6 @@ _ENGRAVED_WASH = QColor(230, 160, 30, 55)
_BADGE_Z = 10 _BADGE_Z = 10
def section(title: str, box: QVBoxLayout, *, expanded: bool = True) -> QVBoxLayout:
"""A collapsible section. Returns the layout its contents go into.
A disclosure arrow, not a checkable QGroupBox: a checkbox in a group
header reads as "enable this feature" rather than "expand this", and a
column of framed boxes with checkboxes is hard to scan. A hairline above
each one does the separating that the frames used to.
"""
box.addWidget(ui.Rule())
header = QToolButton()
header.setText(title.upper())
header.setCheckable(True)
header.setChecked(expanded)
header.setArrowType(Qt.DownArrow if expanded else Qt.RightArrow)
header.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
header.setAutoRaise(True)
header.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
header.setStyleSheet(ui.HEADING)
body = QWidget()
layout = QVBoxLayout(body)
layout.setContentsMargins(10, 6, 0, 10)
body.setVisible(expanded)
def toggled(open_: bool) -> None:
body.setVisible(open_)
header.setArrowType(Qt.DownArrow if open_ else Qt.RightArrow)
header.toggled.connect(toggled)
box.addWidget(header)
box.addWidget(body)
return layout
class PageView(QGraphicsView): class PageView(QGraphicsView):
"""Pan, zoom, and direct manipulation of cuts and the content rectangle.""" """Pan, zoom, and direct manipulation of cuts and the content rectangle."""
@@ -459,21 +426,67 @@ class Editor(QMainWindow):
# -- ui --------------------------------------------------------------- # -- ui ---------------------------------------------------------------
def _section(self, title: str, box: QVBoxLayout, *, expanded: bool = True) -> QVBoxLayout:
"""A collapsible section. Returns the layout its contents go into.
A disclosure arrow, not a checkable QGroupBox: a checkbox in a group
header reads as "enable this feature" rather than "expand this", and a
column of framed boxes with checkboxes is hard to scan.
"""
header = QToolButton()
header.setText(title)
header.setCheckable(True)
header.setChecked(expanded)
header.setArrowType(Qt.DownArrow if expanded else Qt.RightArrow)
header.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
header.setAutoRaise(True)
header.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
# Bold and greyed: a mid grey reads as a heading against both light and
# dark palettes without needing a second stylesheet.
header.setStyleSheet(
"QToolButton {"
" border: none;"
" font-weight: 700;"
" color: #808080;"
" padding: 7px 0 4px 0;"
" text-align: left;"
"}"
"QToolButton:hover { color: #a0a0a0; }"
)
body = QWidget()
layout = QVBoxLayout(body)
layout.setContentsMargins(10, 6, 0, 10)
body.setVisible(expanded)
def toggled(open_: bool) -> None:
body.setVisible(open_)
header.setArrowType(Qt.DownArrow if open_ else Qt.RightArrow)
header.toggled.connect(toggled)
box.addWidget(header)
box.addWidget(body)
return layout
def _panel(self) -> QWidget: def _panel(self) -> QWidget:
inner = QWidget() inner = QWidget()
box = QVBoxLayout(inner) box = QVBoxLayout(inner)
box.setContentsMargins(14, 12, 14, 14) panel = QScrollArea()
box.setSpacing(0) panel.setWidget(inner)
scroller = QScrollArea() panel.setWidgetResizable(True)
scroller.setWidget(inner) panel.setMinimumWidth(260)
scroller.setWidgetResizable(True)
scroller.setMinimumWidth(280)
self.rail = ui.PageRail() nav = QHBoxLayout()
self.rail.picked.connect(self._load_page) self.page_label = QLabel()
box.addWidget(self.rail) prev, nxt = QPushButton(""), QPushButton("")
prev.clicked.connect(lambda: self._load_page(self.index - 1))
nxt.clicked.connect(lambda: self._load_page(self.index + 1))
nav.addWidget(prev)
nav.addWidget(self.page_label, 1)
nav.addWidget(nxt)
box.addLayout(nav)
page_section = section("Page", box) page_section = self._section("Page", box)
form = QFormLayout() form = QFormLayout()
page_section.addLayout(form) page_section.addLayout(form)
self.skew = QDoubleSpinBox() self.skew = QDoubleSpinBox()
@@ -484,54 +497,33 @@ class Editor(QMainWindow):
self.skew.valueChanged.connect(self._skew_changed) self.skew.valueChanged.connect(self._skew_changed)
form.addRow("Skew", self.skew) form.addRow("Skew", self.skew)
self.levels = ui.LevelsBar() self.black = QSlider(Qt.Horizontal)
self.levels.changed.connect(self._levels_changed) self.black.setRange(0, 255)
self.levels.setToolTip( self.white = QSlider(Qt.Horizontal)
"Drag the white dot to the foot of the paper hump and the light one " self.white.setRange(0, 255)
"to the foot of the ink hump. The strip below is the resulting tone." self.white.setValue(255)
) for s in (self.black, self.white):
page_section.addWidget(self.levels) s.valueChanged.connect(self._levels_changed)
form.addRow("Black point", self.black)
form.addRow("White point", self.white)
buttons = QHBoxLayout() discard = QPushButton("Toggle discard (D)")
discard = QPushButton("Discard slice")
discard.setToolTip("Or press D. Discarded slices never reach the tablet.")
discard.clicked.connect(self.view.toggle_discard) discard.clicked.connect(self.view.toggle_discard)
reset = QPushButton("Reset crop") form.addRow(discard)
reset.setToolTip("Back to the content rectangle detection proposed for this page") reset = QPushButton("Reset content rectangle")
reset.setToolTip("Back to the rectangle detection proposed for this page")
reset.clicked.connect(self._reset_rect) reset.clicked.connect(self._reset_rect)
buttons.addWidget(discard) form.addRow(reset)
buttons.addWidget(reset)
page_section.addLayout(buttons)
slice_layout = section("This slice", box) marker_layout = self._section("Markers on 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.setProperty("role", "number")
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 = QListWidget()
self.marker_list.setMaximumHeight(110) self.marker_list.setMaximumHeight(110)
marker_layout.addWidget(self.marker_list) marker_layout.addWidget(self.marker_list)
add_row = QHBoxLayout() add_row = QHBoxLayout()
self.marker_type = QComboBox() self.marker_type = QComboBox()
# Shown as prose, sent as the enum: "D.S. al coda" is what a musician self.marker_type.addItems(MARKER_TYPES)
# reads off the page, `ds_al_coda` is what noteman parses. self.marker_type.currentTextChanged.connect(self._marker_type_changed)
for kind in MARKER_TYPES:
self.marker_type.addItem(kind.replace("_", " ").capitalize(), kind)
self.marker_type.currentIndexChanged.connect(
lambda: self._marker_type_changed(self.marker_type.currentData())
)
add_row.addWidget(self.marker_type, 1) add_row.addWidget(self.marker_type, 1)
self.marker_label = QLineEdit() self.marker_label = QLineEdit()
self.marker_label.setPlaceholderText("label") self.marker_label.setPlaceholderText("label")
@@ -549,24 +541,24 @@ class Editor(QMainWindow):
for button in (add, remove, self.retarget): for button in (add, remove, self.retarget):
button_row.addWidget(button) button_row.addWidget(button)
marker_layout.addLayout(button_row) marker_layout.addLayout(button_row)
self._marker_type_changed(self.marker_type.currentData()) self._marker_type_changed(self.marker_type.currentText())
# Optional feature: without LilyPond installed the pane never appears, # Optional feature: without LilyPond installed the pane never appears,
# and nothing else about the tool changes. Collapsed by default — most # and nothing else about the tool changes. Collapsed by default — most
# slices are never re-engraved, and it is the tallest block here. # slices are never re-engraved, and it is the tallest block here.
self.ly_status = None self.ly_status = None
if lilypond.available(): if lilypond.available():
ly_layout = section("Re-engrave this slice", box, expanded=False) ly_layout = self._section("Re-engrave this slice", box, expanded=False)
open_engrave = QPushButton("Open engrave window…") open_engrave = QPushButton("Open engrave window…")
open_engrave.setToolTip("Or double-click the slice on the page") open_engrave.setToolTip("Or double-click the slice on the page")
open_engrave.clicked.connect(self._open_engrave) open_engrave.clicked.connect(self._open_engrave)
ly_layout.addWidget(open_engrave) ly_layout.addWidget(open_engrave)
self.ly_status = QLabel() self.ly_status = QLabel()
self.ly_status.setWordWrap(True) self.ly_status.setWordWrap(True)
self.ly_status.setProperty("role", "hint") self.ly_status.setStyleSheet("color: #808080;")
ly_layout.addWidget(self.ly_status) ly_layout.addWidget(self.ly_status)
meta_layout = section("Song", box) meta_layout = self._section("Song", box)
meta_form = QFormLayout() meta_form = QFormLayout()
meta_layout.addLayout(meta_form) meta_layout.addLayout(meta_form)
self.metadata: dict[str, QLineEdit] = {} self.metadata: dict[str, QLineEdit] = {}
@@ -582,7 +574,6 @@ class Editor(QMainWindow):
# metronome where "Andante" cannot. # metronome where "Andante" cannot.
edit.setValidator(QIntValidator(20, 400, edit)) edit.setValidator(QIntValidator(20, 400, edit))
edit.setPlaceholderText("BPM") edit.setPlaceholderText("BPM")
edit.setProperty("role", "number")
edit.setFixedWidth(90) edit.setFixedWidth(90)
meta_form.addRow(f"{field.replace('_', ' ').title()}{' *' if required else ''}", edit) meta_form.addRow(f"{field.replace('_', ' ').title()}{' *' if required else ''}", edit)
@@ -591,39 +582,27 @@ class Editor(QMainWindow):
self.optimise.clicked.connect(self._optimise_pdf) self.optimise.clicked.connect(self._optimise_pdf)
meta_layout.addWidget(self.optimise) meta_layout.addWidget(self.optimise)
# Open by default: the first thing a new user needs is to know that a
# double-click adds a cut, and a collapsed section does not tell them.
keys_layout = section("Keys and mouse", box)
keys = QLabel(ui.shortcut_html())
keys.setTextFormat(Qt.RichText)
keys_layout.addWidget(keys)
box.addStretch(1)
# Where you are and the way out, pinned below the scroll. Export is the
# one thing that must never be hidden by however far the panel is
# scrolled, and the count beside it is what says whether it is ready.
footer = QWidget()
column = QVBoxLayout(footer)
column.setContentsMargins(14, 0, 14, 12)
column.addWidget(ui.Rule())
self.summary = QLabel() self.summary = QLabel()
self.summary.setWordWrap(True) self.summary.setWordWrap(True)
self.summary.setProperty("role", "reading") box.addWidget(self.summary)
self.summary.setContentsMargins(0, 10, 0, 6)
column.addWidget(self.summary)
export = QPushButton("Export bundle…")
export.setProperty("role", "primary")
export.clicked.connect(self._export)
column.addWidget(export)
holder = QWidget() export = QPushButton("Export bundle…")
stack = QVBoxLayout(holder) export.clicked.connect(self._export)
stack.setContentsMargins(0, 0, 0, 0) box.addWidget(export)
stack.setSpacing(0) box.addStretch(1)
stack.addWidget(scroller, 1)
stack.addWidget(footer) help_text = QLabel(
holder.setMinimumWidth(300) "Double-click: add cut\n"
return holder "Drag: move cut · Ctrl-click: add vertex\n"
"Right-click: delete cut or vertex\n"
"Click a slice, then D to discard\n"
"Drag the blue edges: content rectangle\n"
"Jump markers: add, then click the target slice\n"
"Shift-double-click a slice: re-engrave it"
)
help_text.setStyleSheet("color: palette(mid);")
box.addWidget(help_text)
return panel
def _shortcuts(self) -> None: def _shortcuts(self) -> None:
for key, slot in ( for key, slot in (
@@ -663,31 +642,29 @@ class Editor(QMainWindow):
return return
self.index = index self.index = index
self.view.show_page(self.project, index, self._preview(index)) self.view.show_page(self.project, index, self._preview(index))
# The histogram is of the raw scan, not the levelled preview: it has to
# keep showing where the ink is while you drag the points over it.
self.levels.set_page(self._raster(index))
self._sync() self._sync()
def _sync(self) -> None: def _sync(self) -> None:
page = self.project.pages[self.index] page = self.project.pages[self.index]
self.rail.build([p.slice_count for p in self.project.pages], self.index) self.page_label.setText(f"Page {self.index + 1} / {len(self.project.pages)}")
self.skew.blockSignals(True) for widget, value in ((self.skew, page.skew),):
self.skew.setValue(page.skew) widget.blockSignals(True)
self.skew.blockSignals(False) widget.setValue(value)
self.levels.set_levels(*self.project.page_levels(self.index)) widget.blockSignals(False)
bar = page.bars[self.view.selected_slice] black, white = self.project.page_levels(self.index)
self.bar.blockSignals(True) for widget, value in ((self.black, black), (self.white, white)):
self.bar.setText("" if bar is None else str(bar)) widget.blockSignals(True)
self.bar.blockSignals(False) widget.setValue(value)
widget.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())
total = sum(p.slice_count for p in self.project.pages) total = sum(p.slice_count for p in self.project.pages)
state = "discarded" if page.discards[self.view.selected_slice] else "kept" state = "discarded" if page.discards[self.view.selected_slice] else "kept"
self.summary.setText( self.summary.setText(
f"page {self.index + 1}/{len(self.project.pages)} · " f"{page.slice_count} slices on this page · slice "
f"slice {self.view.selected_slice + 1}/{page.slice_count} is {state}\n" f"{self.view.selected_slice + 1} is {state}\n"
f"{kept} of {total} slices ship" f"{kept} of {total} slices kept in the song"
) )
# -- edits ------------------------------------------------------------ # -- edits ------------------------------------------------------------
@@ -696,18 +673,13 @@ 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))
self._touched() self._touched()
def _levels_changed(self, black: int, white: int) -> None: def _levels_changed(self) -> None:
self.project.pages[self.index].levels = (black, white) self.project.pages[self.index].levels = (self.black.value(), self.white.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))
self._touched() self._touched()
@@ -721,7 +693,7 @@ class Editor(QMainWindow):
self.retarget.setEnabled(kind in JUMP_TYPES) self.retarget.setEnabled(kind in JUMP_TYPES)
def _add_marker(self) -> None: def _add_marker(self) -> None:
kind = self.marker_type.currentData() kind = self.marker_type.currentText()
label = self.marker_label.text().strip() or None label = self.marker_label.text().strip() or None
marker = Marker(type=kind, label=label if kind in LABELLED_TYPES else None) marker = Marker(type=kind, label=label if kind in LABELLED_TYPES else None)
self._slot_markers().append(marker) self._slot_markers().append(marker)
@@ -917,7 +889,6 @@ class Editor(QMainWindow):
def launch(pdf: Path, source_type=None, resume: bool = False) -> int: def launch(pdf: Path, source_type=None, resume: bool = False) -> int:
app = QApplication(sys.argv[:1]) app = QApplication(sys.argv[:1])
app.setStyleSheet(ui.STYLESHEET)
source = open_source(pdf, source_type) source = open_source(pdf, source_type)
# An exported project is spent: this opens a fresh session from detection # An exported project is spent: this opens a fresh session from detection
+16 -41
View File
@@ -14,7 +14,7 @@ from __future__ import annotations
import cv2 import cv2
import numpy as np import numpy as np
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtGui import QImage, QIntValidator, QKeySequence, QPixmap, QShortcut from PySide6.QtGui import QImage, QKeySequence, QPixmap, QShortcut
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QCheckBox, QCheckBox,
QComboBox, QComboBox,
@@ -31,9 +31,8 @@ from PySide6.QtWidgets import (
QWidget, QWidget,
) )
from . import lilypond, panel as ui from . import lilypond
from .detect import staff_height from .detect import staff_height
from .editor import section
from .project import Project, Replacement, Voice from .project import Project, Replacement, Voice
@@ -96,9 +95,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)
self.state = project.pages[page] state = project.pages[page]
self.replacement = self.state.replacements[slot] or self._seed() self.replacement = state.replacements[slot] or self._seed()
self.state.replacements[slot] = self.replacement 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)))
@@ -141,7 +140,7 @@ class EngraveWindow(QDialog):
box = QVBoxLayout(panel) box = QVBoxLayout(panel)
box.setContentsMargins(0, 0, 0, 0) box.setContentsMargins(0, 0, 0, 0)
heading = QLabel(title) heading = QLabel(title)
heading.setStyleSheet(ui.HEADING.replace("QToolButton", "QLabel")) heading.setStyleSheet("font-weight: 700; color: #808080;")
box.addWidget(heading) box.addWidget(heading)
view = label or QLabel() view = label or QLabel()
@@ -181,22 +180,10 @@ class EngraveWindow(QDialog):
self.print_time.toggled.connect(self._settings_changed) self.print_time.toggled.connect(self._settings_changed)
row.addWidget(self.print_time, 1) row.addWidget(self.print_time, 1)
top.addRow("Time", row) top.addRow("Time", row)
# 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")
self.bar.setToolTip("Printed above the first bar, as a printed score numbers its systems")
self.bar.textChanged.connect(self._bar_changed)
top.addRow("First bar", self.bar)
box.addLayout(top) box.addLayout(top)
voices_label = QLabel("Voices") voices_label = QLabel("Voices")
voices_label.setStyleSheet(ui.HEADING.replace("QToolButton", "QLabel")) voices_label.setStyleSheet("font-weight: 700; color: #808080;")
box.addWidget(voices_label) box.addWidget(voices_label)
self.voice_box = QVBoxLayout() self.voice_box = QVBoxLayout()
@@ -211,7 +198,6 @@ class EngraveWindow(QDialog):
remove = QPushButton("Remove last voice") remove = QPushButton("Remove last voice")
remove.clicked.connect(self._remove_voice) remove.clicked.connect(self._remove_voice)
render = QPushButton("Render (Ctrl+↵)") render = QPushButton("Render (Ctrl+↵)")
render.setProperty("role", "primary")
render.clicked.connect(self.render) render.clicked.connect(self.render)
drop = QPushButton("Discard replacement") drop = QPushButton("Discard replacement")
drop.clicked.connect(self._discard) drop.clicked.connect(self._discard)
@@ -223,15 +209,11 @@ class EngraveWindow(QDialog):
self.status.setWordWrap(True) self.status.setWordWrap(True)
box.addWidget(self.status) box.addWidget(self.status)
# Collapsed: the source is what the form writes for you, so it is for
# checking what a field did, not for working in. Open it and it stays
# open for the life of the window.
raw = section("LilyPond source", box, expanded=False)
self.generated = QPlainTextEdit() self.generated = QPlainTextEdit()
self.generated.setReadOnly(True) self.generated.setReadOnly(True)
self.generated.setMaximumHeight(220) self.generated.setMaximumHeight(120)
self.generated.setStyleSheet(f"color: {ui.GRAPHITE};") self.generated.setStyleSheet("color: #808080;")
raw.addWidget(self.generated) box.addWidget(self.generated)
self._refresh_source() self._refresh_source()
return panel return panel
@@ -258,10 +240,6 @@ class EngraveWindow(QDialog):
row.setParent(None) row.setParent(None)
self._refresh_source() self._refresh_source()
def _bar_changed(self, text: str) -> None:
self.state.bars[self.slot] = int(text) if text.strip().isdigit() else None
self._refresh_source()
def _settings_changed(self) -> None: def _settings_changed(self) -> None:
# Set on the song, not the slice: they are song properties in practice, # Set on the song, not the slice: they are song properties in practice,
# and this is what makes the next re-engraved slice open pre-filled. # and this is what makes the next re-engraved slice open pre-filled.
@@ -276,25 +254,22 @@ 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 _source(self) -> str:
return lilypond.generate(
self.replacement, self.project.key, self.project.time, self.state.bars[self.slot]
)
def _refresh_source(self) -> None: def _refresh_source(self) -> None:
self.generated.setPlainText(self._source()) self.generated.setPlainText(
lilypond.generate(self.replacement, self.project.key, self.project.time)
)
# -- rendering -------------------------------------------------------- # -- rendering --------------------------------------------------------
def render(self) -> None: def render(self) -> None:
source = self._source() source = lilypond.generate(self.replacement, self.project.key, self.project.time)
self.status.setStyleSheet(f"color: {ui.GRAPHITE};") self.status.setStyleSheet("color: #808080;")
self.status.setText("rendering…") self.status.setText("rendering…")
self.repaint() self.repaint()
try: try:
image = lilypond.render(source) image = lilypond.render(source)
except lilypond.LilypondError as error: except lilypond.LilypondError as error:
self.status.setStyleSheet(f"color: {ui.PROOF};") self.status.setStyleSheet("color: #c0392b;")
self.status.setText(str(error)[-600:]) self.status.setText(str(error)[-600:])
return return
+3 -41
View File
@@ -12,7 +12,6 @@ scaling.
from __future__ import annotations from __future__ import annotations
import re
import shutil import shutil
import subprocess import subprocess
import tempfile import tempfile
@@ -64,25 +63,6 @@ RELATIVE_REFERENCE = {
"bass": "c", "bass": "c",
} }
# LilyPond renamed the repeat barlines and silently draws *nothing* for the old
# names — no error, no warning, just a missing repeat that you find on the
# tablet. Every book, every forum answer and every score anyone has typed before
# uses the old ones, so translate them.
_BAR_ALIASES = {
"|:": ".|:",
":|": ":|.",
":|:": ":|.|:",
"||:": ".|:",
":||": ":|.",
":||:": ":|.|:",
}
_BAR = re.compile(r'(\\bar\s*")([^"]*)(")')
def _modernise_bars(notes: str) -> str:
return _BAR.sub(lambda m: m[1] + _BAR_ALIASES.get(m[2], m[2]) + m[3], notes)
_PREAMBLE = """\\version "2.24.0" _PREAMBLE = """\\version "2.24.0"
\\paper { \\paper {
indent = 0\\mm indent = 0\\mm
@@ -94,7 +74,7 @@ _PREAMBLE = """\\version "2.24.0"
""" """
def generate(replacement, key: str, time: str, bar: int | None = None) -> str: def generate(replacement, key: str, time: str) -> 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
@@ -105,35 +85,17 @@ def generate(replacement, key: str, time: str, bar: int | None = None) -> str:
key = replacement.key or key key = replacement.key or key
time = replacement.time or time time = replacement.time or time
# Bar numbering is a Score property, so it is set once, on the first staff.
# Visible at the beginning of a line and nowhere else — which in a
# one-system slice means exactly one number, above the first bar, the way a
# printed score numbers its systems. The empty bar line is what gives the
# number a line beginning to attach to.
number = ""
if bar:
number = (
f" \\set Score.currentBarNumber = #{int(bar)}\n"
" \\override Score.BarNumber.break-visibility = #'#(#f #f #t)\n"
' \\bar ""\n'
)
staves = [] staves = []
for voice in replacement.voices: for voice in replacement.voices:
hide = "" if replacement.print_time else " \\omit Staff.TimeSignature\n" hide = "" if replacement.print_time else " \\omit Staff.TimeSignature\n"
body = _modernise_bars(voice.notes.strip()) or "s1" body = voice.notes.strip() or "s1"
reference = RELATIVE_REFERENCE.get(voice.clef, "c'") reference = RELATIVE_REFERENCE.get(voice.clef, "c'")
staff = ( staff = (
" \\new Staff {\n" " \\new Staff {\n"
f"{hide}" f"{hide}"
# Quoted, because an octavated name has to be: unquoted, f" \\clef {voice.clef}\n"
# `\clef treble_8` parses as a plain treble clef with a stray "8"
# markup that lands under the first note, and the staff then reads
# an octave off.
f' \\clef "{voice.clef}"\n'
f" \\key {key} \\major\n" f" \\key {key} \\major\n"
f" \\time {time}\n" f" \\time {time}\n"
f"{number if not staves else ''}"
f" \\relative {reference} {{ {body} }}\n" f" \\relative {reference} {{ {body} }}\n"
" }\n" " }\n"
) )
-373
View File
@@ -1,373 +0,0 @@
"""Look and feel for the editor: palette, chrome, and two custom controls.
The page already speaks a colour language red cut lines, a blue content
rectangle, purple marker chips, amber for a re-engraved system. The panel
speaks the same one, from the same constants, so a colour means one thing in
this window rather than two. Everything else is neutral, and the chrome is
dark for the reason photo editors are: the scanned page should be the
brightest object on screen, because it is the thing being judged.
Numbers are set in mono and prose is not. This is a measuring tool; skew,
levels, bar and page numbers are measurements, and they line up in a column
when they are monospaced.
"""
from __future__ import annotations
import numpy as np
from PySide6.QtCore import QRectF, Qt, Signal
from PySide6.QtGui import QBrush, QColor, QFont, QLinearGradient, QPainter, QPen
from PySide6.QtWidgets import QGridLayout, QSizePolicy, QToolButton, QWidget
INK = "#14161a" # window chrome
DESK = "#1d2026" # panel surface
RAISED = "#262a33" # inputs, chips
RULE = "#333844" # hairlines
GRAPHITE = "#8b93a3" # secondary text and section headings
PAPER = "#e6e9f0" # primary text, borrowed from the scan
PROOF = "#dc2828" # cuts
CROP = "#288cdc" # content rectangle, primary action
MARK = "#9638be" # markers
PLATE = "#c87800" # re-engraved
MONO = '"JetBrains Mono", "DejaVu Sans Mono", "Menlo", monospace'
STYLESHEET = f"""
QMainWindow, QDialog {{ background: {INK}; }}
QWidget {{ color: {PAPER}; font-size: 13px; }}
QScrollArea, QScrollArea > QWidget > QWidget {{ background: {DESK}; border: none; }}
QSplitter::handle {{ background: {RULE}; width: 1px; }}
QGraphicsView {{ background: {INK}; border: none; }}
QLabel {{ background: transparent; }}
QLabel[role="hint"] {{ color: {GRAPHITE}; font-size: 12px; }}
QLabel[role="reading"] {{ color: {PAPER}; font-family: {MONO}; font-size: 12px; }}
QLineEdit, QComboBox, QDoubleSpinBox, QListWidget {{
background: {RAISED}; border: 1px solid {RULE}; border-radius: 3px;
padding: 4px 6px; selection-background-color: {CROP};
}}
QLineEdit:focus, QComboBox:focus, QDoubleSpinBox:focus, QListWidget:focus {{
border-color: {CROP};
}}
QLineEdit[role="number"], QDoubleSpinBox {{ font-family: {MONO}; }}
QComboBox::drop-down {{ border: none; width: 18px; }}
QDoubleSpinBox::up-button, QDoubleSpinBox::down-button {{
background: {RULE}; border: none; width: 16px;
}}
QDoubleSpinBox::up-arrow, QDoubleSpinBox::down-arrow {{ width: 7px; height: 7px; }}
QComboBox QAbstractItemView {{
background: {RAISED}; border: 1px solid {RULE}; selection-background-color: {CROP};
}}
QListWidget::item {{ padding: 2px 4px; }}
QListWidget::item:selected {{ background: {MARK}; }}
QPushButton {{
background: {RAISED}; border: 1px solid {RULE}; border-radius: 3px;
padding: 6px 12px;
}}
QPushButton:hover {{ border-color: {GRAPHITE}; }}
QPushButton:pressed {{ background: {RULE}; }}
QPushButton:disabled {{ color: {RULE}; }}
QPushButton:focus {{ border-color: {CROP}; }}
QPushButton[role="primary"] {{
background: {CROP}; border-color: {CROP}; color: #ffffff;
font-weight: 600; padding: 9px 12px;
}}
QPushButton[role="primary"]:hover {{ background: #3a9de8; }}
QScrollBar:vertical {{ background: {DESK}; width: 10px; margin: 0; }}
QScrollBar:horizontal {{ background: {DESK}; height: 10px; margin: 0; }}
QScrollBar::handle {{ background: {RULE}; border-radius: 5px; min-height: 30px; }}
QScrollBar::handle:hover {{ background: {GRAPHITE}; }}
QScrollBar::add-line, QScrollBar::sub-line {{ height: 0; width: 0; }}
QScrollBar::add-page, QScrollBar::sub-page {{ background: transparent; }}
QCheckBox {{ spacing: 7px; }}
QCheckBox::indicator {{
width: 14px; height: 14px; border: 1px solid {RULE};
border-radius: 3px; background: {RAISED};
}}
QCheckBox::indicator:checked {{ background: {CROP}; border-color: {CROP}; }}
QCheckBox::indicator:hover {{ border-color: {GRAPHITE}; }}
QStatusBar {{ background: {INK}; color: {GRAPHITE}; }}
QToolTip {{ background: {RAISED}; color: {PAPER}; border: 1px solid {RULE}; padding: 4px; }}
"""
HEADING = f"""
QToolButton {{
border: none; background: transparent; text-align: left;
color: {GRAPHITE}; font-size: 11px; font-weight: 700;
letter-spacing: 1.4px; padding: 10px 0 5px 0;
}}
QToolButton:hover {{ color: {PAPER}; }}
"""
def mono(size: int = 12, weight: int = QFont.Normal) -> QFont:
font = QFont("JetBrains Mono", size, weight)
font.setStyleHint(QFont.Monospace)
return font
class Rule(QWidget):
"""A hairline between sections. Structure the eye can follow without boxes."""
def __init__(self) -> None:
super().__init__()
self.setFixedHeight(1)
self.setStyleSheet(f"background: {RULE};")
class PageRail(QWidget):
"""One chip per page, each carrying its slice count.
Replaces a 1/6 stepper. The song *is* a sequence of pages with a
number of systems on each, and seeing that sequence is how you notice the
page where detection found one slice where the others found three the
failure this tool actually produces.
"""
picked = Signal(int)
COLUMNS = 7 # ponytail: fixed, sized for the panel's minimum width
def __init__(self) -> None:
super().__init__()
self.buttons: list[QToolButton] = []
self.grid = QGridLayout(self)
self.grid.setContentsMargins(0, 0, 0, 0)
self.grid.setSpacing(4)
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
def build(self, counts: list[int], current: int) -> None:
while self.buttons:
chip = self.buttons.pop()
self.grid.removeWidget(chip)
chip.deleteLater()
for i, count in enumerate(counts):
chip = QToolButton()
chip.setText(f"{i + 1}\n{count}")
chip.setFont(mono(11))
chip.setFixedSize(34, 38)
chip.setCursor(Qt.PointingHandCursor)
chip.setToolTip(f"Page {i + 1}{count} slice{'s' if count != 1 else ''}")
chip.setStyleSheet(self._chip_style(i == current))
chip.clicked.connect(lambda _=False, n=i: self.picked.emit(n))
self.grid.addWidget(chip, i // self.COLUMNS, i % self.COLUMNS)
self.buttons.append(chip)
self.grid.setColumnStretch(self.COLUMNS, 1)
@staticmethod
def _chip_style(current: bool) -> str:
return (
f"QToolButton {{ background: {'#12405f' if current else RULE};"
f" border: 1px solid {CROP if current else '#454b59'}; border-radius: 3px;"
f" color: {PAPER if current else GRAPHITE}; }}"
f"QToolButton:hover {{ border-color: {PAPER}; color: {PAPER}; }}"
)
class LevelsBar(QWidget):
"""The scan's own ink distribution, with the black and white points on it.
The signature control of this window, and the one place worth spending
pixels: getting levels wrong is the single mistake that cannot be seen
until the bundle is on the tablet, and two anonymous sliders give no reason
to move either one. Here the paper hump and the ink hump are visible, the
handles sit on them, and the strip underneath shows the tone ramp that
results grey ink looks grey right there.
"""
changed = Signal(int, int)
RAMP = 14 # height of the tone strip under the histogram
GRAB = 7
def __init__(self) -> None:
super().__init__()
self.hist = np.zeros(256)
self.black, self.white = 0, 255
self._drag: str | None = None
self.setMinimumHeight(96)
self.setMouseTracking(True)
self.setCursor(Qt.SizeHorCursor)
self.setFocusPolicy(Qt.StrongFocus)
def set_page(self, gray: np.ndarray) -> None:
counts = np.bincount(gray.ravel(), minlength=256).astype(float)
# Square root, clipped to the tallest bin that is not the paper spike.
# Linear buries the ink hump under a spike two orders of magnitude
# taller; log flattens everything into one slab. This keeps both humps
# shaped like humps, which is the whole point of showing them.
scale = np.sqrt(counts)
ceiling = np.partition(scale, -3)[-3] or scale.max() or 1.0
self.hist = np.clip(scale / ceiling, 0, 1)
self.update()
def set_levels(self, black: int, white: int) -> None:
self.black, self.white = black, white
self.update()
# -- painting ---------------------------------------------------------
def _x(self, value: int) -> float:
return value / 255 * (self.width() - 1)
def paintEvent(self, event) -> None:
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
w, h = self.width(), self.height()
top = h - self.RAMP - 10
p.fillRect(0, 0, w, top, QColor(INK))
p.setPen(Qt.NoPen)
p.setBrush(QColor("#5f7d99"))
for value in range(256):
bar = self.hist[value] * (top - 4)
p.drawRect(QRectF(self._x(value), top - bar, max(w / 256, 1.0), bar))
# What is clipped away, dimmed at both ends.
p.setBrush(QColor(20, 22, 26, 170))
p.drawRect(QRectF(0, 0, self._x(self.black), top))
p.drawRect(QRectF(self._x(self.white), 0, w - self._x(self.white), top))
ramp = QLinearGradient(self._x(self.black), 0, self._x(self.white), 0)
ramp.setColorAt(0.0, QColor(0, 0, 0))
ramp.setColorAt(1.0, QColor(255, 255, 255))
p.setBrush(QBrush(ramp))
p.drawRect(QRectF(0, h - self.RAMP, w, self.RAMP))
p.fillRect(QRectF(0, h - self.RAMP, self._x(self.black), self.RAMP), QColor(0, 0, 0))
p.fillRect(
QRectF(self._x(self.white), h - self.RAMP, w - self._x(self.white), self.RAMP),
QColor(255, 255, 255),
)
for value, colour in ((self.black, QColor(PAPER)), (self.white, QColor(CROP))):
x = self._x(value)
p.setPen(QPen(colour, 2))
p.drawLine(QRectF(x, 0, 0, h).topLeft(), QRectF(x, 0, 0, h).bottomLeft())
p.setPen(Qt.NoPen)
p.setBrush(colour)
p.drawEllipse(QRectF(x - 4, top + 1, 8, 8))
# Readouts inside the histogram, not on the tone strip: white text on
# the pale end of that ramp is unreadable exactly when the white point
# is where you most need to read it.
p.setFont(mono(10))
p.setPen(QColor(PAPER))
p.drawText(
QRectF(5, 2, w - 10, 16), Qt.AlignLeft | Qt.AlignVCenter, f"black {self.black}"
)
p.setPen(QColor(CROP))
p.drawText(
QRectF(5, 2, w - 10, 16), Qt.AlignRight | Qt.AlignVCenter, f"white {self.white}"
)
# -- interaction ------------------------------------------------------
def _nearest(self, x: float) -> str:
return "black" if abs(x - self._x(self.black)) <= abs(x - self._x(self.white)) else "white"
def mousePressEvent(self, event) -> None:
self._drag = self._nearest(event.position().x())
self.mouseMoveEvent(event)
def mouseMoveEvent(self, event) -> None:
if not self._drag:
return
value = int(round(event.position().x() / max(self.width() - 1, 1) * 255))
value = min(255, max(0, value))
if self._drag == "black":
self.black = min(value, self.white - 1)
else:
self.white = max(value, self.black + 1)
self.update()
self.changed.emit(self.black, self.white)
def mouseReleaseEvent(self, event) -> None:
self._drag = None
def keyPressEvent(self, event) -> None:
step = {Qt.Key_Left: -1, Qt.Key_Right: 1}.get(event.key())
if step is None:
return super().keyPressEvent(event)
# Shift picks the white point, so the whole control is reachable from
# the keyboard without a second focus stop.
if event.modifiers() & Qt.ShiftModifier:
self.white = min(255, max(self.black + 1, self.white + step))
else:
self.black = max(0, min(self.white - 1, self.black + step))
self.update()
self.changed.emit(self.black, self.white)
def keycap(text: str) -> str:
"""A key name as inline HTML, for the shortcut list."""
return (
f'<span style="font-family:{MONO}; background:{RAISED}; color:{PAPER};'
f' border:1px solid {RULE}; padding:1px 4px;">{text}</span>'
)
SHORTCUTS = [
("Double-click", "add a cut"),
("Drag", "move a cut"),
("Ctrl-click", "add a vertex"),
("Right-click", "delete a cut or vertex"),
("D", "discard the selected slice"),
("Shift-double-click", "re-engrave a slice"),
("PgUp / PgDn", "change page"),
("Ctrl+S", "save"),
]
def shortcut_html() -> str:
rows = "".join(
f"<tr><td style='padding:2px 10px 2px 0'>{keycap(k)}</td>"
f"<td style='color:{GRAPHITE}'>{v}</td></tr>"
for k, v in SHORTCUTS
)
return f"<table cellspacing='0'>{rows}</table>"
def demo() -> None:
"""Self-check: the histogram and handles behave without a real page."""
from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([])
bar = LevelsBar()
bar.resize(300, 96)
bar.set_page(np.array([[10, 10, 250, 250, 250]], np.uint8))
assert bar.hist[250] == 1.0 and 0 < bar.hist[10] <= 1.0, bar.hist[[10, 250]]
assert bar.hist[128] == 0.0, "an empty bin draws nothing"
bar.set_levels(40, 200)
seen: list[tuple[int, int]] = []
bar.changed.connect(lambda b, w: seen.append((b, w)))
bar._drag = "white"
bar.white = 30 # a drag past the black point must not invert the ramp
bar.set_levels(40, 200)
bar.keyPressEvent(_Key(Qt.Key_Left, Qt.NoModifier))
assert bar.black == 39 and seen[-1] == (39, 200), (bar.black, seen)
bar.keyPressEvent(_Key(Qt.Key_Right, Qt.ShiftModifier))
assert bar.white == 201, bar.white
bar.grab() # paints; raises if the painter path is wrong
assert PageRail()._chip_style(True) != PageRail._chip_style(False)
del app
print("ok")
class _Key:
def __init__(self, key, modifiers):
self._key, self._mod = key, modifiers
def key(self):
return self._key
def modifiers(self):
return self._mod
if __name__ == "__main__":
demo()
+1 -28
View File
@@ -113,11 +113,7 @@ class Marker:
return self.type in JUMP_TYPES return self.type in JUMP_TYPES
def describe(self) -> str: def describe(self) -> str:
"""For the marker list and the badge drawn on the page — never the wire. text = self.type
A musician reads "D.S. al coda" off the score, not `ds_al_coda`.
"""
text = self.type.replace("_", " ").capitalize()
if self.label: if self.label:
text += f"{self.label}" text += f"{self.label}"
if self.destination: if self.destination:
@@ -168,11 +164,6 @@ 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
@@ -198,10 +189,6 @@ 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:
@@ -214,8 +201,6 @@ 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."""
@@ -307,7 +292,6 @@ 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,
@@ -375,7 +359,6 @@ class Project:
} }
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,
} }
@@ -432,16 +415,6 @@ class Project:
) )
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,
) )
+1 -3
View File
@@ -161,9 +161,7 @@ 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( lilypond.generate(engraved, project.key, project.time)
engraved, project.key, project.time, page_state.bars[slot]
)
) )
else: else:
if page is None: if page is None:
+2 -7
View File
@@ -77,14 +77,9 @@ def main() -> int:
# Skew and levels reach the model and re-render without raising. # Skew and levels reach the model and re-render without raising.
editor.skew.setValue(-1.4) editor.skew.setValue(-1.4)
assert abs(page.skew + 1.4) < 1e-6 assert abs(page.skew + 1.4) < 1e-6
# The levels bar carries the page's own histogram and drives the model editor.black.setValue(40)
# directly — there are no sliders behind it to keep in step. editor.white.setValue(210)
assert editor.levels.hist.sum() > 0, "the histogram should hold the scan"
editor.levels.set_levels(40, 210)
editor._levels_changed(40, 210)
assert project.page_levels(0) == (40, 210) assert project.page_levels(0) == (40, 210)
editor.rail.picked.emit(0) # the page rail navigates
assert editor.index == 0 and len(editor.rail.buttons) == len(project.pages)
# Metadata. # Metadata.
editor.metadata["title"].setText("Ketun joululaulu") editor.metadata["title"].setText("Ketun joululaulu")
-36
View File
@@ -52,40 +52,6 @@ def _scan_pdf(path: Path) -> None:
def main() -> int: def main() -> int:
# Bar aliases are string work, so they are checked whether or not LilyPond
# is installed. The old repeat names draw nothing at all in 2.24 — silently,
# which is how a missing repeat reaches a tablet.
aliased = lilypond.generate(
Replacement(voices=[Voice("treble", 'c4 d \\bar ":|" e f \\bar "|:" g', "")]), "c", "4/4"
)
assert '\\bar ":|."' in aliased and '\\bar ".|:"' in aliased, aliased
kept = lilypond.generate(
Replacement(voices=[Voice("treble", 'c4 \\bar "|." d', "")]), "c", "4/4"
)
assert '\\bar "|."' in kept, "a name LilyPond still knows is left alone"
# An octavated clef name must be quoted. Unquoted, `\clef treble_8` is a
# plain treble with a stray "8" markup under the first note, an octave off.
tenor = lilypond.generate(
Replacement(voices=[Voice("treble_8", "c4 d", "")]), "c", "4/4"
)
assert '\\clef "treble_8"' in tenor, tenor
# A bar number is set once, on the first staff, since it is a Score
# 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", "")]),
"c",
"4/4",
33,
)
assert numbered.count("currentBarNumber = #33") == 1, numbered
assert "break-visibility = #'#(#f #f #t)" in numbered
assert "currentBarNumber" not in lilypond.generate(
Replacement(voices=[Voice("treble", "c4 d", "")]), "c", "4/4"
), "an unnumbered system prints no number"
if not lilypond.available(): if not lilypond.available():
print("ok (skipped: LilyPond not installed)") print("ok (skipped: LilyPond not installed)")
return 0 return 0
@@ -172,7 +138,6 @@ 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.
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)
@@ -181,7 +146,6 @@ 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 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()
+2 -81
View File
@@ -70,16 +70,11 @@ 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()))]
@@ -103,18 +98,14 @@ def main() -> int:
# from the bundle alone. # from the bundle alone.
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 ", " ")]
) )
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"]) == ("aes", "3/4", False) 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"
@@ -145,78 +136,8 @@ 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"
# A bundle from a producer that records no cuts: refused by default, and
# cut from scratch by detection when the caller says so. The slices are in
# reading order either way, so markers can be lined up by position — but
# only when detection finds exactly as many.
plain = tmp / "plain.zip"
with zipfile.ZipFile(out) as src, zipfile.ZipFile(plain, "w") as dst:
for name in src.namelist():
data = src.read(name)
if name == "song.json":
manifest = json.loads(data)
manifest.pop("source")
for entry in manifest["slices"]:
entry.pop("page", None)
entry.pop("slot", None)
data = json.dumps(manifest).encode()
dst.writestr(name, data)
assert bundle.has_cuts(out) and not bundle.has_cuts(plain)
try:
bundle.read(plain, tmp / "nocuts.pdf")
except bundle.NoCuts as error:
assert "no cuts" in str(error), error
else:
raise AssertionError("a bundle without cuts should not open silently")
cut_again, again_pdf = bundle.read(plain, tmp / "nocuts.pdf", detect=True)
assert again_pdf.exists()
assert cut_again.metadata["title"] == "Test song", "the title block still comes back"
assert len(cut_again.kept_slices()) == len(reloaded.kept_slices())
placed = [m for page in cut_again.pages for slot in page.markers for m in slot]
assert len(placed) == 3, placed
assert placed[0].label == "A"
# 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 ( for f in (pdf, out, saved, default_path(pdf)):
pdf,
out,
plain,
saved,
unpacked,
again_pdf,
default_path(unpacked),
default_path(again_pdf),
default_path(pdf),
):
f.unlink(missing_ok=True) f.unlink(missing_ok=True)
tmp.rmdir() tmp.rmdir()
print("ok") print("ok")