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