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