Compare commits
5
Commits
dev
..
0da9dc29bf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0da9dc29bf | ||
|
|
6ce45bb1d8 | ||
|
|
c36001f25f | ||
|
|
5f67a8c359 | ||
|
|
d5af7b6159 |
+3
-87
@@ -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
-52
@@ -44,27 +44,13 @@ 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. **Set black and white points.** Pull the *White point* down until the paper
|
||||||
a hump of ink on the left, a hump of paper on the right, and two handles you
|
goes pure white and its texture disappears; pull *Black point* up until the
|
||||||
drag. Put the white handle at the foot of the paper hump and the black one at
|
notes are solid black rather than grey mush. Scans need this; clean digital
|
||||||
the foot of the ink hump. The strip underneath shows the tone that results.
|
PDFs usually don't.
|
||||||
Getting this wrong is the one mistake you cannot see until the bundle is on
|
|
||||||
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
|
||||||
|
|
||||||
@@ -91,8 +77,7 @@ and "Andante" cannot.
|
|||||||
|
|
||||||
## Export
|
## Export
|
||||||
|
|
||||||
**Export bundle…**, choose where the `.zip` goes, done. It is named after the
|
**Export bundle…**, choose where the `.zip` goes, done. Inside are the slice
|
||||||
song's title — *Bicycle Race* becomes `Bicycle-Race.zip`. Inside are the slice
|
|
||||||
images in order, their markers, the song metadata, and the original PDF as the
|
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
|
archive copy. That zip is the whole interface to noteman; hand it over and open
|
||||||
it there.
|
it there.
|
||||||
@@ -106,31 +91,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 +128,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 +140,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.
|
|
||||||
|
|||||||
@@ -236,14 +236,6 @@ point just under the paper's luminance and the paper vanishes completely; set th
|
|||||||
black point at the ink's darkest and notes go solid. It is also the single
|
black point at the ink's darkest and notes go solid. It is also the single
|
||||||
biggest lever on output size.
|
biggest lever on output size.
|
||||||
|
|
||||||
**Detection proposes both**, like it proposes cuts and skew, because the default
|
|
||||||
0–255 is the one setting whose harm is invisible until the bundle is on a tablet.
|
|
||||||
Notation is two-tone, so Otsu's split between ink and paper is the measurement;
|
|
||||||
the points sit halfway from it to each end of the range, leaving the ramp between
|
|
||||||
them as the antialiasing. A page already scanned bilevel has no interior split —
|
|
||||||
Otsu degenerates to 0 there — and is left at 0–255. The proposal is per page and
|
|
||||||
the median becomes the song's, so a near-blank page cannot set it.
|
|
||||||
|
|
||||||
Adaptive methods (CLAHE, adaptive thresholding) are the trap — tuned for text,
|
Adaptive methods (CLAHE, adaptive thresholding) are the trap — tuned for text,
|
||||||
they eat the thin stuff on notation: hairpin tips, slur ends, ledger lines,
|
they eat the thin stuff on notation: hairpin tips, slur ends, ledger lines,
|
||||||
tapered beams. A global LUT whose effect you can see beats a local algorithm you
|
tapered beams. A global LUT whose effect you can see beats a local algorithm you
|
||||||
@@ -347,15 +339,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
-231
@@ -13,7 +13,6 @@ a jump source's `destination`.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import re
|
|
||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -40,19 +39,6 @@ METADATA_FIELDS = (
|
|||||||
NUMERIC_FIELDS = frozenset({"tempo"})
|
NUMERIC_FIELDS = frozenset({"tempo"})
|
||||||
|
|
||||||
|
|
||||||
def filename(project: Project) -> str:
|
|
||||||
"""The bundle's name, from the song's title.
|
|
||||||
|
|
||||||
Spaces become dashes and anything that is not a letter, digit, dash, dot or
|
|
||||||
underscore goes. Letters keep their accents — ä and ö are not a filesystem's
|
|
||||||
problem — but a leading dot would make the bundle invisible.
|
|
||||||
"""
|
|
||||||
# Drop the unsafe characters before collapsing whitespace, not after, or
|
|
||||||
# "Sävel & Ääni" keeps the dash the ampersand left behind.
|
|
||||||
title = re.sub(r"[^\w\s.-]", "", (project.metadata.get("title") or ""))
|
|
||||||
return f"{re.sub(r'\s+', '-', title.strip()).lstrip('.-') or 'song'}.zip"
|
|
||||||
|
|
||||||
|
|
||||||
def _engraving(project: Project, page: int, slot: int) -> dict | None:
|
def _engraving(project: Project, page: int, slot: int) -> dict | None:
|
||||||
"""The notation behind a re-engraved slice, or None for a scanned one.
|
"""The notation behind a re-engraved slice, or None for a scanned one.
|
||||||
|
|
||||||
@@ -77,33 +63,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 +85,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 +108,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 +155,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
|
|
||||||
|
|||||||
+1
-67
@@ -87,7 +87,7 @@ def _export(args: argparse.Namespace) -> int:
|
|||||||
elif project.source_changed():
|
elif project.source_changed():
|
||||||
print("WARNING: the PDF has changed since these cuts were made")
|
print("WARNING: the PDF has changed since these cuts were made")
|
||||||
|
|
||||||
out = Path(args.out) if args.out else source.path.with_name(bundle.filename(project))
|
out = Path(args.out) if args.out else source.path.with_suffix(".zip")
|
||||||
try:
|
try:
|
||||||
bundle.write(project, source, out)
|
bundle.write(project, source, out)
|
||||||
except ValueError as error:
|
except ValueError as error:
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ _SKEW_WORK_SCALE = 0.25
|
|||||||
_INK = 128 # below this is ink, above is paper
|
_INK = 128 # below this is ink, above is paper
|
||||||
_ANCHOR_KERNEL = 0.03 # vertical open kernel, as a fraction of page height
|
_ANCHOR_KERNEL = 0.03 # vertical open kernel, as a fraction of page height
|
||||||
_ANCHOR_MIN = 0.04 # a bracket is at least this tall, as a fraction of page
|
_ANCHOR_MIN = 0.04 # a bracket is at least this tall, as a fraction of page
|
||||||
_ANCHOR_MAX_RATIO = 2.0 # a stroke this much taller than the typical one is an artefact
|
|
||||||
_PROFILE_FLOOR = 0.02 # ink-run threshold, as a fraction of the profile peak
|
_PROFILE_FLOOR = 0.02 # ink-run threshold, as a fraction of the profile peak
|
||||||
_EXPAND_REACH = 1.5 # how far past the bracket a system's ink reaches, in staff heights
|
_EXPAND_REACH = 1.5 # how far past the bracket a system's ink reaches, in staff heights
|
||||||
_STAFF_KERNEL = 0.05 # horizontal open kernel, as a fraction of page width
|
_STAFF_KERNEL = 0.05 # horizontal open kernel, as a fraction of page width
|
||||||
@@ -65,7 +64,6 @@ class PageDetection:
|
|||||||
systems: list[System] = field(default_factory=list)
|
systems: list[System] = field(default_factory=list)
|
||||||
cuts: list[int] = field(default_factory=list)
|
cuts: list[int] = field(default_factory=list)
|
||||||
content: tuple[float, float, float, float] = (0.0, 0.0, 1.0, 1.0)
|
content: tuple[float, float, float, float] = (0.0, 0.0, 1.0, 1.0)
|
||||||
levels: tuple[int, int] = (0, 255)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def bracketless(self) -> bool:
|
def bracketless(self) -> bool:
|
||||||
@@ -125,17 +123,6 @@ def system_anchors(gray: np.ndarray) -> list[Anchor]:
|
|||||||
if stats[i, cv2.CC_STAT_HEIGHT] > h * _ANCHOR_MIN
|
if stats[i, cv2.CC_STAT_HEIGHT] > h * _ANCHOR_MIN
|
||||||
]
|
]
|
||||||
|
|
||||||
# A scanner leaves a dark line down the sheet edge — the binder shadow, the
|
|
||||||
# glass, the page next to it — and it runs the whole height of the scan.
|
|
||||||
# Being the tallest stroke on the page it wins every overlap below and
|
|
||||||
# swallows every system into one. A page's brackets and barlines are all
|
|
||||||
# about one system tall, so anything wildly taller than the typical stroke
|
|
||||||
# is not notation. Relative, not an absolute fraction of the page: a page
|
|
||||||
# holding one big system is legitimate and must survive.
|
|
||||||
if len(tall) > 1:
|
|
||||||
limit = float(np.median([a.bottom - a.top for a in tall])) * _ANCHOR_MAX_RATIO
|
|
||||||
tall = [a for a in tall if a.bottom - a.top <= limit] or tall
|
|
||||||
|
|
||||||
# Tallest first, keeping only strokes that don't overlap one already kept:
|
# Tallest first, keeping only strokes that don't overlap one already kept:
|
||||||
# a system's barlines all overlap its bracket, so each system yields one.
|
# a system's barlines all overlap its bracket, so each system yields one.
|
||||||
# The kept stroke is the tallest, which is the bracket rather than a barline.
|
# The kept stroke is the tallest, which is the bracket rather than a barline.
|
||||||
@@ -293,26 +280,6 @@ def staff_height(gray: np.ndarray, top: int, bottom: int) -> float | None:
|
|||||||
return float(np.median(intra) * 4) # 5 lines, 4 spaces
|
return float(np.median(intra) * 4) # 5 lines, 4 spaces
|
||||||
|
|
||||||
|
|
||||||
def ink_levels(gray: np.ndarray) -> tuple[int, int]:
|
|
||||||
"""Black and white points that put the ink on black and the paper on white.
|
|
||||||
|
|
||||||
Left at 0–255 a slice ships whatever grey the scanner produced, and the
|
|
||||||
downscale to the song's width then blends every stroke edge further, so a
|
|
||||||
fine engraving arrives on the tablet as a wash. Notation is two-tone by
|
|
||||||
nature — ink and paper, nothing in between — so Otsu's split is exactly the
|
|
||||||
measurement wanted, and the points sit halfway to each end of the range from
|
|
||||||
it. Halfway rather than at the split itself: the ramp between them is the
|
|
||||||
antialiasing, and collapsing it would leave the notes jagged.
|
|
||||||
"""
|
|
||||||
split = float(cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)[0])
|
|
||||||
if split < 1:
|
|
||||||
# A page already scanned bilevel has no interior split to find, and
|
|
||||||
# Otsu degenerates to 0. There is nothing between ink and paper to
|
|
||||||
# stretch, so leave the sliders where they are.
|
|
||||||
return 0, 255
|
|
||||||
return int(split / 2), int(split + (255 - split) / 2)
|
|
||||||
|
|
||||||
|
|
||||||
def _gap(run: tuple[int, int], anchor: Anchor) -> int:
|
def _gap(run: tuple[int, int], anchor: Anchor) -> int:
|
||||||
"""Vertical distance between an ink run and a bracket; 0 if they overlap."""
|
"""Vertical distance between an ink run and a bracket; 0 if they overlap."""
|
||||||
start, end = run
|
start, end = run
|
||||||
@@ -408,9 +375,5 @@ def detect_page(gray: np.ndarray, skew: float | None = None) -> PageDetection:
|
|||||||
# would risk clipping a tempo mark or a section label above the first staff.
|
# would risk clipping a tempo mark or a section label above the first staff.
|
||||||
left, right = content_columns(straight, anchors)
|
left, right = content_columns(straight, anchors)
|
||||||
return PageDetection(
|
return PageDetection(
|
||||||
skew=angle,
|
skew=angle, systems=systems, cuts=cuts, content=(left, 0.0, right, 1.0)
|
||||||
systems=systems,
|
|
||||||
cuts=cuts,
|
|
||||||
content=(left, 0.0, right, 1.0),
|
|
||||||
levels=ink_levels(straight),
|
|
||||||
)
|
)
|
||||||
|
|||||||
+117
-166
@@ -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
|
||||||
@@ -74,8 +75,6 @@ _CUT = QColor(220, 40, 40)
|
|||||||
_CUT_ACTIVE = QColor(255, 120, 0)
|
_CUT_ACTIVE = QColor(255, 120, 0)
|
||||||
_VERTEX = QColor(255, 200, 0)
|
_VERTEX = QColor(255, 200, 0)
|
||||||
_DISCARD = QColor(120, 120, 140, 90)
|
_DISCARD = QColor(120, 120, 140, 90)
|
||||||
_SELECT = QColor(0, 170, 0)
|
|
||||||
_SELECT_WASH = QColor(0, 200, 60, 40)
|
|
||||||
_RECT = QColor(40, 140, 220)
|
_RECT = QColor(40, 140, 220)
|
||||||
_MARKER = QColor(150, 60, 190)
|
_MARKER = QColor(150, 60, 190)
|
||||||
_ENGRAVED = QColor(200, 120, 0)
|
_ENGRAVED = QColor(200, 120, 0)
|
||||||
@@ -83,40 +82,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."""
|
||||||
|
|
||||||
@@ -139,7 +104,6 @@ class PageView(QGraphicsView):
|
|||||||
self.selected_slice = 0
|
self.selected_slice = 0
|
||||||
self.picking = False
|
self.picking = False
|
||||||
self._drag: tuple[str, int, int] | None = None
|
self._drag: tuple[str, int, int] | None = None
|
||||||
self._fitted = False
|
|
||||||
|
|
||||||
# -- state ------------------------------------------------------------
|
# -- state ------------------------------------------------------------
|
||||||
|
|
||||||
@@ -173,15 +137,10 @@ class PageView(QGraphicsView):
|
|||||||
self._slice_polygon(slot, w, h), QPen(Qt.NoPen), QBrush(_DISCARD)
|
self._slice_polygon(slot, w, h), QPen(Qt.NoPen), QBrush(_DISCARD)
|
||||||
)
|
)
|
||||||
|
|
||||||
# The selected slice. The outline alone is nearly invisible: its top and
|
# The selected slice, outlined so trim anomalies are visible.
|
||||||
# bottom edges run under the cut lines drawn over them, leaving two thin
|
pen = QPen(QColor(0, 170, 0), 2)
|
||||||
# verticals at the page margins. A wash says which slice is selected at
|
|
||||||
# a glance; the outline stays, because it is what shows trim anomalies.
|
|
||||||
selected = self._slice_polygon(self.selected_slice, w, h)
|
|
||||||
scene.addPolygon(selected, QPen(Qt.NoPen), QBrush(_SELECT_WASH))
|
|
||||||
pen = QPen(_SELECT, 3)
|
|
||||||
pen.setCosmetic(True)
|
pen.setCosmetic(True)
|
||||||
scene.addPolygon(selected, pen)
|
scene.addPolygon(self._slice_polygon(self.selected_slice, w, h), pen)
|
||||||
|
|
||||||
x0, y0, x1, y1 = self.project.page_content_rect(self.page_index)
|
x0, y0, x1, y1 = self.project.page_content_rect(self.page_index)
|
||||||
pen = QPen(_RECT, 2, Qt.DashLine)
|
pen = QPen(_RECT, 2, Qt.DashLine)
|
||||||
@@ -402,15 +361,6 @@ class PageView(QGraphicsView):
|
|||||||
self.redraw()
|
self.redraw()
|
||||||
self.changed.emit()
|
self.changed.emit()
|
||||||
|
|
||||||
def resizeEvent(self, event) -> None:
|
|
||||||
super().resizeEvent(event)
|
|
||||||
# The fit in show_page runs before the window has been laid out, when
|
|
||||||
# the viewport is still its default size, so the first page opens at
|
|
||||||
# some arbitrary zoom. Redo it once, when the real size arrives.
|
|
||||||
if not self._fitted and self.pixmap is not None:
|
|
||||||
self._fitted = True
|
|
||||||
self.fitInView(self.scene().sceneRect(), Qt.KeepAspectRatio)
|
|
||||||
|
|
||||||
def wheelEvent(self, event) -> None:
|
def wheelEvent(self, event) -> None:
|
||||||
factor = 1.15 if event.angleDelta().y() > 0 else 1 / 1.15
|
factor = 1.15 if event.angleDelta().y() > 0 else 1 / 1.15
|
||||||
self.scale(factor, factor)
|
self.scale(factor, factor)
|
||||||
@@ -459,21 +409,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 +480,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 +524,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 +557,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 +565,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 +625,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 +656,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 +676,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)
|
||||||
@@ -889,10 +844,7 @@ class Editor(QMainWindow):
|
|||||||
self.metadata["title"].setFocus()
|
self.metadata["title"].setFocus()
|
||||||
return
|
return
|
||||||
target, _ = QFileDialog.getSaveFileName(
|
target, _ = QFileDialog.getSaveFileName(
|
||||||
self,
|
self, "Export bundle", str(self.source.path.with_suffix(".zip")), "Bundle (*.zip)"
|
||||||
"Export bundle",
|
|
||||||
str(self.source.path.with_name(bundle.filename(self.project))),
|
|
||||||
"Bundle (*.zip)",
|
|
||||||
)
|
)
|
||||||
if not target:
|
if not target:
|
||||||
return
|
return
|
||||||
@@ -917,7 +869,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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
-16
@@ -90,28 +90,13 @@ def page_raster(source: Source, index: int) -> np.ndarray:
|
|||||||
# Pixmap(doc, xref) rather than decoding extract_image() bytes:
|
# Pixmap(doc, xref) rather than decoding extract_image() bytes:
|
||||||
# MuPDF handles JBIG2 and CCITT, which no image library will.
|
# MuPDF handles JBIG2 and CCITT, which no image library will.
|
||||||
pix = pymupdf.Pixmap(source.doc, xref)
|
pix = pymupdf.Pixmap(source.doc, xref)
|
||||||
# The embedded image is in its own orientation, not the page's: a
|
return _to_gray(pix)
|
||||||
# scanner that fed the sheet sideways stores it landscape and the
|
|
||||||
# PDF sets /Rotate so viewers turn it upright. Extracting by xref
|
|
||||||
# bypasses that, so apply it here — otherwise every system runs
|
|
||||||
# down the page and detection finds nothing.
|
|
||||||
return _rotate(_to_gray(pix), page.rotation)
|
|
||||||
# A scanned PDF whose page has no embedded image (a blank, or a
|
# A scanned PDF whose page has no embedded image (a blank, or a
|
||||||
# cover typeset in vector). Rendering is the only option left.
|
# cover typeset in vector). Rendering is the only option left.
|
||||||
|
|
||||||
return _to_gray(page.get_pixmap(dpi=source.render_dpi, colorspace=pymupdf.csGRAY))
|
return _to_gray(page.get_pixmap(dpi=source.render_dpi, colorspace=pymupdf.csGRAY))
|
||||||
|
|
||||||
|
|
||||||
def _rotate(gray: np.ndarray, degrees: int) -> np.ndarray:
|
|
||||||
"""Turn a page raster clockwise by a multiple of 90°, as /Rotate means it.
|
|
||||||
|
|
||||||
ponytail: quarter turns only. A page rotated by anything else would need
|
|
||||||
resampling, and no scanner produces one.
|
|
||||||
"""
|
|
||||||
turns = round(degrees / 90) % 4
|
|
||||||
return np.ascontiguousarray(np.rot90(gray, -turns)) if turns else gray
|
|
||||||
|
|
||||||
|
|
||||||
def _to_gray(pix: pymupdf.Pixmap) -> np.ndarray:
|
def _to_gray(pix: pymupdf.Pixmap) -> np.ndarray:
|
||||||
if pix.alpha or pix.colorspace is None or pix.colorspace.n != 1:
|
if pix.alpha or pix.colorspace is None or pix.colorspace.n != 1:
|
||||||
pix = pymupdf.Pixmap(pymupdf.csGRAY, pix)
|
pix = pymupdf.Pixmap(pymupdf.csGRAY, pix)
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from statistics import median
|
|
||||||
|
|
||||||
from .detect import PageDetection
|
from .detect import PageDetection
|
||||||
|
|
||||||
@@ -113,11 +112,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 +163,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 +188,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 +200,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,24 +291,12 @@ 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,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# Levels per song, not per page: a scanner's contrast does not change
|
return cls(source=source, source_hash=hash_file(source), pages=pages)
|
||||||
# between sheets, and one pair of sliders for the whole song is what a
|
|
||||||
# user actually wants to nudge. The median keeps a near-blank page —
|
|
||||||
# where the ink/paper split is guesswork — from setting them.
|
|
||||||
proposals = [d.levels for d in detections] or [(0, 255)]
|
|
||||||
levels = (
|
|
||||||
int(median(b for b, _ in proposals)),
|
|
||||||
int(median(w for _, w in proposals)),
|
|
||||||
)
|
|
||||||
return cls(
|
|
||||||
source=source, source_hash=hash_file(source), pages=pages, levels=levels
|
|
||||||
)
|
|
||||||
|
|
||||||
def save(self, path: Path | None = None) -> Path:
|
def save(self, path: Path | None = None) -> Path:
|
||||||
"""Atomic write, so a crash mid-save cannot destroy the previous state."""
|
"""Atomic write, so a crash mid-save cannot destroy the previous state."""
|
||||||
@@ -375,7 +347,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 +403,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,
|
||||||
)
|
)
|
||||||
|
|||||||
+19
-22
@@ -26,10 +26,7 @@ from .project import Cut, Project
|
|||||||
|
|
||||||
MAX_WIDTH = 1920
|
MAX_WIDTH = 1920
|
||||||
ALPHA_LEVELS = 16 # quantising alpha costs nothing visible and ~32% of the bytes
|
ALPHA_LEVELS = 16 # quantising alpha costs nothing visible and ~32% of the bytes
|
||||||
# A row or column carrying less ink than this is a fleck, not content: at least
|
_SPECK_AREA = 300 # ink blobs smaller than this don't anchor a trim
|
||||||
# this many pixels, and at least this share of the slice's own size.
|
|
||||||
_SPECK_INK = 8
|
|
||||||
_SPECK_SHARE = 0.005
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -108,24 +105,26 @@ def _ink_bbox(gray: np.ndarray) -> tuple[int, int, int, int] | None:
|
|||||||
|
|
||||||
One scan fleck at the far left would otherwise anchor the trim and shift
|
One scan fleck at the far left would otherwise anchor the trim and shift
|
||||||
that slice relative to every other one.
|
that slice relative to every other one.
|
||||||
|
|
||||||
Measured per row and per column rather than per blob. Judging each blob on
|
|
||||||
its own area throws away a whole line of lyrics — every letter is its own
|
|
||||||
small component, and no single one is big enough to keep — which is how a
|
|
||||||
slice loses its bottom voice's words. A row carrying a line of text carries
|
|
||||||
plenty of ink *in total*, and a fleck's row carries almost none.
|
|
||||||
"""
|
"""
|
||||||
ink = gray < 200
|
ink = (gray < 200).astype(np.uint8)
|
||||||
rows, cols = ink.sum(axis=1), ink.sum(axis=0)
|
count, _, stats, _ = cv2.connectedComponentsWithStats(ink, 8)
|
||||||
kept_rows = np.where(rows >= max(_SPECK_INK, ink.shape[1] * _SPECK_SHARE))[0]
|
boxes = [
|
||||||
kept_cols = np.where(cols >= max(_SPECK_INK, ink.shape[0] * _SPECK_SHARE))[0]
|
(
|
||||||
if not kept_rows.size or not kept_cols.size:
|
stats[i, cv2.CC_STAT_LEFT],
|
||||||
|
stats[i, cv2.CC_STAT_TOP],
|
||||||
|
stats[i, cv2.CC_STAT_LEFT] + stats[i, cv2.CC_STAT_WIDTH],
|
||||||
|
stats[i, cv2.CC_STAT_TOP] + stats[i, cv2.CC_STAT_HEIGHT],
|
||||||
|
)
|
||||||
|
for i in range(1, count)
|
||||||
|
if stats[i, cv2.CC_STAT_AREA] >= _SPECK_AREA
|
||||||
|
]
|
||||||
|
if not boxes:
|
||||||
return None
|
return None
|
||||||
return (
|
return (
|
||||||
int(kept_cols[0]),
|
min(b[0] for b in boxes),
|
||||||
int(kept_rows[0]),
|
min(b[1] for b in boxes),
|
||||||
int(kept_cols[-1]) + 1,
|
max(b[2] for b in boxes),
|
||||||
int(kept_rows[-1]) + 1,
|
max(b[3] for b in boxes),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -161,9 +160,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:
|
||||||
|
|||||||
+1
-23
@@ -14,12 +14,7 @@ import numpy as np
|
|||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
from noteman_slicer.detect import ( # noqa: E402
|
from noteman_slicer.detect import deskew, deskew_angle, detect_page # noqa: E402
|
||||||
deskew,
|
|
||||||
deskew_angle,
|
|
||||||
detect_page,
|
|
||||||
ink_levels,
|
|
||||||
)
|
|
||||||
|
|
||||||
W, H = 1000, 1400
|
W, H = 1000, 1400
|
||||||
STAFF_GAP = 15 # → staff height 60, so expansion reaches 90px past a bracket
|
STAFF_GAP = 15 # → staff height 60, so expansion reaches 90px past a bracket
|
||||||
@@ -76,23 +71,6 @@ def main() -> int:
|
|||||||
found = deskew_angle(deskew(page, angle))
|
found = deskew_angle(deskew(page, angle))
|
||||||
assert abs(found + angle) <= 0.15, f"skew {angle}: got {found}"
|
assert abs(found + angle) <= 0.15, f"skew {angle}: got {found}"
|
||||||
|
|
||||||
# Levels are proposed too. A grey scan left at 0–255 ships its wash to the
|
|
||||||
# tablet, and the downscale to the song's width only blends it further.
|
|
||||||
grey = np.full((H, W), 210, np.uint8) # paper, not white
|
|
||||||
grey[200:400, 100:900] = 70 # ink, not black
|
|
||||||
black, white = ink_levels(grey)
|
|
||||||
assert black < 70 < white < 210, (black, white)
|
|
||||||
# A page already bilevel has nothing between ink and paper to stretch.
|
|
||||||
assert ink_levels(_page()) == (0, 255)
|
|
||||||
|
|
||||||
# A scanner's edge line runs the whole height of the sheet. Being taller
|
|
||||||
# than every bracket it used to win each overlap and swallow the page into
|
|
||||||
# one system — Olukainen juomukainen, where five pages of six came out as a
|
|
||||||
# single slice each.
|
|
||||||
scanned = _page()
|
|
||||||
scanned[10 : H - 10, W - 8 : W - 4] = 0
|
|
||||||
assert len(detect_page(scanned).systems) == 2, "an edge artefact is not a bracket"
|
|
||||||
|
|
||||||
# No brackets: every ink run is its own system.
|
# No brackets: every ink run is its own system.
|
||||||
bare = np.full((H, W), 255, np.uint8)
|
bare = np.full((H, W), 255, np.uint8)
|
||||||
for y in (200, 500, 800):
|
for y in (200, 500, 800):
|
||||||
|
|||||||
+2
-30
@@ -19,7 +19,6 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|||||||
|
|
||||||
from PySide6.QtWidgets import QApplication # noqa: E402
|
from PySide6.QtWidgets import QApplication # noqa: E402
|
||||||
|
|
||||||
from noteman_slicer import bundle # noqa: E402
|
|
||||||
from noteman_slicer.detect import detect_page # noqa: E402
|
from noteman_slicer.detect import detect_page # noqa: E402
|
||||||
from noteman_slicer.editor import Editor # noqa: E402
|
from noteman_slicer.editor import Editor # noqa: E402
|
||||||
from noteman_slicer.pdf import open_source, page_raster # noqa: E402
|
from noteman_slicer.pdf import open_source, page_raster # noqa: E402
|
||||||
@@ -77,14 +76,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")
|
||||||
@@ -111,28 +105,6 @@ def main() -> int:
|
|||||||
assert reloaded.pages[0].levels == (40, 210)
|
assert reloaded.pages[0].levels == (40, 210)
|
||||||
assert [c.points for c in reloaded.pages[0].cuts] == [c.points for c in page.cuts]
|
assert [c.points for c in reloaded.pages[0].cuts] == [c.points for c in page.cuts]
|
||||||
|
|
||||||
# The page fits the viewport once the window has a real size. show_page's
|
|
||||||
# own fit runs before layout, when the viewport is still its default.
|
|
||||||
editor.resize(900, 700)
|
|
||||||
editor.show()
|
|
||||||
app.processEvents()
|
|
||||||
scene = editor.view.sceneRect()
|
|
||||||
scale = editor.view.transform().m11()
|
|
||||||
viewport = editor.view.viewport()
|
|
||||||
fill = max(
|
|
||||||
scale * scene.width() / viewport.width(),
|
|
||||||
scale * scene.height() / viewport.height(),
|
|
||||||
)
|
|
||||||
# Fit means nearly touching one edge — Qt leaves a small margin of its own.
|
|
||||||
# A "≤ 1" check alone would pass a page zoomed down to a dot.
|
|
||||||
assert 0.9 <= fill <= 1.02, f"page is not fitted to the window: {fill:.3f}"
|
|
||||||
|
|
||||||
# The bundle is named after the song, not the PDF.
|
|
||||||
assert bundle.filename(project) == "Ketun-joululaulu.zip"
|
|
||||||
project.metadata["title"] = "AC/DC: T.N.T. (live)"
|
|
||||||
assert bundle.filename(project) == "ACDC-T.N.T.-live.zip"
|
|
||||||
project.metadata["title"] = "Ketun joululaulu"
|
|
||||||
|
|
||||||
editor.close()
|
editor.close()
|
||||||
source.close()
|
source.close()
|
||||||
for f in (pdf, saved):
|
for f in (pdf, saved):
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
|
|||||||
+4
-26
@@ -28,21 +28,15 @@ def _vector_pdf(path: Path, pages: int = 2) -> None:
|
|||||||
doc.save(path)
|
doc.save(path)
|
||||||
|
|
||||||
|
|
||||||
def _scan_pdf(path: Path, pages: int = 2, w: int = 1653, h: int = 2332, rotation: int = 0) -> None:
|
def _scan_pdf(path: Path, pages: int = 2, w: int = 1653, h: int = 2332) -> None:
|
||||||
"""Each page is one full-page grayscale image — what a real scan looks like.
|
"""Each page is one full-page grayscale image — what a real scan looks like."""
|
||||||
|
|
||||||
`rotation` reproduces a sheet fed sideways: the image is stored in its own
|
|
||||||
orientation and /Rotate turns it upright for a viewer.
|
|
||||||
"""
|
|
||||||
art = np.full((h, w), 255, np.uint8)
|
art = np.full((h, w), 255, np.uint8)
|
||||||
art[500:505, 100 : w - 100] = 0 # a staff line, so it isn't uniform
|
art[500:505, 100 : w - 100] = 0 # a staff line, so it isn't uniform
|
||||||
art[:60, :60] = 0 # a corner mark, so orientation is checkable
|
|
||||||
pix = pymupdf.Pixmap(pymupdf.csGRAY, w, h, bytearray(art.tobytes()), False)
|
pix = pymupdf.Pixmap(pymupdf.csGRAY, w, h, bytearray(art.tobytes()), False)
|
||||||
doc = pymupdf.open()
|
doc = pymupdf.open()
|
||||||
for _ in range(pages):
|
for _ in range(pages):
|
||||||
page = doc.new_page(width=A4.width, height=A4.width * h / w)
|
page = doc.new_page(width=A4.width, height=A4.height)
|
||||||
page.insert_image(page.rect, pixmap=pix)
|
page.insert_image(page.rect, pixmap=pix)
|
||||||
page.set_rotation(rotation)
|
|
||||||
doc.save(path)
|
doc.save(path)
|
||||||
|
|
||||||
|
|
||||||
@@ -70,22 +64,6 @@ def main() -> int:
|
|||||||
assert page.min() == 0 and page.max() == 255, (page.min(), page.max())
|
assert page.min() == 0 and page.max() == 255, (page.min(), page.max())
|
||||||
src.close()
|
src.close()
|
||||||
|
|
||||||
# The corner mark sits top-left in an upright scan.
|
|
||||||
assert page[:60, :60].max() == 0 and page[:60, -60:].min() == 255
|
|
||||||
|
|
||||||
# A sideways scan comes back upright: the page's /Rotate applies to the
|
|
||||||
# image extracted by xref, which bypasses it. Okular gets this right and
|
|
||||||
# the slicer used to not.
|
|
||||||
sideways = tmp / "sideways.pdf"
|
|
||||||
_scan_pdf(sideways, pages=1, w=2332, h=1653, rotation=90)
|
|
||||||
src = open_source(sideways)
|
|
||||||
assert src.type is SourceType.RASTER
|
|
||||||
turned = page_raster(src, 0)
|
|
||||||
assert turned.shape == (2332, 1653), turned.shape
|
|
||||||
# Turned clockwise, so the mark that was top-left is now top-right.
|
|
||||||
assert turned[:60, -60:].max() == 0 and turned[:60, :60].min() == 255
|
|
||||||
src.close()
|
|
||||||
|
|
||||||
# An override must win over detection, and say so.
|
# An override must win over detection, and say so.
|
||||||
src = open_source(scan, SourceType.VECTOR)
|
src = open_source(scan, SourceType.VECTOR)
|
||||||
assert src.type is SourceType.VECTOR and src.detected is SourceType.RASTER
|
assert src.type is SourceType.VECTOR and src.detected is SourceType.RASTER
|
||||||
@@ -93,7 +71,7 @@ def main() -> int:
|
|||||||
assert page_raster(src, 0).shape[1] > 4000, "override must force a render"
|
assert page_raster(src, 0).shape[1] > 4000, "override must force a render"
|
||||||
src.close()
|
src.close()
|
||||||
|
|
||||||
for f in (vec, scan, sideways):
|
for f in (vec, scan):
|
||||||
f.unlink()
|
f.unlink()
|
||||||
tmp.rmdir()
|
tmp.rmdir()
|
||||||
print("ok")
|
print("ok")
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ from noteman_slicer.pdf import open_source, page_raster # noqa: E402
|
|||||||
from noteman_slicer.project import Cut, Project, default_path # noqa: E402
|
from noteman_slicer.project import Cut, Project, default_path # noqa: E402
|
||||||
from noteman_slicer.render import ( # noqa: E402
|
from noteman_slicer.render import ( # noqa: E402
|
||||||
ALPHA_LEVELS,
|
ALPHA_LEVELS,
|
||||||
_ink_bbox,
|
|
||||||
apply_levels,
|
apply_levels,
|
||||||
encode,
|
encode,
|
||||||
pad_right,
|
pad_right,
|
||||||
@@ -88,19 +87,6 @@ def main() -> int:
|
|||||||
assert rgba[:, :, 3].min() == 0, "paper must be fully transparent"
|
assert rgba[:, :, 3].min() == 0, "paper must be fully transparent"
|
||||||
assert len(np.unique(rgba[:, :, 3])) <= ALPHA_LEVELS
|
assert len(np.unique(rgba[:, :, 3])) <= ALPHA_LEVELS
|
||||||
|
|
||||||
# Trim keeps a line of lyrics and drops a fleck. Each letter is its own
|
|
||||||
# small blob, so judging blobs by area threw the whole line away and the
|
|
||||||
# bottom voice lost its words; a fleck's row carries almost no ink at all.
|
|
||||||
art = np.full((300, 800), 255, np.uint8)
|
|
||||||
art[100:150, 50:750] = 0 # a staff
|
|
||||||
for x in range(60, 700, 30): # lyrics: many small glyphs, one row
|
|
||||||
art[200:220, x : x + 14] = 0
|
|
||||||
art[5:9, 10:14] = 0 # a fleck in the far corner
|
|
||||||
x0, y0, x1, y1 = _ink_bbox(art)
|
|
||||||
assert (y0, y1) == (100, 220), f"lyrics kept, fleck dropped: {(y0, y1)}"
|
|
||||||
assert (x0, x1) == (50, 750), (x0, x1)
|
|
||||||
assert _ink_bbox(np.full((50, 50), 255, np.uint8)) is None, "blank slice has no box"
|
|
||||||
|
|
||||||
# Levels: a white point below the paper value wipes the paper out entirely.
|
# Levels: a white point below the paper value wipes the paper out entirely.
|
||||||
faint = np.full((10, 10), 200, np.uint8)
|
faint = np.full((10, 10), 200, np.uint8)
|
||||||
assert apply_levels(faint, 0, 180).max() == 255
|
assert apply_levels(faint, 0, 180).max() == 255
|
||||||
|
|||||||
Reference in New Issue
Block a user