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,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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|