Skip to main content

chordsketch_render_html/
lib.rs

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