tonus 0.1.8 → 0.6.1

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