tonus 0.1.8 → 0.5.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.
Files changed (164) hide show
  1. package/BIBLIOGRAPHY.md +132 -108
  2. package/CHANGELOG.md +598 -1
  3. package/LICENSE +133 -29
  4. package/README.md +106 -83
  5. package/dist/data/am.js +2666 -11196
  6. package/dist/data/ams.d.ts +5 -0
  7. package/dist/data/ams.js +122 -0
  8. package/dist/data/attestation.d.ts +19 -0
  9. package/dist/data/attestation.js +15716 -0
  10. package/dist/data/attestation.json +15711 -0
  11. package/dist/data/cadentiae.d.ts +43 -0
  12. package/dist/data/cadentiae.js +174 -0
  13. package/dist/data/cal.js +48 -0
  14. package/dist/data/census.d.ts +12 -0
  15. package/dist/data/census.js +36 -0
  16. package/dist/data/commune-office.d.ts +4 -0
  17. package/dist/data/commune-office.js +2371 -0
  18. package/dist/data/commune-office.json +2365 -0
  19. package/dist/data/commune.js +181 -11
  20. package/dist/data/corpus-overlap.d.ts +17 -0
  21. package/dist/data/corpus-overlap.js +299 -5
  22. package/dist/data/cot.d.ts +5 -0
  23. package/dist/data/cot.js +172 -0
  24. package/dist/data/cse.d.ts +5 -0
  25. package/dist/data/cse.js +122 -0
  26. package/dist/data/gabc-glyphs.d.ts +45 -0
  27. package/dist/data/gabc-glyphs.js +122 -0
  28. package/dist/data/gr.js +754 -6394
  29. package/dist/data/kyriale.js +116 -116
  30. package/dist/data/la.js +799 -13419
  31. package/dist/data/lh.js +113 -3473
  32. package/dist/data/lu.js +931 -17631
  33. package/dist/data/nocturnale-romanum.js +1659 -10411
  34. package/dist/data/office-ferial.d.ts +4 -0
  35. package/dist/data/office-ferial.js +396 -0
  36. package/dist/data/office-ferial.json +391 -0
  37. package/dist/data/office-monastic.d.ts +17 -1
  38. package/dist/data/office-monastic.js +1403 -466
  39. package/dist/data/office-psalms-monastic.d.ts +13 -1
  40. package/dist/data/office-psalms-monastic.js +9 -0
  41. package/dist/data/propers.js +1 -1
  42. package/dist/data/psalms.js +22919 -5
  43. package/dist/data/psm.d.ts +5 -0
  44. package/dist/data/psm.js +122 -0
  45. package/dist/data/seasonal-respbreve.d.ts +5 -0
  46. package/dist/data/seasonal-respbreve.js +41 -0
  47. package/dist/data/seasonal-respbreve.json +35 -0
  48. package/dist/data/smufl-glyphs.d.ts +17 -0
  49. package/dist/data/smufl-glyphs.js +1546 -0
  50. package/dist/data/smufl-glyphs.json +1530 -0
  51. package/dist/engines/cal/calendar.d.ts +3 -2
  52. package/dist/engines/cal/calendar.js +105 -29
  53. package/dist/engines/cal/data/eras.d.ts +35 -0
  54. package/dist/engines/cal/data/eras.js +128 -0
  55. package/dist/engines/cal/date.js +44 -0
  56. package/dist/engines/cal/types.d.ts +15 -3
  57. package/dist/engines/cal/types.js +5 -5
  58. package/dist/engines/census/census.d.ts +7 -0
  59. package/dist/engines/census/census.js +179 -0
  60. package/dist/engines/census/types.d.ts +55 -0
  61. package/dist/engines/census/types.js +8 -0
  62. package/dist/engines/chant/attest.d.ts +39 -0
  63. package/dist/engines/chant/attest.js +90 -0
  64. package/dist/engines/chant/chant.d.ts +16 -4
  65. package/dist/engines/chant/chant.js +220 -30
  66. package/dist/engines/chant/data/compline.js +2 -1
  67. package/dist/engines/chant/data/masses.d.ts +56 -4
  68. package/dist/engines/chant/data/masses.js +305 -80
  69. package/dist/engines/chant/data/prime.js +1 -1
  70. package/dist/engines/chant/hour.js +279 -58
  71. package/dist/engines/chant/ordinary.d.ts +2 -0
  72. package/dist/engines/chant/ordinary.js +336 -56
  73. package/dist/engines/chant/propers.js +55 -5
  74. package/dist/engines/chant/psalm.d.ts +4 -4
  75. package/dist/engines/chant/psalm.js +25 -11
  76. package/dist/engines/chant/syllabify.d.ts +1 -0
  77. package/dist/engines/chant/syllabify.js +90 -17
  78. package/dist/engines/chant/types.d.ts +115 -12
  79. package/dist/engines/chant/types.js +38 -3
  80. package/dist/engines/harmonia/api.js +4 -0
  81. package/dist/engines/harmonia/data/doctrines.js +3 -1
  82. package/dist/engines/harmonia/tabula.d.ts +3 -0
  83. package/dist/engines/harmonia/tabula.js +1 -0
  84. package/dist/engines/harmonia/voice.d.ts +4 -0
  85. package/dist/engines/harmonia/voice.js +8 -4
  86. package/dist/engines/imprint.js +14 -1
  87. package/dist/engines/planet/orbital.js +4 -4
  88. package/dist/engines/planet/planet.d.ts +10 -0
  89. package/dist/engines/planet/planet.js +30 -3
  90. package/dist/engines/planet/position.js +13 -10
  91. package/dist/engines/planet/types.d.ts +1 -0
  92. package/dist/engines/score/api.d.ts +2 -13
  93. package/dist/engines/score/api.js +21 -8
  94. package/dist/engines/score/articulation.js +2 -2
  95. package/dist/engines/score/cadence.d.ts +76 -0
  96. package/dist/engines/score/cadence.js +96 -0
  97. package/dist/engines/score/emitters/accidentals.d.ts +21 -0
  98. package/dist/engines/score/emitters/accidentals.js +88 -0
  99. package/dist/engines/score/emitters/atramentum.d.ts +107 -0
  100. package/dist/engines/score/emitters/atramentum.js +239 -0
  101. package/dist/engines/score/emitters/breaking.d.ts +62 -0
  102. package/dist/engines/score/emitters/breaking.js +80 -0
  103. package/dist/engines/score/emitters/moderna.d.ts +38 -0
  104. package/dist/engines/score/emitters/moderna.js +612 -0
  105. package/dist/engines/score/emitters/svg.d.ts +143 -0
  106. package/dist/engines/score/emitters/svg.js +1328 -0
  107. package/dist/engines/score/emitters/tracks.d.ts +104 -0
  108. package/dist/engines/score/emitters/tracks.js +728 -0
  109. package/dist/engines/score/infer.d.ts +3 -3
  110. package/dist/engines/score/infer.js +2 -2
  111. package/dist/engines/score/inscriptio.d.ts +69 -0
  112. package/dist/engines/score/inscriptio.js +138 -0
  113. package/dist/engines/score/ir.d.ts +2 -2
  114. package/dist/engines/score/ir.js +59 -12
  115. package/dist/engines/score/lyric.d.ts +23 -0
  116. package/dist/engines/score/lyric.js +234 -0
  117. package/dist/engines/score/meta.d.ts +2 -2
  118. package/dist/engines/score/modulation.d.ts +12 -0
  119. package/dist/engines/score/modulation.js +49 -0
  120. package/dist/engines/score/neume.js +35 -4
  121. package/dist/engines/score/parse.js +150 -9
  122. package/dist/engines/score/phrasing.js +4 -3
  123. package/dist/engines/score/prosody.d.ts +38 -0
  124. package/dist/engines/score/prosody.js +70 -6
  125. package/dist/engines/score/tabula.d.ts +37 -5
  126. package/dist/engines/score/tabula.js +18 -0
  127. package/dist/engines/score/types.d.ts +88 -1
  128. package/dist/engines/temper/api.d.ts +4 -1
  129. package/dist/engines/temper/api.js +28 -5
  130. package/dist/engines/temper/data/guido.js +6 -2
  131. package/dist/engines/temper/data/modes.d.ts +6 -0
  132. package/dist/engines/temper/data/modes.js +42 -0
  133. package/dist/engines/temper/data/tones.d.ts +1 -1
  134. package/dist/engines/temper/data/tones.js +20 -11
  135. package/dist/engines/temper/interval.js +4 -3
  136. package/dist/engines/temper/modality.d.ts +11 -2
  137. package/dist/engines/temper/modality.js +74 -2
  138. package/dist/engines/temper/modes.d.ts +1 -1
  139. package/dist/engines/temper/pitch.d.ts +1 -1
  140. package/dist/engines/temper/pitch.js +12 -2
  141. package/dist/engines/temper/scale.d.ts +53 -0
  142. package/dist/engines/temper/scale.js +107 -8
  143. package/dist/index.d.ts +26 -8
  144. package/dist/index.js +37 -4
  145. package/docs/api/calendar.md +279 -0
  146. package/docs/api/census.md +288 -0
  147. package/docs/api/chant.md +657 -0
  148. package/docs/api/heavens.md +346 -0
  149. package/docs/api/index.md +263 -0
  150. package/docs/api/score.md +873 -0
  151. package/docs/api/tuning.md +619 -0
  152. package/package.json +11 -5
  153. package/dist/data/office-matins-roman.d.ts +0 -19
  154. package/dist/data/office-matins-roman.js +0 -4383
  155. package/dist/data/office-psalms-roman.d.ts +0 -15
  156. package/dist/data/office-psalms-roman.js +0 -28
  157. package/dist/data/office-roman.d.ts +0 -19
  158. package/dist/data/office-roman.js +0 -13792
  159. package/dist/engines/chant/matutinum.d.ts +0 -33
  160. package/dist/engines/chant/matutinum.js +0 -81
  161. package/dist/engines/score/emitters/midi.d.ts +0 -65
  162. package/dist/engines/score/emitters/midi.js +0 -162
  163. package/dist/engines/score/emitters/musicxml.d.ts +0 -18
  164. package/dist/engines/score/emitters/musicxml.js +0 -166
@@ -0,0 +1,1328 @@
1
+ // ---------------------------------------------------------------------------
2
+ // engines/score/emitters/svg — square-note chant score as SVG
3
+ // ---------------------------------------------------------------------------
4
+ // Renders the score's tabula as a 4-line Gregorian staff with SMuFL glyphs
5
+ // (outlines baked from Bravura in smufl-glyphs.json). Fully self-contained: all
6
+ // notation is inline <path>; only lyric text uses a system font.
7
+ //
8
+ // SMuFL fonts are metrically standardized: 1 em = 4 staff spaces, so one staff
9
+ // space = upm/4 font units. Bravura's chant noteheads are drawn to fill ~0.8 of
10
+ // a space; Solesmes engraving leaves more air, so noteheads render at
11
+ // `noteScale` (default 0.8) of the SMuFL size while clefs and divisiones stay
12
+ // full-size. Staff positions are half-spaces from the bottom line (odd = lines,
13
+ // even = spaces); y = baseline − position × staffInterval.
14
+ //
15
+ // Figures follow Solesmes engraving: the pes stacks its two notes; the clivis
16
+ // is two abutting puncta with a left stem; the torculus three abutting puncta
17
+ // with junction stems; the porrectus uses the baked diagonal swash; descending
18
+ // inclinata cascade as diamonds. Stems overshoot the lower note slightly, as
19
+ // in the printed books.
20
+ import { decideBreak } from "./breaking.js";
21
+ import { trimRuns } from "../lyric.js";
22
+ import { GLYPHS, GLYPH_UPM } from "../../../data/smufl-glyphs.js";
23
+ import { computeAccidentals, } from "./accidentals.js";
24
+ import { GLYPH, SHAPE_GLYPH, DIVISIO_GLYPH, ligaturaDesc, } from "../../../data/gabc-glyphs.js";
25
+ import { buildChironomia, buildProsodia, buildTonarium, trackBands, } from "./tracks.js";
26
+ function resolveFont(slot, fallback) {
27
+ if (!slot)
28
+ return { family: fallback, weight: null, scale: 1, embed: null };
29
+ if (typeof slot === "string")
30
+ return { family: slot, weight: null, scale: 1, embed: null };
31
+ return {
32
+ family: slot.family,
33
+ weight: slot.weight ?? null,
34
+ scale: slot.scale ?? 1,
35
+ embed: slot.embed ?? null,
36
+ };
37
+ }
38
+ const EMBED_MIME = {
39
+ opentype: "font/otf", truetype: "font/ttf", woff: "font/woff", woff2: "font/woff2",
40
+ };
41
+ /** One @font-face rule per embedded (family, weight); deduped across slots. */
42
+ export function fontFaceCss(fonts) {
43
+ const seen = new Set();
44
+ const rules = [];
45
+ for (const f of fonts) {
46
+ if (!f.embed)
47
+ continue;
48
+ const key = `${f.family}::${f.weight ?? ""}`;
49
+ if (seen.has(key))
50
+ continue;
51
+ seen.add(key);
52
+ const format = f.embed.format ?? "opentype";
53
+ rules.push(`@font-face{font-family:${JSON.stringify(f.family)};` +
54
+ (f.weight != null ? `font-weight:${f.weight};` : "") +
55
+ `src:url(data:${EMBED_MIME[format] ?? "font/otf"};base64,${f.embed.base64}) ` +
56
+ `format("${format}")}`);
57
+ }
58
+ return rules.length ? `<defs><style>${rules.join("")}</style></defs>` : "";
59
+ }
60
+ /** font-family (+ optional font-weight) attributes for a resolved slot. */
61
+ function fontAttrs(f) {
62
+ return `font-family="${esc(f.family)}"` + (f.weight != null ? ` font-weight="${f.weight}"` : "");
63
+ }
64
+ /** A themeable colour: the caller's value, reachable from CSS by name. */
65
+ const cssVar = (name, value) => `var(--tonus-${name}, ${value})`;
66
+ function resolveOpts(o) {
67
+ const staffHeight = o.staffHeight ?? 40;
68
+ // 4 lines span 3 gaps; each gap = 2 staffIntervals ⇒ staffInterval = h/6.
69
+ const staffInterval = staffHeight / 6;
70
+ const glyphScale = (staffInterval * 2) / (GLYPH_UPM / 4);
71
+ const noteScale = o.noteScale ?? 0.7;
72
+ const punctum = GLYPHS[GLYPH.punctum];
73
+ const noteheadH = punctum
74
+ ? (punctum.bbox[3] - punctum.bbox[1]) * glyphScale * noteScale
75
+ : staffInterval * 1.3;
76
+ const rawNote = o.noteColor ?? "#111";
77
+ return {
78
+ staffInterval,
79
+ // A FIXED margin, not one derived from the staff. The margin belongs to the
80
+ // page, not to the notation: a book does not widen its margins when the
81
+ // staff grows, and scaling it here made a large chant get LESS usable width
82
+ // than a small one (93% of a 900px canvas at small against 89% at large) —
83
+ // exactly backwards, since a bigger chant needs more room, not less.
84
+ padding: o.padding ?? 14,
85
+ // Staff lines match the note colour by default (they carry their own
86
+ // option for later, but for now everything is one ink).
87
+ //
88
+ // Each colour reaches the SVG as a CSS custom property with the resolved
89
+ // value as its FALLBACK — `var(--tonus-note, #111)`. An inline fill beats
90
+ // any stylesheet rule, so writing the literal made the 17 semantic classes
91
+ // this emitter already carries (note, lyric, dropcap, custos, episema…)
92
+ // unstylable from the host page. With the var, a page can retheme a drawn
93
+ // chant by setting three properties, and a file opened on its own still
94
+ // shows the ink it was rendered with.
95
+ // Wrap the RAW value, not the already-wrapped one: defaulting the staff
96
+ // line to `noteColor` after wrapping nests the vars
97
+ // (`var(--tonus-staff-line, var(--tonus-note, #111))`), which works but
98
+ // reads as a mistake and ties the two properties together in CSS.
99
+ staffLineColor: cssVar("staff-line", o.staffLineColor ?? rawNote),
100
+ noteColor: cssVar("note", rawNote),
101
+ fontFamily: o.fontFamily ?? HOUSE_SERIF,
102
+ fonts: {
103
+ dropcap: resolveFont(o.fonts?.dropcap, o.fontFamily ?? HOUSE_SERIF),
104
+ title: resolveFont(o.fonts?.title, o.fontFamily ?? HOUSE_SERIF),
105
+ annotation: resolveFont(o.fonts?.annotation, o.fontFamily ?? HOUSE_SERIF),
106
+ lyric: resolveFont(o.fonts?.lyric, o.fontFamily ?? HOUSE_SERIF),
107
+ },
108
+ glyphScale,
109
+ noteScale,
110
+ lineWeight: Math.max(0.5, staffInterval * 0.11),
111
+ stemWeight: Math.max(0.6, staffInterval * 0.14),
112
+ noteheadH,
113
+ // Air between figures within a syllable. 0.62 until 2026-08-04, which set
114
+ // the square notation tighter than the books do — the neumes read as one
115
+ // mass rather than as separable figures. Scales with the staff, so the
116
+ // relationship holds at any size.
117
+ interGlyph: staffInterval * 0.86,
118
+ // Syllable and word spacing. Both widened 2026-08-04 (1.85 / 1.15): the
119
+ // square notation read as one dense mass, and the neumes need enough air
120
+ // between syllables for a reader to see where one ends. Scales with the
121
+ // staff, so the relationship holds at any size.
122
+ interSyllable: staffInterval * 2.35,
123
+ interWord: staffInterval * 1.55,
124
+ lyricSize: staffInterval * 2.2,
125
+ width: o.width ?? null,
126
+ // Likewise the air between systems: flat 24px held the system pitch at
127
+ // 135px whether the staff was 30 or 56, so a large chant crowded and a
128
+ // small one sprawled.
129
+ systemGap: o.systemGap ?? staffHeight * 0.6,
130
+ custos: o.custos ?? (o.width != null),
131
+ title: o.title ?? null,
132
+ // "auto" is resolved in toSvg where the chant meta is in hand.
133
+ rubric: typeof o.rubric === "string" ? o.rubric : null,
134
+ dropcap: o.dropcap ?? false,
135
+ rubricaColor: cssVar("rubrica", o.rubricaColor ?? "#9E2B25"),
136
+ };
137
+ }
138
+ const HOUSE_SERIF = "'Crimson Pro', 'Crimson Text', 'EB Garamond', Garamond, Georgia, serif";
139
+ // The books abbreviate the genus in the margin mark (Intr., Grad., Offert.);
140
+ // a genus not in the table prints as-is with its period.
141
+ const GENUS_ABBREV = {
142
+ Introitus: "Intr.", Graduale: "Grad.", Offertorium: "Offert.",
143
+ Communio: "Comm.", Tractus: "Tract.", Alleluia: "All.",
144
+ Antiphona: "Ant.", Responsorium: "Resp.", "Responsorium Breve": "Resp. br.",
145
+ Hymnus: "Hymn.", Sequentia: "Seq.", Canticum: "Cant.", Psalmus: "Ps.",
146
+ };
147
+ /** The `annotation: "auto"` mark, derived from chant meta and stacked as the
148
+ * books set it ("Intr." over "8.") — shared by both species. */
149
+ export function autoRubricLines(chant) {
150
+ const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1);
151
+ // AN ORDINARY CHANT SHOWS ITS MODE ALONE. `genus` for these is "Ordinarium",
152
+ // which reads identically over every Kyrie, Gloria, Sanctus and Agnus — a
153
+ // mark that never changes tells a reader nothing. Naming the piece instead
154
+ // ("Agnus", "Kyrie") only repeats the title set directly above the score,
155
+ // and this slot is the CATEGORY's, not the piece's. So the line is dropped
156
+ // and the mode stands on its own.
157
+ const genus = chant.ordinarium
158
+ ? null
159
+ : chant.genus && capitalize(GENUS_ABBREV[chant.genus] ?? `${chant.genus}.`);
160
+ return [
161
+ genus,
162
+ chant.mode && `${chant.mode}.`,
163
+ ].filter(Boolean);
164
+ }
165
+ // ── the initial's own width ────────────────────────────────────────────────
166
+ // A DROPCAP IS INDENTED BY WHAT IT ACTUALLY OCCUPIES. The indent used one
167
+ // factor (0.72) for every letter, which is not a measurement of anything: in
168
+ // Junicode the capitals run from I at 0.344 to W at 0.971, so a narrow letter
169
+ // reserved a column of white it never filled and a wide one ran out under the
170
+ // staff. Measured in a browser at 100px, both faces, every capital.
171
+ //
172
+ // Keyed by FACE, because the caller chooses the dropcap's family and a
173
+ // blackletter is a different set of widths: Jacquard's capitals sit between
174
+ // 0.535 and 0.698, near enough uniform, where Junicode's spread is threefold.
175
+ // An unknown family falls back to the widest plausible letter rather than an
176
+ // average — reserving too much leaves white, reserving too little collides
177
+ // with the music, and only one of those is a defect.
178
+ const CAP_ADVANCE = {
179
+ junicode: {
180
+ A: 0.684, B: 0.613, C: 0.656, D: 0.748, E: 0.606, F: 0.563, G: 0.688,
181
+ H: 0.803, I: 0.344, J: 0.350, K: 0.655, L: 0.659, M: 0.902, N: 0.731,
182
+ O: 0.711, P: 0.571, Q: 0.716, R: 0.689, S: 0.509, T: 0.645, U: 0.731,
183
+ V: 0.628, W: 0.971, X: 0.649, Y: 0.621, Z: 0.606,
184
+ },
185
+ // Crimson / Garamond / Georgia and the generic serif, which is what the
186
+ // library defaults to. Measured on Georgia, the widest of them.
187
+ serif: {
188
+ A: 0.671, B: 0.639, C: 0.66, D: 0.751, E: 0.613, F: 0.573, G: 0.71,
189
+ H: 0.828, I: 0.39, J: 0.418, K: 0.71, L: 0.611, M: 0.927, N: 0.775,
190
+ O: 0.759, P: 0.596, Q: 0.759, R: 0.679, S: 0.561, T: 0.618, U: 0.777,
191
+ V: 0.671, W: 0.976, X: 0.66, Y: 0.62, Z: 0.591,
192
+ },
193
+ jacquard: {
194
+ A: 0.651, B: 0.651, C: 0.558, D: 0.558, E: 0.558, F: 0.651, G: 0.605,
195
+ H: 0.651, I: 0.535, J: 0.535, K: 0.698, L: 0.581, M: 0.698, N: 0.605,
196
+ O: 0.628, P: 0.581, Q: 0.674, R: 0.628, S: 0.535, T: 0.651, U: 0.605,
197
+ V: 0.581, W: 0.698, X: 0.651, Y: 0.605, Z: 0.581,
198
+ },
199
+ };
200
+ const CAP_ADVANCE_FALLBACK = 0.9;
201
+ /** How far a capital rises above its baseline, as a fraction of font size.
202
+ * A cap height, near enough, and near enough equal across the faces this
203
+ * draws in — it decides where the margin stack clears, not where ink lands. */
204
+ const CAP_RISE = 0.72;
205
+ /** The initial's advance, as a fraction of its font size.
206
+ *
207
+ * The DEFAULT row is the one that matters most: the library's own dropcap
208
+ * face is the Crimson stack, not the site's Junicode, so a table that knew
209
+ * only the two named faces sent every default render to the fallback. Old
210
+ * serifs vary little at the capitals (Georgia, Garamond and the generic
211
+ * serif agree to about a hundredth on M and W), so one row serves the
212
+ * stack. */
213
+ function capAdvance(letter, family) {
214
+ const face = family.toLowerCase();
215
+ const ch = letter.toUpperCase();
216
+ for (const key of Object.keys(CAP_ADVANCE)) {
217
+ if (key !== "serif" && face.includes(key)) {
218
+ return CAP_ADVANCE[key][ch] ?? CAP_ADVANCE_FALLBACK;
219
+ }
220
+ }
221
+ return CAP_ADVANCE.serif[ch] ?? CAP_ADVANCE_FALLBACK;
222
+ }
223
+ /** Where a custos sits: AT THE LINE'S END, tucked inside the staff's edge.
224
+ *
225
+ * The three break paths each arrived at their own `x` — after a divisio,
226
+ * after a forced break, at the width test — and each drew the custos there,
227
+ * so it landed anywhere from 16 to 96 units inside the staff's right edge on
228
+ * one page. The books put it in one place: at the end of the line it belongs
229
+ * to, hard against the margin, because that is what tells the eye it is the
230
+ * line ENDING rather than a note in it.
231
+ *
232
+ * The staff's own right edge is `max(x, prevLyricRight) + padding` — the same
233
+ * value each path pushes into systemMaxX a moment later — so the custos is
234
+ * set from that rather than from wherever the cursor happens to be. */
235
+ function custosX(x, prevLyricRight, r) {
236
+ // The staff line is drawn to `systemMaxX - padding`, and systemMaxX is
237
+ // pushed as `max(x, prevLyricRight) + padding` — so the edge IS that max.
238
+ //
239
+ // The custos ENDS at that edge rather than starting a fixed way inside it:
240
+ // it is the last thing on the line and should meet the line's end, which is
241
+ // where the books put it. All six cuts share one width (60 units), so the
242
+ // inked width is the same whichever is drawn.
243
+ const edge = Math.max(x, prevLyricRight);
244
+ const g = GLYPHS[GLYPH.custosUp[0]];
245
+ const inked = g ? (g.bbox[2] - g.bbox[0]) * r.glyphScale * r.noteScale * CUSTOS_FACTOR : 0;
246
+ return edge - inked;
247
+ }
248
+ // A tenth over a notehead's own scale: the sign is small by design, and this
249
+ // is the size it reads at without competing with the notes it guides.
250
+ const CUSTOS_FACTOR = 0.85 * 1.1;
251
+ /** The custos for a pitch: which of the six cuts.
252
+ *
253
+ * EA04-EA09 are COMPLETE signs — a skinny neume, head and stroke in one
254
+ * closed shape, 60 units wide. The narrowness is the sign, not a missing
255
+ * half: it is the mark the books set at the right of the staff.
256
+ *
257
+ * The stroke runs AWAY from the staff, so a pitch below the middle takes a
258
+ * stem-up cut and one above takes stem-down, and the three lengths each way
259
+ * reach back toward the staff as the pitch gets further from it.
260
+ *
261
+ * `staffPosition` counts upward from the bottom line, so 4 is the middle. */
262
+ function custosGlyph(staffPosition) {
263
+ const from = staffPosition - 4;
264
+ // THE NAMES SAY THE MAPPING, and the heights agree with them:
265
+ // PosMiddle 416 the shortest stem — the pitch is already at the middle
266
+ // PosLow 541
267
+ // PosLowest 666 the longest — furthest out, most stem to draw
268
+ // So the stem grows with the DISTANCE from the middle, and the arrays run
269
+ // [Lowest, Low, Middle] with rung 0 at the far edge.
270
+ // custosUp runs [Lowest, Low, Middle] and custosDown [Middle, High,
271
+ // Highest] — the two arrays read outward from opposite ends, so the index
272
+ // is mirrored between them.
273
+ const rung = Math.min(2, Math.floor(Math.abs(from) / 2));
274
+ return from <= 0 ? GLYPH.custosUp[2 - rung] : GLYPH.custosDown[rung];
275
+ }
276
+ const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
277
+ .replace(/"/g, "&quot;");
278
+ /**
279
+ * A syllable's lyric as SVG text content: styled runs become <tspan>s
280
+ * (italic, bold, small caps, rubric color); a plain lyric stays a bare
281
+ * escaped string. Shared by both species — callers pass runs whose
282
+ * concatenation equals the plain text they measure with.
283
+ */
284
+ export function lyricMarkup(runs, plain, rubricaColor) {
285
+ if (!runs || runs.length === 0)
286
+ return esc(plain);
287
+ return runs
288
+ .map((run) => {
289
+ const attrs = [];
290
+ if (run.italic)
291
+ attrs.push('font-style="italic"');
292
+ if (run.bold)
293
+ attrs.push('font-weight="700"');
294
+ if (run.smallCaps)
295
+ attrs.push('font-variant="small-caps"');
296
+ if (run.rubric)
297
+ attrs.push(`fill="${esc(rubricaColor)}"`);
298
+ return attrs.length
299
+ ? `<tspan ${attrs.join(" ")}>${esc(run.text)}</tspan>`
300
+ : esc(run.text);
301
+ })
302
+ .join("");
303
+ }
304
+ function makeLayout(r, trackExtra = 0) {
305
+ const topY = r.staffInterval * 5; // room for high notes + episema above
306
+ // Lyric baseline sits 28px below the bottom line at the default staffHeight
307
+ // — MATCHED to moderna's staff→lyric gap (ruled 2026-07-29: one gap across
308
+ // the duae species), scaling with the staff. The staff spans SIX intervals,
309
+ // so the gap is (10.2 − 6) of them; it was 9.15 (a 21px gap) until
310
+ // 2026-08-04, which crowded the lyrics against notes hanging below the
311
+ // staff. Those notes share this room, as they do in the books.
312
+ const lyricY = topY + r.staffInterval * 10.2;
313
+ return {
314
+ topY,
315
+ bottomY: topY + r.staffInterval * 6,
316
+ baselineY: topY + r.staffInterval * 7,
317
+ lyricY,
318
+ systemY: 0,
319
+ // A requested track band widens every system by its reserved room.
320
+ systemHeight: Math.ceil(lyricY + r.lyricSize * 0.6) + trackExtra + r.systemGap,
321
+ };
322
+ }
323
+ // The y for a staff position, offset into the current system.
324
+ function yFor(pos, L, r) {
325
+ return L.systemY + L.baselineY - pos * r.staffInterval;
326
+ }
327
+ // The y for a staff position in a SPECIFIC system — used by the post-passes
328
+ // (episema, rhythmic signs) that run after layout, when notes may live in
329
+ // different systems than the current L.systemY.
330
+ function yAt(pos, systemY, L, r) {
331
+ return systemY + L.baselineY - pos * r.staffInterval;
332
+ }
333
+ // Place one glyph with its origin at (x, y). `factor` scales relative to the
334
+ // SMuFL nominal (noteheads use r.noteScale; clefs/divisiones use 1). dyFont
335
+ // shifts the glyph in font units (y-up) before the flip — used to re-register
336
+ // base-registered components.
337
+ function placeGlyph(code, x, y, r, cls, data = "", factor = 1, dyFont = 0) {
338
+ const g = GLYPHS[code];
339
+ if (!g)
340
+ return null;
341
+ const s = r.glyphScale * factor;
342
+ const yy = y - dyFont * s;
343
+ const svg = `<g class="${cls}"${data} transform="translate(${x.toFixed(2)} ${yy.toFixed(2)}) scale(${s.toFixed(5)} ${(-s).toFixed(5)})">` +
344
+ `<path d="${g.path}" fill="${r.noteColor}"/></g>`;
345
+ return {
346
+ svg,
347
+ advance: g.advance * s,
348
+ inkLeft: x + g.bbox[0] * s,
349
+ inkRight: x + g.bbox[2] * s,
350
+ };
351
+ }
352
+ export function toSvg(rows, chant, options = {}) {
353
+ const r = resolveOpts(options);
354
+ // Track scale: chiron-14's constants are px at the default staffHeight 40
355
+ // (staffInterval 40/6); other staff sizes scale the whole band with them.
356
+ const trackScale = r.staffInterval / (40 / 6);
357
+ const bands = trackBands(options.tracks, trackScale);
358
+ const L = makeLayout(r, bands.extra);
359
+ // ── Front matter ── Title, rubric annotation, and dropcap sit in a header
360
+ // band above the first system, set as the Solesmes books open a piece: the
361
+ // TITLE centered over the score ("Dominica Prima Adventus."), the
362
+ // genus/mode mark at the left margin over the dropcap ("Introitus. 8.",
363
+ // upright). Everything below offsets down by the band's height. The text
364
+ // is emitted at final assembly, when the score's width is known (the title
365
+ // centers on it); here we only reserve the band.
366
+ const autoLines = options.annotation === "auto" ? autoRubricLines(chant) : [];
367
+ // The mark STACKS as the books set it — "Offert." over "2." — one line per
368
+ // element; an explicit `rubric` string stays a single line.
369
+ const rubricLines = r.rubric ? [r.rubric] : autoLines;
370
+ const markSize = r.lyricSize * 1.05;
371
+ const markLineH = markSize * 0.98; // tight, as the books stack Intr. over 8.
372
+ // The stack's full height in rows — genus over mode. In the margin it is
373
+ // bottom-aligned to this, so a lone mode keeps the mode's row rather than
374
+ // rising into the genus's.
375
+ const MARK_ROWS = 2;
376
+ let headerY = 0;
377
+ let titleBaseline = 0;
378
+ let rubricTop = 0;
379
+ if (r.title) {
380
+ const size = r.lyricSize * 1.5;
381
+ titleBaseline = size;
382
+ headerY += size * 1.4;
383
+ }
384
+ if (rubricLines.length > 0 && !r.dropcap) {
385
+ // No cap → no margin column; the stack takes a header band of its own.
386
+ rubricTop = (r.title ? headerY : 0) + markSize * 1.1;
387
+ headerY = rubricTop + markLineH * (rubricLines.length - 1) + markSize * 0.5;
388
+ }
389
+ // Push all systems below the header band.
390
+ L.systemY = headerY;
391
+ const body = []; // glyphs and stems
392
+ const behind = []; // ledger lines (render under glyphs)
393
+ const lyrics = [];
394
+ // Display form of a row's lyric: trimmed styled runs when markup rides,
395
+ // else the hyphen-trimmed plain string. One derivation for measuring and
396
+ // drawing, so the two can never disagree.
397
+ const displayLyric = (row) => {
398
+ if (row.runs) {
399
+ const runs = trimRuns(row.runs);
400
+ return { text: runs.map((s) => s.text).join(""), runs };
401
+ }
402
+ return { text: row.lyric.replace(/^-+/, "").replace(/-+$/, "").trim() };
403
+ };
404
+ const placements = [];
405
+ // Dropcap column — the book's illuminated capital owns the left margin of
406
+ // the FIRST system only: its staff, clef, and lyric all start past the cap;
407
+ // later systems return to the full margin (Solesmes practice).
408
+ const capInitial = r.dropcap
409
+ ? (rows.find((row) => row.lyric.trim())?.lyric.trim().charAt(0) ?? "")
410
+ : "";
411
+ // Sized to span staff + lyric (the book initial), sitting close to the staff.
412
+ // 9.5, not 10: the initial spans staff + lyric, and a twentieth off it
413
+ // gives the margin mark above room without shrinking the letter's weight.
414
+ const capSize = r.staffInterval * 9.5;
415
+ // The letter's OWN advance, plus a hair of air before the staff begins.
416
+ const capIndent = capInitial
417
+ ? capSize * r.fonts.dropcap.scale
418
+ * capAdvance(capInitial, r.fonts.dropcap.family)
419
+ + r.staffInterval * 0.45
420
+ : 0;
421
+ let x = r.padding + capIndent;
422
+ // Multi-system layout state. Everything is emitted with the CURRENT system's Y
423
+ // baked in (via yFor + L.systemY); we also record where each system starts so
424
+ // the staff lines can be drawn per system at the end.
425
+ let system = 0;
426
+ const systemMaxX = []; // rightmost x reached in each finished system
427
+ // Intonation channel: precompute each row's accidental/cents mark once (the
428
+ // repeat-suppression and heji guard live in the engine), keyed by identity.
429
+ const accMode = options.accidentals ?? "standard";
430
+ // Square notation writes its own accidentals: b rotundum / b quadratum /
431
+ // croix — the medieval glyph set, not the modern transcription's ♭ ♮ ♯.
432
+ const marks = computeAccidentals(rows, accMode, options.centsBaseline ?? "pythagorean", "medieval");
433
+ const markByRow = new Map();
434
+ rows.forEach((row, i) => { const m = marks[i]; if (m)
435
+ markByRow.set(row, m); });
436
+ const dataAttrs = (row) => ` data-note-index="${row.phraseIndex}.${row.syllableIndex}.${row.neumeGroup}.${row.neumeIndex}"` +
437
+ ` data-staff="${row.staffLetter}"`;
438
+ // Vertical stem joining two pitches at a notehead edge. Runs from the upper
439
+ // pitch down past the lower one by a slight overshoot, as in the books.
440
+ const stem = (edgeX, posA, posB) => {
441
+ const y0 = yFor(Math.max(posA, posB), L, r);
442
+ const y1 = yFor(Math.min(posA, posB), L, r) + r.noteheadH * 0.45;
443
+ const w = r.stemWeight;
444
+ return `<rect class="stem" x="${(edgeX - w).toFixed(2)}" y="${y0.toFixed(2)}" ` +
445
+ `width="${w.toFixed(2)}" height="${(y1 - y0).toFixed(2)}" fill="${r.noteColor}"/>`;
446
+ };
447
+ // Short ledger lines behind a notehead outside the staff.
448
+ const ledger = (pos, inkLeft, inkRight) => {
449
+ const pad = (inkRight - inkLeft) * 0.25;
450
+ const emit = (lp) => {
451
+ const ly = yFor(lp, L, r);
452
+ behind.push(`<line class="ledger" x1="${(inkLeft - pad).toFixed(2)}" y1="${ly.toFixed(2)}" ` +
453
+ `x2="${(inkRight + pad).toFixed(2)}" y2="${ly.toFixed(2)}" ` +
454
+ `stroke="${r.staffLineColor}" stroke-width="${r.lineWeight.toFixed(2)}"/>`);
455
+ };
456
+ for (let lp = -1; lp >= pos; lp -= 2)
457
+ emit(lp);
458
+ for (let lp = 9; lp <= pos; lp += 2)
459
+ emit(lp);
460
+ };
461
+ // Place a notehead glyph for a row at x; returns the placement.
462
+ const placeNote = (row, atX, code, dyFont = 0) => {
463
+ const glyphCode = code ?? SHAPE_GLYPH[row.shape] ?? GLYPH.punctum;
464
+ const y = yFor(row.staffPosition, L, r);
465
+ const sc = row.liquescent ? r.noteScale * 0.66 : r.noteScale;
466
+ const p = placeGlyph(glyphCode, atX, y, r, "note", dataAttrs(row), sc, dyFont);
467
+ if (!p)
468
+ return null;
469
+ ledger(row.staffPosition, p.inkLeft, p.inkRight);
470
+ body.push(p.svg);
471
+ placements.push({ row, inkLeft: p.inkLeft, inkRight: p.inkRight, x: atX, y, system, systemY: L.systemY });
472
+ return p;
473
+ };
474
+ // The note's intonation mark before/above it; returns the advance consumed.
475
+ // A glyph (standard accidental or HEJI comma) precedes the head; a cents label
476
+ // floats above it (and consumes no horizontal advance).
477
+ const placeAccidental = (row, atX) => {
478
+ const mark = markByRow.get(row);
479
+ if (!mark)
480
+ return 0;
481
+ if (mark.kind === "cents") {
482
+ const y = yFor(row.staffPosition, L, r) - r.noteheadH * 0.9;
483
+ body.push(`<text class="cents" x="${atX.toFixed(2)}" y="${y.toFixed(2)}" ` +
484
+ `font-family="${esc(r.fontFamily)}" font-size="${(r.lyricSize * 0.5).toFixed(1)}" ` +
485
+ `fill="${r.noteColor}">${esc(mark.label ?? "")}</text>`);
486
+ return 0;
487
+ }
488
+ const p = placeGlyph(mark.glyph, atX, yFor(row.staffPosition, L, r), r, "accidental", "", r.noteScale * 0.62);
489
+ if (!p)
490
+ return 0;
491
+ body.push(p.svg);
492
+ return p.advance + r.interGlyph * 0.6;
493
+ };
494
+ // ── Figure renderers ── each returns the new x cursor.
495
+ const renderPes = (lo, hi, atX) => {
496
+ let cx = atX;
497
+ if (lo.shape !== "punctum") {
498
+ // Quilisma/special lower note: keep its glyph, stack a punctum above
499
+ // sharing the right column, joined by a stem.
500
+ const lower = placeNote(lo, cx);
501
+ if (!lower)
502
+ return cx;
503
+ const upWidth = (GLYPHS[GLYPH.punctum]?.advance ?? 0) * r.glyphScale * r.noteScale;
504
+ const upX = Math.max(cx, lower.inkRight - upWidth);
505
+ const upper = placeNote(hi, upX); /* stacked, stemless (Solesmes) */
506
+ return Math.max(lower.inkRight, upper?.inkRight ?? 0);
507
+ }
508
+ // Authentic stacked pes: base-registered components re-centered on pitch.
509
+ const lower = placeNote(lo, cx, GLYPH.podatusLower, -82);
510
+ if (!lower)
511
+ return cx;
512
+ const upper = placeNote(hi, cx + lower.advance, GLYPH.podatusUpper, -96);
513
+ if (hi.staffPosition - lo.staffPosition > 1) {
514
+ body.push(stem(lower.inkRight, lo.staffPosition, hi.staffPosition));
515
+ }
516
+ return Math.max(lower.inkRight, upper?.inkRight ?? 0);
517
+ };
518
+ // Clivis: a left stem, then two abutting square notes descending.
519
+ const renderClivis = (hi, lo, atX) => {
520
+ let cx = atX;
521
+ body.push(stem(cx + r.stemWeight, hi.staffPosition, lo.staffPosition));
522
+ const first = placeNote(hi, cx);
523
+ if (!first)
524
+ return cx;
525
+ const second = placeNote(lo, first.inkRight);
526
+ return second?.inkRight ?? first.inkRight;
527
+ };
528
+ const renderFallback = (figure, atX) => {
529
+ let cx = atX;
530
+ let prev = null;
531
+ const inclinata = figure.every((f, i) => i === 0 || f.shape === "inclinatum");
532
+ for (let i = 0; i < figure.length; i++) {
533
+ const row = figure[i];
534
+ if (prev && prev.pos === row.staffPosition)
535
+ cx += r.staffInterval * 0.55; /* strophae breathe (Solesmes) */
536
+ const p = placeNote(row, cx);
537
+ if (!p)
538
+ continue;
539
+ if (prev && !inclinata && Math.abs(prev.pos - row.staffPosition) > 1) {
540
+ body.push(stem(p.inkLeft + r.stemWeight, prev.pos, row.staffPosition));
541
+ }
542
+ // Inclinata cascade uses wider, interval-scaled steps (exsurge rule);
543
+ // square notes abut.
544
+ const step = inclinata && i > 0
545
+ ? p.advance * Math.max(1.1, Math.abs(prev.pos - row.staffPosition) * (2 / 3))
546
+ : p.advance;
547
+ prev = { pos: row.staffPosition, inkRight: p.inkRight };
548
+ cx += step;
549
+ }
550
+ return prev?.inkRight ?? cx;
551
+ };
552
+ /**
553
+ * Place a figure, report where it ended, and leave nothing behind.
554
+ *
555
+ * PLACEMENT IS THE MEASUREMENT. The break test used to estimate the coming
556
+ * phrase from a note count times a nominal advance, and that estimate was
557
+ * wrong in both directions — too small and figures spilled off the line, too
558
+ * large and the break came early and the line sat 60-78% full. Four separate
559
+ * attempts at a better estimate failed the same way, because the estimate is
560
+ * a second code path that has to agree with the drawing code and cannot.
561
+ *
562
+ * Exsurge answers this by placing the element and asking whether it fit
563
+ * (`positionNotationElement`). This is the same move in tonus's shape: the
564
+ * emitter draws into arrays, so a trial run records their lengths, calls the
565
+ * real renderFigure, reads the resulting x, and truncates them back. What
566
+ * the drawing code would do IS what the measurement reports, because it is
567
+ * the drawing code.
568
+ *
569
+ * EVERY ARRAY THE DRAWING TOUCHES HAS TO ROLL BACK. `behind` was added later
570
+ * for ledger lines and was not on this list, so every trial run left its
571
+ * ledgers in place: a note above the staff drew three of them, at the two
572
+ * positions the measurement tried and the one it settled on, and the strays
573
+ * landed wherever those attempts happened to fall — including left of the
574
+ * clef, at x 4.5, where the staff has not started yet.
575
+ */
576
+ const measureFigure = (figure, atX) => {
577
+ const bodyMark = body.length;
578
+ const behindMark = behind.length;
579
+ const placeMark = placements.length;
580
+ const endX = renderFigure(figure, atX);
581
+ body.length = bodyMark;
582
+ behind.length = behindMark;
583
+ placements.length = placeMark;
584
+ return endX;
585
+ };
586
+ const renderFigure = (figure, atXIn) => {
587
+ // Solesmes practice: an accidental inflecting ANY note of a ligature is
588
+ // printed BEFORE the whole figure, at the inflected note's staff position —
589
+ // never interleaved mid-ligature. (Placing only the first note's mark
590
+ // silently dropped a flat on the upper note of a pes.)
591
+ let atX = atXIn;
592
+ for (const row of figure)
593
+ atX += placeAccidental(row, atX);
594
+ if (figure.length === 1) {
595
+ const cx = atX;
596
+ const p = placeNote(figure[0], cx);
597
+ return p?.inkRight ?? cx;
598
+ }
599
+ const dirs = figure.slice(1).map((f, i) => Math.sign(f.staffPosition - figure[i].staffPosition));
600
+ if (figure.length === 2 && dirs[0] === 1) {
601
+ return renderPes(figure[0], figure[1], atX);
602
+ }
603
+ if (figure.length === 2 && dirs[0] === -1) {
604
+ return renderClivis(figure[0], figure[1], atX);
605
+ }
606
+ if (figure.length === 3 && dirs[0] === 1 && dirs[1] === -1) {
607
+ // Torculus: three abutting notes with stems at both junctions.
608
+ const cx = atX;
609
+ const first = placeNote(figure[0], cx);
610
+ if (!first)
611
+ return cx;
612
+ body.push(stem(first.inkRight + r.stemWeight, figure[0].staffPosition, figure[1].staffPosition));
613
+ const second = placeNote(figure[1], first.inkRight);
614
+ if (!second)
615
+ return first.inkRight;
616
+ body.push(stem(second.inkRight + r.stemWeight, figure[1].staffPosition, figure[2].staffPosition));
617
+ const third = placeNote(figure[2], second.inkRight);
618
+ return third?.inkRight ?? second.inkRight;
619
+ }
620
+ if (figure.length === 3 && dirs[0] === -1 && dirs[1] === 1) {
621
+ // Porrectus: the baked diagonal swash for the fall (2nd–5th), the final
622
+ // note stacked at its end.
623
+ const drop = figure[0].staffPosition - figure[1].staffPosition;
624
+ if (drop >= 1 && drop <= 4) {
625
+ const cx = atX;
626
+ const swash = placeGlyph(ligaturaDesc(drop + 1), cx, yFor(figure[0].staffPosition, L, r), r, "note swash", dataAttrs(figure[0]), r.noteScale);
627
+ if (swash) {
628
+ ledger(figure[0].staffPosition, swash.inkLeft, swash.inkRight);
629
+ ledger(figure[1].staffPosition, swash.inkLeft, swash.inkRight);
630
+ // The Solesmes porrectus carries a left stem — the descent edge,
631
+ // as on the clivis (the swash is a clivis whose fall stretched).
632
+ body.push(stem(swash.inkLeft + r.stemWeight, figure[0].staffPosition, figure[1].staffPosition));
633
+ body.push(swash.svg);
634
+ placements.push({ row: figure[0], inkLeft: swash.inkLeft, inkRight: swash.inkRight, x: cx, y: yFor(figure[0].staffPosition, L, r), system, systemY: L.systemY });
635
+ placements.push({ row: figure[1], inkLeft: swash.inkLeft, inkRight: swash.inkRight, x: cx, y: yFor(figure[1].staffPosition, L, r), system, systemY: L.systemY });
636
+ const upWidth = (GLYPHS[GLYPH.punctum]?.advance ?? 0) * r.glyphScale * r.noteScale;
637
+ const upper = placeNote(figure[2], Math.max(atX, swash.inkRight - upWidth));
638
+ if (figure[2].staffPosition - figure[1].staffPosition > 1) {
639
+ body.push(stem(swash.inkRight, figure[1].staffPosition, figure[2].staffPosition));
640
+ }
641
+ return Math.max(swash.inkRight, upper?.inkRight ?? 0);
642
+ }
643
+ }
644
+ return renderFallback(figure, atX);
645
+ }
646
+ if (figure.length === 3 && dirs[0] === 1 && dirs[1] === 1) {
647
+ // Scandicus: first note, then a stacked pes on top.
648
+ const cx = atX;
649
+ const first = placeNote(figure[0], cx);
650
+ if (!first)
651
+ return cx;
652
+ if (figure[1].staffPosition - figure[0].staffPosition > 1) {
653
+ body.push(stem(first.inkRight + r.stemWeight, figure[0].staffPosition, figure[1].staffPosition));
654
+ }
655
+ return renderPes(figure[1], figure[2], first.inkRight);
656
+ }
657
+ return renderFallback(figure, atX);
658
+ };
659
+ // ── Clef ──
660
+ const clefStr = rows[0]?.clef ?? "c3";
661
+ const drawClef = (clef, atX) => {
662
+ const isF = clef[0] === "f";
663
+ const line = parseInt(clef[clef.length - 1] ?? "3", 10) || 3;
664
+ const pos = 2 * line - 1;
665
+ const p = placeGlyph(isF ? GLYPH.fClef : GLYPH.cClef, atX, yFor(pos, L, r), r, "clef", "", r.noteScale);
666
+ if (!p)
667
+ return atX;
668
+ body.push(p.svg);
669
+ let cx = p.inkRight + r.interGlyph;
670
+ if (clef.includes("b")) {
671
+ // Key flat at the te position: one letter below do for C clefs, the te
672
+ // below fa for F clefs.
673
+ const flatPos = isF ? pos - 4 : pos - 1;
674
+ const fp = placeGlyph(GLYPH.flat, cx, yFor(flatPos, L, r), r, "accidental key-flat", "", r.noteScale * 0.62);
675
+ if (fp) {
676
+ body.push(fp.svg);
677
+ cx = fp.inkRight + r.interGlyph;
678
+ }
679
+ }
680
+ return cx + r.staffInterval * 1.2;
681
+ };
682
+ x = drawClef(clefStr, x);
683
+ let activeClef = clefStr;
684
+ // Estimated lyric width for column spacing (headless: no text measurement).
685
+ // Lyric width, for the collision check below. Case-aware, because a flat
686
+ // per-character average is wrong exactly where it matters: chant sets its
687
+ // opening word in capitals ("CAntábo", "DE us"), and capitals run about 0.70
688
+ // em against lowercase's 0.50. A flat 0.52 underestimated "CAn" by 5px — a
689
+ // quarter of its width — so the opening syllables were placed as if they
690
+ // fitted and then collided with what followed.
691
+ const estLyricW = (text) => {
692
+ let w = 0;
693
+ for (const ch of text) {
694
+ w += /[A-ZÀ-ÞŒÆ]/.test(ch) ? 0.70 : /[.,;:'’\- ]/.test(ch) ? 0.28 : 0.50;
695
+ }
696
+ return w * r.lyricSize;
697
+ };
698
+ // Clear air between one syllable's right edge and the next one's left.
699
+ // 0.25 em until 2026-08-04, which let syllables touch even when the width
700
+ // estimate was right — a space between words has to read as a space.
701
+ const minLyricGap = r.lyricSize * 0.42;
702
+ let prevLyricRight = -Infinity;
703
+ // ── Walk figures grouped by (phraseIndex, syllableIndex, neumeGroup) ──
704
+ // syllableIndex resets per phrase, so the phrase must be part of the key:
705
+ // without it, phrase N's last figure and phrase N+1's first merge whenever
706
+ // the indices collide, silently dropping the second figure's lyric and the
707
+ // divisio between them.
708
+ let i = 0;
709
+ let prevSyllable = -1;
710
+ let prevPhrase = -1;
711
+ let afterDivisio = false;
712
+ while (i < rows.length) {
713
+ const { phraseIndex, syllableIndex, neumeGroup } = rows[i];
714
+ let j = i;
715
+ while (j < rows.length &&
716
+ rows[j].phraseIndex === phraseIndex &&
717
+ rows[j].syllableIndex === syllableIndex &&
718
+ rows[j].neumeGroup === neumeGroup)
719
+ j++;
720
+ const figure = rows.slice(i, j);
721
+ // Mid-score clef change.
722
+ if (figure[0].clef !== activeClef) {
723
+ activeClef = figure[0].clef;
724
+ x = drawClef(activeClef, x + r.interGlyph);
725
+ }
726
+ const newSyllable = syllableIndex !== prevSyllable || phraseIndex !== prevPhrase;
727
+ // ── The engraver's own break ────────────────────────────────────────
728
+ //
729
+ // GABC's `z` says "start a new line here", and it is not a hint: an
730
+ // editor who set a chant chose where its lines end, and that choice
731
+ // carries a reading of the piece a width cannot infer. tonus SKIPPED the
732
+ // token at parse — 41 Graduale chants carry one and every break was
733
+ // being thrown away, which is why the automatic breaks looked arbitrary
734
+ // against a printed copy.
735
+ //
736
+ // It wins over the fit test. Where it is absent the layout still decides.
737
+ if (r.width != null && figure[0].lineBreak && prevSyllable !== -1) {
738
+ if (r.custos) {
739
+ const cp = placeGlyph(custosGlyph(figure[0].staffPosition), custosX(x, prevLyricRight, r), yFor(figure[0].staffPosition, L, r), r, "custos", "", r.noteScale * CUSTOS_FACTOR);
740
+ if (cp) {
741
+ // A custos outside the staff needs its ledger like any other
742
+ // sign at that pitch: it names a note, and a note above or below
743
+ // the lines is unreadable without one.
744
+ ledger(figure[0].staffPosition, cp.inkLeft, cp.inkRight);
745
+ body.push(cp.svg);
746
+ }
747
+ }
748
+ systemMaxX.push(Math.max(x, prevLyricRight) + r.padding);
749
+ L.systemY += L.systemHeight;
750
+ system++;
751
+ x = r.padding;
752
+ x = drawClef(activeClef, x);
753
+ prevLyricRight = -Infinity;
754
+ afterDivisio = true;
755
+ }
756
+ if (newSyllable && prevSyllable !== -1) {
757
+ x += afterDivisio ? 0 : r.interSyllable;
758
+ if (figure[0].wordStart && !afterDivisio)
759
+ x += r.interWord;
760
+ afterDivisio = false;
761
+ // Column rule: don't let this syllable's lyric collide with the last.
762
+ const lyricText = displayLyric(figure[0]).text;
763
+ if (lyricText) {
764
+ const estFigW = figure.length *
765
+ (GLYPHS[GLYPH.punctum]?.advance ?? 0) * r.glyphScale * r.noteScale;
766
+ const estLeft = x + estFigW / 2 - estLyricW(lyricText) / 2;
767
+ if (estLeft < prevLyricRight + minLyricGap) {
768
+ x += prevLyricRight + minLyricGap - estLeft;
769
+ }
770
+ }
771
+ }
772
+ else if (!newSyllable && prevSyllable !== -1) {
773
+ x += figure[0].quilisma ? r.staffInterval * 0.12 : r.interGlyph;
774
+ }
775
+ // Close the current system and open the next, optionally guiding the eye
776
+ // with a custos at `nextPos`. Both break paths (divisio and word boundary)
777
+ // route through here so the two cannot drift apart.
778
+ const closeSystem = (nextPos) => {
779
+ if (r.custos && nextPos != null) {
780
+ const p = placeGlyph(custosGlyph(nextPos), custosX(x, prevLyricRight, r), yFor(nextPos, L, r), r, "custos", "", r.noteScale * CUSTOS_FACTOR);
781
+ if (p) {
782
+ ledger(nextPos, p.inkLeft, p.inkRight);
783
+ body.push(p.svg);
784
+ }
785
+ }
786
+ systemMaxX.push(Math.max(x, prevLyricRight) + r.padding);
787
+ system++;
788
+ L.systemY += L.systemHeight;
789
+ x = r.padding;
790
+ x = drawClef(activeClef, x);
791
+ afterDivisio = false;
792
+ prevLyricRight = -Infinity;
793
+ };
794
+ const figureStartX = x;
795
+ x = renderFigure(figure, x);
796
+ if (newSyllable) {
797
+ const { text, runs } = displayLyric(figure[0]);
798
+ if (text) {
799
+ const cx = (figureStartX + x) / 2;
800
+ lyrics.push({ cx, text, runs, wordStart: figure[0].wordStart, systemY: L.systemY });
801
+ prevLyricRight = cx + estLyricW(text) / 2;
802
+ }
803
+ }
804
+ // Divisio at the end of a phrase.
805
+ const div = figure[figure.length - 1].divisio;
806
+ const phraseEnds = j >= rows.length || rows[j].phraseIndex !== figure[0].phraseIndex;
807
+ if (div && phraseEnds) {
808
+ x += r.staffInterval * 2.1;
809
+ const code = DIVISIO_GLYPH[div];
810
+ if (code) {
811
+ // Divisiones register at the staff center (position 4).
812
+ const p = placeGlyph(code, x, yFor(4, L, r), r, "divisio");
813
+ if (p) {
814
+ body.push(p.svg);
815
+ x = p.inkRight;
816
+ }
817
+ }
818
+ x += r.staffInterval * 2.1;
819
+ afterDivisio = true;
820
+ // ── System break ── When wrapping, a divisio is a legal break point.
821
+ // Break if the next phrase would overflow the width — but never on the
822
+ // last divisio (nothing follows). A custos guides the eye to the next
823
+ // system's first pitch.
824
+ // Break when the NEXT phrase will not fit, rather than once this one has
825
+ // already overrun. The check was `x > width - padding`, which only fires
826
+ // AFTER the boundary is crossed — and since a system may break only at a
827
+ // divisio, the overrun was a whole phrase wide. Measured over thirty
828
+ // graduals, every one of them overran a 900px request, by up to 289px.
829
+ // That is what made a render wider than the column it was drawn for, and
830
+ // why "sometimes bigger, sometimes smaller" varied by chant: the overrun
831
+ // depends on where the phrases happen to fall.
832
+ //
833
+ // The estimate is calibrated, not guessed: measured over twenty graduals,
834
+ // a phrase's width is 1.53 x (notes x staffInterval) at the median and
835
+ // 2.26 at p90. 2.2 keeps most phrases inside the line; a lower figure
836
+ // (1.35, the first attempt) sat at the median and let half of them spill.
837
+ // Capped at 0.8 of a system, since a phrase longer than that has nowhere
838
+ // better to go and moving it only empties the line it left.
839
+ const moreToCome = j < rows.length;
840
+ // The line's usable width RESERVES the custos, rather than drawing it
841
+ // afterwards and hoping. Exsurge computes the same boundary once up front
842
+ // (`staffRight - CustosLong.width`), which makes a custos structurally
843
+ // unable to overrun — where tonus used to place it past a finished `x`.
844
+ // See working/notes/exsurge-line-breaking.md.
845
+ //
846
+ // A final divisio needs no custos (nothing follows), so the full width is
847
+ // available there.
848
+ const custosW = r.custos
849
+ ? (GLYPHS[GLYPH.punctum]?.advance ?? 0) * r.glyphScale * r.noteScale * 0.85
850
+ + r.interGlyph
851
+ : 0;
852
+ const rightBoundary = r.width != null
853
+ ? r.width - r.padding - (div === "::" ? 0 : custosW)
854
+ : Infinity;
855
+ // How wide is the coming phrase? MEASURED, by placing it and rewinding —
856
+ // not estimated. See measureFigure above for why every estimate failed.
857
+ //
858
+ // The trial walks the phrase figure by figure exactly as the real loop
859
+ // will, including the syllable and word gaps, so the number it returns is
860
+ // the number the drawing will produce.
861
+ let nextPhraseW = 0;
862
+ if (r.width != null && moreToCome) {
863
+ const phrase = rows[j].phraseIndex;
864
+ let tx = 0;
865
+ let prevSyl = -1;
866
+ for (let k = j; k < rows.length && rows[k].phraseIndex === phrase;) {
867
+ let e = k;
868
+ while (e < rows.length &&
869
+ rows[e].phraseIndex === rows[k].phraseIndex &&
870
+ rows[e].syllableIndex === rows[k].syllableIndex &&
871
+ rows[e].neumeGroup === rows[k].neumeGroup)
872
+ e++;
873
+ if (rows[k].syllableIndex !== prevSyl && prevSyl !== -1) {
874
+ tx += r.interSyllable;
875
+ if (rows[k].wordStart)
876
+ tx += r.interWord;
877
+ }
878
+ else if (prevSyl !== -1) {
879
+ tx += r.interGlyph;
880
+ }
881
+ prevSyl = rows[k].syllableIndex;
882
+ tx = measureFigure(rows.slice(k, e), tx);
883
+ k = e;
884
+ }
885
+ nextPhraseW = tx;
886
+ }
887
+ // No tolerance. It existed to absorb the error in an ESTIMATED phrase
888
+ // width; the width is measured now, so slack only buys overruns. Set to
889
+ // 3% it doubled the renders that exceeded the requested width (16 of 80
890
+ // against 8) for a four-point gain in line fill — the wrong trade when a
891
+ // uniform scale is what a reader notices.
892
+ const slack = 0;
893
+ // `<nlba>` seals a seam: the editor set "T. P. Allelúia" and its verse as
894
+ // one unbreakable run, and a break inside it splits a group the book
895
+ // keeps whole. Measured across the 35 Graduale chants that carry the tag,
896
+ // the automatic breaks violated it 5-13 times depending on width — and
897
+ // every one of them in moderna, which is why a quadrata-only check first
898
+ // reported none.
899
+ //
900
+ // The seal only forbids; it never forces. If this break point is sealed
901
+ // the line simply runs on to the next candidate, which is what Gregorio's
902
+ // own renderer does in spirit — nabc-lib pushes the whole kept-together
903
+ // stack down to the next line rather than breaking inside it. tonus can
904
+ // take the simpler road because its breaks fall at phrase boundaries: a
905
+ // sealed boundary is just not a candidate.
906
+ const sealed = moreToCome && rows[j].keepWithPrev;
907
+ // A divisio is the BEST place to end a system, so it still breaks when the
908
+ // coming phrase will not fit whole — but only once the line has earned it.
909
+ // Breaking at every barline that cannot hold a whole phrase is what left a
910
+ // quarter of quadrata's lines under 75% full: a long phrase would not fit
911
+ // anywhere, so the line ended early and the phrase overran the next one
912
+ // regardless. Now that a word boundary can end a system too, the barline
913
+ // can hold out for a line that is actually full, and the word rule below
914
+ // catches the remainder mid-phrase.
915
+ //
916
+ // 0.88 is measured, not chosen: sweeping the threshold over 120 graduals,
917
+ // short lines fall 28% → 27% → 23% → 8% → 4% across 0.55/0.65/0.72/0.80/
918
+ // 0.88 and then stop moving (0.95 also gives 4%). The knee is at 0.88, so
919
+ // it takes the whole gain while still letting a barline end a line that is
920
+ // merely close to full — a higher figure would only discard divisio breaks
921
+ // for nothing.
922
+ const earned = x >= rightBoundary * 0.88;
923
+ // The shared rules (breaking.ts) decide `z`, the seal, and the width; the
924
+ // `earned` threshold is quadrata's own policy — a divisio is the BEST
925
+ // place to end a system, so it holds out for a line that is nearly full
926
+ // and lets the word rule below take the remainder.
927
+ // A sealed seam is not a candidate here at all: quadrata's breaks fall at
928
+ // phrase boundaries, so refusing is enough — the word rule below finds the
929
+ // group's head. Testing the seal INSIDE the shared decision instead let a
930
+ // sealed boundary close a system, which drew a custos on a line that had
931
+ // none before (measured: 8 custos became 9 on gregobase:697).
932
+ const divVerdict = r.width != null && moreToCome && !sealed
933
+ ? decideBreak({
934
+ next: rows[j],
935
+ x,
936
+ boundary: rightBoundary,
937
+ need: earned ? nextPhraseW + slack : 0,
938
+ lineStart: r.padding,
939
+ forcedHandled: true,
940
+ })
941
+ : { break: false, reason: "none" };
942
+ if (divVerdict.break) {
943
+ // A custos after a FULL STOP is noise. The sign says "the melody
944
+ // continues, at this pitch" — a divisio finalis has already said the
945
+ // opposite, and drawing both put two marks in the same place, which
946
+ // reads as a heavy double barline rather than as a guide. (Gregorio
947
+ // and exsurge both suppress it there for the same reason.)
948
+ //
949
+ // After a minor divisio it earns its place: the phrase is punctuated,
950
+ // not finished, and the eye still has to find the next pitch.
951
+ if (r.custos && div !== "::") {
952
+ // The line-end guide naming the next system's first pitch, drawn as
953
+ // the real custos now that the bake carries one (see gabc-glyphs.ts).
954
+ const nextPos = rows[j].staffPosition;
955
+ const p = placeGlyph(custosGlyph(nextPos), custosX(x, prevLyricRight, r), yFor(nextPos, L, r), r, "custos", "", r.noteScale * CUSTOS_FACTOR);
956
+ if (p) {
957
+ ledger(nextPos, p.inkLeft, p.inkRight);
958
+ body.push(p.svg);
959
+ }
960
+ }
961
+ systemMaxX.push(Math.max(x, prevLyricRight) + r.padding);
962
+ system++;
963
+ L.systemY += L.systemHeight;
964
+ x = r.padding;
965
+ // The clef repeats at the head of every system.
966
+ x = drawClef(activeClef, x);
967
+ afterDivisio = false; // a fresh system starts clean, not "after a divisio"
968
+ // Forget the previous system's rightmost lyric — otherwise the lyric-
969
+ // column rule would shove this system's first syllable across the page
970
+ // to clear a lyric that is now a line above.
971
+ prevLyricRight = -Infinity;
972
+ }
973
+ }
974
+ // ── Break at a word, when the phrase has nowhere else to end ──
975
+ //
976
+ // A divisio is the RIGHT place to end a system and stays the first choice
977
+ // above. But it cannot be the only one: quadrata's break test used to live
978
+ // entirely inside `if (div && phraseEnds)`, so a system could end nowhere
979
+ // else, and a phrase wider than the line simply ran until its next barline.
980
+ // Measured over 120 graduals, a QUARTER of quadrata's lines came out under
981
+ // 75% full against 6% in moderna — which breaks between syllables. That gap
982
+ // was the asymmetry, not a spacing difference.
983
+ //
984
+ // The books break mid-phrase freely; the unit is the word, never a syllable
985
+ // mid-word (which would split a lyric) and never mid-neume. So: at a word
986
+ // start, with the coming word measured, break if it will not fit.
987
+ if (r.width != null && j < rows.length && rows[j].wordStart &&
988
+ !afterDivisio && !rows[j].keepWithPrev) {
989
+ // Measure the coming WORD the same way the phrase is measured — by
990
+ // placing and rewinding, so the number is the one the drawing produces.
991
+ let tw = 0;
992
+ let pSyl = rows[j].syllableIndex;
993
+ let k = j;
994
+ while (k < rows.length) {
995
+ if (k > j && rows[k].wordStart)
996
+ break; // the next word begins
997
+ let e = k;
998
+ while (e < rows.length &&
999
+ rows[e].phraseIndex === rows[k].phraseIndex &&
1000
+ rows[e].syllableIndex === rows[k].syllableIndex &&
1001
+ rows[e].neumeGroup === rows[k].neumeGroup)
1002
+ e++;
1003
+ if (rows[k].syllableIndex !== pSyl)
1004
+ tw += r.interSyllable;
1005
+ else if (k > j)
1006
+ tw += r.interGlyph;
1007
+ pSyl = rows[k].syllableIndex;
1008
+ tw = measureFigure(rows.slice(k, e), tw);
1009
+ k = e;
1010
+ }
1011
+ const custosW2 = r.custos
1012
+ ? (GLYPHS[GLYPH.punctum]?.advance ?? 0) * r.glyphScale * r.noteScale * 0.85
1013
+ + r.interGlyph
1014
+ : 0;
1015
+ const bound = r.width - r.padding - custosW2;
1016
+ const wordVerdict = decideBreak({
1017
+ next: rows[j],
1018
+ x: x + r.interSyllable + r.interWord,
1019
+ boundary: bound,
1020
+ need: tw,
1021
+ sealedRun: tw,
1022
+ lineStart: r.padding,
1023
+ forcedHandled: true,
1024
+ });
1025
+ if (wordVerdict.break)
1026
+ closeSystem(rows[j].staffPosition);
1027
+ }
1028
+ prevSyllable = syllableIndex;
1029
+ prevPhrase = phraseIndex;
1030
+ i = j;
1031
+ }
1032
+ // ── Episema: one bar per neume group, spanning the group's ink ──
1033
+ {
1034
+ const groups = new Map();
1035
+ for (const pl of placements) {
1036
+ const key = `${pl.row.phraseIndex}.${pl.row.syllableIndex}.${pl.row.neumeGroup}`;
1037
+ const g = groups.get(key) ?? { l: Infinity, rr: -Infinity, top: -Infinity, has: false, systemY: pl.systemY };
1038
+ g.l = Math.min(g.l, pl.inkLeft);
1039
+ g.rr = Math.max(g.rr, pl.inkRight);
1040
+ g.top = Math.max(g.top, pl.row.staffPosition);
1041
+ g.systemY = pl.systemY;
1042
+ if (pl.row.episema)
1043
+ g.has = true;
1044
+ groups.set(key, g);
1045
+ }
1046
+ for (const g of groups.values()) {
1047
+ if (!g.has)
1048
+ continue;
1049
+ const y = yAt(g.top, g.systemY, L, r) - r.staffInterval * 1.35;
1050
+ body.push(`<rect class="episema" x="${g.l.toFixed(2)}" y="${y.toFixed(2)}" ` +
1051
+ `width="${(g.rr - g.l).toFixed(2)}" height="${(r.lineWeight * 1.7).toFixed(2)}" fill="${r.noteColor}"/>`);
1052
+ }
1053
+ }
1054
+ // ── Rhythmic signs, per placed notehead ──
1055
+ for (let k = 0; k < placements.length; k++) {
1056
+ const pl = placements[k];
1057
+ const { row } = pl;
1058
+ const midX = (pl.inkLeft + pl.inkRight) / 2;
1059
+ if (row.mora) {
1060
+ const prevRow = k > 0 ? placements[k - 1].row : null;
1061
+ const fromAbove = prevRow != null && prevRow.staffPosition > row.staffPosition;
1062
+ const dotPos = row.staffPosition % 2 !== 0
1063
+ ? row.staffPosition + (fromAbove ? -1 : 1)
1064
+ : row.staffPosition;
1065
+ const p = placeGlyph(GLYPH.mora, pl.inkRight + r.staffInterval * 0.3, yAt(dotPos, pl.systemY, L, r) + r.staffInterval * 0.33, r, "mora", "", r.noteScale);
1066
+ if (p)
1067
+ body.push(p.svg);
1068
+ }
1069
+ if (row.ictusSign) {
1070
+ // The tick's ink starts only 28 font-units past its origin, so shift the
1071
+ // origin past the notehead's edge to keep it clear.
1072
+ const below = row.staffPosition > 0;
1073
+ const code = below ? GLYPH.ictusBelow : GLYPH.ictusAbove;
1074
+ const g = GLYPHS[code];
1075
+ const w = (g ? (g.bbox[2] - g.bbox[0]) : 0) * r.glyphScale * r.noteScale;
1076
+ const clearance = r.noteheadH * 0.45;
1077
+ const y = yAt(row.staffPosition, pl.systemY, L, r) + (below ? clearance : -clearance);
1078
+ const p = placeGlyph(code, midX - w / 2, y, r, "ictus", "", r.noteScale);
1079
+ if (p)
1080
+ body.push(p.svg);
1081
+ }
1082
+ }
1083
+ // Close the final system; height reaches the last.
1084
+ systemMaxX.push(Math.max(x, prevLyricRight) + r.padding);
1085
+ // The canvas is what the CALLER asked for, not what the content happened to
1086
+ // reach. Width was `max(systemMaxX)` in both species, so a render of a
1087
+ // requested 900 came out 915, 986, 1074, 1203 — whatever the widest system
1088
+ // ended at. A host applying `max-width: 100%` then shrank each render by a
1089
+ // different factor, which is why the same page showed one chant's notation
1090
+ // a third smaller than another's, and why the two species never agreed:
1091
+ // measured across fourteen graduals, moderna landed between 0.66 and 0.95
1092
+ // of quadrata's on-screen size with no pattern a reader could learn.
1093
+ //
1094
+ // `width` is the wrap point, and now also the canvas. Content still wraps
1095
+ // inside it; it no longer decides how big the picture is. Without a width
1096
+ // there is nothing to wrap to and the content still sets the size.
1097
+ const contentW = Math.ceil(Math.max(...systemMaxX));
1098
+ // A CEILING, not a floor. `width` is the room the caller has; content wraps
1099
+ // inside it and the canvas never exceeds it, so a host applying `max-width`
1100
+ // shrinks nothing and every render on a page shares one scale. But a chant
1101
+ // that does not fill the room keeps its own width — padding it out to the
1102
+ // full column would leave a short chant floating in white space, and any
1103
+ // host that scales to fit would then shrink the notation for having been
1104
+ // short. That is the bug this line replaced, in the other direction.
1105
+ // The canvas is the requested width. Every render on a page then shares one
1106
+ // scale, which is the thing a reader actually notices: width used to be
1107
+ // `max(systemMaxX)`, so a requested 900 came back 915, 986, 1074, 1203 by
1108
+ // chant and a host applying `max-width` shrank each differently.
1109
+ //
1110
+ // Content that overruns is handled at the SOURCE — the wrap check breaks a
1111
+ // system before it spills (see the lookahead below) — not by growing the
1112
+ // canvas to fit it. Letting the canvas follow the content is what made the
1113
+ // scale wander again; letting it clip is what cut the tail off a staff. The
1114
+ // overrun itself had to go.
1115
+ // The requested width, so every render on a page shares one scale — a
1116
+ // content-driven width returned 915, 986, 1074, 1203 for the same request
1117
+ // and a host applying `max-width` shrank each differently.
1118
+ //
1119
+ // The `max` is a safety net for the last few pixels. Placement is decided
1120
+ // from an estimate of the coming phrase, absorbed by a tolerance (see the
1121
+ // break above), and the residue is small: over forty chants the worst
1122
+ // overrun is 35px on a 900px line. Growing the canvas by that is invisible;
1123
+ // clipping it takes the end off a staff.
1124
+ const width = r.width != null ? Math.max(Math.ceil(r.width), contentW) : contentW;
1125
+ const height = Math.ceil(L.systemY + L.lyricY + r.lyricSize * 0.6 + bands.extra);
1126
+ // ── The analysis tracks, below each system ──
1127
+ // Downstream of the notation: they consume the placements (the same anchors
1128
+ // the geometry contract exports), never the score's own ink. Drawn after the
1129
+ // page width is known — the tonarium's lane measures itself against each
1130
+ // system's right edge.
1131
+ if (bands.prosodia || bands.chironomia || bands.tonarium) {
1132
+ const trackNotes = placements.map((pl) => ({
1133
+ row: pl.row, x: pl.x, y: pl.y, system: pl.system, systemY: pl.systemY,
1134
+ inkLeft: pl.inkLeft, inkRight: pl.inkRight,
1135
+ }));
1136
+ if (bands.prosodia) {
1137
+ body.push(buildProsodia(trackNotes, {
1138
+ k: trackScale,
1139
+ laneTop: L.lyricY + bands.prosodia.top,
1140
+ rightFor: (s) => (systemMaxX[s] ?? width) - r.padding,
1141
+ rubricaColor: r.rubricaColor,
1142
+ }));
1143
+ }
1144
+ if (bands.chironomia) {
1145
+ body.push(buildChironomia(trackNotes, {
1146
+ k: trackScale,
1147
+ // Clear of the lyric line's descenders: the crest's letters top out
1148
+ // ~26px above the midline, and the lyric baseline sits at lyricY.
1149
+ waveMidY: L.lyricY + bands.chironomia.top + 33 * trackScale,
1150
+ }));
1151
+ }
1152
+ if (bands.tonarium) {
1153
+ body.push(buildTonarium(trackNotes, options.trackData ?? { cadences: [], modulations: [] }, {
1154
+ k: trackScale,
1155
+ laneTop: L.lyricY + bands.tonarium.top + 26 * trackScale,
1156
+ rightFor: (s) => (systemMaxX[s] ?? width) - r.padding,
1157
+ serifFamily: r.fonts.lyric.family,
1158
+ rubricaColor: r.rubricaColor,
1159
+ }));
1160
+ }
1161
+ }
1162
+ // Staff lines (positions 1, 3, 5, 7), once per system. A system's rightmost
1163
+ // ink bounds its staff so a short final line doesn't stretch to the page edge.
1164
+ const staffLines = [];
1165
+ for (let s = 0; s <= system; s++) {
1166
+ const sysY = headerY + s * L.systemHeight;
1167
+ const right = (systemMaxX[s] ?? width) - r.padding;
1168
+ const left = r.padding + (s === 0 ? capIndent : 0); // the cap owns system 0's margin
1169
+ for (const pos of [1, 3, 5, 7]) {
1170
+ const ly = sysY + L.baselineY - pos * r.staffInterval;
1171
+ staffLines.push(`<line x1="${left.toFixed(2)}" y1="${ly.toFixed(2)}" x2="${right.toFixed(2)}" ` +
1172
+ `y2="${ly.toFixed(2)}" stroke="${r.staffLineColor}" stroke-width="${r.lineWeight.toFixed(2)}"/>`);
1173
+ }
1174
+ }
1175
+ // Lyrics. Within a word, syllables are joined by a hyphen floated CENTRED in
1176
+ // the gap between them (Vendome practice, matching moderna) rather than a
1177
+ // dash appended to the text — only when both syllables share a system.
1178
+ // The dropcap owns the first letter — the lyric line carries the remainder
1179
+ // (strip BEFORE rendering; the cap itself is drawn later, over the margin).
1180
+ if (capInitial && lyrics.length > 0) {
1181
+ const first = lyrics[0];
1182
+ first.text = first.text.slice(1);
1183
+ if (first.runs && first.runs.length > 0) {
1184
+ first.runs = first.runs
1185
+ .map((run, i) => (i === 0 ? { ...run, text: run.text.slice(1) } : run))
1186
+ .filter((run) => run.text.length > 0);
1187
+ }
1188
+ }
1189
+ const lyricSvgs = [];
1190
+ const lyricFontSize = r.lyricSize * r.fonts.lyric.scale;
1191
+ // Lyric weight defaults to moderna's 518 (one weight across the duae
1192
+ // species, ruled 2026-07-29); an explicit fonts.lyric.weight overrides.
1193
+ const lyricWeightAttr = r.fonts.lyric.weight != null ? "" : ' font-weight="518"';
1194
+ const lyricText = (cx, systemY, text, runs) => `<text class="lyric" x="${cx.toFixed(2)}" y="${(systemY + L.lyricY).toFixed(2)}" ` +
1195
+ `text-anchor="middle" ${fontAttrs(r.fonts.lyric)}${lyricWeightAttr} ` +
1196
+ `font-size="${lyricFontSize.toFixed(1)}" fill="${r.noteColor}">${lyricMarkup(runs, text, r.rubricaColor)}</text>`;
1197
+ for (let k = 0; k < lyrics.length; k++) {
1198
+ const ly = lyrics[k];
1199
+ const next = lyrics[k + 1];
1200
+ lyricSvgs.push(lyricText(ly.cx, ly.systemY, ly.text, ly.runs));
1201
+ // Continuing syllable in the same system → a centred hyphen in the gap.
1202
+ if (next && !next.wordStart && next.systemY === ly.systemY) {
1203
+ const thisRight = ly.cx + estLyricW(ly.text) / 2;
1204
+ const nextLeft = next.cx - estLyricW(next.text) / 2;
1205
+ if (nextLeft - thisRight > r.lyricSize * 0.4) {
1206
+ lyricSvgs.push(lyricText((thisRight + nextLeft) / 2, ly.systemY, "-"));
1207
+ }
1208
+ }
1209
+ else if (next && !next.wordStart) {
1210
+ // ...and a word carried to the NEXT system takes a hyphen at the line's
1211
+ // end, which is what the books set. The gap-centred rule above cannot
1212
+ // reach this case — the two syllables have no gap between them, they have
1213
+ // a line break — so the hyphen was simply dropped: measured, 351 splits
1214
+ // across 165 of 200 graduals rendered with nothing joining the halves.
1215
+ // "Sanc" ended a line and "tus" opened the next, reading as two words.
1216
+ const thisRight = ly.cx + estLyricW(ly.text) / 2;
1217
+ lyricSvgs.push(lyricText(thisRight + r.lyricSize * 0.42, ly.systemY, "-"));
1218
+ }
1219
+ }
1220
+ // Dropcap — the large initial in its own left column beside the first
1221
+ // system. The initial IS the lyric's first letter, so the lyric line carries
1222
+ // the remainder only (the book prints "K yrie" as cap + "yrie").
1223
+ //
1224
+ // IT TAKES THE NOTE INK, NOT THE RUBRICA. The printed books set the initial
1225
+ // in black and spend their red on the genus/mode mark beside it — see the
1226
+ // Liber's "Intr. 1." over a black R. It was rubricated here, which put the
1227
+ // reserved colour on the largest mark on the page and left the rubric it is
1228
+ // reserved for competing with it. A caller wanting a red initial themes
1229
+ // `--tonus-note` on the cap, or passes its own colour.
1230
+ const dropcapSvgs = [];
1231
+ if (capInitial && lyrics.length > 0) {
1232
+ const y = headerY + L.lyricY; // bottom-aligned with the first lyric baseline
1233
+ dropcapSvgs.push(`<text class="dropcap" x="${r.padding.toFixed(2)}" y="${y.toFixed(2)}" ` +
1234
+ `${fontAttrs(r.fonts.dropcap)} font-size="${(capSize * r.fonts.dropcap.scale).toFixed(1)}" ` +
1235
+ `fill="${r.noteColor}">${esc(capInitial.toUpperCase())}</text>`);
1236
+ }
1237
+ // Front-matter text, deferred to here so the title can center on the
1238
+ // final width (the books center the piece's title over the whole score).
1239
+ const header = [];
1240
+ if (r.title) {
1241
+ const size = r.lyricSize * 1.5;
1242
+ header.push(`<text class="title" x="${(width / 2).toFixed(2)}" y="${titleBaseline.toFixed(2)}" ` +
1243
+ `text-anchor="middle" ${fontAttrs(r.fonts.title)} ` +
1244
+ `font-size="${(size * r.fonts.title.scale).toFixed(1)}" ` +
1245
+ `fill="${r.noteColor}">${esc(r.title)}</text>`);
1246
+ }
1247
+ if (rubricLines.length > 0) {
1248
+ // With a dropcap the stack owns the margin column: centered on the cap's
1249
+ // width, its last line landing beside the first staff's upper reaches
1250
+ // (the "Offert." / "2." of the books). Otherwise it sits left-aligned in
1251
+ // its own header band. Oldstyle figures for the mode numeral.
1252
+ const inMargin = r.dropcap && capIndent > 0;
1253
+ // LEFT-ALIGNED OVER THE CAP, not centred on it. Centring worked while the
1254
+ // initial was a plain roman capital whose ink stopped well below the
1255
+ // stack; a blackletter's flourishes climb into that band, and the numeral
1256
+ // landed on top of one. Sitting at the margin the mark clears the letter's
1257
+ // reach whatever face draws it, and the books set it there anyway.
1258
+ // CENTRED OVER THE INITIAL when there is one, and left-aligned when there
1259
+ // is not. The stack names the chant the cap opens, so it belongs over that
1260
+ // letter's own width rather than at the margin beside it — and the width
1261
+ // is the letter's real advance, so an M centres over an M and an I over an
1262
+ // I. (It was briefly left-aligned for Jacquard, whose flourishes climb
1263
+ // into the numeral's band; Junicode's capitals do not, and the initial is
1264
+ // Junicode again.)
1265
+ const cx = inMargin
1266
+ ? r.padding + capSize * r.fonts.dropcap.scale
1267
+ * capAdvance(capInitial, r.fonts.dropcap.family) / 2
1268
+ : r.padding;
1269
+ const anchor = inMargin ? 'text-anchor="middle" ' : "";
1270
+ // The stack sits ABOVE the cap, not beside it. Its last line lands a clear
1271
+ // markSize over the cap's own ink, which is what the books do — "Grad."
1272
+ // over "5." over a large Q, each clear of the next.
1273
+ //
1274
+ // It used to start at the staff's top line (topY + 0.2 × markSize) on the
1275
+ // reasoning that the mark rides the staff. But the CAP rises far above the
1276
+ // staff — its ink began at y 41.3 while the numeral ran to 54.5, measured,
1277
+ // a 13-unit overlap — so the two collided in the one column they share.
1278
+ // CAP_RISE, not capAdvance: this is how far the initial climbs ABOVE its
1279
+ // baseline, and the advance is how wide it is. The two shared the 0.72
1280
+ // literal that used to stand for both, so replacing that literal with a
1281
+ // width table quietly made a vertical position depend on a horizontal
1282
+ // measurement — an I would have hung far lower than an M.
1283
+ const capTop = capInitial
1284
+ ? headerY + L.lyricY - capSize * r.fonts.dropcap.scale * CAP_RISE
1285
+ : headerY + L.topY;
1286
+ // Sitting ON the staff's top line — where the books set it. The stack was
1287
+ // floating well above the staff, reading as a header rather than as a mark
1288
+ // in the margin beside the music.
1289
+ //
1290
+ // BOTTOM-ALIGNED, so the stack grows upward from a fixed last row. A mode
1291
+ // standing alone (no genus above it) belongs on the SECOND row, level with
1292
+ // where it sits when "Intr." is over it — anchoring the top row instead
1293
+ // would float a lone numeral high and off the staff.
1294
+ const y0 = inMargin
1295
+ ? headerY + L.topY + markSize * 0.18
1296
+ + markLineH * (MARK_ROWS - rubricLines.length)
1297
+ : rubricTop;
1298
+ rubricLines.forEach((line, i) => {
1299
+ header.push(`<text class="rubric" x="${cx.toFixed(2)}" y="${(y0 + i * markLineH).toFixed(2)}" ` +
1300
+ `${anchor}${fontAttrs(r.fonts.annotation)} ` +
1301
+ `font-size="${(markSize * r.fonts.annotation.scale).toFixed(1)}" ` +
1302
+ `style="font-feature-settings:'onum'" ` +
1303
+ `fill="${r.noteColor}">${esc(line)}</text>`);
1304
+ });
1305
+ }
1306
+ const svgTitle = chant.incipit ? `<title>${esc(chant.incipit)}</title>` : "";
1307
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" ` +
1308
+ `width="${width}" height="${height}" class="tonus-chant">${svgTitle}` +
1309
+ fontFaceCss([r.fonts.dropcap, r.fonts.title, r.fonts.annotation, r.fonts.lyric]) +
1310
+ header.join("") +
1311
+ staffLines.join("") + behind.join("") + body.join("") + lyricSvgs.join("") +
1312
+ dropcapSvgs.join("") +
1313
+ `</svg>`;
1314
+ // The geometry contract: one entry per placed note, in tabula order, carrying
1315
+ // which system it landed in and that system's top offset.
1316
+ const geometry = placements.map((pl) => ({
1317
+ phraseIndex: pl.row.phraseIndex,
1318
+ syllableIndex: pl.row.syllableIndex,
1319
+ neumeGroup: pl.row.neumeGroup,
1320
+ noteIndex: pl.row.neumeIndex,
1321
+ system: pl.system,
1322
+ x: Number(pl.x.toFixed(2)),
1323
+ y: Number(pl.y.toFixed(2)),
1324
+ systemY: Number(pl.systemY.toFixed(2)),
1325
+ }));
1326
+ return { svg, geometry };
1327
+ }
1328
+ //# sourceMappingURL=svg.js.map