@real-music-packages/web-core 0.45.1 → 0.46.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.
- package/README.md +10 -1
- package/dist/audio.js +7 -7
- package/dist/notationBeams.d.ts +26 -0
- package/dist/notationBeams.js +133 -0
- package/dist/notationBeams.js.map +1 -0
- package/dist/notationPlayerVerovio.d.ts +61 -5
- package/dist/notationPlayerVerovio.js +54 -26
- package/dist/notationPlayerVerovio.js.map +1 -1
- package/dist/scene/index.d.ts +410 -2
- package/dist/scene/index.js +725 -21
- package/dist/scene/index.js.map +1 -1
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -78,11 +78,20 @@ npm install @real-music-packages/web-core
|
|
|
78
78
|
## Dev
|
|
79
79
|
```
|
|
80
80
|
npm install
|
|
81
|
-
npm test # vitest
|
|
81
|
+
npm test # vitest — all 76 files / 788 tests (~28s)
|
|
82
82
|
npm run build # tsup → dist/ (ESM + .d.ts)
|
|
83
83
|
npm run build:watch # tsup --watch → rebuild dist/ on every edit (fast dev loop)
|
|
84
|
+
|
|
85
|
+
bash scripts/quick.sh # tests reachable from your diff + tsc (~19s)
|
|
86
|
+
bash scripts/quick.sh origin/main # ...from the whole branch
|
|
84
87
|
```
|
|
85
88
|
|
|
89
|
+
`quick.sh` runs `vitest --changed`, so only the tests your diff can reach through
|
|
90
|
+
the module graph. `tsc --noEmit` always runs alongside it (~6.5s) — this package is
|
|
91
|
+
a **published dependency**, so a type regression escapes into every app that vendors
|
|
92
|
+
it. If `--changed` matches no test, it prints `TYPES ONLY` rather than a green tick.
|
|
93
|
+
It is not a release gate; publishing still goes through `npm test` + `npm run build`.
|
|
94
|
+
|
|
86
95
|
**Iterating against a live app without a publish/re-pin?** See
|
|
87
96
|
[DEVELOPMENT.md](./DEVELOPMENT.md) — `build:watch` + `tools/dev-link.sh <app>`
|
|
88
97
|
links the local web-core into an app so its vite dev server reflects your edits
|
package/dist/audio.js
CHANGED
|
@@ -1,10 +1,3 @@
|
|
|
1
|
-
import {
|
|
2
|
-
KEYS_PREFER_FLATS
|
|
3
|
-
} from "./chunk-BYZBLH25.js";
|
|
4
|
-
import {
|
|
5
|
-
getMidiNote,
|
|
6
|
-
midiToNoteName
|
|
7
|
-
} from "./chunk-GORQ5YMR.js";
|
|
8
1
|
import {
|
|
9
2
|
SALAMANDER_CDN_BASE,
|
|
10
3
|
SALAMANDER_URLS_8,
|
|
@@ -12,6 +5,13 @@ import {
|
|
|
12
5
|
createSalamanderSampler,
|
|
13
6
|
generateReverb
|
|
14
7
|
} from "./chunk-74TMWRNK.js";
|
|
8
|
+
import {
|
|
9
|
+
KEYS_PREFER_FLATS
|
|
10
|
+
} from "./chunk-BYZBLH25.js";
|
|
11
|
+
import {
|
|
12
|
+
getMidiNote,
|
|
13
|
+
midiToNoteName
|
|
14
|
+
} from "./chunk-GORQ5YMR.js";
|
|
15
15
|
|
|
16
16
|
// src/engine.ts
|
|
17
17
|
var browser = typeof window !== "undefined";
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-derive every `<beam>` in `xml` from the time signature: beam within a
|
|
3
|
+
* beat, break at rests, never span a beat boundary.
|
|
4
|
+
*
|
|
5
|
+
* Existing beams are DISCARDED rather than merged — a malformed group
|
|
6
|
+
* (unclosed, or an `end` with no `begin`) is precisely the input this exists
|
|
7
|
+
* to repair, so trusting any of it would defeat the purpose.
|
|
8
|
+
*
|
|
9
|
+
* Details that are engraving decisions, not implementation accidents:
|
|
10
|
+
* - Compound meters (6/8, 9/8, 12/8) group in dotted beats — three eighths —
|
|
11
|
+
* because that is what makes them read as compound rather than as fast
|
|
12
|
+
* simple time. 3/8 and 3/4 are simple and group per beat.
|
|
13
|
+
* - Chord members never carry beams; only a chord's first note does.
|
|
14
|
+
* - Grace notes are left untouched. Their beaming belongs to the ornament
|
|
15
|
+
* they decorate, not to the beat grid.
|
|
16
|
+
* - A beat holding a single beamable note gets NO beam — that note takes a
|
|
17
|
+
* flag, which is correct engraving, not a missing feature.
|
|
18
|
+
* - Beams never cross a staff or voice change; each (staff, voice) stream is
|
|
19
|
+
* grouped independently.
|
|
20
|
+
*
|
|
21
|
+
* Malformed input (fails to parse) is returned unchanged, the same defensive
|
|
22
|
+
* stance as `stampNoteIds`/`injectSystemBreaks` in ./notationXml.ts.
|
|
23
|
+
*/
|
|
24
|
+
declare function rebeamByMeter(xml: string): string;
|
|
25
|
+
|
|
26
|
+
export { rebeamByMeter };
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// src/notationBeams.ts
|
|
2
|
+
var BEAM_COUNT_BY_TYPE = {
|
|
3
|
+
eighth: 1,
|
|
4
|
+
"16th": 2,
|
|
5
|
+
"32nd": 3,
|
|
6
|
+
"64th": 4,
|
|
7
|
+
"128th": 5,
|
|
8
|
+
"256th": 6
|
|
9
|
+
};
|
|
10
|
+
function intText(parent, tag) {
|
|
11
|
+
const el = parent.querySelector(`:scope > ${tag}`);
|
|
12
|
+
const n = el ? Number(el.textContent) : NaN;
|
|
13
|
+
return Number.isFinite(n) ? n : null;
|
|
14
|
+
}
|
|
15
|
+
function insertBeam(note, beam) {
|
|
16
|
+
const before = note.querySelector(":scope > notations, :scope > lyric");
|
|
17
|
+
if (before) note.insertBefore(beam, before);
|
|
18
|
+
else note.appendChild(beam);
|
|
19
|
+
}
|
|
20
|
+
function emitBeams(doc, run) {
|
|
21
|
+
const maxLevel = Math.max(...run.map((n) => n.beams));
|
|
22
|
+
for (let level = 1; level <= maxLevel; level++) {
|
|
23
|
+
let i = 0;
|
|
24
|
+
while (i < run.length) {
|
|
25
|
+
if (run[i].beams < level) {
|
|
26
|
+
i++;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
let j = i;
|
|
30
|
+
while (j + 1 < run.length && run[j + 1].beams >= level) j++;
|
|
31
|
+
if (j > i) {
|
|
32
|
+
for (let k = i; k <= j; k++) {
|
|
33
|
+
const beam = doc.createElement("beam");
|
|
34
|
+
beam.setAttribute("number", String(level));
|
|
35
|
+
beam.textContent = k === i ? "begin" : k === j ? "end" : "continue";
|
|
36
|
+
insertBeam(run[k].el, beam);
|
|
37
|
+
}
|
|
38
|
+
} else if (level > 1) {
|
|
39
|
+
const beam = doc.createElement("beam");
|
|
40
|
+
beam.setAttribute("number", String(level));
|
|
41
|
+
beam.textContent = i === 0 ? "forward hook" : "backward hook";
|
|
42
|
+
insertBeam(run[i].el, beam);
|
|
43
|
+
}
|
|
44
|
+
i = j + 1;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function rebeamByMeter(xml) {
|
|
49
|
+
const doc = new DOMParser().parseFromString(xml, "application/xml");
|
|
50
|
+
if (doc.querySelector("parsererror")) return xml;
|
|
51
|
+
for (const part of Array.from(doc.querySelectorAll("score-partwise > part"))) {
|
|
52
|
+
let divisions = 1;
|
|
53
|
+
let beats = 4;
|
|
54
|
+
let beatType = 4;
|
|
55
|
+
for (const measure of Array.from(part.querySelectorAll("measure"))) {
|
|
56
|
+
const attrs = measure.querySelector(":scope > attributes");
|
|
57
|
+
if (attrs) {
|
|
58
|
+
divisions = intText(attrs, "divisions") ?? divisions;
|
|
59
|
+
const time = attrs.querySelector(":scope > time");
|
|
60
|
+
if (time) {
|
|
61
|
+
beats = intText(time, "beats") ?? beats;
|
|
62
|
+
beatType = intText(time, "beat-type") ?? beatType;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const beatDivs = divisions * 4 / beatType;
|
|
66
|
+
const compound = beatType >= 8 && beats % 3 === 0 && beats > 3;
|
|
67
|
+
const groupDivs = compound ? beatDivs * 3 : beatDivs;
|
|
68
|
+
if (!(groupDivs > 0)) continue;
|
|
69
|
+
const streams = /* @__PURE__ */ new Map();
|
|
70
|
+
let pos = 0;
|
|
71
|
+
for (const el of Array.from(measure.children)) {
|
|
72
|
+
if (el.tagName === "backup") {
|
|
73
|
+
pos -= intText(el, "duration") ?? 0;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (el.tagName === "forward") {
|
|
77
|
+
pos += intText(el, "duration") ?? 0;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (el.tagName !== "note") continue;
|
|
81
|
+
const isGrace = !!el.querySelector(":scope > grace");
|
|
82
|
+
const isChord = !!el.querySelector(":scope > chord");
|
|
83
|
+
const isRest = !!el.querySelector(":scope > rest");
|
|
84
|
+
const dur = intText(el, "duration") ?? 0;
|
|
85
|
+
const type = el.querySelector(":scope > type")?.textContent?.trim() ?? "";
|
|
86
|
+
if (!isGrace) {
|
|
87
|
+
for (const b of Array.from(el.querySelectorAll(":scope > beam"))) b.remove();
|
|
88
|
+
}
|
|
89
|
+
if (!isGrace && !isChord) {
|
|
90
|
+
const staff = el.querySelector(":scope > staff")?.textContent?.trim() ?? "1";
|
|
91
|
+
const voice = el.querySelector(":scope > voice")?.textContent?.trim() ?? "1";
|
|
92
|
+
const key = `${staff}:${voice}`;
|
|
93
|
+
let stream = streams.get(key);
|
|
94
|
+
if (!stream) {
|
|
95
|
+
stream = [];
|
|
96
|
+
streams.set(key, stream);
|
|
97
|
+
}
|
|
98
|
+
stream.push({
|
|
99
|
+
el,
|
|
100
|
+
start: pos,
|
|
101
|
+
beams: isRest ? 0 : BEAM_COUNT_BY_TYPE[type] ?? 0
|
|
102
|
+
});
|
|
103
|
+
pos += dur;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
for (const notes of streams.values()) {
|
|
107
|
+
let run = [];
|
|
108
|
+
const flush = () => {
|
|
109
|
+
if (run.length >= 2) emitBeams(doc, run);
|
|
110
|
+
run = [];
|
|
111
|
+
};
|
|
112
|
+
for (const note of notes) {
|
|
113
|
+
if (note.beams === 0) {
|
|
114
|
+
flush();
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (run.length) {
|
|
118
|
+
const prev = run[run.length - 1];
|
|
119
|
+
const sameBeat = Math.floor(prev.start / groupDivs) === Math.floor(note.start / groupDivs);
|
|
120
|
+
if (!sameBeat) flush();
|
|
121
|
+
}
|
|
122
|
+
run.push(note);
|
|
123
|
+
}
|
|
124
|
+
flush();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return new XMLSerializer().serializeToString(doc);
|
|
129
|
+
}
|
|
130
|
+
export {
|
|
131
|
+
rebeamByMeter
|
|
132
|
+
};
|
|
133
|
+
//# sourceMappingURL=notationBeams.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/notationBeams.ts"],"sourcesContent":["// Beam grouping — the part of engraving the ENGRAVER cannot supply.\n//\n// Verovio does NOT auto-beam imported MusicXML. Strip a score's `<beam>`\n// elements and every eighth/sixteenth comes out with an individual flag\n// (measured on the RSR corpus: 566 beams removed -> 0 beams, 22 flags). Beam\n// grouping is therefore entirely whatever the source file encodes, and a\n// generator that emits beams spanning beat boundaries — or unbalanced groups\n// (`begin` with no `end`) — produces engraving that nothing downstream can\n// improve by rendering harder.\n//\n// This lives in web-core rather than in a consumer app on purpose: beaming is\n// a fact about how notation is DISPLAYED, which this package owns. An app\n// re-deriving it would be inferring something the notation layer holds\n// authoritatively — the exact move the owned-layer rule exists to prevent.\n//\n// Sibling of ./notationXml.ts (stampNoteIds / injectSystemBreaks) and follows\n// its conventions: pure XML-in → XML-out, DOMParser/XMLSerializer, and\n// malformed input returned unchanged rather than thrown on.\n\nconst BEAM_COUNT_BY_TYPE: Record<string, number> = {\n eighth: 1, '16th': 2, '32nd': 3, '64th': 4, '128th': 5, '256th': 6,\n};\n\ninterface BeamNote {\n el: Element;\n /** Divisions from the start of the measure. */\n start: number;\n /** Number of beams this note's `<type>` implies; 0 for rests. */\n beams: number;\n}\n\nfunction intText(parent: Element, tag: string): number | null {\n const el = parent.querySelector(`:scope > ${tag}`);\n const n = el ? Number(el.textContent) : NaN;\n return Number.isFinite(n) ? n : null;\n}\n\n/** Where a `<beam>` may legally sit inside `<note>`: after `<stem>`/`<staff>`,\n * before `<notations>`/`<lyric>`. Verovio is lenient about this, but emitting\n * schema-ordered output keeps the result usable by anything else that reads\n * MusicXML. */\nfunction insertBeam(note: Element, beam: Element): void {\n const before = note.querySelector(':scope > notations, :scope > lyric');\n if (before) note.insertBefore(beam, before);\n else note.appendChild(beam);\n}\n\n/** Emit level-1 begin/continue/end across `run`, then per-level maximal runs\n * for levels 2+, with hooks for a shorter note that has no partner. */\nfunction emitBeams(doc: Document, run: BeamNote[]): void {\n const maxLevel = Math.max(...run.map((n) => n.beams));\n for (let level = 1; level <= maxLevel; level++) {\n let i = 0;\n while (i < run.length) {\n if (run[i].beams < level) { i++; continue; }\n let j = i;\n while (j + 1 < run.length && run[j + 1].beams >= level) j++;\n if (j > i) {\n for (let k = i; k <= j; k++) {\n const beam = doc.createElement('beam');\n beam.setAttribute('number', String(level));\n beam.textContent = k === i ? 'begin' : k === j ? 'end' : 'continue';\n insertBeam(run[k].el, beam);\n }\n } else if (level > 1) {\n // A lone 16th among 8ths gets a hook, not a beam it has no partner\n // for. It points backward unless it opens the group.\n const beam = doc.createElement('beam');\n beam.setAttribute('number', String(level));\n beam.textContent = i === 0 ? 'forward hook' : 'backward hook';\n insertBeam(run[i].el, beam);\n }\n i = j + 1;\n }\n }\n}\n\n/**\n * Re-derive every `<beam>` in `xml` from the time signature: beam within a\n * beat, break at rests, never span a beat boundary.\n *\n * Existing beams are DISCARDED rather than merged — a malformed group\n * (unclosed, or an `end` with no `begin`) is precisely the input this exists\n * to repair, so trusting any of it would defeat the purpose.\n *\n * Details that are engraving decisions, not implementation accidents:\n * - Compound meters (6/8, 9/8, 12/8) group in dotted beats — three eighths —\n * because that is what makes them read as compound rather than as fast\n * simple time. 3/8 and 3/4 are simple and group per beat.\n * - Chord members never carry beams; only a chord's first note does.\n * - Grace notes are left untouched. Their beaming belongs to the ornament\n * they decorate, not to the beat grid.\n * - A beat holding a single beamable note gets NO beam — that note takes a\n * flag, which is correct engraving, not a missing feature.\n * - Beams never cross a staff or voice change; each (staff, voice) stream is\n * grouped independently.\n *\n * Malformed input (fails to parse) is returned unchanged, the same defensive\n * stance as `stampNoteIds`/`injectSystemBreaks` in ./notationXml.ts.\n */\nexport function rebeamByMeter(xml: string): string {\n const doc = new DOMParser().parseFromString(xml, 'application/xml');\n if (doc.querySelector('parsererror')) return xml;\n\n for (const part of Array.from(doc.querySelectorAll('score-partwise > part'))) {\n // divisions and the time signature persist across measures until restated.\n let divisions = 1;\n let beats = 4;\n let beatType = 4;\n\n for (const measure of Array.from(part.querySelectorAll('measure'))) {\n const attrs = measure.querySelector(':scope > attributes');\n if (attrs) {\n divisions = intText(attrs, 'divisions') ?? divisions;\n const time = attrs.querySelector(':scope > time');\n if (time) {\n beats = intText(time, 'beats') ?? beats;\n beatType = intText(time, 'beat-type') ?? beatType;\n }\n }\n\n const beatDivs = (divisions * 4) / beatType;\n const compound = beatType >= 8 && beats % 3 === 0 && beats > 3;\n const groupDivs = compound ? beatDivs * 3 : beatDivs;\n if (!(groupDivs > 0)) continue;\n\n // Walk in document order, tracking position through backup/forward, and\n // bucket notes by the (staff, voice) stream they belong to.\n const streams = new Map<string, BeamNote[]>();\n let pos = 0;\n\n for (const el of Array.from(measure.children)) {\n if (el.tagName === 'backup') { pos -= intText(el, 'duration') ?? 0; continue; }\n if (el.tagName === 'forward') { pos += intText(el, 'duration') ?? 0; continue; }\n if (el.tagName !== 'note') continue;\n\n const isGrace = !!el.querySelector(':scope > grace');\n const isChord = !!el.querySelector(':scope > chord');\n const isRest = !!el.querySelector(':scope > rest');\n const dur = intText(el, 'duration') ?? 0;\n const type = el.querySelector(':scope > type')?.textContent?.trim() ?? '';\n\n // Wipe first: every beam in the output is re-derived below. Grace\n // notes keep whatever they came with.\n if (!isGrace) {\n for (const b of Array.from(el.querySelectorAll(':scope > beam'))) b.remove();\n }\n\n if (!isGrace && !isChord) {\n const staff = el.querySelector(':scope > staff')?.textContent?.trim() ?? '1';\n const voice = el.querySelector(':scope > voice')?.textContent?.trim() ?? '1';\n const key = `${staff}:${voice}`;\n let stream = streams.get(key);\n if (!stream) { stream = []; streams.set(key, stream); }\n stream.push({\n el, start: pos,\n beams: isRest ? 0 : (BEAM_COUNT_BY_TYPE[type] ?? 0),\n });\n // Grace notes steal no time; chord members share the previous onset.\n pos += dur;\n }\n }\n\n for (const notes of streams.values()) {\n let run: BeamNote[] = [];\n const flush = (): void => {\n if (run.length >= 2) emitBeams(doc, run);\n run = [];\n };\n for (const note of notes) {\n if (note.beams === 0) { flush(); continue; }\n if (run.length) {\n const prev = run[run.length - 1];\n const sameBeat = Math.floor(prev.start / groupDivs) === Math.floor(note.start / groupDivs);\n if (!sameBeat) flush();\n }\n run.push(note);\n }\n flush();\n }\n }\n }\n\n return new XMLSerializer().serializeToString(doc);\n}\n"],"mappings":"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","names":[]}
|
|
@@ -148,6 +148,22 @@ interface CreateVerovioNotationPlayerOpts {
|
|
|
148
148
|
* "why" behind the defaults themselves).
|
|
149
149
|
*/
|
|
150
150
|
verovioOptions?: VerovioLayoutOptions;
|
|
151
|
+
/**
|
|
152
|
+
* TEST-ONLY SEAM — not part of this player's real production behavior.
|
|
153
|
+
* When set, `engraveOnce`'s fit-pass/fallback-decision math reads system
|
|
154
|
+
* widths from this function (called with `svgHost`) instead of
|
|
155
|
+
* `currentLayout.systems`. Exists because jsdom implements neither
|
|
156
|
+
* `getBBox` nor a real `getBoundingClientRect` (both always report
|
|
157
|
+
* zero-size boxes), so `currentLayout.systems` widths — and therefore
|
|
158
|
+
* `fitZoomFactor`'s result — are always 0/`1` (fits) in a headless test
|
|
159
|
+
* regardless of how dense the underlying MusicXML actually is. A test can
|
|
160
|
+
* pass a fixed fake (e.g. `() => [1200]`) to exercise the readability-floor
|
|
161
|
+
* fallback (`VerovioLayoutOptions.minFitFactor`) end-to-end against a REAL
|
|
162
|
+
* Verovio engrave, asserting on the resulting DOM shape
|
|
163
|
+
* (`systemMeasureCounts`) rather than on geometry a real browser would be
|
|
164
|
+
* needed to produce. Never used by any real caller.
|
|
165
|
+
*/
|
|
166
|
+
measureSystemWidths?: (root: Element) => number[];
|
|
151
167
|
}
|
|
152
168
|
interface VerovioNotationPlayer {
|
|
153
169
|
/** Resolves once the engraving is in the DOM and ready to draw. `setTime`/
|
|
@@ -362,6 +378,25 @@ interface VerovioLayoutOptions {
|
|
|
362
378
|
* never second-guesses.
|
|
363
379
|
*/
|
|
364
380
|
avoidWidows?: boolean;
|
|
381
|
+
/**
|
|
382
|
+
* Readability floor for CALLER-ENCODED breaks (`breaks: 'line'` or
|
|
383
|
+
* `'encoded'`) — see `engraveOnce`'s own "Fallback pass" doc. Stave's own
|
|
384
|
+
* motivating case: `<print new-system="yes"/>` every 4 bars + `breaks:
|
|
385
|
+
* 'line'` gets exact N-bars-per-line on normal music, but on dense music
|
|
386
|
+
* (e.g. a Bach fugue excerpt) the one system between two encoded breaks
|
|
387
|
+
* can be too wide to fit even at Verovio's minimum spacing — the ONLY
|
|
388
|
+
* lever `fitZoomFactor` then has left is shrinking the effective zoom,
|
|
389
|
+
* which on a dense-enough system means unreadably small glyphs. Good
|
|
390
|
+
* sight-reading software prefers readable glyphs over a fixed bar count.
|
|
391
|
+
*
|
|
392
|
+
* A PLAYER option, not a Verovio one — like `avoidWidows`, stripped out in
|
|
393
|
+
* `verovioRenderOptions` before `setOptions` ever sees it. Default 0.75
|
|
394
|
+
* (see `shouldFallbackToAutoBreaks`'s own doc for the exact trigger
|
|
395
|
+
* condition). `minFitFactor: 0` disables the fallback entirely — a caller
|
|
396
|
+
* who always wants their exact encoded bar count, however small the
|
|
397
|
+
* glyphs get, can opt back into the pre-existing behavior.
|
|
398
|
+
*/
|
|
399
|
+
minFitFactor?: number;
|
|
365
400
|
}
|
|
366
401
|
/**
|
|
367
402
|
* Layout defaults applied to EVERY engrave (initial + every reflow) unless a
|
|
@@ -408,10 +443,10 @@ declare const VEROVIO_LAYOUT_DEFAULTS: {
|
|
|
408
443
|
* Verovio toolkit (`tests/notationPlayerVerovio.test.ts`'s "options merge"
|
|
409
444
|
* tests call this directly).
|
|
410
445
|
*
|
|
411
|
-
* `avoidWidows` (see `VerovioLayoutOptions`'s own
|
|
412
|
-
* not
|
|
413
|
-
* `setOptions`, same "narrow the passthrough" spirit as this
|
|
414
|
-
* accepting `VerovioLayoutOptions`'s typed surface at all.
|
|
446
|
+
* `avoidWidows`/`minFitFactor` (see `VerovioLayoutOptions`'s own docs) are
|
|
447
|
+
* PLAYER options, not Verovio ones — destructured out here and never
|
|
448
|
+
* forwarded to `setOptions`, same "narrow the passthrough" spirit as this
|
|
449
|
+
* function only accepting `VerovioLayoutOptions`'s typed surface at all.
|
|
415
450
|
*
|
|
416
451
|
* `pageHeight: 60000` (Verovio's own max) + `pageMarginTop`/`pageMarginBottom:
|
|
417
452
|
* 0` are unconditional, like `adjustPageHeight` — not in `VerovioLayoutOptions`
|
|
@@ -455,6 +490,27 @@ declare function verovioRenderOptions(hostWidthPx: number, zoom: number, layout?
|
|
|
455
490
|
* available width.
|
|
456
491
|
*/
|
|
457
492
|
declare function fitZoomFactor(systemWidthsPx: readonly number[], engraveWidthPx: number): number;
|
|
493
|
+
/** Default `VerovioLayoutOptions.minFitFactor` — see that field's own doc. */
|
|
494
|
+
declare const DEFAULT_MIN_FIT_FACTOR = 0.75;
|
|
495
|
+
/**
|
|
496
|
+
* Pure decision for the readability floor (`VerovioLayoutOptions.minFitFactor`,
|
|
497
|
+
* `engraveOnce`'s "Fallback pass") — true exactly when ALL of:
|
|
498
|
+
* - `factor` (a `fitZoomFactor` result) is finite and strictly below
|
|
499
|
+
* `minFitFactor`;
|
|
500
|
+
* - `minFitFactor` is a positive, finite floor (0 — or anything
|
|
501
|
+
* non-positive/non-finite — disables the fallback unconditionally, the
|
|
502
|
+
* documented opt-out);
|
|
503
|
+
* - `breaks` is `'line'` or `'encoded'` — the two ways a caller can hand
|
|
504
|
+
* Verovio EXACT, pre-decided break points (as opposed to `'auto'`/
|
|
505
|
+
* `'smart'`/`'none'`/unset, where Verovio already owns the line-breaking
|
|
506
|
+
* decision and there is no "caller's encoded bar count" to fall back
|
|
507
|
+
* FROM in the first place).
|
|
508
|
+
*
|
|
509
|
+
* Never throws; no DOM/Verovio access — this is the single source of truth
|
|
510
|
+
* `engraveOnce` calls after every fit-factor computation, so the trigger
|
|
511
|
+
* condition is unit-testable independent of a real engrave.
|
|
512
|
+
*/
|
|
513
|
+
declare function shouldFallbackToAutoBreaks(factor: number, breaks: VerovioLayoutOptions['breaks'] | undefined, minFitFactor: number): boolean;
|
|
458
514
|
/**
|
|
459
515
|
* Semantic zoom → Verovio options. The migration spike's `zoom-test*.mjs`
|
|
460
516
|
* proved `pageWidth` (Verovio's line-breaking width, in ITS OWN units)
|
|
@@ -501,4 +557,4 @@ declare const MAX_ENGRAVE_WIDTH_VRV = 1400;
|
|
|
501
557
|
* (in stave-web-sightread) §2 for the full design. */
|
|
502
558
|
declare function createVerovioNotationPlayer(opts: CreateVerovioNotationPlayerOpts): VerovioNotationPlayer;
|
|
503
559
|
|
|
504
|
-
export { type CreateVerovioNotationPlayerOpts, 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, systemMeasureCounts, verovioEngravedNotes, verovioNotationLayout, verovioNotesAtColumn, verovioOnsetColumns, verovioRenderOptions, verovioZoomOptions };
|
|
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 };
|
|
@@ -396,7 +396,7 @@ var VEROVIO_LAYOUT_DEFAULTS = {
|
|
|
396
396
|
};
|
|
397
397
|
function verovioRenderOptions(hostWidthPx, zoom, layout) {
|
|
398
398
|
const { scale, pageWidth } = verovioZoomOptions(hostWidthPx, zoom);
|
|
399
|
-
const { avoidWidows: _avoidWidows, ...verovioLayout } = layout ?? {};
|
|
399
|
+
const { avoidWidows: _avoidWidows, minFitFactor: _minFitFactor, ...verovioLayout } = layout ?? {};
|
|
400
400
|
return {
|
|
401
401
|
...VEROVIO_LAYOUT_DEFAULTS,
|
|
402
402
|
...verovioLayout,
|
|
@@ -417,6 +417,13 @@ function fitZoomFactor(systemWidthsPx, engraveWidthPx) {
|
|
|
417
417
|
const factor = engraveWidthPx / widest;
|
|
418
418
|
return Number.isFinite(factor) && factor > 0 ? factor : 1;
|
|
419
419
|
}
|
|
420
|
+
var DEFAULT_MIN_FIT_FACTOR = 0.75;
|
|
421
|
+
function shouldFallbackToAutoBreaks(factor, breaks, minFitFactor) {
|
|
422
|
+
if (!Number.isFinite(factor)) return false;
|
|
423
|
+
if (!(minFitFactor > 0)) return false;
|
|
424
|
+
if (breaks !== "line" && breaks !== "encoded") return false;
|
|
425
|
+
return factor < minFitFactor;
|
|
426
|
+
}
|
|
420
427
|
function verovioZoomOptions(hostWidthPx, zoom) {
|
|
421
428
|
const z = Number.isFinite(zoom) && zoom > 0 ? zoom : 1;
|
|
422
429
|
const scale = Math.max(VEROVIO_MIN_SCALE, Math.min(VEROVIO_MAX_SCALE, VEROVIO_BASE_SCALE * z));
|
|
@@ -553,42 +560,61 @@ function createVerovioNotationPlayer(opts) {
|
|
|
553
560
|
}
|
|
554
561
|
}
|
|
555
562
|
}
|
|
563
|
+
function runWidowPass(toolkit, widthPx, zoom, xml, layoutOpts) {
|
|
564
|
+
const counts = systemMeasureCounts(svgHost);
|
|
565
|
+
if (counts.length >= 2 && counts[counts.length - 1] === 1) {
|
|
566
|
+
const totalMeasures = counts.reduce((a, b) => a + b, 0);
|
|
567
|
+
const breakPositions = balancedSystemBreaks(totalMeasures, counts.length);
|
|
568
|
+
if (breakPositions.length) {
|
|
569
|
+
const widowXml = injectSystemBreaks(xml, breakPositions);
|
|
570
|
+
const widowLayoutOpts = { ...layoutOpts, breaks: "line" };
|
|
571
|
+
toolkit.setOptions(verovioRenderOptions(widthPx, zoom, widowLayoutOpts));
|
|
572
|
+
const okWidow = !!toolkit.loadData(widowXml);
|
|
573
|
+
if (okWidow) {
|
|
574
|
+
renderAllPages(toolkit);
|
|
575
|
+
rebuildLayoutFromDom();
|
|
576
|
+
return { xml: widowXml, layoutOpts: widowLayoutOpts };
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return { xml, layoutOpts };
|
|
581
|
+
}
|
|
556
582
|
function engraveOnce(toolkit, widthPx, zoom) {
|
|
557
583
|
const layoutOpts = opts.verovioOptions;
|
|
584
|
+
const effectiveBreaks = layoutOpts?.breaks ?? VEROVIO_LAYOUT_DEFAULTS.breaks;
|
|
585
|
+
const minFitFactor = layoutOpts?.minFitFactor ?? DEFAULT_MIN_FIT_FACTOR;
|
|
586
|
+
const avoidWidows = layoutOpts?.avoidWidows !== false;
|
|
558
587
|
let xmlToRender = stampedXml;
|
|
559
588
|
let renderLayoutOpts = layoutOpts;
|
|
560
589
|
toolkit.setOptions(verovioRenderOptions(widthPx, zoom, renderLayoutOpts));
|
|
561
590
|
const ok = !!toolkit.loadData(xmlToRender);
|
|
562
591
|
if (ok) renderAllPages(toolkit);
|
|
563
592
|
rebuildLayoutFromDom();
|
|
564
|
-
if (ok) {
|
|
565
|
-
const
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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
|
+
if (ok && currentLayout) {
|
|
599
|
+
const systemWidthsPx = () => opts.measureSystemWidths ? opts.measureSystemWidths(svgHost) : currentLayout.systems.map((s) => s.w);
|
|
600
|
+
let factor = fitZoomFactor(systemWidthsPx(), widthPx);
|
|
601
|
+
if (shouldFallbackToAutoBreaks(factor, effectiveBreaks, minFitFactor)) {
|
|
602
|
+
const autoLayoutOpts = { ...layoutOpts, breaks: "auto" };
|
|
603
|
+
toolkit.setOptions(verovioRenderOptions(widthPx, zoom, autoLayoutOpts));
|
|
604
|
+
const okAuto = !!toolkit.loadData(stampedXml);
|
|
605
|
+
if (okAuto) {
|
|
606
|
+
renderAllPages(toolkit);
|
|
607
|
+
rebuildLayoutFromDom();
|
|
608
|
+
xmlToRender = stampedXml;
|
|
609
|
+
renderLayoutOpts = autoLayoutOpts;
|
|
610
|
+
if (avoidWidows) {
|
|
611
|
+
const widowed = runWidowPass(toolkit, widthPx, zoom, xmlToRender, renderLayoutOpts);
|
|
612
|
+
xmlToRender = widowed.xml;
|
|
613
|
+
renderLayoutOpts = widowed.layoutOpts;
|
|
583
614
|
}
|
|
615
|
+
factor = fitZoomFactor(systemWidthsPx(), widthPx);
|
|
584
616
|
}
|
|
585
617
|
}
|
|
586
|
-
}
|
|
587
|
-
if (ok && currentLayout) {
|
|
588
|
-
const factor = fitZoomFactor(
|
|
589
|
-
currentLayout.systems.map((s) => s.w),
|
|
590
|
-
widthPx
|
|
591
|
-
);
|
|
592
618
|
if (factor < 1) {
|
|
593
619
|
const effectiveZoom = zoom * factor;
|
|
594
620
|
toolkit.setOptions(verovioRenderOptions(widthPx, effectiveZoom, renderLayoutOpts));
|
|
@@ -710,6 +736,7 @@ function createVerovioNotationPlayer(opts) {
|
|
|
710
736
|
};
|
|
711
737
|
}
|
|
712
738
|
export {
|
|
739
|
+
DEFAULT_MIN_FIT_FACTOR,
|
|
713
740
|
MARKED_NOTE_CLASS,
|
|
714
741
|
MAX_ENGRAVE_WIDTH_VRV,
|
|
715
742
|
VEROVIO_BASE_SCALE,
|
|
@@ -719,6 +746,7 @@ export {
|
|
|
719
746
|
applyPrintObjectHiding,
|
|
720
747
|
createVerovioNotationPlayer,
|
|
721
748
|
fitZoomFactor,
|
|
749
|
+
shouldFallbackToAutoBreaks,
|
|
722
750
|
systemMeasureCounts,
|
|
723
751
|
verovioEngravedNotes,
|
|
724
752
|
verovioNotationLayout,
|