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}