Skip to main content

chordsketch_render_html/
lib.rs

1//! HTML renderer for ChordPro documents.
2//!
3//! Converts a parsed ChordPro AST into either a self-contained HTML5 document
4//! (with embedded CSS) or a body-only `<div class="song">` fragment suitable
5//! for embedding in a host document. Use [`render_song`] / [`render_songs`]
6//! for the full-document API and [`render_song_body`] / [`render_songs_body`]
7//! for the fragment API. Pair the latter with [`render_html_css`] to obtain
8//! the matching stylesheet.
9//!
10//! # Security
11//!
12//! Delegate section environments (`{start_of_svg}`, `{start_of_abc}`,
13//! `{start_of_ly}`, `{start_of_textblock}`) emit their content as raw,
14//! unescaped HTML. This is by design per the ChordPro specification, as these
15//! sections contain verbatim markup (e.g., inline SVG).
16//!
17//! SVG sections are sanitized by default: `<script>` elements and event
18//! handler attributes (`onload`, `onerror`, etc.) are stripped to prevent
19//! XSS. When rendering untrusted ChordPro input, consumers should still
20//! apply Content Security Policy (CSP) headers as additional defense.
21
22use std::fmt::Write;
23
24mod music_glyphs;
25
26use chordsketch_chordpro::ast::{CommentStyle, DirectiveKind, Line, LyricsLine, Song};
27use chordsketch_chordpro::canonical_chord_name;
28use chordsketch_chordpro::config::Config;
29use chordsketch_chordpro::escape::escape_xml as escape;
30use chordsketch_chordpro::inline_markup::{SpanAttributes, TextSpan};
31use chordsketch_chordpro::render_result::{
32    RenderResult, push_warning, validate_capo, validate_multiple_capo, validate_strict_key,
33};
34use chordsketch_chordpro::resolve_diagrams_instrument;
35use chordsketch_chordpro::transpose::{transpose_chord_with_style, transposed_key_prefers_flat};
36use chordsketch_chordpro::typography::{tempo_marking_for, unicode_accidentals};
37
38/// Maximum number of chorus recall directives allowed per song.
39/// Prevents output amplification from malicious inputs with many `{chorus}` lines.
40const MAX_CHORUS_RECALLS: usize = 1000;
41
42/// Maximum number of warnings the renderer accumulates per render pass.
43/// Re-exported from `chordsketch-chordpro::render_result` so callers can
44/// keep importing `chordsketch_render_html::MAX_WARNINGS` unchanged
45/// (issue #1874).
46pub use chordsketch_chordpro::render_result::MAX_WARNINGS;
47
48/// Maximum number of CSS columns allowed.
49/// Matches `MAX_COLUMNS` in the PDF renderer.
50const MAX_COLUMNS: u32 = 32;
51
52/// Minimum font size (in points) accepted from user directives.
53/// Matches `MIN_FONT_SIZE` in the PDF renderer.
54const MIN_FONT_SIZE: f32 = 0.5;
55/// Maximum font size (in points) accepted from user directives.
56/// Matches `MAX_FONT_SIZE` in the PDF renderer.
57const MAX_FONT_SIZE: f32 = 200.0;
58
59// ---------------------------------------------------------------------------
60// Formatting state
61// ---------------------------------------------------------------------------
62
63/// Tracks the current font/size/color settings for an element type.
64///
65/// Formatting directives like `{textfont}`, `{chordsize}`, etc. set these
66/// values. The state persists until changed by another directive of the same
67/// type.
68#[derive(Default, Clone)]
69struct ElementStyle {
70    font: Option<String>,
71    size: Option<String>,
72    colour: Option<String>,
73}
74
75impl ElementStyle {
76    /// Generate a CSS `style` attribute string, or empty if no styles are set.
77    ///
78    /// All values are passed through [`sanitize_css_value`] to prevent CSS
79    /// injection via crafted directive values.
80    fn to_css(&self) -> String {
81        let mut css = String::new();
82        if let Some(ref font) = self.font {
83            let _ = write!(css, "font-family: {};", sanitize_css_value(font));
84        }
85        if let Some(ref size) = self.size {
86            let safe = sanitize_css_value(size);
87            if safe.chars().all(|c| c.is_ascii_digit()) {
88                let _ = write!(css, "font-size: {safe}pt;");
89            } else {
90                let _ = write!(css, "font-size: {safe};");
91            }
92        }
93        if let Some(ref colour) = self.colour {
94            let _ = write!(css, "color: {};", sanitize_css_value(colour));
95        }
96        css
97    }
98}
99
100/// Formatting state for all element types.
101#[derive(Default, Clone)]
102struct FormattingState {
103    text: ElementStyle,
104    chord: ElementStyle,
105    tab: ElementStyle,
106    title: ElementStyle,
107    chorus: ElementStyle,
108    label: ElementStyle,
109    grid: ElementStyle,
110}
111
112impl FormattingState {
113    /// Apply a formatting directive, updating the appropriate style.
114    ///
115    /// Font size values are clamped to `[MIN_FONT_SIZE, MAX_FONT_SIZE]` to
116    /// prevent degenerate CSS output from extreme values. This matches the
117    /// clamping applied in the PDF renderer per `renderer-parity.md`.
118    fn apply(&mut self, kind: &DirectiveKind, value: &Option<String>) {
119        let val = value.clone();
120        let clamped_size = || -> Option<String> {
121            value
122                .as_deref()
123                .and_then(|v| v.parse::<f32>().ok())
124                .map(|s| s.clamp(MIN_FONT_SIZE, MAX_FONT_SIZE).to_string())
125        };
126        match kind {
127            DirectiveKind::TextFont => self.text.font = val,
128            DirectiveKind::TextSize => self.text.size = clamped_size(),
129            DirectiveKind::TextColour => self.text.colour = val,
130            DirectiveKind::ChordFont => self.chord.font = val,
131            DirectiveKind::ChordSize => self.chord.size = clamped_size(),
132            DirectiveKind::ChordColour => self.chord.colour = val,
133            DirectiveKind::TabFont => self.tab.font = val,
134            DirectiveKind::TabSize => self.tab.size = clamped_size(),
135            DirectiveKind::TabColour => self.tab.colour = val,
136            DirectiveKind::TitleFont => self.title.font = val,
137            DirectiveKind::TitleSize => self.title.size = clamped_size(),
138            DirectiveKind::TitleColour => self.title.colour = val,
139            DirectiveKind::ChorusFont => self.chorus.font = val,
140            DirectiveKind::ChorusSize => self.chorus.size = clamped_size(),
141            DirectiveKind::ChorusColour => self.chorus.colour = val,
142            DirectiveKind::LabelFont => self.label.font = val,
143            DirectiveKind::LabelSize => self.label.size = clamped_size(),
144            DirectiveKind::LabelColour => self.label.colour = val,
145            DirectiveKind::GridFont => self.grid.font = val,
146            DirectiveKind::GridSize => self.grid.size = clamped_size(),
147            DirectiveKind::GridColour => self.grid.colour = val,
148            // Header/Footer/TOC directives are not rendered in the main body
149            _ => {}
150        }
151    }
152}
153
154/// Render a [`Song`] AST to an HTML5 document string.
155///
156/// The output is a complete `<!DOCTYPE html>` document with embedded CSS
157/// that positions chords above their corresponding lyrics.
158///
159/// The `{chorus}` directive recalls the most recently defined chorus section.
160/// Recalled chorus content is wrapped in `<div class="chorus-recall">` and
161/// includes the full chorus body.
162#[must_use]
163pub fn render_song(song: &Song) -> String {
164    render_song_with_transpose(song, 0, &Config::defaults())
165}
166
167/// Render a [`Song`] AST to an HTML5 document with an additional CLI transposition offset.
168///
169/// The `cli_transpose` parameter is added to any in-file `{transpose}` directive
170/// values, allowing the CLI `--transpose` flag to combine with in-file directives.
171///
172/// Warnings are printed to stderr via `eprintln!`. Use
173/// [`render_song_with_warnings`] to capture them programmatically.
174#[must_use]
175pub fn render_song_with_transpose(song: &Song, cli_transpose: i8, config: &Config) -> String {
176    let result = render_song_with_warnings(song, cli_transpose, config);
177    for w in &result.warnings {
178        eprintln!("warning: {w}");
179    }
180    result.output
181}
182
183/// Render a [`Song`] AST to an HTML5 document, returning warnings programmatically.
184///
185/// This is the structured variant of [`render_song_with_transpose`]. Instead
186/// of printing warnings to stderr, they are collected into
187/// [`RenderResult::warnings`].
188#[must_use = "caller must check warnings in the returned RenderResult"]
189pub fn render_song_with_warnings(
190    song: &Song,
191    cli_transpose: i8,
192    config: &Config,
193) -> RenderResult<String> {
194    let mut warnings = Vec::new();
195    let title = song.metadata.title.as_deref().unwrap_or("Untitled");
196    let mut html = String::new();
197    let _ = write!(
198        html,
199        "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>{}</title>\n",
200        escape(title)
201    );
202    html.push_str("<style>\n");
203    html.push_str(&css_for_wraplines(read_wraplines(config)));
204    html.push_str("</style>\n</head>\n<body>\n");
205    render_song_body_into(song, cli_transpose, config, &mut html, &mut warnings);
206    html.push_str("</body>\n</html>\n");
207    RenderResult::with_warnings(html, warnings)
208}
209
210/// Render the `<div class="song">...</div>` body for a single song into `html`.
211///
212/// This is the shared implementation used by both single-song and multi-song
213/// rendering. It appends directly to the provided buffer without any document
214/// wrapper (`<html>`, `<head>`, etc.).
215fn render_song_body_into(
216    song: &Song,
217    cli_transpose: i8,
218    config: &Config,
219    html: &mut String,
220    warnings: &mut Vec<String>,
221) {
222    // Apply song-level config overrides ({+config.KEY: VALUE} directives).
223    let song_overrides = song.config_overrides();
224    let song_config;
225    let config = if song_overrides.is_empty() {
226        config
227    } else {
228        song_config = config
229            .clone()
230            .with_song_overrides(&song_overrides, warnings);
231        &song_config
232    };
233    // Extract song-level transpose delta from {+config.settings.transpose}.
234    // The base config transpose is already folded into cli_transpose by the caller.
235    let song_transpose_delta = Config::song_transpose_delta(&song_overrides);
236    let (combined_transpose, _) =
237        chordsketch_chordpro::transpose::combine_transpose(cli_transpose, song_transpose_delta);
238    let mut transpose_offset: i8 = combined_transpose;
239    let mut fmt_state = FormattingState::default();
240    // `<article>` is the semantic root for a self-contained song —
241    // a single ChordPro document is a "composition complete in
242    // itself" per the HTML5 article definition. Carries the
243    // existing `.song` class so consumer CSS keyed on `.song`
244    // keeps hitting. Sister-site to the React JSX walker's
245    // `<article class="song">` wrapper.
246    html.push_str("<article class=\"song\">\n");
247
248    validate_capo(&song.metadata, warnings);
249    validate_multiple_capo(song, warnings);
250    validate_strict_key(&song.metadata, config, warnings);
251    render_metadata(&song.metadata, html);
252
253    // Tracks whether a multi-column div is currently open.
254    let mut columns_open = false;
255    // Buffer for collecting SVG section content. Content is sanitized as a
256    // single string on EndOfSvg to prevent multi-line tag splitting bypasses.
257    let mut svg_buf: Option<String> = None;
258    // Tracks whether the body cursor is currently inside a
259    // `{start_of_grid}` section. Grid body lines render via
260    // the structured tokeniser (`chordsketch_chordpro::grid`)
261    // instead of the generic chord-over-lyrics path.
262    let mut in_grid = false;
263    // Delegate tool availability: Some(true) = force enable, Some(false) = force
264    // disable, None = auto-detect on first encounter. The auto-detect value is
265    // lazily resolved (via `get_or_insert_with`) so that subprocess checks only
266    // run when a delegate section is actually present in the input.
267    let mut abc2svg_resolved: Option<bool> = config.get_path("delegates.abc2svg").as_bool();
268    let mut lilypond_resolved: Option<bool> = config.get_path("delegates.lilypond").as_bool();
269    let mut musescore_resolved: Option<bool> = config.get_path("delegates.musescore").as_bool();
270    let mut abc_buf: Option<String> = None;
271    let mut abc_label: Option<String> = None;
272    let mut ly_buf: Option<String> = None;
273    let mut ly_label: Option<String> = None;
274    let mut musicxml_buf: Option<String> = None;
275    let mut musicxml_label: Option<String> = None;
276
277    // Controls whether chord diagrams are rendered. Set by {diagrams: off/on}.
278    let mut show_diagrams = true;
279
280    // Read configurable frets_shown for chord diagrams.
281    let diagram_frets = config.get_path("diagrams.frets").as_f64().map_or(
282        chordsketch_chordpro::chord_diagram::DEFAULT_FRETS_SHOWN,
283        |n| (n as usize).max(1),
284    );
285
286    // Instrument for the auto-inject diagram block at end of song.
287    // Set by {diagrams: guitar/ukulele/on}; cleared by {diagrams: off} / {no_diagrams}.
288    // None means no auto-inject grid is rendered.
289    let default_instrument = config
290        .get_path("diagrams.instrument")
291        .as_str()
292        .map(str::to_ascii_lowercase)
293        .unwrap_or_else(|| "guitar".to_string());
294    let mut auto_diagrams_instrument: Option<String> = None;
295    // (Walker-time `active_bpm` tracking was retired together
296    // with the conductor-pattern animation on the time-signature
297    // glyph. If we ever need it again — e.g. for a different
298    // BPM-driven visual treatment — restore the
299    // `metadata.tempos.last()` seed + `{tempo}` arm update
300    // from git history.)
301    // Canonical chord names (sharp form) that were actually rendered inline via
302    // {define} while show_diagrams was true.  Used to exclude them from the
303    // auto-inject grid and avoid duplicates.
304    let mut inline_defined: std::collections::HashSet<String> = std::collections::HashSet::new();
305
306    // Stores the AST lines of the most recently defined chorus body.
307    // Re-rendered at recall time so the current transpose offset is applied.
308    let mut chorus_body: Vec<Line> = Vec::new();
309    // Temporary buffer for collecting chorus AST lines.
310    let mut chorus_buf: Option<Vec<Line>> = None;
311    // Saved fmt_state before entering a chorus, restored on EndOfChorus
312    // to prevent in-chorus formatting directives from leaking outward.
313    let mut saved_fmt_state: Option<FormattingState> = None;
314    let mut chorus_recall_count: usize = 0;
315
316    for line in &song.lines {
317        match line {
318            Line::Lyrics(lyrics_line) => {
319                if let Some(ref mut buf) = svg_buf {
320                    // Inside SVG section: collect content into buffer.
321                    // Sanitization is deferred to EndOfSvg so that multi-line
322                    // tags cannot bypass dangerous element detection.
323                    let raw = lyrics_line.text();
324                    buf.push_str(&raw);
325                    buf.push('\n');
326                } else if let Some(ref mut buf) = abc_buf {
327                    // Inside ABC section with abc2svg enabled: collect content.
328                    let raw = lyrics_line.text();
329                    buf.push_str(&raw);
330                    buf.push('\n');
331                } else if let Some(ref mut buf) = ly_buf {
332                    // Inside Lilypond section with lilypond enabled: collect content.
333                    let raw = lyrics_line.text();
334                    buf.push_str(&raw);
335                    buf.push('\n');
336                } else if let Some(ref mut buf) = musicxml_buf {
337                    // Inside MusicXML section with musescore enabled: collect content.
338                    let raw = lyrics_line.text();
339                    buf.push_str(&raw);
340                    buf.push('\n');
341                } else if in_grid {
342                    if let Some(buf) = chorus_buf.as_mut() {
343                        buf.push(line.clone());
344                    }
345                    render_grid_line(&lyrics_line.text(), html);
346                } else {
347                    if let Some(buf) = chorus_buf.as_mut() {
348                        buf.push(line.clone());
349                    }
350                    let prefer_flat = transposed_key_prefers_flat(&song.metadata, transpose_offset);
351                    render_lyrics(lyrics_line, transpose_offset, prefer_flat, &fmt_state, html);
352                }
353            }
354            Line::Directive(directive) => {
355                if directive.kind.is_metadata() {
356                    // ChordPro spec: `{key}` / `{tempo}` / `{time}` are
357                    // `[Nx] [Pos]` — every declaration applies forward
358                    // from its position. Render a small inline marker
359                    // at the directive's position so a reader can see
360                    // where mid-song key / tempo / meter changes
361                    // happen (Phase B of #2454). The header chip
362                    // already lists every value joined by `"; "`
363                    // (Phase A); the body marker is what makes the
364                    // *position* part of `[Pos]` visible.
365                    if let Some(value) = directive
366                        .value
367                        .as_deref()
368                        .map(str::trim)
369                        .filter(|v| !v.is_empty())
370                    {
371                        // (`active_bpm` tracking removed together
372                        // with the conductor-pattern animation on
373                        // the time-signature glyph.)
374                        // Each marker carries a music-notation glyph
375                        // next to the textual label / value. The
376                        // glyphs (key signature, animated metronome,
377                        // stacked time-signature digits) mirror the
378                        // React JSX walker's `<KeySignatureGlyph> /
379                        // <MetronomeGlyph> / <TimeSignatureGlyph>`
380                        // output per `.claude/rules/renderer-
381                        // parity.md` §"Sanitizer Parity (React JSX
382                        // surface)". The `{time}` arm intentionally
383                        // omits the textual `meta-inline__value`
384                        // because the stacked-digit glyph IS the
385                        // value display — sister-site to the React
386                        // walker.
387                        match directive.kind {
388                            DirectiveKind::Key => {
389                                html.push_str(&format!(
390                                    "<span class=\"meta-inline meta-inline--key\">\
391                                     {glyph}\
392                                     <span class=\"meta-inline__label\">Key:</span> \
393                                     <span class=\"meta-inline__value\">{val}</span></span>\n",
394                                    glyph = music_glyphs::key_signature_svg(value),
395                                    val = escape(&unicode_accidentals(value)),
396                                ));
397                            }
398                            DirectiveKind::Tempo => {
399                                // The metronome glyph signals the
400                                // marker's meaning on its own — drop
401                                // the textual "Tempo:" label and keep
402                                // only the BPM value (sister-site to
403                                // the React JSX walker). When the
404                                // BPM matches an Italian tempo
405                                // marking, append it in parens.
406                                let marking = value
407                                    .trim()
408                                    .parse::<f32>()
409                                    .ok()
410                                    .and_then(tempo_marking_for)
411                                    .map(|m| {
412                                        format!(
413                                            " <span class=\"meta-inline__marking\">({m})</span>"
414                                        )
415                                    })
416                                    .unwrap_or_default();
417                                html.push_str(&format!(
418                                    "<span class=\"meta-inline meta-inline--tempo\">\
419                                     {glyph}\
420                                     <span class=\"meta-inline__value\">{val} BPM{marking}</span></span>\n",
421                                    glyph = music_glyphs::metronome_svg(value),
422                                    val = escape(value),
423                                ));
424                            }
425                            DirectiveKind::Time => {
426                                html.push_str(&format!(
427                                    "<span class=\"meta-inline meta-inline--time\">\
428                                     <span class=\"meta-inline__label\">Time:</span> \
429                                     {glyph}</span>\n",
430                                    glyph = music_glyphs::time_signature_html(value),
431                                ));
432                            }
433                            _ => {}
434                        }
435                    }
436                    continue;
437                }
438                if directive.kind == DirectiveKind::Diagrams {
439                    auto_diagrams_instrument = resolve_diagrams_instrument(
440                        directive.value.as_deref(),
441                        &default_instrument,
442                    );
443                    show_diagrams = auto_diagrams_instrument.is_some();
444                    continue;
445                }
446                if directive.kind == DirectiveKind::NoDiagrams {
447                    show_diagrams = false;
448                    auto_diagrams_instrument = None;
449                    continue;
450                }
451                if directive.kind == DirectiveKind::Transpose {
452                    // A missing or empty value silently resets to 0; only a
453                    // non-empty value that cannot be parsed as i8 emits a warning.
454                    let file_offset: i8 = match directive.value.as_deref() {
455                        None | Some("") => 0,
456                        Some(raw) => match raw.parse() {
457                            Ok(v) => v,
458                            Err(_) => {
459                                push_warning(
460                                    warnings,
461                                    format!(
462                                        "{{transpose}} value {raw:?} cannot be \
463                                         parsed as i8, ignored (using 0)"
464                                    ),
465                                );
466                                0
467                            }
468                        },
469                    };
470                    let (combined, saturated) = chordsketch_chordpro::transpose::combine_transpose(
471                        file_offset,
472                        cli_transpose,
473                    );
474                    if saturated {
475                        push_warning(
476                            warnings,
477                            format!(
478                                "transpose offset {file_offset} + {cli_transpose} \
479                                 exceeds i8 range, clamped to {combined}"
480                            ),
481                        );
482                    }
483                    transpose_offset = combined;
484                    continue;
485                }
486                if directive.kind.is_font_size_color() {
487                    if let Some(buf) = chorus_buf.as_mut() {
488                        buf.push(line.clone());
489                    }
490                    fmt_state.apply(&directive.kind, &directive.value);
491                    continue;
492                }
493                match &directive.kind {
494                    DirectiveKind::StartOfChorus => {
495                        render_section_open("chorus", "Chorus", &directive.value, html);
496                        chorus_buf = Some(Vec::new());
497                        // Save fmt_state so in-chorus formatting directives
498                        // do not leak into sections after the chorus.
499                        saved_fmt_state = Some(fmt_state.clone());
500                    }
501                    DirectiveKind::EndOfChorus => {
502                        html.push_str("</section>\n");
503                        if let Some(buf) = chorus_buf.take() {
504                            chorus_body = buf;
505                        }
506                        // Restore fmt_state to pre-chorus value.
507                        if let Some(saved) = saved_fmt_state.take() {
508                            fmt_state = saved;
509                        }
510                    }
511                    DirectiveKind::Chorus => {
512                        if chorus_recall_count < MAX_CHORUS_RECALLS {
513                            let prefer_flat =
514                                transposed_key_prefers_flat(&song.metadata, transpose_offset);
515                            render_chorus_recall(
516                                &directive.value,
517                                &ChorusRecallCtx {
518                                    chorus_body: &chorus_body,
519                                    transpose_offset,
520                                    prefer_flat,
521                                    fmt_state: &fmt_state,
522                                    show_diagrams,
523                                    diagram_frets,
524                                },
525                                html,
526                            );
527                            chorus_recall_count += 1;
528                        } else if chorus_recall_count == MAX_CHORUS_RECALLS {
529                            push_warning(
530                                warnings,
531                                format!(
532                                    "chorus recall limit ({MAX_CHORUS_RECALLS}) reached, \
533                                     further recalls suppressed"
534                                ),
535                            );
536                            chorus_recall_count += 1;
537                        }
538                    }
539                    DirectiveKind::Columns => {
540                        // Clamp to 1..=32 to prevent degenerate CSS output.
541                        // Parsing as u32 already rejects non-numeric input;
542                        // clamping ensures the formatted value is always safe.
543                        let n: u32 = directive
544                            .value
545                            .as_deref()
546                            .and_then(|v| v.trim().parse().ok())
547                            .unwrap_or(1)
548                            .clamp(1, MAX_COLUMNS);
549                        if columns_open {
550                            html.push_str("</div>\n");
551                            columns_open = false;
552                        }
553                        if n > 1 {
554                            let _ = writeln!(
555                                html,
556                                "<div style=\"column-count: {n};column-gap: 2em;\">"
557                            );
558                            columns_open = true;
559                        }
560                    }
561                    // All page control directives ({new_page}, {new_physical_page},
562                    // {column_break}, {columns}) are intentionally excluded from the
563                    // chorus buffer. These affect global page/column layout, and
564                    // replaying them during {chorus} recall would produce unexpected
565                    // layout changes (e.g., duplicate page breaks, column resets).
566                    DirectiveKind::ColumnBreak => {
567                        html.push_str("<div style=\"break-before: column;\"></div>\n");
568                    }
569                    DirectiveKind::NewPage => {
570                        html.push_str("<div style=\"break-before: page;\"></div>\n");
571                    }
572                    DirectiveKind::NewPhysicalPage => {
573                        // Use CSS `break-before: recto` so the browser inserts
574                        // a blank page when needed to start on a right-hand page
575                        // in duplex printing.
576                        html.push_str("<div style=\"break-before: recto;\"></div>\n");
577                    }
578                    DirectiveKind::StartOfAbc => {
579                        #[cfg(not(target_arch = "wasm32"))]
580                        let enabled = *abc2svg_resolved
581                            .get_or_insert_with(chordsketch_chordpro::external_tool::has_abc2svg);
582                        #[cfg(target_arch = "wasm32")]
583                        let enabled = *abc2svg_resolved.get_or_insert(false);
584                        if enabled {
585                            abc_buf = Some(String::new());
586                            abc_label = directive.value.clone();
587                        } else {
588                            if let Some(buf) = chorus_buf.as_mut() {
589                                buf.push(line.clone());
590                            }
591                            render_directive_inner(directive, show_diagrams, diagram_frets, html);
592                        }
593                    }
594                    DirectiveKind::EndOfAbc if abc_buf.is_some() => {
595                        if let Some(abc_content) = abc_buf.take() {
596                            render_abc_with_fallback(&abc_content, &abc_label, html, warnings);
597                            abc_label = None;
598                        }
599                    }
600                    DirectiveKind::StartOfLy => {
601                        #[cfg(not(target_arch = "wasm32"))]
602                        let enabled = *lilypond_resolved
603                            .get_or_insert_with(chordsketch_chordpro::external_tool::has_lilypond);
604                        #[cfg(target_arch = "wasm32")]
605                        let enabled = *lilypond_resolved.get_or_insert(false);
606                        if enabled {
607                            ly_buf = Some(String::new());
608                            ly_label = directive.value.clone();
609                        } else {
610                            if let Some(buf) = chorus_buf.as_mut() {
611                                buf.push(line.clone());
612                            }
613                            render_directive_inner(directive, show_diagrams, diagram_frets, html);
614                        }
615                    }
616                    DirectiveKind::EndOfLy if ly_buf.is_some() => {
617                        if let Some(ly_content) = ly_buf.take() {
618                            render_ly_with_fallback(&ly_content, &ly_label, html, warnings);
619                            ly_label = None;
620                        }
621                    }
622                    DirectiveKind::StartOfMusicxml => {
623                        #[cfg(not(target_arch = "wasm32"))]
624                        let enabled = *musescore_resolved
625                            .get_or_insert_with(chordsketch_chordpro::external_tool::has_musescore);
626                        #[cfg(target_arch = "wasm32")]
627                        let enabled = *musescore_resolved.get_or_insert(false);
628                        if enabled {
629                            musicxml_buf = Some(String::new());
630                            musicxml_label = directive.value.clone();
631                        } else {
632                            if let Some(buf) = chorus_buf.as_mut() {
633                                buf.push(line.clone());
634                            }
635                            render_directive_inner(directive, show_diagrams, diagram_frets, html);
636                        }
637                    }
638                    DirectiveKind::EndOfMusicxml if musicxml_buf.is_some() => {
639                        if let Some(musicxml_content) = musicxml_buf.take() {
640                            render_musicxml_with_fallback(
641                                &musicxml_content,
642                                &musicxml_label,
643                                html,
644                                warnings,
645                            );
646                            musicxml_label = None;
647                        }
648                    }
649                    DirectiveKind::StartOfSvg => {
650                        svg_buf = Some(String::new());
651                    }
652                    DirectiveKind::EndOfSvg if svg_buf.is_some() => {
653                        if let Some(svg_content) = svg_buf.take() {
654                            html.push_str("<div class=\"svg-section\">\n");
655                            html.push_str(&sanitize_svg_content(&svg_content));
656                            html.push('\n');
657                            html.push_str("</div>\n");
658                        }
659                    }
660                    DirectiveKind::StartOfGrid => {
661                        if let Some(buf) = chorus_buf.as_mut() {
662                            buf.push(line.clone());
663                        }
664                        // `directive.value` may be:
665                        // - a human-readable label via the
666                        //   colon form (`{start_of_grid: Intro}`),
667                        // - an attribute payload via the inline
668                        //   form (`{start_of_grid shape="..."}`)
669                        //   which MAY include a `label="..."`
670                        //   attribute to be surfaced as the
671                        //   heading.
672                        // Try the structured `label="..."` first,
673                        // then fall back to the colon-form value
674                        // when it doesn't contain `=` (legacy
675                        // human label). When the value contains
676                        // `=` but no `label="..."` (e.g. just
677                        // `shape="..."`), suppress it entirely
678                        // so the heading reads "Grid" instead of
679                        // the raw attribute string.
680                        let label_value = directive.value.as_ref().and_then(|v| {
681                            if let Some(label) = chordsketch_chordpro::grid::extract_grid_label(v) {
682                                Some(label)
683                            } else if !v.contains('=') {
684                                Some(v.clone())
685                            } else {
686                                None
687                            }
688                        });
689                        render_section_open("grid", "Grid", &label_value, html);
690                        in_grid = true;
691                    }
692                    DirectiveKind::EndOfGrid => {
693                        if let Some(buf) = chorus_buf.as_mut() {
694                            buf.push(line.clone());
695                        }
696                        html.push_str("</section>\n");
697                        in_grid = false;
698                    }
699                    _ => {
700                        if let Some(buf) = chorus_buf.as_mut() {
701                            buf.push(line.clone());
702                        }
703                        // Track {define} chords that are rendered inline so the
704                        // auto-inject grid can skip them (dedup for #1211/#1245/#1246).
705                        if directive.kind == DirectiveKind::Define && show_diagrams {
706                            if let Some(ref val) = directive.value {
707                                let name =
708                                    chordsketch_chordpro::ast::ChordDefinition::parse_value(val)
709                                        .name;
710                                if !name.is_empty() {
711                                    inline_defined.insert(canonical_chord_name(&name));
712                                }
713                            }
714                        }
715                        render_directive_inner(directive, show_diagrams, diagram_frets, html);
716                    }
717                }
718            }
719            Line::Comment(style, text) => {
720                if let Some(buf) = chorus_buf.as_mut() {
721                    buf.push(line.clone());
722                }
723                render_comment(*style, text, html);
724            }
725            Line::Empty => {
726                if let Some(buf) = chorus_buf.as_mut() {
727                    buf.push(line.clone());
728                }
729                html.push_str("<div class=\"empty-line\" aria-hidden=\"true\"></div>\n");
730            }
731        }
732    }
733
734    // Close any open multi-column div.
735    if columns_open {
736        html.push_str("</div>\n");
737    }
738
739    // Auto-inject diagram grid when {diagrams} (or {diagrams: guitar/ukulele/piano/on}) was seen.
740    if let Some(ref instrument) = auto_diagrams_instrument {
741        // Skip chords that were actually rendered inline via {define} (i.e., show_diagrams
742        // was true at the time).  Compare in canonical sharp form to catch enharmonic
743        // pairs like {define: Bb …} vs [A#] in lyrics.
744        let chord_names: Vec<String> = song
745            .used_chord_names()
746            .into_iter()
747            .filter(|name| !inline_defined.contains(&canonical_chord_name(name)))
748            .collect();
749
750        if instrument == "piano" {
751            // Keyboard instrument: use the piano voicing database.
752            let kbd_defines = song.keyboard_defines();
753            let voicings: Vec<_> = chord_names
754                .into_iter()
755                .filter_map(|name| {
756                    chordsketch_chordpro::lookup_keyboard_voicing(&name, &kbd_defines)
757                })
758                .collect();
759            if !voicings.is_empty() {
760                html.push_str("<section class=\"chord-diagrams\" aria-labelledby=\"cs-chord-diagrams-label\">\n");
761                html.push_str("<h3 id=\"cs-chord-diagrams-label\" class=\"section-label\">Chord Diagrams</h3>\n");
762                html.push_str("<div class=\"chord-diagrams-grid\">\n");
763                for voicing in &voicings {
764                    html.push_str("<figure class=\"chord-diagram-container\">");
765                    html.push_str(&chordsketch_chordpro::chord_diagram::render_keyboard_svg(
766                        voicing,
767                    ));
768                    html.push_str("</figure>\n");
769                }
770                html.push_str("</div>\n");
771                html.push_str("</section>\n");
772            }
773        } else {
774            // Fretted instruments (guitar, ukulele, etc.).
775            let defines = song.fretted_defines();
776            let diagrams: Vec<_> = chord_names
777                .into_iter()
778                .filter_map(|name| {
779                    chordsketch_chordpro::lookup_diagram(&name, &defines, instrument, diagram_frets)
780                })
781                .collect();
782            if !diagrams.is_empty() {
783                html.push_str("<section class=\"chord-diagrams\" aria-labelledby=\"cs-chord-diagrams-label\">\n");
784                html.push_str("<h3 id=\"cs-chord-diagrams-label\" class=\"section-label\">Chord Diagrams</h3>\n");
785                html.push_str("<div class=\"chord-diagrams-grid\">\n");
786                for diagram in &diagrams {
787                    html.push_str("<figure class=\"chord-diagram-container\">");
788                    html.push_str(&chordsketch_chordpro::chord_diagram::render_svg(diagram));
789                    html.push_str("</figure>\n");
790                }
791                html.push_str("</div>\n");
792                html.push_str("</section>\n");
793            }
794        }
795    }
796
797    // Close the song's outer `<article class="song">` opened at
798    // the top of this function (semantic-HTML refactor — sister
799    // to the React JSX walker's `<article>` wrapper).
800    html.push_str("</article>\n");
801}
802
803/// Render multiple [`Song`]s into a single HTML5 document.
804#[must_use]
805pub fn render_songs(songs: &[Song]) -> String {
806    render_songs_with_transpose(songs, 0, &Config::defaults())
807}
808
809/// Render multiple [`Song`]s into a single HTML5 document with transposition.
810///
811/// When there is only one song, this is identical to [`render_song_with_transpose`].
812/// For multiple songs, the document uses the first song's title and separates
813/// each song with an `<hr class="song-separator">`.
814///
815/// Warnings are printed to stderr via `eprintln!`. Use
816/// [`render_songs_with_warnings`] to capture them programmatically.
817#[must_use]
818pub fn render_songs_with_transpose(songs: &[Song], cli_transpose: i8, config: &Config) -> String {
819    let result = render_songs_with_warnings(songs, cli_transpose, config);
820    for w in &result.warnings {
821        eprintln!("warning: {w}");
822    }
823    result.output
824}
825
826/// Render multiple [`Song`]s into a single HTML5 document, returning warnings
827/// programmatically.
828///
829/// This is the structured variant of [`render_songs_with_transpose`]. Instead
830/// of printing warnings to stderr, they are collected into
831/// [`RenderResult::warnings`].
832#[must_use = "caller must check warnings in the returned RenderResult"]
833pub fn render_songs_with_warnings(
834    songs: &[Song],
835    cli_transpose: i8,
836    config: &Config,
837) -> RenderResult<String> {
838    let mut warnings = Vec::new();
839    if songs.len() <= 1 {
840        let output = songs
841            .first()
842            .map(|s| {
843                let r = render_song_with_warnings(s, cli_transpose, config);
844                warnings = r.warnings;
845                r.output
846            })
847            .unwrap_or_default();
848        return RenderResult::with_warnings(output, warnings);
849    }
850    // Use the first song's title for the document
851    let mut html = String::new();
852    let title = songs
853        .first()
854        .and_then(|s| s.metadata.title.as_deref())
855        .unwrap_or("Untitled");
856    let _ = write!(
857        html,
858        "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>{}</title>\n",
859        escape(title)
860    );
861    html.push_str("<style>\n");
862    html.push_str(&css_for_wraplines(read_wraplines(config)));
863    html.push_str("</style>\n</head>\n<body>\n");
864
865    for (i, song) in songs.iter().enumerate() {
866        if i > 0 {
867            html.push_str("<hr class=\"song-separator\">\n");
868        }
869        render_song_body_into(song, cli_transpose, config, &mut html, &mut warnings);
870    }
871
872    html.push_str("</body>\n</html>\n");
873    RenderResult::with_warnings(html, warnings)
874}
875
876/// Render a [`Song`] AST to its body-only HTML fragment.
877///
878/// Unlike [`render_song`], this returns just the `<div class="song">...</div>`
879/// markup — no `<!DOCTYPE>`, `<html>`, `<head>`, `<title>`, or embedded
880/// `<style>` block. Use this when the consumer is going to wrap the output
881/// in its own document (e.g. a VS Code WebView, a Tauri shell, or a static
882/// site generator) and supply CSS separately via [`render_html_css`].
883///
884/// Background: prior to #2279 the only public API was the full-document
885/// variants, which forced consumers to either accept a complete HTML
886/// document where they wanted a fragment, or to reimplement body
887/// rendering. The latter produced sister-site drift across the playground,
888/// the desktop shell, and the VS Code preview (each with its own bespoke
889/// `wrapHtml`-style helper). The body-only family closes that gap.
890#[must_use]
891pub fn render_song_body(song: &Song) -> String {
892    render_song_body_with_transpose(song, 0, &Config::defaults())
893}
894
895/// Render a [`Song`] AST to its body-only HTML fragment with a CLI
896/// transposition offset.
897///
898/// See [`render_song_body`] for the contract. Warnings are printed to
899/// stderr via `eprintln!`; use [`render_song_body_with_warnings`] to
900/// capture them programmatically.
901#[must_use]
902pub fn render_song_body_with_transpose(song: &Song, cli_transpose: i8, config: &Config) -> String {
903    let result = render_song_body_with_warnings(song, cli_transpose, config);
904    for w in &result.warnings {
905        eprintln!("warning: {w}");
906    }
907    result.output
908}
909
910/// Render a [`Song`] AST to its body-only HTML fragment, returning warnings
911/// programmatically.
912///
913/// This is the structured variant of [`render_song_body_with_transpose`].
914/// See [`render_song_body`] for the contract.
915#[must_use = "caller must check warnings in the returned RenderResult"]
916pub fn render_song_body_with_warnings(
917    song: &Song,
918    cli_transpose: i8,
919    config: &Config,
920) -> RenderResult<String> {
921    let mut warnings = Vec::new();
922    let mut html = String::new();
923    render_song_body_into(song, cli_transpose, config, &mut html, &mut warnings);
924    RenderResult::with_warnings(html, warnings)
925}
926
927/// Render multiple [`Song`]s into a single body-only HTML fragment.
928///
929/// Songs are separated with `<hr class="song-separator">`, matching the
930/// inline separator used by [`render_songs`]. See [`render_song_body`]
931/// for the contract.
932#[must_use]
933pub fn render_songs_body(songs: &[Song]) -> String {
934    render_songs_body_with_transpose(songs, 0, &Config::defaults())
935}
936
937/// Render multiple [`Song`]s into a single body-only HTML fragment with
938/// transposition.
939///
940/// See [`render_songs_body`] for the contract. Warnings are printed to
941/// stderr via `eprintln!`; use [`render_songs_body_with_warnings`] to
942/// capture them programmatically.
943#[must_use]
944pub fn render_songs_body_with_transpose(
945    songs: &[Song],
946    cli_transpose: i8,
947    config: &Config,
948) -> String {
949    let result = render_songs_body_with_warnings(songs, cli_transpose, config);
950    for w in &result.warnings {
951        eprintln!("warning: {w}");
952    }
953    result.output
954}
955
956/// Render multiple [`Song`]s into a single body-only HTML fragment, returning
957/// warnings programmatically.
958///
959/// See [`render_songs_body`] for the contract.
960#[must_use = "caller must check warnings in the returned RenderResult"]
961pub fn render_songs_body_with_warnings(
962    songs: &[Song],
963    cli_transpose: i8,
964    config: &Config,
965) -> RenderResult<String> {
966    let mut warnings = Vec::new();
967    if songs.len() <= 1 {
968        let output = songs
969            .first()
970            .map(|s| {
971                let r = render_song_body_with_warnings(s, cli_transpose, config);
972                warnings = r.warnings;
973                r.output
974            })
975            .unwrap_or_default();
976        return RenderResult::with_warnings(output, warnings);
977    }
978    let mut html = String::new();
979    for (i, song) in songs.iter().enumerate() {
980        if i > 0 {
981            html.push_str("<hr class=\"song-separator\">\n");
982        }
983        render_song_body_into(song, cli_transpose, config, &mut html, &mut warnings);
984    }
985    RenderResult::with_warnings(html, warnings)
986}
987
988/// The canonical chord-over-lyrics CSS that the full-document renderers
989/// embed inside `<style>`.
990///
991/// Returns the default-configuration variant (`flex-wrap: wrap` on the
992/// `.line` rule, matching `settings.wraplines: true`). To obtain the CSS
993/// for a non-default `settings.wraplines`, call
994/// [`render_html_css_with_config`] instead.
995///
996/// Pair this with [`render_song_body`] / [`render_songs_body`] when the
997/// consumer is supplying its own document envelope: inline the returned
998/// string inside a `<style>` block, ship it as a separate file referenced
999/// via `<link rel="stylesheet">`, or merge it into the host's existing
1000/// stylesheet. The contract is byte-stable; consumers can hash the result
1001/// for cache-busting filenames.
1002#[must_use]
1003pub fn render_html_css() -> String {
1004    css_for_wraplines(true)
1005}
1006
1007/// Variant of [`render_html_css`] that honours `settings.wraplines` from
1008/// the supplied config (R6.100.0). When `wraplines` is false, the `.line`
1009/// rule emits `flex-wrap: nowrap` so chord/lyric runs that exceed the
1010/// viewport width preserve the source line structure instead of reflowing.
1011#[must_use]
1012pub fn render_html_css_with_config(config: &Config) -> String {
1013    css_for_wraplines(read_wraplines(config))
1014}
1015
1016/// Read `settings.wraplines` from the config, defaulting to `true` when
1017/// missing or non-boolean.
1018fn read_wraplines(config: &Config) -> bool {
1019    config.get_path("settings.wraplines").as_bool() != Some(false)
1020}
1021
1022/// Build the embedded CSS string with the supplied wraplines value
1023/// substituted into the `.line` rule.
1024///
1025/// The substitution targets a unique sentinel rather than the literal
1026/// `flex-wrap: wrap` so that `.chord-diagrams-grid` (which intentionally
1027/// keeps its own `flex-wrap: wrap`) is not affected.
1028fn css_for_wraplines(wraplines: bool) -> String {
1029    CSS_TEMPLATE.replace(
1030        "__LINE_FLEX_WRAP__",
1031        if wraplines { "wrap" } else { "nowrap" },
1032    )
1033}
1034
1035/// Parse a ChordPro source string and render it to HTML.
1036///
1037/// Returns `Ok(html)` on success, or the [`chordsketch_chordpro::ParseError`] if
1038/// the input cannot be parsed.
1039#[must_use = "parse errors should be handled"]
1040pub fn try_render(input: &str) -> Result<String, chordsketch_chordpro::ParseError> {
1041    let song = chordsketch_chordpro::parse(input)?;
1042    Ok(render_song(&song))
1043}
1044
1045/// Parse a ChordPro source string and render it to HTML.
1046///
1047/// Convenience wrapper that converts parse errors to a string.
1048/// Use [`try_render`] if you need error handling.
1049#[must_use]
1050pub fn render(input: &str) -> String {
1051    match try_render(input) {
1052        Ok(html) => html,
1053        Err(e) => format!(
1054            "<!DOCTYPE html><html><body><pre>Parse error at line {} column {}: {}</pre></body></html>\n",
1055            e.line(),
1056            e.column(),
1057            escape(&e.message)
1058        ),
1059    }
1060}
1061
1062// ---------------------------------------------------------------------------
1063// CSS
1064// ---------------------------------------------------------------------------
1065
1066/// Embedded CSS template for chord-over-lyrics layout.
1067///
1068/// The `.line` rule contains a sentinel `__LINE_FLEX_WRAP__` substituted
1069/// at render time by [`css_for_wraplines`] based on `settings.wraplines`.
1070/// The substitution targets the sentinel rather than the literal value so
1071/// `.chord-diagrams-grid`'s own `flex-wrap: wrap` is unaffected.
1072// Design tokens from `design-system/tokens.css` at the workspace
1073// root (see `design-system/DESIGN.md` §2 / §3). The font stacks
1074// fall back to system-ui so the
1075// document is readable when the design fonts are not installed; a
1076// host that loads Noto Sans JP / Inter / JetBrains Mono / Roboto
1077// (e.g. the playground or the desktop WebView) gets the full
1078// design language. Class names are stable (`.chord-block`,
1079// `.chord`, `.lyrics`, `.line`, `.section-label`,
1080// `.chord-diagrams-grid`) so external consumers' overrides keep
1081// working.
1082//
1083// Two prefixes are pinned by unit tests in this crate
1084// (`test_render_html_css_returns_canonical_block`,
1085// `test_wraplines_*`):
1086//   - `.line { display: flex; flex-wrap: __LINE_FLEX_WRAP__; ...`
1087//   - `.chord-diagrams-grid { display: flex; flex-wrap: wrap; ...`
1088// Do not alter the prefix bytes of those two rules.
1089//
1090// Sister-site styling note: the React surface ships a richer
1091// stylesheet at `packages/react/src/styles.css` that consumes
1092// design-system tokens via CSS custom properties
1093// (`var(--cs-font-chord, …)`). This static-HTML stylesheet
1094// cannot inherit those tokens at consumer time — the rendered
1095// HTML is meant to display correctly in browsers / readers
1096// that haven't loaded any consumer stylesheet at all — so
1097// every font / color / size is materialised as a literal
1098// value here. When the design-system tokens change, BOTH
1099// stylesheets need updating; the differences are
1100// representational, not behavioural.
1101const CSS_TEMPLATE: &str = "\
1102body { font-family: \"Noto Sans JP\", system-ui, -apple-system, \"Helvetica Neue\", Arial, sans-serif; font-size: 1rem; line-height: 1.6875; color: #0A0A0B; max-width: 720px; margin: 2em auto; padding: 0 1em; }
1103h1 { font-family: \"Noto Sans JP\", system-ui, -apple-system, sans-serif; font-weight: 700; font-size: 1.875rem; letter-spacing: -0.02em; color: #0A0A0B; margin-bottom: 0.2em; }
1104h2 { font-family: \"Noto Sans JP\", system-ui, -apple-system, sans-serif; font-weight: 400; font-size: 1rem; color: #67646D; margin-top: 0; }
1105.meta { font-family: \"JetBrains Mono\", ui-monospace, \"SF Mono\", Menlo, Consolas, monospace; font-size: 0.8125rem; color: #67646D; margin: 0 0 0.4em; font-feature-settings: \"tnum\" 1; }
1106.song-header > .meta:last-of-type { margin-bottom: 1.5em; }
1107.meta--attribution { font-family: \"Inter\", system-ui, sans-serif; font-size: 1rem; color: #4A4750; margin: 0.1em 0; }
1108.meta--attribution-secondary { font-size: 0.8125rem; color: #8A8790; margin-bottom: 0.8em; }
1109.meta__label { color: #8A8790; font-weight: 400; }
1110.meta--params { display: flex; flex-wrap: wrap; gap: 0.4em; margin: 0.2em 0 0.8em; }
1111.meta__chip { display: inline-block; padding: 0.15em 0.6em; border: 1px solid #D4D1D6; border-radius: 4px; background-color: #FAFAFA; color: #2A262E; font-family: \"JetBrains Mono\", ui-monospace, monospace; font-size: 0.8125rem; font-weight: 500; line-height: 1.4; font-feature-settings: \"tnum\" 1; }
1112.meta--supplementary { font-size: 0.75rem; color: #A8A4AD; margin-bottom: 0.4em; }
1113.meta-inline { display: inline-flex; align-items: center; gap: 0.25rem; margin: 0.15em 0.3em 0.15em 0; padding: 0 0.5rem; min-height: 1.6rem; border-radius: 2px; background-color: #F6F4F7; border: 1px solid #E8E6EA; font-family: \"JetBrains Mono\", ui-monospace, monospace; font-size: 0.75rem; color: #44424A; line-height: 1.2; letter-spacing: 0.02em; font-feature-settings: \"tnum\" 1; vertical-align: middle; }
1114.meta-inline__label { color: #8A8790; font-weight: 500; }
1115.meta-inline__value { color: #0A0A0B; font-weight: 600; }
1116.meta-inline__marking { color: #8A8790; font-weight: 400; font-style: italic; }
1117.meta-inline .music-glyph { display: inline-flex; align-items: center; flex-shrink: 0; color: #0A0A0B; height: 1.1em; }
1118.meta-inline svg.music-glyph { height: 1.1em; width: auto; display: block; }
1119.meta-inline span.music-glyph--time { font-size: 0.85em; }
1120.music-glyph { display: inline-block; flex-shrink: 0; vertical-align: middle; color: #1A1718; }
1121.music-glyph--time { display: inline-flex; flex-direction: column; align-items: center; justify-content: center; line-height: 1; font-family: \"Source Serif Pro\", serif; font-weight: 700; font-size: 1.1em; letter-spacing: 0; }
1122.music-glyph--time__num, .music-glyph--time__den { display: block; line-height: 0.9; font-feature-settings: \"tnum\" 1; }
1123.music-glyph--time__bar { display: block; width: 0.9em; height: 1.5px; margin: 0.05em 0; background-color: currentColor; border-radius: 1px; flex-shrink: 0; }
1124.music-glyph--metronome__pendulum { transform-origin: 9px 19px; animation: cs-metronome-swing var(--cs-metronome-period, 1s) cubic-bezier(0.55, 0, 0.45, 1) infinite alternate; }
1125@keyframes cs-metronome-swing { from { transform: rotate(-28deg); } to { transform: rotate(28deg); } }
1126@media (prefers-reduced-motion: reduce) { .music-glyph--metronome__pendulum { animation: none; transform: rotate(0deg); } }
1127.line { display: flex; flex-wrap: __LINE_FLEX_WRAP__; margin: 0.1em 0; }
1128.chord-block { display: inline-flex; flex-direction: column; align-items: flex-start; }
1129.chord { font-family: \"Roboto\", system-ui, -apple-system, \"Helvetica Neue\", Arial, sans-serif; font-weight: 700; color: #BD1642; font-size: 1rem; letter-spacing: 0.01em; line-height: 1; min-height: 1em; }
1130.lyrics { font-family: \"Noto Sans JP\", system-ui, -apple-system, \"Helvetica Neue\", Arial, sans-serif; font-weight: 400; font-size: 1.125rem; white-space: pre; }
1131.empty-line { height: 1em; }
1132section { margin: 1em 0; }
1133section > .section-label, .chorus-recall > .section-label { font-family: \"Inter\", system-ui, -apple-system, sans-serif; font-weight: 600; font-size: 0.75rem; color: #67646D; margin: 0 0 0.5em; line-height: 1.4; }
1134.comment { font-family: \"Inter\", system-ui, -apple-system, sans-serif; font-style: italic; color: #8A8790; margin: 0.3em 0; }
1135.comment-box { border: 1px solid #D4D1D6; border-radius: 4px; padding: 0.2em 0.5em; display: block; width: fit-content; margin: 0.3em 0; }
1136.comment.comment--highlight { background-color: #FFF3A3; color: #1A1718; font-style: normal; font-weight: 600; padding: 0.15em 0.4em; border-radius: 3px; display: block; width: fit-content; }
1137.comment.comment--highlight mark { background: none; color: inherit; }
1138section.tab .lyrics, section.grid .lyrics, section.abc .lyrics, section.ly .lyrics, section.textblock .lyrics { font-family: \"JetBrains Mono\", ui-monospace, \"SF Mono\", Menlo, Consolas, monospace; font-size: 0.875rem; font-feature-settings: \"tnum\" 1; }
1139.chorus-recall { margin: 1em 0; }
1140img { max-width: 100%; height: auto; }
1141.chord-diagrams-grid { display: flex; flex-wrap: wrap; gap: 0.5em; margin: 0.5em 0; }
1142.chord-diagram-container { display: inline-block; vertical-align: top; }
1143.chord-diagram { display: block; }
1144.grid-line { display: flex; align-items: stretch; margin: 0.35em 0; min-height: 2.4em; font-family: \"Roboto\", system-ui, -apple-system, \"Helvetica Neue\", Arial, sans-serif; font-weight: 600; font-size: 1rem; line-height: 1.4; color: #1A1718; }
1145.grid-bar { flex: 1 1 0; display: flex; align-items: center; padding: 0.2em 0.4em; min-width: 3em; }
1146.grid-beat { flex: 1 1 0; display: flex; align-items: center; justify-content: flex-start; min-width: 0; }
1147.grid-chord { color: #BD1642; }
1148.grid-no-chord { color: #67646D; font-style: italic; font-weight: 500; }
1149.grid-barline { display: inline-flex; align-items: stretch; align-self: stretch; gap: 1.5px; flex-shrink: 0; }
1150.grid-barline:not([class*='--']) { width: 1.5px; background-color: #1A1718; }
1151.grid-barline__line { display: inline-block; width: 1.5px; background-color: #1A1718; }
1152.grid-barline__line--thick { width: 3.5px; }
1153.grid-barline__dots { display: inline-flex; flex-direction: column; justify-content: center; gap: 0.18em; padding: 0 2.5px; }
1154.grid-barline__dots > span { width: 3px; height: 3px; border-radius: 50%; background-color: #1A1718; display: inline-block; }
1155.grid-volta { position: relative; display: inline-flex; align-items: stretch; flex-shrink: 0; }
1156.grid-volta .grid-barline__line { background-color: #1A1718; }
1157.grid-volta__bracket { position: absolute; top: -0.1em; left: 0; display: flex; flex-direction: column; align-items: flex-start; justify-content: flex-start; }
1158.grid-volta__cap { display: block; width: 1.5em; height: 1.5px; background-color: #1A1718; }
1159.grid-volta__label { display: inline-block; margin-left: 0.15em; font-size: 0.7em; font-weight: 600; color: #1A1718; line-height: 1; padding-top: 0.1em; }
1160.grid-row__label { display: inline-flex; align-items: center; flex-shrink: 0; padding-right: 0.6em; font-weight: 700; color: #44424A; text-transform: uppercase; font-size: 0.85em; letter-spacing: 0.04em; min-width: 2em; }
1161.grid-row__comment { display: inline-flex; align-items: center; flex-shrink: 0; padding-left: 0.6em; font-style: italic; font-weight: 500; font-size: 0.85em; color: #67646D; }
1162.grid-percent { font-weight: 700; font-size: 0.9em; color: #44424A; }
1163.grid-beat--percent1, .grid-beat--percent2 { justify-content: center; }
1164.grid-chord__sep { display: inline-block; margin: 0 0.15em; color: #8A8790; font-weight: 400; font-size: 0.85em; }
1165.grid-beat--multi { gap: 0; }
1166.grid-line--strum { margin-top: -0.4em; min-height: 1.6em; font-size: 0.85em; color: #67646D; }
1167.grid-strum { font-weight: 600; }
1168.grid-strum__glyph { font-family: \"JetBrains Mono\", ui-monospace, \"SF Mono\", Menlo, Consolas, monospace; }
1169.grid-strum--up { color: #BD1642; }
1170.grid-strum--down { color: #1A1718; }
1171.grid-strum--up-accent, .grid-strum--down-accent { font-weight: 700; }
1172.grid-strum--anticipated { font-style: italic; }
1173.grid-strum--custom { color: #67646D; font-style: italic; }
1174.sr-only { position: absolute; clip: rect(0,0,0,0); width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; white-space: nowrap; border: 0; }
1175";
1176
1177// ---------------------------------------------------------------------------
1178// Escape
1179// ---------------------------------------------------------------------------
1180
1181// ---------------------------------------------------------------------------
1182// Metadata
1183// ---------------------------------------------------------------------------
1184
1185/// Render song metadata as HTML header elements.
1186///
1187/// Layout matches the design-system reference at
1188/// `design-system/ui_kits/web/editor.html`:
1189///
1190///   <h1>{title}</h1>
1191///   <h2>{subtitle}</h2>          (one per `{subtitle}` directive)
1192///   <p class="meta">Artist · Key G · Capo N · BPM T · 4/4</p>
1193///
1194/// The meta strip is suppressed entirely when none of the
1195/// artist / key / capo / tempo / time fields are populated, so a
1196/// minimal `{title: T}`-only document still renders just an `<h1>`.
1197fn render_metadata(metadata: &chordsketch_chordpro::ast::Metadata, html: &mut String) {
1198    // Build the header contents first so we know whether to emit
1199    // the wrapping `<header>` at all — an empty AST with no
1200    // metadata should not produce an empty header landmark.
1201    let mut inner = String::new();
1202    if let Some(title) = &metadata.title {
1203        let _ = writeln!(inner, "<h1>{}</h1>", escape(title));
1204    }
1205    for subtitle in &metadata.subtitles {
1206        let _ = writeln!(inner, "<h2>{}</h2>", escape(subtitle));
1207    }
1208
1209    // The meta strip is split into three visual tiers (sister-site
1210    // to the React JSX walker):
1211    //   * `.meta--attribution`   — who made the song. Primary
1212    //       "Artist", secondary "Composer X · Lyrics Y ·
1213    //       Arranger Z".
1214    //   * `.meta--params`        — musical parameters as chip
1215    //       badges (Capo / Duration only — `{key}` / `{tempo}`
1216    //       / `{time}` are surfaced inline at their source
1217    //       positions, not in the chip strip).
1218    //   * `.meta--supplementary` — album / year / copyright at
1219    //       a smaller, muted weight.
1220    //
1221    // Role icons (mic / eighth-note / pencil / hash / album)
1222    // are intentionally React-only: the static-HTML renderer is
1223    // designed for embedding in third-party consumer stylesheets
1224    // and shouldn't impose SVG glyph choices on them. The React
1225    // JSX walker is free to render richer chrome since it ships
1226    // alongside `@chordsketch/react`'s own `styles.css`. Sister-
1227    // site asymmetry documented per `.claude/rules/renderer-
1228    // parity.md` §Required practices.
1229
1230    // Tier 1 — attribution.
1231    if !metadata.artists.is_empty() {
1232        let _ = writeln!(
1233            inner,
1234            "<p class=\"meta meta--attribution\"><span class=\"meta__label\" aria-hidden=\"true\">by </span>{}</p>",
1235            escape(&metadata.artists.join(", "))
1236        );
1237    }
1238    let mut attribution_secondary: Vec<String> = Vec::new();
1239    if !metadata.composers.is_empty() {
1240        attribution_secondary.push(format!(
1241            "<span class=\"meta__label\" aria-hidden=\"true\">Music </span>{}",
1242            escape(&metadata.composers.join(", "))
1243        ));
1244    }
1245    if !metadata.lyricists.is_empty() {
1246        attribution_secondary.push(format!(
1247            "<span class=\"meta__label\" aria-hidden=\"true\">Lyrics </span>{}",
1248            escape(&metadata.lyricists.join(", "))
1249        ));
1250    }
1251    if !metadata.arrangers.is_empty() {
1252        attribution_secondary.push(format!(
1253            "<span class=\"meta__label\" aria-hidden=\"true\">Arr. </span>{}",
1254            escape(&metadata.arrangers.join(", "))
1255        ));
1256    }
1257    if !attribution_secondary.is_empty() {
1258        let _ = writeln!(
1259            inner,
1260            "<p class=\"meta meta--attribution meta--attribution-secondary\">{}</p>",
1261            attribution_secondary.join(" · ")
1262        );
1263    }
1264
1265    // Tier 2 — musical params (chips).
1266    //
1267    // `{key}` / `{tempo}` / `{time}` are spec'd as `[Nx] [Pos]`
1268    // (multiple specifications, each applies forward from its
1269    // position in the song — see ChordPro spec §`{key}` /
1270    // §`{tempo}` / §`{time}`). Perl ChordPro accumulates these into
1271    // `{key}` / `{tempo}` / `{time}` are now surfaced inline at
1272    // each directive's source position via the `.meta-inline`
1273    // markers (key-signature glyph, animated metronome, time
1274    // signature + conductor dot), so duplicating them in the
1275    // header chip strip is pure redundancy. The chip row keeps
1276    // only the values that have no positional marker: `{capo}`
1277    // (modelled as a song-global) and `{duration}` (purely
1278    // informational). Sister-site to the React JSX walker's
1279    // `renderHeader`.
1280    let mut chips: Vec<String> = Vec::new();
1281    if let Some(capo) = metadata.capo.as_deref().filter(|s| !s.trim().is_empty()) {
1282        chips.push(format!(
1283            "<span class=\"meta__chip\">Capo {}</span>",
1284            escape(capo)
1285        ));
1286    }
1287    if let Some(duration) = metadata
1288        .duration
1289        .as_deref()
1290        .filter(|s| !s.trim().is_empty())
1291    {
1292        chips.push(format!(
1293            "<span class=\"meta__chip\">{}</span>",
1294            escape(duration)
1295        ));
1296    }
1297    if !chips.is_empty() {
1298        let _ = writeln!(
1299            inner,
1300            "<p class=\"meta meta--params\">{}</p>",
1301            chips.join("")
1302        );
1303    }
1304
1305    // Tier 3 — supplementary.
1306    let mut supplementary: Vec<String> = Vec::new();
1307    if let Some(album) = metadata.album.as_deref().filter(|s| !s.trim().is_empty()) {
1308        supplementary.push(escape(album));
1309    }
1310    if let Some(year) = metadata.year.as_deref().filter(|s| !s.trim().is_empty()) {
1311        supplementary.push(escape(year));
1312    }
1313    if let Some(copyright) = metadata
1314        .copyright
1315        .as_deref()
1316        .filter(|s| !s.trim().is_empty())
1317    {
1318        supplementary.push(escape(copyright));
1319    }
1320    if !supplementary.is_empty() {
1321        let _ = writeln!(
1322            inner,
1323            "<p class=\"meta meta--supplementary\">{}</p>",
1324            supplementary.join(" · ")
1325        );
1326    }
1327
1328    // Wrap title + subtitle + meta strip in a `<header class="song-header">`
1329    // so the song's introductory matter registers as an HTML
1330    // landmark. Sister-site to the React JSX walker's `<header>`
1331    // wrapper.
1332    if !inner.is_empty() {
1333        html.push_str("<header class=\"song-header\">\n");
1334        html.push_str(&inner);
1335        html.push_str("</header>\n");
1336    }
1337}
1338
1339// ---------------------------------------------------------------------------
1340// Lyrics (chord-over-lyrics layout)
1341// ---------------------------------------------------------------------------
1342
1343/// Render a lyrics line with chord-over-lyrics layout.
1344///
1345/// Each chord+text pair is wrapped in a `<span class="chord-block">` with
1346/// the chord in `<span class="chord">` and the text in `<span class="lyrics">`.
1347///
1348/// chord-over-lyric is a visual arrangement of two parallel
1349/// data lanes — chord names are performance instructions, lyric
1350/// text is the words being sung. That is structurally different
1351/// from a ruby annotation (which exists to *pronounce* the base
1352/// text), so the markup stays a pair of `<span>`s rather than
1353/// `<ruby>` / `<rt>`. CSS positions the chord above the lyric
1354/// via `inline-flex; flex-direction: column-reverse`.
1355/// Formatting directives (font, size, color) are applied via
1356/// inline CSS as before.
1357// `prefer_flat` is the song-wide flat/sharp spelling preference
1358// computed at the call site from
1359// `transposed_key_prefers_flat(&song.metadata, transpose_offset)`.
1360// Passing it here keeps every chord's re-spelling consistent
1361// with the song's transposed key signature — no mixed `D#` /
1362// `Ab` in an Eb-major song.
1363fn render_lyrics(
1364    lyrics_line: &LyricsLine,
1365    transpose_offset: i8,
1366    prefer_flat: bool,
1367    fmt_state: &FormattingState,
1368    html: &mut String,
1369) {
1370    html.push_str("<div class=\"line\">");
1371
1372    for segment in &lyrics_line.segments {
1373        html.push_str("<span class=\"chord-block\">");
1374
1375        if let Some(chord) = &segment.chord {
1376            let raw_display = if transpose_offset != 0 {
1377                let transposed = transpose_chord_with_style(chord, transpose_offset, prefer_flat);
1378                transposed.display_name().to_string()
1379            } else {
1380                chord.display_name().to_string()
1381            };
1382            // Typeset `Bb` as `B♭` and `F#` as `F♯` — matches the
1383            // React JSX walker's `unicodeAccidentals` so the same
1384            // chord renders identically in the static HTML and the
1385            // live React preview. Sister-site to
1386            // `packages/react/src/chordpro-jsx.tsx`.
1387            let display_name = unicode_accidentals(&raw_display);
1388            let chord_css = fmt_state.chord.to_css();
1389            if chord_css.is_empty() {
1390                let _ = write!(
1391                    html,
1392                    "<span class=\"chord\">{}</span>",
1393                    escape(&display_name)
1394                );
1395            } else {
1396                let _ = write!(
1397                    html,
1398                    "<span class=\"chord\" style=\"{}\">{}</span>",
1399                    escape(&chord_css),
1400                    escape(&display_name)
1401                );
1402            }
1403        } else if lyrics_line.has_chords() {
1404            // U+00A0 (NBSP) inside the chord placeholder so the
1405            // inline-flex `.chord-block` column reserves a full
1406            // chord-row-height line box (see #2142). `aria-hidden`
1407            // prevents assistive tech from announcing the
1408            // placeholder as "space" — the chord row carries
1409            // performance information, and a presentational NBSP
1410            // should stay silent.
1411            html.push_str("<span class=\"chord\" aria-hidden=\"true\">\u{00A0}</span>");
1412        }
1413
1414        let text_css = fmt_state.text.to_css();
1415        if text_css.is_empty() {
1416            html.push_str("<span class=\"lyrics\">");
1417        } else {
1418            let _ = write!(
1419                html,
1420                "<span class=\"lyrics\" style=\"{}\">",
1421                escape(&text_css)
1422            );
1423        }
1424        if segment.has_markup() {
1425            render_spans(&segment.spans, html);
1426        } else {
1427            html.push_str(&escape(&segment.text));
1428        }
1429        html.push_str("</span>");
1430
1431        html.push_str("</span>");
1432    }
1433
1434    html.push_str("</div>\n");
1435}
1436
1437/// Render a list of [`TextSpan`]s as HTML inline elements.
1438///
1439/// Maps each markup tag to its HTML equivalent:
1440/// - `Bold` → `<b>`
1441/// - `Italic` → `<i>`
1442/// - `Highlight` → `<mark>`
1443/// - `Comment` → `<span class="comment">`
1444/// - `Span` → `<span style="...">` with CSS properties from attributes
1445fn render_spans(spans: &[TextSpan], html: &mut String) {
1446    for span in spans {
1447        match span {
1448            TextSpan::Plain(text) => html.push_str(&escape(text)),
1449            TextSpan::Bold(children) => {
1450                html.push_str("<b>");
1451                render_spans(children, html);
1452                html.push_str("</b>");
1453            }
1454            TextSpan::Italic(children) => {
1455                html.push_str("<i>");
1456                render_spans(children, html);
1457                html.push_str("</i>");
1458            }
1459            TextSpan::Highlight(children) => {
1460                html.push_str("<mark>");
1461                render_spans(children, html);
1462                html.push_str("</mark>");
1463            }
1464            TextSpan::Comment(children) => {
1465                html.push_str("<span class=\"comment\">");
1466                render_spans(children, html);
1467                html.push_str("</span>");
1468            }
1469            TextSpan::Span(attrs, children) => {
1470                let css = span_attrs_to_css(attrs);
1471                if css.is_empty() {
1472                    html.push_str("<span>");
1473                } else {
1474                    let _ = write!(html, "<span style=\"{}\">", escape(&css));
1475                }
1476                render_spans(children, html);
1477                html.push_str("</span>");
1478            }
1479        }
1480    }
1481}
1482
1483/// Convert [`SpanAttributes`] to a CSS inline style string.
1484fn span_attrs_to_css(attrs: &SpanAttributes) -> String {
1485    let mut css = String::new();
1486    if let Some(ref font_family) = attrs.font_family {
1487        let _ = write!(css, "font-family: {};", sanitize_css_value(font_family));
1488    }
1489    if let Some(ref size) = attrs.size {
1490        let safe = sanitize_css_value(size);
1491        // If the size is a plain number, treat it as pt; otherwise pass through.
1492        if safe.chars().all(|c| c.is_ascii_digit()) {
1493            let _ = write!(css, "font-size: {safe}pt;");
1494        } else {
1495            let _ = write!(css, "font-size: {safe};");
1496        }
1497    }
1498    if let Some(ref fg) = attrs.foreground {
1499        let _ = write!(css, "color: {};", sanitize_css_value(fg));
1500    }
1501    if let Some(ref bg) = attrs.background {
1502        let _ = write!(css, "background-color: {};", sanitize_css_value(bg));
1503    }
1504    if let Some(ref weight) = attrs.weight {
1505        let _ = write!(css, "font-weight: {};", sanitize_css_value(weight));
1506    }
1507    if let Some(ref style) = attrs.style {
1508        let _ = write!(css, "font-style: {};", sanitize_css_value(style));
1509    }
1510    css
1511}
1512
1513/// Sanitize a user-provided value for use in a CSS property value context.
1514///
1515/// Uses a whitelist approach: only characters safe in CSS values are retained.
1516/// Allowed: ASCII alphanumeric, `#` (hex colors), `.` (decimals), `-` (negatives,
1517/// hyphenated names), ` ` (multi-word font names), `,` (font family lists),
1518/// `%` (percentages), `+` (font-weight values like `+lighter`).
1519fn sanitize_css_value(s: &str) -> String {
1520    s.chars()
1521        .filter(|c| {
1522            c.is_ascii_alphanumeric() || matches!(c, '#' | '.' | '-' | ' ' | ',' | '%' | '+')
1523        })
1524        .collect()
1525}
1526
1527/// Sanitize a string for use as a CSS class name.
1528///
1529/// Only allows ASCII alphanumeric characters, hyphens, and underscores.
1530/// All other characters are replaced with hyphens. Leading hyphens that would
1531/// create an invalid CSS identifier are preserved since they follow the
1532/// `section-` prefix.
1533fn sanitize_css_class(s: &str) -> String {
1534    s.chars()
1535        .map(|c| {
1536            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
1537                c
1538            } else {
1539                '-'
1540            }
1541        })
1542        .collect()
1543}
1544
1545/// Sanitize SVG/HTML content by removing `<script>` elements and event handler
1546/// attributes (`onload`, `onerror`, `onclick`, etc.).
1547///
1548/// This provides defense-in-depth against XSS when rendering untrusted `.cho`
1549/// files. The ChordPro specification allows raw SVG passthrough, but script
1550/// injection is never legitimate in music notation.
1551fn sanitize_svg_content(input: &str) -> String {
1552    // Dangerous elements that are stripped entirely (opening tag through closing tag).
1553    // Per sanitizer-security.md §SVG tag blocklists, this MUST cover all SVG/HTML
1554    // elements that can load external resources: script, feImage, image, iframe,
1555    // embed, object, and foreign-content containers.
1556    // Note: <use> elements are NOT stripped here; their href/xlink:href attributes are
1557    // restricted by sanitize_tag_attrs to fragment-only references (^#...). External
1558    // URIs — including https — are stripped entirely to prevent tracking-pixel,
1559    // cross-origin-referer, and timing-based exfiltration attacks (see #1828).
1560    const DANGEROUS_TAGS: &[&str] = &[
1561        "script",
1562        "foreignobject",
1563        "iframe",
1564        "object",
1565        "embed",
1566        "math",
1567        // feImage is an SVG filter primitive that loads external content via href.
1568        // Stripping by URI scheme alone is insufficient: <feImage href="https://attacker.com/"/>
1569        // would survive since https: is allowed. The element must be stripped entirely.
1570        "feimage",
1571        // SVG <image> element loads external raster/vector images. Not needed in
1572        // music notation SVG; strip entirely to prevent tracking-pixel and
1573        // cross-origin leaks even over https:.
1574        "image",
1575        "set",
1576        "animate",
1577        "animatetransform",
1578        "animatemotion",
1579    ];
1580
1581    let mut result = String::with_capacity(input.len());
1582    let mut chars = input.char_indices().peekable();
1583    let bytes = input.as_bytes();
1584
1585    while let Some((i, c)) = chars.next() {
1586        if c == '<' {
1587            let rest = &input[i..];
1588            // Use a safe UTF-8 boundary for the prefix check. All tag names
1589            // are ASCII, so 30 bytes is more than enough for matching.
1590            let limit = rest
1591                .char_indices()
1592                .map(|(idx, _)| idx)
1593                .find(|&idx| idx >= 30)
1594                .unwrap_or(rest.len());
1595            let rest_upper = &rest[..limit];
1596
1597            // Optional XML namespace prefix (e.g. `<svg:script>`,
1598            // `<xhtml:iframe>`). HTML5 parsers outside an SVG root treat
1599            // these as colon-in-name plain elements, so the practical
1600            // attack surface is narrow — but the sister-site rule for
1601            // blocklists demands we not rely on callers always stripping
1602            // namespaces beforehand. See sanitizer-security.md §SVG tag
1603            // blocklists.
1604            let ns_open = namespace_prefix_len(&rest.as_bytes()[1..]);
1605            let tag_start_in_rest = 1 + ns_open;
1606
1607            // Check for opening dangerous tags: <tag or <tag> or <tag ...>
1608            let mut matched = false;
1609            for tag in DANGEROUS_TAGS {
1610                let tag_end_in_rest = tag_start_in_rest + tag.len();
1611                if rest.len() > tag_end_in_rest
1612                    && rest_upper.len() >= tag_end_in_rest
1613                    && starts_with_ignore_case(&rest_upper[tag_start_in_rest..], tag)
1614                    && bytes
1615                        .get(i + tag_end_in_rest)
1616                        .is_none_or(|b| b.is_ascii_whitespace() || *b == b'>' || *b == b'/')
1617                {
1618                    // Check if this opening tag is self-closing (ends with />).
1619                    // Skips `>` inside quoted attribute values to handle
1620                    // cases like `<set to="a>b"/>`.
1621                    let is_self_closing = {
1622                        let tag_bytes = rest.as_bytes();
1623                        let mut in_quote: Option<u8> = None;
1624                        let mut gt_pos = None;
1625                        for (idx, &b) in tag_bytes.iter().enumerate() {
1626                            match in_quote {
1627                                Some(q) if b == q => in_quote = None,
1628                                Some(_) => {}
1629                                None if b == b'"' || b == b'\'' => in_quote = Some(b),
1630                                None if b == b'>' => {
1631                                    gt_pos = Some(idx);
1632                                    break;
1633                                }
1634                                _ => {}
1635                            }
1636                        }
1637                        gt_pos.is_some_and(|gt| gt > 0 && tag_bytes[gt - 1] == b'/')
1638                    };
1639
1640                    if is_self_closing {
1641                        // Self-closing tag — skip past the closing >.
1642                        // Use quote-aware scanning to avoid stopping at >
1643                        // inside attribute values.
1644                        let mut skip_quote: Option<char> = None;
1645                        while let Some(&(_, ch)) = chars.peek() {
1646                            chars.next();
1647                            match skip_quote {
1648                                Some(q) if ch == q => skip_quote = None,
1649                                Some(_) => {}
1650                                None if ch == '"' || ch == '\'' => {
1651                                    skip_quote = Some(ch);
1652                                }
1653                                None if ch == '>' => break,
1654                                _ => {}
1655                            }
1656                        }
1657                    } else if let Some(end) = find_end_tag_ignore_case(input, i, tag) {
1658                        // Skip until after </tag>.
1659                        while let Some(&(j, _)) = chars.peek() {
1660                            if j >= end {
1661                                break;
1662                            }
1663                            chars.next();
1664                        }
1665                    } else {
1666                        // No closing tag — skip to end of input.
1667                        return result;
1668                    }
1669                    matched = true;
1670                    break;
1671                }
1672            }
1673            if matched {
1674                continue;
1675            }
1676
1677            // Check for stray closing dangerous tags: </tag> or </ns:tag>
1678            let ns_close = namespace_prefix_len(rest.as_bytes().get(2..).unwrap_or(&[]));
1679            let tag_start_in_close = 2 + ns_close;
1680            for tag in DANGEROUS_TAGS {
1681                let tag_end_in_close = tag_start_in_close + tag.len();
1682                if rest_upper.len() >= tag_end_in_close
1683                    && rest.len() > tag_end_in_close
1684                    && rest.starts_with("</")
1685                    && starts_with_ignore_case(&rest_upper[tag_start_in_close..], tag)
1686                    && bytes
1687                        .get(i + tag_end_in_close)
1688                        .is_none_or(|b| b.is_ascii_whitespace() || *b == b'>')
1689                {
1690                    // Skip past the closing >.
1691                    while let Some(&(_, ch)) = chars.peek() {
1692                        chars.next();
1693                        if ch == '>' {
1694                            break;
1695                        }
1696                    }
1697                    matched = true;
1698                    break;
1699                }
1700            }
1701            if matched {
1702                continue;
1703            }
1704
1705            result.push(c);
1706        } else {
1707            result.push(c);
1708        }
1709    }
1710
1711    // Strip event handler attributes and dangerous URI schemes.
1712    strip_dangerous_attrs(&result)
1713}
1714
1715/// Check if `s` starts with `prefix` (ASCII case-insensitive).
1716fn starts_with_ignore_case(s: &str, prefix: &str) -> bool {
1717    if s.len() < prefix.len() {
1718        return false;
1719    }
1720    s.as_bytes()[..prefix.len()]
1721        .iter()
1722        .zip(prefix.as_bytes())
1723        .all(|(a, b)| a.eq_ignore_ascii_case(b))
1724}
1725
1726/// Zero-width, BOM, and bidirectional-override code points that browsers
1727/// may render as invisible inside a URI scheme but which an attacker can
1728/// use to split a blocked scheme name (e.g. `java\u{200B}script:` or
1729/// `java\u{FEFF}script:`). Stripped before scheme comparison.
1730fn is_invisible_format_char(c: char) -> bool {
1731    matches!(
1732        c,
1733        '\u{00AD}' // soft hyphen
1734        | '\u{200B}' // zero-width space
1735        | '\u{200C}' // zero-width non-joiner
1736        | '\u{200D}' // zero-width joiner
1737        | '\u{200E}' // left-to-right mark (see #2087)
1738        | '\u{200F}' // right-to-left mark (see #2087)
1739        | '\u{2060}' // word joiner
1740        | '\u{FEFF}' // zero-width no-break space / BOM
1741        | '\u{202A}'..='\u{202E}' // bidi embedding/override
1742        | '\u{2066}'..='\u{2069}' // isolate / pop directional
1743    )
1744}
1745
1746/// Length of an optional XML namespace prefix at the start of `bytes`
1747/// (e.g. `svg:`, `xhtml:`), including the trailing colon. Returns `0` when
1748/// no prefix is present, so callers can always add the result to a fixed
1749/// offset without branching.
1750fn namespace_prefix_len(bytes: &[u8]) -> usize {
1751    let mut idx = 0;
1752    match bytes.first() {
1753        Some(b) if b.is_ascii_alphabetic() => idx += 1,
1754        _ => return 0,
1755    }
1756    // XML Namespaces §2 NCName body allows alphanumerics, `-`, `_`, and `.`
1757    // after the first character. `.` is intentionally excluded from the
1758    // first-character match above — see issue #2088 for context.
1759    while let Some(&b) = bytes.get(idx) {
1760        if b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.' {
1761            idx += 1;
1762        } else {
1763            break;
1764        }
1765    }
1766    if bytes.get(idx) == Some(&b':') {
1767        idx + 1
1768    } else {
1769        0
1770    }
1771}
1772
1773/// Find the byte offset just past the closing `</tag>` for the given tag name,
1774/// starting the search from position `start`. Returns `None` if not found.
1775///
1776/// Accepts an optional XML namespace prefix on the closing tag so that
1777/// `<svg:script>…</svg:script>` is fully consumed even though we matched
1778/// the opening form by stripping `svg:` at lookup time.
1779fn find_end_tag_ignore_case(input: &str, start: usize, tag: &str) -> Option<usize> {
1780    let search = &input.as_bytes()[start..];
1781    let tag_bytes = tag.as_bytes();
1782
1783    for i in 0..search.len() {
1784        if search[i] == b'<' && search.get(i + 1) == Some(&b'/') {
1785            let after_slash = &search[i + 2..];
1786            let ns = namespace_prefix_len(after_slash);
1787            let tag_end = ns + tag_bytes.len();
1788            if after_slash.len() >= tag_end {
1789                let candidate = &after_slash[ns..tag_end];
1790                if candidate
1791                    .iter()
1792                    .zip(tag_bytes)
1793                    .all(|(a, b)| a.eq_ignore_ascii_case(b))
1794                {
1795                    // Find the closing '>'.
1796                    if let Some(gt) = after_slash[tag_end..].iter().position(|&b| b == b'>') {
1797                        return Some(start + i + 2 + tag_end + gt + 1);
1798                    }
1799                }
1800            }
1801        }
1802    }
1803    None
1804}
1805
1806/// Strip dangerous attributes from HTML/SVG tags: event handlers (`on*`) and
1807/// URI attributes (see [`is_uri_attr`]) with dangerous schemes
1808/// (see [`has_dangerous_uri_scheme`]). Only operates inside `<...>`
1809/// delimiters to avoid false positives in text content.
1810fn strip_dangerous_attrs(input: &str) -> String {
1811    let mut result = String::with_capacity(input.len());
1812    let bytes = input.as_bytes();
1813    let mut pos = 0;
1814
1815    while pos < bytes.len() {
1816        if bytes[pos] == b'<' && pos + 1 < bytes.len() && bytes[pos + 1] != b'/' {
1817            // Inside an opening tag — find the closing `>` using
1818            // quote-aware scanning so that `>` inside attribute values
1819            // (e.g. title=">") does not prematurely end the tag.
1820            if let Some(gt) = find_tag_end(&bytes[pos..]) {
1821                let tag_end = pos + gt + 1;
1822                let tag_content = &input[pos..tag_end];
1823                result.push_str(&sanitize_tag_attrs(tag_content));
1824                pos = tag_end;
1825            } else {
1826                result.push_str(&input[pos..]);
1827                break;
1828            }
1829        } else {
1830            // Outside a tag — advance one UTF-8 character at a time to
1831            // preserve multi-byte characters (CJK, emoji, accented, etc.).
1832            debug_assert!(
1833                input.is_char_boundary(pos),
1834                "pos must land on a char boundary; advancing by c.len_utf8() is the invariant"
1835            );
1836            let ch = &input[pos..];
1837            let c = ch
1838                .chars()
1839                .next()
1840                .expect("pos is on a char boundary and within bounds");
1841            result.push(c);
1842            pos += c.len_utf8();
1843        }
1844    }
1845    result
1846}
1847
1848/// Find the index of the closing `>` of an opening tag, skipping `>` characters
1849/// inside quoted attribute values (`"..."` or `'...'`).
1850fn find_tag_end(bytes: &[u8]) -> Option<usize> {
1851    let mut i = 0;
1852    let mut in_quote: Option<u8> = None;
1853    while i < bytes.len() {
1854        let b = bytes[i];
1855        if let Some(q) = in_quote {
1856            if b == q {
1857                in_quote = None;
1858            }
1859        } else if b == b'"' || b == b'\'' {
1860            in_quote = Some(b);
1861        } else if b == b'>' {
1862            return Some(i);
1863        }
1864        i += 1;
1865    }
1866    None
1867}
1868
1869/// Check if a URI value starts with a dangerous scheme (`javascript:`,
1870/// `vbscript:`, `data:`), ignoring leading whitespace and case.
1871fn has_dangerous_uri_scheme(value: &str) -> bool {
1872    // Strip leading whitespace, then remove embedded whitespace, ASCII
1873    // control characters, and the Unicode format characters that have
1874    // historically been used to obfuscate URI schemes (zero-width spaces,
1875    // zero-width joiners, bidi overrides, BOM, word joiner). Filter runs
1876    // before `take(30)` so the cap applies to meaningful characters,
1877    // preventing bypass via large numbers of embedded invisible
1878    // characters. See sanitizer-security.md §Blocklist completeness and
1879    // the OWASP XSS Prevention Cheat Sheet for motivation.
1880    let lower: String = value
1881        .trim_start()
1882        .chars()
1883        .filter(|&c| {
1884            !c.is_ascii_whitespace() && !c.is_ascii_control() && !is_invisible_format_char(c)
1885        })
1886        .take(30)
1887        .flat_map(|c| c.to_lowercase())
1888        .collect();
1889    // Blocked schemes — parity with is_safe_image_src which uses an allowlist approach:
1890    //   javascript/vbscript: code execution
1891    //   data:               content injection
1892    //   file:/blob:         local file access when HTML is opened as a local file
1893    //   mhtml:              MIME HTML (IE-era; blocked by is_safe_image_src via allowlist)
1894    // See OWASP XSS Prevention Cheat Sheet for further rationale.
1895    lower.starts_with("javascript:")
1896        || lower.starts_with("vbscript:")
1897        || lower.starts_with("data:")
1898        || lower.starts_with("file:")
1899        || lower.starts_with("blob:")
1900        || lower.starts_with("mhtml:")
1901}
1902
1903/// Check if an attribute name is a URI-bearing attribute that needs scheme
1904/// validation.
1905///
1906/// Covers the minimum set required by `.claude/rules/sanitizer-security.md`:
1907/// `href`, `xlink:href`, `src`, `action`, `formaction`, `poster`, `background`,
1908/// `ping`, `to`, `values`, `from`, `by`.
1909fn is_uri_attr(name: &str) -> bool {
1910    let lower: String = name.chars().flat_map(|c| c.to_lowercase()).collect();
1911    lower == "href"
1912        || lower == "src"
1913        || lower == "xlink:href"
1914        // SVG animation attributes that carry path/URI values
1915        || lower == "to"
1916        || lower == "values"
1917        || lower == "from"
1918        || lower == "by"
1919        // HTML form / navigation attributes that can carry executable URIs
1920        || lower == "action"
1921        || lower == "formaction"
1922        // Media / embed attributes
1923        || lower == "poster"
1924        || lower == "background"
1925        // Ping sends requests to the listed URLs on link click
1926        || lower == "ping"
1927}
1928
1929/// Sanitize attributes in a single HTML/SVG tag string.
1930///
1931/// Removes event handler attributes (`on*`) entirely and strips URI attributes
1932/// (see [`is_uri_attr`]) that use dangerous schemes (see [`has_dangerous_uri_scheme`]).
1933///
1934/// This function operates at the byte level for performance. This is safe
1935/// because HTML/SVG tag names, attribute names, and structural characters
1936/// (`<`, `>`, `=`, `"`, `'`, `/`, whitespace) are all ASCII. Attribute
1937/// *values* are extracted via string slicing on the original `&str`, which
1938/// preserves UTF-8 correctness for non-ASCII content.
1939fn sanitize_tag_attrs(tag: &str) -> String {
1940    let mut result = String::with_capacity(tag.len());
1941    let bytes = tag.as_bytes();
1942    let mut i = 0;
1943
1944    // Copy the '<' and tag name (always ASCII in valid HTML/SVG).
1945    while i < bytes.len() && bytes[i] != b' ' && bytes[i] != b'>' && bytes[i] != b'/' {
1946        result.push(bytes[i] as char);
1947        i += 1;
1948    }
1949
1950    // Remember the tag name (without the leading '<') for tag-specific
1951    // attribute rules such as the `<use>` fragment-only policy below.
1952    let tag_name = &result[1..];
1953    let is_use_tag =
1954        tag_name.eq_ignore_ascii_case("use") || tag_name.eq_ignore_ascii_case("svg:use");
1955
1956    while i < bytes.len() {
1957        // Skip whitespace.
1958        while i < bytes.len() && bytes[i].is_ascii_whitespace() {
1959            result.push(bytes[i] as char);
1960            i += 1;
1961        }
1962
1963        if i >= bytes.len() || bytes[i] == b'>' || bytes[i] == b'/' {
1964            result.push_str(&tag[i..]);
1965            return result;
1966        }
1967
1968        // Read attribute name.
1969        let attr_start = i;
1970        while i < bytes.len()
1971            && bytes[i] != b'='
1972            && bytes[i] != b' '
1973            && bytes[i] != b'>'
1974            && bytes[i] != b'/'
1975        {
1976            i += 1;
1977        }
1978        let attr_name = &tag[attr_start..i];
1979
1980        let is_event_handler = attr_name.len() > 2
1981            && attr_name.as_bytes()[..2].eq_ignore_ascii_case(b"on")
1982            && attr_name.as_bytes()[2].is_ascii_alphabetic();
1983
1984        // Extract the attribute value (if any) without copying yet.
1985        let value_start = i;
1986        let mut attr_value: Option<String> = None;
1987        if i < bytes.len() && bytes[i] == b'=' {
1988            i += 1; // skip '='
1989            if i < bytes.len() && (bytes[i] == b'"' || bytes[i] == b'\'') {
1990                let quote = bytes[i];
1991                i += 1;
1992                let val_start = i;
1993                while i < bytes.len() && bytes[i] != quote {
1994                    i += 1;
1995                }
1996                attr_value = Some(tag[val_start..i].to_string());
1997                if i < bytes.len() {
1998                    i += 1; // skip closing quote
1999                }
2000            } else {
2001                // Unquoted value.
2002                let val_start = i;
2003                while i < bytes.len() && !bytes[i].is_ascii_whitespace() && bytes[i] != b'>' {
2004                    i += 1;
2005                }
2006                attr_value = Some(tag[val_start..i].to_string());
2007            }
2008        }
2009
2010        if is_event_handler {
2011            // Strip event handler attributes entirely.
2012            continue;
2013        }
2014
2015        if is_uri_attr(attr_name) {
2016            if let Some(ref val) = attr_value {
2017                if has_dangerous_uri_scheme(val) {
2018                    // Strip the attribute if it uses a dangerous URI scheme.
2019                    continue;
2020                }
2021                // <use href="..."> / <use xlink:href="..."> must be
2022                // fragment-only (^#...). External URIs (even over https)
2023                // allow cross-origin tracking, referer leakage, and
2024                // timing-based exfiltration from rendered ChordPro
2025                // content. See issue #1828 and sanitizer-security.md
2026                // §SVG tag blocklists.
2027                if is_use_tag
2028                    && (attr_name.eq_ignore_ascii_case("href")
2029                        || attr_name.eq_ignore_ascii_case("xlink:href"))
2030                    && !val.trim_start().starts_with('#')
2031                {
2032                    continue;
2033                }
2034            }
2035        }
2036
2037        // Strip style attributes that contain url() or expression() to
2038        // prevent CSS-based data exfiltration via network requests.
2039        if attr_name.eq_ignore_ascii_case("style") {
2040            if let Some(ref val) = attr_value {
2041                let lower_val: String = val.chars().flat_map(|c| c.to_lowercase()).collect();
2042                if lower_val.contains("url(")
2043                    || lower_val.contains("expression(")
2044                    || lower_val.contains("@import")
2045                {
2046                    continue;
2047                }
2048            }
2049        }
2050
2051        // Copy the attribute as-is.
2052        result.push_str(&tag[attr_start..value_start]);
2053        if attr_value.is_some() {
2054            result.push_str(&tag[value_start..i]);
2055        }
2056    }
2057
2058    result
2059}
2060
2061// ---------------------------------------------------------------------------
2062// Directives
2063// ---------------------------------------------------------------------------
2064
2065/// Render a directive as HTML (dispatches to section open/close/other).
2066///
2067/// StartOfChorus, EndOfChorus, and Chorus are handled directly in
2068/// `render_song` for chorus-recall state tracking.
2069fn render_directive_inner(
2070    directive: &chordsketch_chordpro::ast::Directive,
2071    show_diagrams: bool,
2072    diagram_frets: usize,
2073    html: &mut String,
2074) {
2075    match &directive.kind {
2076        DirectiveKind::StartOfChorus => {
2077            render_section_open("chorus", "Chorus", &directive.value, html);
2078        }
2079        DirectiveKind::StartOfVerse => {
2080            render_section_open("verse", "Verse", &directive.value, html);
2081        }
2082        DirectiveKind::StartOfBridge => {
2083            render_section_open("bridge", "Bridge", &directive.value, html);
2084        }
2085        DirectiveKind::StartOfTab => {
2086            render_section_open("tab", "Tab", &directive.value, html);
2087        }
2088        DirectiveKind::StartOfGrid => {
2089            // See the outer body-loop arm for the full
2090            // selection logic. This fallback exists for
2091            // callers (e.g. chorus replay) that route
2092            // `StartOfGrid` through this helper without the
2093            // surrounding body context.
2094            let label_value = directive.value.as_ref().and_then(|v| {
2095                if let Some(label) = chordsketch_chordpro::grid::extract_grid_label(v) {
2096                    Some(label)
2097                } else if !v.contains('=') {
2098                    Some(v.clone())
2099                } else {
2100                    None
2101                }
2102            });
2103            render_section_open("grid", "Grid", &label_value, html);
2104        }
2105        DirectiveKind::StartOfAbc => {
2106            render_section_open("abc", "ABC", &directive.value, html);
2107        }
2108        DirectiveKind::StartOfLy => {
2109            render_section_open("ly", "Lilypond", &directive.value, html);
2110        }
2111        // StartOfSvg is handled in the main rendering loop with raw HTML
2112        // embedding (<div class="svg-section">), not via render_section_open.
2113        DirectiveKind::StartOfTextblock => {
2114            render_section_open("textblock", "Textblock", &directive.value, html);
2115        }
2116        DirectiveKind::StartOfMusicxml => {
2117            render_section_open("musicxml", "MusicXML", &directive.value, html);
2118        }
2119        DirectiveKind::StartOfSection(section_name) => {
2120            let class = format!("section-{}", sanitize_css_class(section_name));
2121            let label = escape(&chordsketch_chordpro::capitalize(section_name));
2122            render_section_open(&class, &label, &directive.value, html);
2123        }
2124        DirectiveKind::EndOfChorus
2125        | DirectiveKind::EndOfVerse
2126        | DirectiveKind::EndOfBridge
2127        | DirectiveKind::EndOfTab
2128        | DirectiveKind::EndOfGrid
2129        | DirectiveKind::EndOfAbc
2130        | DirectiveKind::EndOfLy
2131        | DirectiveKind::EndOfMusicxml
2132        | DirectiveKind::EndOfSvg
2133        | DirectiveKind::EndOfTextblock
2134        | DirectiveKind::EndOfSection(_) => {
2135            html.push_str("</section>\n");
2136        }
2137        DirectiveKind::Image(attrs) => {
2138            render_image(attrs, html);
2139        }
2140        DirectiveKind::Define if show_diagrams => {
2141            if let Some(ref value) = directive.value {
2142                let def = chordsketch_chordpro::ast::ChordDefinition::parse_value(value);
2143                // Keyboard defines: render a piano keyboard SVG.
2144                if let Some(ref keys_raw) = def.keys {
2145                    let keys_u8: Vec<u8> = keys_raw
2146                        .iter()
2147                        .filter_map(|&k| {
2148                            if (0i32..=127).contains(&k) {
2149                                Some(k as u8)
2150                            } else {
2151                                None
2152                            }
2153                        })
2154                        .collect();
2155                    if !keys_u8.is_empty() {
2156                        let root = keys_u8[0];
2157                        let voicing = chordsketch_chordpro::chord_diagram::KeyboardVoicing {
2158                            name: def.name.clone(),
2159                            display_name: def.display.clone(),
2160                            keys: keys_u8,
2161                            root_key: root,
2162                        };
2163                        html.push_str("<figure class=\"chord-diagram-container\">");
2164                        html.push_str(&chordsketch_chordpro::chord_diagram::render_keyboard_svg(
2165                            &voicing,
2166                        ));
2167                        html.push_str("</figure>\n");
2168                    }
2169                } else if let Some(ref raw) = def.raw {
2170                    // Fretted defines: render the standard fret-grid SVG.
2171                    if let Some(mut diagram) =
2172                        chordsketch_chordpro::chord_diagram::DiagramData::from_raw_infer_frets(
2173                            &def.name,
2174                            raw,
2175                            diagram_frets,
2176                        )
2177                    {
2178                        diagram.display_name = def.display.clone();
2179                        html.push_str("<figure class=\"chord-diagram-container\">");
2180                        html.push_str(&chordsketch_chordpro::chord_diagram::render_svg(&diagram));
2181                        html.push_str("</figure>\n");
2182                    }
2183                }
2184            }
2185        }
2186        DirectiveKind::Define => {}
2187        _ => {}
2188    }
2189}
2190
2191/// Render ABC notation content using abc2svg, falling back to preformatted text.
2192///
2193/// When abc2svg is available and produces valid output, the SVG fragment is
2194/// embedded inside a `<section class="abc">` element. When abc2svg is
2195/// unavailable or fails, the raw ABC notation is rendered as preformatted text.
2196#[cfg(not(target_arch = "wasm32"))]
2197fn render_abc_with_fallback(
2198    abc_content: &str,
2199    label: &Option<String>,
2200    html: &mut String,
2201    warnings: &mut Vec<String>,
2202) {
2203    match chordsketch_chordpro::external_tool::invoke_abc2svg(abc_content) {
2204        Ok(svg_fragment) => {
2205            render_section_open("abc", "ABC", label, html);
2206            html.push_str(&sanitize_svg_content(&svg_fragment));
2207            html.push('\n');
2208            html.push_str("</section>\n");
2209        }
2210        Err(e) => {
2211            push_warning(warnings, format!("abc2svg invocation failed: {e}"));
2212            render_section_open("abc", "ABC", label, html);
2213            html.push_str("<pre>");
2214            html.push_str(&escape(abc_content));
2215            html.push_str("</pre>\n");
2216            html.push_str("</section>\n");
2217        }
2218    }
2219}
2220
2221/// Fallback for wasm32: external tools are never available, so render as
2222/// preformatted text. This function is unreachable in practice because
2223/// `has_abc2svg()` always returns false on wasm32, but the compiler needs it.
2224#[cfg(target_arch = "wasm32")]
2225fn render_abc_with_fallback(
2226    abc_content: &str,
2227    label: &Option<String>,
2228    html: &mut String,
2229    _warnings: &mut Vec<String>,
2230) {
2231    render_section_open("abc", "ABC", label, html);
2232    html.push_str("<pre>");
2233    html.push_str(&escape(abc_content));
2234    html.push_str("</pre>\n");
2235    html.push_str("</section>\n");
2236}
2237
2238/// Check whether an image `src` value is safe to emit in HTML.
2239///
2240/// Re-export shared image-src validation from `chordsketch-chordpro`.
2241///
2242/// The actual allowlist logic lives in `chordsketch_chordpro::image_path::is_safe_image_src`
2243/// so every renderer (text, HTML, PDF) applies the same check — see
2244/// `.claude/rules/renderer-parity.md` §Validation Parity.
2245use chordsketch_chordpro::image_path::is_safe_image_src;
2246
2247/// Render Lilypond notation content using lilypond, falling back to preformatted text.
2248///
2249/// When lilypond is available and produces valid output, the SVG is embedded
2250/// inside a `<section class="ly">` element. When lilypond is unavailable or
2251/// fails, the raw notation is rendered as preformatted text.
2252#[cfg(not(target_arch = "wasm32"))]
2253fn render_ly_with_fallback(
2254    ly_content: &str,
2255    label: &Option<String>,
2256    html: &mut String,
2257    warnings: &mut Vec<String>,
2258) {
2259    match chordsketch_chordpro::external_tool::invoke_lilypond(ly_content) {
2260        Ok(svg) => {
2261            render_section_open("ly", "Lilypond", label, html);
2262            html.push_str(&sanitize_svg_content(&svg));
2263            html.push('\n');
2264            html.push_str("</section>\n");
2265        }
2266        Err(e) => {
2267            push_warning(warnings, format!("lilypond invocation failed: {e}"));
2268            render_section_open("ly", "Lilypond", label, html);
2269            html.push_str("<pre>");
2270            html.push_str(&escape(ly_content));
2271            html.push_str("</pre>\n");
2272            html.push_str("</section>\n");
2273        }
2274    }
2275}
2276
2277/// Fallback for wasm32: external tools are never available, so render as
2278/// preformatted text. Unreachable in practice because `has_lilypond()` always
2279/// returns false on wasm32.
2280#[cfg(target_arch = "wasm32")]
2281fn render_ly_with_fallback(
2282    ly_content: &str,
2283    label: &Option<String>,
2284    html: &mut String,
2285    _warnings: &mut Vec<String>,
2286) {
2287    render_section_open("ly", "Lilypond", label, html);
2288    html.push_str("<pre>");
2289    html.push_str(&escape(ly_content));
2290    html.push_str("</pre>\n");
2291    html.push_str("</section>\n");
2292}
2293
2294/// Render MusicXML content using MuseScore, falling back to preformatted text.
2295///
2296/// When MuseScore is available and produces valid output, the SVG is embedded
2297/// inside a `<section class="musicxml">` element. When MuseScore is unavailable
2298/// or fails, the raw MusicXML is rendered as preformatted text.
2299#[cfg(not(target_arch = "wasm32"))]
2300fn render_musicxml_with_fallback(
2301    musicxml_content: &str,
2302    label: &Option<String>,
2303    html: &mut String,
2304    warnings: &mut Vec<String>,
2305) {
2306    match chordsketch_chordpro::external_tool::invoke_musescore(musicxml_content) {
2307        Ok(svg) => {
2308            render_section_open("musicxml", "MusicXML", label, html);
2309            html.push_str(&sanitize_svg_content(&svg));
2310            html.push('\n');
2311            html.push_str("</section>\n");
2312        }
2313        Err(e) => {
2314            push_warning(warnings, format!("musescore invocation failed: {e}"));
2315            render_section_open("musicxml", "MusicXML", label, html);
2316            html.push_str("<pre>");
2317            html.push_str(&escape(musicxml_content));
2318            html.push_str("</pre>\n");
2319            html.push_str("</section>\n");
2320        }
2321    }
2322}
2323
2324/// Fallback for wasm32: external tools are never available, so render as
2325/// preformatted text. Unreachable in practice because `has_musescore()` always
2326/// returns false on wasm32.
2327#[cfg(target_arch = "wasm32")]
2328fn render_musicxml_with_fallback(
2329    musicxml_content: &str,
2330    label: &Option<String>,
2331    html: &mut String,
2332    _warnings: &mut Vec<String>,
2333) {
2334    render_section_open("musicxml", "MusicXML", label, html);
2335    html.push_str("<pre>");
2336    html.push_str(&escape(musicxml_content));
2337    html.push_str("</pre>\n");
2338    html.push_str("</section>\n");
2339}
2340
2341/// Render an `{image}` directive as an HTML `<img>` element.
2342///
2343/// Generates a `<div>` wrapper (with optional alignment from the `anchor`
2344/// attribute) containing an `<img>` tag.  The `src`, `width`, `height`, and
2345/// `title` (as `alt`) attributes are forwarded.  A `scale` value is applied
2346/// via a CSS `transform: scale(…)` style.
2347///
2348/// Paths that fail [`is_safe_image_src`] are silently skipped.
2349fn render_image(attrs: &chordsketch_chordpro::ast::ImageAttributes, html: &mut String) {
2350    if !is_safe_image_src(&attrs.src) {
2351        return;
2352    }
2353
2354    let mut style = String::new();
2355    let mut img_attrs = format!("src=\"{}\"", escape(&attrs.src));
2356
2357    if let Some(ref title) = attrs.title {
2358        let _ = write!(img_attrs, " alt=\"{}\"", escape(title));
2359    }
2360
2361    if let Some(ref width) = attrs.width {
2362        let _ = write!(img_attrs, " width=\"{}\"", escape(width));
2363    }
2364    if let Some(ref height) = attrs.height {
2365        let _ = write!(img_attrs, " height=\"{}\"", escape(height));
2366    }
2367    if let Some(ref scale) = attrs.scale {
2368        // Scale as a CSS transform
2369        let _ = write!(
2370            style,
2371            "transform: scale({});transform-origin: top left;",
2372            sanitize_css_value(scale)
2373        );
2374    }
2375
2376    // Determine wrapper alignment
2377    let align_css = match attrs.anchor.as_deref() {
2378        Some("column") | Some("paper") => "text-align: center;",
2379        _ => "",
2380    };
2381
2382    if !align_css.is_empty() {
2383        let _ = write!(html, "<div style=\"{align_css}\">");
2384    } else {
2385        html.push_str("<div>");
2386    }
2387
2388    let _ = write!(html, "<img {img_attrs}");
2389    if !style.is_empty() {
2390        // The style string is first sanitised (sanitize_css_value removes
2391        // dangerous characters) and then HTML-escaped here.  The double
2392        // processing is intentional: sanitisation makes the CSS value safe,
2393        // while escape() ensures the surrounding attribute context is safe
2394        // (e.g. a `"` in the style cannot break out of the attribute).
2395        let _ = write!(html, " style=\"{}\"", escape(&style));
2396    }
2397    html.push_str("></div>\n");
2398}
2399
2400/// Open a `<section>` with a class and optional label.
2401///
2402/// The label is emitted as an `<h3 class="section-label">` so
2403/// screen readers recognise it as the section's heading (HTML5
2404/// implicitly names a sectioning element by its first descendant
2405/// heading). Sister-site to the React JSX walker's `<h3>` /
2406/// `aria-labelledby` pair. The Rust renderer skips the explicit
2407/// `aria-labelledby` because the renderer does not currently
2408/// thread per-section unique IDs (a separate enhancement) — the
2409/// implicit "first heading is the accessible name" rule still
2410/// applies.
2411fn render_section_open(class: &str, label: &str, value: &Option<String>, html: &mut String) {
2412    let safe_class = sanitize_css_class(class);
2413    let _ = writeln!(html, "<section class=\"{safe_class}\">");
2414    let display_label = match value {
2415        Some(v) if !v.is_empty() => format!("{label}: {}", escape(v)),
2416        _ => label.to_string(),
2417    };
2418    let _ = writeln!(html, "<h3 class=\"section-label\">{display_label}</h3>");
2419}
2420
2421/// Render a single body line inside a `{start_of_grid}` section
2422/// as structured grid markup. Sister-site to
2423/// `renderGridLine` in `packages/react/src/chordpro-jsx.tsx`;
2424/// the two surfaces emit equivalent DOM shapes so the
2425/// `.grid-*` CSS picks them up identically.
2426fn render_grid_line(raw: &str, html: &mut String) {
2427    use chordsketch_chordpro::grid::{GridBarline, GridRowKind, GridToken, classify_grid_row};
2428    use chordsketch_chordpro::typography::unicode_accidentals;
2429
2430    let row = classify_grid_row(raw);
2431    let row_class = if matches!(row.kind, GridRowKind::Strum) {
2432        "grid-line grid-line--strum"
2433    } else {
2434        "grid-line"
2435    };
2436    let _ = write!(html, "<div class=\"{row_class}\">");
2437
2438    if let Some(ref label) = row.label {
2439        let _ = write!(
2440            html,
2441            "<span class=\"grid-row__label\">{}</span>",
2442            escape(label)
2443        );
2444    }
2445
2446    // Group body tokens into bars + barlines, like the React
2447    // walker. Beat slots are collected until a barline flushes
2448    // them as one `.grid-bar`.
2449    enum BeatSlot {
2450        Chord(Vec<String>),
2451        Strum(String),
2452        Continuation,
2453        Percent1,
2454        Percent2,
2455    }
2456    enum Cell {
2457        Bar {
2458            beats: Vec<BeatSlot>,
2459            no_chord: bool,
2460        },
2461        Barline(GridBarline),
2462        Volta(u8),
2463    }
2464    let mut cells: Vec<Cell> = Vec::new();
2465    let mut current: Option<(Vec<BeatSlot>, bool)> = None;
2466    let flush = |current: &mut Option<(Vec<BeatSlot>, bool)>, cells: &mut Vec<Cell>| {
2467        if let Some((beats, no_chord)) = current.take() {
2468            if !beats.is_empty() || no_chord {
2469                cells.push(Cell::Bar { beats, no_chord });
2470            }
2471        }
2472    };
2473    let strum_row = matches!(row.kind, GridRowKind::Strum);
2474    for tok in &row.body {
2475        match tok {
2476            GridToken::Space => {}
2477            GridToken::Cell(names) => {
2478                let entry = current.get_or_insert_with(|| (Vec::new(), false));
2479                if strum_row {
2480                    entry.0.push(BeatSlot::Strum(names.join("~")));
2481                } else {
2482                    entry.0.push(BeatSlot::Chord(names.clone()));
2483                }
2484            }
2485            GridToken::Percent1 => {
2486                current
2487                    .get_or_insert_with(|| (Vec::new(), false))
2488                    .0
2489                    .push(BeatSlot::Percent1);
2490            }
2491            GridToken::Percent2 => {
2492                current
2493                    .get_or_insert_with(|| (Vec::new(), false))
2494                    .0
2495                    .push(BeatSlot::Percent2);
2496            }
2497            GridToken::Continuation => {
2498                current
2499                    .get_or_insert_with(|| (Vec::new(), false))
2500                    .0
2501                    .push(BeatSlot::Continuation);
2502            }
2503            GridToken::NoChord => {
2504                let entry = current.get_or_insert_with(|| (Vec::new(), false));
2505                entry.1 = true;
2506            }
2507            GridToken::Barline(b) => {
2508                flush(&mut current, &mut cells);
2509                cells.push(Cell::Barline(*b));
2510            }
2511            GridToken::Volta(n) => {
2512                flush(&mut current, &mut cells);
2513                cells.push(Cell::Volta(*n));
2514            }
2515        }
2516    }
2517    flush(&mut current, &mut cells);
2518
2519    for cell in &cells {
2520        match cell {
2521            Cell::Bar { beats, no_chord } => {
2522                let slots: &[BeatSlot] = if beats.is_empty() {
2523                    &[BeatSlot::Continuation]
2524                } else {
2525                    beats
2526                };
2527                let _ = write!(
2528                    html,
2529                    "<span class=\"grid-bar\" data-beats=\"{}\">",
2530                    slots.len()
2531                );
2532                if *no_chord {
2533                    html.push_str(
2534                        "<span class=\"grid-no-chord\" aria-label=\"no chord\">N.C.</span>",
2535                    );
2536                } else {
2537                    for slot in slots {
2538                        match slot {
2539                            BeatSlot::Chord(names) => {
2540                                if names.len() == 1 {
2541                                    let _ = write!(
2542                                        html,
2543                                        "<span class=\"grid-beat\"><span class=\"grid-chord\">{}</span></span>",
2544                                        escape(&unicode_accidentals(&names[0]))
2545                                    );
2546                                } else {
2547                                    html.push_str("<span class=\"grid-beat grid-beat--multi\">");
2548                                    for (i, name) in names.iter().enumerate() {
2549                                        if i > 0 {
2550                                            html.push_str(
2551                                                "<span class=\"grid-chord__sep\" aria-hidden=\"true\">~</span>",
2552                                            );
2553                                        }
2554                                        let _ = write!(
2555                                            html,
2556                                            "<span class=\"grid-chord\">{}</span>",
2557                                            escape(&unicode_accidentals(name))
2558                                        );
2559                                    }
2560                                    html.push_str("</span>");
2561                                }
2562                            }
2563                            BeatSlot::Strum(raw) => {
2564                                let (class, glyph) = strum_class_and_glyph(raw);
2565                                let _ = write!(
2566                                    html,
2567                                    "<span class=\"grid-beat grid-strum {}\">\
2568                                     <span class=\"grid-strum__glyph\" aria-hidden=\"true\">{}</span>\
2569                                     <span class=\"sr-only\">{}</span>\
2570                                     </span>",
2571                                    class,
2572                                    escape(&glyph),
2573                                    escape(raw),
2574                                );
2575                            }
2576                            BeatSlot::Continuation => {
2577                                html.push_str("<span class=\"grid-beat\"></span>");
2578                            }
2579                            BeatSlot::Percent1 => {
2580                                html.push_str(
2581                                    "<span class=\"grid-beat grid-beat--percent1\" \
2582                                     aria-label=\"repeat previous bar\">\
2583                                     <span class=\"grid-percent\" aria-hidden=\"true\">%</span>\
2584                                     </span>",
2585                                );
2586                            }
2587                            BeatSlot::Percent2 => {
2588                                html.push_str(
2589                                    "<span class=\"grid-beat grid-beat--percent2\" \
2590                                     aria-label=\"repeat previous two bars\">\
2591                                     <span class=\"grid-percent\" aria-hidden=\"true\">%%</span>\
2592                                     </span>",
2593                                );
2594                            }
2595                        }
2596                    }
2597                }
2598                html.push_str("</span>");
2599            }
2600            Cell::Barline(b) => match b {
2601                GridBarline::Single => {
2602                    html.push_str("<span class=\"grid-barline\" aria-hidden=\"true\"></span>");
2603                }
2604                GridBarline::Double => {
2605                    html.push_str(
2606                        "<span class=\"grid-barline grid-barline--double\" aria-hidden=\"true\">\
2607                         <span class=\"grid-barline__line\"></span>\
2608                         <span class=\"grid-barline__line\"></span>\
2609                         </span>",
2610                    );
2611                }
2612                GridBarline::Final => {
2613                    html.push_str(
2614                        "<span class=\"grid-barline grid-barline--final\" aria-label=\"final barline\">\
2615                         <span class=\"grid-barline__line\"></span>\
2616                         <span class=\"grid-barline__line grid-barline__line--thick\"></span>\
2617                         </span>",
2618                    );
2619                }
2620                GridBarline::RepeatStart => {
2621                    html.push_str(
2622                        "<span class=\"grid-barline grid-barline--repeat-start\" aria-label=\"repeat start\">\
2623                         <span class=\"grid-barline__line grid-barline__line--thick\"></span>\
2624                         <span class=\"grid-barline__line\"></span>\
2625                         <span class=\"grid-barline__dots\"><span></span><span></span></span>\
2626                         </span>",
2627                    );
2628                }
2629                GridBarline::RepeatEnd => {
2630                    html.push_str(
2631                        "<span class=\"grid-barline grid-barline--repeat-end\" aria-label=\"repeat end\">\
2632                         <span class=\"grid-barline__dots\"><span></span><span></span></span>\
2633                         <span class=\"grid-barline__line\"></span>\
2634                         <span class=\"grid-barline__line grid-barline__line--thick\"></span>\
2635                         </span>",
2636                    );
2637                }
2638                GridBarline::RepeatBoth => {
2639                    html.push_str(
2640                        "<span class=\"grid-barline grid-barline--repeat-both\" aria-label=\"repeat end and start\">\
2641                         <span class=\"grid-barline__dots\"><span></span><span></span></span>\
2642                         <span class=\"grid-barline__line grid-barline__line--thick\"></span>\
2643                         <span class=\"grid-barline__line grid-barline__line--thick\"></span>\
2644                         <span class=\"grid-barline__dots\"><span></span><span></span></span>\
2645                         </span>",
2646                    );
2647                }
2648            },
2649            Cell::Volta(n) => {
2650                let _ = write!(
2651                    html,
2652                    "<span class=\"grid-volta\" aria-label=\"{n} ending\">\
2653                     <span class=\"grid-volta__bracket\">\
2654                     <span class=\"grid-volta__cap\"></span>\
2655                     <span class=\"grid-volta__label\">{n}.</span>\
2656                     </span>\
2657                     <span class=\"grid-barline__line\"></span>\
2658                     </span>",
2659                );
2660            }
2661        }
2662    }
2663
2664    if let Some(ref comment) = row.trailing_comment {
2665        let _ = write!(
2666            html,
2667            "<span class=\"grid-row__comment\">{}</span>",
2668            escape(comment)
2669        );
2670    }
2671
2672    html.push_str("</div>\n");
2673}
2674
2675/// Map a strum-pattern token to (css-class-suffix, glyph)
2676/// pair. Sister-site to `strumClassFor` + `strumGlyphFor` in
2677/// `packages/react/src/chordpro-jsx.tsx` — must produce the
2678/// same class names so the shared `.grid-strum--*` rules in
2679/// the embedded stylesheet apply identically.
2680fn strum_class_and_glyph(raw: &str) -> (String, String) {
2681    let anticipated = raw.starts_with('~');
2682    let stripped = if anticipated { &raw[1..] } else { raw };
2683    let lower = stripped.to_ascii_lowercase();
2684    let (base, glyph): (&str, String) = match lower.as_str() {
2685        "up" | "u" => (
2686            "grid-strum--up",
2687            if anticipated { "~↑" } else { "↑" }.to_string(),
2688        ),
2689        "dn" | "d" => (
2690            "grid-strum--down",
2691            if anticipated { "~↓" } else { "↓" }.to_string(),
2692        ),
2693        "u+" => (
2694            "grid-strum--up-accent",
2695            if anticipated { "~↑+" } else { "↑+" }.to_string(),
2696        ),
2697        "d+" => (
2698            "grid-strum--down-accent",
2699            if anticipated { "~↓+" } else { "↓+" }.to_string(),
2700        ),
2701        "ua" => (
2702            "grid-strum--up-arpeggio",
2703            if anticipated { "~↑·" } else { "↑·" }.to_string(),
2704        ),
2705        "da" => (
2706            "grid-strum--down-arpeggio",
2707            if anticipated { "~↓·" } else { "↓·" }.to_string(),
2708        ),
2709        // Dialect tokens (`dn~up`, `~ux`, `d+~u+`) — pass the
2710        // raw text through verbatim so the reader still sees
2711        // the source intent.
2712        _ => ("grid-strum--custom", raw.to_string()),
2713    };
2714    let class = if anticipated {
2715        format!("{base} grid-strum--anticipated")
2716    } else {
2717        base.to_string()
2718    };
2719    (class, glyph)
2720}
2721
2722/// Render a `{chorus}` recall directive as HTML.
2723///
2724/// Re-renders the stored chorus AST lines with the current transpose offset,
2725/// so chords are transposed correctly even if `{transpose}` changed after
2726/// the chorus was defined.
2727/// Per-call rendering context for `render_chorus_recall`.
2728///
2729/// The previous flat-arg form exceeded clippy's
2730/// `too_many_arguments` threshold (8/7). Bundling the
2731/// rendering knobs into one struct keeps each call site
2732/// readable AND lets clippy 1.95 see only two parameters
2733/// (the recall payload + the rendering context) without
2734/// resorting to a per-call `#[allow]`.
2735struct ChorusRecallCtx<'a> {
2736    chorus_body: &'a [Line],
2737    transpose_offset: i8,
2738    prefer_flat: bool,
2739    fmt_state: &'a FormattingState,
2740    show_diagrams: bool,
2741    diagram_frets: usize,
2742}
2743
2744fn render_chorus_recall(value: &Option<String>, ctx: &ChorusRecallCtx<'_>, html: &mut String) {
2745    let chorus_body = ctx.chorus_body;
2746    let transpose_offset = ctx.transpose_offset;
2747    let prefer_flat = ctx.prefer_flat;
2748    let fmt_state = ctx.fmt_state;
2749    let show_diagrams = ctx.show_diagrams;
2750    let diagram_frets = ctx.diagram_frets;
2751    html.push_str("<div class=\"chorus-recall\">\n");
2752    let display_label = match value {
2753        Some(v) if !v.is_empty() => format!("Chorus: {}", escape(v)),
2754        _ => "Chorus".to_string(),
2755    };
2756    let _ = writeln!(html, "<h3 class=\"section-label\">{display_label}</h3>");
2757    // Use a local copy of fmt_state so in-chorus formatting directives
2758    // (e.g. {size}, {bold}) are applied during recall without mutating
2759    // the caller's state.
2760    let mut local_fmt = fmt_state.clone();
2761    for line in chorus_body {
2762        match line {
2763            Line::Lyrics(lyrics) => {
2764                render_lyrics(lyrics, transpose_offset, prefer_flat, &local_fmt, html)
2765            }
2766            Line::Comment(style, text) => render_comment(*style, text, html),
2767            Line::Empty => html.push_str("<div class=\"empty-line\" aria-hidden=\"true\"></div>\n"),
2768            Line::Directive(d) if d.kind.is_font_size_color() => {
2769                local_fmt.apply(&d.kind, &d.value);
2770            }
2771            Line::Directive(d) if !d.kind.is_metadata() => {
2772                render_directive_inner(d, show_diagrams, diagram_frets, html);
2773            }
2774            _ => {}
2775        }
2776    }
2777    html.push_str("</div>\n");
2778}
2779
2780// ---------------------------------------------------------------------------
2781// Comments
2782// ---------------------------------------------------------------------------
2783
2784/// Render a comment as HTML.
2785fn render_comment(style: CommentStyle, text: &str, html: &mut String) {
2786    match style {
2787        CommentStyle::Normal => {
2788            let _ = writeln!(html, "<p class=\"comment\">{}</p>", escape(text));
2789        }
2790        CommentStyle::Italic => {
2791            let _ = writeln!(html, "<p class=\"comment\"><em>{}</em></p>", escape(text));
2792        }
2793        CommentStyle::Boxed => {
2794            let _ = writeln!(html, "<div class=\"comment-box\">{}</div>", escape(text));
2795        }
2796        CommentStyle::Highlight => {
2797            // `{highlight}` is the spec's stronger sibling of `{comment}`
2798            // — `chordsketch-render-html` emits a separate `comment--highlight`
2799            // class so consumer stylesheets can paint it distinctly (bold
2800            // weight, yellow background, etc.) without forking the
2801            // base `.comment` styles. The text sits inside a `<mark>`
2802            // so HTML5's "marked text" semantic carries through to
2803            // assistive tech. Sister-site to the React JSX walker.
2804            let _ = writeln!(
2805                html,
2806                "<p class=\"comment comment--highlight\"><mark>{}</mark></p>",
2807                escape(text)
2808            );
2809        }
2810    }
2811}
2812
2813// ===========================================================================
2814// Tests
2815// ===========================================================================
2816
2817#[cfg(test)]
2818mod sanitize_tag_attrs_tests {
2819    use super::*;
2820
2821    #[test]
2822    fn test_preserves_normal_attrs() {
2823        let tag = "<svg width=\"100\" height=\"50\">";
2824        assert_eq!(sanitize_tag_attrs(tag), tag);
2825    }
2826
2827    #[test]
2828    fn test_strips_event_handler() {
2829        let tag = "<svg onclick=\"alert(1)\" width=\"100\">";
2830        let result = sanitize_tag_attrs(tag);
2831        assert!(!result.contains("onclick"));
2832        assert!(result.contains("width"));
2833    }
2834
2835    #[test]
2836    fn test_non_ascii_in_attr_value_preserved() {
2837        let tag = "<text title=\"日本語テスト\" x=\"10\">";
2838        let result = sanitize_tag_attrs(tag);
2839        assert!(result.contains("日本語テスト"));
2840        assert!(result.contains("x=\"10\""));
2841    }
2842
2843    // --- Case-insensitive event handler detection (#663) ---
2844
2845    #[test]
2846    fn test_strips_mixed_case_event_handler() {
2847        let tag = "<svg OnClick=\"alert(1)\" width=\"100\">";
2848        let result = sanitize_tag_attrs(tag);
2849        assert!(!result.contains("OnClick"));
2850        assert!(result.contains("width"));
2851    }
2852
2853    #[test]
2854    fn test_strips_uppercase_event_handler() {
2855        let tag = "<svg ONLOAD=\"alert(1)\">";
2856        let result = sanitize_tag_attrs(tag);
2857        assert!(!result.contains("ONLOAD"));
2858    }
2859
2860    // --- Style attribute sanitization (#654) ---
2861
2862    #[test]
2863    fn test_strips_style_with_url() {
2864        let tag =
2865            "<rect style=\"background-image: url('https://attacker.com/exfil')\" width=\"10\">";
2866        let result = sanitize_tag_attrs(tag);
2867        assert!(!result.contains("style"));
2868        assert!(result.contains("width"));
2869    }
2870
2871    #[test]
2872    fn test_strips_style_with_expression() {
2873        let tag = "<rect style=\"width: expression(alert(1))\">";
2874        let result = sanitize_tag_attrs(tag);
2875        assert!(!result.contains("style"));
2876    }
2877
2878    #[test]
2879    fn test_strips_style_with_import() {
2880        let tag = "<rect style=\"@import url(evil.css)\">";
2881        let result = sanitize_tag_attrs(tag);
2882        assert!(!result.contains("style"));
2883    }
2884
2885    #[test]
2886    fn test_preserves_safe_style() {
2887        let tag = "<rect style=\"fill: red; stroke: blue\" width=\"10\">";
2888        let result = sanitize_tag_attrs(tag);
2889        assert!(result.contains("style"));
2890        assert!(result.contains("fill: red"));
2891    }
2892
2893    #[test]
2894    fn test_use_strips_relative_url_href() {
2895        // `<use>` only allows fragment-only (`#foo`) hrefs (policy added in
2896        // PR #1857, which this test extends; see also #1828 for the broader
2897        // SVG attack surface tracker). A relative URL like
2898        // `sprites.svg#icon` is NOT fragment-only — it resolves against the
2899        // document's base URL and could fetch an external SVG sprite sheet.
2900        // The fragment-only check strips it.
2901        let tag = "<use href=\"sprites.svg#icon\">";
2902        let result = sanitize_tag_attrs(tag);
2903        assert!(
2904            !result.contains("href="),
2905            "relative URL must be stripped for <use>; got {result:?}"
2906        );
2907    }
2908
2909    #[test]
2910    fn test_use_preserves_whitespace_prefixed_fragment_href() {
2911        // Some serializers emit `href=" #symbol"` with leading whitespace
2912        // around the value. The fragment-only check uses `trim_start` so
2913        // whitespace-padded fragments are still accepted.
2914        let tag = "<use href=\" #myShape\">";
2915        let result = sanitize_tag_attrs(tag);
2916        // Match `href=` (with the `=`) specifically: the bare `href`
2917        // substring would also match the tag name `<use>` plus the word
2918        // `href` appearing in unrelated sanitized output.
2919        assert!(
2920            result.contains("href="),
2921            "whitespace-prefixed fragment href must be preserved; got {result:?}"
2922        );
2923    }
2924}
2925
2926#[cfg(test)]
2927mod tests {
2928    use super::*;
2929
2930    // `unicode_accidentals_*` tests moved to
2931    // `chordsketch_chordpro::typography::tests` together with
2932    // the helper itself.
2933
2934    #[test]
2935    fn test_transposed_chord_uses_canonical_spelling_per_target_key() {
2936        // C +3 → Eb (flat side). A `D#`/`Eb` chord transposed
2937        // should appear with the flat spelling, NOT `D#`. This
2938        // is the High finding from the parallel review: legacy
2939        // `transpose_chord` per-chord style preservation would
2940        // emit `F#` (since source `D#` is sharp-style), but
2941        // song-wide canonical spelling gives `Gb`.
2942        let song = chordsketch_chordpro::parse("{key: C}\n[D#]hi").unwrap();
2943        let result = render_song_with_warnings(&song, 3, &Config::defaults());
2944        assert!(
2945            result.output.contains(">G\u{266D}<"),
2946            "expected `Gb` (flat-side spelling) in transposed Eb song; got: {}",
2947            result.output
2948        );
2949        assert!(
2950            !result.output.contains(">F\u{266F}<"),
2951            "must not emit sharp-side spelling for a flat-side song; got: {}",
2952            result.output
2953        );
2954    }
2955
2956    #[test]
2957    fn test_chord_block_uses_unicode_accidentals() {
2958        let html = render("[Bb]hi");
2959        assert!(
2960            html.contains("<span class=\"chord\">B\u{266D}</span>"),
2961            "expected `B♭` in chord block, got: {html}"
2962        );
2963    }
2964
2965    #[test]
2966    fn test_inline_key_marker_uses_unicode_accidentals_for_flat_key() {
2967        // The chip strip no longer carries `{key}` — it's surfaced
2968        // inline at the directive's source position. Verify the
2969        // inline marker uses the ♭ unicode accidental.
2970        let html = render("{key: Bb}");
2971        assert!(
2972            html.contains("<span class=\"meta-inline__value\">B\u{266D}</span>"),
2973            "expected `B♭` in inline key value, got: {html}"
2974        );
2975    }
2976
2977    #[test]
2978    fn test_inline_key_marker_uses_unicode_accidentals() {
2979        let html = render("[G]a\n{key: Eb}\n[Eb]b");
2980        assert!(
2981            html.contains("<span class=\"meta-inline__value\">E\u{266D}</span>"),
2982            "expected `E♭` in inline key value, got: {html}"
2983        );
2984    }
2985
2986    #[test]
2987    fn test_render_empty() {
2988        let song = chordsketch_chordpro::parse("").unwrap();
2989        let html = render_song(&song);
2990        assert!(html.contains("<!DOCTYPE html>"));
2991        assert!(html.contains("</html>"));
2992    }
2993
2994    #[test]
2995    fn test_render_song_body_omits_document_envelope() {
2996        // The whole point of the body-only family is that consumers
2997        // wrapping the output in their own document do not get a
2998        // nested `<!DOCTYPE>` / `<html>` / `<head>` / `<style>` to
2999        // unwrap. Pin that contract here so future refactors cannot
3000        // silently re-introduce the envelope.
3001        let song =
3002            chordsketch_chordpro::parse("{title: Sample}\nWas [G]blind but [D]now I [G]see.")
3003                .unwrap();
3004        let body = render_song_body(&song);
3005        assert!(!body.contains("<!DOCTYPE"));
3006        assert!(!body.contains("<html"));
3007        assert!(!body.contains("</html>"));
3008        // `<head>` (the document-envelope element), not `<head`
3009        // — the body now legitimately contains `<header>` for
3010        // the song-header landmark, which would match a naked
3011        // `<head` prefix check.
3012        assert!(!body.contains("<head>"));
3013        assert!(!body.contains("<style"));
3014        assert!(!body.contains("<title>"));
3015        // The body must still contain the song wrapper and metadata
3016        // blocks that the full-document renderer produces inside
3017        // `<body>` — that's the contract consumers depend on.
3018        assert!(body.contains("<article class=\"song\">"));
3019        assert!(body.contains("<h1>Sample</h1>"));
3020        // The `chord-block` flex layout is what positions chords above
3021        // lyrics; body-only consumers will provide the CSS via
3022        // `render_html_css()` but the markup itself must still emit
3023        // the class names.
3024        assert!(body.contains("class=\"chord-block\""));
3025    }
3026
3027    #[test]
3028    fn test_render_song_body_byte_stable_with_full_render_body_section() {
3029        // The body-only output should be byte-equal to the body slice
3030        // of the full-document output (between `<body>\n` and
3031        // `</body>`). That is the only way to guarantee that callers
3032        // who switch over from the full-document API to the body-only
3033        // API see no rendering drift.
3034        let song = chordsketch_chordpro::parse(
3035            "{title: Amazing Grace}\nA[G]mazing [D]grace, how [G]sweet the sound.",
3036        )
3037        .unwrap();
3038        let full = render_song(&song);
3039        let body = render_song_body(&song);
3040        let body_start = full
3041            .find("<body>\n")
3042            .expect("full-document render must have <body>")
3043            + "<body>\n".len();
3044        let body_end = full
3045            .rfind("</body>")
3046            .expect("full-document render must have </body>");
3047        let extracted = &full[body_start..body_end];
3048        assert_eq!(
3049            extracted, body,
3050            "body-only output must match the body slice of the full document"
3051        );
3052    }
3053
3054    #[test]
3055    fn test_render_html_css_returns_canonical_block() {
3056        // Consumers can hash this result to build cache-busting
3057        // filenames or inline it directly. Pin a few key selectors
3058        // so a future refactor that drops one (e.g. `.chord-block`,
3059        // which is what makes the chord-over-lyrics layout work)
3060        // surfaces here as a hard test failure rather than a silent
3061        // visual regression in every host that uses the body-only
3062        // family.
3063        let css = render_html_css();
3064        assert!(css.contains(".chord-block"));
3065        assert!(css.contains(".chord "));
3066        assert!(css.contains(".lyrics"));
3067        // Default config has settings.wraplines=true, so the canonical
3068        // block must reference the wrap variant of the .line rule.
3069        assert!(css.contains(".line { display: flex; flex-wrap: wrap;"));
3070        // The full-document renderer embeds *exactly* this string
3071        // inside its `<style>` block — assert the lockstep so a
3072        // future divergence is caught immediately.
3073        let song = chordsketch_chordpro::parse("{title: t}").unwrap();
3074        let full = render_song(&song);
3075        assert!(full.contains(&css));
3076    }
3077
3078    // -- settings.wraplines (R6.100.0, #2296) ------------------------------
3079
3080    #[test]
3081    fn test_wraplines_default_is_wrap() {
3082        // No setting → default true → wrap.
3083        let css = render_html_css_with_config(&Config::defaults());
3084        assert!(
3085            css.contains(".line { display: flex; flex-wrap: wrap;"),
3086            "default settings.wraplines must emit flex-wrap: wrap; got: {css}"
3087        );
3088        // The grid keeps its own wrap regardless.
3089        assert!(
3090            css.contains(".chord-diagrams-grid { display: flex; flex-wrap: wrap;"),
3091            ".chord-diagrams-grid wrap must never be substituted"
3092        );
3093        // Sentinel must not survive.
3094        assert!(
3095            !css.contains("__LINE_FLEX_WRAP__"),
3096            "the sentinel must always be replaced; got: {css}"
3097        );
3098    }
3099
3100    #[test]
3101    fn test_wraplines_false_emits_nowrap() {
3102        let cfg = Config::defaults()
3103            .with_define("settings.wraplines=false")
3104            .unwrap();
3105        let css = render_html_css_with_config(&cfg);
3106        assert!(
3107            css.contains(".line { display: flex; flex-wrap: nowrap;"),
3108            "settings.wraplines=false must emit flex-wrap: nowrap; got: {css}"
3109        );
3110        // .chord-diagrams-grid must keep its own wrap.
3111        assert!(
3112            css.contains(".chord-diagrams-grid { display: flex; flex-wrap: wrap;"),
3113            ".chord-diagrams-grid wrap must NOT change with settings.wraplines"
3114        );
3115    }
3116
3117    #[test]
3118    fn test_wraplines_full_document_embeds_configured_value() {
3119        // The full-document renderer must embed the same CSS that
3120        // render_html_css_with_config returns for the same config.
3121        let cfg = Config::defaults()
3122            .with_define("settings.wraplines=false")
3123            .unwrap();
3124        let song = chordsketch_chordpro::parse("{title: t}").unwrap();
3125        let full = render_song_with_warnings(&song, 0, &cfg).output;
3126        assert!(
3127            full.contains(".line { display: flex; flex-wrap: nowrap;"),
3128            "full document must embed nowrap when settings.wraplines=false"
3129        );
3130    }
3131
3132    #[test]
3133    fn test_wraplines_true_explicit_matches_default() {
3134        let cfg = Config::defaults()
3135            .with_define("settings.wraplines=true")
3136            .unwrap();
3137        assert_eq!(
3138            render_html_css_with_config(&cfg),
3139            render_html_css_with_config(&Config::defaults())
3140        );
3141    }
3142
3143    #[test]
3144    fn test_render_songs_body_separator_between_songs() {
3145        let parsed =
3146            chordsketch_chordpro::parse_multi_lenient("{title: A}\n{new_song}\n{title: B}");
3147        let songs: Vec<_> = parsed.results.into_iter().map(|r| r.song).collect();
3148        assert_eq!(songs.len(), 2, "expected two songs in the parsed output");
3149        let body = render_songs_body(&songs);
3150        assert!(body.contains("<hr class=\"song-separator\">"));
3151        assert!(!body.contains("<!DOCTYPE"));
3152    }
3153
3154    #[test]
3155    fn test_render_songs_body_empty_input() {
3156        let body = render_songs_body(&[]);
3157        assert!(body.is_empty());
3158    }
3159
3160    #[test]
3161    fn test_render_title() {
3162        let html = render("{title: My Song}");
3163        assert!(html.contains("<h1>My Song</h1>"));
3164        assert!(html.contains("<title>My Song</title>"));
3165    }
3166
3167    #[test]
3168    fn test_render_subtitle() {
3169        let html = render("{title: Song}\n{subtitle: By Someone}");
3170        assert!(html.contains("<h2>By Someone</h2>"));
3171    }
3172
3173    #[test]
3174    fn test_render_lyrics_with_chords() {
3175        let html = render("[Am]Hello [G]world");
3176        assert!(html.contains("chord-block"));
3177        assert!(html.contains("<span class=\"chord\">Am</span>"));
3178        assert!(html.contains("<span class=\"lyrics\">Hello </span>"));
3179        assert!(html.contains("<span class=\"chord\">G</span>"));
3180    }
3181
3182    #[test]
3183    fn test_render_lyrics_no_chords() {
3184        let html = render("Just plain text");
3185        assert!(html.contains("<span class=\"lyrics\">Just plain text</span>"));
3186        // Should NOT have chord spans when no chords are present
3187        assert!(!html.contains("class=\"chord\""));
3188    }
3189
3190    #[test]
3191    fn test_render_chorus_section() {
3192        let html = render("{start_of_chorus}\n[G]La la\n{end_of_chorus}");
3193        assert!(html.contains("<section class=\"chorus\">"));
3194        assert!(html.contains("</section>"));
3195        assert!(html.contains("Chorus"));
3196    }
3197
3198    #[test]
3199    fn test_render_verse_with_label() {
3200        let html = render("{start_of_verse: Verse 1}\nLyrics\n{end_of_verse}");
3201        assert!(html.contains("<section class=\"verse\">"));
3202        assert!(html.contains("Verse: Verse 1"));
3203    }
3204
3205    #[test]
3206    fn test_render_comment() {
3207        let html = render("{comment: A note}");
3208        assert!(html.contains("<p class=\"comment\">A note</p>"));
3209    }
3210
3211    #[test]
3212    fn test_render_comment_italic() {
3213        let html = render("{comment_italic: Softly}");
3214        assert!(html.contains("<em>Softly</em>"));
3215    }
3216
3217    #[test]
3218    fn test_render_comment_box() {
3219        let html = render("{comment_box: Important}");
3220        assert!(html.contains("<div class=\"comment-box\">Important</div>"));
3221    }
3222
3223    #[test]
3224    fn test_render_comment_highlight() {
3225        // `{highlight}` per spec is an "alternative to comment" with
3226        // stronger visual emphasis. Sister to the text renderer's
3227        // `<<...>>` delimiter and the PDF renderer's bold variant.
3228        // Wrapping `<mark>` carries the HTML5 "marked text"
3229        // semantic — see the semantic-HTML refactor in this PR.
3230        let html = render("{highlight: Watch out}");
3231        assert!(
3232            html.contains("<p class=\"comment comment--highlight\"><mark>Watch out</mark></p>"),
3233            "got: {html}"
3234        );
3235    }
3236
3237    #[test]
3238    fn test_html_escaping() {
3239        let html = render("{title: Tom & Jerry <3}");
3240        assert!(html.contains("Tom &amp; Jerry &lt;3"));
3241    }
3242
3243    #[test]
3244    fn test_try_render_success() {
3245        let result = try_render("{title: Test}");
3246        assert!(result.is_ok());
3247    }
3248
3249    #[test]
3250    fn test_try_render_error() {
3251        let result = try_render("{unclosed");
3252        assert!(result.is_err());
3253    }
3254
3255    #[test]
3256    fn test_render_valid_html_structure() {
3257        let html = render("{title: Test}\n\n{start_of_verse}\n[G]Hello [C]world\n{end_of_verse}");
3258        assert!(html.starts_with("<!DOCTYPE html>"));
3259        assert!(html.contains("<html"));
3260        assert!(html.contains("<head>"));
3261        assert!(html.contains("<style>"));
3262        assert!(html.contains("<body>"));
3263        assert!(html.contains("</html>"));
3264    }
3265
3266    #[test]
3267    fn test_text_before_first_chord() {
3268        let html = render("Hello [Am]world");
3269        // Chord-less segments in a chord-bearing line render with a
3270        // U+00A0 NBSP inside the `.chord` placeholder so the
3271        // inline-flex column reserves a full line box for the chord
3272        // row; see #2142 for the baseline-alignment bug that a
3273        // genuinely empty span caused.
3274        assert!(
3275            html.contains(
3276                "<span class=\"chord\" aria-hidden=\"true\">\u{00A0}</span><span class=\"lyrics\">Hello </span>"
3277            )
3278        );
3279    }
3280
3281    #[test]
3282    fn test_chord_less_and_chord_bearing_segments_share_baseline_placeholder() {
3283        // Regression guard for #2142: when a lyrics line mixes
3284        // chord-less segments with chord-bearing segments, the
3285        // chord-less side must emit the NBSP-bearing placeholder
3286        // so the flex columns align vertically.
3287        let html = render("Was [G]blind but [D]now I [G]see.");
3288        // The leading "Was " segment must carry the NBSP
3289        // placeholder, not a bare empty span.
3290        assert!(
3291            html.contains(
3292                "<span class=\"chord\" aria-hidden=\"true\">\u{00A0}</span><span class=\"lyrics\">Was </span>"
3293            ),
3294            "expected NBSP-bearing chord placeholder for \"Was \" segment, got: {html}"
3295        );
3296        // The chord-bearing segments still carry their chord text.
3297        assert!(html.contains("<span class=\"chord\">G</span>"));
3298        assert!(html.contains("<span class=\"chord\">D</span>"));
3299    }
3300
3301    #[test]
3302    fn test_empty_line() {
3303        let html = render("Line one\n\nLine two");
3304        assert!(html.contains("empty-line"));
3305    }
3306
3307    #[test]
3308    fn test_render_grid_section() {
3309        let html = render("{start_of_grid}\n| Am . | C . |\n{end_of_grid}");
3310        assert!(html.contains("<section class=\"grid\">"));
3311        assert!(html.contains("Grid"));
3312        assert!(html.contains("</section>"));
3313    }
3314
3315    // --- Custom sections (#108) ---
3316
3317    #[test]
3318    fn test_render_custom_section_intro() {
3319        let html = render("{start_of_intro}\n[Am]Da da\n{end_of_intro}");
3320        assert!(html.contains("<section class=\"section-intro\">"));
3321        assert!(html.contains("Intro"));
3322        assert!(html.contains("</section>"));
3323    }
3324
3325    #[test]
3326    fn test_render_grid_section_with_label() {
3327        let html = render("{start_of_grid: Intro}\n| Am |\n{end_of_grid}");
3328        assert!(html.contains("<section class=\"grid\">"));
3329        assert!(html.contains("Grid: Intro"));
3330    }
3331
3332    #[test]
3333    fn test_render_grid_short_alias() {
3334        let html = render("{sog}\n| G . |\n{eog}");
3335        assert!(html.contains("<section class=\"grid\">"));
3336        assert!(html.contains("</section>"));
3337    }
3338
3339    #[test]
3340    fn test_render_custom_section_with_label() {
3341        let html = render("{start_of_intro: Guitar}\nNotes\n{end_of_intro}");
3342        assert!(html.contains("<section class=\"section-intro\">"));
3343        assert!(html.contains("Intro: Guitar"));
3344    }
3345
3346    #[test]
3347    fn test_render_custom_section_outro() {
3348        let html = render("{start_of_outro}\nFinal\n{end_of_outro}");
3349        assert!(html.contains("<section class=\"section-outro\">"));
3350        assert!(html.contains("Outro"));
3351    }
3352
3353    #[test]
3354    fn test_render_custom_section_solo() {
3355        let html = render("{start_of_solo}\n[Em]Solo\n{end_of_solo}");
3356        assert!(html.contains("<section class=\"section-solo\">"));
3357        assert!(html.contains("Solo"));
3358        assert!(html.contains("</section>"));
3359    }
3360
3361    #[test]
3362    fn test_custom_section_name_escaped() {
3363        let html = render(
3364            "{start_of_x<script>alert(1)</script>}\ntext\n{end_of_x<script>alert(1)</script>}",
3365        );
3366        assert!(!html.contains("<script>"));
3367        assert!(html.contains("&lt;script&gt;"));
3368    }
3369
3370    #[test]
3371    fn test_custom_section_name_quotes_escaped() {
3372        let html =
3373            render("{start_of_x\" onclick=\"alert(1)}\ntext\n{end_of_x\" onclick=\"alert(1)}");
3374        // The `"` must be escaped to `&quot;` so the attribute boundary is not broken.
3375        assert!(html.contains("&quot;"));
3376        assert!(!html.contains("class=\"section-x\""));
3377    }
3378
3379    #[test]
3380    fn test_custom_section_name_single_quotes_escaped() {
3381        let html = render("{start_of_x' onclick='alert(1)}\ntext\n{end_of_x' onclick='alert(1)}");
3382        // The `'` must be escaped so single-quote attribute boundaries
3383        // cannot be broken. Both `&#39;` and `&apos;` are acceptable.
3384        assert!(html.contains("&apos;") || html.contains("&#39;"));
3385        assert!(!html.contains("onclick='alert"));
3386    }
3387
3388    #[test]
3389    fn test_custom_section_name_space_sanitized_in_class() {
3390        // Spaces in section names must not create multiple CSS classes
3391        let html = render("{start_of_foo bar}\ntext\n{end_of_foo bar}");
3392        // Class should be "section-foo-bar", not "section-foo bar"
3393        assert!(html.contains("section-foo-bar"));
3394        assert!(!html.contains("class=\"section-foo bar\""));
3395    }
3396
3397    #[test]
3398    fn test_custom_section_name_special_chars_sanitized_in_class() {
3399        let html = render("{start_of_a&b<c>d}\ntext\n{end_of_a&b<c>d}");
3400        // Special characters replaced with hyphens in class name
3401        assert!(html.contains("section-a-b-c-d"));
3402        // Label still uses HTML escaping for display
3403        assert!(html.contains("&amp;"));
3404    }
3405
3406    #[test]
3407    fn test_custom_section_capitalize_before_escape() {
3408        // Section name starting with "&" — capitalize must run on the
3409        // original text, then escape the result. If escape runs first,
3410        // capitalize would operate on "&amp;" instead.
3411        let html = render("{start_of_&test}\ntext\n{end_of_&test}");
3412        // Should capitalize the "&" (no-op) then escape -> "&amp;test"
3413        // NOT capitalize "&amp;" -> "&Amp;test"
3414        assert!(html.contains("&amp;test"));
3415        assert!(!html.contains("&Amp;"));
3416    }
3417
3418    #[test]
3419    fn test_define_display_name_in_html_output() {
3420        let html = render("{define: Am base-fret 1 frets x 0 2 2 1 0 display=\"A minor\"}");
3421        assert!(
3422            html.contains("A minor"),
3423            "display name should appear in rendered HTML output"
3424        );
3425    }
3426}
3427
3428#[cfg(test)]
3429mod transpose_tests {
3430    use super::*;
3431
3432    #[test]
3433    fn test_transpose_directive_up_2() {
3434        let input = "{transpose: 2}\n[G]Hello [C]world";
3435        let song = chordsketch_chordpro::parse(input).unwrap();
3436        let html = render_song(&song);
3437        // G+2=A, C+2=D
3438        assert!(html.contains("<span class=\"chord\">A</span>"));
3439        assert!(html.contains("<span class=\"chord\">D</span>"));
3440        assert!(!html.contains("<span class=\"chord\">G</span>"));
3441        assert!(!html.contains("<span class=\"chord\">C</span>"));
3442    }
3443
3444    #[test]
3445    fn test_transpose_directive_replaces_previous() {
3446        let input = "{transpose: 2}\n[G]First\n{transpose: 0}\n[G]Second";
3447        let song = chordsketch_chordpro::parse(input).unwrap();
3448        let html = render_song(&song);
3449        // First G transposed +2 = A, second G at 0 = G
3450        assert!(html.contains("<span class=\"chord\">A</span>"));
3451        assert!(html.contains("<span class=\"chord\">G</span>"));
3452    }
3453
3454    #[test]
3455    fn test_transpose_directive_with_cli_offset() {
3456        let input = "{transpose: 2}\n[C]Hello";
3457        let song = chordsketch_chordpro::parse(input).unwrap();
3458        let html = render_song_with_transpose(&song, 3, &Config::defaults());
3459        // 2 + 3 = 5, C+5=F
3460        assert!(html.contains("<span class=\"chord\">F</span>"));
3461    }
3462
3463    #[test]
3464    fn test_transpose_out_of_i8_range_emits_warning() {
3465        // 999 cannot be represented as i8; should fall back to 0 with a warning
3466        let input = "{transpose: 999}\n[G]Hello";
3467        let song = chordsketch_chordpro::parse(input).unwrap();
3468        let result = render_song_with_warnings(&song, 0, &Config::defaults());
3469        assert!(
3470            result.output.contains("<span class=\"chord\">G</span>"),
3471            "chord should be untransposed"
3472        );
3473        assert!(
3474            result.warnings.iter().any(|w| w.contains("\"999\"")),
3475            "expected warning about out-of-range value, got: {:?}",
3476            result.warnings
3477        );
3478    }
3479
3480    #[test]
3481    fn test_transpose_no_value_treated_as_zero() {
3482        // {transpose} with no value should silently reset to 0, no warning.
3483        let input = "{transpose}\n[G]Hello";
3484        let song = chordsketch_chordpro::parse(input).unwrap();
3485        let result = render_song_with_warnings(&song, 0, &Config::defaults());
3486        assert!(
3487            result.output.contains("<span class=\"chord\">G</span>"),
3488            "chord should be untransposed"
3489        );
3490        assert!(
3491            result.warnings.is_empty(),
3492            "missing {{transpose}} value should not emit a warning; got: {:?}",
3493            result.warnings
3494        );
3495    }
3496
3497    #[test]
3498    fn test_transpose_whitespace_value_treated_as_zero() {
3499        // {transpose:   } with whitespace-only value should silently reset to 0,
3500        // no warning emitted. The parser trims whitespace → Some(""), which the
3501        // Some("") arm converts to 0.
3502        let input = "{transpose:   }\n[G]Hello";
3503        let song = chordsketch_chordpro::parse(input).unwrap();
3504        let result = render_song_with_warnings(&song, 0, &Config::defaults());
3505        assert!(
3506            result.output.contains("<span class=\"chord\">G</span>"),
3507            "chord should be untransposed"
3508        );
3509        assert!(
3510            result.warnings.is_empty(),
3511            "whitespace-only {{transpose}} value should not emit a warning; got: {:?}",
3512            result.warnings
3513        );
3514    }
3515
3516    // --- Issue #109: {chorus} recall ---
3517
3518    #[test]
3519    fn test_render_chorus_recall_basic() {
3520        let html = render("{start_of_chorus}\n[G]La la\n{end_of_chorus}\n\n{chorus}");
3521        // Should contain chorus-recall div
3522        assert!(html.contains("<div class=\"chorus-recall\">"));
3523        // The recalled content should include the chord
3524        assert!(html.contains("chorus-recall"));
3525        // Check the original section is still there
3526        assert!(html.contains("<section class=\"chorus\">"));
3527    }
3528
3529    #[test]
3530    fn test_render_chorus_recall_with_label() {
3531        let html = render("{start_of_chorus}\nSing\n{end_of_chorus}\n{chorus: Repeat}");
3532        assert!(html.contains("Chorus: Repeat"));
3533        assert!(html.contains("chorus-recall"));
3534    }
3535
3536    #[test]
3537    fn test_render_chorus_recall_no_chorus_defined() {
3538        let html = render("{chorus}");
3539        // Should still produce a chorus-recall div with just the label
3540        assert!(html.contains("<div class=\"chorus-recall\">"));
3541        assert!(html.contains("Chorus"));
3542    }
3543
3544    #[test]
3545    fn test_render_chorus_recall_content_replayed() {
3546        let html = render("{start_of_chorus}\nChorus text\n{end_of_chorus}\n{chorus}");
3547        // "Chorus text" should appear twice: once in original, once in recall
3548        let count = html.matches("Chorus text").count();
3549        assert_eq!(count, 2, "chorus content should appear twice");
3550    }
3551
3552    #[test]
3553    fn test_chorus_recall_applies_current_transpose() {
3554        // Chorus defined with no transpose, recalled after {transpose: 2}.
3555        // G should become A in the recalled chorus.
3556        let html = render("{start_of_chorus}\n[G]La la\n{end_of_chorus}\n{transpose: 2}\n{chorus}");
3557        // Original chorus has chord "G"
3558        assert!(
3559            html.contains("<span class=\"chord\">G</span>"),
3560            "original chorus should have G"
3561        );
3562        // Recalled chorus should have transposed chord "A"
3563        assert!(
3564            html.contains("<span class=\"chord\">A</span>"),
3565            "recalled chorus should have transposed chord A, got:\n{html}"
3566        );
3567    }
3568
3569    #[test]
3570    fn test_chorus_recall_preserves_formatting_directives() {
3571        // A {textsize: 20} inside the chorus should be applied at recall time.
3572        let html =
3573            render("{start_of_chorus}\n{textsize: 20}\n[Am]Big text\n{end_of_chorus}\n{chorus}");
3574        // The recall section should contain the font-size style.
3575        let recall_start = html.find("chorus-recall").expect("should have recall");
3576        let recall_section = &html[recall_start..];
3577        assert!(
3578            recall_section.contains("font-size"),
3579            "recalled chorus should apply in-chorus formatting directives"
3580        );
3581    }
3582
3583    #[test]
3584    fn test_chorus_formatting_does_not_leak_to_outer_scope() {
3585        // {textsize: 20} inside chorus must not affect text after the chorus.
3586        let html =
3587            render("{start_of_chorus}\n{textsize: 20}\n[Am]Big\n{end_of_chorus}\n[G]Normal text");
3588        // Find content after </section> (end of chorus)
3589        let after_chorus = html
3590            .rfind("Normal text")
3591            .expect("should have post-chorus text");
3592        // Look backward from "Normal text" for the nearest <div class="line">
3593        let line_start = html[..after_chorus].rfind("<div class=\"line\"").unwrap();
3594        let line_end = html[line_start..]
3595            .find("</div>")
3596            .map_or(html.len(), |i| line_start + i + 6);
3597        let post_chorus_line = &html[line_start..line_end];
3598        assert!(
3599            !post_chorus_line.contains("font-size"),
3600            "in-chorus {{textsize}} should not leak to post-chorus content: {post_chorus_line}"
3601        );
3602    }
3603
3604    // -- inline markup rendering tests ----------------------------------------
3605
3606    #[test]
3607    fn test_render_bold_markup() {
3608        let html = render("Hello <b>bold</b> world");
3609        assert!(html.contains("<b>bold</b>"));
3610        assert!(html.contains("Hello "));
3611        assert!(html.contains(" world"));
3612    }
3613
3614    #[test]
3615    fn test_render_italic_markup() {
3616        let html = render("Hello <i>italic</i> text");
3617        assert!(html.contains("<i>italic</i>"));
3618    }
3619
3620    #[test]
3621    fn test_render_highlight_markup() {
3622        let html = render("<highlight>important</highlight>");
3623        assert!(html.contains("<mark>important</mark>"));
3624    }
3625
3626    #[test]
3627    fn test_render_comment_inline_markup() {
3628        let html = render("<comment>note</comment>");
3629        assert!(html.contains("<span class=\"comment\">note</span>"));
3630    }
3631
3632    #[test]
3633    fn test_render_span_with_foreground() {
3634        let html = render(r#"<span foreground="red">red text</span>"#);
3635        assert!(html.contains("color: red;"));
3636        assert!(html.contains("red text"));
3637    }
3638
3639    #[test]
3640    fn test_render_span_with_multiple_attrs() {
3641        let html = render(
3642            r#"<span font_family="Serif" size="14" foreground="blue" weight="bold">styled</span>"#,
3643        );
3644        assert!(html.contains("font-family: Serif;"));
3645        assert!(html.contains("font-size: 14pt;"));
3646        assert!(html.contains("color: blue;"));
3647        assert!(html.contains("font-weight: bold;"));
3648        assert!(html.contains("styled"));
3649    }
3650
3651    #[test]
3652    fn test_span_css_injection_url_prevented() {
3653        let html = render(
3654            r#"<span foreground="red; background-image: url('https://evil.com/')">text</span>"#,
3655        );
3656        // Parentheses and semicolons must be stripped, preventing url() and property injection.
3657        assert!(!html.contains("url("));
3658        assert!(!html.contains(";background-image"));
3659    }
3660
3661    #[test]
3662    fn test_span_css_injection_semicolon_stripped() {
3663        let html =
3664            render(r#"<span foreground="red; position: absolute; z-index: 9999">text</span>"#);
3665        // Semicolons must be stripped so injected properties cannot create new
3666        // CSS property boundaries. Without `;`, "position: absolute" is just
3667        // noise inside the single `color:` value, not a separate property.
3668        assert!(!html.contains(";position"));
3669        assert!(!html.contains("; position"));
3670        assert!(html.contains("color:"));
3671    }
3672
3673    #[test]
3674    fn test_render_nested_markup() {
3675        let html = render("<b><i>bold italic</i></b>");
3676        assert!(html.contains("<b><i>bold italic</i></b>"));
3677    }
3678
3679    #[test]
3680    fn test_render_markup_with_chord() {
3681        let html = render("[Am]Hello <b>bold</b> world");
3682        assert!(html.contains("<b>bold</b>"));
3683        assert!(html.contains("<span class=\"chord\">Am</span>"));
3684    }
3685
3686    #[test]
3687    fn test_render_no_markup_unchanged() {
3688        let html = render("Just plain text");
3689        // Should NOT have any inline formatting tags
3690        assert!(!html.contains("<b>"));
3691        assert!(!html.contains("<i>"));
3692        assert!(html.contains("Just plain text"));
3693    }
3694
3695    // -- formatting directive tests -------------------------------------------
3696
3697    #[test]
3698    fn test_textfont_directive_applies_css() {
3699        let html = render("{textfont: Courier}\nHello world");
3700        assert!(html.contains("font-family: Courier;"));
3701    }
3702
3703    #[test]
3704    fn test_textsize_directive_applies_css() {
3705        let html = render("{textsize: 14}\nHello world");
3706        assert!(html.contains("font-size: 14pt;"));
3707    }
3708
3709    #[test]
3710    fn test_textcolour_directive_applies_css() {
3711        let html = render("{textcolour: blue}\nHello world");
3712        assert!(html.contains("color: blue;"));
3713    }
3714
3715    #[test]
3716    fn test_chordfont_directive_applies_css() {
3717        let html = render("{chordfont: Monospace}\n[Am]Hello");
3718        assert!(html.contains("font-family: Monospace;"));
3719    }
3720
3721    #[test]
3722    fn test_chordsize_directive_applies_css() {
3723        let html = render("{chordsize: 16}\n[Am]Hello");
3724        // Chord span should have the size style
3725        assert!(html.contains("font-size: 16pt;"));
3726    }
3727
3728    #[test]
3729    fn test_chordcolour_directive_applies_css() {
3730        let html = render("{chordcolour: green}\n[Am]Hello");
3731        assert!(html.contains("color: green;"));
3732    }
3733
3734    #[test]
3735    fn test_formatting_persists_across_lines() {
3736        let html = render("{textcolour: red}\nLine one\nLine two");
3737        // Both lines should have the color applied
3738        let count = html.matches("color: red;").count();
3739        assert!(
3740            count >= 2,
3741            "formatting should persist: found {count} matches"
3742        );
3743    }
3744
3745    #[test]
3746    fn test_formatting_overridden_by_later_directive() {
3747        let html = render("{textcolour: red}\nRed text\n{textcolour: blue}\nBlue text");
3748        assert!(html.contains("color: red;"));
3749        assert!(html.contains("color: blue;"));
3750    }
3751
3752    #[test]
3753    fn test_no_formatting_no_style_attr() {
3754        let html = render("Plain text");
3755        // lyrics span should not have a style attribute
3756        assert!(!html.contains("<span class=\"lyrics\" style="));
3757    }
3758
3759    #[test]
3760    fn test_formatting_directive_css_injection_prevented() {
3761        let html = render("{textcolour: red; position: fixed; z-index: 9999}\nHello");
3762        // Semicolons stripped — no additional CSS property injection.
3763        assert!(!html.contains(";position"));
3764        assert!(!html.contains("; position"));
3765        assert!(html.contains("color:"));
3766    }
3767
3768    #[test]
3769    fn test_formatting_directive_url_injection_prevented() {
3770        let html = render("{textcolour: red; background-image: url('https://evil.com/')}\nHello");
3771        // Parentheses and semicolons stripped.
3772        assert!(!html.contains("url("));
3773    }
3774
3775    // -- column layout tests --------------------------------------------------
3776
3777    #[test]
3778    fn test_columns_directive_generates_css() {
3779        let html = render("{columns: 2}\nLine one\nLine two");
3780        assert!(html.contains("column-count: 2"));
3781    }
3782
3783    #[test]
3784    fn test_columns_reset_to_one() {
3785        let html = render("{columns: 2}\nTwo cols\n{columns: 1}\nOne col");
3786        // Should open and then close the multi-column div
3787        let count = html.matches("column-count: 2").count();
3788        assert_eq!(count, 1);
3789        assert!(html.contains("One col"));
3790    }
3791
3792    #[test]
3793    fn test_column_break_generates_css() {
3794        let html = render("{columns: 2}\nCol 1\n{column_break}\nCol 2");
3795        assert!(html.contains("break-before: column;"));
3796    }
3797
3798    #[test]
3799    fn test_columns_clamped_to_max() {
3800        let html = render("{columns: 999}\nContent");
3801        // Should be clamped to 32
3802        assert!(html.contains("column-count: 32"));
3803    }
3804
3805    #[test]
3806    fn test_columns_zero_treated_as_one() {
3807        let html = render("{columns: 0}\nContent");
3808        // 0 is clamped to 1, so no multi-column div should be opened
3809        assert!(!html.contains("column-count"));
3810    }
3811
3812    #[test]
3813    fn test_columns_non_numeric_defaults_to_one() {
3814        let html = render("{columns: abc}\nHello");
3815        // Non-numeric value should default to 1, so no multi-column div.
3816        assert!(!html.contains("column-count"));
3817    }
3818
3819    #[test]
3820    fn test_new_page_generates_page_break() {
3821        let html = render("Page 1\n{new_page}\nPage 2");
3822        assert!(html.contains("break-before: page;"));
3823    }
3824
3825    #[test]
3826    fn test_new_physical_page_generates_recto_break() {
3827        let html = render("Page 1\n{new_physical_page}\nPage 2");
3828        assert!(
3829            html.contains("break-before: recto;"),
3830            "new_physical_page should use break-before: recto for duplex printing"
3831        );
3832        assert!(
3833            !html.contains("break-before: page;"),
3834            "new_physical_page should not emit generic page break"
3835        );
3836    }
3837
3838    #[test]
3839    fn test_page_control_not_replayed_in_chorus_recall() {
3840        // Page control directives inside a chorus must NOT appear in {chorus} recall.
3841        let input = "\
3842{start_of_chorus}\n\
3843{new_page}\n\
3844[G]La la la\n\
3845{end_of_chorus}\n\
3846Verse text\n\
3847{chorus}";
3848        let html = render(input);
3849        // The initial chorus renders a page break.
3850        assert!(html.contains("break-before: page;"));
3851        // Count: only ONE page-break div should exist (from the original chorus,
3852        // not from the recall).
3853        let count = html.matches("break-before: page;").count();
3854        assert_eq!(count, 1, "page break must not be replayed in chorus recall");
3855    }
3856
3857    // -- image directive tests ------------------------------------------------
3858
3859    #[test]
3860    fn test_image_basic() {
3861        let html = render("{image: src=photo.jpg}");
3862        assert!(html.contains("<img src=\"photo.jpg\""));
3863    }
3864
3865    #[test]
3866    fn test_image_with_dimensions() {
3867        let html = render("{image: src=photo.jpg width=200 height=100}");
3868        assert!(html.contains("width=\"200\""));
3869        assert!(html.contains("height=\"100\""));
3870    }
3871
3872    #[test]
3873    fn test_image_with_title() {
3874        let html = render("{image: src=photo.jpg title=\"My Photo\"}");
3875        assert!(html.contains("alt=\"My Photo\""));
3876    }
3877
3878    #[test]
3879    fn test_image_with_scale() {
3880        let html = render("{image: src=photo.jpg scale=0.5}");
3881        assert!(html.contains("scale(0.5)"));
3882    }
3883
3884    #[test]
3885    fn test_image_empty_src_skipped() {
3886        let html = render("{image: src=}");
3887        assert!(
3888            !html.contains("<img"),
3889            "empty src should not produce an img element"
3890        );
3891    }
3892
3893    #[test]
3894    fn test_image_javascript_uri_rejected() {
3895        let html = render("{image: src=javascript:alert(1)}");
3896        assert!(!html.contains("<img"), "javascript: URI must be rejected");
3897    }
3898
3899    #[test]
3900    fn test_image_data_uri_rejected() {
3901        let html = render("{image: src=data:text/html,<script>alert(1)</script>}");
3902        assert!(!html.contains("<img"), "data: URI must be rejected");
3903    }
3904
3905    #[test]
3906    fn test_image_vbscript_uri_rejected() {
3907        let html = render("{image: src=vbscript:MsgBox}");
3908        assert!(!html.contains("<img"), "vbscript: URI must be rejected");
3909    }
3910
3911    #[test]
3912    fn test_image_javascript_uri_case_insensitive() {
3913        let html = render("{image: src=JaVaScRiPt:alert(1)}");
3914        assert!(
3915            !html.contains("<img"),
3916            "scheme check must be case-insensitive"
3917        );
3918    }
3919
3920    #[test]
3921    fn test_image_safe_relative_path_allowed() {
3922        let html = render("{image: src=images/photo.jpg}");
3923        assert!(html.contains("<img src=\"images/photo.jpg\""));
3924    }
3925
3926    // -- {capo} validation parity (#1834) ---------------------------------
3927
3928    #[test]
3929    fn test_capo_out_of_range_emits_warning() {
3930        let song = chordsketch_chordpro::parse("{title: T}\n{capo: 999}").unwrap();
3931        let result = render_song_with_warnings(&song, 0, &Config::defaults());
3932        assert!(
3933            result
3934                .warnings
3935                .iter()
3936                .any(|w| w.contains("capo") && w.contains("999")),
3937            "expected out-of-range {{capo}} warning; got {:?}",
3938            result.warnings
3939        );
3940    }
3941
3942    #[test]
3943    fn test_capo_non_numeric_emits_warning() {
3944        let song = chordsketch_chordpro::parse("{title: T}\n{capo: foo}").unwrap();
3945        let result = render_song_with_warnings(&song, 0, &Config::defaults());
3946        assert!(
3947            result
3948                .warnings
3949                .iter()
3950                .any(|w| w.contains("capo") && w.contains("foo")),
3951            "expected non-integer {{capo}} warning; got {:?}",
3952            result.warnings
3953        );
3954    }
3955
3956    #[test]
3957    fn test_capo_in_range_is_silent() {
3958        let song = chordsketch_chordpro::parse("{title: T}\n{capo: 5}").unwrap();
3959        let result = render_song_with_warnings(&song, 0, &Config::defaults());
3960        assert!(
3961            !result.warnings.iter().any(|w| w.contains("capo")),
3962            "valid {{capo: 5}} should not warn; got {:?}",
3963            result.warnings
3964        );
3965    }
3966
3967    // -- multi-value [Nx] [Pos] metadata in header chips ------------------
3968
3969    /// Spec: `{key}` / `{tempo}` / `{time}` are `[Nx] [Pos]`. Perl
3970    /// `{key}` / `{tempo}` / `{time}` are now surfaced inline at
3971    /// each directive's source position via the `.meta-inline`
3972    /// markers. The chip strip no longer carries them — only
3973    /// `{capo}` and `{duration}` (the two metadata values
3974    /// without a positional inline display) remain in the
3975    /// header. Sister-site to the React JSX walker's
3976    /// `renderHeader`.
3977    #[test]
3978    fn test_multi_value_keys_do_not_appear_in_chip_strip() {
3979        let html = render("{key: G}\n{key: D}\n[D]hi");
3980        assert!(
3981            !html.contains("class=\"meta__chip\">Key"),
3982            "Key chip should be omitted from header (positional inline marker covers it); got: {html}"
3983        );
3984        // The inline body marker still surfaces the value.
3985        assert!(html.contains("meta-inline--key"));
3986    }
3987
3988    #[test]
3989    fn test_multi_value_tempos_do_not_appear_in_chip_strip() {
3990        let html = render("{tempo: 120}\n{tempo: 140}\n[G]a");
3991        assert!(
3992            !html.contains("BPM</span>") || !html.contains("class=\"meta__chip\""),
3993            "BPM chip should be omitted; got: {html}"
3994        );
3995        assert!(html.contains("meta-inline--tempo"));
3996    }
3997
3998    #[test]
3999    fn test_multi_value_times_do_not_appear_in_chip_strip() {
4000        let html = render("{time: 4/4}\n{time: 6/8}\n[G]a");
4001        // Time signature should NOT appear as a header chip.
4002        assert!(
4003            !html.contains("<span class=\"meta__chip\">4/4"),
4004            "Time chip should be omitted; got: {html}"
4005        );
4006        // The inline body marker still surfaces it.
4007        assert!(html.contains("meta-inline--time"));
4008    }
4009
4010    /// Phase B of #2454: `{key}` / `{tempo}` / `{time}` are
4011    /// `[Nx] [Pos]` per ChordPro spec. Every declaration must
4012    /// surface as a positional marker in the body, so a reader
4013    /// can see *where* mid-song key / tempo / meter changes
4014    /// happen. Sister-site to the text / PDF renderers and the
4015    /// React JSX walker (`renderer-parity.md`).
4016    #[test]
4017    fn test_inline_meta_marker_for_key() {
4018        let html = render("[G]first\n{key: D}\n[D]second");
4019        // The marker carries a `<svg class="music-glyph music-glyph--key">`
4020        // before the label / value pair — assert the structural pieces
4021        // rather than a single-string `contains` so the SVG body (which
4022        // has many tunable numbers) doesn't make the test brittle.
4023        assert!(
4024            html.contains("meta-inline meta-inline--key"),
4025            "expected key marker class; got: {html}"
4026        );
4027        assert!(
4028            html.contains("music-glyph music-glyph--key"),
4029            "expected key-signature glyph SVG; got: {html}"
4030        );
4031        assert!(
4032            html.contains("<span class=\"meta-inline__label\">Key:</span>"),
4033            "expected label span; got: {html}"
4034        );
4035        assert!(
4036            html.contains("<span class=\"meta-inline__value\">D</span>"),
4037            "expected value span; got: {html}"
4038        );
4039    }
4040
4041    #[test]
4042    fn test_inline_meta_marker_for_tempo() {
4043        let html = render("[G]a\n{tempo: 140}\n[C]b");
4044        assert!(
4045            html.contains("meta-inline--tempo"),
4046            "expected inline tempo marker class; got: {html}"
4047        );
4048        assert!(
4049            html.contains("music-glyph music-glyph--metronome"),
4050            "expected metronome glyph SVG; got: {html}"
4051        );
4052        // 140 BPM → 60/140 * 2 = 0.857s.
4053        assert!(
4054            // 140 BPM → half-cycle = 60/140 ≈ 0.429s (truncated to
4055            // 3 decimal places).
4056            html.contains("--cs-metronome-period:0.429s"),
4057            "expected animation period derived from BPM; got: {html}"
4058        );
4059        assert!(
4060            html.contains("140 BPM"),
4061            "expected '140 BPM' in inline marker; got: {html}"
4062        );
4063    }
4064
4065    #[test]
4066    fn test_inline_meta_marker_for_time() {
4067        let html = render("{tempo: 120}\n[G]a\n{time: 6/8}\n[D]b");
4068        assert!(
4069            html.contains("meta-inline--time"),
4070            "expected inline time marker class; got: {html}"
4071        );
4072        // Stacked time-signature glyph (numerator on top, fraction
4073        // bar, denominator on bottom) — sister-site to React's
4074        // `<TimeSignatureGlyph>`.
4075        assert!(
4076            html.contains("music-glyph--time__num\" aria-hidden=\"true\">6</span>"),
4077            "expected stacked numerator '6'; got: {html}"
4078        );
4079        assert!(
4080            html.contains("music-glyph--time__bar\""),
4081            "expected fraction bar between digits; got: {html}"
4082        );
4083        assert!(
4084            html.contains("music-glyph--time__den\" aria-hidden=\"true\">8</span>"),
4085            "expected stacked denominator '8'; got: {html}"
4086        );
4087        // The conductor-pattern animation was retired — the
4088        // glyph should NOT carry a conduct-N class.
4089        assert!(
4090            !html.contains("music-glyph--time--conduct-"),
4091            "conductor animation was retired; got: {html}"
4092        );
4093        assert!(
4094            !html.contains("<span class=\"meta-inline__value\">6/8</span>"),
4095            "redundant text value should not be emitted alongside the icon; got: {html}"
4096        );
4097    }
4098
4099    // (The conductor-pattern animation was retired, so the
4100    // "no conductor without tempo" / "header tempo seeds the
4101    // animation" tests no longer apply — the time-signature
4102    // glyph never animates.)
4103
4104    /// Empty value → no marker (avoids confusing empty brackets).
4105    #[test]
4106    fn test_inline_meta_marker_skipped_for_empty_value() {
4107        let html = render("[G]a\n{key:}\n[D]b");
4108        assert!(
4109            !html.contains("meta-inline--key"),
4110            "empty {{key}} must not produce a marker; got: {html}"
4111        );
4112    }
4113
4114    /// Key chip is intentionally absent from the header — the
4115    /// inline body marker (`<p class="meta-inline meta-inline--
4116    /// key">`) is the canonical positional display.
4117    #[test]
4118    fn test_single_value_key_emits_no_chip() {
4119        let html = render("{key: G}");
4120        assert!(
4121            !html.contains("<span class=\"meta__chip\">Key G</span>"),
4122            "Key chip should be omitted from header; got: {html}"
4123        );
4124        // The inline marker IS present.
4125        assert!(html.contains("meta-inline--key"));
4126        assert!(html.contains("<span class=\"meta-inline__value\">G</span>"));
4127    }
4128
4129    // -- settings.strict missing-{key} warning (R6.100.0, #2291) ----------
4130
4131    #[test]
4132    fn test_strict_off_with_missing_key_is_silent() {
4133        let song = chordsketch_chordpro::parse("{title: T}").unwrap();
4134        let result = render_song_with_warnings(&song, 0, &Config::defaults());
4135        assert!(
4136            !result
4137                .warnings
4138                .iter()
4139                .any(|w| w.contains("settings.strict")),
4140            "default settings.strict=false must not warn on missing {{key}}; got {:?}",
4141            result.warnings
4142        );
4143    }
4144
4145    #[test]
4146    fn test_strict_on_with_missing_key_warns() {
4147        let song = chordsketch_chordpro::parse("{title: T}").unwrap();
4148        let cfg = Config::defaults()
4149            .with_define("settings.strict=true")
4150            .unwrap();
4151        let result = render_song_with_warnings(&song, 0, &cfg);
4152        assert!(
4153            result
4154                .warnings
4155                .iter()
4156                .any(|w| w.contains("{key}") && w.contains("settings.strict")),
4157            "expected missing-{{key}} warning under settings.strict; got {:?}",
4158            result.warnings
4159        );
4160    }
4161
4162    #[test]
4163    fn test_strict_on_with_present_key_is_silent() {
4164        let song = chordsketch_chordpro::parse("{title: T}\n{key: G}").unwrap();
4165        let cfg = Config::defaults()
4166            .with_define("settings.strict=true")
4167            .unwrap();
4168        let result = render_song_with_warnings(&song, 0, &cfg);
4169        assert!(
4170            !result
4171                .warnings
4172                .iter()
4173                .any(|w| w.contains("settings.strict")),
4174            "settings.strict warning must not fire when {{key}} is present; got {:?}",
4175            result.warnings
4176        );
4177    }
4178
4179    // -- MAX_WARNINGS cap (#1833) -----------------------------------------
4180
4181    #[test]
4182    fn test_max_warnings_truncates() {
4183        let mut input = String::from("{title: T}\n");
4184        for _ in 0..(MAX_WARNINGS + 50) {
4185            input.push_str("{transpose: not-a-number}\n");
4186        }
4187        let song = chordsketch_chordpro::parse(&input).unwrap();
4188        let result = render_song_with_warnings(&song, 0, &Config::defaults());
4189        assert_eq!(
4190            result.warnings.len(),
4191            MAX_WARNINGS + 1,
4192            "expected exactly MAX_WARNINGS warnings plus one truncation marker"
4193        );
4194        assert!(
4195            result.warnings.last().unwrap().contains("MAX_WARNINGS"),
4196            "last entry must be the truncation marker; got {:?}",
4197            result.warnings.last()
4198        );
4199    }
4200
4201    #[test]
4202    fn test_is_safe_image_src() {
4203        // Allowed: relative paths
4204        assert!(is_safe_image_src("photo.jpg"));
4205        assert!(is_safe_image_src("images/photo.jpg"));
4206        assert!(is_safe_image_src("path/to:file.jpg")); // colon after slash is not a scheme
4207
4208        // Allowed: http/https
4209        assert!(is_safe_image_src("http://example.com/photo.jpg"));
4210        assert!(is_safe_image_src("https://example.com/photo.jpg"));
4211        assert!(is_safe_image_src("HTTP://EXAMPLE.COM/PHOTO.JPG"));
4212
4213        // Rejected: empty
4214        assert!(!is_safe_image_src(""));
4215
4216        // Rejected: dangerous schemes (denylist is now implicit via allowlist)
4217        assert!(!is_safe_image_src("javascript:alert(1)"));
4218        assert!(!is_safe_image_src("JAVASCRIPT:alert(1)"));
4219        assert!(!is_safe_image_src("  javascript:alert(1)"));
4220        assert!(!is_safe_image_src("data:image/png;base64,abc"));
4221        assert!(!is_safe_image_src("vbscript:MsgBox"));
4222
4223        // Rejected: file/blob/mhtml schemes (previously allowed)
4224        assert!(!is_safe_image_src("file:///etc/passwd"));
4225        assert!(!is_safe_image_src("FILE:///etc/passwd"));
4226        assert!(!is_safe_image_src("blob:https://example.com/uuid"));
4227        assert!(!is_safe_image_src("mhtml:file://C:/page.mhtml"));
4228
4229        // Rejected: absolute filesystem paths
4230        assert!(!is_safe_image_src("/etc/passwd"));
4231        assert!(!is_safe_image_src("/home/user/photo.jpg"));
4232
4233        // Rejected: null bytes
4234        assert!(!is_safe_image_src("photo\0.jpg"));
4235        assert!(!is_safe_image_src("\0"));
4236
4237        // Rejected: directory traversal
4238        assert!(!is_safe_image_src("../photo.jpg"));
4239        assert!(!is_safe_image_src("images/../../etc/passwd"));
4240        assert!(!is_safe_image_src(r"..\photo.jpg"));
4241        assert!(!is_safe_image_src(r"images\..\..\photo.jpg"));
4242
4243        // Rejected: Windows-style absolute paths (all platforms)
4244        assert!(!is_safe_image_src(r"C:\photo.jpg"));
4245        assert!(!is_safe_image_src(r"D:\Users\photo.jpg"));
4246        assert!(!is_safe_image_src(r"\\server\share\photo.jpg"));
4247        assert!(!is_safe_image_src("C:/photo.jpg"));
4248    }
4249
4250    #[test]
4251    fn test_image_anchor_column_centers() {
4252        let html = render("{image: src=photo.jpg anchor=column}");
4253        assert!(
4254            html.contains("<div style=\"text-align: center;\">"),
4255            "anchor=column should produce centered div"
4256        );
4257    }
4258
4259    #[test]
4260    fn test_image_anchor_paper_centers() {
4261        let html = render("{image: src=photo.jpg anchor=paper}");
4262        assert!(
4263            html.contains("<div style=\"text-align: center;\">"),
4264            "anchor=paper should produce centered div"
4265        );
4266    }
4267
4268    #[test]
4269    fn test_image_anchor_line_no_style() {
4270        let html = render("{image: src=photo.jpg anchor=line}");
4271        // anchor=line should produce a bare <div> without style
4272        assert!(html.contains("<div><img"));
4273        assert!(!html.contains("text-align"));
4274    }
4275
4276    #[test]
4277    fn test_image_no_anchor_no_style() {
4278        let html = render("{image: src=photo.jpg}");
4279        // No anchor should produce a bare <div> without style
4280        assert!(html.contains("<div><img"));
4281        assert!(!html.contains("text-align"));
4282    }
4283
4284    #[test]
4285    fn test_image_max_width_css_present() {
4286        let html = render("{image: src=photo.jpg}");
4287        assert!(
4288            html.contains("img { max-width: 100%; height: auto; }"),
4289            "CSS should include img max-width rule to prevent overflow"
4290        );
4291    }
4292
4293    #[test]
4294    fn test_chord_diagram_css_rules_present() {
4295        let html = render("{define: Am base-fret 1 frets x 0 2 2 1 0}");
4296        assert!(
4297            html.contains(".chord-diagram-container"),
4298            "CSS should include .chord-diagram-container rule"
4299        );
4300        assert!(
4301            html.contains(".chord-diagram {"),
4302            "CSS should include .chord-diagram rule"
4303        );
4304    }
4305
4306    // -- chord diagram tests --------------------------------------------------
4307
4308    #[test]
4309    fn test_define_renders_svg_diagram() {
4310        let html = render("{define: Am base-fret 1 frets x 0 2 2 1 0}");
4311        assert!(html.contains("<svg"));
4312        assert!(html.contains("Am"));
4313        assert!(html.contains("chord-diagram"));
4314    }
4315
4316    #[test]
4317    fn test_define_keyboard_renders_keyboard_svg() {
4318        // {define: Am keys 0 3 7} should now render a keyboard diagram SVG.
4319        let html = render("{define: Am keys 0 3 7}");
4320        assert!(
4321            html.contains("<svg"),
4322            "keyboard define should produce an SVG"
4323        );
4324        assert!(
4325            html.contains("keyboard-diagram"),
4326            "should use keyboard-diagram CSS class"
4327        );
4328        assert!(html.contains("Am"), "chord name should appear in SVG");
4329    }
4330
4331    #[test]
4332    fn test_define_keyboard_absolute_midi_renders_svg() {
4333        // Absolute MIDI note numbers (as in the issue spec example).
4334        let html = render("{define: Cmaj7 keys 60 64 67 71}");
4335        assert!(html.contains("<svg"));
4336        assert!(html.contains("keyboard-diagram"));
4337        assert!(html.contains("Cmaj7"));
4338    }
4339
4340    #[test]
4341    fn test_diagrams_piano_auto_inject() {
4342        let input = "{diagrams: piano}\n[Am]Hello [C]world";
4343        let html = render(input);
4344        // Should auto-inject keyboard diagrams for Am and C
4345        assert!(
4346            html.contains("keyboard-diagram"),
4347            "piano instrument should use keyboard diagrams"
4348        );
4349        assert!(
4350            html.contains("chord-diagrams"),
4351            "diagram section should be present"
4352        );
4353    }
4354
4355    #[test]
4356    fn test_define_ukulele_diagram() {
4357        let html = render("{define: C frets 0 0 0 3}");
4358        assert!(html.contains("<svg"));
4359        assert!(html.contains("chord-diagram"));
4360        // 4 strings: SVG width = (4-1)*16 + 20*2 = 88
4361        assert!(
4362            html.contains("width=\"88\""),
4363            "Expected 4-string SVG width (88)"
4364        );
4365    }
4366
4367    #[test]
4368    fn test_define_banjo_diagram() {
4369        let html = render("{define: G frets 0 0 0 0 0}");
4370        assert!(html.contains("<svg"));
4371        // 5 strings: SVG width = (5-1)*16 + 20*2 = 104
4372        assert!(
4373            html.contains("width=\"104\""),
4374            "Expected 5-string SVG width (104)"
4375        );
4376    }
4377
4378    #[test]
4379    fn test_diagrams_frets_config_controls_svg_height() {
4380        let input = "{define: Am base-fret 1 frets x 0 2 2 1 0}";
4381        let song = chordsketch_chordpro::parse(input).unwrap();
4382        let config = chordsketch_chordpro::config::Config::defaults()
4383            .with_define("diagrams.frets=4")
4384            .unwrap();
4385        let html = render_song_with_transpose(&song, 0, &config);
4386        // 4 frets: grid_h = 4*20 = 80, total_h = 80 + 30 + 30 = 140
4387        assert!(
4388            html.contains("height=\"140\""),
4389            "SVG height should reflect diagrams.frets=4 (expected 140)"
4390        );
4391    }
4392
4393    // -- {diagrams} directive tests -----------------------------------------------
4394
4395    #[test]
4396    fn test_diagrams_off_suppresses_chord_diagrams() {
4397        let html = render("{diagrams: off}\n{define: Am base-fret 1 frets x 0 2 2 1 0}");
4398        assert!(
4399            !html.contains("<svg"),
4400            "chord diagram SVG should be suppressed when diagrams=off"
4401        );
4402    }
4403
4404    #[test]
4405    fn test_diagrams_on_shows_chord_diagrams() {
4406        let html = render("{diagrams: on}\n{define: Am base-fret 1 frets x 0 2 2 1 0}");
4407        assert!(
4408            html.contains("<svg"),
4409            "chord diagram SVG should be shown when diagrams=on"
4410        );
4411    }
4412
4413    #[test]
4414    fn test_diagrams_default_shows_chord_diagrams() {
4415        let html = render("{define: Am base-fret 1 frets x 0 2 2 1 0}");
4416        assert!(
4417            html.contains("<svg"),
4418            "chord diagram SVG should be shown by default"
4419        );
4420    }
4421
4422    #[test]
4423    fn test_diagrams_off_then_on_restores() {
4424        let html = render(
4425            "{diagrams: off}\n{define: Am base-fret 1 frets x 0 2 2 1 0}\n{diagrams: on}\n{define: G base-fret 1 frets 3 2 0 0 0 3}",
4426        );
4427        // Am should be suppressed, G should be shown
4428        assert!(!html.contains(">Am<"), "Am diagram should be suppressed");
4429        assert!(html.contains(">G<"), "G diagram should be rendered");
4430    }
4431
4432    #[test]
4433    fn test_diagrams_parsed_as_known_directive() {
4434        let song = chordsketch_chordpro::parse("{diagrams: off}").unwrap();
4435        if let chordsketch_chordpro::ast::Line::Directive(d) = &song.lines[0] {
4436            assert_eq!(
4437                d.kind,
4438                chordsketch_chordpro::ast::DirectiveKind::Diagrams,
4439                "diagrams should parse as DirectiveKind::Diagrams"
4440            );
4441            assert_eq!(d.value, Some("off".to_string()));
4442        } else {
4443            panic!("expected a directive line, got: {:?}", &song.lines[0]);
4444        }
4445    }
4446
4447    // --- Case-insensitive {diagrams} directive (#652) ---
4448
4449    #[test]
4450    fn test_diagrams_off_case_insensitive() {
4451        let html = render("{diagrams: Off}\n{define: Am base-fret 1 frets x 0 2 2 1 0}");
4452        assert!(
4453            !html.contains("<svg"),
4454            "diagrams=Off should suppress diagrams (case-insensitive)"
4455        );
4456    }
4457
4458    #[test]
4459    fn test_diagrams_off_uppercase() {
4460        let html = render("{diagrams: OFF}\n{define: Am base-fret 1 frets x 0 2 2 1 0}");
4461        assert!(
4462            !html.contains("<svg"),
4463            "diagrams=OFF should suppress diagrams (case-insensitive)"
4464        );
4465    }
4466
4467    // -- auto-inject diagram grid (issue #1140) -----------------------------------
4468
4469    #[test]
4470    fn test_diagrams_auto_inject_from_builtin_db() {
4471        // {diagrams} with known chords should append a grid section
4472        let html = render("{diagrams}\n[Am]Hello [G]World");
4473        assert!(
4474            html.contains("class=\"chord-diagrams\""),
4475            "should render chord-diagrams section"
4476        );
4477        // Both Am and G are in the built-in guitar DB
4478        assert!(html.contains(">Am<"), "Am diagram expected");
4479        assert!(html.contains(">G<"), "G diagram expected");
4480    }
4481
4482    #[test]
4483    fn test_diagrams_auto_inject_unknown_chord_skipped() {
4484        // Unknown chords (not in DB, no {define}) should be silently skipped
4485        let html = render("{diagrams}\n[Xyzzy]Hello");
4486        // No chord-diagrams section because no known chords
4487        assert!(
4488            !html.contains("class=\"chord-diagrams\""),
4489            "no diagram section for unknown chord"
4490        );
4491    }
4492
4493    #[test]
4494    fn test_no_diagrams_suppresses_auto_inject() {
4495        let html = render("{no_diagrams}\n[Am]Hello");
4496        assert!(
4497            !html.contains("class=\"chord-diagrams\""),
4498            "{{no_diagrams}} should suppress auto-inject"
4499        );
4500    }
4501
4502    #[test]
4503    fn test_diagrams_define_takes_priority_over_builtin() {
4504        // Chords with a {define} entry are rendered inline at the directive position
4505        // and excluded from the auto-inject grid (dedup).  When all used chords are
4506        // defined, the auto-inject section is absent entirely.
4507        let html = render("{diagrams}\n{define: Am base-fret 1 frets x 0 2 2 1 0}\n[Am]Hello");
4508        // Am is rendered inline (at the {define} position).
4509        assert!(
4510            html.contains("font-weight=\"bold\">Am</text>"),
4511            "Am diagram should appear inline at the {{define}} position"
4512        );
4513        // All used chords have {define} entries → grid is not rendered.
4514        assert!(
4515            !html.contains("class=\"chord-diagrams\""),
4516            "auto-inject section should be absent when all used chords are defined"
4517        );
4518    }
4519
4520    #[test]
4521    fn test_diagrams_off_suppresses_auto_inject() {
4522        let html = render("{diagrams: off}\n[Am]Hello");
4523        assert!(
4524            !html.contains("class=\"chord-diagrams\""),
4525            "{{diagrams: off}} should suppress auto-inject grid"
4526        );
4527    }
4528
4529    #[test]
4530    fn test_diagrams_ukulele_instrument() {
4531        let html = render("{diagrams: ukulele}\n[Am]Hello");
4532        assert!(
4533            html.contains("class=\"chord-diagrams\""),
4534            "ukulele diagrams section expected"
4535        );
4536        // Ukulele Am has 4 strings so the SVG will differ from guitar
4537        assert!(html.contains(">Am<"), "Am diagram expected");
4538    }
4539
4540    #[test]
4541    fn test_diagrams_guitar_explicit_overrides_config_default() {
4542        // Even when config could default to ukulele, {diagrams: guitar} should
4543        // use guitar (6-string Am) not ukulele (4-string Am).
4544        let song = chordsketch_chordpro::parse("{diagrams: guitar}\n[Am]Hello").unwrap();
4545        let config = chordsketch_chordpro::config::Config::defaults()
4546            .with_define("diagrams.instrument=ukulele")
4547            .unwrap();
4548        let html = render_song_with_transpose(&song, 0, &config);
4549        assert!(
4550            html.contains("class=\"chord-diagrams\""),
4551            "guitar diagrams section expected"
4552        );
4553        assert!(html.contains(">Am<"), "Am diagram expected");
4554        let guitar_am_html = render_song_with_transpose(
4555            &chordsketch_chordpro::parse("{diagrams: guitar}\n[Am]Hello").unwrap(),
4556            0,
4557            &chordsketch_chordpro::config::Config::defaults(),
4558        );
4559        let uke_am_html = render_song_with_transpose(
4560            &chordsketch_chordpro::parse("{diagrams: ukulele}\n[Am]Hello").unwrap(),
4561            0,
4562            &chordsketch_chordpro::config::Config::defaults(),
4563        );
4564        // Guitar and ukulele diagrams must differ in their SVG content.
4565        assert_ne!(
4566            guitar_am_html, uke_am_html,
4567            "guitar and ukulele Am diagrams should differ"
4568        );
4569        // With config defaulting to ukulele, {diagrams: guitar} must produce
4570        // the same output as the guitar default.
4571        assert_eq!(
4572            html, guitar_am_html,
4573            "{{diagrams: guitar}} must select guitar regardless of config default"
4574        );
4575    }
4576
4577    #[test]
4578    fn test_no_diagrams_suppresses_inline_define_diagrams() {
4579        // {no_diagrams} should suppress inline {define} diagram rendering
4580        // (show_diagrams = false), not just the auto-inject grid.
4581        let html = render("{no_diagrams}\n{define: Am base-fret 1 frets x 0 2 2 1 0}\n[Am]Hello");
4582        assert!(
4583            !html.contains("<svg"),
4584            "{{no_diagrams}} should suppress inline define diagram SVG"
4585        );
4586    }
4587
4588    #[test]
4589    fn test_define_chord_not_duplicated_in_auto_inject_grid() {
4590        // When a chord has a {define} entry (rendered inline) and also appears in
4591        // lyrics with {diagrams} active, the auto-inject grid must NOT include it
4592        // again. Regression test for #1211.
4593        let html =
4594            render("{define: Am base-fret 1 frets x 0 2 2 1 0}\n{diagrams}\n[Am]Hello [G]world\n");
4595        // Am was rendered inline at the {define} position; count SVG occurrences.
4596        let am_svg_count = html.match_indices("font-weight=\"bold\">Am</text>").count();
4597        assert_eq!(
4598            am_svg_count, 1,
4599            "Am diagram should appear exactly once (inline via {{define}}), not also in auto-inject grid"
4600        );
4601        // G has no {define} and should appear in the auto-inject grid.
4602        assert!(
4603            html.contains("font-weight=\"bold\">G</text>"),
4604            "G diagram should appear in the auto-inject grid"
4605        );
4606    }
4607
4608    #[test]
4609    fn test_define_after_nodiagrams_appears_in_grid() {
4610        // {define} encountered while show_diagrams=false must NOT be tracked as
4611        // inline-rendered; the chord should appear in the auto-inject grid.
4612        // Regression test for #1245.
4613        let html = render(
4614            "{no_diagrams}\n{define: Am base-fret 1 frets x 0 2 2 1 0}\n{diagrams}\n[Am]Hello\n",
4615        );
4616        // Am was NOT rendered inline ({no_diagrams} was active at {define} time).
4617        // It should appear in the auto-inject grid.
4618        assert!(
4619            html.contains("class=\"chord-diagrams\""),
4620            "auto-inject grid should appear since Am was not rendered inline"
4621        );
4622        assert!(
4623            html.contains("font-weight=\"bold\">Am</text>"),
4624            "Am should appear in the auto-inject grid"
4625        );
4626    }
4627
4628    #[test]
4629    fn test_enharmonic_define_dedup() {
4630        // {define: Bb …} + [A#] in lyrics: the flat/sharp pair must be treated as
4631        // the same chord so A# is excluded from the auto-inject grid.
4632        // Regression test for #1246.
4633        let html = render("{define: Bb base-fret 1 frets x 1 3 3 3 1}\n{diagrams}\n[A#]Hello\n");
4634        // Bb was rendered inline (as Bb); A# is the same chord enharmonically.
4635        let bb_count = html.match_indices("font-weight=\"bold\">Bb</text>").count();
4636        let as_count = html.match_indices("font-weight=\"bold\">A#</text>").count();
4637        assert_eq!(bb_count, 1, "Bb should appear once (inline)");
4638        assert_eq!(
4639            as_count, 0,
4640            "A# should NOT appear in the auto-inject grid (same chord as Bb)"
4641        );
4642    }
4643
4644    #[test]
4645    fn test_chord_directive_appears_in_auto_inject_grid() {
4646        // {chord} (DirectiveKind::ChordDirective) does not render inline — it must
4647        // always appear in the auto-inject grid.  Regression test for #1250.
4648        let html = render("{chord: Am base-fret 1 frets x 0 2 2 1 0}\n{diagrams}\n[Am]Hello\n");
4649        // Am has a {chord} entry but no inline diagram was rendered.
4650        // It should appear in the auto-inject grid.
4651        assert!(
4652            html.contains("class=\"chord-diagrams\""),
4653            "auto-inject grid should appear since {{chord}} does not render inline"
4654        );
4655        assert!(
4656            html.contains("font-weight=\"bold\">Am</text>"),
4657            "Am should appear in the auto-inject grid via {{chord}} voicing"
4658        );
4659    }
4660
4661    // -- abc2svg delegate rendering tests -----------------------------------------
4662
4663    #[test]
4664    fn test_abc_section_disabled_by_config() {
4665        // With delegates.abc2svg explicitly disabled, ABC renders as text
4666        let input = "{start_of_abc}\nX:1\n{end_of_abc}";
4667        let song = chordsketch_chordpro::parse(input).unwrap();
4668        let config = chordsketch_chordpro::config::Config::defaults()
4669            .with_define("delegates.abc2svg=false")
4670            .unwrap();
4671        let html = render_song_with_transpose(&song, 0, &config);
4672        assert!(html.contains("<section class=\"abc\">"));
4673        assert!(html.contains("ABC"));
4674        assert!(html.contains("</section>"));
4675    }
4676
4677    #[test]
4678    fn test_abc_section_null_config_auto_detect_disabled() {
4679        // Default config has delegates.abc2svg=null (auto-detect).
4680        // When abc2svg is not installed, sections render as plain text.
4681        if chordsketch_chordpro::external_tool::has_abc2svg() {
4682            return; // Skip on machines with abc2svg installed
4683        }
4684        let input = "{start_of_abc}\nX:1\n{end_of_abc}";
4685        let song = chordsketch_chordpro::parse(input).unwrap();
4686        // Use defaults — delegates.abc2svg is null (auto-detect)
4687        let config = chordsketch_chordpro::config::Config::defaults();
4688        assert!(
4689            config.get_path("delegates.abc2svg").is_null(),
4690            "default config should have null delegates.abc2svg"
4691        );
4692        let html = render_song_with_transpose(&song, 0, &config);
4693        assert!(
4694            html.contains("<section class=\"abc\">"),
4695            "null auto-detect with no abc2svg should render as text section"
4696        );
4697    }
4698
4699    #[test]
4700    fn test_abc_section_fallback_preformatted() {
4701        // With delegate enabled but abc2svg not available, falls back to <pre>
4702        if chordsketch_chordpro::external_tool::has_abc2svg() {
4703            return; // Skip on machines with abc2svg installed
4704        }
4705        let input = "{start_of_abc}\nX:1\nT:Test\nK:C\n{end_of_abc}";
4706        let song = chordsketch_chordpro::parse(input).unwrap();
4707        let config = chordsketch_chordpro::config::Config::defaults()
4708            .with_define("delegates.abc2svg=true")
4709            .unwrap();
4710        let html = render_song_with_transpose(&song, 0, &config);
4711        assert!(html.contains("<section class=\"abc\">"));
4712        assert!(html.contains("<pre>"));
4713        assert!(html.contains("X:1"));
4714        assert!(html.contains("</pre>"));
4715    }
4716
4717    #[test]
4718    fn test_abc_section_with_label_delegate_fallback() {
4719        if chordsketch_chordpro::external_tool::has_abc2svg() {
4720            return;
4721        }
4722        let input = "{start_of_abc: Melody}\nX:1\n{end_of_abc}";
4723        let song = chordsketch_chordpro::parse(input).unwrap();
4724        let config = chordsketch_chordpro::config::Config::defaults()
4725            .with_define("delegates.abc2svg=true")
4726            .unwrap();
4727        let html = render_song_with_transpose(&song, 0, &config);
4728        assert!(html.contains("ABC: Melody"));
4729        assert!(html.contains("<pre>"));
4730    }
4731
4732    #[test]
4733    #[ignore]
4734    fn test_abc_section_renders_svg_with_abc2svg() {
4735        // Requires abc2svg installed. Run with: cargo test -- --ignored
4736        let input = "{start_of_abc}\nX:1\nT:Test\nM:4/4\nK:C\nCDEF|GABc|\n{end_of_abc}";
4737        let song = chordsketch_chordpro::parse(input).unwrap();
4738        let config = chordsketch_chordpro::config::Config::defaults()
4739            .with_define("delegates.abc2svg=true")
4740            .unwrap();
4741        let html = render_song_with_transpose(&song, 0, &config);
4742        assert!(html.contains("<section class=\"abc\">"));
4743        assert!(
4744            html.contains("<svg"),
4745            "should contain rendered SVG from abc2svg"
4746        );
4747        assert!(html.contains("</section>"));
4748    }
4749
4750    #[test]
4751    fn test_abc_section_auto_detect_default_config() {
4752        // Default config has delegates.abc2svg=null (auto-detect).
4753        // When the tool is not found, auto-detect resolves to false and the
4754        // section renders with raw content as regular text (no SVG, no <pre>).
4755        let input = "{start_of_abc}\nX:1\nT:Test\nK:C\n{end_of_abc}";
4756        let song = chordsketch_chordpro::parse(input).unwrap();
4757        let config = chordsketch_chordpro::config::Config::defaults();
4758        let html = render_song_with_transpose(&song, 0, &config);
4759        assert!(
4760            html.contains("<section class=\"abc\">"),
4761            "auto-detect should produce abc section"
4762        );
4763        if !chordsketch_chordpro::external_tool::has_abc2svg() {
4764            assert!(
4765                html.contains("X:1"),
4766                "raw ABC content should be present without tool"
4767            );
4768            assert!(
4769                !html.contains("<svg"),
4770                "no SVG should be generated without abc2svg"
4771            );
4772        }
4773    }
4774
4775    // -- lilypond delegate rendering tests ----------------------------------------
4776
4777    #[test]
4778    fn test_ly_section_auto_detect_default_config() {
4779        // Same as ABC: auto-detect renders a section regardless of tool availability.
4780        let input = "{start_of_ly}\n\\relative c' { c4 }\n{end_of_ly}";
4781        let song = chordsketch_chordpro::parse(input).unwrap();
4782        let config = chordsketch_chordpro::config::Config::defaults();
4783        let html = render_song_with_transpose(&song, 0, &config);
4784        assert!(
4785            html.contains("<section class=\"ly\">"),
4786            "auto-detect should produce ly section"
4787        );
4788        if !chordsketch_chordpro::external_tool::has_lilypond() {
4789            assert!(
4790                html.contains("\\relative"),
4791                "raw Lilypond content should be present without tool"
4792            );
4793            assert!(
4794                !html.contains("<svg"),
4795                "no SVG should be generated without lilypond"
4796            );
4797        }
4798    }
4799
4800    #[test]
4801    fn test_ly_section_disabled_by_config() {
4802        // With delegates.lilypond explicitly disabled, Ly renders as text
4803        let input = "{start_of_ly}\n\\relative c' { c4 }\n{end_of_ly}";
4804        let song = chordsketch_chordpro::parse(input).unwrap();
4805        let config = chordsketch_chordpro::config::Config::defaults()
4806            .with_define("delegates.lilypond=false")
4807            .unwrap();
4808        let html = render_song_with_transpose(&song, 0, &config);
4809        assert!(html.contains("<section class=\"ly\">"));
4810        assert!(html.contains("Lilypond"));
4811        assert!(html.contains("</section>"));
4812    }
4813
4814    #[test]
4815    fn test_ly_section_fallback_preformatted() {
4816        if chordsketch_chordpro::external_tool::has_lilypond() {
4817            return;
4818        }
4819        let input = "{start_of_ly}\n\\relative c' { c4 }\n{end_of_ly}";
4820        let song = chordsketch_chordpro::parse(input).unwrap();
4821        let config = chordsketch_chordpro::config::Config::defaults()
4822            .with_define("delegates.lilypond=true")
4823            .unwrap();
4824        let html = render_song_with_transpose(&song, 0, &config);
4825        assert!(html.contains("<section class=\"ly\">"));
4826        assert!(html.contains("<pre>"));
4827        assert!(html.contains("</pre>"));
4828    }
4829
4830    #[test]
4831    #[ignore]
4832    fn test_ly_section_renders_svg_with_lilypond() {
4833        // Requires lilypond installed. Run with: cargo test -- --ignored
4834        let input = "{start_of_ly}\n\\relative c' { c4 d e f | g2 g | }\n{end_of_ly}";
4835        let song = chordsketch_chordpro::parse(input).unwrap();
4836        let config = chordsketch_chordpro::config::Config::defaults()
4837            .with_define("delegates.lilypond=true")
4838            .unwrap();
4839        let html = render_song_with_transpose(&song, 0, &config);
4840        assert!(html.contains("<section class=\"ly\">"));
4841        assert!(
4842            html.contains("<svg"),
4843            "should contain rendered SVG from lilypond"
4844        );
4845        assert!(html.contains("</section>"));
4846    }
4847}
4848
4849#[cfg(test)]
4850mod delegate_tests {
4851    use super::*;
4852
4853    #[test]
4854    fn test_render_abc_section() {
4855        let html = render("{start_of_abc}\nX:1\n{end_of_abc}");
4856        assert!(html.contains("<section class=\"abc\">"));
4857        assert!(html.contains("ABC"));
4858        assert!(html.contains("</section>"));
4859    }
4860
4861    #[test]
4862    fn test_render_abc_section_with_label() {
4863        let html = render("{start_of_abc: Melody}\nX:1\n{end_of_abc}");
4864        assert!(html.contains("<section class=\"abc\">"));
4865        assert!(html.contains("ABC: Melody"));
4866    }
4867
4868    #[test]
4869    fn test_render_ly_section() {
4870        let html = render("{start_of_ly}\nnotes\n{end_of_ly}");
4871        assert!(html.contains("<section class=\"ly\">"));
4872        assert!(html.contains("Lilypond"));
4873        assert!(html.contains("</section>"));
4874    }
4875
4876    // -- MusicXML delegate rendering tests ----------------------------------
4877
4878    #[test]
4879    fn test_render_musicxml_section_disabled() {
4880        // With delegates.musescore explicitly disabled, MusicXML renders as text.
4881        let input = "{start_of_musicxml}\n<score-partwise/>\n{end_of_musicxml}";
4882        let song = chordsketch_chordpro::parse(input).unwrap();
4883        let config = chordsketch_chordpro::config::Config::defaults()
4884            .with_define("delegates.musescore=false")
4885            .unwrap();
4886        let html = render_song_with_transpose(&song, 0, &config);
4887        assert!(
4888            html.contains("<section class=\"musicxml\">"),
4889            "fallback section should render when musescore is disabled: {html}"
4890        );
4891        assert!(html.contains("MusicXML"), "section label should appear");
4892        assert!(html.contains("</section>"), "section should be closed");
4893    }
4894
4895    #[test]
4896    fn test_render_musicxml_section_no_musescore_installed() {
4897        // Default config has delegates.musescore=null (auto-detect).
4898        // When musescore is not installed, sections render as plain text.
4899        if chordsketch_chordpro::external_tool::has_musescore() {
4900            return; // Skip on machines with musescore installed
4901        }
4902
4903        let input = "{start_of_musicxml}\n<score-partwise/>\n{end_of_musicxml}";
4904        let song = chordsketch_chordpro::parse(input).unwrap();
4905        let config = chordsketch_chordpro::config::Config::defaults();
4906        assert!(
4907            config.get_path("delegates.musescore").is_null(),
4908            "default config should have null delegates.musescore"
4909        );
4910        let html = render_song_with_transpose(&song, 0, &config);
4911        assert!(
4912            html.contains("<section class=\"musicxml\">"),
4913            "null auto-detect with no musescore should render as text section"
4914        );
4915    }
4916
4917    #[test]
4918    fn test_render_musicxml_section_with_label() {
4919        let input = "{start_of_musicxml: Score}\n<score-partwise/>\n{end_of_musicxml}";
4920        let song = chordsketch_chordpro::parse(input).unwrap();
4921        let config = chordsketch_chordpro::config::Config::defaults()
4922            .with_define("delegates.musescore=false")
4923            .unwrap();
4924        let html = render_song_with_transpose(&song, 0, &config);
4925        assert!(
4926            html.contains("Score"),
4927            "label should appear in section header"
4928        );
4929    }
4930
4931    #[test]
4932    fn test_abc_fallback_sanitizes_would_be_script_in_svg() {
4933        // Even though abc2svg is not installed, verify the sanitization path
4934        // by directly calling the helper with a mocked SVG containing a
4935        // script tag.  The sanitize_svg_content call must strip it.
4936        let malicious_svg = "<svg><script>alert(1)</script><circle r=\"5\"/></svg>";
4937        let sanitized = sanitize_svg_content(malicious_svg);
4938        assert!(
4939            !sanitized.contains("<script>"),
4940            "script tags must be stripped from delegate SVG output"
4941        );
4942        assert!(sanitized.contains("<circle"));
4943    }
4944
4945    #[test]
4946    fn test_sanitize_svg_strips_event_handlers_from_delegate_output() {
4947        let svg_with_handler = "<svg><rect onmouseover=\"alert(1)\" width=\"10\"/></svg>";
4948        let sanitized = sanitize_svg_content(svg_with_handler);
4949        assert!(
4950            !sanitized.contains("onmouseover"),
4951            "event handlers must be stripped from delegate SVG output"
4952        );
4953        assert!(sanitized.contains("<rect"));
4954    }
4955
4956    #[test]
4957    fn test_sanitize_svg_strips_foreignobject_from_delegate_output() {
4958        let svg = "<svg><foreignObject><body xmlns=\"http://www.w3.org/1999/xhtml\"><script>alert(1)</script></body></foreignObject></svg>";
4959        let sanitized = sanitize_svg_content(svg);
4960        assert!(
4961            !sanitized.contains("<foreignObject"),
4962            "foreignObject must be stripped from delegate SVG output"
4963        );
4964    }
4965
4966    #[test]
4967    fn test_sanitize_svg_strips_math_element() {
4968        let svg = "<svg><math><mi>x</mi></math></svg>";
4969        let sanitized = sanitize_svg_content(svg);
4970        assert!(
4971            !sanitized.contains("<math"),
4972            "math element must be stripped from delegate SVG output"
4973        );
4974    }
4975
4976    // -- Namespaced dangerous tags (sister-site gap, sanitizer-security.md) --
4977
4978    #[test]
4979    fn test_sanitize_svg_strips_namespaced_script() {
4980        // `<svg:script>` used to survive because the DANGEROUS_TAGS scan
4981        // only matched `<script`. HTML5 parsers outside an `<svg>` root
4982        // treat this as a plain element, so the exploitability is narrow,
4983        // but the blocklist must still cover the namespaced form.
4984        let svg = "<svg:script>alert(1)</svg:script><circle r=\"5\"/>";
4985        let sanitized = sanitize_svg_content(svg);
4986        assert!(
4987            !sanitized.to_ascii_lowercase().contains("script"),
4988            "namespaced <svg:script> must be stripped, got: {sanitized}"
4989        );
4990        assert!(sanitized.contains("<circle"));
4991    }
4992
4993    #[test]
4994    fn test_sanitize_svg_strips_namespaced_iframe_case_insensitive() {
4995        let svg = "<XHTML:Iframe src=\"javascript:alert(1)\"></XHTML:Iframe>text";
4996        let sanitized = sanitize_svg_content(svg);
4997        assert!(
4998            !sanitized.to_ascii_lowercase().contains("iframe"),
4999            "namespaced iframe must be stripped, got: {sanitized}"
5000        );
5001        assert!(sanitized.contains("text"));
5002    }
5003
5004    #[test]
5005    fn test_sanitize_svg_strips_namespaced_foreignobject() {
5006        let svg = "<svg:foreignObject><body><script>x()</script></body></svg:foreignObject>safe";
5007        let sanitized = sanitize_svg_content(svg);
5008        assert!(
5009            !sanitized.to_ascii_lowercase().contains("foreignobject"),
5010            "namespaced foreignObject must be stripped, got: {sanitized}"
5011        );
5012        assert!(!sanitized.to_ascii_lowercase().contains("script"));
5013        assert!(sanitized.contains("safe"));
5014    }
5015
5016    #[test]
5017    fn test_sanitize_svg_strips_stray_namespaced_closing_tag() {
5018        // A stray closing `</svg:script>` without a matching opener must
5019        // still be stripped — previously only `</script>` was recognised.
5020        let svg = "lyrics</svg:script>more";
5021        let sanitized = sanitize_svg_content(svg);
5022        assert!(
5023            !sanitized.to_ascii_lowercase().contains("script"),
5024            "stray namespaced closing tag must be stripped, got: {sanitized}"
5025        );
5026    }
5027
5028    #[test]
5029    fn test_render_svg_section() {
5030        let html = render("{start_of_svg}\n<svg/>\n{end_of_svg}");
5031        // SVG sections embed content directly (not in a section element)
5032        assert!(html.contains("<div class=\"svg-section\">"));
5033        assert!(html.contains("<svg/>"));
5034        assert!(html.contains("</div>"));
5035    }
5036
5037    #[test]
5038    fn test_render_svg_inline_content() {
5039        let svg = r#"<svg width="100" height="100"><circle cx="50" cy="50" r="40"/></svg>"#;
5040        let input = format!("{{start_of_svg}}\n{svg}\n{{end_of_svg}}");
5041        let html = render(&input);
5042        assert!(html.contains(svg));
5043    }
5044
5045    #[test]
5046    fn test_svg_section_strips_script_tags() {
5047        let input = "{start_of_svg}\n<svg><script>alert('xss')</script><circle r=\"10\"/></svg>\n{end_of_svg}";
5048        let html = render(input);
5049        assert!(!html.contains("<script>"), "script tags must be stripped");
5050        assert!(!html.contains("alert"), "script content must be stripped");
5051        assert!(
5052            html.contains("<circle r=\"10\"/>"),
5053            "safe SVG content must be preserved"
5054        );
5055    }
5056
5057    #[test]
5058    fn test_svg_section_strips_event_handlers() {
5059        let input = "{start_of_svg}\n<svg onload=\"alert(1)\"><rect width=\"10\" onerror=\"hack()\"/></svg>\n{end_of_svg}";
5060        let html = render(input);
5061        assert!(!html.contains("onload"), "onload handler must be stripped");
5062        assert!(
5063            !html.contains("onerror"),
5064            "onerror handler must be stripped"
5065        );
5066        assert!(
5067            html.contains("width=\"10\""),
5068            "safe attributes must be preserved"
5069        );
5070    }
5071
5072    #[test]
5073    fn test_svg_section_preserves_safe_content() {
5074        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><text x="10" y="20">Hello</text></svg>"#;
5075        let input = format!("{{start_of_svg}}\n{svg}\n{{end_of_svg}}");
5076        let html = render(&input);
5077        assert!(html.contains("xmlns=\"http://www.w3.org/2000/svg\""));
5078        assert!(html.contains("<text x=\"10\" y=\"20\">Hello</text>"));
5079    }
5080
5081    #[test]
5082    fn test_svg_section_strips_case_insensitive_script() {
5083        let input = "{start_of_svg}\n<SCRIPT>alert(1)</SCRIPT><svg/>\n{end_of_svg}";
5084        let html = render(input);
5085        assert!(!html.contains("SCRIPT"), "case-insensitive script removal");
5086        assert!(!html.contains("alert"));
5087        assert!(html.contains("<svg/>"));
5088    }
5089
5090    #[test]
5091    fn test_svg_section_strips_foreignobject() {
5092        let input = "{start_of_svg}\n<svg><foreignObject><body onload=\"alert(1)\"></body></foreignObject><rect width=\"10\"/></svg>\n{end_of_svg}";
5093        let html = render(input);
5094        assert!(
5095            !html.contains("foreignObject"),
5096            "foreignObject must be stripped"
5097        );
5098        assert!(
5099            !html.contains("foreignobject"),
5100            "foreignObject (lowercase) must be stripped"
5101        );
5102        assert!(
5103            html.contains("<rect width=\"10\"/>"),
5104            "safe content must be preserved"
5105        );
5106    }
5107
5108    #[test]
5109    fn test_svg_section_strips_iframe() {
5110        let input = "{start_of_svg}\n<svg><iframe src=\"javascript:alert(1)\"></iframe><circle r=\"5\"/></svg>\n{end_of_svg}";
5111        let html = render(input);
5112        assert!(!html.contains("iframe"), "iframe must be stripped");
5113        assert!(html.contains("<circle r=\"5\"/>"));
5114    }
5115
5116    #[test]
5117    fn test_svg_section_strips_object_and_embed() {
5118        let input = "{start_of_svg}\n<svg><object data=\"evil.swf\"></object><embed src=\"evil.swf\"></embed><rect/></svg>\n{end_of_svg}";
5119        let html = render(input);
5120        assert!(!html.contains("object"), "object must be stripped");
5121        assert!(!html.contains("embed"), "embed must be stripped");
5122        assert!(html.contains("<rect/>"));
5123    }
5124
5125    #[test]
5126    fn test_svg_section_strips_javascript_uri_in_href() {
5127        let input = "{start_of_svg}\n<svg><a href=\"javascript:alert(1)\"><text>Click</text></a></svg>\n{end_of_svg}";
5128        let html = render(input);
5129        assert!(
5130            !html.contains("javascript:"),
5131            "javascript: URI must be stripped from href"
5132        );
5133        assert!(html.contains("<text>Click</text>"));
5134    }
5135
5136    #[test]
5137    fn test_svg_section_strips_vbscript_uri() {
5138        let input = "{start_of_svg}\n<svg><a href=\"vbscript:MsgBox\"><text>Click</text></a></svg>\n{end_of_svg}";
5139        let html = render(input);
5140        assert!(
5141            !html.contains("vbscript:"),
5142            "vbscript: URI must be stripped"
5143        );
5144    }
5145
5146    #[test]
5147    fn test_svg_section_strips_data_uri_in_use() {
5148        let input = "{start_of_svg}\n<svg><use href=\"data:image/svg+xml;base64,PHN2Zy8+\"/></svg>\n{end_of_svg}";
5149        let html = render(input);
5150        assert!(
5151            !html.contains("data:"),
5152            "data: URI must be stripped from use href"
5153        );
5154    }
5155
5156    #[test]
5157    fn test_svg_section_strips_javascript_uri_case_insensitive() {
5158        let input = "{start_of_svg}\n<svg><a href=\"JaVaScRiPt:alert(1)\"><text>X</text></a></svg>\n{end_of_svg}";
5159        let html = render(input);
5160        assert!(
5161            !html.to_lowercase().contains("javascript:"),
5162            "case-insensitive javascript: URI must be stripped"
5163        );
5164    }
5165
5166    #[test]
5167    fn test_svg_section_strips_xlink_href_dangerous_uri() {
5168        let input =
5169            "{start_of_svg}\n<svg><use xlink:href=\"javascript:alert(1)\"/></svg>\n{end_of_svg}";
5170        let html = render(input);
5171        assert!(
5172            !html.contains("javascript:"),
5173            "javascript: URI in xlink:href must be stripped"
5174        );
5175    }
5176
5177    #[test]
5178    fn test_svg_section_preserves_safe_href() {
5179        let input = "{start_of_svg}\n<svg><a href=\"https://example.com\"><text>Link</text></a></svg>\n{end_of_svg}";
5180        let html = render(input);
5181        assert!(
5182            html.contains("href=\"https://example.com\""),
5183            "safe https: href must be preserved"
5184        );
5185    }
5186
5187    #[test]
5188    fn test_svg_section_preserves_fragment_href() {
5189        let input = "{start_of_svg}\n<svg><use href=\"#myShape\"/></svg>\n{end_of_svg}";
5190        let html = render(input);
5191        assert!(
5192            html.contains("href=\"#myShape\""),
5193            "fragment-only href must be preserved"
5194        );
5195    }
5196
5197    #[test]
5198    fn test_svg_section_strips_use_external_https() {
5199        // Per #1828, <use href="https://..."> is a tracker/exfiltration
5200        // vector even over safe schemes (referer leakage, cross-origin
5201        // tracking pixel). Only fragment-only references ^# are allowed.
5202        let input = "{start_of_svg}\n<svg><use href=\"https://attacker.example.com/x.svg#sym\"/></svg>\n{end_of_svg}";
5203        let html = render(input);
5204        assert!(
5205            !html.contains("attacker.example.com"),
5206            "external https: URI in <use href> must be stripped; got: {html}"
5207        );
5208    }
5209
5210    #[test]
5211    fn test_svg_section_strips_use_external_xlink_href() {
5212        // Same policy for the legacy xlink:href attribute.
5213        let input = "{start_of_svg}\n<svg><use xlink:href=\"https://tracker.example/pixel.svg\"/></svg>\n{end_of_svg}";
5214        let html = render(input);
5215        assert!(
5216            !html.contains("tracker.example"),
5217            "external https: URI in <use xlink:href> must be stripped; got: {html}"
5218        );
5219    }
5220
5221    #[test]
5222    fn test_svg_section_preserves_fragment_xlink_href() {
5223        let input = "{start_of_svg}\n<svg><use xlink:href=\"#mySymbol\"/></svg>\n{end_of_svg}";
5224        let html = render(input);
5225        assert!(
5226            html.contains("xlink:href=\"#mySymbol\""),
5227            "fragment-only xlink:href must be preserved"
5228        );
5229    }
5230
5231    #[test]
5232    fn test_render_textblock_section() {
5233        let html = render("{start_of_textblock}\nPreformatted\n{end_of_textblock}");
5234        assert!(html.contains("<section class=\"textblock\">"));
5235        assert!(html.contains("Textblock"));
5236        assert!(html.contains("</section>"));
5237    }
5238
5239    // --- Multi-song rendering ---
5240
5241    #[test]
5242    fn test_render_songs_single() {
5243        let songs = chordsketch_chordpro::parse_multi("{title: Only}").unwrap();
5244        let html = render_songs(&songs);
5245        // Single song: should be identical to render_song
5246        assert_eq!(html, render_song(&songs[0]));
5247    }
5248
5249    #[test]
5250    fn test_render_songs_two_songs_with_hr_separator() {
5251        let songs = chordsketch_chordpro::parse_multi(
5252            "{title: Song A}\n[Am]Hello\n{new_song}\n{title: Song B}\n[G]World",
5253        )
5254        .unwrap();
5255        let html = render_songs(&songs);
5256        // Document title from first song
5257        assert!(html.contains("<title>Song A</title>"));
5258        // Both songs present
5259        assert!(html.contains("<h1>Song A</h1>"));
5260        assert!(html.contains("<h1>Song B</h1>"));
5261        // Separator between songs
5262        assert!(html.contains("<hr class=\"song-separator\">"));
5263        // Each song in its own div.song
5264        assert_eq!(html.matches("<article class=\"song\">").count(), 2);
5265        // Single HTML document wrapper
5266        assert_eq!(html.matches("<!DOCTYPE html>").count(), 1);
5267        assert_eq!(html.matches("</html>").count(), 1);
5268    }
5269
5270    #[test]
5271    fn test_image_scale_css_injection_prevented() {
5272        // The scale parameter must be sanitized as a CSS value to prevent
5273        // injection of arbitrary CSS properties via parentheses and semicolons.
5274        let html = render("{image: src=photo.jpg scale=0.5); position: fixed; z-index: 9999}");
5275        // Inspect the inline `style="..."` attribute on the image
5276        // element, not the document as a whole — the embedded
5277        // stylesheet uses `ruby-position` for the chord-block
5278        // layout (added in the semantic-HTML refactor) which is
5279        // unrelated to the injection vector this test guards.
5280        let img_style = extract_img_style(&html);
5281        assert!(!img_style.contains("position"), "got style: {img_style}");
5282        assert!(!img_style.contains("z-index"), "got style: {img_style}");
5283        assert!(
5284            !img_style.contains("position: fixed"),
5285            "got style: {img_style}"
5286        );
5287    }
5288
5289    /// Pull the first `style="..."` attribute of the rendered `<img>`
5290    /// element out of the document HTML so injection-vector tests can
5291    /// scope their assertions to the attribute they actually
5292    /// protect.
5293    fn extract_img_style(html: &str) -> String {
5294        let needle = "<img ";
5295        let img_start = html
5296            .find(needle)
5297            .expect("rendered html should contain an <img>");
5298        let after_img = &html[img_start..];
5299        let img_end = after_img.find('>').expect("<img> must close");
5300        let img_tag = &after_img[..=img_end];
5301        let style_marker = "style=\"";
5302        match img_tag.find(style_marker) {
5303            None => String::new(),
5304            Some(s) => {
5305                let after_open = &img_tag[s + style_marker.len()..];
5306                let end = after_open
5307                    .find('"')
5308                    .expect("style attribute must have a closing quote");
5309                after_open[..end].to_string()
5310            }
5311        }
5312    }
5313
5314    #[test]
5315    fn test_render_songs_with_transpose() {
5316        let songs =
5317            chordsketch_chordpro::parse_multi("{title: S1}\n[C]Do\n{new_song}\n{title: S2}\n[G]Re")
5318                .unwrap();
5319        let html = render_songs_with_transpose(&songs, 2, &Config::defaults());
5320        // C+2=D, G+2=A
5321        assert!(html.contains(">D<"));
5322        assert!(html.contains(">A<"));
5323    }
5324
5325    // --- SVG animation XSS prevention (#572) ---
5326
5327    #[test]
5328    fn test_sanitize_svg_strips_set_element() {
5329        let svg = r##"<svg><a href="#"><set attributeName="href" to="javascript:alert(1)"/><text>Click</text></a></svg>"##;
5330        let sanitized = sanitize_svg_content(svg);
5331        assert!(
5332            !sanitized.contains("<set"),
5333            "set element must be stripped to prevent SVG animation XSS"
5334        );
5335        assert!(sanitized.contains("<text>Click</text>"));
5336    }
5337
5338    #[test]
5339    fn test_sanitize_svg_strips_animate_element() {
5340        let svg =
5341            r#"<svg><animate attributeName="href" values="javascript:alert(1)"/><rect/></svg>"#;
5342        let sanitized = sanitize_svg_content(svg);
5343        assert!(
5344            !sanitized.contains("<animate"),
5345            "animate element must be stripped"
5346        );
5347        assert!(sanitized.contains("<rect/>"));
5348    }
5349
5350    #[test]
5351    fn test_sanitize_svg_strips_animatetransform() {
5352        let svg =
5353            "<svg><animateTransform attributeName=\"transform\" type=\"rotate\"/><rect/></svg>";
5354        let sanitized = sanitize_svg_content(svg);
5355        assert!(
5356            !sanitized.contains("animateTransform"),
5357            "animateTransform must be stripped"
5358        );
5359        assert!(
5360            !sanitized.contains("animatetransform"),
5361            "animatetransform (lowercase) must be stripped"
5362        );
5363    }
5364
5365    #[test]
5366    fn test_sanitize_svg_strips_animatemotion() {
5367        let svg = "<svg><animateMotion path=\"M0,0 L100,100\"/><rect/></svg>";
5368        let sanitized = sanitize_svg_content(svg);
5369        assert!(
5370            !sanitized.contains("animateMotion"),
5371            "animateMotion must be stripped"
5372        );
5373    }
5374
5375    #[test]
5376    fn test_sanitize_svg_strips_to_attr_with_dangerous_uri() {
5377        let svg = r#"<svg><a to="javascript:alert(1)"><text>X</text></a></svg>"#;
5378        let sanitized = sanitize_svg_content(svg);
5379        assert!(
5380            !sanitized.contains("javascript:"),
5381            "dangerous URI in 'to' attr must be stripped"
5382        );
5383    }
5384
5385    #[test]
5386    fn test_sanitize_svg_strips_values_attr_with_dangerous_uri() {
5387        let svg = r#"<svg><a values="javascript:alert(1)"><text>X</text></a></svg>"#;
5388        let sanitized = sanitize_svg_content(svg);
5389        assert!(
5390            !sanitized.contains("javascript:"),
5391            "dangerous URI in 'values' attr must be stripped"
5392        );
5393    }
5394
5395    // --- UTF-8 preservation in strip_dangerous_attrs (#578) ---
5396
5397    #[test]
5398    fn test_strip_dangerous_attrs_preserves_cjk_text() {
5399        let input = "<svg><text x=\"10\">日本語テスト</text></svg>";
5400        let result = strip_dangerous_attrs(input);
5401        assert!(
5402            result.contains("日本語テスト"),
5403            "CJK characters must not be corrupted"
5404        );
5405    }
5406
5407    #[test]
5408    fn test_strip_dangerous_attrs_preserves_emoji() {
5409        let input = "<svg><text>🎵🎸🎹</text></svg>";
5410        let result = strip_dangerous_attrs(input);
5411        assert!(result.contains("🎵🎸🎹"), "emoji must not be corrupted");
5412    }
5413
5414    #[test]
5415    fn test_strip_dangerous_attrs_preserves_accented_chars() {
5416        let input = "<svg><text>café résumé naïve</text></svg>";
5417        let result = strip_dangerous_attrs(input);
5418        assert!(
5419            result.contains("café résumé naïve"),
5420            "accented characters must not be corrupted"
5421        );
5422    }
5423
5424    #[test]
5425    fn test_sanitize_svg_full_roundtrip_with_non_ascii() {
5426        let input = "<svg><text x=\"10\">コード譜 🎵</text><rect width=\"100\"/></svg>";
5427        let sanitized = sanitize_svg_content(input);
5428        assert!(sanitized.contains("コード譜 🎵"));
5429        assert!(sanitized.contains("<rect width=\"100\"/>"));
5430    }
5431
5432    #[test]
5433    fn test_sanitize_svg_self_closing_with_gt_in_attr_value() {
5434        // The `>` inside the attribute value should not confuse self-closing detection.
5435        let svg = r#"<svg><set to="a>b"/><text>safe</text></svg>"#;
5436        let sanitized = sanitize_svg_content(svg);
5437        assert!(
5438            !sanitized.contains("<set"),
5439            "dangerous <set> element must be stripped"
5440        );
5441        assert!(
5442            sanitized.contains("<text>safe</text>"),
5443            "content after stripped self-closing element must be preserved"
5444        );
5445    }
5446
5447    // --- Quote-aware tag boundary scan (#646) ---
5448
5449    #[test]
5450    fn test_strip_dangerous_attrs_gt_in_double_quoted_attr() {
5451        // `>` inside title=">" should not split the tag.
5452        let input = r#"<rect title=">" onload="alert(1)"/>"#;
5453        let result = strip_dangerous_attrs(input);
5454        assert!(
5455            !result.contains("onload"),
5456            "onload after quoted > must be stripped"
5457        );
5458        assert!(result.contains("title"));
5459    }
5460
5461    #[test]
5462    fn test_strip_dangerous_attrs_gt_in_single_quoted_attr() {
5463        let input = "<rect title='>' onload=\"alert(1)\"/>";
5464        let result = strip_dangerous_attrs(input);
5465        assert!(
5466            !result.contains("onload"),
5467            "onload after single-quoted > must be stripped"
5468        );
5469    }
5470
5471    // --- URI scheme with embedded whitespace/control chars (#655) ---
5472
5473    #[test]
5474    fn test_dangerous_uri_scheme_with_embedded_tab() {
5475        assert!(has_dangerous_uri_scheme("java\tscript:alert(1)"));
5476    }
5477
5478    #[test]
5479    fn test_dangerous_uri_scheme_with_embedded_newline() {
5480        assert!(has_dangerous_uri_scheme("java\nscript:alert(1)"));
5481    }
5482
5483    #[test]
5484    fn test_dangerous_uri_scheme_with_control_chars() {
5485        assert!(has_dangerous_uri_scheme("java\x00script:alert(1)"));
5486    }
5487
5488    #[test]
5489    fn test_safe_uri_not_flagged() {
5490        assert!(!has_dangerous_uri_scheme("https://example.com"));
5491    }
5492
5493    #[test]
5494    fn test_dangerous_uri_scheme_with_many_embedded_whitespace() {
5495        // 1 tab between each letter: colon at raw position 20, within the 30-char window.
5496        // Both old and new code detect this; kept as a basic obfuscation smoke-test.
5497        let payload = "j\ta\tv\ta\ts\tc\tr\ti\tp\tt\t:\ta\tl\te\tr\tt\t(\t1\t)\t";
5498        assert!(
5499            has_dangerous_uri_scheme(payload),
5500            "1 tab between letters should not bypass javascript: detection"
5501        );
5502    }
5503
5504    #[test]
5505    fn test_dangerous_uri_scheme_whitespace_bypass_regression() {
5506        // 3 tabs between each letter pushes the colon to raw position 40, past the
5507        // 30-char cap. The old `.take(30).filter(...)` ordering cut off the colon and
5508        // missed the match. Filter-first (`.filter(...).take(30)`) fixes this.
5509        // This test FAILS with the old ordering and PASSES with the fix.
5510        let payload = "j\t\t\ta\t\t\tv\t\t\ta\t\t\ts\t\t\tc\t\t\tr\t\t\ti\t\t\tp\t\t\tt\t\t\t:";
5511        assert!(
5512            has_dangerous_uri_scheme(payload),
5513            "3 tabs between letters (colon at raw position 40) must still be detected"
5514        );
5515    }
5516
5517    // -- Unicode invisible-format-character obfuscation --------------------
5518
5519    #[test]
5520    fn test_dangerous_uri_scheme_with_zero_width_space() {
5521        assert!(
5522            has_dangerous_uri_scheme("java\u{200B}script:alert(1)"),
5523            "ZWSP embedded in javascript: scheme must still be blocked"
5524        );
5525    }
5526
5527    #[test]
5528    fn test_dangerous_uri_scheme_with_zero_width_joiner() {
5529        assert!(
5530            has_dangerous_uri_scheme("vb\u{200D}script:alert(1)"),
5531            "ZWJ embedded in vbscript: scheme must still be blocked"
5532        );
5533    }
5534
5535    #[test]
5536    fn test_dangerous_uri_scheme_with_byte_order_mark() {
5537        assert!(
5538            has_dangerous_uri_scheme("java\u{FEFF}script:alert(1)"),
5539            "BOM/ZWNBSP embedded in javascript: scheme must still be blocked"
5540        );
5541    }
5542
5543    #[test]
5544    fn test_dangerous_uri_scheme_with_soft_hyphen() {
5545        assert!(
5546            has_dangerous_uri_scheme("data\u{00AD}:text/html,xss"),
5547            "soft hyphen embedded in data: scheme must still be blocked"
5548        );
5549    }
5550
5551    #[test]
5552    fn test_dangerous_uri_scheme_with_bidi_override() {
5553        assert!(
5554            has_dangerous_uri_scheme("\u{202E}javascript:alert(1)"),
5555            "leading bidi override must not hide the scheme"
5556        );
5557        assert!(
5558            has_dangerous_uri_scheme("java\u{202A}script:alert(1)"),
5559            "embedded bidi override must not hide the scheme"
5560        );
5561    }
5562
5563    #[test]
5564    fn test_dangerous_uri_scheme_safe_after_unicode_filter() {
5565        // The filter must not flag safe schemes just because they pass
5566        // through the wider Unicode stripper.
5567        assert!(!has_dangerous_uri_scheme("https://example.com/a\u{200B}b"));
5568    }
5569
5570    #[test]
5571    fn test_dangerous_uri_scheme_with_lrm() {
5572        // LEFT-TO-RIGHT MARK (U+200E) is a Unicode Cf (Format) character
5573        // that is invisible in rendered text. Per #2087, it must be
5574        // stripped from the scheme candidate before comparison.
5575        assert!(
5576            has_dangerous_uri_scheme("java\u{200E}script:alert(1)"),
5577            "LRM embedded in javascript: scheme must still be blocked"
5578        );
5579    }
5580
5581    #[test]
5582    fn test_dangerous_uri_scheme_with_rlm() {
5583        // RIGHT-TO-LEFT MARK (U+200F) mirror of LRM. Per #2087.
5584        assert!(
5585            has_dangerous_uri_scheme("vb\u{200F}script:alert(1)"),
5586            "RLM embedded in vbscript: scheme must still be blocked"
5587        );
5588    }
5589
5590    // -- Namespace prefix with `.` (XML NCName, #2088) --------------------
5591
5592    #[test]
5593    fn test_sanitize_svg_strips_namespaced_script_with_dot_in_prefix() {
5594        // NCName body allows `.` after the first character, so `foo.bar:`
5595        // is a valid namespace prefix that previous versions of
5596        // `namespace_prefix_len` did not recognise. The blocklist must
5597        // still strip it.
5598        let svg = "<foo.bar:script>alert(1)</foo.bar:script>text";
5599        let sanitized = sanitize_svg_content(svg);
5600        assert!(
5601            !sanitized.to_ascii_lowercase().contains("script"),
5602            "`foo.bar:script` must be stripped, got: {sanitized}"
5603        );
5604        assert!(sanitized.contains("text"));
5605    }
5606
5607    // --- Multi-line tag splitting XSS prevention (#711) ---
5608
5609    #[test]
5610    fn test_svg_section_blocks_multiline_script_tag_splitting() {
5611        // Splitting <script> across two lines must NOT bypass the sanitizer.
5612        let input = "{start_of_svg}\n<script\n>alert(1)</script>\n{end_of_svg}";
5613        let html = render(input);
5614        assert!(
5615            !html.contains("alert(1)"),
5616            "multi-line <script> tag splitting must not execute JS"
5617        );
5618        assert!(
5619            !html.to_lowercase().contains("<script"),
5620            "multi-line <script> tag must be stripped"
5621        );
5622    }
5623
5624    #[test]
5625    fn test_svg_section_blocks_multiline_iframe_tag_splitting() {
5626        let input =
5627            "{start_of_svg}\n<iframe\nsrc=\"javascript:alert(1)\">\n</iframe>\n{end_of_svg}";
5628        let html = render(input);
5629        assert!(
5630            !html.to_lowercase().contains("<iframe"),
5631            "multi-line <iframe> tag splitting must be stripped"
5632        );
5633        assert!(
5634            !html.contains("javascript:"),
5635            "javascript: URI in split iframe must be stripped"
5636        );
5637    }
5638
5639    #[test]
5640    fn test_svg_section_blocks_multiline_foreignobject_splitting() {
5641        let input = "{start_of_svg}\n<foreignObject\n><script>alert(1)</script></foreignObject>\n{end_of_svg}";
5642        let html = render(input);
5643        assert!(
5644            !html.to_lowercase().contains("<foreignobject"),
5645            "multi-line <foreignObject> splitting must be stripped"
5646        );
5647    }
5648
5649    // --- file: and blob: URI scheme blocking (#1538) ---
5650
5651    #[test]
5652    fn test_dangerous_uri_file_scheme_blocked() {
5653        // file: URI in href must be blocked — parity with is_safe_image_src
5654        assert!(
5655            has_dangerous_uri_scheme("file:///etc/passwd"),
5656            "file: URI scheme must be detected as dangerous"
5657        );
5658        assert!(
5659            has_dangerous_uri_scheme("FILE:///etc/passwd"),
5660            "FILE: (uppercase) must be detected as dangerous"
5661        );
5662    }
5663
5664    #[test]
5665    fn test_dangerous_uri_blob_scheme_blocked() {
5666        assert!(
5667            has_dangerous_uri_scheme("blob:https://example.com/uuid"),
5668            "blob: URI scheme must be detected as dangerous"
5669        );
5670        assert!(
5671            has_dangerous_uri_scheme("BLOB:https://example.com/uuid"),
5672            "BLOB: (uppercase) must be detected as dangerous"
5673        );
5674    }
5675
5676    #[test]
5677    fn test_svg_section_strips_file_uri_in_use_href() {
5678        // <use href="file:///etc/passwd"/> must have the href stripped
5679        let input = "{start_of_svg}\n<svg><use href=\"file:///etc/passwd\"/></svg>\n{end_of_svg}";
5680        let html = render(input);
5681        assert!(
5682            !html.contains("file:///"),
5683            "file: URI in <use href> must be stripped; got: {html}"
5684        );
5685    }
5686
5687    #[test]
5688    fn test_svg_section_strips_file_uri_in_xlink_href() {
5689        let input =
5690            "{start_of_svg}\n<svg><use xlink:href=\"file:///etc/passwd\"/></svg>\n{end_of_svg}";
5691        let html = render(input);
5692        assert!(
5693            !html.contains("file:///"),
5694            "file: URI in xlink:href must be stripped; got: {html}"
5695        );
5696    }
5697
5698    // --- feImage tag blocking (#1545) ---
5699
5700    #[test]
5701    fn test_svg_section_strips_feimage_element() {
5702        // <feImage href="file:///etc/passwd"/> — SVG filter primitive loading external content
5703        let input =
5704            "{start_of_svg}\n<svg><feImage href=\"file:///etc/passwd\"/></svg>\n{end_of_svg}";
5705        let html = render(input);
5706        assert!(
5707            !html.to_lowercase().contains("<feimage"),
5708            "feImage element must be stripped entirely; got: {html}"
5709        );
5710        assert!(
5711            !html.contains("file:///"),
5712            "file: URI inside feImage must not appear in output; got: {html}"
5713        );
5714    }
5715
5716    #[test]
5717    fn test_svg_section_strips_feimage_with_http_href() {
5718        // feImage is dangerous regardless of URI scheme because it loads external SVG content
5719        let input = "{start_of_svg}\n<svg><feImage href=\"https://evil.example.com/spy.svg\"/></svg>\n{end_of_svg}";
5720        let html = render(input);
5721        assert!(
5722            !html.to_lowercase().contains("<feimage"),
5723            "feImage element must be stripped even with http href; got: {html}"
5724        );
5725    }
5726
5727    // --- Extended URI attribute list (#1545) ---
5728
5729    #[test]
5730    fn test_svg_section_strips_action_javascript_uri() {
5731        // action attribute carrying javascript: URI must be stripped
5732        let input =
5733            "{start_of_svg}\n<svg><a action=\"javascript:alert(1)\">click</a></svg>\n{end_of_svg}";
5734        let html = render(input);
5735        assert!(
5736            !html.contains("javascript:"),
5737            "javascript: URI in action attribute must be stripped; got: {html}"
5738        );
5739    }
5740
5741    #[test]
5742    fn test_svg_section_strips_formaction_javascript_uri() {
5743        let input = "{start_of_svg}\n<svg><a formaction=\"javascript:alert(1)\">click</a></svg>\n{end_of_svg}";
5744        let html = render(input);
5745        assert!(
5746            !html.contains("javascript:"),
5747            "javascript: URI in formaction attribute must be stripped; got: {html}"
5748        );
5749    }
5750
5751    #[test]
5752    fn test_svg_section_strips_ping_javascript_uri() {
5753        // ping attribute sends POST requests on link click
5754        let input =
5755            "{start_of_svg}\n<svg><a ping=\"javascript:alert(1)\">click</a></svg>\n{end_of_svg}";
5756        let html = render(input);
5757        assert!(
5758            !html.contains("javascript:"),
5759            "javascript: URI in ping attribute must be stripped; got: {html}"
5760        );
5761    }
5762
5763    #[test]
5764    fn test_svg_section_strips_poster_file_uri() {
5765        // poster attribute on video — blocked via file: URI scheme
5766        let input =
5767            "{start_of_svg}\n<svg><video poster=\"file:///etc/passwd\"/></svg>\n{end_of_svg}";
5768        let html = render(input);
5769        assert!(
5770            !html.contains("file:///"),
5771            "file: URI in poster attribute must be stripped; got: {html}"
5772        );
5773    }
5774
5775    #[test]
5776    fn test_svg_section_strips_background_file_uri() {
5777        // background attribute (legacy HTML body attribute)
5778        let input =
5779            "{start_of_svg}\n<svg><body background=\"file:///etc/passwd\"/></svg>\n{end_of_svg}";
5780        let html = render(input);
5781        assert!(
5782            !html.contains("file:///"),
5783            "file: URI in background attribute must be stripped; got: {html}"
5784        );
5785    }
5786
5787    // --- mhtml: URI scheme blocking (parity with is_safe_image_src) ---
5788
5789    #[test]
5790    fn test_dangerous_uri_mhtml_scheme_blocked() {
5791        // mhtml: is an IE-era MIME HTML scheme; blocked by is_safe_image_src via allowlist.
5792        assert!(
5793            has_dangerous_uri_scheme("mhtml:file://C:/page.mhtml"),
5794            "mhtml: URI scheme must be detected as dangerous"
5795        );
5796        assert!(
5797            has_dangerous_uri_scheme("MHTML:file://C:/page.mhtml"),
5798            "MHTML: (uppercase) must be detected as dangerous"
5799        );
5800    }
5801
5802    // --- SVG <image> element stripping ---
5803
5804    #[test]
5805    fn test_svg_section_strips_image_element() {
5806        // SVG <image> can load external raster/vector content and is not needed
5807        // in music notation SVG.
5808        let input =
5809            "{start_of_svg}\n<svg><image href=\"https://evil.com/spy.png\"/></svg>\n{end_of_svg}";
5810        let html = render(input);
5811        assert!(
5812            !html.to_lowercase().contains("<image"),
5813            "SVG <image> element must be stripped entirely; got: {html}"
5814        );
5815    }
5816
5817    // --- Font size clamping (renderer parity with PDF) ---
5818
5819    #[test]
5820    fn test_extreme_textsize_is_clamped_to_max() {
5821        // Font size must be clamped to MAX_FONT_SIZE (200), not 99999.
5822        // Matches the equivalent test in the PDF renderer.
5823        let input = "{title: T}\n{textsize: 99999}\n[C]Hello";
5824        let html = render(input);
5825        assert!(
5826            !html.contains("99999"),
5827            "extreme textsize should be clamped, not passed through"
5828        );
5829        assert!(
5830            html.contains("200"),
5831            "extreme textsize should be clamped to MAX_FONT_SIZE (200)"
5832        );
5833    }
5834
5835    #[test]
5836    fn test_negative_textsize_is_clamped_to_min() {
5837        // Negative size must be clamped to MIN_FONT_SIZE (0.5).
5838        // Matches the equivalent test in the PDF renderer.
5839        let input = "{title: T}\n{textsize: -10}\n[C]Hello";
5840        let html = render(input);
5841        assert!(
5842            html.contains("0.5"),
5843            "negative textsize should be clamped to MIN_FONT_SIZE (0.5)"
5844        );
5845    }
5846
5847    #[test]
5848    fn test_extreme_chordsize_is_clamped_to_max() {
5849        let input = "{title: T}\n{chordsize: 50000}\n[C]Hello";
5850        let html = render(input);
5851        assert!(
5852            !html.contains("50000"),
5853            "extreme chordsize should be clamped"
5854        );
5855        assert!(
5856            html.contains("200"),
5857            "extreme chordsize should be clamped to MAX_FONT_SIZE (200)"
5858        );
5859    }
5860}