Skip to main content

chordsketch_render_html/
lib.rs

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