@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 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 doc) is a PLAYER option,
412
- * not a Verovio one — destructured out here and never forwarded to
413
- * `setOptions`, same "narrow the passthrough" spirit as this function only
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 avoidWidows = layoutOpts?.avoidWidows !== false;
566
- const effectiveBreaks = layoutOpts?.breaks ?? VEROVIO_LAYOUT_DEFAULTS.breaks;
567
- if (avoidWidows && effectiveBreaks === "auto") {
568
- const counts = systemMeasureCounts(svgHost);
569
- if (counts.length >= 2 && counts[counts.length - 1] === 1) {
570
- const totalMeasures = counts.reduce((a, b) => a + b, 0);
571
- const breakPositions = balancedSystemBreaks(totalMeasures, counts.length);
572
- if (breakPositions.length) {
573
- const widowXml = injectSystemBreaks(stampedXml, breakPositions);
574
- const widowLayoutOpts = { ...layoutOpts, breaks: "line" };
575
- toolkit.setOptions(verovioRenderOptions(widthPx, zoom, widowLayoutOpts));
576
- const okWidow = !!toolkit.loadData(widowXml);
577
- if (okWidow) {
578
- renderAllPages(toolkit);
579
- rebuildLayoutFromDom();
580
- xmlToRender = widowXml;
581
- renderLayoutOpts = widowLayoutOpts;
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,