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,612 @@
1
+ // ---------------------------------------------------------------------------
2
+ // engines/score/emitters/moderna — modern round-note transcription
3
+ // ---------------------------------------------------------------------------
4
+ // The moderna species: the chant transcribed to a modern 5-line staff with
5
+ // round noteheads, engraved slurs, and a treble-8 clef. A DISTINCT rendering
6
+ // from quadrata (svg.ts) with its own spacing pass — square notation clusters a
7
+ // neume near zero advance, but round heads need ~1.3 head-widths of air, so the
8
+ // two views cannot share one x-pass (design: notatio-moderna, "duae species").
9
+ //
10
+ // This is a faithful port of the locked reference `working/moderna-generator.py`
11
+ // (Lomer transcription practice, Vendome deltas): stemless black noteheads, one
12
+ // engraved slur per figure (strophic runs merged), small heads for liquescents,
13
+ // noteheadHalf for the double mora, the medRenQuilismaCMN squiggle fused before
14
+ // its head, augmentation dots for the mora, divisio ticks/bars by kind, centred
15
+ // floating hyphens. The generator's constants are its spec and are reproduced
16
+ // here verbatim.
17
+ //
18
+ // It returns the same { svg, geometry } contract as quadrata, so downstream
19
+ // tracks and inscriptio treat both species uniformly.
20
+ import { GLYPHS } from "../../../data/smufl-glyphs.js";
21
+ import { fontFaceCss, lyricMarkup } from "./svg.js";
22
+ import { trimRuns } from "../lyric.js";
23
+ import { decideBreak } from "./breaking.js";
24
+ import { computeAccidentals, } from "./accidentals.js";
25
+ import { buildChironomia, buildProsodia, buildTonarium, trackBands } from "./tracks.js";
26
+ // ── Bravura moderna glyph codepoints (baked in smufl-glyphs.json) ──
27
+ const G = {
28
+ gClef8vb: "E052",
29
+ noteheadHalf: "E0A3",
30
+ noteheadBlack: "E0A4",
31
+ augmentationDot: "E1E7",
32
+ quilisma: "EA20", // medRenQuilismaCMN
33
+ };
34
+ // ── geometry, as a function of the staff ──────────────────────────────────
35
+ //
36
+ // THE CONTRACT (ruled 2026-08-04): `staffHeight` is the height of the STAFF
37
+ // ITSELF — top line to bottom line — and means the same thing in both species.
38
+ // Quadrata's four lines and moderna's five then occupy the same band, which is
39
+ // what "the same size" means when the two sit on one page: the eye reads the
40
+ // block of staff, not the gap between its lines.
41
+ //
42
+ // Moderna was engraved against a fixed 7.4px space (`MSP_1` below, from
43
+ // moderna-generator.py) and read no option at all — measured, a request of 30,
44
+ // 40 or 60 left it at 7.4 every time while quadrata moved 10 → 13.3 → 20. Two
45
+ // species that could not be brought to one size by anything a caller passed.
46
+ //
47
+ // The fix has to happen BEFORE layout, not after it. Scaling the finished SVG
48
+ // looks right in isolation and fails in a page: it scales the width too, so a
49
+ // wider render is shrunk further by whatever `max-width` the host applies, and
50
+ // the two species land at different on-screen sizes again. (Measured: post-
51
+ // scaling moderna to span 40 pushed its width 1016 → 1373, and the site's
52
+ // column shrank it straight back to 26px against quadrata's 36.)
53
+ //
54
+ // So every constant derives from the space, and the space derives from the
55
+ // requested staff height. `metrics()` is that derivation, computed once per
56
+ // render and threaded through the helpers that draw.
57
+ const MSP_1 = 7.4; // the engraved staff space
58
+ const SCALE_1 = (MSP_1 * 4) / 1000; // SMuFL: 1 em = 4 spaces
59
+ const MTOP_1 = 20; // top staff line, system-local
60
+ // Per-note advance inside a melisma — the DENSITY dial, and the number that
61
+ // decides how much music fills a line.
62
+ //
63
+ // 12.8 was the engraved value: 1.47x the notehead, generous modern spacing.
64
+ // Against quadrata's ~6px it meant the same chant took twice the systems, and
65
+ // a reader comparing the two species saw one of them looking half the size —
66
+ // which is what the eye actually reads, more than the staff block does.
67
+ //
68
+ // Ruled 2026-08-04: the species answer to one DENSITY, near enough that a line
69
+ // of one holds about as much music as a line of the other. Not identically —
70
+ // round noteheads need air where quadrata's squares can abut, and 8.4 (a true
71
+ // match) read as cramped. 10.4 keeps the air and still fits a chant in about
72
+ // the same number of systems.
73
+ const ADV_1 = 11.6;
74
+ const SYL_GAP_1 = 9.5; // gap after each syllable
75
+ // Staff bottom → lyric baseline. NOT scaled with the staff: the duae species
76
+ // share one lyric setting (ruled 2026-07-29), and that ruling is about type,
77
+ // which sits at its own size. Scaling it with the staff broke the parity the
78
+ // moment moderna's staff started moving.
79
+ const LYRIC_GAP = 28;
80
+ const SYSTEM_GAP_DEFAULT = 24;
81
+ /** The staff height both species answer to when the caller names none. */
82
+ const DEFAULT_STAFF_HEIGHT = 40;
83
+ /** Moderna's engraved span: five lines, four spaces. */
84
+ const MODERNA_SPAN_1 = 4 * MSP_1;
85
+ /**
86
+ * Moderna's metrics for a requested staff height.
87
+ *
88
+ * Everything scales together, so the engraving's internal relationships —
89
+ * notehead to staff, slur to notehead, lyric to baseline — hold at any size;
90
+ * they were drawn as a set and stay one.
91
+ */
92
+ export function metrics(staffHeight) {
93
+ // `staffHeight` scales the engraving as a whole. Moderna keeps its own
94
+ // proportions — a five-line staff at its engraved space — rather than
95
+ // forcing its span onto quadrata's four-line block: matching the SPANS made
96
+ // moderna render wider than the caller asked for (1155px for a requested
97
+ // 900), so a host applying `max-width` shrank it further and the two species
98
+ // landed at different on-screen sizes again. What a reader compares is how
99
+ // much music fills a line, and that is the note advance, not the staff.
100
+ const k = (staffHeight ?? DEFAULT_STAFF_HEIGHT) / DEFAULT_STAFF_HEIGHT;
101
+ const MSP = MSP_1 * k;
102
+ const SCALE = (MSP * 4) / 1000;
103
+ const MTOP = MTOP_1 * k;
104
+ return {
105
+ MSP,
106
+ SCALE,
107
+ MTOP,
108
+ NH_W: 295 * SCALE,
109
+ ADV: ADV_1 * k,
110
+ SYL_GAP: SYL_GAP_1 * k,
111
+ LYRIC_Y: MTOP + 4 * MSP + LYRIC_GAP,
112
+ k,
113
+ };
114
+ }
115
+ const LETTERS = { C: 0, D: 1, E: 2, F: 3, G: 4, A: 5, B: 6 };
116
+ const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
117
+ /** Written y for a scientific pitch name on the treble-8 staff (bottom line E4). */
118
+ function writtenY(spn, systemY, gm) {
119
+ const m = /([A-G])[#b]?(-?\d)/.exec(spn);
120
+ if (!m)
121
+ return { y: systemY + gm.MTOP + 4 * gm.MSP, steps: 0 };
122
+ const di = (Number(m[2]) + 2) * 7 + LETTERS[m[1]];
123
+ const steps = di - (4 * 7 + LETTERS["E"]); // relative to bottom line E4
124
+ return { y: systemY + gm.MTOP + 4 * gm.MSP - steps * (gm.MSP / 2), steps };
125
+ }
126
+ // Moderna's ink, as a CSS custom property with the house default as fallback —
127
+ // the same three properties quadrata emits, so one stylesheet themes both
128
+ // species. Every mark here is one colour, so a module constant does the job a
129
+ // threaded parameter would; `theme.colors.note` overrides it through
130
+ // `resolveInk` below.
131
+ //
132
+ // Until now these were 17 literal "#111"s and moderna ignored `noteColor`
133
+ // outright — so a caller theming the note colour saw quadrata change and
134
+ // moderna not.
135
+ let INK = "var(--tonus-note, #111)";
136
+ let RUBRICA = "var(--tonus-rubrica, #9E2B25)";
137
+ /** Set the module ink from resolved options. Called once per render. */
138
+ function resolveInk(options) {
139
+ INK = `var(--tonus-note, ${options.noteColor ?? "#111"})`;
140
+ RUBRICA = `var(--tonus-rubrica, ${options.rubricaColor ?? "#9E2B25"})`;
141
+ }
142
+ function glyph(name, x, y, scale) {
143
+ const g = GLYPHS[name];
144
+ if (!g)
145
+ return "";
146
+ return `<g transform="translate(${x.toFixed(2)} ${y.toFixed(2)}) scale(${scale.toFixed(5)} ${(-scale).toFixed(5)})">` +
147
+ `<path d="${g.path}" fill="${INK}"/></g>`;
148
+ }
149
+ /** A glyph carrying an SVG class (so downstream tracks / tests can select it). */
150
+ function classedGlyph(cls, name, x, y, scale) {
151
+ const g = GLYPHS[name];
152
+ if (!g)
153
+ return "";
154
+ return `<g class="${cls}" transform="translate(${x.toFixed(2)} ${y.toFixed(2)}) scale(${scale.toFixed(5)} ${(-scale).toFixed(5)})">` +
155
+ `<path d="${g.path}" fill="${INK}"/></g>`;
156
+ }
157
+ // The notehead is drawn at 0.825 of its engraved size. Only the GLYPH
158
+ // shrinks: advance still comes from NH_W and ADV, so the spacing — and
159
+ // therefore every x in the geometry — is exactly what it was. Bravura's black
160
+ // notehead is drawn for a five-line orchestral staff, and against chant's
161
+ // wider spacing and single melodic line it reads heavy; reduced, the note is a
162
+ // mark on the staff rather than a blot. Three quarters proved too far — the
163
+ // head lost the staff line it sits on — so this is that, opened back up a
164
+ // tenth.
165
+ const HEAD_K = 0.825;
166
+ /** Which head a written shape takes in the modern transcription.
167
+ *
168
+ * ONLY the double mora takes an open head. The square notation's other shapes
169
+ * — inclinatum, virga, quilisma, oriscus — are written distinctions, not
170
+ * durational ones, and moderna transcribes them all as the plain black head
171
+ * the modern staff means by "a note".
172
+ *
173
+ * This briefly mapped inclinatum to a half note, on the reasoning that the
174
+ * ambitus figure uses that shape to mark a mode's tenor. That confused a
175
+ * DIAGRAM's private vocabulary with the notation's: an inclinatum is 10.4% of
176
+ * every note in the corpus, so a tenth of every transcribed chant came out
177
+ * hollow, each one implying a length it does not have.
178
+ */
179
+ function notehead(x, y, small, half, gm) {
180
+ const s = gm.SCALE * (small ? 0.68 : 1.0) * HEAD_K;
181
+ // Centred on the ADVANCE, not on the shrunken glyph: the note has to sit
182
+ // where the engraving puts it, not drift left as the head gets smaller.
183
+ const w = 295 * gm.SCALE * (small ? 0.68 : 1.0);
184
+ const code = half ? G.noteheadHalf : G.noteheadBlack;
185
+ // The whole note is a wider glyph (422 units against 295), so it is centred
186
+ // on its OWN width — otherwise it would hang right of its advance.
187
+ const own = (GLYPHS[code]?.advance ?? 295) * s;
188
+ // Classed `note`, as quadrata's heads are. The class is the contract a
189
+ // caller selects on — the site rings and reddens the chosen note through it
190
+ // — and moderna emitted its heads bare, so nothing downstream could find
191
+ // them and selection did nothing in the modern transcription.
192
+ return classedGlyph("note", code, x - own / 2, y, s);
193
+ }
194
+ function clef(x, systemY, gm) {
195
+ return glyph(G.gClef8vb, x, systemY + gm.MTOP + 3 * gm.MSP, gm.SCALE);
196
+ }
197
+ function moraDots(x, y, onLine, gm) {
198
+ const dy = onLine ? -gm.MSP / 2 : 0;
199
+ return glyph(G.augmentationDot, x + 6.4, y + dy, gm.SCALE);
200
+ }
201
+ // The engraved slur: a filled two-cubic shape tapered to points, belly ~1.55.
202
+ function slur(x0, y0, x1, ytop) {
203
+ const span = x1 - x0;
204
+ const h = Math.min(3.8 + span * 0.075, 8.6);
205
+ const a0 = y0 - 4.7;
206
+ const co = ytop - 4.7 - h; // outer control height
207
+ const ci = co + 1.55; // inner control height (belly thickness)
208
+ const c0x = x0 + span * 0.30;
209
+ const c1x = x1 - span * 0.30;
210
+ return `<path class="slur" d="M ${x0.toFixed(2)} ${a0.toFixed(2)} ` +
211
+ `C ${c0x.toFixed(2)} ${co.toFixed(2)} ${c1x.toFixed(2)} ${co.toFixed(2)} ${x1.toFixed(2)} ${a0.toFixed(2)} ` +
212
+ `C ${c1x.toFixed(2)} ${ci.toFixed(2)} ${c0x.toFixed(2)} ${ci.toFixed(2)} ${x0.toFixed(2)} ${a0.toFixed(2)} Z" fill="${INK}"/>`;
213
+ }
214
+ function quilismaMark(x, y, gm) {
215
+ const s = gm.SCALE * 0.92;
216
+ const w = 416 * s;
217
+ return glyph(G.quilisma, x - gm.NH_W / 2 - w - 1.2, y + 149 * s, s);
218
+ }
219
+ // Accidental glyph scale (matches quadrata's noteScale * 0.62 factor).
220
+ const ACC_GAP = 1.2; // trailing air between accidental and notehead (as quilisma)
221
+ /** Horizontal room an accidental glyph reserves left of the notehead. */
222
+ function accidentalWidth(code, gm) {
223
+ const g = GLYPHS[code];
224
+ if (!g)
225
+ return 0;
226
+ return g.advance * (gm.SCALE * 0.62) + ACC_GAP;
227
+ }
228
+ /** Draw a standard/HEJI accidental glyph left of the notehead at (x, y). */
229
+ function accidentalMark(x, y, code, gm) {
230
+ return classedGlyph("accidental", code, x - gm.NH_W / 2 - accidentalWidth(code, gm), y, (gm.SCALE * 0.62));
231
+ }
232
+ const DIV_KIND = {
233
+ "`": "tick", ",": "tick", ";": "half", ":": "full", "::": "double",
234
+ };
235
+ function divisioMark(x, kind, top, final, gm) {
236
+ const bot = top + 4 * gm.MSP;
237
+ if (kind === "tick")
238
+ return `<line class="divisio" x1="${x.toFixed(2)}" y1="${top - 7}" x2="${x.toFixed(2)}" y2="${top - 1}" stroke="${INK}" stroke-width="0.9"/>`;
239
+ if (kind === "half")
240
+ return `<line class="divisio" x1="${x.toFixed(2)}" y1="${top + gm.MSP}" x2="${x.toFixed(2)}" y2="${top + 3 * gm.MSP}" stroke="${INK}" stroke-width="0.9"/>`;
241
+ if (kind === "full")
242
+ return `<line class="divisio" x1="${x.toFixed(2)}" y1="${top}" x2="${x.toFixed(2)}" y2="${bot}" stroke="${INK}" stroke-width="0.9"/>`;
243
+ if (final)
244
+ return `<line class="divisio" x1="${(x - 3.6).toFixed(2)}" y1="${top}" x2="${(x - 3.6).toFixed(2)}" y2="${bot}" stroke="${INK}" stroke-width="0.9"/>` +
245
+ `<line class="divisio" x1="${x.toFixed(2)}" y1="${top}" x2="${x.toFixed(2)}" y2="${bot}" stroke="${INK}" stroke-width="2.2"/>`;
246
+ return `<line class="divisio" x1="${(x - 3.2).toFixed(2)}" y1="${top}" x2="${(x - 3.2).toFixed(2)}" y2="${bot}" stroke="${INK}" stroke-width="0.9"/>` +
247
+ `<line class="divisio" x1="${x.toFixed(2)}" y1="${top}" x2="${x.toFixed(2)}" y2="${bot}" stroke="${INK}" stroke-width="0.9"/>`;
248
+ }
249
+ function textW(s) {
250
+ return s.replace(/-/g, "").length * 6.7 + 2;
251
+ }
252
+ // Lyric text arrives pre-decoded (the parser strips GABC markup into `runs`);
253
+ // the display trim here only clears syllable-joining hyphens and edge space.
254
+ const stripLyric = (s) => s.replace(/^-+/, "").replace(/-+$/, "").trim();
255
+ /**
256
+ * Render the tabula as moderna. Multi-system when `width` is set; single system
257
+ * otherwise. Returns the shared { svg, geometry } contract.
258
+ */
259
+ export function toModerna(rows, chant, options = {}) {
260
+ // The fonts option's lyric slot applies here too; moderna keeps its own
261
+ // engraved defaults for everything else. References only — never bundled.
262
+ const lyricSlot = options.fonts?.lyric;
263
+ const lyricFace = !lyricSlot
264
+ ? "'Crimson Pro', Georgia, serif"
265
+ : typeof lyricSlot === "string" ? lyricSlot : lyricSlot.family;
266
+ const lyricWeight = typeof lyricSlot === "object" && lyricSlot.weight != null
267
+ ? lyricSlot.weight
268
+ : 518;
269
+ const lyricScale = typeof lyricSlot === "object" && lyricSlot.scale != null
270
+ ? lyricSlot.scale
271
+ : 1;
272
+ const lyricEmbed = typeof lyricSlot === "object" && lyricSlot.embed
273
+ ? fontFaceCss([{ family: lyricFace, weight: lyricWeight, scale: 1, embed: lyricSlot.embed }])
274
+ : "";
275
+ // Every geometric constant for this render, derived from the requested staff
276
+ // height. Built once and passed to the helpers that draw — see metrics().
277
+ resolveInk(options);
278
+ const gm = metrics(options.staffHeight);
279
+ const padding = options.padding ?? 14;
280
+ const width = options.width ?? null;
281
+ const systemGap = options.systemGap ?? SYSTEM_GAP_DEFAULT;
282
+ // A requested track band widens every system by its reserved room. Moderna's
283
+ // tracks scale with the staff they annotate — gm.k is 1 at the default.
284
+ const bands = trackBands(options.tracks, gm.k);
285
+ const systemHeight = gm.LYRIC_Y + 24 + bands.extra + systemGap;
286
+ // Intonation channel: precompute each row's accidental/cents mark once (the
287
+ // repeat-suppression and heji guard live in the engine), keyed by identity.
288
+ const accMode = options.accidentals ?? "standard";
289
+ const marks = computeAccidentals(rows, accMode, options.centsBaseline ?? "pythagorean");
290
+ const markByRow = new Map();
291
+ rows.forEach((row, i) => { const m = marks[i]; if (m)
292
+ markByRow.set(row, m); });
293
+ // ── Front matter ── The same official display quadrata sets (title centered
294
+ // over the score, the genus/mode mark stacked at the left margin — the
295
+ // `annotation: "auto"` params), honoured here so both species open a piece
296
+ // the same way. No dropcap: tonus scores skip the illuminated capital (it
297
+ // conflicts with the analysis-track layouts).
298
+ const titleFace = options.fonts?.title;
299
+ const faceOf = (slot) => !slot ? lyricFace : typeof slot === "string" ? slot : slot.family;
300
+ const weightOf = (slot) => typeof slot === "object" && slot.weight != null ? slot.weight : null;
301
+ // NO MARGIN MARK, AND NO INITIAL. The genus/mode stack and the illuminated
302
+ // capital are quadrata's — they belong to the chant book, and moderna is a
303
+ // transcription onto a modern staff, which is read as an edition rather than
304
+ // as a page from the Liber. Moderna also carries the analysis tracks, and a
305
+ // left column reserved for a cap fights the bands they draw.
306
+ //
307
+ // `annotation` and `dropcap` are therefore IGNORED here, not refused: a
308
+ // species ignores options that do not apply to it (see inscriptio.ts), so
309
+ // one call can render either species without the caller stripping options.
310
+ const titleSize = 22;
311
+ let headerY = 0;
312
+ let titleBaseline = 0;
313
+ if (options.title) {
314
+ titleBaseline = titleSize;
315
+ headerY += titleSize * 1.4;
316
+ }
317
+ const body = [];
318
+ const slurs = [];
319
+ const lyricSvgs = [];
320
+ const lyricRuns = [];
321
+ const placements = [];
322
+ const systemMaxX = [];
323
+ let system = 0;
324
+ // Cents mode floats labels ABOVE the top staff line; pad the first system
325
+ // down so the staggered upper row doesn't clip the viewBox. The front-matter
326
+ // band pushes every system down by its height.
327
+ const topPad = (accMode === "cents" ? 12 : 0) + headerY;
328
+ let systemY = topPad;
329
+ // Every system reserves the same clef zone — the clef glyph at x=10 runs
330
+ // ~30px wide, and continuation systems once reset to padding+4, printing
331
+ // their first notes through it.
332
+ const CLEF_ZONE = 32;
333
+ let x = padding + CLEF_ZONE;
334
+ const clefSvgs = [clef(10, topPad, gm)];
335
+ // The floating cents band dodges its own collisions: two rows above the
336
+ // staff, greedy — a label crowding the last one on the low row steps up.
337
+ const CENTS_SIZE = 10;
338
+ const CENTS_MIN_GAP = 32; // a "−21.5" at 10px runs ~28px
339
+ const centsRowX = [-Infinity, -Infinity];
340
+ // Group rows into syllables (contiguous phrase+syllable index).
341
+ const sylKeys = [];
342
+ const bySyl = new Map();
343
+ for (const r of rows) {
344
+ const k = `${r.phraseIndex}.${r.syllableIndex}`;
345
+ if (!bySyl.has(k)) {
346
+ bySyl.set(k, []);
347
+ sylKeys.push(k);
348
+ }
349
+ bySyl.get(k).push(r);
350
+ }
351
+ // Width of a syllable and the gap that follows it — moderna's unit of
352
+ // advance, and the only measuring this emitter owes the break decision.
353
+ const sylWidth = (key) => {
354
+ const rows = bySyl.get(key) ?? [];
355
+ return rows.length * gm.ADV + gm.SYL_GAP;
356
+ };
357
+ const breakBefore = (k, si) => {
358
+ // Moderna breaks BETWEEN SYLLABLES, a finer granularity than quadrata's
359
+ // divisio, so the measurement is exact rather than statistical: the
360
+ // syllable's own notes at their own advance, plus its trailing gap.
361
+ //
362
+ // The rules governing the decision — `z` forces, `<nlba>` seals, a seal
363
+ // yields to the page, the boundary is never overrun — live in breaking.ts
364
+ // and are shared with quadrata. Only the measuring is moderna's own.
365
+ if (width == null)
366
+ return false;
367
+ const srows = bySyl.get(k) ?? [];
368
+ const head = srows[0];
369
+ if (!head)
370
+ return false;
371
+ // The sealed run from here to the close of its `<nlba>` group. Measured
372
+ // whole because admitting the head alone seals every seam after it, and
373
+ // the line could then no longer break at all.
374
+ let sealedRun = sylWidth(k);
375
+ for (let m = si + 1; m < sylKeys.length; m++) {
376
+ const mr = bySyl.get(sylKeys[m]) ?? [];
377
+ if (!mr[0]?.keepWithPrev)
378
+ break;
379
+ sealedRun += sylWidth(sylKeys[m]);
380
+ }
381
+ return decideBreak({
382
+ next: head,
383
+ x,
384
+ boundary: width - padding,
385
+ need: sealedRun,
386
+ sealedRun,
387
+ lineStart: padding + CLEF_ZONE,
388
+ }).break;
389
+ };
390
+ for (let si = 0; si < sylKeys.length; si++) {
391
+ const k = sylKeys[si];
392
+ const srows = bySyl.get(k);
393
+ // The engraver's own break (GABC `z`), which outranks the fit test — see
394
+ // the note in svg.ts. A sealed seam cannot suppress it: `z` is an
395
+ // instruction, not a preference.
396
+ const forced = si > 0 && srows[0].lineBreak;
397
+ if (si > 0 && (forced || breakBefore(k, si))) {
398
+ systemMaxX.push(x + padding);
399
+ system++;
400
+ systemY += systemHeight;
401
+ x = padding + CLEF_ZONE;
402
+ centsRowX[0] = centsRowX[1] = -Infinity;
403
+ clefSvgs.push(clef(10, systemY, gm));
404
+ }
405
+ // Display form: trimmed styled runs when markup rides, else the trimmed
406
+ // plain string — one derivation for measuring and drawing.
407
+ const spans = srows[0].runs ? trimRuns(srows[0].runs) : undefined;
408
+ const lyr = spans ? spans.map((s) => s.text).join("") : stripLyric(srows[0].lyric ?? "");
409
+ // Note x-positions within the syllable.
410
+ let nx = x + gm.NH_W / 2 + 1;
411
+ const notePos = [];
412
+ for (const r of srows) {
413
+ if (r.quilisma)
414
+ nx += 9.6; // room for the fused squiggle
415
+ const mk = markByRow.get(r);
416
+ if (mk?.kind === "glyph")
417
+ nx += accidentalWidth(mk.glyph, gm); // room for the accidental
418
+ const { y, steps } = writtenY(r.spn, systemY, gm);
419
+ notePos.push({ mx: nx, my: y, steps });
420
+ nx += gm.ADV + 4.6 * r.mora;
421
+ }
422
+ const notesW = nx - x - gm.ADV + gm.NH_W / 2 + 2;
423
+ const sylW = Math.max(notesW, textW(lyr));
424
+ // Draw notes.
425
+ srows.forEach((r, i) => {
426
+ const { mx, my, steps } = notePos[i];
427
+ const onLine = steps % 2 === 0;
428
+ if (r.quilisma)
429
+ body.push(quilismaMark(mx, my, gm));
430
+ const mk = markByRow.get(r);
431
+ if (mk?.kind === "glyph")
432
+ body.push(accidentalMark(mx, my, mk.glyph, gm));
433
+ else if (mk?.kind === "cents") {
434
+ // Cents labels float in a band above the staff (not glued to the
435
+ // head) — an analytic overlay, not an engraving mark.
436
+ const bandRow = mx - centsRowX[0] >= CENTS_MIN_GAP ? 0 : 1;
437
+ centsRowX[bandRow] = mx;
438
+ body.push(`<text class="cents" x="${mx.toFixed(2)}" y="${(systemY + gm.MTOP - 10 - bandRow * 10).toFixed(2)}" ` +
439
+ `text-anchor="middle" font-size="${CENTS_SIZE}" fill="#666" ` +
440
+ `font-family="'Crimson Pro', Georgia, serif">${esc(mk.label ?? "")}</text>`);
441
+ }
442
+ body.push(notehead(mx, my, r.liquescent, r.mora === 2, gm));
443
+ if (r.mora === 1)
444
+ body.push(moraDots(mx, my, onLine, gm));
445
+ placements.push({ row: r, x: mx, y: my, system, systemY });
446
+ });
447
+ // Slurs per figure; strophic runs on the same pitch merge into one.
448
+ const figs = [];
449
+ let curG = -1;
450
+ for (const r of srows) {
451
+ if (r.neumeGroup !== curG) {
452
+ figs.push([]);
453
+ curG = r.neumeGroup;
454
+ }
455
+ figs[figs.length - 1].push(r);
456
+ }
457
+ const merged = [];
458
+ for (const fg of figs) {
459
+ const prev = merged[merged.length - 1];
460
+ if (prev && fg.every((q) => q.strophicus) && prev.every((q) => q.strophicus) &&
461
+ fg[0].spn === prev[prev.length - 1].spn) {
462
+ prev.push(...fg);
463
+ }
464
+ else
465
+ merged.push(fg);
466
+ }
467
+ for (const fg of merged) {
468
+ if (fg.length > 1) {
469
+ const idxs = fg.map((r) => srows.indexOf(r));
470
+ const ytop = Math.min(...idxs.map((i) => notePos[i].my));
471
+ slurs.push(slur(notePos[idxs[0]].mx, notePos[idxs[0]].my, notePos[idxs[idxs.length - 1]].mx, ytop));
472
+ }
473
+ }
474
+ // Lyric — collected here, emitted (with centred hyphens between same-word
475
+ // syllables, matching quadrata's Vendôme practice) after the walk.
476
+ const tx = notePos[0].mx - gm.NH_W / 2;
477
+ if (lyr) {
478
+ lyricRuns.push({ x: tx, systemY, text: lyr, spans, wordStart: srows[0].wordStart });
479
+ }
480
+ x += sylW + gm.SYL_GAP;
481
+ // Divisio at a phrase end.
482
+ const last = srows[srows.length - 1];
483
+ const nextK = si + 1 < sylKeys.length ? sylKeys[si + 1] : null;
484
+ const nextPhrase = nextK ? Number(nextK.split(".")[0]) : -1;
485
+ if (last.divisio && (nextK === null || nextPhrase !== last.phraseIndex)) {
486
+ const kind = DIV_KIND[last.divisio] ?? "full";
487
+ const pad = { tick: 2, half: 5, full: 7, double: 9 }[kind] ?? 7;
488
+ const isFinal = nextK === null;
489
+ body.push(divisioMark(x + pad - 4, kind, systemY + gm.MTOP, isFinal, gm));
490
+ x += pad + 8;
491
+ }
492
+ }
493
+ systemMaxX.push(x + padding);
494
+ // The canvas is what the CALLER asked for, not what the content happened to
495
+ // reach. Width was `max(systemMaxX)` in both species, so a render of a
496
+ // requested 900 came out 915, 986, 1074, 1203 — whatever the widest system
497
+ // ended at. A host applying `max-width: 100%` then shrank each render by a
498
+ // different factor, which is why the same page showed one chant's notation
499
+ // a third smaller than another's, and why the two species never agreed:
500
+ // measured across fourteen graduals, moderna landed between 0.66 and 0.95
501
+ // of quadrata's on-screen size with no pattern a reader could learn.
502
+ //
503
+ // `width` is the wrap point, and now also the canvas. Content still wraps
504
+ // inside it; it no longer decides how big the picture is. Without a width
505
+ // there is nothing to wrap to and the content still sets the size.
506
+ const contentW = Math.ceil(Math.max(...systemMaxX));
507
+ // The requested width — see the matching note in svg.ts.
508
+ const W = width != null ? Math.ceil(width) : contentW;
509
+ const height = Math.ceil(systemY + gm.LYRIC_Y + 24 + bands.extra);
510
+ // ── The analysis tracks, below each system ──
511
+ // Downstream of the notation: they consume the placements (the same anchors
512
+ // the geometry contract exports), never the transcription's own ink.
513
+ if (bands.prosodia || bands.chironomia || bands.tonarium) {
514
+ const trackNotes = placements.map((pl) => ({
515
+ row: pl.row, x: pl.x, y: pl.y, system: pl.system, systemY: pl.systemY,
516
+ // Moderna centres its noteheads on the anchor, so the ink edges are
517
+ // derived; quadrata's square glyphs start at the anchor and are
518
+ // measured as they are placed, so it records them.
519
+ inkLeft: pl.x - gm.NH_W / 2, inkRight: pl.x + gm.NH_W / 2,
520
+ }));
521
+ if (bands.prosodia) {
522
+ body.push(buildProsodia(trackNotes, {
523
+ k: gm.k,
524
+ laneTop: gm.LYRIC_Y + bands.prosodia.top,
525
+ rightFor: (s) => (systemMaxX[s] ?? W) - padding,
526
+ rubricaColor: RUBRICA,
527
+ }));
528
+ }
529
+ if (bands.chironomia) {
530
+ // The wave's constants are calibrated at quadrata's default staff
531
+ // interval, near enough to moderna's fixed staff space to read at k: 1.
532
+ body.push(buildChironomia(trackNotes, {
533
+ k: gm.k,
534
+ waveMidY: gm.LYRIC_Y + bands.chironomia.top + 33 * gm.k,
535
+ }));
536
+ }
537
+ if (bands.tonarium) {
538
+ body.push(buildTonarium(trackNotes, options.trackData ?? { cadences: [], modulations: [] }, {
539
+ k: gm.k,
540
+ laneTop: gm.LYRIC_Y + bands.tonarium.top + 26 * gm.k,
541
+ rightFor: (s) => (systemMaxX[s] ?? W) - padding,
542
+ serifFamily: lyricFace,
543
+ rubricaColor: RUBRICA,
544
+ }));
545
+ }
546
+ }
547
+ // Staff lines: five per system.
548
+ const staff = [];
549
+ for (let s = 0; s <= system; s++) {
550
+ const sysY = s * systemHeight + topPad;
551
+ const right = (systemMaxX[s] ?? W) - padding;
552
+ for (let i = 0; i < 5; i++) {
553
+ const ly = sysY + gm.MTOP + i * gm.MSP;
554
+ staff.push(`<line x1="4" y1="${ly.toFixed(2)}" x2="${right.toFixed(2)}" y2="${ly.toFixed(2)}" stroke="${INK}" stroke-width="0.7"/>`);
555
+ }
556
+ }
557
+ const svgTitle = chant.incipit ? `<title>${esc(chant.incipit)}</title>` : "";
558
+ // Second pass: lyric texts, with a centred hyphen in the gap between
559
+ // syllables of one word when both sit in the same system.
560
+ const lyricSize = 15 * lyricScale;
561
+ const rubricaColor = RUBRICA;
562
+ const estW = (t) => t.length * lyricSize * 0.52;
563
+ for (let k = 0; k < lyricRuns.length; k++) {
564
+ const run = lyricRuns[k];
565
+ lyricSvgs.push(`<text class="lyric" x="${run.x.toFixed(2)}" y="${(run.systemY + gm.LYRIC_Y).toFixed(2)}" ` +
566
+ `font-size="${lyricSize.toFixed(1)}" ` +
567
+ `font-weight="${lyricWeight}" fill="${INK}" font-family="${esc(lyricFace)}">${lyricMarkup(run.spans, run.text, rubricaColor)}</text>`);
568
+ const next = lyricRuns[k + 1];
569
+ const hyphen = (hx) => {
570
+ lyricSvgs.push(`<text class="lyric hyphen" x="${hx.toFixed(2)}" y="${(run.systemY + gm.LYRIC_Y).toFixed(2)}" ` +
571
+ `text-anchor="middle" font-size="${lyricSize.toFixed(1)}" ` +
572
+ `font-weight="${lyricWeight}" fill="${INK}" font-family="${esc(lyricFace)}">-</text>`);
573
+ };
574
+ if (next && !next.wordStart && next.systemY === run.systemY) {
575
+ const thisRight = run.x + estW(run.text);
576
+ if (next.x - thisRight > lyricSize * 0.25)
577
+ hyphen((thisRight + next.x) / 2);
578
+ }
579
+ else if (next && !next.wordStart) {
580
+ // A word carried to the next system takes its hyphen at the line's end —
581
+ // the same bug as quadrata's, and the same fix. The gap rule above cannot
582
+ // see this case: there is no gap between the halves, there is a break.
583
+ hyphen(run.x + estW(run.text) + lyricSize * 0.3);
584
+ }
585
+ }
586
+ // Front-matter text, deferred so the title centers on the final width.
587
+ const header = [];
588
+ if (options.title) {
589
+ header.push(`<text class="title" x="${(W / 2).toFixed(2)}" y="${titleBaseline.toFixed(2)}" ` +
590
+ `text-anchor="middle" font-family="${esc(faceOf(titleFace))}"` +
591
+ `${weightOf(titleFace) != null ? ` font-weight="${weightOf(titleFace)}"` : ""} ` +
592
+ `font-size="${titleSize}" fill="${INK}">${esc(options.title)}</text>`);
593
+ }
594
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${height}" ` +
595
+ `width="${W}" height="${height}" class="tonus-chant moderna">${svgTitle}` +
596
+ lyricEmbed +
597
+ header.join("") +
598
+ staff.join("") + clefSvgs.join("") + body.join("") + slurs.join("") + lyricSvgs.join("") +
599
+ `</svg>`;
600
+ const geometry = placements.map((pl) => ({
601
+ phraseIndex: pl.row.phraseIndex,
602
+ syllableIndex: pl.row.syllableIndex,
603
+ neumeGroup: pl.row.neumeGroup,
604
+ noteIndex: pl.row.neumeIndex,
605
+ system: pl.system,
606
+ x: Number(pl.x.toFixed(2)),
607
+ y: Number(pl.y.toFixed(2)),
608
+ systemY: Number(pl.systemY.toFixed(2)),
609
+ }));
610
+ return { svg, geometry };
611
+ }
612
+ //# sourceMappingURL=moderna.js.map