Commit Graph
17 Commits
Author SHA1 Message Date
Esa Kataja c36001f25f Merge dev: content rectangle detection, spent projects, ADR 0007 2026-07-28 23:55:07 +03:00
Esa Kataja 15f64e4131 Record the spent-project reversal as ADR 0007
The project file existed to persist state, so a future reader finding
the exported flag would otherwise re-litigate it. The ADR states plainly
that this reverses an earlier decision, what the original reasoning was,
and what changed in use.

ADR 0002 deferred the SVG renderer partly because re-export made it free
to add later. That no longer holds, so its 'not stranded' bullet is
struck through and pointed at ADR 0007 rather than left standing to
mislead whoever revisits the SVG question.
2026-07-28 23:51:53 +03:00
Esa Kataja b6847a06ee Treat a project as spent once its song has been exported
Opening an exported song starts a fresh session from detection instead
of resuming: cuts, discards and metadata do not carry over, so a re-cut
never inherits decisions that have already shipped. --resume overrides
it on edit, export and project.

This reverses what was agreed in planning and written into docs/spec.md
and CONTEXT.md, which promised resume-across-sessions and re-export.
Both are corrected. The cost is deliberate and worth stating: changing
the width cap or adding the SVG renderer later now means re-cutting each
song by hand rather than regenerating every bundle from its project
file.

Export records the flag in bundle.write, so no caller can forget it.

Also removed --refit and the Auto-fit buttons, which were added without
being asked for and whose only purpose - migrating projects made before
the content rectangle was proposed - disappears once exported projects
start fresh. Reset now restores detection's proposal rather than the
whole page: clearing to full width would undo the thing the rectangle
exists for, so one button covers it.

open_project() replaces four copies of load-or-detect across the CLI
and the editor.
2026-07-28 23:49:03 +03:00
Esa Kataja 54b8e37657 Add project --refit to re-propose the content rectangle
Saved state always wins over a fresh proposal, which is what the
project file is for — but it also means a project made before detection
proposed a content rectangle keeps the old whole-page one forever, and
reopening or re-exporting changes nothing.

--refit re-runs the proposal over every page while leaving cuts,
discards, stepped cuts and metadata untouched. Verified on the real
Engel project: rectangles updated on all 6 pages, all 4 cuts per page
kept including both stepped ones, metadata intact, and exported slices
scale larger now that the margin shadow no longer pads the width.

--force remains the destructive option that re-detects everything.
2026-07-28 23:35:10 +03:00
Esa Kataja c00a00bb2a Propose the content rectangle from the staff lines
Both scan problems reported from testing came from the same place: the
content rectangle defaulted to the whole page, so the mechanism meant
to handle margin junk never engaged. Ketun joululaulu has a vertical
scan streak down the right margin and Engel a shadow, and because trim
is tight and per slice, either one sets that slice's width, which sets
the song's widest slice, which scales the whole song down.

Detection can propose it. Staff lines are long horizontal runs; scan
shadows, spine darkening and glass streaks are vertical, so a wide flat
opening keeps one and erases the other. Three corrections were needed
against the corpus:

- Search only rows inside detected systems. A horizontal artefact above
  or below the music is itself a long horizontal run reaching the paper
  edge, which put Engel's left bound at 0.
- Take a percentile of the staff-line extents, not the maximum. Where
  an artefact touches a staff line the two merge into one component: on
  Ketun p2 the merged line ends at 1575px against 1544px on the clean
  page.
- Take the left bound from the brackets too. A bracket sits left of
  every staff line, so a staff-line bound crops it off — visible
  immediately when comparing exported slices.

Anchors are now a dataclass carrying their left edge rather than a
(top, bottom) tuple.

Engel now drops 12-14% of page width and its music fills 1920px instead
of leaving the shadow's dead space; Ketun drops 12%.

Existing projects keep their saved rectangle; the editor's new Auto-fit
and Auto-fit all buttons re-propose it without disturbing cuts.
2026-07-28 23:28:45 +03:00
Esa Kataja 5f67a8c359 Merge dev: render pipeline, bundle export and the editor 2026-07-28 23:03:50 +03:00
Esa Kataja bc19eae111 Add the editor: page view, cut editing, discard, levels, metadata
QGraphicsView for the viewport, with cuts and the content rectangle
manipulated by hit-testing in the view rather than as movable items —
the geometry is normalised, so what the screen shows and what the
renderer uses are the same numbers at a different zoom.

Cuts are edited as polylines: double-click adds one, drag moves it,
Ctrl-click inserts a vertex, right-click deletes a vertex or the whole
cut. That is how a straight cut becomes the stepped cut Engel needs.

Slice boundaries are drawn, not just cut lines, so a trim anomaly is
visible before export rather than after. Discarded slices are shaded.

Pages preview at 1800px regardless of source resolution, cached per
page, because re-reading a 4959x7017 vector render on every slider move
is unusable.

Autosave is debounced at 800ms and also fires on close.

Closes #12
Closes #14
Closes #15
Closes #16
Closes #17
Closes #18
Closes #19
Closes #20
Closes #26
2026-07-28 23:03:49 +03:00
Esa Kataja 974c91a727 Add the render pipeline and bundle export
Project state plus PDF in, finished slice images out. Slices are cut as
polygons rather than row ranges, so a stepped cut yields a slice with a
transparent notch instead of one that covers its neighbour.

Masking paints white, which the ink-to-alpha step turns into full
transparency — the same outcome the spec asks for, one step earlier.

Scale normalises every slice to the median staff height before fitting
the song to 1920px, so a rescanned page sits at the same note size as
its neighbours. The cap only ever shrinks: a song narrower than 1920
stays narrower.

Alpha quantisation rounds to 16 values spanning 0-255 inclusive.
Flooring, as first written, capped full ink at 240 and left every note
6% transparent — caught by decoding an exported slice rather than by
reading the code.

Ketun joululaulu exports 24 slices at a uniform 1489px, under the cap
and correctly not upscaled from its 200 DPI source; Feliz Navidad 20;
Elaman nalka 18.

Closes #21
Closes #22
Closes #23
Closes #24
Closes #25
Closes #27
2026-07-28 23:00:53 +03:00
Esa Kataja d5af7b6159 Merge dev: design docs, PDF input, detection and project state 2026-07-28 22:52:48 +03:00
Esa Kataja 9ed38323ef Add project state with cuts, discards and atomic save
Everything the human decides, in normalised page coordinates so the
file is independent of DPI and of which renderer produced it. The
bundle will be generated from this, which is what makes re-export
possible without repeating human work.

Cuts are polylines from the start, two points being the ordinary
straight case, so the stepped cuts Engel needs are a data question
rather than a migration. Adding a cut splits a slice and copies its
discard flag to both halves; removing one merges them.

Boundary cuts belong here rather than in detection: detection emits
cuts only between systems, so a page would have exactly as many slices
as systems, with the header and footer inside the first and last.
Isolating and discarding them is a slicing decision.

Saves are write-then-rename, so a crash mid-save cannot destroy the
previous state. The PDF is hashed, not copied, so an edit underneath is
reported rather than silently re-cut.

Ketun joululaulu now yields 24 kept slices over 12 pages and Feliz
Navidad 20 over 4, with headers and footers discarded on every page.

Closes #10, #11, #13
2026-07-28 22:49:51 +03:00
Esa Kataja 61dac44037 Give each ink run a single owner, by precedence not proximity
Feliz Navidad exposed two assignment faults. Each bracket was expanding
independently, so a lyric line between two systems could be claimed by
both, producing overlapping extents. And nearest-bracket is the wrong
rule: engravers space lyrics generously, so a line sits 43px under its
own system's bracket but only 10px above the next one's.

Now one pass assigns every run exactly once: ink overlapping a bracket
belongs to it, and otherwise the system above wins over the system
below. Text under a staff belongs to that staff.

Known limit, documented in _assign: where a lyric is printed tight
enough that no blank row separates it from the next system's staves,
the two fuse into one ink run and no row profile can split them. The
lyric goes to the system below and the cut lands ~90px high. Dragging
it is the fix.

Feliz Navidad now reads 5 systems on every page, staff 70px, with no
overlapping extents; Ketun joululaulu, Engel and Elaman nalka are
unchanged.
2026-07-28 22:46:27 +03:00
Esa Kataja 6ff956a622 Add detection: skew, system anchors, ink extents, cuts, staff height
Systems anchor on the vertical bracket and expand over nearby ink, per
ADR 0006. Two corrections the corpus forced:

- Expansion measures distance from the bracket, never from the growing
  extent. A chaining expansion hops between the closely-stacked lines of
  a title block and walks the whole way up the page — on Engel p1 it
  swallowed the title into system 1 and the copyright footer into
  system 3.
- Reach is 1.5 staff heights, which takes in the lyrics below the last
  staff and the tempo mark and INTRO box above the first, while leaving
  the title block and footer out.

Verified against the corpus. Ketun joululaulu: 2 systems per page except
p7 (3) and p12 (1), staff 48px throughout, skew -2.7 to +1.2 per page,
and p2 system 1 spans 179-1071 where its bracket is 177-994 — the
difference being the bottom voice's lyric line. Engel: 2-3 systems per
page, staff 70-72px. Elaman nalka: 3 systems per page, 0 skew, staff
118px at 600 DPI.

The overlay dump is what made both bugs visible, and stays as the tool
for diagnosing a page that comes out wrong.

Closes #4, #5, #6, #7, #8, #9
2026-07-28 22:38:50 +03:00
Esa Kataja e2f3e8fbdc Add package skeleton, source classification and raster loading
The pdf module is the whole of M1 except the debug overlay, which needs
detection results to draw.

Source type is detected per PDF by looking for a page-covering image,
and is always reported for confirmation rather than applied silently
(ADR 0004). Rasters are extracted via Pixmap(doc, xref) rather than by
decoding extract_image() bytes, because MuPDF handles JBIG2 and CCITT
scans that no image library will.

Scanned pages are extracted at the embedded image's native resolution;
only vector pages are rendered, at 600 DPI. Verified against the corpus:
Elaman nalka (vector) renders 4959x7017, Ketun joululaulu (scan) loads
1653x2332, Engel (scan) 2552x3504 — and Engel's page 2 is 2480 wide
where page 1 is 2552, so scan width varies within one PDF.

tests/test_pdf.py builds its own PDFs so the check runs without corpus
files, which are copyrighted and gitignored.

Closes #1, #2, #3
2026-07-28 22:33:26 +03:00
Esa Kataja 01227b3382 Model cuts as polylines, not horizontal lines
Page 1 of Engel (Bosse/Partitura) has a boxed VERSE 1 label in the left
margin at the same rows as the preceding system's bass lyric line — ink
on both sides of the page throughout the band, so no horizontal line
separates them. The label belongs to system 2, the lyrics to system 1.

A cut is therefore a polyline spanning the page, two points being the
ordinary straight case. A slice bounded by a non-straight cut is not
rectangular: its image is the region's bounding box with everything
outside made transparent, which composites invisibly on the viewer's
sheet. Masking paints transparency, never white.
2026-07-28 22:22:03 +03:00
Esa Kataja 46ae7e813a Replace the handoff notes with a durable spec and ADRs
The handoff was written as a message to relay information; several
decisions lived only there. Split into permanent homes:

- docs/spec.md — scope, geometry model, pipeline order, detection,
  levels, editor, project file, markers, bundle format, noteman's
  changes, reference values
- ADR 0002 — raster only in release 1; SVG slices measured at 40x WebP
  naive, 2.6x with a bounding-box cull, deferred on risk not size
- ADR 0003 — lossless WebP with levels and 16-level alpha; every lossy
  option and alternative format measured larger for line art
- ADR 0004 — detection proposes, the human disposes; no unattended mode
- ADR 0005 — PyMuPDF for all PDF access, accepting AGPL
- ADR 0006 — systems are found by vertical brackets, not row-darkness
  gaps, which miscount every page of a 6-voice score

Also from testing against the hardest score in the repertoire: scanned
PDFs carry their scan as an embedded image and must be extracted at
native resolution rather than re-rendered at 600 DPI, and per-page
deskew is mandatory (skew varies -2.6 to +1.2 within one PDF).
2026-07-28 22:05:09 +03:00
Esa Kataja efc920fdc5 Add design docs: glossary, ADR 0001, and project scaffolding
Records the outcome of the design review that reworked the original
handoff notes:

- CONTEXT.md — ubiquitous language for the slicer (slice, cut, discard,
  content rectangle, trim, song scale, bundle, project, jump source)
- docs/adr/0001 — the slicer owns all image processing and the bundle is
  the only channel to noteman; noteman's sharp pipeline goes away
- pyproject renamed to noteman-slicer
- .gitignore keeps scores and bundles out of the repo (copyright, size)
2026-07-28 21:33:14 +03:00
Esa Kataja ecd303322f Initial commit 2026-07-28 21:12:41 +03:00