Skip to main content

chordsketch_render_html/
lib.rs

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