@real-music-packages/web-core 0.46.0 → 0.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,176 @@
1
+ // src/notationXml.ts
2
+ function stampNoteIds(xml) {
3
+ const doc = new DOMParser().parseFromString(xml, "application/xml");
4
+ if (doc.querySelector("parsererror")) return xml;
5
+ const parts = Array.from(doc.querySelectorAll("score-partwise > part"));
6
+ parts.forEach((part, partIdx) => {
7
+ for (const measure of Array.from(part.querySelectorAll("measure"))) {
8
+ const number = measure.getAttribute("number") ?? "0";
9
+ const notes = Array.from(measure.children).filter((el) => el.tagName === "note");
10
+ notes.forEach((note, noteIdx) => {
11
+ note.setAttribute("id", `n-${partIdx}-${number}-${noteIdx}`);
12
+ });
13
+ }
14
+ });
15
+ return new XMLSerializer().serializeToString(doc);
16
+ }
17
+ function measureCount(xml) {
18
+ const doc = new DOMParser().parseFromString(xml, "application/xml");
19
+ if (doc.querySelector("parsererror")) return 0;
20
+ const root = doc.documentElement;
21
+ if (!root || root.tagName !== "score-partwise") return 0;
22
+ const [firstPart] = childrenNamed(root, "part");
23
+ return firstPart ? childrenNamed(firstPart, "measure").length : 0;
24
+ }
25
+ function injectSystemBreaks(xml, breakBeforeBarIndexes) {
26
+ const doc = new DOMParser().parseFromString(xml, "application/xml");
27
+ if (doc.querySelector("parsererror")) return xml;
28
+ const root = doc.documentElement;
29
+ if (!root || root.tagName !== "score-partwise") return xml;
30
+ for (const part of childrenNamed(root, "part")) {
31
+ const measures = childrenNamed(part, "measure");
32
+ for (const pos of breakBeforeBarIndexes) {
33
+ if (!Number.isInteger(pos) || pos <= 0 || pos >= measures.length) continue;
34
+ const measure = measures[pos];
35
+ const existingPrint = firstChildNamed(measure, "print");
36
+ if (existingPrint) {
37
+ existingPrint.setAttribute("new-system", "yes");
38
+ } else {
39
+ const printEl = doc.createElement("print");
40
+ printEl.setAttribute("new-system", "yes");
41
+ measure.insertBefore(printEl, measure.firstChild);
42
+ }
43
+ }
44
+ }
45
+ return new XMLSerializer().serializeToString(doc);
46
+ }
47
+ function balancedSystemBreaks(totalMeasures, systems) {
48
+ if (systems <= 1 || totalMeasures <= 0) return [];
49
+ const per = Math.ceil(totalMeasures / systems);
50
+ if (per <= 0) return [];
51
+ const breaks = [];
52
+ for (let b = per; b < totalMeasures; b += per) breaks.push(b);
53
+ return breaks;
54
+ }
55
+ function barsPerLineBreaks(totalBars, every) {
56
+ if (!Number.isFinite(totalBars) || !Number.isFinite(every)) return [];
57
+ if (every <= 0 || totalBars <= 0) return [];
58
+ let lines = Math.ceil(totalBars / every);
59
+ let per = Math.ceil(totalBars / lines);
60
+ while (lines > 1 && totalBars - (lines - 1) * per < 2) {
61
+ lines--;
62
+ per = Math.ceil(totalBars / lines);
63
+ }
64
+ return balancedSystemBreaks(totalBars, lines);
65
+ }
66
+ function sectionAwareBreaks(totalBars, sectionStarts, every) {
67
+ if (!Number.isFinite(totalBars) || totalBars <= 0) return [];
68
+ const usable = sectionStarts.filter((b) => Number.isInteger(b) && b > 0 && b < totalBars);
69
+ const boundaries = Array.from(/* @__PURE__ */ new Set([0, ...usable])).sort((a, b) => a - b);
70
+ const result = /* @__PURE__ */ new Set();
71
+ if (every > 0) {
72
+ for (let i = 0; i < boundaries.length; i++) {
73
+ const start = boundaries[i];
74
+ const end = i + 1 < boundaries.length ? boundaries[i + 1] : totalBars;
75
+ for (const pos of barsPerLineBreaks(end - start, every)) result.add(start + pos);
76
+ }
77
+ }
78
+ for (const s of usable) result.add(s);
79
+ return Array.from(result).sort((a, b) => a - b);
80
+ }
81
+ var STEP_SEMITONE = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 };
82
+ function firstChildNamed(el, tag) {
83
+ for (const c of Array.from(el.children)) if (c.tagName === tag) return c;
84
+ return null;
85
+ }
86
+ function childrenNamed(el, tag) {
87
+ return Array.from(el.children).filter((c) => c.tagName === tag);
88
+ }
89
+ function textOf(el) {
90
+ return el && el.textContent != null ? el.textContent.trim() : null;
91
+ }
92
+ function pitchMidiFromElement(pitchEl) {
93
+ const step = textOf(firstChildNamed(pitchEl, "step")) ?? "C";
94
+ const octave = Number(textOf(firstChildNamed(pitchEl, "octave")) ?? "4");
95
+ const alter = Number(textOf(firstChildNamed(pitchEl, "alter")) ?? "0");
96
+ return 12 * (octave + 1) + (STEP_SEMITONE[step] ?? 0) + Math.trunc(alter);
97
+ }
98
+ function partStaffCount(part) {
99
+ let maxDeclared = 0;
100
+ for (const attrs of childrenNamed(part, "measure").flatMap((m) => childrenNamed(m, "attributes"))) {
101
+ const staves = Number(textOf(firstChildNamed(attrs, "staves")) ?? "0");
102
+ if (staves > maxDeclared) maxDeclared = staves;
103
+ }
104
+ if (maxDeclared > 0) return maxDeclared;
105
+ let maxStaff = 0;
106
+ for (const measure of childrenNamed(part, "measure")) {
107
+ for (const note of childrenNamed(measure, "note")) {
108
+ const staff = Number(textOf(firstChildNamed(note, "staff")) ?? "0");
109
+ if (staff > maxStaff) maxStaff = staff;
110
+ }
111
+ }
112
+ return Math.max(1, maxStaff);
113
+ }
114
+ function noteModelFromXml(stampedXml) {
115
+ const model = /* @__PURE__ */ new Map();
116
+ let doc;
117
+ try {
118
+ doc = new DOMParser().parseFromString(stampedXml, "application/xml");
119
+ } catch {
120
+ return model;
121
+ }
122
+ if (doc.querySelector("parsererror")) return model;
123
+ const root = doc.documentElement;
124
+ if (!root || root.tagName !== "score-partwise") return model;
125
+ const parts = childrenNamed(root, "part");
126
+ let staffOffset = 0;
127
+ for (const part of parts) {
128
+ const staffCount = partStaffCount(part);
129
+ let divisions = 1;
130
+ const measureEls = childrenNamed(part, "measure");
131
+ measureEls.forEach((measureEl, measureIndex) => {
132
+ for (const child of Array.from(measureEl.children)) {
133
+ if (child.tagName === "attributes") {
134
+ const divText = textOf(firstChildNamed(child, "divisions"));
135
+ if (divText) divisions = Number(divText) || divisions;
136
+ continue;
137
+ }
138
+ if (child.tagName !== "note") continue;
139
+ const note = child;
140
+ const id = note.getAttribute("id");
141
+ if (!id) continue;
142
+ const isGrace = !!firstChildNamed(note, "grace");
143
+ const isRest = !!firstChildNamed(note, "rest");
144
+ const pitchEl = firstChildNamed(note, "pitch");
145
+ const staffNumber = Number(textOf(firstChildNamed(note, "staff")) ?? "1") || 1;
146
+ const durationText = textOf(firstChildNamed(note, "duration"));
147
+ const durationReal = isGrace || !durationText ? 0 : Number(durationText) / divisions / 4;
148
+ const tieStopDirect = childrenNamed(note, "tie").some((t) => t.getAttribute("type") === "stop");
149
+ const notationsEl = firstChildNamed(note, "notations");
150
+ const tieStopNotated = notationsEl ? childrenNamed(notationsEl, "tied").some((t) => t.getAttribute("type") === "stop") : false;
151
+ model.set(id, {
152
+ midi: pitchEl ? pitchMidiFromElement(pitchEl) : null,
153
+ isRest,
154
+ tieContinuation: tieStopDirect || tieStopNotated,
155
+ staffIndex: staffOffset + Math.max(0, staffNumber - 1),
156
+ durationReal,
157
+ measureIndex,
158
+ hidden: note.getAttribute("print-object") === "no"
159
+ });
160
+ }
161
+ });
162
+ staffOffset += staffCount;
163
+ }
164
+ return model;
165
+ }
166
+
167
+ export {
168
+ stampNoteIds,
169
+ measureCount,
170
+ injectSystemBreaks,
171
+ balancedSystemBreaks,
172
+ barsPerLineBreaks,
173
+ sectionAwareBreaks,
174
+ noteModelFromXml
175
+ };
176
+ //# sourceMappingURL=chunk-JCDNEJBT.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/notationXml.ts"],"sourcesContent":["// Pure MusicXML→MusicXML/model helpers for `createVerovioNotationPlayer`\n// (notationPlayerVerovio.ts, 0.40.0) — the id-stamping + note-model half of\n// its \"join model ids to Verovio's rendered SVG\" approach (that module's\n// header doc, point 1). No DOM rendering here; everything in this file is\n// pure XML-in → XML/data-out, unit-testable with no live SVG at all.\n//\n// OWNERSHIP NOTE — `stampNoteIds`'s deterministic id scheme\n// (`n-{partIdx}-{measureNumber}-{noteIdxInMeasure}`) is DELIBERATELY\n// duplicated, byte-for-byte, from stave-web-sightread's\n// `src/lib/bach/xmlTransforms.ts:stampNoteIds` (part of that repo's Verovio\n// transform pipeline, docs/superpowers/specs/2026-08-11-verovio-player-design.md\n// §1). stave's copy is the one actually wired into `parseReduction` (its\n// `noteIds` per span must match what got stamped onto the MusicXML BEFORE\n// Verovio ever saw it), so stave remains that scheme's owner for the\n// pipeline's purposes. This copy exists so `createVerovioNotationPlayer`\n// works correctly for ANY caller — including ones that hand in un-stamped\n// MusicXML — without a runtime dependency from web-core back into an app\n// repo (the wrong direction for a shared package). Both copies are pure\n// functions of a note's POSITION (never of any id already present), so\n// calling either one on already-stamped input reproduces the exact same\n// ids — the two copies can never drift apart in OBSERVABLE behavior even\n// though they are physically two files. If the scheme ever needs to change,\n// change it in BOTH places (this file's own tests pin the scheme\n// independently of stave's, so a one-sided edit fails a test here).\nexport function stampNoteIds(xml: string): string {\n const doc = new DOMParser().parseFromString(xml, 'application/xml');\n if (doc.querySelector('parsererror')) return xml;\n\n const parts = Array.from(doc.querySelectorAll('score-partwise > part'));\n parts.forEach((part, partIdx) => {\n for (const measure of Array.from(part.querySelectorAll('measure'))) {\n const number = measure.getAttribute('number') ?? '0';\n const notes = Array.from(measure.children).filter((el) => el.tagName === 'note');\n notes.forEach((note, noteIdx) => {\n note.setAttribute('id', `n-${partIdx}-${number}-${noteIdx}`);\n });\n }\n });\n\n return new XMLSerializer().serializeToString(doc);\n}\n\n// ─── injectSystemBreaks / balancedSystemBreaks — widow-avoidance helpers ──\n//\n// Used by notationPlayerVerovio.ts's widow pass (`engraveOnce`): when\n// `breaks: 'auto'` leaves a lone trailing measure on its own system (a\n// \"widow\" — Verovio's own `breaksNoWidow` only guards the last PAGE, not the\n// last SYSTEM, per that module's doc), the player forces exact line breaks\n// via `injectSystemBreaks` + `breaks: 'line'`, choosing the break points with\n// `balancedSystemBreaks` so the same number of systems comes out evenly\n// filled instead of front-loaded-then-orphaned.\n//\n// CONTRACT DUPLICATION NOTICE — stave-web-sightread's\n// `src/lib/bach/xmlTransforms.ts` has its OWN `injectSystemBreaks`,\n// `barsPerLineBreaks` and `sectionAwareBreaks`, written on the reasoning\n// that \"a shared package must not runtime-depend on an app repo for\n// something this small\". That reasoning is backwards for break POLICY: the\n// engraver is what owns where systems begin, so those copies are being\n// RETIRED in favour of the ones here, reached through\n// `VerovioLayoutOptions.barsPerLine` / `.sectionStarts` rather than by the\n// app hand-injecting breaks of its own.\n//\n// Until that app-side change lands, both copies exist and must stay in sync.\n// (The app's `injectSystemBreaks` uses `querySelectorAll('measure')` where\n// this one walks direct children only — this one is the stricter of the two\n// on scores with nested measure-like elements.)\n\n/**\n * Insert `<print new-system=\"yes\"/>` as the FIRST child of the `<measure>`\n * at each 0-based position in `breakBeforeBarIndexes`, in EVERY `<part>`\n * (a score's parts share one measure timeline, so a break must be encoded\n * once per part for Verovio to line the systems up across staves). A\n * position that is ≤ 0 or ≥ that part's own measure count is silently\n * ignored FOR THAT PART (0 is a no-op — a part already starts a new system\n * at its own first measure). Idempotent: a measure that already carries a\n * `<print>` element (from a prior call, or from the source data) gets\n * `new-system=\"yes\"` SET on that existing element rather than gaining a\n * second one — calling this twice with the same positions serializes\n * identically both times. Malformed input (fails to parse) is returned\n * unchanged, same defensive style as `stampNoteIds` above. Only ever ADDS\n * `<print>` elements — never touches a `<note>` — so `stampNoteIds`'s\n * position-based id scheme is unaffected by a prior or subsequent call to\n * this function (see this file's own tests for the pinned invariant).\n */\n/**\n * How many `<measure>` elements the FIRST `<part>` has — the `totalBars`\n * that `barsPerLineBreaks`/`sectionAwareBreaks` plan against.\n *\n * The first part specifically, because that is the coordinate system\n * `injectSystemBreaks` positions live in: a score's parts share one measure\n * timeline, so the first part's ordinals index every part. And ORDINALS, not\n * `<measure number>` attributes — scores skip and repeat bar numbers, so a\n * count is the only safe answer. Malformed input, or a document with no\n * part, returns 0 rather than throwing.\n */\nexport function measureCount(xml: string): number {\n const doc = new DOMParser().parseFromString(xml, 'application/xml');\n if (doc.querySelector('parsererror')) return 0;\n const root = doc.documentElement;\n if (!root || root.tagName !== 'score-partwise') return 0;\n const [firstPart] = childrenNamed(root, 'part');\n return firstPart ? childrenNamed(firstPart, 'measure').length : 0;\n}\n\nexport function injectSystemBreaks(xml: string, breakBeforeBarIndexes: readonly number[]): string {\n const doc = new DOMParser().parseFromString(xml, 'application/xml');\n if (doc.querySelector('parsererror')) return xml;\n\n const root = doc.documentElement;\n if (!root || root.tagName !== 'score-partwise') return xml;\n\n for (const part of childrenNamed(root, 'part')) {\n const measures = childrenNamed(part, 'measure');\n for (const pos of breakBeforeBarIndexes) {\n if (!Number.isInteger(pos) || pos <= 0 || pos >= measures.length) continue;\n const measure = measures[pos];\n const existingPrint = firstChildNamed(measure, 'print');\n if (existingPrint) {\n existingPrint.setAttribute('new-system', 'yes');\n } else {\n const printEl = doc.createElement('print');\n printEl.setAttribute('new-system', 'yes');\n measure.insertBefore(printEl, measure.firstChild);\n }\n }\n }\n\n return new XMLSerializer().serializeToString(doc);\n}\n\n/**\n * Evenly-spread system-break positions for `totalMeasures` measures across\n * `systems` systems: `[per, 2·per, …]` (each strictly `< totalMeasures`),\n * with `per = Math.ceil(totalMeasures / systems)` — `Math.ceil` naturally\n * front-loads any remainder into the EARLIER systems (e.g. 7 measures / 2\n * systems -> per=4 -> systems of 4+3, never 3+4), which is what keeps a\n * later system from being the short/orphaned one. `systems <= 1` (nothing to\n * break between) returns `[]`.\n */\nexport function balancedSystemBreaks(totalMeasures: number, systems: number): number[] {\n if (systems <= 1 || totalMeasures <= 0) return [];\n const per = Math.ceil(totalMeasures / systems);\n if (per <= 0) return [];\n const breaks: number[] = [];\n for (let b = per; b < totalMeasures; b += per) breaks.push(b);\n return breaks;\n}\n\n/**\n * Balanced system-break positions for a target BARS-PER-LINE layout — the\n * caller-facing sibling of `balancedSystemBreaks`, which takes a system\n * COUNT. Rather than stepping `every` bars at a time from bar 0 (which\n * strands a short remainder — a 5-bar excerpt at `every: 4` renders 4+1, a\n * widow of our own making), this decides how many LINES the excerpt needs\n * (`ceil(totalBars / every)`) and hands that count to\n * `balancedSystemBreaks`. `(5, 4)` is `[3]` (3+2) instead of `[4]` (4+1);\n * `(9, 4)` is `[3, 6]` (3+3+3) instead of `[4, 8]` (4+4+1).\n *\n * WIDOW BACK-OFF: a small `every` against a small `totalBars` can make the\n * straight `ceil(totalBars / every)` line count land on a trailing line of\n * exactly 1 bar — the same widow this function exists to remove, reappearing\n * at a different total/every combination (`lines = 3` on 7 bars: `per = 3`\n * fills two 3-bar lines and leaves 1 bar for the third). The line count is\n * therefore backed down (never below 1) until the trailing line holds at\n * least 2 bars — 7 bars lands on 2 lines (4+3) instead of 3 (3+3+1), which\n * is the same \"fewer, fuller lines\" direction `every` already asks for.\n * This never changes the result for any total/every whose straight `ceil`\n * count already avoids a 1-bar trailing line.\n *\n * `every <= 0` (the \"auto — no forced breaks\" sentinel callers resolve to)\n * or a non-positive/non-finite `totalBars` returns `[]`. Never throws.\n *\n * Ported from stave-web-sightread (`src/lib/bach/xmlTransforms.ts`), whose\n * copy this replaces — see the CONTRACT DUPLICATION NOTICE above. Reached by\n * consumers through `VerovioLayoutOptions.barsPerLine` rather than called\n * directly, so the player owns both the plan and its interaction with the\n * widow pass and the readability floor.\n */\nexport function barsPerLineBreaks(totalBars: number, every: number): number[] {\n if (!Number.isFinite(totalBars) || !Number.isFinite(every)) return [];\n if (every <= 0 || totalBars <= 0) return [];\n let lines = Math.ceil(totalBars / every);\n let per = Math.ceil(totalBars / lines);\n while (lines > 1 && totalBars - (lines - 1) * per < 2) {\n lines--;\n per = Math.ceil(totalBars / lines);\n }\n return balancedSystemBreaks(totalBars, lines);\n}\n\n/**\n * Like `barsPerLineBreaks`, but each SECTION's span is balanced on its own\n * instead of one balance running across the whole excerpt — so a line never\n * ends part-way into a neighbouring section's opening bars, and a section's\n * own bars are never split into a widowed last line either.\n *\n * `sectionStarts` are 0-BASED POSITIONS into the excerpt (the same\n * coordinate `injectSystemBreaks` consumes), NOT MusicXML `<measure\n * number>` attributes — scores skip bar numbers, so a caller holding\n * bar-numbered section labels must convert first. `0` (the excerpt's own\n * start) is never a break and is ignored if present.\n *\n * Every section start > 0 is a HARD break in its own right — a new section\n * always begins its own line, not subject to balancing — unioned with\n * `barsPerLineBreaks`' balanced positions computed WITHIN each section's\n * span (its start up to the next section's start, or `totalBars` for the\n * last) and offset back into excerpt-absolute positions.\n *\n * `every <= 0` suppresses the balanced component, matching\n * `barsPerLineBreaks`, but section-start breaks still apply — that is the\n * \"sections only, otherwise let the engraver decide\" mode. Out-of-range and\n * non-integer starts are ignored. The result is sorted, de-duplicated, and\n * every entry is a valid `injectSystemBreaks` position (integer, `> 0`,\n * `< totalBars`). Never throws.\n */\nexport function sectionAwareBreaks(\n totalBars: number,\n sectionStarts: readonly number[],\n every: number,\n): number[] {\n if (!Number.isFinite(totalBars) || totalBars <= 0) return [];\n const usable = sectionStarts.filter((b) => Number.isInteger(b) && b > 0 && b < totalBars);\n const boundaries = Array.from(new Set([0, ...usable])).sort((a, b) => a - b);\n\n const result = new Set<number>();\n\n if (every > 0) {\n for (let i = 0; i < boundaries.length; i++) {\n const start = boundaries[i];\n const end = i + 1 < boundaries.length ? boundaries[i + 1] : totalBars;\n for (const pos of barsPerLineBreaks(end - start, every)) result.add(start + pos);\n }\n }\n\n for (const s of usable) result.add(s);\n\n return Array.from(result).sort((a, b) => a - b);\n}\n\n// ─── noteModelFromXml — the note MODEL half of the join ───────────────────\n\n/** One `<note>`'s MODEL identity — the ground truth `EngravedNote`\n * (notationPlayerSvg.ts) needs that Verovio's rendered SVG cannot supply on\n * its own (a rendered `g.note`/`g.rest` carries geometry, not pitch/tie/\n * duration semantics). Keyed by the note's stamped `id` in\n * `notePositions()`'s return of `verovioEngravedNotes`\n * (notationPlayerVerovio.ts). */\nexport interface NoteModel {\n /** `12*(octave+1) + stepSemitone + alter` — the standard MusicXML→MIDI\n * conversion (the same formula stave-web-sightread's own\n * `practice/probeInputs.ts:pitchMidi` uses — a universal formula, not app\n * logic, so this is not an owned-layer violation to restate here). `null`\n * for a rest or an `<unpitched>` note (no `<pitch>` child) — mirrors\n * `EngravedNote.midi`'s own \"null covers both\" contract. */\n midi: number | null;\n /** Has a `<rest/>` child. */\n isRest: boolean;\n /** True for the STOP half of a tie — a direct `<tie type=\"stop\">` child OR\n * `<notations><tied type=\"stop\">` (exporters vary on which they emit;\n * either counts) — matches `EngravedNote.tieContinuation`'s \"continuation\n * note of a tie, not the struck start\" contract. */\n tieContinuation: boolean;\n /** 0-based, matching `EngravedNote.staffIndex`'s \"0 = top staff of the\n * system\" contract: every `<part>` is walked in document order, and\n * every DISTINCT staff within it (by `<attributes><staves>` when\n * present, else the highest `<staff>` number any of its notes uses, else\n * 1) is assigned the next index — so a single-part 2-staff piano score\n * numbers 0/1 by `<staff>`, and a 2-part 1-staff-per-part reduction (this\n * app's own chord+bass shape) numbers 0/1 by PART, with neither case\n * needing different code. */\n staffIndex: number;\n /** Whole-note fraction (0.25 = quarter) — `duration / divisions / 4`,\n * divisions tracked per-part from the LAST `<attributes><divisions>`\n * seen at or before this note (MusicXML: divisions persist until\n * overridden, default 1). 0 for a grace note (no `<duration>` child —\n * the true, spec-correct signal; never guessed from `<type>`). */\n durationReal: number;\n /** 0-based position of this note's `<measure>` among its OWN `<part>`'s\n * measure children, in document order. Informational only — the live\n * join (`verovioOnsetColumns`/`verovioEngravedNotes`, both in\n * notationPlayerVerovio.ts) resolves the note's RENDERED measure index\n * from the live SVG DOM independently (Verovio's own render order, which\n * is what geometry/hit-testing must agree with), never from this field. */\n measureIndex: number;\n /** `print-object=\"no\"` on the source `<note>` (stave's `hideDoubledNotes`\n * sets this on editorially-doubled notes/rests before handing MusicXML to\n * this player). Verovio's importer HONORS this for `<note>` elements\n * carrying a `<pitch>` (renders `visibility=\"hidden\"` on its own,\n * empirically confirmed against a real 6.2.0 render) but does NOT honor\n * it for `<rest>` notes (the `<g class=\"rest\">` renders fully visible\n * regardless — same empirical check). `createVerovioNotationPlayer`\n * reads this field to force `visibility=\"hidden\"` after every render for\n * ANY id where it's true — a no-op re-application on notes Verovio\n * already hid, and the actual fix on the rests it doesn't (see that\n * module's `applyPrintObjectHiding`). */\n hidden: boolean;\n}\n\nconst STEP_SEMITONE: Record<string, number> = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 };\n\nfunction firstChildNamed(el: Element, tag: string): Element | null {\n for (const c of Array.from(el.children)) if (c.tagName === tag) return c;\n return null;\n}\nfunction childrenNamed(el: Element, tag: string): Element[] {\n return Array.from(el.children).filter((c) => c.tagName === tag);\n}\nfunction textOf(el: Element | null): string | null {\n return el && el.textContent != null ? el.textContent.trim() : null;\n}\n\nfunction pitchMidiFromElement(pitchEl: Element): number {\n const step = textOf(firstChildNamed(pitchEl, 'step')) ?? 'C';\n const octave = Number(textOf(firstChildNamed(pitchEl, 'octave')) ?? '4');\n const alter = Number(textOf(firstChildNamed(pitchEl, 'alter')) ?? '0');\n return 12 * (octave + 1) + (STEP_SEMITONE[step] ?? 0) + Math.trunc(alter);\n}\n\n/** How many staves `part` uses: the max `<attributes><staves>N</staves>`\n * seen anywhere in it (the authoritative declaration when present), else\n * the highest `<note><staff>N</staff></note>` number any of its notes\n * uses, else 1 (a plain single-staff part declares neither). */\nfunction partStaffCount(part: Element): number {\n let maxDeclared = 0;\n for (const attrs of childrenNamed(part, 'measure').flatMap((m) => childrenNamed(m, 'attributes'))) {\n const staves = Number(textOf(firstChildNamed(attrs, 'staves')) ?? '0');\n if (staves > maxDeclared) maxDeclared = staves;\n }\n if (maxDeclared > 0) return maxDeclared;\n\n let maxStaff = 0;\n for (const measure of childrenNamed(part, 'measure')) {\n for (const note of childrenNamed(measure, 'note')) {\n const staff = Number(textOf(firstChildNamed(note, 'staff')) ?? '0');\n if (staff > maxStaff) maxStaff = staff;\n }\n }\n return Math.max(1, maxStaff);\n}\n\n/**\n * Pure(ish) — MusicXML-in, `id → NoteModel`-out. Walks every `<part>` in\n * document order, then every `<measure>` in document order, then every\n * DIRECT child in document order — `<attributes>` updates the part's own\n * `divisions` cursor; every other non-`<note>` child (`<backup>`,\n * `<forward>`, `<direction>`, …) is a structural/timeline element this\n * function has NO use for (it reads each note's OWN `<duration>` directly,\n * never a cursor POSITION — see `durationReal`'s doc — so unlike\n * `walkMeasureNotes`-style position walks, `<backup>`/`<forward>` need no\n * special handling here beyond being correctly skipped, which plain\n * tag-name filtering already does) — and every `<note>` becomes one model\n * entry, keyed by its `id` attribute (a note with NO `id` — i.e. input that\n * was never run through `stampNoteIds` — is silently skipped: it has no key\n * to join the render against, so there is nothing useful to record).\n *\n * Never throws: malformed input (fails to parse, or no `<score-partwise>`\n * root) returns an empty Map.\n */\nexport function noteModelFromXml(stampedXml: string): Map<string, NoteModel> {\n const model = new Map<string, NoteModel>();\n let doc: Document;\n try {\n doc = new DOMParser().parseFromString(stampedXml, 'application/xml');\n } catch {\n return model;\n }\n if (doc.querySelector('parsererror')) return model;\n const root = doc.documentElement;\n if (!root || root.tagName !== 'score-partwise') return model;\n\n const parts = childrenNamed(root, 'part');\n let staffOffset = 0;\n\n for (const part of parts) {\n const staffCount = partStaffCount(part);\n let divisions = 1;\n const measureEls = childrenNamed(part, 'measure');\n\n measureEls.forEach((measureEl, measureIndex) => {\n for (const child of Array.from(measureEl.children)) {\n if (child.tagName === 'attributes') {\n const divText = textOf(firstChildNamed(child, 'divisions'));\n if (divText) divisions = Number(divText) || divisions;\n continue;\n }\n if (child.tagName !== 'note') continue;\n const note = child;\n const id = note.getAttribute('id');\n if (!id) continue;\n\n const isGrace = !!firstChildNamed(note, 'grace');\n const isRest = !!firstChildNamed(note, 'rest');\n const pitchEl = firstChildNamed(note, 'pitch');\n const staffNumber = Number(textOf(firstChildNamed(note, 'staff')) ?? '1') || 1;\n const durationText = textOf(firstChildNamed(note, 'duration'));\n const durationReal =\n isGrace || !durationText ? 0 : Number(durationText) / divisions / 4;\n\n const tieStopDirect = childrenNamed(note, 'tie').some((t) => t.getAttribute('type') === 'stop');\n const notationsEl = firstChildNamed(note, 'notations');\n const tieStopNotated = notationsEl\n ? childrenNamed(notationsEl, 'tied').some((t) => t.getAttribute('type') === 'stop')\n : false;\n\n model.set(id, {\n midi: pitchEl ? pitchMidiFromElement(pitchEl) : null,\n isRest,\n tieContinuation: tieStopDirect || tieStopNotated,\n staffIndex: staffOffset + Math.max(0, staffNumber - 1),\n durationReal,\n measureIndex,\n hidden: note.getAttribute('print-object') === 'no',\n });\n }\n });\n\n staffOffset += staffCount;\n }\n\n return model;\n}\n"],"mappings":"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","names":[]}
@@ -1,64 +1,8 @@
1
1
  import { N as NotationLayout } from './notationGeometry-DqVBgL7F.js';
2
2
  import { EngravedNote } from './notationPlayerSvg.js';
3
+ import { NoteModel } from './notationXml.js';
3
4
  import './promo.js';
4
5
 
5
- /** One `<note>`'s MODEL identity — the ground truth `EngravedNote`
6
- * (notationPlayerSvg.ts) needs that Verovio's rendered SVG cannot supply on
7
- * its own (a rendered `g.note`/`g.rest` carries geometry, not pitch/tie/
8
- * duration semantics). Keyed by the note's stamped `id` in
9
- * `notePositions()`'s return of `verovioEngravedNotes`
10
- * (notationPlayerVerovio.ts). */
11
- interface NoteModel {
12
- /** `12*(octave+1) + stepSemitone + alter` — the standard MusicXML→MIDI
13
- * conversion (the same formula stave-web-sightread's own
14
- * `practice/probeInputs.ts:pitchMidi` uses — a universal formula, not app
15
- * logic, so this is not an owned-layer violation to restate here). `null`
16
- * for a rest or an `<unpitched>` note (no `<pitch>` child) — mirrors
17
- * `EngravedNote.midi`'s own "null covers both" contract. */
18
- midi: number | null;
19
- /** Has a `<rest/>` child. */
20
- isRest: boolean;
21
- /** True for the STOP half of a tie — a direct `<tie type="stop">` child OR
22
- * `<notations><tied type="stop">` (exporters vary on which they emit;
23
- * either counts) — matches `EngravedNote.tieContinuation`'s "continuation
24
- * note of a tie, not the struck start" contract. */
25
- tieContinuation: boolean;
26
- /** 0-based, matching `EngravedNote.staffIndex`'s "0 = top staff of the
27
- * system" contract: every `<part>` is walked in document order, and
28
- * every DISTINCT staff within it (by `<attributes><staves>` when
29
- * present, else the highest `<staff>` number any of its notes uses, else
30
- * 1) is assigned the next index — so a single-part 2-staff piano score
31
- * numbers 0/1 by `<staff>`, and a 2-part 1-staff-per-part reduction (this
32
- * app's own chord+bass shape) numbers 0/1 by PART, with neither case
33
- * needing different code. */
34
- staffIndex: number;
35
- /** Whole-note fraction (0.25 = quarter) — `duration / divisions / 4`,
36
- * divisions tracked per-part from the LAST `<attributes><divisions>`
37
- * seen at or before this note (MusicXML: divisions persist until
38
- * overridden, default 1). 0 for a grace note (no `<duration>` child —
39
- * the true, spec-correct signal; never guessed from `<type>`). */
40
- durationReal: number;
41
- /** 0-based position of this note's `<measure>` among its OWN `<part>`'s
42
- * measure children, in document order. Informational only — the live
43
- * join (`verovioOnsetColumns`/`verovioEngravedNotes`, both in
44
- * notationPlayerVerovio.ts) resolves the note's RENDERED measure index
45
- * from the live SVG DOM independently (Verovio's own render order, which
46
- * is what geometry/hit-testing must agree with), never from this field. */
47
- measureIndex: number;
48
- /** `print-object="no"` on the source `<note>` (stave's `hideDoubledNotes`
49
- * sets this on editorially-doubled notes/rests before handing MusicXML to
50
- * this player). Verovio's importer HONORS this for `<note>` elements
51
- * carrying a `<pitch>` (renders `visibility="hidden"` on its own,
52
- * empirically confirmed against a real 6.2.0 render) but does NOT honor
53
- * it for `<rest>` notes (the `<g class="rest">` renders fully visible
54
- * regardless — same empirical check). `createVerovioNotationPlayer`
55
- * reads this field to force `visibility="hidden"` after every render for
56
- * ANY id where it's true — a no-op re-application on notes Verovio
57
- * already hid, and the actual fix on the rests it doesn't (see that
58
- * module's `applyPrintObjectHiding`). */
59
- hidden: boolean;
60
- }
61
-
62
6
  /** Restated independently, not imported as a VALUE from notationPlayerSvg.ts
63
7
  * — same reasoning as `MAX_ENGRAVE_WIDTH_VRV` vs `MAX_ENGRAVE_WIDTH_SVG`
64
8
  * (below): a runtime (non-`type`) import from notationPlayerSvg.ts would
@@ -397,6 +341,31 @@ interface VerovioLayoutOptions {
397
341
  * glyphs get, can opt back into the pre-existing behavior.
398
342
  */
399
343
  minFitFactor?: number;
344
+ /**
345
+ * BREAK PLAN — target bars per line. When set (`> 0`), the player plans
346
+ * system breaks itself (`sectionAwareBreaks`, balanced with a widow
347
+ * back-off) and encodes them into the document before the first engrave,
348
+ * forcing `breaks: 'line'`. Leave unset (or `0`) to let Verovio's own
349
+ * `breaks: 'auto'` decide, which is the default and is content-aware.
350
+ *
351
+ * Combine with `sectionStarts` to keep the balancing inside each section.
352
+ * A PLAYER option, not a Verovio one — stripped in `verovioRenderOptions`
353
+ * before `setOptions` sees it.
354
+ */
355
+ barsPerLine?: number;
356
+ /**
357
+ * BREAK PLAN — 0-based measure POSITIONS at which a musical section
358
+ * begins. Each one > 0 becomes a hard system break, so a section always
359
+ * starts its own line; `barsPerLine`'s balancing then runs within each
360
+ * section's span rather than across the whole excerpt.
361
+ *
362
+ * POSITIONS, not MusicXML `<measure number>` attributes — scores skip bar
363
+ * numbers, so a caller holding bar-numbered section labels must convert
364
+ * first (`measureCount`'s doc explains the coordinate). Setting this alone
365
+ * (no `barsPerLine`) is the "break at sections, otherwise let the engraver
366
+ * decide" mode. A PLAYER option — stripped in `verovioRenderOptions`.
367
+ */
368
+ sectionStarts?: readonly number[];
400
369
  }
401
370
  /**
402
371
  * Layout defaults applied to EVERY engrave (initial + every reflow) unless a
@@ -511,6 +480,21 @@ declare const DEFAULT_MIN_FIT_FACTOR = 0.75;
511
480
  * condition is unit-testable independent of a real engrave.
512
481
  */
513
482
  declare function shouldFallbackToAutoBreaks(factor: number, breaks: VerovioLayoutOptions['breaks'] | undefined, minFitFactor: number): boolean;
483
+ /** True when fitting the current engraving would cross the caller's
484
+ * readability floor. Shared by the encoded-break fallback and auto's
485
+ * narrower re-plan path so the two policies cannot drift. */
486
+ declare function isBelowReadabilityFloor(factor: number, minFitFactor: number): boolean;
487
+ /** One narrower candidate for the auto-break readability floor, or null when
488
+ * the measured fit already holds the floor (or one bar is the terminal
489
+ * layout). Uses ceiling-halves so 4 -> 2 -> 1 and 3 -> 2 -> 1. */
490
+ declare function nextReadabilityFloorBarsPerLine(currentBarsPerLine: number, fittedZoomFactor: number, minFitFactor: number): number | null;
491
+ /**
492
+ * Pure planner seam for the width-aware auto floor. `fittedZoomAt` is the
493
+ * measured-layout oracle: production obtains each measurement by engraving
494
+ * that candidate, while jsdom-free tests can supply deterministic factors.
495
+ * The returned list is the sequence to try; its final `1` is terminal.
496
+ */
497
+ declare function readabilityFloorPlan(initialBarsPerLine: number, widthPx: number, minFitFactor: number, fittedZoomAt: (barsPerLine: number, widthPx: number) => number): number[];
514
498
  /**
515
499
  * Semantic zoom → Verovio options. The migration spike's `zoom-test*.mjs`
516
500
  * proved `pageWidth` (Verovio's line-breaking width, in ITS OWN units)
@@ -557,4 +541,4 @@ declare const MAX_ENGRAVE_WIDTH_VRV = 1400;
557
541
  * (in stave-web-sightread) §2 for the full design. */
558
542
  declare function createVerovioNotationPlayer(opts: CreateVerovioNotationPlayerOpts): VerovioNotationPlayer;
559
543
 
560
- export { type CreateVerovioNotationPlayerOpts, DEFAULT_MIN_FIT_FACTOR, EngravedNote, MARKED_NOTE_CLASS, MAX_ENGRAVE_WIDTH_VRV, VEROVIO_BASE_SCALE, VEROVIO_LAYOUT_DEFAULTS, VEROVIO_MAX_SCALE, VEROVIO_MIN_SCALE, type VerovioLayoutOptions, type VerovioNotationPlayer, type VerovioOnset, type VerovioRenderOptions, applyPrintObjectHiding, createVerovioNotationPlayer, fitZoomFactor, shouldFallbackToAutoBreaks, systemMeasureCounts, verovioEngravedNotes, verovioNotationLayout, verovioNotesAtColumn, verovioOnsetColumns, verovioRenderOptions, verovioZoomOptions };
544
+ export { type CreateVerovioNotationPlayerOpts, DEFAULT_MIN_FIT_FACTOR, EngravedNote, MARKED_NOTE_CLASS, MAX_ENGRAVE_WIDTH_VRV, VEROVIO_BASE_SCALE, VEROVIO_LAYOUT_DEFAULTS, VEROVIO_MAX_SCALE, VEROVIO_MIN_SCALE, type VerovioLayoutOptions, type VerovioNotationPlayer, type VerovioOnset, type VerovioRenderOptions, applyPrintObjectHiding, createVerovioNotationPlayer, fitZoomFactor, isBelowReadabilityFloor, nextReadabilityFloorBarsPerLine, readabilityFloorPlan, shouldFallbackToAutoBreaks, systemMeasureCounts, verovioEngravedNotes, verovioNotationLayout, verovioNotesAtColumn, verovioOnsetColumns, verovioRenderOptions, verovioZoomOptions };
@@ -10,138 +10,14 @@ import {
10
10
  vstackAudioPlayheadLine
11
11
  } from "./chunk-DBFGX6OK.js";
12
12
  import "./chunk-PVZXLC23.js";
13
-
14
- // src/notationXml.ts
15
- function stampNoteIds(xml) {
16
- const doc = new DOMParser().parseFromString(xml, "application/xml");
17
- if (doc.querySelector("parsererror")) return xml;
18
- const parts = Array.from(doc.querySelectorAll("score-partwise > part"));
19
- parts.forEach((part, partIdx) => {
20
- for (const measure of Array.from(part.querySelectorAll("measure"))) {
21
- const number = measure.getAttribute("number") ?? "0";
22
- const notes = Array.from(measure.children).filter((el) => el.tagName === "note");
23
- notes.forEach((note, noteIdx) => {
24
- note.setAttribute("id", `n-${partIdx}-${number}-${noteIdx}`);
25
- });
26
- }
27
- });
28
- return new XMLSerializer().serializeToString(doc);
29
- }
30
- function injectSystemBreaks(xml, breakBeforeBarIndexes) {
31
- const doc = new DOMParser().parseFromString(xml, "application/xml");
32
- if (doc.querySelector("parsererror")) return xml;
33
- const root = doc.documentElement;
34
- if (!root || root.tagName !== "score-partwise") return xml;
35
- for (const part of childrenNamed(root, "part")) {
36
- const measures = childrenNamed(part, "measure");
37
- for (const pos of breakBeforeBarIndexes) {
38
- if (!Number.isInteger(pos) || pos <= 0 || pos >= measures.length) continue;
39
- const measure = measures[pos];
40
- const existingPrint = firstChildNamed(measure, "print");
41
- if (existingPrint) {
42
- existingPrint.setAttribute("new-system", "yes");
43
- } else {
44
- const printEl = doc.createElement("print");
45
- printEl.setAttribute("new-system", "yes");
46
- measure.insertBefore(printEl, measure.firstChild);
47
- }
48
- }
49
- }
50
- return new XMLSerializer().serializeToString(doc);
51
- }
52
- function balancedSystemBreaks(totalMeasures, systems) {
53
- if (systems <= 1 || totalMeasures <= 0) return [];
54
- const per = Math.ceil(totalMeasures / systems);
55
- if (per <= 0) return [];
56
- const breaks = [];
57
- for (let b = per; b < totalMeasures; b += per) breaks.push(b);
58
- return breaks;
59
- }
60
- var STEP_SEMITONE = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 };
61
- function firstChildNamed(el, tag) {
62
- for (const c of Array.from(el.children)) if (c.tagName === tag) return c;
63
- return null;
64
- }
65
- function childrenNamed(el, tag) {
66
- return Array.from(el.children).filter((c) => c.tagName === tag);
67
- }
68
- function textOf(el) {
69
- return el && el.textContent != null ? el.textContent.trim() : null;
70
- }
71
- function pitchMidiFromElement(pitchEl) {
72
- const step = textOf(firstChildNamed(pitchEl, "step")) ?? "C";
73
- const octave = Number(textOf(firstChildNamed(pitchEl, "octave")) ?? "4");
74
- const alter = Number(textOf(firstChildNamed(pitchEl, "alter")) ?? "0");
75
- return 12 * (octave + 1) + (STEP_SEMITONE[step] ?? 0) + Math.trunc(alter);
76
- }
77
- function partStaffCount(part) {
78
- let maxDeclared = 0;
79
- for (const attrs of childrenNamed(part, "measure").flatMap((m) => childrenNamed(m, "attributes"))) {
80
- const staves = Number(textOf(firstChildNamed(attrs, "staves")) ?? "0");
81
- if (staves > maxDeclared) maxDeclared = staves;
82
- }
83
- if (maxDeclared > 0) return maxDeclared;
84
- let maxStaff = 0;
85
- for (const measure of childrenNamed(part, "measure")) {
86
- for (const note of childrenNamed(measure, "note")) {
87
- const staff = Number(textOf(firstChildNamed(note, "staff")) ?? "0");
88
- if (staff > maxStaff) maxStaff = staff;
89
- }
90
- }
91
- return Math.max(1, maxStaff);
92
- }
93
- function noteModelFromXml(stampedXml) {
94
- const model = /* @__PURE__ */ new Map();
95
- let doc;
96
- try {
97
- doc = new DOMParser().parseFromString(stampedXml, "application/xml");
98
- } catch {
99
- return model;
100
- }
101
- if (doc.querySelector("parsererror")) return model;
102
- const root = doc.documentElement;
103
- if (!root || root.tagName !== "score-partwise") return model;
104
- const parts = childrenNamed(root, "part");
105
- let staffOffset = 0;
106
- for (const part of parts) {
107
- const staffCount = partStaffCount(part);
108
- let divisions = 1;
109
- const measureEls = childrenNamed(part, "measure");
110
- measureEls.forEach((measureEl, measureIndex) => {
111
- for (const child of Array.from(measureEl.children)) {
112
- if (child.tagName === "attributes") {
113
- const divText = textOf(firstChildNamed(child, "divisions"));
114
- if (divText) divisions = Number(divText) || divisions;
115
- continue;
116
- }
117
- if (child.tagName !== "note") continue;
118
- const note = child;
119
- const id = note.getAttribute("id");
120
- if (!id) continue;
121
- const isGrace = !!firstChildNamed(note, "grace");
122
- const isRest = !!firstChildNamed(note, "rest");
123
- const pitchEl = firstChildNamed(note, "pitch");
124
- const staffNumber = Number(textOf(firstChildNamed(note, "staff")) ?? "1") || 1;
125
- const durationText = textOf(firstChildNamed(note, "duration"));
126
- const durationReal = isGrace || !durationText ? 0 : Number(durationText) / divisions / 4;
127
- const tieStopDirect = childrenNamed(note, "tie").some((t) => t.getAttribute("type") === "stop");
128
- const notationsEl = firstChildNamed(note, "notations");
129
- const tieStopNotated = notationsEl ? childrenNamed(notationsEl, "tied").some((t) => t.getAttribute("type") === "stop") : false;
130
- model.set(id, {
131
- midi: pitchEl ? pitchMidiFromElement(pitchEl) : null,
132
- isRest,
133
- tieContinuation: tieStopDirect || tieStopNotated,
134
- staffIndex: staffOffset + Math.max(0, staffNumber - 1),
135
- durationReal,
136
- measureIndex,
137
- hidden: note.getAttribute("print-object") === "no"
138
- });
139
- }
140
- });
141
- staffOffset += staffCount;
142
- }
143
- return model;
144
- }
13
+ import {
14
+ balancedSystemBreaks,
15
+ injectSystemBreaks,
16
+ measureCount,
17
+ noteModelFromXml,
18
+ sectionAwareBreaks,
19
+ stampNoteIds
20
+ } from "./chunk-JCDNEJBT.js";
145
21
 
146
22
  // src/notationPlayerVerovio.ts
147
23
  var MARKED_NOTE_CLASS = "rmp-note-marked";
@@ -396,7 +272,13 @@ var VEROVIO_LAYOUT_DEFAULTS = {
396
272
  };
397
273
  function verovioRenderOptions(hostWidthPx, zoom, layout) {
398
274
  const { scale, pageWidth } = verovioZoomOptions(hostWidthPx, zoom);
399
- const { avoidWidows: _avoidWidows, minFitFactor: _minFitFactor, ...verovioLayout } = layout ?? {};
275
+ const {
276
+ avoidWidows: _avoidWidows,
277
+ minFitFactor: _minFitFactor,
278
+ barsPerLine: _barsPerLine,
279
+ sectionStarts: _sectionStarts,
280
+ ...verovioLayout
281
+ } = layout ?? {};
400
282
  return {
401
283
  ...VEROVIO_LAYOUT_DEFAULTS,
402
284
  ...verovioLayout,
@@ -419,10 +301,31 @@ function fitZoomFactor(systemWidthsPx, engraveWidthPx) {
419
301
  }
420
302
  var DEFAULT_MIN_FIT_FACTOR = 0.75;
421
303
  function shouldFallbackToAutoBreaks(factor, breaks, minFitFactor) {
422
- if (!Number.isFinite(factor)) return false;
423
- if (!(minFitFactor > 0)) return false;
304
+ if (!isBelowReadabilityFloor(factor, minFitFactor)) return false;
424
305
  if (breaks !== "line" && breaks !== "encoded") return false;
425
- return factor < minFitFactor;
306
+ return true;
307
+ }
308
+ function isBelowReadabilityFloor(factor, minFitFactor) {
309
+ return Number.isFinite(factor) && Number.isFinite(minFitFactor) && minFitFactor > 0 && factor < minFitFactor;
310
+ }
311
+ function nextReadabilityFloorBarsPerLine(currentBarsPerLine, fittedZoomFactor, minFitFactor) {
312
+ if (!isBelowReadabilityFloor(fittedZoomFactor, minFitFactor)) return null;
313
+ if (!Number.isFinite(currentBarsPerLine) || currentBarsPerLine <= 1) return null;
314
+ return Math.max(1, Math.ceil(currentBarsPerLine / 2));
315
+ }
316
+ function readabilityFloorPlan(initialBarsPerLine, widthPx, minFitFactor, fittedZoomAt) {
317
+ if (!Number.isFinite(initialBarsPerLine) || initialBarsPerLine <= 0 || !(widthPx > 0)) return [];
318
+ let bars = Math.max(1, Math.floor(initialBarsPerLine));
319
+ let factor = fittedZoomAt(bars, widthPx);
320
+ const plan = [];
321
+ for (; ; ) {
322
+ const next = nextReadabilityFloorBarsPerLine(bars, factor, minFitFactor);
323
+ if (next === null) return plan;
324
+ plan.push(next);
325
+ bars = next;
326
+ factor = fittedZoomAt(bars, widthPx);
327
+ if (bars === 1) return plan;
328
+ }
426
329
  }
427
330
  function verovioZoomOptions(hostWidthPx, zoom) {
428
331
  const z = Number.isFinite(zoom) && zoom > 0 ? zoom : 1;
@@ -470,6 +373,19 @@ function createVerovioNotationPlayer(opts) {
470
373
  }
471
374
  const stampedXml = opts.rendered ? musicXml : stampNoteIds(musicXml);
472
375
  const noteModel = opts.rendered ? /* @__PURE__ */ new Map() : noteModelFromXml(stampedXml);
376
+ const measureTotal = measureCount(stampedXml);
377
+ const breakPlan = (() => {
378
+ const layout = opts.verovioOptions;
379
+ const starts = layout?.sectionStarts ?? [];
380
+ const every = layout?.barsPerLine ?? 0;
381
+ if (!starts.length && !(every > 0)) return [];
382
+ return sectionAwareBreaks(measureTotal, starts, every);
383
+ })();
384
+ const plannedXml = breakPlan.length ? injectSystemBreaks(stampedXml, breakPlan) : stampedXml;
385
+ function autoFloorPlanXml(barsPerLine) {
386
+ const positions = barsPerLine <= 1 ? Array.from({ length: Math.max(0, measureTotal - 1) }, (_, i) => i + 1) : sectionAwareBreaks(measureTotal, [], barsPerLine);
387
+ return positions.length ? injectSystemBreaks(stampedXml, positions) : stampedXml;
388
+ }
473
389
  const root = document.createElement("div");
474
390
  root.style.position = "relative";
475
391
  root.style.width = "100%";
@@ -581,23 +497,47 @@ function createVerovioNotationPlayer(opts) {
581
497
  }
582
498
  function engraveOnce(toolkit, widthPx, zoom) {
583
499
  const layoutOpts = opts.verovioOptions;
584
- const effectiveBreaks = layoutOpts?.breaks ?? VEROVIO_LAYOUT_DEFAULTS.breaks;
500
+ const planLayoutOpts = breakPlan.length ? { ...layoutOpts, breaks: "line" } : layoutOpts;
501
+ const effectiveBreaks = planLayoutOpts?.breaks ?? VEROVIO_LAYOUT_DEFAULTS.breaks;
585
502
  const minFitFactor = layoutOpts?.minFitFactor ?? DEFAULT_MIN_FIT_FACTOR;
586
503
  const avoidWidows = layoutOpts?.avoidWidows !== false;
587
- let xmlToRender = stampedXml;
588
- let renderLayoutOpts = layoutOpts;
504
+ let xmlToRender = plannedXml;
505
+ let renderLayoutOpts = planLayoutOpts;
589
506
  toolkit.setOptions(verovioRenderOptions(widthPx, zoom, renderLayoutOpts));
590
507
  const ok = !!toolkit.loadData(xmlToRender);
591
508
  if (ok) renderAllPages(toolkit);
592
509
  rebuildLayoutFromDom();
593
- if (ok && avoidWidows && effectiveBreaks === "auto") {
594
- const widowed = runWidowPass(toolkit, widthPx, zoom, xmlToRender, renderLayoutOpts);
595
- xmlToRender = widowed.xml;
596
- renderLayoutOpts = widowed.layoutOpts;
597
- }
598
510
  if (ok && currentLayout) {
599
511
  const systemWidthsPx = () => opts.measureSystemWidths ? opts.measureSystemWidths(svgHost) : currentLayout.systems.map((s) => s.w);
600
512
  let factor = fitZoomFactor(systemWidthsPx(), widthPx);
513
+ let terminalAutoFloorPlan = false;
514
+ if (effectiveBreaks === "auto") {
515
+ if (!isBelowReadabilityFloor(factor, minFitFactor) && avoidWidows) {
516
+ const widowed = runWidowPass(toolkit, widthPx, zoom, xmlToRender, renderLayoutOpts);
517
+ xmlToRender = widowed.xml;
518
+ renderLayoutOpts = widowed.layoutOpts;
519
+ factor = fitZoomFactor(systemWidthsPx(), widthPx);
520
+ }
521
+ let barsPerLine = Math.max(1, ...systemMeasureCounts(svgHost));
522
+ for (; ; ) {
523
+ const nextBarsPerLine = nextReadabilityFloorBarsPerLine(barsPerLine, factor, minFitFactor);
524
+ if (nextBarsPerLine === null) break;
525
+ const floorXml = autoFloorPlanXml(nextBarsPerLine);
526
+ const floorLayoutOpts = { ...layoutOpts, breaks: "line" };
527
+ toolkit.setOptions(verovioRenderOptions(widthPx, zoom, floorLayoutOpts));
528
+ if (!toolkit.loadData(floorXml)) break;
529
+ renderAllPages(toolkit);
530
+ rebuildLayoutFromDom();
531
+ xmlToRender = floorXml;
532
+ renderLayoutOpts = floorLayoutOpts;
533
+ factor = fitZoomFactor(systemWidthsPx(), widthPx);
534
+ barsPerLine = nextBarsPerLine;
535
+ if (barsPerLine === 1 && isBelowReadabilityFloor(factor, minFitFactor)) {
536
+ terminalAutoFloorPlan = true;
537
+ break;
538
+ }
539
+ }
540
+ }
601
541
  if (shouldFallbackToAutoBreaks(factor, effectiveBreaks, minFitFactor)) {
602
542
  const autoLayoutOpts = { ...layoutOpts, breaks: "auto" };
603
543
  toolkit.setOptions(verovioRenderOptions(widthPx, zoom, autoLayoutOpts));
@@ -615,7 +555,7 @@ function createVerovioNotationPlayer(opts) {
615
555
  factor = fitZoomFactor(systemWidthsPx(), widthPx);
616
556
  }
617
557
  }
618
- if (factor < 1) {
558
+ if (factor < 1 && !terminalAutoFloorPlan) {
619
559
  const effectiveZoom = zoom * factor;
620
560
  toolkit.setOptions(verovioRenderOptions(widthPx, effectiveZoom, renderLayoutOpts));
621
561
  const ok2 = !!toolkit.loadData(xmlToRender);
@@ -746,6 +686,9 @@ export {
746
686
  applyPrintObjectHiding,
747
687
  createVerovioNotationPlayer,
748
688
  fitZoomFactor,
689
+ isBelowReadabilityFloor,
690
+ nextReadabilityFloorBarsPerLine,
691
+ readabilityFloorPlan,
749
692
  shouldFallbackToAutoBreaks,
750
693
  systemMeasureCounts,
751
694
  verovioEngravedNotes,