Skip to main content

chordsketch_render_ireal/
lib.rs

1//! iReal Pro chart renderer — SVG with chord-name typography,
2//! repeat barlines, ending brackets, section labels, and music
3//! symbols.
4//!
5//! This crate renders an [`chordsketch_ireal::IrealSong`] AST as a
6//! fixed-size SVG document. The current scope covers the page
7//! frame, the metadata header (title / composer / style / key),
8//! the 4-bars-per-line grid with section line breaks, superscript
9//! chord-name typography (root + accidental at base size, quality
10//! / extensions raised as superscript, slash + bass back at base
11//! size), repeat / final / double barline glyphs, N-th-ending
12//! brackets with `1.` / `2.` labels, section-letter labels above
13//! each section start, and music symbols (segno / coda glyphs;
14//! `D.C.` / `D.S.` / `Fine` text directives) above the bar that
15//! carries them. Tracked under
16//! [#2050](https://github.com/koedame/chordsketch/issues/2050).
17//!
18//! # Layout overview
19//!
20//! Output is a fixed-size SVG `(595 × 842)` with deterministic
21//! integer coordinates so golden snapshots remain byte-stable.
22//! The page is divided into:
23//!
24//! - **Header band** — title (top), composer (right), style + key
25//!   (left, beneath the title).
26//! - **Bar grid** — bars laid out 4-per-row by the
27//!   [`layout::compute_layout`] engine. Each cell carries a
28//!   centred chord-name `<text>` with mixed `<tspan>` runs:
29//!   root + accidental at base size, quality / extensions
30//!   raised as superscript at a smaller size, slash + bass at
31//!   base size on the original baseline. Bar boundaries display
32//!   the appropriate barline glyph (`Single` via the cell-rect
33//!   stroke; `Double`, `Final`, `OpenRepeat`, `CloseRepeat`
34//!   overlay the cell stroke). N-th-ending brackets, section-
35//!   letter labels, and music-symbol glyphs all sit above the
36//!   row in the same band; music symbols are drawn last so they
37//!   layer on top of any overlapping bracket. Trailing cells in a
38//!   section's last row are filled with empty placeholders so the
39//!   visible grid stays a clean rectangle.
40//!
41//! # Dependency policy
42//!
43//! Only depends on [`chordsketch_ireal`] for the AST. SVG
44//! generation is hand-rolled — no `xmlwriter`, no `svg` crate, no
45//! templating engine. Keeps the transitive-dep surface minimal and
46//! mirrors the zero-external-dep posture of `chordsketch-chordpro`
47//! / `chordsketch-ireal`.
48//!
49//! Enabling the `png` cargo feature additionally pulls in `resvg`
50//! and `tiny-skia` for the `png::render_png` rasteriser; enabling
51//! `pdf` pulls in `svg2pdf` for the `pdf::render_pdf` converter.
52//! Both features are off by default; SVG-only consumers stay on
53//! the single-dep build. (Inline code-spans — not intra-doc links —
54//! because the `png` and `pdf` modules are `#[cfg(feature = ...)]`
55//! and a crate-level rustdoc link would break the default-features
56//! `cargo doc --no-deps` run that gates CI.)
57//!
58//! # Cargo features
59//!
60//! | Feature | Default? | Notes |
61//! |---|---|---|
62//! | `png` | off | Enables `png::render_png` (rasterises the SVG via `resvg`). |
63//! | `pdf` | off | Enables `pdf::render_pdf` (converts the SVG to PDF via `svg2pdf`). |
64//!
65//! # Stability
66//!
67//! Pre-1.0. The SVG output structure is expected to grow new
68//! elements as the iReal Pro tracker (#2050) closes its remaining
69//! items. Existing elements stay stable so that crate consumers
70//! (the playground preview, the PDF rasteriser #2063, the PNG
71//! rasteriser #2064) can rely on a small set of stable selectors
72//! / IDs (`class="title"`, `class="composer"`, `class="meta"`,
73//! `class="bar-grid"`, `class="chord"`, `class="chord-root"`,
74//! `class="chord-ext"`, `class="chord-slash"`, `class="chord-bass"`,
75//! `class="empty"`, `class="section-label"`,
76//! `class="ending-bracket"`, `class="ending-label"`,
77//! `class="barline-double"`, `class="barline-final"`,
78//! `class="barline-repeat-thick"`, `class="barline-repeat-thin"`,
79//! `class="barline-repeat-dot"`, `class="music-symbol-segno"`,
80//! `class="music-symbol-coda"`, `class="music-symbol-text"`,
81//! `class="staff-text"`).
82//!
83//! The previous segno / coda selector set
84//! (`music-symbol-segno-curve` / `-slash` / `-dot` and
85//! `music-symbol-coda-circle` / `-cross`) covered SVG-primitive
86//! approximations and was removed when #2348 swapped in real
87//! Bravura SMuFL outlines as a single `<path>` element each.
88//! Stylesheets that previously targeted any of those selectors
89//! should retarget to the consolidated
90//! `class="music-symbol-segno"` / `class="music-symbol-coda"`,
91//! which is now a single filled `<path>` per glyph (no stroke).
92//! The crate is pre-1.0 so this is documented as a stability note
93//! rather than a breaking-change deprecation cycle.
94//!
95//! # Example
96//!
97//! ```
98//! use chordsketch_ireal::IrealSong;
99//! use chordsketch_render_ireal::{RenderOptions, render_svg};
100//!
101//! let song = IrealSong::new();
102//! let svg = render_svg(&song, &RenderOptions::default());
103//! assert!(svg.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
104//! assert!(svg.contains("<svg "));
105//! ```
106
107#![forbid(unsafe_code)]
108
109mod barlines;
110mod bravura;
111pub mod chord_typography;
112pub mod layout;
113mod markers;
114mod music_symbols;
115pub mod page;
116#[cfg(feature = "pdf")]
117pub mod pdf;
118#[cfg(feature = "png")]
119pub mod png;
120mod staff_texts;
121mod svg;
122
123use chordsketch_ireal::{BarChord, BarChordKind, ChordSize, IrealSong};
124
125pub use chord_typography::{ChordTypography, SpanKind, TypographySpan, chord_to_typography};
126pub use layout::{BarCoord, EmptyCell, Layout, compute_layout};
127pub use page::{
128    BAR_ROW_HEIGHT, BARS_PER_ROW, CHORD_FONT_SIZE_BASE, CHORD_FONT_SIZE_BASE_SMALL,
129    CHORD_FONT_SIZE_SUPERSCRIPT, GRID_TOP, HEADER_BAND_HEIGHT, MARGIN_X, MARGIN_Y, MAX_BARS,
130    MAX_CHORDS_PER_BAR, MAX_SECTIONS, PAGE_HEIGHT, PAGE_WIDTH,
131};
132
133/// Caller-supplied render configuration.
134///
135/// The scaffold accepts only defaults. Adding fields is non-breaking
136/// because the struct is `#[non_exhaustive]`; callers must construct
137/// it via [`RenderOptions::default`] (or `RenderOptions { ..default() }`
138/// once a setter materialises) so future additions cannot drop a
139/// caller's customisations.
140#[derive(Debug, Clone, Default, PartialEq, Eq)]
141#[non_exhaustive]
142pub struct RenderOptions {}
143
144/// Renders an iReal Pro chart as a fixed-size SVG document.
145///
146/// The output is well-formed SVG 1.1 with deterministic integer
147/// coordinates so golden tests remain byte-stable. See the crate
148/// documentation for the layout contract.
149///
150/// # Resource limits
151///
152/// The bar count is clamped to [`MAX_BARS`] before any allocation;
153/// surplus bars are silently truncated. This mirrors the input-
154/// bounds-check pattern in `chordsketch-chordpro`'s chord-diagram
155/// renderer and the `MAX_COLUMNS` clamp in the HTML renderer (per
156/// the validation-parity clause in `.claude/rules/renderer-parity.md`)
157/// and prevents both unbounded `format!` allocation and overflow in
158/// the y-coordinate arithmetic.
159#[must_use = "rendering produces a string the caller is expected to consume"]
160pub fn render_svg(song: &IrealSong, _options: &RenderOptions) -> String {
161    let layout = compute_layout(song);
162    let mut out = String::new();
163    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
164    out.push_str(&format!(
165        "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{PAGE_WIDTH}\" \
166height=\"{PAGE_HEIGHT}\" viewBox=\"0 0 {PAGE_WIDTH} {PAGE_HEIGHT}\">\n"
167    ));
168    write_page_frame(&mut out);
169    write_header(&mut out, song);
170    write_grid(&mut out, song, &layout);
171    out.push_str("</svg>\n");
172    out
173}
174
175fn write_page_frame(out: &mut String) {
176    // Pure white page; the engraved chart no longer paints a
177    // black 1px frame around the entire SVG (it competed with the
178    // chart's own barlines and read as "boxed", not "engraved").
179    out.push_str(&format!(
180        "  <rect x=\"0\" y=\"0\" width=\"{PAGE_WIDTH}\" height=\"{PAGE_HEIGHT}\" \
181fill=\"white\"/>\n"
182    ));
183}
184
185fn write_header(out: &mut String, song: &IrealSong) {
186    // Three-column header band — italic Source-Serif style label
187    // on the left, centred bold title, italic "Lead Sheet" tag on
188    // the right. Mirrors the chart-card header in
189    // `design-system/ui_kits/web/editor-irealb.html`.
190    let header_top = MARGIN_Y;
191    let center_y = header_top + 32;
192    let raw_title = if song.title.is_empty() {
193        "Untitled"
194    } else {
195        song.title.as_str()
196    };
197    let title_text = svg::escape_xml(raw_title);
198    let center_x = PAGE_WIDTH / 2;
199    // Header typography — Source Serif 4 italic for the (style) /
200    // Lead Sheet / composer marks per
201    // `design-system/ui_kits/web/editor-irealb.html`. The host
202    // (playground / desktop / VS Code preview) loads the design-
203    // system fonts via Google Fonts; the SVG falls back through
204    // `serif` for environments that did not preload the family.
205    let serif_stack = "'Source Serif 4', Georgia, serif";
206    out.push_str(&format!(
207        "  <text x=\"{center_x}\" y=\"{center_y}\" font-family=\"{serif_stack}\" \
208font-weight=\"700\" font-size=\"22\" text-anchor=\"middle\" class=\"title\">{title_text}</text>\n"
209    ));
210    let style = song.style.as_deref().unwrap_or("Medium Swing");
211    let style_text = svg::escape_xml(&format!("({style})"));
212    out.push_str(&format!(
213        "  <text x=\"{MARGIN_X}\" y=\"{center_y}\" font-family=\"{serif_stack}\" \
214font-style=\"italic\" font-size=\"13\" class=\"meta\">{style_text}</text>\n"
215    ));
216    let lead_x = PAGE_WIDTH - MARGIN_X;
217    out.push_str(&format!(
218        "  <text x=\"{lead_x}\" y=\"{center_y}\" font-family=\"{serif_stack}\" \
219font-style=\"italic\" font-size=\"13\" text-anchor=\"end\" class=\"lead-sheet\">Lead Sheet</text>\n"
220    ));
221    if let Some(composer) = song.composer.as_deref() {
222        let escaped = svg::escape_xml(composer);
223        out.push_str(&format!(
224            "  <text x=\"{center_x}\" y=\"{composer_y}\" font-family=\"{serif_stack}\" \
225font-size=\"12\" font-style=\"italic\" text-anchor=\"middle\" class=\"composer\">{escaped}</text>\n",
226            composer_y = center_y + 18,
227        ));
228    }
229    // Thin rule separating the header band from the chart body.
230    let rule_y = header_top + page::HEADER_BAND_HEIGHT - 8;
231    out.push_str(&format!(
232        "  <line x1=\"{MARGIN_X}\" y1=\"{rule_y}\" x2=\"{rule_x2}\" y2=\"{rule_y}\" \
233stroke=\"#E8E6EA\" stroke-width=\"1\"/>\n",
234        rule_x2 = PAGE_WIDTH - MARGIN_X,
235    ));
236}
237
238/// Returns `note` if it is in the documented `'A'..='G'` uppercase
239/// ASCII range, otherwise `'?'`. Single source of truth for the
240/// out-of-range fallback shared with
241/// [`crate::chord_typography::chord_to_typography`]'s root / bass
242/// writers, so a future tightening of the rule (per
243/// `.claude/rules/sanitizer-security.md` "security asymmetry")
244/// only needs to change one site.
245///
246/// The AST documents `root.note` as `'A'..='G'` uppercase ASCII
247/// but the field is `pub` and not validated at construction. A
248/// malformed AST that flows in via direct field assignment still
249/// produces a deterministic, non-malicious string — `'?'` is
250/// visually distinct from any valid one and is unaffected by
251/// [`crate::svg::escape_xml`].
252pub(crate) fn note_glyph_or_fallback(note: char) -> char {
253    if matches!(note, 'A'..='G') { note } else { '?' }
254}
255
256fn write_grid(out: &mut String, song: &IrealSong, layout: &Layout) {
257    if layout.bars.is_empty() && layout.trailing_empties.is_empty() {
258        return;
259    }
260    out.push_str("  <g class=\"bar-grid\">\n");
261
262    // Group bars by row so we can decide which barline (left/right)
263    // to draw per cell. The layout engine already groups via
264    // `cell.y`; bars sharing a `y` belong to the same chart line.
265    // The right-side barline of a bar that abuts another bar in the
266    // same line is the LEFT side of its right neighbour, so we only
267    // need to emit one barline per boundary.
268
269    // First, paint barlines for filled cells. Each cell contributes
270    // its left barline (single by default, or the kind the bar's
271    // `start` field declares). The rightmost cell in a row also
272    // contributes a right barline at its right edge.
273    for (idx, cell) in layout.bars.iter().enumerate() {
274        let bar = song
275            .sections
276            .get(cell.section_index)
277            .and_then(|s| s.bars.get(cell.bar_index_in_section));
278        let start_kind = bar
279            .map(|b| b.start)
280            .unwrap_or(chordsketch_ireal::BarLine::Single);
281        let end_kind = bar
282            .map(|b| b.end)
283            .unwrap_or(chordsketch_ireal::BarLine::Single);
284        out.push_str(&barlines::render_left_barline(cell, start_kind));
285        // Emit the right barline only when no neighbour will paint
286        // a left barline at the same x. The next filled cell with
287        // the same `y` would do so; otherwise (end of row, end of
288        // section, end of song) the right barline closes the bar.
289        let next_filled_at_same_y = layout
290            .bars
291            .get(idx + 1)
292            .is_some_and(|next| next.y == cell.y && next.x == cell.x + cell.width);
293        if !next_filled_at_same_y {
294            out.push_str(&barlines::render_right_barline(cell, end_kind));
295        }
296        let chords = chords_for_bar(song, cell);
297        write_bar_chord_text(out, cell, chords, song.time_signature.numerator);
298    }
299
300    // Trailing empties stay invisible — the engraved chart
301    // doesn't paint placeholder barlines past a section's last
302    // real bar. The empties are still tracked so future renderers
303    // (PDF / PNG #2063 / #2064) keep deterministic layout
304    // boundaries; the `for` loop below is preserved as a no-op
305    // to keep the layout-engine contract obvious.
306    for _empty in &layout.trailing_empties {
307        // intentionally empty — see comment above.
308    }
309    // Time signature on line 1.
310    write_time_signature(out, song, layout);
311    // Section labels (black-filled square with letter), ending
312    // brackets, and music-symbol glyphs all sit ABOVE the cells in
313    // the row's gap area. Paint them last so they layer above the
314    // chord text. Music symbols come last so their glyphs sit on
315    // top of any overlapping ending bracket.
316    out.push_str(&markers::render_section_labels(song, layout));
317    out.push_str(&markers::render_endings(song, layout));
318    out.push_str(&music_symbols::render_music_symbols(song, layout));
319    // Staff text (#2426) sits below the bar by default and above
320    // the bar at `*74`; emit after music symbols so an above-the-
321    // bar caption can layer correctly when both happen on the same
322    // bar.
323    out.push_str(&staff_texts::render_staff_texts(song, layout));
324    out.push_str("  </g>\n");
325}
326
327/// Stacked numerator/denominator at the very start of line 1, in
328/// the reserved indent area before the first bar's left barline.
329/// Renders only when the AST carries a non-default time signature
330/// or always — iReal Pro charts always show the time signature on
331/// line 1.
332fn write_time_signature(out: &mut String, song: &IrealSong, layout: &Layout) {
333    let Some(first) = layout.bars.first() else {
334        return;
335    };
336    let num = song.time_signature.numerator;
337    let denom = song.time_signature.denominator;
338    // Centre the digits in the indent area immediately to the left
339    // of the first bar's barline, vertically centred against the
340    // bar's chord row.
341    let cx = first.x - 14;
342    let cy = first.y + first.height / 2;
343    let num_y = cy - 4;
344    let denom_y = cy + 14;
345    out.push_str(&format!(
346        "    <text x=\"{cx}\" y=\"{num_y}\" font-family=\"serif\" \
347font-weight=\"700\" font-size=\"16\" text-anchor=\"middle\" class=\"time-sig-num\">{num}</text>\n"
348    ));
349    out.push_str(&format!(
350        "    <line x1=\"{x1}\" y1=\"{y}\" x2=\"{x2}\" y2=\"{y}\" \
351stroke=\"black\" stroke-width=\"1\" class=\"time-sig-rule\"/>\n",
352        x1 = cx - 6,
353        x2 = cx + 6,
354        y = cy + 1,
355    ));
356    out.push_str(&format!(
357        "    <text x=\"{cx}\" y=\"{denom_y}\" font-family=\"serif\" \
358font-weight=\"700\" font-size=\"16\" text-anchor=\"middle\" class=\"time-sig-denom\">{denom}</text>\n"
359    ));
360}
361
362fn chords_for_bar<'a>(song: &'a IrealSong, cell: &BarCoord) -> &'a [BarChord] {
363    // The layout engine guarantees `section_index` and
364    // `bar_index_in_section` are valid for the song that produced
365    // it, but defensive `get` lookups keep the renderer crash-free
366    // if a caller hand-rolls a `Layout` for a different song.
367    song.sections
368        .get(cell.section_index)
369        .and_then(|s| s.bars.get(cell.bar_index_in_section))
370        .map(|b| b.chords.as_slice())
371        .unwrap_or(&[])
372}
373
374/// Emits one `<text>` element per bar containing typography
375/// `<tspan>` runs — root + accidental at base size, quality /
376/// extension(s) raised as superscript at a smaller size, slash +
377/// bass returning to the base size on the original baseline.
378///
379/// Multi-chord bars (split bars) are rendered as a single
380/// space-separated `<text>` whose children alternate per chord.
381/// Beat-aware horizontal placement (one chord per beat slot)
382/// requires bar-cell subdivision and is deferred to a follow-up
383/// of the iReal Pro tracker (#2050).
384/// Beat-positioned chord typography.
385///
386/// editor-irealb.html lays each bar out as a 4-column metric grid
387/// (one column per beat in 4/4); chords with `position.beat = N`
388/// land in column N. We approximate the same here: a bar's chord
389/// list is mapped onto `time_signature.numerator` equal slots, each
390/// chord's slot derived from its `position.beat` (clamped to the
391/// numerator), and emitted as one `<text>` element per chord with
392/// its own `x` anchor. This produces the metric placement the
393/// reference shows without requiring full grid lines.
394fn write_bar_chord_text(out: &mut String, cell: &BarCoord, chords: &[BarChord], beats_per_bar: u8) {
395    if chords.is_empty() {
396        return;
397    }
398    let chord_limit = chords.len().min(page::MAX_CHORDS_PER_BAR);
399    let beats = beats_per_bar.max(1) as i32;
400    // Inset the beat columns inside the bar so glyphs don't kiss
401    // the barlines. 6 px on each side keeps the chord ink clear of
402    // the 1 px barline strokes at the bar boundaries.
403    let inner_left = cell.x + 8;
404    let inner_right = cell.x + cell.width - 4;
405    let inner_w = (inner_right - inner_left).max(0);
406    // Chord baseline sits ~62 % of the way down the bar so the
407    // engraved cap-line lands roughly at the bar's centre and the
408    // descender clears the lower barline area.
409    let base_y = cell.y + (cell.height * 62) / 100;
410    // Distribution strategy:
411    //
412    //   * exactly-one-chord bar → place it at the chord's beat,
413    //     defaulting to beat 1 (the leftmost slot).
414    //   * multiple chords at distinct beats → place each at its
415    //     own beat slot.
416    //   * multiple chords sharing a beat (or more chords than
417    //     beats — common for irealb URLs that pack 4 chords into a
418    //     3/4 bar without explicit beat data) → fall back to
419    //     even-spaced index distribution.
420    //
421    // Compact-mode horizontal scale (~70 %) kicks in whenever the
422    // chord count exceeds the beat count so dense bars stay
423    // legible.
424    let unique_beats: std::collections::BTreeSet<i32> = chords
425        .iter()
426        .take(chord_limit)
427        .map(|bc| bc.position.beat.get() as i32)
428        .collect();
429    let by_beat = chord_limit <= beats as usize && unique_beats.len() == chord_limit;
430    let compact = (chord_limit as i32) > beats || !by_beat;
431    let scale_pct: f32 = if compact { 0.7 } else { 1.0 };
432    for (i, bc) in chords.iter().take(chord_limit).enumerate() {
433        let slot = if by_beat {
434            let beat = bc.position.beat.get() as i32;
435            ((beat - 1) * inner_w) / beats
436        } else {
437            ((i as i32) * inner_w) / (chord_limit as i32)
438        };
439        let chord_x = inner_left + slot;
440        let transform_attr = if compact {
441            // SVG transforms compose right-to-left around the
442            // origin: pre-translate to the anchor, scale, then
443            // un-translate so the chord stays centred on its slot.
444            format!(
445                " transform=\"translate({chord_x} 0) scale({scale_pct} 1) translate({negx} 0)\"",
446                negx = -chord_x,
447            )
448        } else {
449            String::new()
450        };
451        // Pick the root font size from the chord's size hint. The
452        // iReal Pro `s` marker stamps `ChordSize::Small` on every
453        // chord between `s` and the next `l` so dense bars stay
454        // legible; the AST carries the hint per-chord so the renderer
455        // can paint each glyph at its own size without losing
456        // granularity.
457        let base = match bc.size {
458            ChordSize::Default => page::CHORD_FONT_SIZE_BASE,
459            ChordSize::Small => page::CHORD_FONT_SIZE_BASE_SMALL,
460        };
461        // Pause-slash repeats render as a single forward-slash glyph
462        // in place of chord typography — the iReal Pro spec's
463        // "repeat the preceding chord" cue. The slash sits on the
464        // same baseline as a chord root, sized to match, so packed
465        // bars like `|C7,p,p,p,|` line up cleanly.
466        if bc.kind == BarChordKind::SlashRepeat {
467            out.push_str(&format!(
468                "    <text x=\"{chord_x}\" y=\"{base_y}\" font-family=\"Roboto, sans-serif\" \
469font-weight=\"700\" font-size=\"{base}\" class=\"chord slash-repeat\"{transform_attr}>/</text>\n",
470            ));
471            continue;
472        }
473        out.push_str(&format!(
474            "    <text x=\"{chord_x}\" y=\"{base_y}\" font-family=\"Roboto, sans-serif\" \
475font-weight=\"700\" font-size=\"{base}\" class=\"chord\"{transform_attr}>",
476        ));
477        let typography = chord_typography::chord_to_typography(&bc.chord);
478        write_chord_spans(out, &typography, base);
479        out.push_str("</text>\n");
480    }
481}
482
483fn write_chord_spans(out: &mut String, typography: &ChordTypography, base_size: i32) {
484    // Cumulative `dy` cursor position. SVG `dy` is relative to the
485    // previous span's baseline, so each transition between baseline
486    // states must emit the inverse of the previous shift before
487    // applying the new one. Tracking the current offset keeps that
488    // accounting honest across Root → Accidental → Extension →
489    // Slash → Bass → Accidental sequences.
490    let mut current_dy: i32 = 0;
491    // Scale the accidental / extension sizes and their baseline
492    // shifts proportionally to `base_size`. At `base_size ==
493    // CHORD_FONT_SIZE_BASE` (32) these collapse to the original
494    // constants so existing Default-size golden snapshots stay
495    // byte-stable. For `CHORD_FONT_SIZE_BASE_SMALL` (22) the
496    // accidental / extension / dys shrink by the same ~70 %
497    // ratio so the engraved chord keeps its visual hierarchy.
498    let acc_size = (base_size * page::CHORD_FONT_SIZE_ACCIDENTAL) / page::CHORD_FONT_SIZE_BASE;
499    let ext_size = (base_size * page::CHORD_FONT_SIZE_SUPERSCRIPT) / page::CHORD_FONT_SIZE_BASE;
500    let acc_dy = (base_size * page::CHORD_ACCIDENTAL_DY) / page::CHORD_FONT_SIZE_BASE;
501    let qual_dy = (base_size * page::CHORD_QUALITY_DY) / page::CHORD_FONT_SIZE_BASE;
502    for span in &typography.spans {
503        let escaped = svg::escape_xml(&span.text);
504        match span.kind {
505            SpanKind::Root => {
506                let restore = -current_dy;
507                let dy_attr = if restore == 0 {
508                    String::new()
509                } else {
510                    format!(" font-size=\"{base_size}\" dy=\"{restore}\"")
511                };
512                out.push_str(&format!(
513                    "<tspan class=\"chord-root\"{dy_attr}>{escaped}</tspan>"
514                ));
515                current_dy = 0;
516            }
517            SpanKind::Accidental => {
518                // Smaller font + raised baseline so the sharp / flat
519                // sits as a superscript next to the root letter.
520                let target = acc_dy;
521                let shift = target - current_dy;
522                out.push_str(&format!(
523                    "<tspan class=\"chord-acc\" font-size=\"{acc_size}\" dy=\"{shift}\">{escaped}</tspan>",
524                ));
525                current_dy = target;
526            }
527            SpanKind::Extension => {
528                // Smaller font + slight subscript so the quality
529                // hangs just below the chord baseline, matching
530                // editor-irealb.html's `vertical-align: -0.15em`.
531                let target = qual_dy;
532                let shift = target - current_dy;
533                out.push_str(&format!(
534                    "<tspan class=\"chord-ext\" font-size=\"{ext_size}\" dy=\"{shift}\">{escaped}</tspan>",
535                ));
536                current_dy = target;
537            }
538            SpanKind::Slash => {
539                let restore = -current_dy;
540                let attrs = if restore == 0 {
541                    String::new()
542                } else {
543                    format!(" font-size=\"{base_size}\" dy=\"{restore}\"")
544                };
545                out.push_str(&format!(
546                    "<tspan class=\"chord-slash\"{attrs}>{escaped}</tspan>"
547                ));
548                current_dy = 0;
549            }
550            SpanKind::Bass => {
551                let restore = -current_dy;
552                let attrs = if restore == 0 {
553                    String::new()
554                } else {
555                    format!(" font-size=\"{base_size}\" dy=\"{restore}\"")
556                };
557                out.push_str(&format!(
558                    "<tspan class=\"chord-bass\"{attrs}>{escaped}</tspan>"
559                ));
560                current_dy = 0;
561            }
562        }
563    }
564}
565
566/// Returns the library version (the workspace `Cargo.toml`
567/// `version` field, baked in at compile time).
568#[must_use]
569pub fn version() -> &'static str {
570    env!("CARGO_PKG_VERSION")
571}