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