Compare commits
5
Commits
6ce45bb1d8
...
0da9dc29bf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0da9dc29bf | ||
|
|
cf1344c5bf | ||
|
|
630541c0cd | ||
|
|
b594968bb8 | ||
|
|
b8d93cee47 |
@@ -8,7 +8,8 @@ A slice is one *system* — one full line of music across all voices, typically
|
|||||||
scroll, so the slicer's job is to cut a printed page into systems, clean them up
|
scroll, so the slicer's job is to cut a printed page into systems, clean them up
|
||||||
enough to read on a tablet, and tag them with the score's navigation symbols.
|
enough to read on a tablet, and tag them with the score's navigation symbols.
|
||||||
|
|
||||||
**Status: design only.** No code yet. The design is settled; see below.
|
**New here? [docs/guide.md](docs/guide.md) walks you through making your first
|
||||||
|
bundle.**
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
@@ -29,22 +30,28 @@ at it.
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
Not yet installable. When it is:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
uv tool install --editable .
|
uv tool install --editable .
|
||||||
```
|
```
|
||||||
|
|
||||||
That puts a `noteman-slicer` command on PATH which runs from any directory — no venv to
|
That puts a `noteman-slicer` command on PATH which runs from any directory — no venv to
|
||||||
activate. Dependencies (PyMuPDF, PySide6, OpenCV, numpy) are all wheels; nothing
|
activate. Dependencies (PyMuPDF, PySide6, OpenCV, numpy) are all wheels; nothing
|
||||||
needs a system package.
|
needs a system package. LilyPond is optional and only enables re-engraving.
|
||||||
|
|
||||||
|
Then:
|
||||||
|
|
||||||
|
```
|
||||||
|
noteman-slicer edit my-song.pdf
|
||||||
|
```
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| [docs/guide.md](docs/guide.md) | How to use it: install, cut a score, place markers, export a bundle. Start here if you just want to make one. |
|
||||||
| [CONTEXT.md](CONTEXT.md) | Glossary. What a slice, cut, discard, bundle and song scale actually mean here. Start here. |
|
| [CONTEXT.md](CONTEXT.md) | Glossary. What a slice, cut, discard, bundle and song scale actually mean here. Start here. |
|
||||||
| [docs/spec.md](docs/spec.md) | The specification: pipeline, geometry model, detection, editor, bundle format, and what noteman has to change. |
|
| [docs/spec.md](docs/spec.md) | The specification: pipeline, geometry model, detection, editor, bundle format, and what noteman has to change. |
|
||||||
|
| [docs/bundle-format.md](docs/bundle-format.md) | The Score Bundle Format — a standalone specification of the export format, independent of this tool. |
|
||||||
|
|
||||||
Deferred work is tracked as issues and milestones on the Gitea repo, not in this
|
Deferred work is tracked as issues and milestones on the Gitea repo, not in this
|
||||||
tree.
|
tree.
|
||||||
|
|||||||
@@ -0,0 +1,364 @@
|
|||||||
|
# Score Bundle Format, version 1
|
||||||
|
|
||||||
|
A container for one musical score, prepared for continuous-scroll display.
|
||||||
|
|
||||||
|
A bundle holds the score as a sequence of images — one per system of music —
|
||||||
|
together with the metadata that names the piece and the markers that describe
|
||||||
|
how a performer navigates it. It is self-contained: nothing outside the file is
|
||||||
|
needed to present the score.
|
||||||
|
|
||||||
|
This document defines the format. It does not describe any particular program
|
||||||
|
that writes or reads one.
|
||||||
|
|
||||||
|
## Terminology
|
||||||
|
|
||||||
|
**Slice** — one *system* of music: a single line spanning all voices, typically
|
||||||
|
four to twelve bars, with lyrics intact. A slice is the atomic unit of the
|
||||||
|
format. A slice is presented as an image; a slice that was engraved rather than
|
||||||
|
scanned may also carry the notation it was engraved from.
|
||||||
|
|
||||||
|
**Marker** — a semantic annotation attached to a slice, describing a navigational
|
||||||
|
feature printed in the score: a rehearsal letter, a repeat, a jump.
|
||||||
|
|
||||||
|
**Producer** — anything that writes a bundle. **Consumer** — anything that reads
|
||||||
|
one.
|
||||||
|
|
||||||
|
## Container
|
||||||
|
|
||||||
|
A bundle is a ZIP archive.
|
||||||
|
|
||||||
|
```
|
||||||
|
<name>.zip
|
||||||
|
├── song.json manifest: metadata, slice order, markers
|
||||||
|
├── original.pdf the source document (optional)
|
||||||
|
├── 001.webp
|
||||||
|
├── 002.webp
|
||||||
|
└── … one file per slice
|
||||||
|
```
|
||||||
|
|
||||||
|
- `song.json` is required and must be at the archive root.
|
||||||
|
- Slice images are at the archive root. Their names are given in `song.json`;
|
||||||
|
the zero-padded numbering shown is conventional, not required.
|
||||||
|
- `original.pdf` is optional. When present it is the document the score was
|
||||||
|
prepared from, carried along for printing or archival. It is not required to
|
||||||
|
present the score and consumers may ignore it.
|
||||||
|
- No directories, and no entries beyond those referenced by the manifest plus
|
||||||
|
the optional PDF.
|
||||||
|
- Compression method is unconstrained. Producers typically deflate `song.json`
|
||||||
|
and store the images and PDF, which are already compressed.
|
||||||
|
|
||||||
|
For scale: a twelve-page, twenty-four-slice choral score runs about 2.4 MB, of
|
||||||
|
which roughly 830 KB is the source PDF and the rest slice images at ~20 KB each.
|
||||||
|
|
||||||
|
## Manifest
|
||||||
|
|
||||||
|
`song.json` is UTF-8 encoded JSON.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"v": 1,
|
||||||
|
"title": "Ketun joululaulu",
|
||||||
|
"composer": "trad.",
|
||||||
|
"arranger": "P. Rapi",
|
||||||
|
"tempo": 92,
|
||||||
|
"slices": [
|
||||||
|
{
|
||||||
|
"file": "001.webp",
|
||||||
|
"markers": [
|
||||||
|
{ "type": "rehearsal_letter", "label": "A" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "002.webp",
|
||||||
|
"markers": [
|
||||||
|
{ "type": "segno" },
|
||||||
|
{ "type": "to_coda", "destination": 7 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ "file": "003.webp" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Top-level fields
|
||||||
|
|
||||||
|
| Field | Type | | |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `v` | integer | required | Format version. `1` for this document. |
|
||||||
|
| `slices` | array | required | Ordered, at least one entry. See below. |
|
||||||
|
| `title` | string | required | The name of the piece. |
|
||||||
|
| `subtitle` | string | optional | Alternate or translated title. |
|
||||||
|
| `composer` | string | optional | Who wrote the music. |
|
||||||
|
| `original_artist` | string | optional | Who originally performed the work, where that differs from the composer. |
|
||||||
|
| `arranger` | string | optional | Who adapted it for these forces. |
|
||||||
|
| `lyricist` | string | optional | Who wrote the words. |
|
||||||
|
| `translator` | string | optional | Who translated the words. |
|
||||||
|
| `tempo` | integer | optional | Beats per minute. |
|
||||||
|
| `voices` | string | optional | The parts in this arrangement, as free text. |
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
`tempo` is a number, never a word: a figure can drive a metronome or a click
|
||||||
|
track, and verbal markings are not interchangeable between readers.
|
||||||
|
|
||||||
|
Unrecognised top-level fields may be added by future versions. A consumer should
|
||||||
|
ignore fields it does not know rather than reject the bundle.
|
||||||
|
|
||||||
|
### Slices
|
||||||
|
|
||||||
|
Each entry of `slices` is an object:
|
||||||
|
|
||||||
|
| Field | Type | | |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `file` | string | required | Name of the image entry in the archive. |
|
||||||
|
| `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). |
|
||||||
|
|
||||||
|
**The array order is the reading order of the score.** It is the only ordering
|
||||||
|
the format defines. Filenames often sort into the same order, but a consumer
|
||||||
|
must not derive order from them.
|
||||||
|
|
||||||
|
A slice's **index** is its zero-based position in this array. Indices are the
|
||||||
|
only identifiers the format has, and they are meaningful only within one bundle.
|
||||||
|
|
||||||
|
## Slice images
|
||||||
|
|
||||||
|
Every slice image in a bundle satisfies the following. A consumer can rely on
|
||||||
|
these and does not need to inspect the images to lay them out.
|
||||||
|
|
||||||
|
- **Format: WebP, losslessly encoded.** (Lossless rather than lossy because
|
||||||
|
engraved music is line art — large flat areas separated by thin high-contrast
|
||||||
|
strokes — which lossless encoders compress *better* than lossy ones as well as
|
||||||
|
exactly.)
|
||||||
|
- **RGBA, with all three colour channels zero.** The image is carried entirely
|
||||||
|
by the alpha channel: ink is opaque black, paper is fully transparent, and
|
||||||
|
antialiased edges are partially transparent. Compositing a slice over a
|
||||||
|
background of any colour reproduces the printed appearance on that colour of
|
||||||
|
paper.
|
||||||
|
- **Uniform width within a bundle.** Every slice has the same pixel width, so a
|
||||||
|
consumer can lay them out in a single column without measuring. Systems
|
||||||
|
shorter than the widest are padded on the right with transparent pixels; they
|
||||||
|
end early rather than stretching.
|
||||||
|
- **Width is at most 1920 pixels**, and is frequently less. A narrower bundle is
|
||||||
|
not a defect: images are never enlarged beyond the resolution of their source,
|
||||||
|
because that adds bytes and softness without adding detail. Consumers should
|
||||||
|
scale to fit their own layout and should not treat 1920 as a target.
|
||||||
|
- **Height varies per slice**, being the height of that system.
|
||||||
|
- **Slices need not be rectangular in content.** Where two systems interleave —
|
||||||
|
for example a section label printed level with the previous system's lyric
|
||||||
|
line — the boundary between them steps, and each slice is delivered as its
|
||||||
|
bounding box with the region belonging to its neighbour left transparent. This
|
||||||
|
requires nothing special from a consumer; it composites correctly.
|
||||||
|
|
||||||
|
Images are **presentation-ready**. They have already been deskewed, cropped,
|
||||||
|
levelled and scaled as a set. Re-encoding, re-cropping or re-scaling them
|
||||||
|
individually will at best waste work and at worst break the uniformity the
|
||||||
|
format guarantees.
|
||||||
|
|
||||||
|
One specific hazard is worth naming, because it is silent: an image pipeline
|
||||||
|
that *discards* the alpha channel rather than compositing it will turn every
|
||||||
|
slice into a solid black rectangle, since the colour channels are all zero.
|
||||||
|
|
||||||
|
## Engraving
|
||||||
|
|
||||||
|
Most slices are photographs of print: an image and nothing more. A slice that
|
||||||
|
was *engraved* — set from notation rather than scanned — can carry the notation
|
||||||
|
it came from, in an `engraving` object.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"file": "007.webp",
|
||||||
|
"engraving": {
|
||||||
|
"lang": "lilypond",
|
||||||
|
"key": "aes",
|
||||||
|
"time": "4/4",
|
||||||
|
"print_time": false,
|
||||||
|
"voices": [
|
||||||
|
{ "clef": "treble", "notes": "c4 des ees f | ees2. r4", "lyrics": "Kai -- paa -- va sy -- dän" },
|
||||||
|
{ "clef": "treble_8", "notes": "aes,4 aes aes aes | aes2. r4" },
|
||||||
|
{ "clef": "bass", "notes": "aes,4 ges f ees | aes2. r4" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | | |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `lang` | string | required | The notation language. `"lilypond"` is the only value defined by this version. |
|
||||||
|
| `voices` | array | required | One entry per staff, in the order they are printed top to bottom. At least one. |
|
||||||
|
| `key` | string | optional | Key signature, in `lang`'s spelling. For `lilypond`, the tonic of the major spelling: `"aes"`, `"c"`, `"fis"`. |
|
||||||
|
| `time` | string | optional | Time signature, as `"4/4"`. |
|
||||||
|
| `print_time` | boolean | optional | Whether the time signature is printed on this system. Default `false`. |
|
||||||
|
|
||||||
|
Each entry of `voices`:
|
||||||
|
|
||||||
|
| Field | Type | | |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `notes` | string | required | The music for this staff, verbatim in `lang`. |
|
||||||
|
| `clef` | string | optional | `"treble"`, `"treble_8"`, `"alto"`, `"bass"`. Default `"treble"`. |
|
||||||
|
| `lyrics` | string | optional | The words under this staff, verbatim in `lang`. Omitted when the staff has none. |
|
||||||
|
|
||||||
|
Three properties make this worth carrying:
|
||||||
|
|
||||||
|
- **It is the source, not a transcription.** The image was engraved from exactly
|
||||||
|
these strings. A consumer that re-engraves them gets the same system back.
|
||||||
|
- **It is editable.** A wrong note can be corrected here and the slice engraved
|
||||||
|
again, which a raster image does not allow.
|
||||||
|
- **It is playable.** `voices` are separated per staff with pitches, durations
|
||||||
|
and a key, so the passage can be sounded — a practice track, a click, a
|
||||||
|
pitch reference — without anyone reading the image.
|
||||||
|
|
||||||
|
`notes` and `lyrics` are opaque to this format. They are whatever `lang` accepts,
|
||||||
|
including constructs the fields above say nothing about: slurs, dynamics,
|
||||||
|
tuplets, and the tie idioms that carry a note across a slice boundary. A consumer
|
||||||
|
that does not speak `lang` must pass them through unaltered or ignore them, never
|
||||||
|
attempt to repair them.
|
||||||
|
|
||||||
|
An `engraving` **describes the slice above it, not the whole song**. Each is
|
||||||
|
self-contained: `key` and `time` are stated per slice, so nothing has to be
|
||||||
|
inherited from a neighbour or from the bundle. Slices without an `engraving` are
|
||||||
|
scanned, and the two kinds mix freely within one score — re-engraving a single
|
||||||
|
ruined system is the ordinary case.
|
||||||
|
|
||||||
|
A consumer that only presents the score can ignore `engraving` entirely. The
|
||||||
|
image is always the authority on what the slice looks like; where an image and
|
||||||
|
its engraving disagree, the image is what the producer intended to be read.
|
||||||
|
|
||||||
|
Notation languages other than `lilypond` may be added by future versions. A
|
||||||
|
consumer should ignore an `engraving` whose `lang` it does not know, and present
|
||||||
|
the slice image as it would any other.
|
||||||
|
|
||||||
|
## Markers
|
||||||
|
|
||||||
|
A marker annotates the slice it appears on.
|
||||||
|
|
||||||
|
| Field | Type | | |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `type` | string | required | One of the vocabulary below. |
|
||||||
|
| `label` | string | optional | Free text. Meaningful for `rehearsal_letter`, `section_label` and `volta`. |
|
||||||
|
| `destination` | integer | optional | Index into `slices`. Present on jump types. |
|
||||||
|
|
||||||
|
A slice may carry several markers. Their order within the array is not
|
||||||
|
significant.
|
||||||
|
|
||||||
|
### Vocabulary
|
||||||
|
|
||||||
|
Named positions — places a performer may be directed to:
|
||||||
|
|
||||||
|
| `type` | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `rehearsal_letter` | A boxed letter or number printed above a system, used to say "from C". `label` holds it. |
|
||||||
|
| `section_label` | A named section: INTRO, VERSE, CHORUS. `label` holds the name. |
|
||||||
|
| `segno` | The 𝄋 sign, target of a *dal segno*. |
|
||||||
|
| `coda` | The 𝄌 sign, beginning of the closing section. |
|
||||||
|
| `fine` | The end of the piece when reached by a *da capo* or *dal segno*. |
|
||||||
|
|
||||||
|
Structural notation — printed context, affecting how the music is read but not
|
||||||
|
directing the reader elsewhere:
|
||||||
|
|
||||||
|
| `type` | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `repeat_start` | The start of a repeated passage. |
|
||||||
|
| `repeat_end` | The end of a repeated passage. |
|
||||||
|
| `volta` | An alternative ending bracket. `label` holds its number. |
|
||||||
|
|
||||||
|
Jumps — points where the reader is directed to another slice:
|
||||||
|
|
||||||
|
| `type` | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `to_coda` | "To Coda": leave here for the coda. |
|
||||||
|
| `ds_al_coda` | *Dal segno al coda*: return to the segno. |
|
||||||
|
| `ds_al_fine` | *Dal segno al fine*: return to the segno and play to the fine. |
|
||||||
|
| `dc_al_coda` | *Da capo al coda*: return to the beginning. |
|
||||||
|
| `dc_al_fine` | *Da capo al fine*: return to the beginning and play to the fine. |
|
||||||
|
| `generic_jump` | An unclassified jump. |
|
||||||
|
|
||||||
|
**Every jump marker states its destination explicitly**, as an index into
|
||||||
|
`slices`. A consumer does not need to infer where a jump leads by searching for
|
||||||
|
a matching `coda` or `segno`, and must not assume a bundle contains only one of
|
||||||
|
each. A `destination` always refers to an existing index.
|
||||||
|
|
||||||
|
Unrecognised marker types may be added by future versions. A consumer should
|
||||||
|
ignore markers it does not understand rather than reject the bundle.
|
||||||
|
|
||||||
|
## Versioning
|
||||||
|
|
||||||
|
`v` is an integer that increases when a change would break an existing consumer.
|
||||||
|
Additions that a consumer can safely ignore — new optional fields, new marker
|
||||||
|
types, new `engraving` languages — do not increase it. `engraving` was added
|
||||||
|
this way: a bundle carrying one is still a version 1 bundle, and a consumer that
|
||||||
|
has never heard of it presents the score unchanged.
|
||||||
|
|
||||||
|
A consumer should refuse a bundle whose `v` it does not recognise rather than
|
||||||
|
attempt to interpret it.
|
||||||
|
|
||||||
|
## Validating a bundle
|
||||||
|
|
||||||
|
A consumer is advised to check:
|
||||||
|
|
||||||
|
- `v` is a recognised version.
|
||||||
|
- `title` is present and non-empty; `slices` is a non-empty array.
|
||||||
|
- Every `file` names an entry present in the archive.
|
||||||
|
- Every `destination` is within the bounds of `slices`.
|
||||||
|
- Every `engraving` has a `lang` and a non-empty `voices`; unknown `lang` values
|
||||||
|
are ignored rather than rejected.
|
||||||
|
- Archive entry names contain no path separators, no `..`, and no absolute
|
||||||
|
paths, as with any archive from an untrusted source.
|
||||||
|
|
||||||
|
## Identity and updates
|
||||||
|
|
||||||
|
A bundle describes one complete score. The format has no notion of updating a
|
||||||
|
previously read bundle: there are no stable identifiers, and a slice's index is
|
||||||
|
meaningful only within the bundle that contains it.
|
||||||
|
|
||||||
|
Two bundles of the same piece are therefore independent documents, not versions
|
||||||
|
of one. A consumer that stores imported bundles and assigns its own identifiers
|
||||||
|
should treat a second bundle as a new score rather than merging it into an
|
||||||
|
existing one — jump destinations resolved against the first bundle's slices do
|
||||||
|
not survive being repointed at a second bundle's.
|
||||||
|
|
||||||
|
## Complete example
|
||||||
|
|
||||||
|
A 24-slice bundle, abbreviated:
|
||||||
|
|
||||||
|
```
|
||||||
|
song.zip
|
||||||
|
├── song.json 1.4 KB
|
||||||
|
├── original.pdf 827 KB
|
||||||
|
├── 001.webp 21 KB 1489 × 1058
|
||||||
|
├── 002.webp 18 KB 1489 × 818
|
||||||
|
├── …
|
||||||
|
└── 024.webp 1489 px wide, like every other slice
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"v": 1,
|
||||||
|
"title": "Ketun joululaulu",
|
||||||
|
"composer": "trad.",
|
||||||
|
"arranger": "P. Rapi",
|
||||||
|
"tempo": 92,
|
||||||
|
"slices": [
|
||||||
|
{ "file": "001.webp", "markers": [ { "type": "rehearsal_letter", "label": "A" } ] },
|
||||||
|
{ "file": "002.webp", "markers": [ { "type": "segno" },
|
||||||
|
{ "type": "to_coda", "destination": 7 } ] },
|
||||||
|
{ "file": "003.webp" },
|
||||||
|
{ "file": "004.webp" },
|
||||||
|
{ "file": "005.webp" },
|
||||||
|
{ "file": "006.webp" },
|
||||||
|
{ "file": "007.webp", "engraving": { "lang": "lilypond", "key": "aes", "time": "4/4",
|
||||||
|
"voices": [ { "clef": "treble",
|
||||||
|
"notes": "c4 des ees f | ees2. r4",
|
||||||
|
"lyrics": "Kai -- paa -- va sy -- dän" },
|
||||||
|
{ "clef": "bass",
|
||||||
|
"notes": "aes,4 ges f ees | aes2. r4" } ] } },
|
||||||
|
{ "file": "008.webp", "markers": [ { "type": "coda" } ] },
|
||||||
|
{ "file": "009.webp" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reading the score means presenting `001.webp` through `024.webp` in that order,
|
||||||
|
in one column, each scaled to the same width. A reader who follows the `to_coda`
|
||||||
|
on slice index 1 continues at slice index 7.
|
||||||
+145
@@ -0,0 +1,145 @@
|
|||||||
|
# Making a bundle
|
||||||
|
|
||||||
|
Start to finish: a score PDF in, one `.zip` out that noteman can open. Fifteen
|
||||||
|
minutes for a typical four-page song, most of it spent nudging cuts.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```
|
||||||
|
uv tool install --editable .
|
||||||
|
```
|
||||||
|
|
||||||
|
That puts `noteman-slicer` on PATH; it runs from any directory. Everything it
|
||||||
|
needs is a wheel — no system packages. LilyPond is optional and only enables
|
||||||
|
re-engraving (below); without it the tool works the same minus that pane.
|
||||||
|
|
||||||
|
## The one command you need
|
||||||
|
|
||||||
|
```
|
||||||
|
noteman-slicer edit my-song.pdf
|
||||||
|
```
|
||||||
|
|
||||||
|
The editor opens on page 1 with detection's guesses already drawn: horizontal
|
||||||
|
**cuts** between the systems, a **skew** correction, and a blue **content
|
||||||
|
rectangle** marking what is music rather than page margin. All of it is a
|
||||||
|
starting point — detection is an accelerator, not an authority. Fix whatever is
|
||||||
|
wrong.
|
||||||
|
|
||||||
|
Your work is saved to `my-song.slicer.json` next to the PDF, automatically on
|
||||||
|
export and with Ctrl+S any time. Closing and reopening picks up where you left
|
||||||
|
off.
|
||||||
|
|
||||||
|
## What you do on each page
|
||||||
|
|
||||||
|
1. **Straighten it.** If the staff lines slope, turn the *Skew* dial until they
|
||||||
|
are level. The preview updates live.
|
||||||
|
2. **Fix the cuts.** One cut line per boundary between systems. Double-click to
|
||||||
|
add one, drag to move it, right-click to delete it. A cut is a polyline, not
|
||||||
|
a straight line — Ctrl-click on a cut adds a vertex, so it can bend around a
|
||||||
|
low-hanging lyric or a slur that crosses the gap. Right-click a vertex to
|
||||||
|
drop it.
|
||||||
|
3. **Discard what isn't music.** Page headers, footers, page numbers and title
|
||||||
|
blocks are slices too, and they should not reach the tablet. Click the slice,
|
||||||
|
press <kbd>D</kbd>. Discarded slices show hatched. <kbd>D</kbd> again brings
|
||||||
|
one back.
|
||||||
|
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.
|
||||||
|
5. **Set black and white points.** Pull the *White point* down until the paper
|
||||||
|
goes pure white and its texture disappears; pull *Black point* up until the
|
||||||
|
notes are solid black rather than grey mush. Scans need this; clean digital
|
||||||
|
PDFs usually don't.
|
||||||
|
|
||||||
|
Page Up / Page Down move between pages. Levels carry over from the previous
|
||||||
|
page, so a consistent scan only needs setting once.
|
||||||
|
|
||||||
|
## Markers
|
||||||
|
|
||||||
|
Markers are the navigation symbols noteman uses to jump around the score:
|
||||||
|
rehearsal letters, section labels, segno, coda, fine, repeats, voltas, and the
|
||||||
|
D.S./D.C. instructions. They belong to a slice.
|
||||||
|
|
||||||
|
Select the slice, pick the type, type a label if the type takes one (rehearsal
|
||||||
|
letters, section labels and voltas do), and press **Add**.
|
||||||
|
|
||||||
|
Jump markers — *to coda*, *D.S. al coda*, *D.C. al fine* and friends — also need
|
||||||
|
a destination. After adding one, press **Set target…** and click the slice it
|
||||||
|
jumps to, on any page. That is what lets noteman follow the repeat structure
|
||||||
|
instead of just scrolling.
|
||||||
|
|
||||||
|
## The title block
|
||||||
|
|
||||||
|
Fill in the *Song* section. **Title is required** — export refuses without one.
|
||||||
|
The rest (subtitle, composer, original artist, arranger, lyricist, translator,
|
||||||
|
voices) is optional and travels with the bundle into noteman's library.
|
||||||
|
|
||||||
|
*Tempo* is beats per minute, a number, because a number can drive a metronome
|
||||||
|
and "Andante" cannot.
|
||||||
|
|
||||||
|
## Export
|
||||||
|
|
||||||
|
**Export bundle…**, choose where the `.zip` goes, done. Inside are the slice
|
||||||
|
images in order, their markers, the song metadata, and the original PDF as the
|
||||||
|
archive copy. That zip is the whole interface to noteman; hand it over and open
|
||||||
|
it there.
|
||||||
|
|
||||||
|
Two things worth knowing:
|
||||||
|
|
||||||
|
- **Shrink the original PDF…** offers to store the archived PDF as bilevel,
|
||||||
|
which is dramatically smaller for scans. It shows you a before/after crop
|
||||||
|
first — check that the staff lines survived. It never touches the slices.
|
||||||
|
- **An exported project is spent.** Reopening the same PDF starts fresh from
|
||||||
|
detection rather than resuming decisions that already shipped. If you really
|
||||||
|
want the old cuts back, `noteman-slicer edit my-song.pdf --resume`.
|
||||||
|
|
||||||
|
## Re-engraving a slice (optional, needs LilyPond)
|
||||||
|
|
||||||
|
When a system is beyond rescue — a bad scan, a wrong transposition, a passage
|
||||||
|
you want rewritten — shift-double-click it. A window opens where you enter the
|
||||||
|
music as LilyPond, one block per voice, render, and compare against the
|
||||||
|
original. Accept and the rendered version replaces that slice in the bundle.
|
||||||
|
|
||||||
|
The LilyPond you typed travels in the bundle alongside the image, so the passage
|
||||||
|
can be corrected and re-engraved later, or played, without the project file.
|
||||||
|
|
||||||
|
## Mouse and keyboard
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Double-click | add a cut |
|
||||||
|
| Drag a cut | move it |
|
||||||
|
| Ctrl-click a cut | add a vertex |
|
||||||
|
| Right-click | delete the cut or vertex under the cursor |
|
||||||
|
| Click a slice, then <kbd>D</kbd> | discard it (or bring it back) |
|
||||||
|
| Drag the blue edges | resize the content rectangle |
|
||||||
|
| Shift-double-click a slice | re-engrave it |
|
||||||
|
| <kbd>Page Up</kbd> / <kbd>Page Down</kbd> | previous / next page |
|
||||||
|
| <kbd>Ctrl</kbd>+<kbd>S</kbd> | save the project |
|
||||||
|
|
||||||
|
## What the tool won't do
|
||||||
|
|
||||||
|
Erasing a previous owner's pencil marks, chord letters and breath marks. Do that
|
||||||
|
in GIMP before slicing — with a stylus it is quick, and no amount of thresholding
|
||||||
|
substitutes for it.
|
||||||
|
|
||||||
|
## When something looks wrong
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| 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. |
|
||||||
|
| Export says a title is required | Fill in *Song → Title*. |
|
||||||
|
| Slices look grey and washed out | The white point is too high. |
|
||||||
|
| Notes have holes in them | The black point is too high. |
|
||||||
|
|
||||||
|
## The command line
|
||||||
|
|
||||||
|
The editor is the tool; these exist for checking things quickly.
|
||||||
|
|
||||||
|
```
|
||||||
|
noteman-slicer info my-song.pdf # source type and page rasters
|
||||||
|
noteman-slicer detect my-song.pdf # detection results + debug overlays
|
||||||
|
noteman-slicer project my-song.pdf # what the project file currently holds
|
||||||
|
noteman-slicer export my-song.pdf # export without opening the editor
|
||||||
|
```
|
||||||
|
|
||||||
|
Every command takes `--type raster|vector` to override source-type detection.
|
||||||
+9
-3
@@ -343,13 +343,19 @@ 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.
|
||||||
|
|
||||||
**Contents:** slices, markers, the original PDF, and song-level text metadata
|
**Contents:** slices, markers, the original PDF, and song-level metadata (title,
|
||||||
(title, subtitle, composer, original artist, arranger, lyricist, translator,
|
subtitle, composer, original artist, arranger, lyricist, translator, tempo,
|
||||||
voice list). Metadata is included not because the slicer transforms it but
|
voice list). Metadata is included not because the slicer transforms it but
|
||||||
because you have to read the title block anyway to mark the header slice
|
because you have to read the title block anyway to mark the header slice
|
||||||
discarded — typing eight fields while it's on screen beats reopening the PDF
|
discarded — typing the fields while it's on screen beats reopening the PDF
|
||||||
later.
|
later.
|
||||||
|
|
||||||
|
**Title is required**; everything else is optional and omitted when blank.
|
||||||
|
**Tempo is an integer**, beats per minute — a number can drive a metronome and
|
||||||
|
a starting-chord playback where *Andante* cannot, and two people will not agree
|
||||||
|
what *Andante* means. noteman's column is currently free-form text and needs
|
||||||
|
changing; see [`bundle-format.md`](bundle-format.md).
|
||||||
|
|
||||||
Rehearsal MIDI and MP3s are deliberately out of the first bundle.
|
Rehearsal MIDI and MP3s are deliberately out of the first bundle.
|
||||||
|
|
||||||
### One rule for the import side
|
### One rule for the import side
|
||||||
|
|||||||
@@ -29,18 +29,52 @@ METADATA_FIELDS = (
|
|||||||
"arranger",
|
"arranger",
|
||||||
"lyricist",
|
"lyricist",
|
||||||
"translator",
|
"translator",
|
||||||
# Free-form, matching noteman's own column: scores notate tempo as a mix of
|
|
||||||
# BPM ("♩=72"), Italian ("Andante") and prose.
|
|
||||||
"tempo",
|
"tempo",
|
||||||
"voices",
|
"voices",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Beats per minute, exported as a JSON number. A figure is worth more than a
|
||||||
|
# word here: "Andante" cannot drive a metronome and two people will not agree
|
||||||
|
# what it means.
|
||||||
|
NUMERIC_FIELDS = frozenset({"tempo"})
|
||||||
|
|
||||||
|
|
||||||
|
def _engraving(project: Project, page: int, slot: int) -> dict | None:
|
||||||
|
"""The notation behind a re-engraved slice, or None for a scanned one.
|
||||||
|
|
||||||
|
The slice image stays the presentation; this is the notation it was made
|
||||||
|
from, carried so the music can be edited again or turned into sound. Key
|
||||||
|
and time are resolved against the song defaults here — a consumer reading
|
||||||
|
one slice should not have to know what the rest of the song inherited.
|
||||||
|
"""
|
||||||
|
replacement = project.pages[page].replacements[slot]
|
||||||
|
if not replacement or not replacement.voices:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"lang": "lilypond",
|
||||||
|
"key": replacement.key or project.key,
|
||||||
|
"time": replacement.time or project.time,
|
||||||
|
"print_time": replacement.print_time,
|
||||||
|
"voices": [
|
||||||
|
{"clef": v.clef, "notes": v.notes.strip()}
|
||||||
|
| ({"lyrics": v.lyrics.strip()} if v.lyrics.strip() else {})
|
||||||
|
for v in replacement.voices
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
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:
|
||||||
value = project.metadata.get(field)
|
value = (project.metadata.get(field) or "").strip()
|
||||||
if value:
|
if not value:
|
||||||
|
continue
|
||||||
|
if field in NUMERIC_FIELDS:
|
||||||
|
try:
|
||||||
|
payload[field] = int(value)
|
||||||
|
except ValueError:
|
||||||
|
continue # not a number, so not worth exporting as one
|
||||||
|
else:
|
||||||
payload[field] = value
|
payload[field] = value
|
||||||
|
|
||||||
kept = project.kept_slices()
|
kept = project.kept_slices()
|
||||||
@@ -52,6 +86,9 @@ 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}
|
||||||
|
engraving = _engraving(project, page, slot)
|
||||||
|
if engraving:
|
||||||
|
entry["engraving"] = engraving
|
||||||
markers = []
|
markers = []
|
||||||
for marker in project.pages[page].markers[slot]:
|
for marker in project.pages[page].markers[slot]:
|
||||||
item: dict = {"type": marker.type}
|
item: dict = {"type": marker.type}
|
||||||
@@ -100,7 +137,15 @@ def write(project: Project, source: Source, path: Path) -> Path:
|
|||||||
zipfile.ZIP_DEFLATED,
|
zipfile.ZIP_DEFLATED,
|
||||||
)
|
)
|
||||||
if project.source.exists():
|
if project.source.exists():
|
||||||
zf.write(project.source, "original.pdf")
|
pdf = project.source.read_bytes()
|
||||||
|
if project.optimise_pdf:
|
||||||
|
import pymupdf
|
||||||
|
|
||||||
|
from .pdfopt import optimise
|
||||||
|
|
||||||
|
shrunk, _ = optimise(pymupdf.open(project.source), len(pdf))
|
||||||
|
pdf = shrunk or pdf # empty means it found no saving
|
||||||
|
zf.writestr("original.pdf", pdf, zipfile.ZIP_STORED)
|
||||||
for name, data in zip(names, images):
|
for name, data in zip(names, images):
|
||||||
zf.writestr(name, data, zipfile.ZIP_STORED)
|
zf.writestr(name, data, zipfile.ZIP_STORED)
|
||||||
|
|
||||||
|
|||||||
+75
-15
@@ -19,6 +19,7 @@ from PySide6.QtGui import (
|
|||||||
QBrush,
|
QBrush,
|
||||||
QColor,
|
QColor,
|
||||||
QImage,
|
QImage,
|
||||||
|
QIntValidator,
|
||||||
QKeySequence,
|
QKeySequence,
|
||||||
QPainter,
|
QPainter,
|
||||||
QPen,
|
QPen,
|
||||||
@@ -33,6 +34,7 @@ from PySide6.QtWidgets import (
|
|||||||
QGraphicsScene,
|
QGraphicsScene,
|
||||||
QGraphicsView,
|
QGraphicsView,
|
||||||
QComboBox,
|
QComboBox,
|
||||||
|
QDialog,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
QLineEdit,
|
QLineEdit,
|
||||||
@@ -50,7 +52,7 @@ from PySide6.QtWidgets import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from . import bundle, lilypond
|
from . import bundle, lilypond
|
||||||
from .bundle import METADATA_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
|
||||||
from .project import (
|
from .project import (
|
||||||
@@ -204,20 +206,6 @@ class PageView(QGraphicsView):
|
|||||||
text.setPos(x, y)
|
text.setPos(x, y)
|
||||||
return x + box.width() * scale + pad * 3
|
return x + box.width() * scale + pad * 3
|
||||||
|
|
||||||
for i, cut in enumerate(self.page.cuts):
|
|
||||||
colour = _CUT_ACTIVE if i == self.selected_cut else _CUT
|
|
||||||
pen = QPen(colour, 2)
|
|
||||||
pen.setCosmetic(True)
|
|
||||||
points = [QPointF(x * w, y * h) for x, y in cut.points]
|
|
||||||
for a, b in zip(points, points[1:]):
|
|
||||||
scene.addLine(a.x(), a.y(), b.x(), b.y(), pen)
|
|
||||||
if i == self.selected_cut:
|
|
||||||
r = HIT * 1.5 / max(self.transform().m11(), 1e-6)
|
|
||||||
for p in points:
|
|
||||||
scene.addEllipse(
|
|
||||||
p.x() - r, p.y() - r, r * 2, r * 2, QPen(Qt.NoPen), QBrush(_VERTEX)
|
|
||||||
)
|
|
||||||
|
|
||||||
def _slice_polygon(self, slot: int, w: int, h: int) -> QPolygonF:
|
def _slice_polygon(self, slot: int, w: int, h: int) -> QPolygonF:
|
||||||
above, below = self.page.bounds(slot)
|
above, below = self.page.bounds(slot)
|
||||||
top = [(0.0, 0.0), (1.0, 0.0)] if above is None else above.points
|
top = [(0.0, 0.0), (1.0, 0.0)] if above is None else above.points
|
||||||
@@ -564,8 +552,19 @@ class Editor(QMainWindow):
|
|||||||
required = field == "title"
|
required = field == "title"
|
||||||
if required:
|
if required:
|
||||||
edit.setPlaceholderText("required")
|
edit.setPlaceholderText("required")
|
||||||
|
if field in NUMERIC_FIELDS:
|
||||||
|
# Beats per minute, and only that: a number can drive a
|
||||||
|
# metronome where "Andante" cannot.
|
||||||
|
edit.setValidator(QIntValidator(20, 400, edit))
|
||||||
|
edit.setPlaceholderText("BPM")
|
||||||
|
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)
|
||||||
|
|
||||||
|
self.optimise = QPushButton("Shrink the original PDF…")
|
||||||
|
self.optimise.setToolTip("Convert scanned pages to bilevel in the archived PDF")
|
||||||
|
self.optimise.clicked.connect(self._optimise_pdf)
|
||||||
|
meta_layout.addWidget(self.optimise)
|
||||||
|
|
||||||
self.summary = QLabel()
|
self.summary = QLabel()
|
||||||
self.summary.setWordWrap(True)
|
self.summary.setWordWrap(True)
|
||||||
box.addWidget(self.summary)
|
box.addWidget(self.summary)
|
||||||
@@ -758,6 +757,67 @@ class Editor(QMainWindow):
|
|||||||
}
|
}
|
||||||
self.autosave.start()
|
self.autosave.start()
|
||||||
|
|
||||||
|
def _optimise_pdf(self) -> None:
|
||||||
|
"""Offer to shrink the archived PDF, showing the result before agreeing.
|
||||||
|
|
||||||
|
A before/after crop rather than a checkbox: the failure this can produce
|
||||||
|
— broken staff lines on a coarse scan — is obvious at a glance and
|
||||||
|
invisible in a byte count.
|
||||||
|
"""
|
||||||
|
import pymupdf
|
||||||
|
|
||||||
|
from .pdfopt import Report, optimise, preview
|
||||||
|
|
||||||
|
self.statusBar().showMessage("examining the PDF…")
|
||||||
|
QApplication.processEvents()
|
||||||
|
source = self.project.source
|
||||||
|
data, report = optimise(pymupdf.open(source), source.stat().st_size)
|
||||||
|
self.statusBar().clearMessage()
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
QMessageBox.information(self, "Nothing to shrink", report.summary())
|
||||||
|
self.project.optimise_pdf = False
|
||||||
|
return
|
||||||
|
|
||||||
|
dialog = QDialog(self)
|
||||||
|
dialog.setWindowTitle("Shrink the original PDF")
|
||||||
|
layout = QVBoxLayout(dialog)
|
||||||
|
text = QLabel(report.summary() + "\n\nThe slices are unaffected — only the archived PDF.")
|
||||||
|
text.setWordWrap(True)
|
||||||
|
layout.addWidget(text)
|
||||||
|
|
||||||
|
crop = preview(pymupdf.open(source), pymupdf.open(stream=data, filetype="pdf"))
|
||||||
|
crop = np.ascontiguousarray(crop)
|
||||||
|
h, w, _ = crop.shape
|
||||||
|
image = QImage(crop.data, w, h, w * 3, QImage.Format_BGR888).copy()
|
||||||
|
label = QLabel()
|
||||||
|
label.setPixmap(QPixmap.fromImage(image))
|
||||||
|
area = QScrollArea()
|
||||||
|
area.setWidget(label)
|
||||||
|
area.setWidgetResizable(True)
|
||||||
|
area.setMinimumHeight(420)
|
||||||
|
layout.addWidget(area)
|
||||||
|
layout.addWidget(QLabel("Original above, shrunk below. Check the staff lines."))
|
||||||
|
|
||||||
|
buttons = QHBoxLayout()
|
||||||
|
use = QPushButton("Use the smaller PDF")
|
||||||
|
use.clicked.connect(dialog.accept)
|
||||||
|
keep = QPushButton("Keep the original")
|
||||||
|
keep.clicked.connect(dialog.reject)
|
||||||
|
buttons.addWidget(use)
|
||||||
|
buttons.addWidget(keep)
|
||||||
|
layout.addLayout(buttons)
|
||||||
|
dialog.resize(1100, 700)
|
||||||
|
|
||||||
|
self.project.optimise_pdf = dialog.exec() == QDialog.Accepted
|
||||||
|
self._touched()
|
||||||
|
self.statusBar().showMessage(
|
||||||
|
"the bundle will carry the shrunk PDF"
|
||||||
|
if self.project.optimise_pdf
|
||||||
|
else "the bundle will carry the original PDF",
|
||||||
|
4000,
|
||||||
|
)
|
||||||
|
|
||||||
def _reset_rect(self) -> None:
|
def _reset_rect(self) -> None:
|
||||||
"""Back to what detection proposed for this page.
|
"""Back to what detection proposed for this page.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
"""Optional shrinking of the original PDF carried in a bundle.
|
||||||
|
|
||||||
|
Scanned scores are usually black ink on white paper stored as 8-bit greyscale
|
||||||
|
or RGB, which costs several times what the same page costs as a bilevel image.
|
||||||
|
Converting them is worth 7–9× on a real corpus.
|
||||||
|
|
||||||
|
Two things it must not do, both found by looking at output rather than at
|
||||||
|
numbers:
|
||||||
|
|
||||||
|
* A page that is genuinely coloured — cover artwork — loses its artwork.
|
||||||
|
* A scan too coarse to have more than about one pixel per staff line comes
|
||||||
|
back with the staff lines broken.
|
||||||
|
|
||||||
|
Both are detectable before converting, so both are skipped. Everything skipped
|
||||||
|
is reported, so a caller can say what was left alone and why.
|
||||||
|
|
||||||
|
This affects only the archival copy of the score. Slices are cut from the
|
||||||
|
original before any of this and are unchanged either way.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
import pymupdf
|
||||||
|
|
||||||
|
# Below this many pixels per inch as the image is *placed on the page*, staff
|
||||||
|
# lines are about a pixel wide and thresholding breaks them. Measured against a
|
||||||
|
# corpus where the one failure sat at ~115 DPI and the successes at 260+.
|
||||||
|
MIN_DPI = 200
|
||||||
|
|
||||||
|
# An image is "coloured" when this share of sampled pixels are off-grey by
|
||||||
|
# more than _CHROMA. The two populations are far apart: measured on a corpus,
|
||||||
|
# cover artwork sits at 44% while a greyscale scan's sensor tint reaches 3%.
|
||||||
|
# Ten percent sits in the gap with room on both sides.
|
||||||
|
_CHROMA = 24
|
||||||
|
_COLOUR_SHARE = 0.10
|
||||||
|
|
||||||
|
_BLOCK = 31 # adaptive threshold window
|
||||||
|
_OFFSET = 15
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Report:
|
||||||
|
before: int = 0
|
||||||
|
after: int = 0
|
||||||
|
converted: int = 0
|
||||||
|
skipped: dict[str, int] = field(default_factory=dict)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ratio(self) -> float:
|
||||||
|
return self.after / self.before if self.before else 1.0
|
||||||
|
|
||||||
|
def skip(self, reason: str) -> None:
|
||||||
|
self.skipped[reason] = self.skipped.get(reason, 0) + 1
|
||||||
|
|
||||||
|
def summary(self) -> str:
|
||||||
|
if not self.converted:
|
||||||
|
return "nothing to optimise — every image is already bilevel, coloured or too coarse"
|
||||||
|
parts = [
|
||||||
|
f"{self.before / 1024:.0f} KB → {self.after / 1024:.0f} KB "
|
||||||
|
f"({self.ratio * 100:.0f}%), {self.converted} images converted"
|
||||||
|
]
|
||||||
|
for reason, count in sorted(self.skipped.items()):
|
||||||
|
parts.append(f"{count} left alone: {reason}")
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_coloured(image: np.ndarray) -> bool:
|
||||||
|
if image.ndim != 3 or image.shape[2] < 3:
|
||||||
|
return False
|
||||||
|
sample = image[::4, ::4, :3].astype(np.int16)
|
||||||
|
spread = sample.max(axis=2) - sample.min(axis=2)
|
||||||
|
return float((spread > _CHROMA).mean()) > _COLOUR_SHARE
|
||||||
|
|
||||||
|
|
||||||
|
def _placed_dpi(page: pymupdf.Page, item, width: int) -> float:
|
||||||
|
"""Pixels per inch of an image as it appears on the page.
|
||||||
|
|
||||||
|
Not the pixel count: a page split into tiles has small images at a high
|
||||||
|
resolution, and a full-page image can be large yet coarse.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
bbox = pymupdf.Rect(page.get_image_bbox(item))
|
||||||
|
except (ValueError, RuntimeError):
|
||||||
|
return float("inf")
|
||||||
|
inches = abs(bbox.width) / 72.0
|
||||||
|
return width / inches if inches > 0 else float("inf")
|
||||||
|
|
||||||
|
|
||||||
|
def optimise(doc: pymupdf.Document, source_bytes: int) -> tuple[bytes, Report]:
|
||||||
|
"""Return the optimised PDF and a report of what was done.
|
||||||
|
|
||||||
|
`doc` is modified in place, so pass a copy or reopen afterwards.
|
||||||
|
"""
|
||||||
|
report = Report(before=source_bytes)
|
||||||
|
|
||||||
|
for page in doc:
|
||||||
|
for item in page.get_images(full=True):
|
||||||
|
xref = item[0]
|
||||||
|
info = doc.extract_image(xref)
|
||||||
|
|
||||||
|
if info.get("bpc") == 1:
|
||||||
|
report.skip("already bilevel")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if _placed_dpi(page, item, info["width"]) < MIN_DPI:
|
||||||
|
report.skip(f"below {MIN_DPI} DPI, staff lines would break")
|
||||||
|
continue
|
||||||
|
|
||||||
|
raw = cv2.imdecode(np.frombuffer(info["image"], np.uint8), cv2.IMREAD_UNCHANGED)
|
||||||
|
if raw is None:
|
||||||
|
report.skip("unreadable encoding")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if _is_coloured(raw):
|
||||||
|
report.skip("coloured artwork")
|
||||||
|
continue
|
||||||
|
|
||||||
|
gray = cv2.cvtColor(raw, cv2.COLOR_BGR2GRAY) if raw.ndim == 3 else raw
|
||||||
|
bilevel = cv2.adaptiveThreshold(
|
||||||
|
gray, 255, cv2.ADAPTIVE_THRESH_MEAN_C, cv2.THRESH_BINARY, _BLOCK, _OFFSET
|
||||||
|
)
|
||||||
|
ok, buffer = cv2.imencode(".png", bilevel, [cv2.IMWRITE_PNG_COMPRESSION, 9])
|
||||||
|
if not ok:
|
||||||
|
report.skip("re-encoding failed")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
page.replace_image(xref, stream=buffer.tobytes())
|
||||||
|
except (ValueError, RuntimeError):
|
||||||
|
report.skip("could not be replaced")
|
||||||
|
continue
|
||||||
|
report.converted += 1
|
||||||
|
|
||||||
|
data = doc.tobytes(garbage=4, deflate=True, clean=True)
|
||||||
|
# Never hand back something larger than what came in.
|
||||||
|
if len(data) >= source_bytes:
|
||||||
|
report.after = source_bytes
|
||||||
|
report.converted = 0
|
||||||
|
report.skip("no saving available")
|
||||||
|
return b"", report
|
||||||
|
|
||||||
|
report.after = len(data)
|
||||||
|
return data, report
|
||||||
|
|
||||||
|
|
||||||
|
def preview(original: pymupdf.Document, optimised: pymupdf.Document, dpi: int = 260):
|
||||||
|
"""A stacked before/after crop of the first page, for eyeballing the result.
|
||||||
|
|
||||||
|
The numbers cannot show the failure this guards against — a broken staff
|
||||||
|
line is obvious at a glance and invisible in a byte count.
|
||||||
|
"""
|
||||||
|
rect = original[0].rect
|
||||||
|
clip = pymupdf.Rect(
|
||||||
|
rect.x0 + rect.width * 0.08,
|
||||||
|
rect.y0 + rect.height * 0.20,
|
||||||
|
rect.x0 + rect.width * 0.58,
|
||||||
|
rect.y0 + rect.height * 0.33,
|
||||||
|
)
|
||||||
|
|
||||||
|
def render(doc: pymupdf.Document) -> np.ndarray:
|
||||||
|
pix = doc[0].get_pixmap(dpi=dpi, clip=clip)
|
||||||
|
image = np.frombuffer(pix.samples, np.uint8).reshape(pix.height, pix.width, pix.n)
|
||||||
|
return image[:, :, :3] if pix.n >= 3 else cv2.cvtColor(image[:, :, 0], cv2.COLOR_GRAY2BGR)
|
||||||
|
|
||||||
|
before, after = render(original), render(optimised)
|
||||||
|
h = min(before.shape[0], after.shape[0])
|
||||||
|
w = min(before.shape[1], after.shape[1])
|
||||||
|
divider = np.full((4, w, 3), 128, np.uint8)
|
||||||
|
return np.vstack([before[:h, :w], divider, after[:h, :w]])
|
||||||
@@ -222,6 +222,10 @@ class Project:
|
|||||||
key: str = "c"
|
key: str = "c"
|
||||||
time: str = "4/4"
|
time: str = "4/4"
|
||||||
clefs: list[str] = field(default_factory=list)
|
clefs: list[str] = field(default_factory=list)
|
||||||
|
# Shrink the archival PDF carried in the bundle by converting its scanned
|
||||||
|
# pages to bilevel. Off by default: it is lossy on the copy kept for
|
||||||
|
# printing, and on some scans it breaks staff lines.
|
||||||
|
optimise_pdf: bool = False
|
||||||
path: Path | None = None
|
path: Path | None = None
|
||||||
# Set once the song has been exported. A project is spent at that point:
|
# Set once the song has been exported. A project is spent at that point:
|
||||||
# opening the PDF again starts a fresh session from detection rather than
|
# opening the PDF again starts a fresh session from detection rather than
|
||||||
@@ -308,6 +312,7 @@ class Project:
|
|||||||
"key": self.key,
|
"key": self.key,
|
||||||
"time": self.time,
|
"time": self.time,
|
||||||
"clefs": self.clefs,
|
"clefs": self.clefs,
|
||||||
|
"optimise_pdf": self.optimise_pdf,
|
||||||
"pages": [
|
"pages": [
|
||||||
{
|
{
|
||||||
"skew": page.skew,
|
"skew": page.skew,
|
||||||
@@ -415,6 +420,7 @@ class Project:
|
|||||||
key=data.get("key", "c"),
|
key=data.get("key", "c"),
|
||||||
time=data.get("time", "4/4"),
|
time=data.get("time", "4/4"),
|
||||||
clefs=data.get("clefs", []),
|
clefs=data.get("clefs", []),
|
||||||
|
optimise_pdf=data.get("optimise_pdf", False),
|
||||||
)
|
)
|
||||||
|
|
||||||
def source_changed(self) -> bool:
|
def source_changed(self) -> bool:
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ from noteman_slicer.project import ( # noqa: E402
|
|||||||
Cut,
|
Cut,
|
||||||
Marker,
|
Marker,
|
||||||
Project,
|
Project,
|
||||||
|
Replacement,
|
||||||
|
Voice,
|
||||||
default_path,
|
default_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -76,13 +78,44 @@ def main() -> int:
|
|||||||
|
|
||||||
# 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()))]
|
||||||
|
project.metadata.update({"title": "Test song", "tempo": "92", "composer": ""})
|
||||||
payload = song_json(project, names)
|
payload = song_json(project, names)
|
||||||
|
# Tempo is a number, not a string; empty fields are absent, not "".
|
||||||
|
assert payload["tempo"] == 92, payload["tempo"]
|
||||||
|
assert "composer" not in payload
|
||||||
|
|
||||||
|
project.metadata["tempo"] = "Andante"
|
||||||
|
assert "tempo" not in song_json(project, names), "words are not a tempo"
|
||||||
|
project.metadata["tempo"] = "92"
|
||||||
slices = payload["slices"]
|
slices = payload["slices"]
|
||||||
assert [s["file"] for s in slices] == names
|
assert [s["file"] for s in slices] == names
|
||||||
assert slices[0]["markers"][0] == {"type": "rehearsal_letter", "label": "A"}
|
assert slices[0]["markers"][0] == {"type": "rehearsal_letter", "label": "A"}
|
||||||
assert slices[1]["markers"][0] == {"type": "coda"}
|
assert slices[1]["markers"][0] == {"type": "coda"}
|
||||||
assert slices[0]["markers"][1] == {"type": "to_coda", "destination": 1}
|
assert slices[0]["markers"][1] == {"type": "to_coda", "destination": 1}
|
||||||
|
|
||||||
|
# A re-engraved slice carries its notation into the bundle; a scanned one
|
||||||
|
# carries none. This is what makes a later edit or a MIDI render possible
|
||||||
|
# from the bundle alone.
|
||||||
|
project.key, project.time = "aes", "3/4"
|
||||||
|
page.replacements[second] = Replacement(
|
||||||
|
voices=[Voice("treble", "c4 d e f", "la la la la"), Voice("bass", " c4 d e f ", " ")]
|
||||||
|
)
|
||||||
|
engraved = song_json(project, names)["slices"]
|
||||||
|
assert "engraving" not in engraved[0], "a scanned slice has no notation"
|
||||||
|
ly = engraved[1]["engraving"]
|
||||||
|
assert ly["lang"] == "lilypond"
|
||||||
|
# Song defaults are resolved per slice: reading one slice needs no context.
|
||||||
|
assert (ly["key"], ly["time"], ly["print_time"]) == ("aes", "3/4", False)
|
||||||
|
assert ly["voices"][0] == {"clef": "treble", "notes": "c4 d e f", "lyrics": "la la la la"}
|
||||||
|
assert "lyrics" not in ly["voices"][1], "an empty field is absent, not empty"
|
||||||
|
assert ly["voices"][1]["notes"] == "c4 d e f"
|
||||||
|
|
||||||
|
override = Replacement(voices=page.replacements[second].voices, key="d", print_time=True)
|
||||||
|
page.replacements[second] = override
|
||||||
|
ly = song_json(project, names)["slices"][1]["engraving"]
|
||||||
|
assert (ly["key"], ly["time"], ly["print_time"]) == ("d", "3/4", True)
|
||||||
|
page.replacements[second] = None
|
||||||
|
|
||||||
# A jump whose target got discarded is dropped, not exported dangling.
|
# A jump whose target got discarded is dropped, not exported dangling.
|
||||||
project.pages[0].discards[second] = True
|
project.pages[0].discards[second] = True
|
||||||
dropped = song_json(project, ["001.webp"])
|
dropped = song_json(project, ["001.webp"])
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Runnable check for optional PDF shrinking, including what it refuses to do."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pymupdf
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from noteman_slicer.pdfopt import MIN_DPI, optimise # noqa: E402
|
||||||
|
|
||||||
|
A4_PT = (595, 842)
|
||||||
|
|
||||||
|
|
||||||
|
def _pdf(path: Path, width: int, height: int, *, colour: bool = False, bilevel: bool = False):
|
||||||
|
"""One full-page image of ruled lines, at the given pixel size."""
|
||||||
|
art = np.full((height, width, 3), 255, np.uint8)
|
||||||
|
for i in range(6):
|
||||||
|
y = int(height * (0.2 + i * 0.03))
|
||||||
|
art[y : y + max(1, height // 900), int(width * 0.1) : int(width * 0.9)] = 0
|
||||||
|
if colour:
|
||||||
|
art[: height // 3, :, 0] = 40 # a strong blue cast over the top third
|
||||||
|
art[: height // 3, :, 1] = 90
|
||||||
|
grey = art[:, :, 0] if not colour else None
|
||||||
|
|
||||||
|
doc = pymupdf.open()
|
||||||
|
page = doc.new_page(width=A4_PT[0], height=A4_PT[1])
|
||||||
|
if bilevel:
|
||||||
|
pix = pymupdf.Pixmap(pymupdf.csGRAY, width, height, bytearray(grey.tobytes()), False)
|
||||||
|
page.insert_image(page.rect, pixmap=pix)
|
||||||
|
doc.save(path, garbage=4, deflate=True)
|
||||||
|
# Re-save through a 1-bit PNG so the stored image really is bilevel.
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
ok, buf = cv2.imencode(".png", (grey > 127).astype(np.uint8) * 255)
|
||||||
|
doc2 = pymupdf.open()
|
||||||
|
p2 = doc2.new_page(width=A4_PT[0], height=A4_PT[1])
|
||||||
|
p2.insert_image(p2.rect, stream=buf.tobytes())
|
||||||
|
doc2.save(path, garbage=4, deflate=True)
|
||||||
|
return
|
||||||
|
stream = art if colour else np.dstack([grey] * 3)
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
ok, buf = cv2.imencode(".jpg", stream, [cv2.IMWRITE_JPEG_QUALITY, 92])
|
||||||
|
page.insert_image(page.rect, stream=buf.tobytes())
|
||||||
|
doc.save(path, garbage=4, deflate=True)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
tmp = Path(__file__).with_name("_tmp")
|
||||||
|
tmp.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# A4 is 8.26in wide, so 2480px is ~300 DPI and 800px is ~97 DPI.
|
||||||
|
fine, coarse, colour = tmp / "fine.pdf", tmp / "coarse.pdf", tmp / "colour.pdf"
|
||||||
|
_pdf(fine, 2480, 3508)
|
||||||
|
_pdf(coarse, 800, 1130)
|
||||||
|
_pdf(colour, 2480, 3508, colour=True)
|
||||||
|
|
||||||
|
data, report = optimise(pymupdf.open(fine), fine.stat().st_size)
|
||||||
|
assert report.converted == 1, report.summary()
|
||||||
|
assert data, "a greyscale scan at 300 DPI should shrink"
|
||||||
|
assert report.ratio < 0.9, report.ratio
|
||||||
|
# The result must still be a readable PDF of the same page count.
|
||||||
|
assert len(pymupdf.open(stream=data, filetype="pdf")) == 1
|
||||||
|
|
||||||
|
# Too coarse: staff lines would break, so it is left alone.
|
||||||
|
_, report = optimise(pymupdf.open(coarse), coarse.stat().st_size)
|
||||||
|
assert report.converted == 0, report.summary()
|
||||||
|
assert any("DPI" in reason for reason in report.skipped), report.skipped
|
||||||
|
|
||||||
|
# Genuine colour: artwork is not thrown away.
|
||||||
|
_, report = optimise(pymupdf.open(colour), colour.stat().st_size)
|
||||||
|
assert report.converted == 0, report.summary()
|
||||||
|
assert any("colour" in reason for reason in report.skipped), report.skipped
|
||||||
|
|
||||||
|
# A no-op run reports honestly rather than returning something bigger.
|
||||||
|
empty = pymupdf.open()
|
||||||
|
empty.new_page()
|
||||||
|
data, report = optimise(empty, 1)
|
||||||
|
assert data == b"" and report.converted == 0
|
||||||
|
assert report.ratio == 1.0
|
||||||
|
|
||||||
|
assert MIN_DPI >= 150, "the floor exists to protect thin staff lines"
|
||||||
|
|
||||||
|
for f in (fine, coarse, colour):
|
||||||
|
f.unlink(missing_ok=True)
|
||||||
|
tmp.rmdir()
|
||||||
|
print("ok")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user