1use std::fmt::Write;
23
24use chordsketch_chordpro::ast::{CommentStyle, DirectiveKind, Line, LyricsLine, Song};
25use chordsketch_chordpro::canonical_chord_name;
26use chordsketch_chordpro::config::Config;
27use chordsketch_chordpro::escape::escape_xml as escape;
28use chordsketch_chordpro::inline_markup::{SpanAttributes, TextSpan};
29use chordsketch_chordpro::render_result::{
30 RenderResult, push_warning, validate_capo, validate_strict_key,
31};
32use chordsketch_chordpro::resolve_diagrams_instrument;
33use chordsketch_chordpro::transpose::transpose_chord;
34
35const MAX_CHORUS_RECALLS: usize = 1000;
38
39pub use chordsketch_chordpro::render_result::MAX_WARNINGS;
44
45const MAX_COLUMNS: u32 = 32;
48
49const MIN_FONT_SIZE: f32 = 0.5;
52const MAX_FONT_SIZE: f32 = 200.0;
55
56#[derive(Default, Clone)]
66struct ElementStyle {
67 font: Option<String>,
68 size: Option<String>,
69 colour: Option<String>,
70}
71
72impl ElementStyle {
73 fn to_css(&self) -> String {
78 let mut css = String::new();
79 if let Some(ref font) = self.font {
80 let _ = write!(css, "font-family: {};", sanitize_css_value(font));
81 }
82 if let Some(ref size) = self.size {
83 let safe = sanitize_css_value(size);
84 if safe.chars().all(|c| c.is_ascii_digit()) {
85 let _ = write!(css, "font-size: {safe}pt;");
86 } else {
87 let _ = write!(css, "font-size: {safe};");
88 }
89 }
90 if let Some(ref colour) = self.colour {
91 let _ = write!(css, "color: {};", sanitize_css_value(colour));
92 }
93 css
94 }
95}
96
97#[derive(Default, Clone)]
99struct FormattingState {
100 text: ElementStyle,
101 chord: ElementStyle,
102 tab: ElementStyle,
103 title: ElementStyle,
104 chorus: ElementStyle,
105 label: ElementStyle,
106 grid: ElementStyle,
107}
108
109impl FormattingState {
110 fn apply(&mut self, kind: &DirectiveKind, value: &Option<String>) {
116 let val = value.clone();
117 let clamped_size = || -> Option<String> {
118 value
119 .as_deref()
120 .and_then(|v| v.parse::<f32>().ok())
121 .map(|s| s.clamp(MIN_FONT_SIZE, MAX_FONT_SIZE).to_string())
122 };
123 match kind {
124 DirectiveKind::TextFont => self.text.font = val,
125 DirectiveKind::TextSize => self.text.size = clamped_size(),
126 DirectiveKind::TextColour => self.text.colour = val,
127 DirectiveKind::ChordFont => self.chord.font = val,
128 DirectiveKind::ChordSize => self.chord.size = clamped_size(),
129 DirectiveKind::ChordColour => self.chord.colour = val,
130 DirectiveKind::TabFont => self.tab.font = val,
131 DirectiveKind::TabSize => self.tab.size = clamped_size(),
132 DirectiveKind::TabColour => self.tab.colour = val,
133 DirectiveKind::TitleFont => self.title.font = val,
134 DirectiveKind::TitleSize => self.title.size = clamped_size(),
135 DirectiveKind::TitleColour => self.title.colour = val,
136 DirectiveKind::ChorusFont => self.chorus.font = val,
137 DirectiveKind::ChorusSize => self.chorus.size = clamped_size(),
138 DirectiveKind::ChorusColour => self.chorus.colour = val,
139 DirectiveKind::LabelFont => self.label.font = val,
140 DirectiveKind::LabelSize => self.label.size = clamped_size(),
141 DirectiveKind::LabelColour => self.label.colour = val,
142 DirectiveKind::GridFont => self.grid.font = val,
143 DirectiveKind::GridSize => self.grid.size = clamped_size(),
144 DirectiveKind::GridColour => self.grid.colour = val,
145 _ => {}
147 }
148 }
149}
150
151#[must_use]
160pub fn render_song(song: &Song) -> String {
161 render_song_with_transpose(song, 0, &Config::defaults())
162}
163
164#[must_use]
172pub fn render_song_with_transpose(song: &Song, cli_transpose: i8, config: &Config) -> String {
173 let result = render_song_with_warnings(song, cli_transpose, config);
174 for w in &result.warnings {
175 eprintln!("warning: {w}");
176 }
177 result.output
178}
179
180#[must_use = "caller must check warnings in the returned RenderResult"]
186pub fn render_song_with_warnings(
187 song: &Song,
188 cli_transpose: i8,
189 config: &Config,
190) -> RenderResult<String> {
191 let mut warnings = Vec::new();
192 let title = song.metadata.title.as_deref().unwrap_or("Untitled");
193 let mut html = String::new();
194 let _ = write!(
195 html,
196 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>{}</title>\n",
197 escape(title)
198 );
199 html.push_str("<style>\n");
200 html.push_str(&css_for_wraplines(read_wraplines(config)));
201 html.push_str("</style>\n</head>\n<body>\n");
202 render_song_body_into(song, cli_transpose, config, &mut html, &mut warnings);
203 html.push_str("</body>\n</html>\n");
204 RenderResult::with_warnings(html, warnings)
205}
206
207fn render_song_body_into(
213 song: &Song,
214 cli_transpose: i8,
215 config: &Config,
216 html: &mut String,
217 warnings: &mut Vec<String>,
218) {
219 let song_overrides = song.config_overrides();
221 let song_config;
222 let config = if song_overrides.is_empty() {
223 config
224 } else {
225 song_config = config
226 .clone()
227 .with_song_overrides(&song_overrides, warnings);
228 &song_config
229 };
230 let song_transpose_delta = Config::song_transpose_delta(&song_overrides);
233 let (combined_transpose, _) =
234 chordsketch_chordpro::transpose::combine_transpose(cli_transpose, song_transpose_delta);
235 let mut transpose_offset: i8 = combined_transpose;
236 let mut fmt_state = FormattingState::default();
237 html.push_str("<div class=\"song\">\n");
238
239 validate_capo(&song.metadata, warnings);
240 validate_strict_key(&song.metadata, config, warnings);
241 render_metadata(&song.metadata, html);
242
243 let mut columns_open = false;
245 let mut svg_buf: Option<String> = None;
248 let mut abc2svg_resolved: Option<bool> = config.get_path("delegates.abc2svg").as_bool();
253 let mut lilypond_resolved: Option<bool> = config.get_path("delegates.lilypond").as_bool();
254 let mut musescore_resolved: Option<bool> = config.get_path("delegates.musescore").as_bool();
255 let mut abc_buf: Option<String> = None;
256 let mut abc_label: Option<String> = None;
257 let mut ly_buf: Option<String> = None;
258 let mut ly_label: Option<String> = None;
259 let mut musicxml_buf: Option<String> = None;
260 let mut musicxml_label: Option<String> = None;
261
262 let mut show_diagrams = true;
264
265 let diagram_frets = config.get_path("diagrams.frets").as_f64().map_or(
267 chordsketch_chordpro::chord_diagram::DEFAULT_FRETS_SHOWN,
268 |n| (n as usize).max(1),
269 );
270
271 let default_instrument = config
275 .get_path("diagrams.instrument")
276 .as_str()
277 .map(str::to_ascii_lowercase)
278 .unwrap_or_else(|| "guitar".to_string());
279 let mut auto_diagrams_instrument: Option<String> = None;
280 let mut inline_defined: std::collections::HashSet<String> = std::collections::HashSet::new();
284
285 let mut chorus_body: Vec<Line> = Vec::new();
288 let mut chorus_buf: Option<Vec<Line>> = None;
290 let mut saved_fmt_state: Option<FormattingState> = None;
293 let mut chorus_recall_count: usize = 0;
294
295 for line in &song.lines {
296 match line {
297 Line::Lyrics(lyrics_line) => {
298 if let Some(ref mut buf) = svg_buf {
299 let raw = lyrics_line.text();
303 buf.push_str(&raw);
304 buf.push('\n');
305 } else if let Some(ref mut buf) = abc_buf {
306 let raw = lyrics_line.text();
308 buf.push_str(&raw);
309 buf.push('\n');
310 } else if let Some(ref mut buf) = ly_buf {
311 let raw = lyrics_line.text();
313 buf.push_str(&raw);
314 buf.push('\n');
315 } else if let Some(ref mut buf) = musicxml_buf {
316 let raw = lyrics_line.text();
318 buf.push_str(&raw);
319 buf.push('\n');
320 } else {
321 if let Some(buf) = chorus_buf.as_mut() {
322 buf.push(line.clone());
323 }
324 render_lyrics(lyrics_line, transpose_offset, &fmt_state, html);
325 }
326 }
327 Line::Directive(directive) => {
328 if directive.kind.is_metadata() {
329 continue;
330 }
331 if directive.kind == DirectiveKind::Diagrams {
332 auto_diagrams_instrument = resolve_diagrams_instrument(
333 directive.value.as_deref(),
334 &default_instrument,
335 );
336 show_diagrams = auto_diagrams_instrument.is_some();
337 continue;
338 }
339 if directive.kind == DirectiveKind::NoDiagrams {
340 show_diagrams = false;
341 auto_diagrams_instrument = None;
342 continue;
343 }
344 if directive.kind == DirectiveKind::Transpose {
345 let file_offset: i8 = match directive.value.as_deref() {
348 None | Some("") => 0,
349 Some(raw) => match raw.parse() {
350 Ok(v) => v,
351 Err(_) => {
352 push_warning(
353 warnings,
354 format!(
355 "{{transpose}} value {raw:?} cannot be \
356 parsed as i8, ignored (using 0)"
357 ),
358 );
359 0
360 }
361 },
362 };
363 let (combined, saturated) = chordsketch_chordpro::transpose::combine_transpose(
364 file_offset,
365 cli_transpose,
366 );
367 if saturated {
368 push_warning(
369 warnings,
370 format!(
371 "transpose offset {file_offset} + {cli_transpose} \
372 exceeds i8 range, clamped to {combined}"
373 ),
374 );
375 }
376 transpose_offset = combined;
377 continue;
378 }
379 if directive.kind.is_font_size_color() {
380 if let Some(buf) = chorus_buf.as_mut() {
381 buf.push(line.clone());
382 }
383 fmt_state.apply(&directive.kind, &directive.value);
384 continue;
385 }
386 match &directive.kind {
387 DirectiveKind::StartOfChorus => {
388 render_section_open("chorus", "Chorus", &directive.value, html);
389 chorus_buf = Some(Vec::new());
390 saved_fmt_state = Some(fmt_state.clone());
393 }
394 DirectiveKind::EndOfChorus => {
395 html.push_str("</section>\n");
396 if let Some(buf) = chorus_buf.take() {
397 chorus_body = buf;
398 }
399 if let Some(saved) = saved_fmt_state.take() {
401 fmt_state = saved;
402 }
403 }
404 DirectiveKind::Chorus => {
405 if chorus_recall_count < MAX_CHORUS_RECALLS {
406 render_chorus_recall(
407 &directive.value,
408 &chorus_body,
409 transpose_offset,
410 &fmt_state,
411 show_diagrams,
412 diagram_frets,
413 html,
414 );
415 chorus_recall_count += 1;
416 } else if chorus_recall_count == MAX_CHORUS_RECALLS {
417 push_warning(
418 warnings,
419 format!(
420 "chorus recall limit ({MAX_CHORUS_RECALLS}) reached, \
421 further recalls suppressed"
422 ),
423 );
424 chorus_recall_count += 1;
425 }
426 }
427 DirectiveKind::Columns => {
428 let n: u32 = directive
432 .value
433 .as_deref()
434 .and_then(|v| v.trim().parse().ok())
435 .unwrap_or(1)
436 .clamp(1, MAX_COLUMNS);
437 if columns_open {
438 html.push_str("</div>\n");
439 columns_open = false;
440 }
441 if n > 1 {
442 let _ = writeln!(
443 html,
444 "<div style=\"column-count: {n};column-gap: 2em;\">"
445 );
446 columns_open = true;
447 }
448 }
449 DirectiveKind::ColumnBreak => {
455 html.push_str("<div style=\"break-before: column;\"></div>\n");
456 }
457 DirectiveKind::NewPage => {
458 html.push_str("<div style=\"break-before: page;\"></div>\n");
459 }
460 DirectiveKind::NewPhysicalPage => {
461 html.push_str("<div style=\"break-before: recto;\"></div>\n");
465 }
466 DirectiveKind::StartOfAbc => {
467 #[cfg(not(target_arch = "wasm32"))]
468 let enabled = *abc2svg_resolved
469 .get_or_insert_with(chordsketch_chordpro::external_tool::has_abc2svg);
470 #[cfg(target_arch = "wasm32")]
471 let enabled = *abc2svg_resolved.get_or_insert(false);
472 if enabled {
473 abc_buf = Some(String::new());
474 abc_label = directive.value.clone();
475 } else {
476 if let Some(buf) = chorus_buf.as_mut() {
477 buf.push(line.clone());
478 }
479 render_directive_inner(directive, show_diagrams, diagram_frets, html);
480 }
481 }
482 DirectiveKind::EndOfAbc if abc_buf.is_some() => {
483 if let Some(abc_content) = abc_buf.take() {
484 render_abc_with_fallback(&abc_content, &abc_label, html, warnings);
485 abc_label = None;
486 }
487 }
488 DirectiveKind::StartOfLy => {
489 #[cfg(not(target_arch = "wasm32"))]
490 let enabled = *lilypond_resolved
491 .get_or_insert_with(chordsketch_chordpro::external_tool::has_lilypond);
492 #[cfg(target_arch = "wasm32")]
493 let enabled = *lilypond_resolved.get_or_insert(false);
494 if enabled {
495 ly_buf = Some(String::new());
496 ly_label = directive.value.clone();
497 } else {
498 if let Some(buf) = chorus_buf.as_mut() {
499 buf.push(line.clone());
500 }
501 render_directive_inner(directive, show_diagrams, diagram_frets, html);
502 }
503 }
504 DirectiveKind::EndOfLy if ly_buf.is_some() => {
505 if let Some(ly_content) = ly_buf.take() {
506 render_ly_with_fallback(&ly_content, &ly_label, html, warnings);
507 ly_label = None;
508 }
509 }
510 DirectiveKind::StartOfMusicxml => {
511 #[cfg(not(target_arch = "wasm32"))]
512 let enabled = *musescore_resolved
513 .get_or_insert_with(chordsketch_chordpro::external_tool::has_musescore);
514 #[cfg(target_arch = "wasm32")]
515 let enabled = *musescore_resolved.get_or_insert(false);
516 if enabled {
517 musicxml_buf = Some(String::new());
518 musicxml_label = directive.value.clone();
519 } else {
520 if let Some(buf) = chorus_buf.as_mut() {
521 buf.push(line.clone());
522 }
523 render_directive_inner(directive, show_diagrams, diagram_frets, html);
524 }
525 }
526 DirectiveKind::EndOfMusicxml if musicxml_buf.is_some() => {
527 if let Some(musicxml_content) = musicxml_buf.take() {
528 render_musicxml_with_fallback(
529 &musicxml_content,
530 &musicxml_label,
531 html,
532 warnings,
533 );
534 musicxml_label = None;
535 }
536 }
537 DirectiveKind::StartOfSvg => {
538 svg_buf = Some(String::new());
539 }
540 DirectiveKind::EndOfSvg if svg_buf.is_some() => {
541 if let Some(svg_content) = svg_buf.take() {
542 html.push_str("<div class=\"svg-section\">\n");
543 html.push_str(&sanitize_svg_content(&svg_content));
544 html.push('\n');
545 html.push_str("</div>\n");
546 }
547 }
548 _ => {
549 if let Some(buf) = chorus_buf.as_mut() {
550 buf.push(line.clone());
551 }
552 if directive.kind == DirectiveKind::Define && show_diagrams {
555 if let Some(ref val) = directive.value {
556 let name =
557 chordsketch_chordpro::ast::ChordDefinition::parse_value(val)
558 .name;
559 if !name.is_empty() {
560 inline_defined.insert(canonical_chord_name(&name));
561 }
562 }
563 }
564 render_directive_inner(directive, show_diagrams, diagram_frets, html);
565 }
566 }
567 }
568 Line::Comment(style, text) => {
569 if let Some(buf) = chorus_buf.as_mut() {
570 buf.push(line.clone());
571 }
572 render_comment(*style, text, html);
573 }
574 Line::Empty => {
575 if let Some(buf) = chorus_buf.as_mut() {
576 buf.push(line.clone());
577 }
578 html.push_str("<div class=\"empty-line\"></div>\n");
579 }
580 }
581 }
582
583 if columns_open {
585 html.push_str("</div>\n");
586 }
587
588 if let Some(ref instrument) = auto_diagrams_instrument {
590 let chord_names: Vec<String> = song
594 .used_chord_names()
595 .into_iter()
596 .filter(|name| !inline_defined.contains(&canonical_chord_name(name)))
597 .collect();
598
599 if instrument == "piano" {
600 let kbd_defines = song.keyboard_defines();
602 let voicings: Vec<_> = chord_names
603 .into_iter()
604 .filter_map(|name| {
605 chordsketch_chordpro::lookup_keyboard_voicing(&name, &kbd_defines)
606 })
607 .collect();
608 if !voicings.is_empty() {
609 html.push_str("<section class=\"chord-diagrams\">\n");
610 html.push_str("<div class=\"section-label\">Chord Diagrams</div>\n");
611 html.push_str("<div class=\"chord-diagrams-grid\">\n");
612 for voicing in &voicings {
613 html.push_str("<div class=\"chord-diagram-container\">");
614 html.push_str(&chordsketch_chordpro::chord_diagram::render_keyboard_svg(
615 voicing,
616 ));
617 html.push_str("</div>\n");
618 }
619 html.push_str("</div>\n");
620 html.push_str("</section>\n");
621 }
622 } else {
623 let defines = song.fretted_defines();
625 let diagrams: Vec<_> = chord_names
626 .into_iter()
627 .filter_map(|name| {
628 chordsketch_chordpro::lookup_diagram(&name, &defines, instrument, diagram_frets)
629 })
630 .collect();
631 if !diagrams.is_empty() {
632 html.push_str("<section class=\"chord-diagrams\">\n");
633 html.push_str("<div class=\"section-label\">Chord Diagrams</div>\n");
634 html.push_str("<div class=\"chord-diagrams-grid\">\n");
635 for diagram in &diagrams {
636 html.push_str("<div class=\"chord-diagram-container\">");
637 html.push_str(&chordsketch_chordpro::chord_diagram::render_svg(diagram));
638 html.push_str("</div>\n");
639 }
640 html.push_str("</div>\n");
641 html.push_str("</section>\n");
642 }
643 }
644 }
645
646 html.push_str("</div>\n");
647}
648
649#[must_use]
651pub fn render_songs(songs: &[Song]) -> String {
652 render_songs_with_transpose(songs, 0, &Config::defaults())
653}
654
655#[must_use]
664pub fn render_songs_with_transpose(songs: &[Song], cli_transpose: i8, config: &Config) -> String {
665 let result = render_songs_with_warnings(songs, cli_transpose, config);
666 for w in &result.warnings {
667 eprintln!("warning: {w}");
668 }
669 result.output
670}
671
672#[must_use = "caller must check warnings in the returned RenderResult"]
679pub fn render_songs_with_warnings(
680 songs: &[Song],
681 cli_transpose: i8,
682 config: &Config,
683) -> RenderResult<String> {
684 let mut warnings = Vec::new();
685 if songs.len() <= 1 {
686 let output = songs
687 .first()
688 .map(|s| {
689 let r = render_song_with_warnings(s, cli_transpose, config);
690 warnings = r.warnings;
691 r.output
692 })
693 .unwrap_or_default();
694 return RenderResult::with_warnings(output, warnings);
695 }
696 let mut html = String::new();
698 let title = songs
699 .first()
700 .and_then(|s| s.metadata.title.as_deref())
701 .unwrap_or("Untitled");
702 let _ = write!(
703 html,
704 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>{}</title>\n",
705 escape(title)
706 );
707 html.push_str("<style>\n");
708 html.push_str(&css_for_wraplines(read_wraplines(config)));
709 html.push_str("</style>\n</head>\n<body>\n");
710
711 for (i, song) in songs.iter().enumerate() {
712 if i > 0 {
713 html.push_str("<hr class=\"song-separator\">\n");
714 }
715 render_song_body_into(song, cli_transpose, config, &mut html, &mut warnings);
716 }
717
718 html.push_str("</body>\n</html>\n");
719 RenderResult::with_warnings(html, warnings)
720}
721
722#[must_use]
737pub fn render_song_body(song: &Song) -> String {
738 render_song_body_with_transpose(song, 0, &Config::defaults())
739}
740
741#[must_use]
748pub fn render_song_body_with_transpose(song: &Song, cli_transpose: i8, config: &Config) -> String {
749 let result = render_song_body_with_warnings(song, cli_transpose, config);
750 for w in &result.warnings {
751 eprintln!("warning: {w}");
752 }
753 result.output
754}
755
756#[must_use = "caller must check warnings in the returned RenderResult"]
762pub fn render_song_body_with_warnings(
763 song: &Song,
764 cli_transpose: i8,
765 config: &Config,
766) -> RenderResult<String> {
767 let mut warnings = Vec::new();
768 let mut html = String::new();
769 render_song_body_into(song, cli_transpose, config, &mut html, &mut warnings);
770 RenderResult::with_warnings(html, warnings)
771}
772
773#[must_use]
779pub fn render_songs_body(songs: &[Song]) -> String {
780 render_songs_body_with_transpose(songs, 0, &Config::defaults())
781}
782
783#[must_use]
790pub fn render_songs_body_with_transpose(
791 songs: &[Song],
792 cli_transpose: i8,
793 config: &Config,
794) -> String {
795 let result = render_songs_body_with_warnings(songs, cli_transpose, config);
796 for w in &result.warnings {
797 eprintln!("warning: {w}");
798 }
799 result.output
800}
801
802#[must_use = "caller must check warnings in the returned RenderResult"]
807pub fn render_songs_body_with_warnings(
808 songs: &[Song],
809 cli_transpose: i8,
810 config: &Config,
811) -> RenderResult<String> {
812 let mut warnings = Vec::new();
813 if songs.len() <= 1 {
814 let output = songs
815 .first()
816 .map(|s| {
817 let r = render_song_body_with_warnings(s, cli_transpose, config);
818 warnings = r.warnings;
819 r.output
820 })
821 .unwrap_or_default();
822 return RenderResult::with_warnings(output, warnings);
823 }
824 let mut html = String::new();
825 for (i, song) in songs.iter().enumerate() {
826 if i > 0 {
827 html.push_str("<hr class=\"song-separator\">\n");
828 }
829 render_song_body_into(song, cli_transpose, config, &mut html, &mut warnings);
830 }
831 RenderResult::with_warnings(html, warnings)
832}
833
834#[must_use]
849pub fn render_html_css() -> String {
850 css_for_wraplines(true)
851}
852
853#[must_use]
858pub fn render_html_css_with_config(config: &Config) -> String {
859 css_for_wraplines(read_wraplines(config))
860}
861
862fn read_wraplines(config: &Config) -> bool {
865 config.get_path("settings.wraplines").as_bool() != Some(false)
866}
867
868fn css_for_wraplines(wraplines: bool) -> String {
875 CSS_TEMPLATE.replace(
876 "__LINE_FLEX_WRAP__",
877 if wraplines { "wrap" } else { "nowrap" },
878 )
879}
880
881#[must_use = "parse errors should be handled"]
886pub fn try_render(input: &str) -> Result<String, chordsketch_chordpro::ParseError> {
887 let song = chordsketch_chordpro::parse(input)?;
888 Ok(render_song(&song))
889}
890
891#[must_use]
896pub fn render(input: &str) -> String {
897 match try_render(input) {
898 Ok(html) => html,
899 Err(e) => format!(
900 "<!DOCTYPE html><html><body><pre>Parse error at line {} column {}: {}</pre></body></html>\n",
901 e.line(),
902 e.column(),
903 escape(&e.message)
904 ),
905 }
906}
907
908const CSS_TEMPLATE: &str = "\
919body { font-family: serif; max-width: 800px; margin: 2em auto; padding: 0 1em; }
920h1 { margin-bottom: 0.2em; }
921h2 { margin-top: 0; font-weight: normal; color: #555; }
922.line { display: flex; flex-wrap: __LINE_FLEX_WRAP__; margin: 0.1em 0; }
923.chord-block { display: inline-flex; flex-direction: column; align-items: flex-start; }
924.chord { font-weight: bold; color: #b00; font-size: 0.9em; min-height: 1.2em; }
925.lyrics { white-space: pre; }
926.empty-line { height: 1em; }
927section { margin: 1em 0; }
928section > .section-label { font-weight: bold; font-style: italic; margin-bottom: 0.3em; }
929.comment { font-style: italic; color: #666; margin: 0.3em 0; }
930.comment-box { border: 1px solid #999; padding: 0.2em 0.5em; display: inline-block; margin: 0.3em 0; }
931.chorus-recall { margin: 1em 0; }
932.chorus-recall > .section-label { font-weight: bold; font-style: italic; margin-bottom: 0.3em; }
933img { max-width: 100%; height: auto; }
934.chord-diagrams-grid { display: flex; flex-wrap: wrap; gap: 0.5em; margin: 0.5em 0; }
935.chord-diagram-container { display: inline-block; vertical-align: top; }
936.chord-diagram { display: block; }
937";
938
939fn render_metadata(metadata: &chordsketch_chordpro::ast::Metadata, html: &mut String) {
949 if let Some(title) = &metadata.title {
950 let _ = writeln!(html, "<h1>{}</h1>", escape(title));
951 }
952 for subtitle in &metadata.subtitles {
953 let _ = writeln!(html, "<h2>{}</h2>", escape(subtitle));
954 }
955}
956
957fn render_lyrics(
967 lyrics_line: &LyricsLine,
968 transpose_offset: i8,
969 fmt_state: &FormattingState,
970 html: &mut String,
971) {
972 html.push_str("<div class=\"line\">");
973
974 for segment in &lyrics_line.segments {
975 html.push_str("<span class=\"chord-block\">");
976
977 if let Some(chord) = &segment.chord {
978 let display_name = if transpose_offset != 0 {
979 let transposed = transpose_chord(chord, transpose_offset);
980 transposed.display_name().to_string()
981 } else {
982 chord.display_name().to_string()
983 };
984 let chord_css = fmt_state.chord.to_css();
985 if chord_css.is_empty() {
986 let _ = write!(
987 html,
988 "<span class=\"chord\">{}</span>",
989 escape(&display_name)
990 );
991 } else {
992 let _ = write!(
993 html,
994 "<span class=\"chord\" style=\"{}\">{}</span>",
995 escape(&chord_css),
996 escape(&display_name)
997 );
998 }
999 } else if lyrics_line.has_chords() {
1000 html.push_str("<span class=\"chord\" aria-hidden=\"true\">\u{00A0}</span>");
1014 }
1015
1016 let text_css = fmt_state.text.to_css();
1017 if text_css.is_empty() {
1018 html.push_str("<span class=\"lyrics\">");
1019 } else {
1020 let _ = write!(
1021 html,
1022 "<span class=\"lyrics\" style=\"{}\">",
1023 escape(&text_css)
1024 );
1025 }
1026 if segment.has_markup() {
1027 render_spans(&segment.spans, html);
1028 } else {
1029 html.push_str(&escape(&segment.text));
1030 }
1031 html.push_str("</span>");
1032 html.push_str("</span>");
1033 }
1034
1035 html.push_str("</div>\n");
1036}
1037
1038fn render_spans(spans: &[TextSpan], html: &mut String) {
1047 for span in spans {
1048 match span {
1049 TextSpan::Plain(text) => html.push_str(&escape(text)),
1050 TextSpan::Bold(children) => {
1051 html.push_str("<b>");
1052 render_spans(children, html);
1053 html.push_str("</b>");
1054 }
1055 TextSpan::Italic(children) => {
1056 html.push_str("<i>");
1057 render_spans(children, html);
1058 html.push_str("</i>");
1059 }
1060 TextSpan::Highlight(children) => {
1061 html.push_str("<mark>");
1062 render_spans(children, html);
1063 html.push_str("</mark>");
1064 }
1065 TextSpan::Comment(children) => {
1066 html.push_str("<span class=\"comment\">");
1067 render_spans(children, html);
1068 html.push_str("</span>");
1069 }
1070 TextSpan::Span(attrs, children) => {
1071 let css = span_attrs_to_css(attrs);
1072 if css.is_empty() {
1073 html.push_str("<span>");
1074 } else {
1075 let _ = write!(html, "<span style=\"{}\">", escape(&css));
1076 }
1077 render_spans(children, html);
1078 html.push_str("</span>");
1079 }
1080 }
1081 }
1082}
1083
1084fn span_attrs_to_css(attrs: &SpanAttributes) -> String {
1086 let mut css = String::new();
1087 if let Some(ref font_family) = attrs.font_family {
1088 let _ = write!(css, "font-family: {};", sanitize_css_value(font_family));
1089 }
1090 if let Some(ref size) = attrs.size {
1091 let safe = sanitize_css_value(size);
1092 if safe.chars().all(|c| c.is_ascii_digit()) {
1094 let _ = write!(css, "font-size: {safe}pt;");
1095 } else {
1096 let _ = write!(css, "font-size: {safe};");
1097 }
1098 }
1099 if let Some(ref fg) = attrs.foreground {
1100 let _ = write!(css, "color: {};", sanitize_css_value(fg));
1101 }
1102 if let Some(ref bg) = attrs.background {
1103 let _ = write!(css, "background-color: {};", sanitize_css_value(bg));
1104 }
1105 if let Some(ref weight) = attrs.weight {
1106 let _ = write!(css, "font-weight: {};", sanitize_css_value(weight));
1107 }
1108 if let Some(ref style) = attrs.style {
1109 let _ = write!(css, "font-style: {};", sanitize_css_value(style));
1110 }
1111 css
1112}
1113
1114fn sanitize_css_value(s: &str) -> String {
1121 s.chars()
1122 .filter(|c| {
1123 c.is_ascii_alphanumeric() || matches!(c, '#' | '.' | '-' | ' ' | ',' | '%' | '+')
1124 })
1125 .collect()
1126}
1127
1128fn sanitize_css_class(s: &str) -> String {
1135 s.chars()
1136 .map(|c| {
1137 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
1138 c
1139 } else {
1140 '-'
1141 }
1142 })
1143 .collect()
1144}
1145
1146fn sanitize_svg_content(input: &str) -> String {
1153 const DANGEROUS_TAGS: &[&str] = &[
1162 "script",
1163 "foreignobject",
1164 "iframe",
1165 "object",
1166 "embed",
1167 "math",
1168 "feimage",
1172 "image",
1176 "set",
1177 "animate",
1178 "animatetransform",
1179 "animatemotion",
1180 ];
1181
1182 let mut result = String::with_capacity(input.len());
1183 let mut chars = input.char_indices().peekable();
1184 let bytes = input.as_bytes();
1185
1186 while let Some((i, c)) = chars.next() {
1187 if c == '<' {
1188 let rest = &input[i..];
1189 let limit = rest
1192 .char_indices()
1193 .map(|(idx, _)| idx)
1194 .find(|&idx| idx >= 30)
1195 .unwrap_or(rest.len());
1196 let rest_upper = &rest[..limit];
1197
1198 let ns_open = namespace_prefix_len(&rest.as_bytes()[1..]);
1206 let tag_start_in_rest = 1 + ns_open;
1207
1208 let mut matched = false;
1210 for tag in DANGEROUS_TAGS {
1211 let tag_end_in_rest = tag_start_in_rest + tag.len();
1212 if rest.len() > tag_end_in_rest
1213 && rest_upper.len() >= tag_end_in_rest
1214 && starts_with_ignore_case(&rest_upper[tag_start_in_rest..], tag)
1215 && bytes
1216 .get(i + tag_end_in_rest)
1217 .is_none_or(|b| b.is_ascii_whitespace() || *b == b'>' || *b == b'/')
1218 {
1219 let is_self_closing = {
1223 let tag_bytes = rest.as_bytes();
1224 let mut in_quote: Option<u8> = None;
1225 let mut gt_pos = None;
1226 for (idx, &b) in tag_bytes.iter().enumerate() {
1227 match in_quote {
1228 Some(q) if b == q => in_quote = None,
1229 Some(_) => {}
1230 None if b == b'"' || b == b'\'' => in_quote = Some(b),
1231 None if b == b'>' => {
1232 gt_pos = Some(idx);
1233 break;
1234 }
1235 _ => {}
1236 }
1237 }
1238 gt_pos.is_some_and(|gt| gt > 0 && tag_bytes[gt - 1] == b'/')
1239 };
1240
1241 if is_self_closing {
1242 let mut skip_quote: Option<char> = None;
1246 while let Some(&(_, ch)) = chars.peek() {
1247 chars.next();
1248 match skip_quote {
1249 Some(q) if ch == q => skip_quote = None,
1250 Some(_) => {}
1251 None if ch == '"' || ch == '\'' => {
1252 skip_quote = Some(ch);
1253 }
1254 None if ch == '>' => break,
1255 _ => {}
1256 }
1257 }
1258 } else if let Some(end) = find_end_tag_ignore_case(input, i, tag) {
1259 while let Some(&(j, _)) = chars.peek() {
1261 if j >= end {
1262 break;
1263 }
1264 chars.next();
1265 }
1266 } else {
1267 return result;
1269 }
1270 matched = true;
1271 break;
1272 }
1273 }
1274 if matched {
1275 continue;
1276 }
1277
1278 let ns_close = namespace_prefix_len(rest.as_bytes().get(2..).unwrap_or(&[]));
1280 let tag_start_in_close = 2 + ns_close;
1281 for tag in DANGEROUS_TAGS {
1282 let tag_end_in_close = tag_start_in_close + tag.len();
1283 if rest_upper.len() >= tag_end_in_close
1284 && rest.len() > tag_end_in_close
1285 && rest.starts_with("</")
1286 && starts_with_ignore_case(&rest_upper[tag_start_in_close..], tag)
1287 && bytes
1288 .get(i + tag_end_in_close)
1289 .is_none_or(|b| b.is_ascii_whitespace() || *b == b'>')
1290 {
1291 while let Some(&(_, ch)) = chars.peek() {
1293 chars.next();
1294 if ch == '>' {
1295 break;
1296 }
1297 }
1298 matched = true;
1299 break;
1300 }
1301 }
1302 if matched {
1303 continue;
1304 }
1305
1306 result.push(c);
1307 } else {
1308 result.push(c);
1309 }
1310 }
1311
1312 strip_dangerous_attrs(&result)
1314}
1315
1316fn starts_with_ignore_case(s: &str, prefix: &str) -> bool {
1318 if s.len() < prefix.len() {
1319 return false;
1320 }
1321 s.as_bytes()[..prefix.len()]
1322 .iter()
1323 .zip(prefix.as_bytes())
1324 .all(|(a, b)| a.eq_ignore_ascii_case(b))
1325}
1326
1327fn is_invisible_format_char(c: char) -> bool {
1332 matches!(
1333 c,
1334 '\u{00AD}' | '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{200E}' | '\u{200F}' | '\u{2060}' | '\u{FEFF}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' )
1345}
1346
1347fn namespace_prefix_len(bytes: &[u8]) -> usize {
1352 let mut idx = 0;
1353 match bytes.first() {
1354 Some(b) if b.is_ascii_alphabetic() => idx += 1,
1355 _ => return 0,
1356 }
1357 while let Some(&b) = bytes.get(idx) {
1361 if b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.' {
1362 idx += 1;
1363 } else {
1364 break;
1365 }
1366 }
1367 if bytes.get(idx) == Some(&b':') {
1368 idx + 1
1369 } else {
1370 0
1371 }
1372}
1373
1374fn find_end_tag_ignore_case(input: &str, start: usize, tag: &str) -> Option<usize> {
1381 let search = &input.as_bytes()[start..];
1382 let tag_bytes = tag.as_bytes();
1383
1384 for i in 0..search.len() {
1385 if search[i] == b'<' && search.get(i + 1) == Some(&b'/') {
1386 let after_slash = &search[i + 2..];
1387 let ns = namespace_prefix_len(after_slash);
1388 let tag_end = ns + tag_bytes.len();
1389 if after_slash.len() >= tag_end {
1390 let candidate = &after_slash[ns..tag_end];
1391 if candidate
1392 .iter()
1393 .zip(tag_bytes)
1394 .all(|(a, b)| a.eq_ignore_ascii_case(b))
1395 {
1396 if let Some(gt) = after_slash[tag_end..].iter().position(|&b| b == b'>') {
1398 return Some(start + i + 2 + tag_end + gt + 1);
1399 }
1400 }
1401 }
1402 }
1403 }
1404 None
1405}
1406
1407fn strip_dangerous_attrs(input: &str) -> String {
1412 let mut result = String::with_capacity(input.len());
1413 let bytes = input.as_bytes();
1414 let mut pos = 0;
1415
1416 while pos < bytes.len() {
1417 if bytes[pos] == b'<' && pos + 1 < bytes.len() && bytes[pos + 1] != b'/' {
1418 if let Some(gt) = find_tag_end(&bytes[pos..]) {
1422 let tag_end = pos + gt + 1;
1423 let tag_content = &input[pos..tag_end];
1424 result.push_str(&sanitize_tag_attrs(tag_content));
1425 pos = tag_end;
1426 } else {
1427 result.push_str(&input[pos..]);
1428 break;
1429 }
1430 } else {
1431 debug_assert!(
1434 input.is_char_boundary(pos),
1435 "pos must land on a char boundary; advancing by c.len_utf8() is the invariant"
1436 );
1437 let ch = &input[pos..];
1438 let c = ch
1439 .chars()
1440 .next()
1441 .expect("pos is on a char boundary and within bounds");
1442 result.push(c);
1443 pos += c.len_utf8();
1444 }
1445 }
1446 result
1447}
1448
1449fn find_tag_end(bytes: &[u8]) -> Option<usize> {
1452 let mut i = 0;
1453 let mut in_quote: Option<u8> = None;
1454 while i < bytes.len() {
1455 let b = bytes[i];
1456 if let Some(q) = in_quote {
1457 if b == q {
1458 in_quote = None;
1459 }
1460 } else if b == b'"' || b == b'\'' {
1461 in_quote = Some(b);
1462 } else if b == b'>' {
1463 return Some(i);
1464 }
1465 i += 1;
1466 }
1467 None
1468}
1469
1470fn has_dangerous_uri_scheme(value: &str) -> bool {
1473 let lower: String = value
1482 .trim_start()
1483 .chars()
1484 .filter(|&c| {
1485 !c.is_ascii_whitespace() && !c.is_ascii_control() && !is_invisible_format_char(c)
1486 })
1487 .take(30)
1488 .flat_map(|c| c.to_lowercase())
1489 .collect();
1490 lower.starts_with("javascript:")
1497 || lower.starts_with("vbscript:")
1498 || lower.starts_with("data:")
1499 || lower.starts_with("file:")
1500 || lower.starts_with("blob:")
1501 || lower.starts_with("mhtml:")
1502}
1503
1504fn is_uri_attr(name: &str) -> bool {
1511 let lower: String = name.chars().flat_map(|c| c.to_lowercase()).collect();
1512 lower == "href"
1513 || lower == "src"
1514 || lower == "xlink:href"
1515 || lower == "to"
1517 || lower == "values"
1518 || lower == "from"
1519 || lower == "by"
1520 || lower == "action"
1522 || lower == "formaction"
1523 || lower == "poster"
1525 || lower == "background"
1526 || lower == "ping"
1528}
1529
1530fn sanitize_tag_attrs(tag: &str) -> String {
1541 let mut result = String::with_capacity(tag.len());
1542 let bytes = tag.as_bytes();
1543 let mut i = 0;
1544
1545 while i < bytes.len() && bytes[i] != b' ' && bytes[i] != b'>' && bytes[i] != b'/' {
1547 result.push(bytes[i] as char);
1548 i += 1;
1549 }
1550
1551 let tag_name = &result[1..];
1554 let is_use_tag =
1555 tag_name.eq_ignore_ascii_case("use") || tag_name.eq_ignore_ascii_case("svg:use");
1556
1557 while i < bytes.len() {
1558 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
1560 result.push(bytes[i] as char);
1561 i += 1;
1562 }
1563
1564 if i >= bytes.len() || bytes[i] == b'>' || bytes[i] == b'/' {
1565 result.push_str(&tag[i..]);
1566 return result;
1567 }
1568
1569 let attr_start = i;
1571 while i < bytes.len()
1572 && bytes[i] != b'='
1573 && bytes[i] != b' '
1574 && bytes[i] != b'>'
1575 && bytes[i] != b'/'
1576 {
1577 i += 1;
1578 }
1579 let attr_name = &tag[attr_start..i];
1580
1581 let is_event_handler = attr_name.len() > 2
1582 && attr_name.as_bytes()[..2].eq_ignore_ascii_case(b"on")
1583 && attr_name.as_bytes()[2].is_ascii_alphabetic();
1584
1585 let value_start = i;
1587 let mut attr_value: Option<String> = None;
1588 if i < bytes.len() && bytes[i] == b'=' {
1589 i += 1; if i < bytes.len() && (bytes[i] == b'"' || bytes[i] == b'\'') {
1591 let quote = bytes[i];
1592 i += 1;
1593 let val_start = i;
1594 while i < bytes.len() && bytes[i] != quote {
1595 i += 1;
1596 }
1597 attr_value = Some(tag[val_start..i].to_string());
1598 if i < bytes.len() {
1599 i += 1; }
1601 } else {
1602 let val_start = i;
1604 while i < bytes.len() && !bytes[i].is_ascii_whitespace() && bytes[i] != b'>' {
1605 i += 1;
1606 }
1607 attr_value = Some(tag[val_start..i].to_string());
1608 }
1609 }
1610
1611 if is_event_handler {
1612 continue;
1614 }
1615
1616 if is_uri_attr(attr_name) {
1617 if let Some(ref val) = attr_value {
1618 if has_dangerous_uri_scheme(val) {
1619 continue;
1621 }
1622 if is_use_tag
1629 && (attr_name.eq_ignore_ascii_case("href")
1630 || attr_name.eq_ignore_ascii_case("xlink:href"))
1631 && !val.trim_start().starts_with('#')
1632 {
1633 continue;
1634 }
1635 }
1636 }
1637
1638 if attr_name.eq_ignore_ascii_case("style") {
1641 if let Some(ref val) = attr_value {
1642 let lower_val: String = val.chars().flat_map(|c| c.to_lowercase()).collect();
1643 if lower_val.contains("url(")
1644 || lower_val.contains("expression(")
1645 || lower_val.contains("@import")
1646 {
1647 continue;
1648 }
1649 }
1650 }
1651
1652 result.push_str(&tag[attr_start..value_start]);
1654 if attr_value.is_some() {
1655 result.push_str(&tag[value_start..i]);
1656 }
1657 }
1658
1659 result
1660}
1661
1662fn render_directive_inner(
1671 directive: &chordsketch_chordpro::ast::Directive,
1672 show_diagrams: bool,
1673 diagram_frets: usize,
1674 html: &mut String,
1675) {
1676 match &directive.kind {
1677 DirectiveKind::StartOfChorus => {
1678 render_section_open("chorus", "Chorus", &directive.value, html);
1679 }
1680 DirectiveKind::StartOfVerse => {
1681 render_section_open("verse", "Verse", &directive.value, html);
1682 }
1683 DirectiveKind::StartOfBridge => {
1684 render_section_open("bridge", "Bridge", &directive.value, html);
1685 }
1686 DirectiveKind::StartOfTab => {
1687 render_section_open("tab", "Tab", &directive.value, html);
1688 }
1689 DirectiveKind::StartOfGrid => {
1690 render_section_open("grid", "Grid", &directive.value, html);
1691 }
1692 DirectiveKind::StartOfAbc => {
1693 render_section_open("abc", "ABC", &directive.value, html);
1694 }
1695 DirectiveKind::StartOfLy => {
1696 render_section_open("ly", "Lilypond", &directive.value, html);
1697 }
1698 DirectiveKind::StartOfTextblock => {
1701 render_section_open("textblock", "Textblock", &directive.value, html);
1702 }
1703 DirectiveKind::StartOfMusicxml => {
1704 render_section_open("musicxml", "MusicXML", &directive.value, html);
1705 }
1706 DirectiveKind::StartOfSection(section_name) => {
1707 let class = format!("section-{}", sanitize_css_class(section_name));
1708 let label = escape(&chordsketch_chordpro::capitalize(section_name));
1709 render_section_open(&class, &label, &directive.value, html);
1710 }
1711 DirectiveKind::EndOfChorus
1712 | DirectiveKind::EndOfVerse
1713 | DirectiveKind::EndOfBridge
1714 | DirectiveKind::EndOfTab
1715 | DirectiveKind::EndOfGrid
1716 | DirectiveKind::EndOfAbc
1717 | DirectiveKind::EndOfLy
1718 | DirectiveKind::EndOfMusicxml
1719 | DirectiveKind::EndOfSvg
1720 | DirectiveKind::EndOfTextblock
1721 | DirectiveKind::EndOfSection(_) => {
1722 html.push_str("</section>\n");
1723 }
1724 DirectiveKind::Image(attrs) => {
1725 render_image(attrs, html);
1726 }
1727 DirectiveKind::Define if show_diagrams => {
1728 if let Some(ref value) = directive.value {
1729 let def = chordsketch_chordpro::ast::ChordDefinition::parse_value(value);
1730 if let Some(ref keys_raw) = def.keys {
1732 let keys_u8: Vec<u8> = keys_raw
1733 .iter()
1734 .filter_map(|&k| {
1735 if (0i32..=127).contains(&k) {
1736 Some(k as u8)
1737 } else {
1738 None
1739 }
1740 })
1741 .collect();
1742 if !keys_u8.is_empty() {
1743 let root = keys_u8[0];
1744 let voicing = chordsketch_chordpro::chord_diagram::KeyboardVoicing {
1745 name: def.name.clone(),
1746 display_name: def.display.clone(),
1747 keys: keys_u8,
1748 root_key: root,
1749 };
1750 html.push_str("<div class=\"chord-diagram-container\">");
1751 html.push_str(&chordsketch_chordpro::chord_diagram::render_keyboard_svg(
1752 &voicing,
1753 ));
1754 html.push_str("</div>\n");
1755 }
1756 } else if let Some(ref raw) = def.raw {
1757 if let Some(mut diagram) =
1759 chordsketch_chordpro::chord_diagram::DiagramData::from_raw_infer_frets(
1760 &def.name,
1761 raw,
1762 diagram_frets,
1763 )
1764 {
1765 diagram.display_name = def.display.clone();
1766 html.push_str("<div class=\"chord-diagram-container\">");
1767 html.push_str(&chordsketch_chordpro::chord_diagram::render_svg(&diagram));
1768 html.push_str("</div>\n");
1769 }
1770 }
1771 }
1772 }
1773 DirectiveKind::Define => {}
1774 _ => {}
1775 }
1776}
1777
1778#[cfg(not(target_arch = "wasm32"))]
1784fn render_abc_with_fallback(
1785 abc_content: &str,
1786 label: &Option<String>,
1787 html: &mut String,
1788 warnings: &mut Vec<String>,
1789) {
1790 match chordsketch_chordpro::external_tool::invoke_abc2svg(abc_content) {
1791 Ok(svg_fragment) => {
1792 render_section_open("abc", "ABC", label, html);
1793 html.push_str(&sanitize_svg_content(&svg_fragment));
1794 html.push('\n');
1795 html.push_str("</section>\n");
1796 }
1797 Err(e) => {
1798 push_warning(warnings, format!("abc2svg invocation failed: {e}"));
1799 render_section_open("abc", "ABC", label, html);
1800 html.push_str("<pre>");
1801 html.push_str(&escape(abc_content));
1802 html.push_str("</pre>\n");
1803 html.push_str("</section>\n");
1804 }
1805 }
1806}
1807
1808#[cfg(target_arch = "wasm32")]
1812fn render_abc_with_fallback(
1813 abc_content: &str,
1814 label: &Option<String>,
1815 html: &mut String,
1816 _warnings: &mut Vec<String>,
1817) {
1818 render_section_open("abc", "ABC", label, html);
1819 html.push_str("<pre>");
1820 html.push_str(&escape(abc_content));
1821 html.push_str("</pre>\n");
1822 html.push_str("</section>\n");
1823}
1824
1825use chordsketch_chordpro::image_path::is_safe_image_src;
1833
1834#[cfg(not(target_arch = "wasm32"))]
1840fn render_ly_with_fallback(
1841 ly_content: &str,
1842 label: &Option<String>,
1843 html: &mut String,
1844 warnings: &mut Vec<String>,
1845) {
1846 match chordsketch_chordpro::external_tool::invoke_lilypond(ly_content) {
1847 Ok(svg) => {
1848 render_section_open("ly", "Lilypond", label, html);
1849 html.push_str(&sanitize_svg_content(&svg));
1850 html.push('\n');
1851 html.push_str("</section>\n");
1852 }
1853 Err(e) => {
1854 push_warning(warnings, format!("lilypond invocation failed: {e}"));
1855 render_section_open("ly", "Lilypond", label, html);
1856 html.push_str("<pre>");
1857 html.push_str(&escape(ly_content));
1858 html.push_str("</pre>\n");
1859 html.push_str("</section>\n");
1860 }
1861 }
1862}
1863
1864#[cfg(target_arch = "wasm32")]
1868fn render_ly_with_fallback(
1869 ly_content: &str,
1870 label: &Option<String>,
1871 html: &mut String,
1872 _warnings: &mut Vec<String>,
1873) {
1874 render_section_open("ly", "Lilypond", label, html);
1875 html.push_str("<pre>");
1876 html.push_str(&escape(ly_content));
1877 html.push_str("</pre>\n");
1878 html.push_str("</section>\n");
1879}
1880
1881#[cfg(not(target_arch = "wasm32"))]
1887fn render_musicxml_with_fallback(
1888 musicxml_content: &str,
1889 label: &Option<String>,
1890 html: &mut String,
1891 warnings: &mut Vec<String>,
1892) {
1893 match chordsketch_chordpro::external_tool::invoke_musescore(musicxml_content) {
1894 Ok(svg) => {
1895 render_section_open("musicxml", "MusicXML", label, html);
1896 html.push_str(&sanitize_svg_content(&svg));
1897 html.push('\n');
1898 html.push_str("</section>\n");
1899 }
1900 Err(e) => {
1901 push_warning(warnings, format!("musescore invocation failed: {e}"));
1902 render_section_open("musicxml", "MusicXML", label, html);
1903 html.push_str("<pre>");
1904 html.push_str(&escape(musicxml_content));
1905 html.push_str("</pre>\n");
1906 html.push_str("</section>\n");
1907 }
1908 }
1909}
1910
1911#[cfg(target_arch = "wasm32")]
1915fn render_musicxml_with_fallback(
1916 musicxml_content: &str,
1917 label: &Option<String>,
1918 html: &mut String,
1919 _warnings: &mut Vec<String>,
1920) {
1921 render_section_open("musicxml", "MusicXML", label, html);
1922 html.push_str("<pre>");
1923 html.push_str(&escape(musicxml_content));
1924 html.push_str("</pre>\n");
1925 html.push_str("</section>\n");
1926}
1927
1928fn render_image(attrs: &chordsketch_chordpro::ast::ImageAttributes, html: &mut String) {
1937 if !is_safe_image_src(&attrs.src) {
1938 return;
1939 }
1940
1941 let mut style = String::new();
1942 let mut img_attrs = format!("src=\"{}\"", escape(&attrs.src));
1943
1944 if let Some(ref title) = attrs.title {
1945 let _ = write!(img_attrs, " alt=\"{}\"", escape(title));
1946 }
1947
1948 if let Some(ref width) = attrs.width {
1949 let _ = write!(img_attrs, " width=\"{}\"", escape(width));
1950 }
1951 if let Some(ref height) = attrs.height {
1952 let _ = write!(img_attrs, " height=\"{}\"", escape(height));
1953 }
1954 if let Some(ref scale) = attrs.scale {
1955 let _ = write!(
1957 style,
1958 "transform: scale({});transform-origin: top left;",
1959 sanitize_css_value(scale)
1960 );
1961 }
1962
1963 let align_css = match attrs.anchor.as_deref() {
1965 Some("column") | Some("paper") => "text-align: center;",
1966 _ => "",
1967 };
1968
1969 if !align_css.is_empty() {
1970 let _ = write!(html, "<div style=\"{align_css}\">");
1971 } else {
1972 html.push_str("<div>");
1973 }
1974
1975 let _ = write!(html, "<img {img_attrs}");
1976 if !style.is_empty() {
1977 let _ = write!(html, " style=\"{}\"", escape(&style));
1983 }
1984 html.push_str("></div>\n");
1985}
1986
1987fn render_section_open(class: &str, label: &str, value: &Option<String>, html: &mut String) {
1989 let safe_class = sanitize_css_class(class);
1990 let _ = writeln!(html, "<section class=\"{safe_class}\">");
1991 let display_label = match value {
1992 Some(v) if !v.is_empty() => format!("{label}: {}", escape(v)),
1993 _ => label.to_string(),
1994 };
1995 let _ = writeln!(html, "<div class=\"section-label\">{display_label}</div>");
1996}
1997
1998fn render_chorus_recall(
2004 value: &Option<String>,
2005 chorus_body: &[Line],
2006 transpose_offset: i8,
2007 fmt_state: &FormattingState,
2008 show_diagrams: bool,
2009 diagram_frets: usize,
2010 html: &mut String,
2011) {
2012 html.push_str("<div class=\"chorus-recall\">\n");
2013 let display_label = match value {
2014 Some(v) if !v.is_empty() => format!("Chorus: {}", escape(v)),
2015 _ => "Chorus".to_string(),
2016 };
2017 let _ = writeln!(html, "<div class=\"section-label\">{display_label}</div>");
2018 let mut local_fmt = fmt_state.clone();
2022 for line in chorus_body {
2023 match line {
2024 Line::Lyrics(lyrics) => render_lyrics(lyrics, transpose_offset, &local_fmt, html),
2025 Line::Comment(style, text) => render_comment(*style, text, html),
2026 Line::Empty => html.push_str("<div class=\"empty-line\"></div>\n"),
2027 Line::Directive(d) if d.kind.is_font_size_color() => {
2028 local_fmt.apply(&d.kind, &d.value);
2029 }
2030 Line::Directive(d) if !d.kind.is_metadata() => {
2031 render_directive_inner(d, show_diagrams, diagram_frets, html);
2032 }
2033 _ => {}
2034 }
2035 }
2036 html.push_str("</div>\n");
2037}
2038
2039fn render_comment(style: CommentStyle, text: &str, html: &mut String) {
2045 match style {
2046 CommentStyle::Normal => {
2047 let _ = writeln!(html, "<p class=\"comment\">{}</p>", escape(text));
2048 }
2049 CommentStyle::Italic => {
2050 let _ = writeln!(html, "<p class=\"comment\"><em>{}</em></p>", escape(text));
2051 }
2052 CommentStyle::Boxed => {
2053 let _ = writeln!(html, "<div class=\"comment-box\">{}</div>", escape(text));
2054 }
2055 }
2056}
2057
2058#[cfg(test)]
2063mod sanitize_tag_attrs_tests {
2064 use super::*;
2065
2066 #[test]
2067 fn test_preserves_normal_attrs() {
2068 let tag = "<svg width=\"100\" height=\"50\">";
2069 assert_eq!(sanitize_tag_attrs(tag), tag);
2070 }
2071
2072 #[test]
2073 fn test_strips_event_handler() {
2074 let tag = "<svg onclick=\"alert(1)\" width=\"100\">";
2075 let result = sanitize_tag_attrs(tag);
2076 assert!(!result.contains("onclick"));
2077 assert!(result.contains("width"));
2078 }
2079
2080 #[test]
2081 fn test_non_ascii_in_attr_value_preserved() {
2082 let tag = "<text title=\"日本語テスト\" x=\"10\">";
2083 let result = sanitize_tag_attrs(tag);
2084 assert!(result.contains("日本語テスト"));
2085 assert!(result.contains("x=\"10\""));
2086 }
2087
2088 #[test]
2091 fn test_strips_mixed_case_event_handler() {
2092 let tag = "<svg OnClick=\"alert(1)\" width=\"100\">";
2093 let result = sanitize_tag_attrs(tag);
2094 assert!(!result.contains("OnClick"));
2095 assert!(result.contains("width"));
2096 }
2097
2098 #[test]
2099 fn test_strips_uppercase_event_handler() {
2100 let tag = "<svg ONLOAD=\"alert(1)\">";
2101 let result = sanitize_tag_attrs(tag);
2102 assert!(!result.contains("ONLOAD"));
2103 }
2104
2105 #[test]
2108 fn test_strips_style_with_url() {
2109 let tag =
2110 "<rect style=\"background-image: url('https://attacker.com/exfil')\" width=\"10\">";
2111 let result = sanitize_tag_attrs(tag);
2112 assert!(!result.contains("style"));
2113 assert!(result.contains("width"));
2114 }
2115
2116 #[test]
2117 fn test_strips_style_with_expression() {
2118 let tag = "<rect style=\"width: expression(alert(1))\">";
2119 let result = sanitize_tag_attrs(tag);
2120 assert!(!result.contains("style"));
2121 }
2122
2123 #[test]
2124 fn test_strips_style_with_import() {
2125 let tag = "<rect style=\"@import url(evil.css)\">";
2126 let result = sanitize_tag_attrs(tag);
2127 assert!(!result.contains("style"));
2128 }
2129
2130 #[test]
2131 fn test_preserves_safe_style() {
2132 let tag = "<rect style=\"fill: red; stroke: blue\" width=\"10\">";
2133 let result = sanitize_tag_attrs(tag);
2134 assert!(result.contains("style"));
2135 assert!(result.contains("fill: red"));
2136 }
2137
2138 #[test]
2139 fn test_use_strips_relative_url_href() {
2140 let tag = "<use href=\"sprites.svg#icon\">";
2147 let result = sanitize_tag_attrs(tag);
2148 assert!(
2149 !result.contains("href="),
2150 "relative URL must be stripped for <use>; got {result:?}"
2151 );
2152 }
2153
2154 #[test]
2155 fn test_use_preserves_whitespace_prefixed_fragment_href() {
2156 let tag = "<use href=\" #myShape\">";
2160 let result = sanitize_tag_attrs(tag);
2161 assert!(
2165 result.contains("href="),
2166 "whitespace-prefixed fragment href must be preserved; got {result:?}"
2167 );
2168 }
2169}
2170
2171#[cfg(test)]
2172mod tests {
2173 use super::*;
2174
2175 #[test]
2176 fn test_render_empty() {
2177 let song = chordsketch_chordpro::parse("").unwrap();
2178 let html = render_song(&song);
2179 assert!(html.contains("<!DOCTYPE html>"));
2180 assert!(html.contains("</html>"));
2181 }
2182
2183 #[test]
2184 fn test_render_song_body_omits_document_envelope() {
2185 let song =
2191 chordsketch_chordpro::parse("{title: Sample}\nWas [G]blind but [D]now I [G]see.")
2192 .unwrap();
2193 let body = render_song_body(&song);
2194 assert!(!body.contains("<!DOCTYPE"));
2195 assert!(!body.contains("<html"));
2196 assert!(!body.contains("</html>"));
2197 assert!(!body.contains("<head"));
2198 assert!(!body.contains("<style"));
2199 assert!(!body.contains("<title>"));
2200 assert!(body.contains("<div class=\"song\">"));
2204 assert!(body.contains("<h1>Sample</h1>"));
2205 assert!(body.contains("class=\"chord-block\""));
2210 }
2211
2212 #[test]
2213 fn test_render_song_body_byte_stable_with_full_render_body_section() {
2214 let song = chordsketch_chordpro::parse(
2220 "{title: Amazing Grace}\nA[G]mazing [D]grace, how [G]sweet the sound.",
2221 )
2222 .unwrap();
2223 let full = render_song(&song);
2224 let body = render_song_body(&song);
2225 let body_start = full
2226 .find("<body>\n")
2227 .expect("full-document render must have <body>")
2228 + "<body>\n".len();
2229 let body_end = full
2230 .rfind("</body>")
2231 .expect("full-document render must have </body>");
2232 let extracted = &full[body_start..body_end];
2233 assert_eq!(
2234 extracted, body,
2235 "body-only output must match the body slice of the full document"
2236 );
2237 }
2238
2239 #[test]
2240 fn test_render_html_css_returns_canonical_block() {
2241 let css = render_html_css();
2249 assert!(css.contains(".chord-block"));
2250 assert!(css.contains(".chord "));
2251 assert!(css.contains(".lyrics"));
2252 assert!(css.contains(".line { display: flex; flex-wrap: wrap;"));
2255 let song = chordsketch_chordpro::parse("{title: t}").unwrap();
2259 let full = render_song(&song);
2260 assert!(full.contains(&css));
2261 }
2262
2263 #[test]
2266 fn test_wraplines_default_is_wrap() {
2267 let css = render_html_css_with_config(&Config::defaults());
2269 assert!(
2270 css.contains(".line { display: flex; flex-wrap: wrap;"),
2271 "default settings.wraplines must emit flex-wrap: wrap; got: {css}"
2272 );
2273 assert!(
2275 css.contains(".chord-diagrams-grid { display: flex; flex-wrap: wrap;"),
2276 ".chord-diagrams-grid wrap must never be substituted"
2277 );
2278 assert!(
2280 !css.contains("__LINE_FLEX_WRAP__"),
2281 "the sentinel must always be replaced; got: {css}"
2282 );
2283 }
2284
2285 #[test]
2286 fn test_wraplines_false_emits_nowrap() {
2287 let cfg = Config::defaults()
2288 .with_define("settings.wraplines=false")
2289 .unwrap();
2290 let css = render_html_css_with_config(&cfg);
2291 assert!(
2292 css.contains(".line { display: flex; flex-wrap: nowrap;"),
2293 "settings.wraplines=false must emit flex-wrap: nowrap; got: {css}"
2294 );
2295 assert!(
2297 css.contains(".chord-diagrams-grid { display: flex; flex-wrap: wrap;"),
2298 ".chord-diagrams-grid wrap must NOT change with settings.wraplines"
2299 );
2300 }
2301
2302 #[test]
2303 fn test_wraplines_full_document_embeds_configured_value() {
2304 let cfg = Config::defaults()
2307 .with_define("settings.wraplines=false")
2308 .unwrap();
2309 let song = chordsketch_chordpro::parse("{title: t}").unwrap();
2310 let full = render_song_with_warnings(&song, 0, &cfg).output;
2311 assert!(
2312 full.contains(".line { display: flex; flex-wrap: nowrap;"),
2313 "full document must embed nowrap when settings.wraplines=false"
2314 );
2315 }
2316
2317 #[test]
2318 fn test_wraplines_true_explicit_matches_default() {
2319 let cfg = Config::defaults()
2320 .with_define("settings.wraplines=true")
2321 .unwrap();
2322 assert_eq!(
2323 render_html_css_with_config(&cfg),
2324 render_html_css_with_config(&Config::defaults())
2325 );
2326 }
2327
2328 #[test]
2329 fn test_render_songs_body_separator_between_songs() {
2330 let parsed =
2331 chordsketch_chordpro::parse_multi_lenient("{title: A}\n{new_song}\n{title: B}");
2332 let songs: Vec<_> = parsed.results.into_iter().map(|r| r.song).collect();
2333 assert_eq!(songs.len(), 2, "expected two songs in the parsed output");
2334 let body = render_songs_body(&songs);
2335 assert!(body.contains("<hr class=\"song-separator\">"));
2336 assert!(!body.contains("<!DOCTYPE"));
2337 }
2338
2339 #[test]
2340 fn test_render_songs_body_empty_input() {
2341 let body = render_songs_body(&[]);
2342 assert!(body.is_empty());
2343 }
2344
2345 #[test]
2346 fn test_render_title() {
2347 let html = render("{title: My Song}");
2348 assert!(html.contains("<h1>My Song</h1>"));
2349 assert!(html.contains("<title>My Song</title>"));
2350 }
2351
2352 #[test]
2353 fn test_render_subtitle() {
2354 let html = render("{title: Song}\n{subtitle: By Someone}");
2355 assert!(html.contains("<h2>By Someone</h2>"));
2356 }
2357
2358 #[test]
2359 fn test_render_lyrics_with_chords() {
2360 let html = render("[Am]Hello [G]world");
2361 assert!(html.contains("chord-block"));
2362 assert!(html.contains("<span class=\"chord\">Am</span>"));
2363 assert!(html.contains("<span class=\"lyrics\">Hello </span>"));
2364 assert!(html.contains("<span class=\"chord\">G</span>"));
2365 }
2366
2367 #[test]
2368 fn test_render_lyrics_no_chords() {
2369 let html = render("Just plain text");
2370 assert!(html.contains("<span class=\"lyrics\">Just plain text</span>"));
2371 assert!(!html.contains("class=\"chord\""));
2373 }
2374
2375 #[test]
2376 fn test_render_chorus_section() {
2377 let html = render("{start_of_chorus}\n[G]La la\n{end_of_chorus}");
2378 assert!(html.contains("<section class=\"chorus\">"));
2379 assert!(html.contains("</section>"));
2380 assert!(html.contains("Chorus"));
2381 }
2382
2383 #[test]
2384 fn test_render_verse_with_label() {
2385 let html = render("{start_of_verse: Verse 1}\nLyrics\n{end_of_verse}");
2386 assert!(html.contains("<section class=\"verse\">"));
2387 assert!(html.contains("Verse: Verse 1"));
2388 }
2389
2390 #[test]
2391 fn test_render_comment() {
2392 let html = render("{comment: A note}");
2393 assert!(html.contains("<p class=\"comment\">A note</p>"));
2394 }
2395
2396 #[test]
2397 fn test_render_comment_italic() {
2398 let html = render("{comment_italic: Softly}");
2399 assert!(html.contains("<em>Softly</em>"));
2400 }
2401
2402 #[test]
2403 fn test_render_comment_box() {
2404 let html = render("{comment_box: Important}");
2405 assert!(html.contains("<div class=\"comment-box\">Important</div>"));
2406 }
2407
2408 #[test]
2409 fn test_html_escaping() {
2410 let html = render("{title: Tom & Jerry <3}");
2411 assert!(html.contains("Tom & Jerry <3"));
2412 }
2413
2414 #[test]
2415 fn test_try_render_success() {
2416 let result = try_render("{title: Test}");
2417 assert!(result.is_ok());
2418 }
2419
2420 #[test]
2421 fn test_try_render_error() {
2422 let result = try_render("{unclosed");
2423 assert!(result.is_err());
2424 }
2425
2426 #[test]
2427 fn test_render_valid_html_structure() {
2428 let html = render("{title: Test}\n\n{start_of_verse}\n[G]Hello [C]world\n{end_of_verse}");
2429 assert!(html.starts_with("<!DOCTYPE html>"));
2430 assert!(html.contains("<html"));
2431 assert!(html.contains("<head>"));
2432 assert!(html.contains("<style>"));
2433 assert!(html.contains("<body>"));
2434 assert!(html.contains("</html>"));
2435 }
2436
2437 #[test]
2438 fn test_text_before_first_chord() {
2439 let html = render("Hello [Am]world");
2440 assert!(
2446 html.contains(
2447 "<span class=\"chord\" aria-hidden=\"true\">\u{00A0}</span><span class=\"lyrics\">Hello </span>"
2448 )
2449 );
2450 }
2451
2452 #[test]
2453 fn test_chord_less_and_chord_bearing_segments_share_baseline_placeholder() {
2454 let html = render("Was [G]blind but [D]now I [G]see.");
2459 assert!(
2462 html.contains(
2463 "<span class=\"chord\" aria-hidden=\"true\">\u{00A0}</span><span class=\"lyrics\">Was </span>"
2464 ),
2465 "expected NBSP-bearing chord placeholder for \"Was \" segment, got: {html}"
2466 );
2467 assert!(html.contains("<span class=\"chord\">G</span>"));
2469 assert!(html.contains("<span class=\"chord\">D</span>"));
2470 }
2471
2472 #[test]
2473 fn test_empty_line() {
2474 let html = render("Line one\n\nLine two");
2475 assert!(html.contains("empty-line"));
2476 }
2477
2478 #[test]
2479 fn test_render_grid_section() {
2480 let html = render("{start_of_grid}\n| Am . | C . |\n{end_of_grid}");
2481 assert!(html.contains("<section class=\"grid\">"));
2482 assert!(html.contains("Grid"));
2483 assert!(html.contains("</section>"));
2484 }
2485
2486 #[test]
2489 fn test_render_custom_section_intro() {
2490 let html = render("{start_of_intro}\n[Am]Da da\n{end_of_intro}");
2491 assert!(html.contains("<section class=\"section-intro\">"));
2492 assert!(html.contains("Intro"));
2493 assert!(html.contains("</section>"));
2494 }
2495
2496 #[test]
2497 fn test_render_grid_section_with_label() {
2498 let html = render("{start_of_grid: Intro}\n| Am |\n{end_of_grid}");
2499 assert!(html.contains("<section class=\"grid\">"));
2500 assert!(html.contains("Grid: Intro"));
2501 }
2502
2503 #[test]
2504 fn test_render_grid_short_alias() {
2505 let html = render("{sog}\n| G . |\n{eog}");
2506 assert!(html.contains("<section class=\"grid\">"));
2507 assert!(html.contains("</section>"));
2508 }
2509
2510 #[test]
2511 fn test_render_custom_section_with_label() {
2512 let html = render("{start_of_intro: Guitar}\nNotes\n{end_of_intro}");
2513 assert!(html.contains("<section class=\"section-intro\">"));
2514 assert!(html.contains("Intro: Guitar"));
2515 }
2516
2517 #[test]
2518 fn test_render_custom_section_outro() {
2519 let html = render("{start_of_outro}\nFinal\n{end_of_outro}");
2520 assert!(html.contains("<section class=\"section-outro\">"));
2521 assert!(html.contains("Outro"));
2522 }
2523
2524 #[test]
2525 fn test_render_custom_section_solo() {
2526 let html = render("{start_of_solo}\n[Em]Solo\n{end_of_solo}");
2527 assert!(html.contains("<section class=\"section-solo\">"));
2528 assert!(html.contains("Solo"));
2529 assert!(html.contains("</section>"));
2530 }
2531
2532 #[test]
2533 fn test_custom_section_name_escaped() {
2534 let html = render(
2535 "{start_of_x<script>alert(1)</script>}\ntext\n{end_of_x<script>alert(1)</script>}",
2536 );
2537 assert!(!html.contains("<script>"));
2538 assert!(html.contains("<script>"));
2539 }
2540
2541 #[test]
2542 fn test_custom_section_name_quotes_escaped() {
2543 let html =
2544 render("{start_of_x\" onclick=\"alert(1)}\ntext\n{end_of_x\" onclick=\"alert(1)}");
2545 assert!(html.contains("""));
2547 assert!(!html.contains("class=\"section-x\""));
2548 }
2549
2550 #[test]
2551 fn test_custom_section_name_single_quotes_escaped() {
2552 let html = render("{start_of_x' onclick='alert(1)}\ntext\n{end_of_x' onclick='alert(1)}");
2553 assert!(html.contains("'") || html.contains("'"));
2556 assert!(!html.contains("onclick='alert"));
2557 }
2558
2559 #[test]
2560 fn test_custom_section_name_space_sanitized_in_class() {
2561 let html = render("{start_of_foo bar}\ntext\n{end_of_foo bar}");
2563 assert!(html.contains("section-foo-bar"));
2565 assert!(!html.contains("class=\"section-foo bar\""));
2566 }
2567
2568 #[test]
2569 fn test_custom_section_name_special_chars_sanitized_in_class() {
2570 let html = render("{start_of_a&b<c>d}\ntext\n{end_of_a&b<c>d}");
2571 assert!(html.contains("section-a-b-c-d"));
2573 assert!(html.contains("&"));
2575 }
2576
2577 #[test]
2578 fn test_custom_section_capitalize_before_escape() {
2579 let html = render("{start_of_&test}\ntext\n{end_of_&test}");
2583 assert!(html.contains("&test"));
2586 assert!(!html.contains("&Amp;"));
2587 }
2588
2589 #[test]
2590 fn test_define_display_name_in_html_output() {
2591 let html = render("{define: Am base-fret 1 frets x 0 2 2 1 0 display=\"A minor\"}");
2592 assert!(
2593 html.contains("A minor"),
2594 "display name should appear in rendered HTML output"
2595 );
2596 }
2597}
2598
2599#[cfg(test)]
2600mod transpose_tests {
2601 use super::*;
2602
2603 #[test]
2604 fn test_transpose_directive_up_2() {
2605 let input = "{transpose: 2}\n[G]Hello [C]world";
2606 let song = chordsketch_chordpro::parse(input).unwrap();
2607 let html = render_song(&song);
2608 assert!(html.contains("<span class=\"chord\">A</span>"));
2610 assert!(html.contains("<span class=\"chord\">D</span>"));
2611 assert!(!html.contains("<span class=\"chord\">G</span>"));
2612 assert!(!html.contains("<span class=\"chord\">C</span>"));
2613 }
2614
2615 #[test]
2616 fn test_transpose_directive_replaces_previous() {
2617 let input = "{transpose: 2}\n[G]First\n{transpose: 0}\n[G]Second";
2618 let song = chordsketch_chordpro::parse(input).unwrap();
2619 let html = render_song(&song);
2620 assert!(html.contains("<span class=\"chord\">A</span>"));
2622 assert!(html.contains("<span class=\"chord\">G</span>"));
2623 }
2624
2625 #[test]
2626 fn test_transpose_directive_with_cli_offset() {
2627 let input = "{transpose: 2}\n[C]Hello";
2628 let song = chordsketch_chordpro::parse(input).unwrap();
2629 let html = render_song_with_transpose(&song, 3, &Config::defaults());
2630 assert!(html.contains("<span class=\"chord\">F</span>"));
2632 }
2633
2634 #[test]
2635 fn test_transpose_out_of_i8_range_emits_warning() {
2636 let input = "{transpose: 999}\n[G]Hello";
2638 let song = chordsketch_chordpro::parse(input).unwrap();
2639 let result = render_song_with_warnings(&song, 0, &Config::defaults());
2640 assert!(
2641 result.output.contains("<span class=\"chord\">G</span>"),
2642 "chord should be untransposed"
2643 );
2644 assert!(
2645 result.warnings.iter().any(|w| w.contains("\"999\"")),
2646 "expected warning about out-of-range value, got: {:?}",
2647 result.warnings
2648 );
2649 }
2650
2651 #[test]
2652 fn test_transpose_no_value_treated_as_zero() {
2653 let input = "{transpose}\n[G]Hello";
2655 let song = chordsketch_chordpro::parse(input).unwrap();
2656 let result = render_song_with_warnings(&song, 0, &Config::defaults());
2657 assert!(
2658 result.output.contains("<span class=\"chord\">G</span>"),
2659 "chord should be untransposed"
2660 );
2661 assert!(
2662 result.warnings.is_empty(),
2663 "missing {{transpose}} value should not emit a warning; got: {:?}",
2664 result.warnings
2665 );
2666 }
2667
2668 #[test]
2669 fn test_transpose_whitespace_value_treated_as_zero() {
2670 let input = "{transpose: }\n[G]Hello";
2674 let song = chordsketch_chordpro::parse(input).unwrap();
2675 let result = render_song_with_warnings(&song, 0, &Config::defaults());
2676 assert!(
2677 result.output.contains("<span class=\"chord\">G</span>"),
2678 "chord should be untransposed"
2679 );
2680 assert!(
2681 result.warnings.is_empty(),
2682 "whitespace-only {{transpose}} value should not emit a warning; got: {:?}",
2683 result.warnings
2684 );
2685 }
2686
2687 #[test]
2690 fn test_render_chorus_recall_basic() {
2691 let html = render("{start_of_chorus}\n[G]La la\n{end_of_chorus}\n\n{chorus}");
2692 assert!(html.contains("<div class=\"chorus-recall\">"));
2694 assert!(html.contains("chorus-recall"));
2696 assert!(html.contains("<section class=\"chorus\">"));
2698 }
2699
2700 #[test]
2701 fn test_render_chorus_recall_with_label() {
2702 let html = render("{start_of_chorus}\nSing\n{end_of_chorus}\n{chorus: Repeat}");
2703 assert!(html.contains("Chorus: Repeat"));
2704 assert!(html.contains("chorus-recall"));
2705 }
2706
2707 #[test]
2708 fn test_render_chorus_recall_no_chorus_defined() {
2709 let html = render("{chorus}");
2710 assert!(html.contains("<div class=\"chorus-recall\">"));
2712 assert!(html.contains("Chorus"));
2713 }
2714
2715 #[test]
2716 fn test_render_chorus_recall_content_replayed() {
2717 let html = render("{start_of_chorus}\nChorus text\n{end_of_chorus}\n{chorus}");
2718 let count = html.matches("Chorus text").count();
2720 assert_eq!(count, 2, "chorus content should appear twice");
2721 }
2722
2723 #[test]
2724 fn test_chorus_recall_applies_current_transpose() {
2725 let html = render("{start_of_chorus}\n[G]La la\n{end_of_chorus}\n{transpose: 2}\n{chorus}");
2728 assert!(
2730 html.contains("<span class=\"chord\">G</span>"),
2731 "original chorus should have G"
2732 );
2733 assert!(
2735 html.contains("<span class=\"chord\">A</span>"),
2736 "recalled chorus should have transposed chord A, got:\n{html}"
2737 );
2738 }
2739
2740 #[test]
2741 fn test_chorus_recall_preserves_formatting_directives() {
2742 let html =
2744 render("{start_of_chorus}\n{textsize: 20}\n[Am]Big text\n{end_of_chorus}\n{chorus}");
2745 let recall_start = html.find("chorus-recall").expect("should have recall");
2747 let recall_section = &html[recall_start..];
2748 assert!(
2749 recall_section.contains("font-size"),
2750 "recalled chorus should apply in-chorus formatting directives"
2751 );
2752 }
2753
2754 #[test]
2755 fn test_chorus_formatting_does_not_leak_to_outer_scope() {
2756 let html =
2758 render("{start_of_chorus}\n{textsize: 20}\n[Am]Big\n{end_of_chorus}\n[G]Normal text");
2759 let after_chorus = html
2761 .rfind("Normal text")
2762 .expect("should have post-chorus text");
2763 let line_start = html[..after_chorus].rfind("<div class=\"line\"").unwrap();
2765 let line_end = html[line_start..]
2766 .find("</div>")
2767 .map_or(html.len(), |i| line_start + i + 6);
2768 let post_chorus_line = &html[line_start..line_end];
2769 assert!(
2770 !post_chorus_line.contains("font-size"),
2771 "in-chorus {{textsize}} should not leak to post-chorus content: {post_chorus_line}"
2772 );
2773 }
2774
2775 #[test]
2778 fn test_render_bold_markup() {
2779 let html = render("Hello <b>bold</b> world");
2780 assert!(html.contains("<b>bold</b>"));
2781 assert!(html.contains("Hello "));
2782 assert!(html.contains(" world"));
2783 }
2784
2785 #[test]
2786 fn test_render_italic_markup() {
2787 let html = render("Hello <i>italic</i> text");
2788 assert!(html.contains("<i>italic</i>"));
2789 }
2790
2791 #[test]
2792 fn test_render_highlight_markup() {
2793 let html = render("<highlight>important</highlight>");
2794 assert!(html.contains("<mark>important</mark>"));
2795 }
2796
2797 #[test]
2798 fn test_render_comment_inline_markup() {
2799 let html = render("<comment>note</comment>");
2800 assert!(html.contains("<span class=\"comment\">note</span>"));
2801 }
2802
2803 #[test]
2804 fn test_render_span_with_foreground() {
2805 let html = render(r#"<span foreground="red">red text</span>"#);
2806 assert!(html.contains("color: red;"));
2807 assert!(html.contains("red text"));
2808 }
2809
2810 #[test]
2811 fn test_render_span_with_multiple_attrs() {
2812 let html = render(
2813 r#"<span font_family="Serif" size="14" foreground="blue" weight="bold">styled</span>"#,
2814 );
2815 assert!(html.contains("font-family: Serif;"));
2816 assert!(html.contains("font-size: 14pt;"));
2817 assert!(html.contains("color: blue;"));
2818 assert!(html.contains("font-weight: bold;"));
2819 assert!(html.contains("styled"));
2820 }
2821
2822 #[test]
2823 fn test_span_css_injection_url_prevented() {
2824 let html = render(
2825 r#"<span foreground="red; background-image: url('https://evil.com/')">text</span>"#,
2826 );
2827 assert!(!html.contains("url("));
2829 assert!(!html.contains(";background-image"));
2830 }
2831
2832 #[test]
2833 fn test_span_css_injection_semicolon_stripped() {
2834 let html =
2835 render(r#"<span foreground="red; position: absolute; z-index: 9999">text</span>"#);
2836 assert!(!html.contains(";position"));
2840 assert!(!html.contains("; position"));
2841 assert!(html.contains("color:"));
2842 }
2843
2844 #[test]
2845 fn test_render_nested_markup() {
2846 let html = render("<b><i>bold italic</i></b>");
2847 assert!(html.contains("<b><i>bold italic</i></b>"));
2848 }
2849
2850 #[test]
2851 fn test_render_markup_with_chord() {
2852 let html = render("[Am]Hello <b>bold</b> world");
2853 assert!(html.contains("<b>bold</b>"));
2854 assert!(html.contains("<span class=\"chord\">Am</span>"));
2855 }
2856
2857 #[test]
2858 fn test_render_no_markup_unchanged() {
2859 let html = render("Just plain text");
2860 assert!(!html.contains("<b>"));
2862 assert!(!html.contains("<i>"));
2863 assert!(html.contains("Just plain text"));
2864 }
2865
2866 #[test]
2869 fn test_textfont_directive_applies_css() {
2870 let html = render("{textfont: Courier}\nHello world");
2871 assert!(html.contains("font-family: Courier;"));
2872 }
2873
2874 #[test]
2875 fn test_textsize_directive_applies_css() {
2876 let html = render("{textsize: 14}\nHello world");
2877 assert!(html.contains("font-size: 14pt;"));
2878 }
2879
2880 #[test]
2881 fn test_textcolour_directive_applies_css() {
2882 let html = render("{textcolour: blue}\nHello world");
2883 assert!(html.contains("color: blue;"));
2884 }
2885
2886 #[test]
2887 fn test_chordfont_directive_applies_css() {
2888 let html = render("{chordfont: Monospace}\n[Am]Hello");
2889 assert!(html.contains("font-family: Monospace;"));
2890 }
2891
2892 #[test]
2893 fn test_chordsize_directive_applies_css() {
2894 let html = render("{chordsize: 16}\n[Am]Hello");
2895 assert!(html.contains("font-size: 16pt;"));
2897 }
2898
2899 #[test]
2900 fn test_chordcolour_directive_applies_css() {
2901 let html = render("{chordcolour: green}\n[Am]Hello");
2902 assert!(html.contains("color: green;"));
2903 }
2904
2905 #[test]
2906 fn test_formatting_persists_across_lines() {
2907 let html = render("{textcolour: red}\nLine one\nLine two");
2908 let count = html.matches("color: red;").count();
2910 assert!(
2911 count >= 2,
2912 "formatting should persist: found {count} matches"
2913 );
2914 }
2915
2916 #[test]
2917 fn test_formatting_overridden_by_later_directive() {
2918 let html = render("{textcolour: red}\nRed text\n{textcolour: blue}\nBlue text");
2919 assert!(html.contains("color: red;"));
2920 assert!(html.contains("color: blue;"));
2921 }
2922
2923 #[test]
2924 fn test_no_formatting_no_style_attr() {
2925 let html = render("Plain text");
2926 assert!(!html.contains("<span class=\"lyrics\" style="));
2928 }
2929
2930 #[test]
2931 fn test_formatting_directive_css_injection_prevented() {
2932 let html = render("{textcolour: red; position: fixed; z-index: 9999}\nHello");
2933 assert!(!html.contains(";position"));
2935 assert!(!html.contains("; position"));
2936 assert!(html.contains("color:"));
2937 }
2938
2939 #[test]
2940 fn test_formatting_directive_url_injection_prevented() {
2941 let html = render("{textcolour: red; background-image: url('https://evil.com/')}\nHello");
2942 assert!(!html.contains("url("));
2944 }
2945
2946 #[test]
2949 fn test_columns_directive_generates_css() {
2950 let html = render("{columns: 2}\nLine one\nLine two");
2951 assert!(html.contains("column-count: 2"));
2952 }
2953
2954 #[test]
2955 fn test_columns_reset_to_one() {
2956 let html = render("{columns: 2}\nTwo cols\n{columns: 1}\nOne col");
2957 let count = html.matches("column-count: 2").count();
2959 assert_eq!(count, 1);
2960 assert!(html.contains("One col"));
2961 }
2962
2963 #[test]
2964 fn test_column_break_generates_css() {
2965 let html = render("{columns: 2}\nCol 1\n{column_break}\nCol 2");
2966 assert!(html.contains("break-before: column;"));
2967 }
2968
2969 #[test]
2970 fn test_columns_clamped_to_max() {
2971 let html = render("{columns: 999}\nContent");
2972 assert!(html.contains("column-count: 32"));
2974 }
2975
2976 #[test]
2977 fn test_columns_zero_treated_as_one() {
2978 let html = render("{columns: 0}\nContent");
2979 assert!(!html.contains("column-count"));
2981 }
2982
2983 #[test]
2984 fn test_columns_non_numeric_defaults_to_one() {
2985 let html = render("{columns: abc}\nHello");
2986 assert!(!html.contains("column-count"));
2988 }
2989
2990 #[test]
2991 fn test_new_page_generates_page_break() {
2992 let html = render("Page 1\n{new_page}\nPage 2");
2993 assert!(html.contains("break-before: page;"));
2994 }
2995
2996 #[test]
2997 fn test_new_physical_page_generates_recto_break() {
2998 let html = render("Page 1\n{new_physical_page}\nPage 2");
2999 assert!(
3000 html.contains("break-before: recto;"),
3001 "new_physical_page should use break-before: recto for duplex printing"
3002 );
3003 assert!(
3004 !html.contains("break-before: page;"),
3005 "new_physical_page should not emit generic page break"
3006 );
3007 }
3008
3009 #[test]
3010 fn test_page_control_not_replayed_in_chorus_recall() {
3011 let input = "\
3013{start_of_chorus}\n\
3014{new_page}\n\
3015[G]La la la\n\
3016{end_of_chorus}\n\
3017Verse text\n\
3018{chorus}";
3019 let html = render(input);
3020 assert!(html.contains("break-before: page;"));
3022 let count = html.matches("break-before: page;").count();
3025 assert_eq!(count, 1, "page break must not be replayed in chorus recall");
3026 }
3027
3028 #[test]
3031 fn test_image_basic() {
3032 let html = render("{image: src=photo.jpg}");
3033 assert!(html.contains("<img src=\"photo.jpg\""));
3034 }
3035
3036 #[test]
3037 fn test_image_with_dimensions() {
3038 let html = render("{image: src=photo.jpg width=200 height=100}");
3039 assert!(html.contains("width=\"200\""));
3040 assert!(html.contains("height=\"100\""));
3041 }
3042
3043 #[test]
3044 fn test_image_with_title() {
3045 let html = render("{image: src=photo.jpg title=\"My Photo\"}");
3046 assert!(html.contains("alt=\"My Photo\""));
3047 }
3048
3049 #[test]
3050 fn test_image_with_scale() {
3051 let html = render("{image: src=photo.jpg scale=0.5}");
3052 assert!(html.contains("scale(0.5)"));
3053 }
3054
3055 #[test]
3056 fn test_image_empty_src_skipped() {
3057 let html = render("{image: src=}");
3058 assert!(
3059 !html.contains("<img"),
3060 "empty src should not produce an img element"
3061 );
3062 }
3063
3064 #[test]
3065 fn test_image_javascript_uri_rejected() {
3066 let html = render("{image: src=javascript:alert(1)}");
3067 assert!(!html.contains("<img"), "javascript: URI must be rejected");
3068 }
3069
3070 #[test]
3071 fn test_image_data_uri_rejected() {
3072 let html = render("{image: src=data:text/html,<script>alert(1)</script>}");
3073 assert!(!html.contains("<img"), "data: URI must be rejected");
3074 }
3075
3076 #[test]
3077 fn test_image_vbscript_uri_rejected() {
3078 let html = render("{image: src=vbscript:MsgBox}");
3079 assert!(!html.contains("<img"), "vbscript: URI must be rejected");
3080 }
3081
3082 #[test]
3083 fn test_image_javascript_uri_case_insensitive() {
3084 let html = render("{image: src=JaVaScRiPt:alert(1)}");
3085 assert!(
3086 !html.contains("<img"),
3087 "scheme check must be case-insensitive"
3088 );
3089 }
3090
3091 #[test]
3092 fn test_image_safe_relative_path_allowed() {
3093 let html = render("{image: src=images/photo.jpg}");
3094 assert!(html.contains("<img src=\"images/photo.jpg\""));
3095 }
3096
3097 #[test]
3100 fn test_capo_out_of_range_emits_warning() {
3101 let song = chordsketch_chordpro::parse("{title: T}\n{capo: 999}").unwrap();
3102 let result = render_song_with_warnings(&song, 0, &Config::defaults());
3103 assert!(
3104 result
3105 .warnings
3106 .iter()
3107 .any(|w| w.contains("capo") && w.contains("999")),
3108 "expected out-of-range {{capo}} warning; got {:?}",
3109 result.warnings
3110 );
3111 }
3112
3113 #[test]
3114 fn test_capo_non_numeric_emits_warning() {
3115 let song = chordsketch_chordpro::parse("{title: T}\n{capo: foo}").unwrap();
3116 let result = render_song_with_warnings(&song, 0, &Config::defaults());
3117 assert!(
3118 result
3119 .warnings
3120 .iter()
3121 .any(|w| w.contains("capo") && w.contains("foo")),
3122 "expected non-integer {{capo}} warning; got {:?}",
3123 result.warnings
3124 );
3125 }
3126
3127 #[test]
3128 fn test_capo_in_range_is_silent() {
3129 let song = chordsketch_chordpro::parse("{title: T}\n{capo: 5}").unwrap();
3130 let result = render_song_with_warnings(&song, 0, &Config::defaults());
3131 assert!(
3132 !result.warnings.iter().any(|w| w.contains("capo")),
3133 "valid {{capo: 5}} should not warn; got {:?}",
3134 result.warnings
3135 );
3136 }
3137
3138 #[test]
3141 fn test_strict_off_with_missing_key_is_silent() {
3142 let song = chordsketch_chordpro::parse("{title: T}").unwrap();
3143 let result = render_song_with_warnings(&song, 0, &Config::defaults());
3144 assert!(
3145 !result
3146 .warnings
3147 .iter()
3148 .any(|w| w.contains("settings.strict")),
3149 "default settings.strict=false must not warn on missing {{key}}; got {:?}",
3150 result.warnings
3151 );
3152 }
3153
3154 #[test]
3155 fn test_strict_on_with_missing_key_warns() {
3156 let song = chordsketch_chordpro::parse("{title: T}").unwrap();
3157 let cfg = Config::defaults()
3158 .with_define("settings.strict=true")
3159 .unwrap();
3160 let result = render_song_with_warnings(&song, 0, &cfg);
3161 assert!(
3162 result
3163 .warnings
3164 .iter()
3165 .any(|w| w.contains("{key}") && w.contains("settings.strict")),
3166 "expected missing-{{key}} warning under settings.strict; got {:?}",
3167 result.warnings
3168 );
3169 }
3170
3171 #[test]
3172 fn test_strict_on_with_present_key_is_silent() {
3173 let song = chordsketch_chordpro::parse("{title: T}\n{key: G}").unwrap();
3174 let cfg = Config::defaults()
3175 .with_define("settings.strict=true")
3176 .unwrap();
3177 let result = render_song_with_warnings(&song, 0, &cfg);
3178 assert!(
3179 !result
3180 .warnings
3181 .iter()
3182 .any(|w| w.contains("settings.strict")),
3183 "settings.strict warning must not fire when {{key}} is present; got {:?}",
3184 result.warnings
3185 );
3186 }
3187
3188 #[test]
3191 fn test_max_warnings_truncates() {
3192 let mut input = String::from("{title: T}\n");
3193 for _ in 0..(MAX_WARNINGS + 50) {
3194 input.push_str("{transpose: not-a-number}\n");
3195 }
3196 let song = chordsketch_chordpro::parse(&input).unwrap();
3197 let result = render_song_with_warnings(&song, 0, &Config::defaults());
3198 assert_eq!(
3199 result.warnings.len(),
3200 MAX_WARNINGS + 1,
3201 "expected exactly MAX_WARNINGS warnings plus one truncation marker"
3202 );
3203 assert!(
3204 result.warnings.last().unwrap().contains("MAX_WARNINGS"),
3205 "last entry must be the truncation marker; got {:?}",
3206 result.warnings.last()
3207 );
3208 }
3209
3210 #[test]
3211 fn test_is_safe_image_src() {
3212 assert!(is_safe_image_src("photo.jpg"));
3214 assert!(is_safe_image_src("images/photo.jpg"));
3215 assert!(is_safe_image_src("path/to:file.jpg")); assert!(is_safe_image_src("http://example.com/photo.jpg"));
3219 assert!(is_safe_image_src("https://example.com/photo.jpg"));
3220 assert!(is_safe_image_src("HTTP://EXAMPLE.COM/PHOTO.JPG"));
3221
3222 assert!(!is_safe_image_src(""));
3224
3225 assert!(!is_safe_image_src("javascript:alert(1)"));
3227 assert!(!is_safe_image_src("JAVASCRIPT:alert(1)"));
3228 assert!(!is_safe_image_src(" javascript:alert(1)"));
3229 assert!(!is_safe_image_src("data:image/png;base64,abc"));
3230 assert!(!is_safe_image_src("vbscript:MsgBox"));
3231
3232 assert!(!is_safe_image_src("file:///etc/passwd"));
3234 assert!(!is_safe_image_src("FILE:///etc/passwd"));
3235 assert!(!is_safe_image_src("blob:https://example.com/uuid"));
3236 assert!(!is_safe_image_src("mhtml:file://C:/page.mhtml"));
3237
3238 assert!(!is_safe_image_src("/etc/passwd"));
3240 assert!(!is_safe_image_src("/home/user/photo.jpg"));
3241
3242 assert!(!is_safe_image_src("photo\0.jpg"));
3244 assert!(!is_safe_image_src("\0"));
3245
3246 assert!(!is_safe_image_src("../photo.jpg"));
3248 assert!(!is_safe_image_src("images/../../etc/passwd"));
3249 assert!(!is_safe_image_src(r"..\photo.jpg"));
3250 assert!(!is_safe_image_src(r"images\..\..\photo.jpg"));
3251
3252 assert!(!is_safe_image_src(r"C:\photo.jpg"));
3254 assert!(!is_safe_image_src(r"D:\Users\photo.jpg"));
3255 assert!(!is_safe_image_src(r"\\server\share\photo.jpg"));
3256 assert!(!is_safe_image_src("C:/photo.jpg"));
3257 }
3258
3259 #[test]
3260 fn test_image_anchor_column_centers() {
3261 let html = render("{image: src=photo.jpg anchor=column}");
3262 assert!(
3263 html.contains("<div style=\"text-align: center;\">"),
3264 "anchor=column should produce centered div"
3265 );
3266 }
3267
3268 #[test]
3269 fn test_image_anchor_paper_centers() {
3270 let html = render("{image: src=photo.jpg anchor=paper}");
3271 assert!(
3272 html.contains("<div style=\"text-align: center;\">"),
3273 "anchor=paper should produce centered div"
3274 );
3275 }
3276
3277 #[test]
3278 fn test_image_anchor_line_no_style() {
3279 let html = render("{image: src=photo.jpg anchor=line}");
3280 assert!(html.contains("<div><img"));
3282 assert!(!html.contains("text-align"));
3283 }
3284
3285 #[test]
3286 fn test_image_no_anchor_no_style() {
3287 let html = render("{image: src=photo.jpg}");
3288 assert!(html.contains("<div><img"));
3290 assert!(!html.contains("text-align"));
3291 }
3292
3293 #[test]
3294 fn test_image_max_width_css_present() {
3295 let html = render("{image: src=photo.jpg}");
3296 assert!(
3297 html.contains("img { max-width: 100%; height: auto; }"),
3298 "CSS should include img max-width rule to prevent overflow"
3299 );
3300 }
3301
3302 #[test]
3303 fn test_chord_diagram_css_rules_present() {
3304 let html = render("{define: Am base-fret 1 frets x 0 2 2 1 0}");
3305 assert!(
3306 html.contains(".chord-diagram-container"),
3307 "CSS should include .chord-diagram-container rule"
3308 );
3309 assert!(
3310 html.contains(".chord-diagram {"),
3311 "CSS should include .chord-diagram rule"
3312 );
3313 }
3314
3315 #[test]
3318 fn test_define_renders_svg_diagram() {
3319 let html = render("{define: Am base-fret 1 frets x 0 2 2 1 0}");
3320 assert!(html.contains("<svg"));
3321 assert!(html.contains("Am"));
3322 assert!(html.contains("chord-diagram"));
3323 }
3324
3325 #[test]
3326 fn test_define_keyboard_renders_keyboard_svg() {
3327 let html = render("{define: Am keys 0 3 7}");
3329 assert!(
3330 html.contains("<svg"),
3331 "keyboard define should produce an SVG"
3332 );
3333 assert!(
3334 html.contains("keyboard-diagram"),
3335 "should use keyboard-diagram CSS class"
3336 );
3337 assert!(html.contains("Am"), "chord name should appear in SVG");
3338 }
3339
3340 #[test]
3341 fn test_define_keyboard_absolute_midi_renders_svg() {
3342 let html = render("{define: Cmaj7 keys 60 64 67 71}");
3344 assert!(html.contains("<svg"));
3345 assert!(html.contains("keyboard-diagram"));
3346 assert!(html.contains("Cmaj7"));
3347 }
3348
3349 #[test]
3350 fn test_diagrams_piano_auto_inject() {
3351 let input = "{diagrams: piano}\n[Am]Hello [C]world";
3352 let html = render(input);
3353 assert!(
3355 html.contains("keyboard-diagram"),
3356 "piano instrument should use keyboard diagrams"
3357 );
3358 assert!(
3359 html.contains("chord-diagrams"),
3360 "diagram section should be present"
3361 );
3362 }
3363
3364 #[test]
3365 fn test_define_ukulele_diagram() {
3366 let html = render("{define: C frets 0 0 0 3}");
3367 assert!(html.contains("<svg"));
3368 assert!(html.contains("chord-diagram"));
3369 assert!(
3371 html.contains("width=\"88\""),
3372 "Expected 4-string SVG width (88)"
3373 );
3374 }
3375
3376 #[test]
3377 fn test_define_banjo_diagram() {
3378 let html = render("{define: G frets 0 0 0 0 0}");
3379 assert!(html.contains("<svg"));
3380 assert!(
3382 html.contains("width=\"104\""),
3383 "Expected 5-string SVG width (104)"
3384 );
3385 }
3386
3387 #[test]
3388 fn test_diagrams_frets_config_controls_svg_height() {
3389 let input = "{define: Am base-fret 1 frets x 0 2 2 1 0}";
3390 let song = chordsketch_chordpro::parse(input).unwrap();
3391 let config = chordsketch_chordpro::config::Config::defaults()
3392 .with_define("diagrams.frets=4")
3393 .unwrap();
3394 let html = render_song_with_transpose(&song, 0, &config);
3395 assert!(
3397 html.contains("height=\"140\""),
3398 "SVG height should reflect diagrams.frets=4 (expected 140)"
3399 );
3400 }
3401
3402 #[test]
3405 fn test_diagrams_off_suppresses_chord_diagrams() {
3406 let html = render("{diagrams: off}\n{define: Am base-fret 1 frets x 0 2 2 1 0}");
3407 assert!(
3408 !html.contains("<svg"),
3409 "chord diagram SVG should be suppressed when diagrams=off"
3410 );
3411 }
3412
3413 #[test]
3414 fn test_diagrams_on_shows_chord_diagrams() {
3415 let html = render("{diagrams: on}\n{define: Am base-fret 1 frets x 0 2 2 1 0}");
3416 assert!(
3417 html.contains("<svg"),
3418 "chord diagram SVG should be shown when diagrams=on"
3419 );
3420 }
3421
3422 #[test]
3423 fn test_diagrams_default_shows_chord_diagrams() {
3424 let html = render("{define: Am base-fret 1 frets x 0 2 2 1 0}");
3425 assert!(
3426 html.contains("<svg"),
3427 "chord diagram SVG should be shown by default"
3428 );
3429 }
3430
3431 #[test]
3432 fn test_diagrams_off_then_on_restores() {
3433 let html = render(
3434 "{diagrams: off}\n{define: Am base-fret 1 frets x 0 2 2 1 0}\n{diagrams: on}\n{define: G base-fret 1 frets 3 2 0 0 0 3}",
3435 );
3436 assert!(!html.contains(">Am<"), "Am diagram should be suppressed");
3438 assert!(html.contains(">G<"), "G diagram should be rendered");
3439 }
3440
3441 #[test]
3442 fn test_diagrams_parsed_as_known_directive() {
3443 let song = chordsketch_chordpro::parse("{diagrams: off}").unwrap();
3444 if let chordsketch_chordpro::ast::Line::Directive(d) = &song.lines[0] {
3445 assert_eq!(
3446 d.kind,
3447 chordsketch_chordpro::ast::DirectiveKind::Diagrams,
3448 "diagrams should parse as DirectiveKind::Diagrams"
3449 );
3450 assert_eq!(d.value, Some("off".to_string()));
3451 } else {
3452 panic!("expected a directive line, got: {:?}", &song.lines[0]);
3453 }
3454 }
3455
3456 #[test]
3459 fn test_diagrams_off_case_insensitive() {
3460 let html = render("{diagrams: Off}\n{define: Am base-fret 1 frets x 0 2 2 1 0}");
3461 assert!(
3462 !html.contains("<svg"),
3463 "diagrams=Off should suppress diagrams (case-insensitive)"
3464 );
3465 }
3466
3467 #[test]
3468 fn test_diagrams_off_uppercase() {
3469 let html = render("{diagrams: OFF}\n{define: Am base-fret 1 frets x 0 2 2 1 0}");
3470 assert!(
3471 !html.contains("<svg"),
3472 "diagrams=OFF should suppress diagrams (case-insensitive)"
3473 );
3474 }
3475
3476 #[test]
3479 fn test_diagrams_auto_inject_from_builtin_db() {
3480 let html = render("{diagrams}\n[Am]Hello [G]World");
3482 assert!(
3483 html.contains("class=\"chord-diagrams\""),
3484 "should render chord-diagrams section"
3485 );
3486 assert!(html.contains(">Am<"), "Am diagram expected");
3488 assert!(html.contains(">G<"), "G diagram expected");
3489 }
3490
3491 #[test]
3492 fn test_diagrams_auto_inject_unknown_chord_skipped() {
3493 let html = render("{diagrams}\n[Xyzzy]Hello");
3495 assert!(
3497 !html.contains("class=\"chord-diagrams\""),
3498 "no diagram section for unknown chord"
3499 );
3500 }
3501
3502 #[test]
3503 fn test_no_diagrams_suppresses_auto_inject() {
3504 let html = render("{no_diagrams}\n[Am]Hello");
3505 assert!(
3506 !html.contains("class=\"chord-diagrams\""),
3507 "{{no_diagrams}} should suppress auto-inject"
3508 );
3509 }
3510
3511 #[test]
3512 fn test_diagrams_define_takes_priority_over_builtin() {
3513 let html = render("{diagrams}\n{define: Am base-fret 1 frets x 0 2 2 1 0}\n[Am]Hello");
3517 assert!(
3519 html.contains("font-weight=\"bold\">Am</text>"),
3520 "Am diagram should appear inline at the {{define}} position"
3521 );
3522 assert!(
3524 !html.contains("class=\"chord-diagrams\""),
3525 "auto-inject section should be absent when all used chords are defined"
3526 );
3527 }
3528
3529 #[test]
3530 fn test_diagrams_off_suppresses_auto_inject() {
3531 let html = render("{diagrams: off}\n[Am]Hello");
3532 assert!(
3533 !html.contains("class=\"chord-diagrams\""),
3534 "{{diagrams: off}} should suppress auto-inject grid"
3535 );
3536 }
3537
3538 #[test]
3539 fn test_diagrams_ukulele_instrument() {
3540 let html = render("{diagrams: ukulele}\n[Am]Hello");
3541 assert!(
3542 html.contains("class=\"chord-diagrams\""),
3543 "ukulele diagrams section expected"
3544 );
3545 assert!(html.contains(">Am<"), "Am diagram expected");
3547 }
3548
3549 #[test]
3550 fn test_diagrams_guitar_explicit_overrides_config_default() {
3551 let song = chordsketch_chordpro::parse("{diagrams: guitar}\n[Am]Hello").unwrap();
3554 let config = chordsketch_chordpro::config::Config::defaults()
3555 .with_define("diagrams.instrument=ukulele")
3556 .unwrap();
3557 let html = render_song_with_transpose(&song, 0, &config);
3558 assert!(
3559 html.contains("class=\"chord-diagrams\""),
3560 "guitar diagrams section expected"
3561 );
3562 assert!(html.contains(">Am<"), "Am diagram expected");
3563 let guitar_am_html = render_song_with_transpose(
3564 &chordsketch_chordpro::parse("{diagrams: guitar}\n[Am]Hello").unwrap(),
3565 0,
3566 &chordsketch_chordpro::config::Config::defaults(),
3567 );
3568 let uke_am_html = render_song_with_transpose(
3569 &chordsketch_chordpro::parse("{diagrams: ukulele}\n[Am]Hello").unwrap(),
3570 0,
3571 &chordsketch_chordpro::config::Config::defaults(),
3572 );
3573 assert_ne!(
3575 guitar_am_html, uke_am_html,
3576 "guitar and ukulele Am diagrams should differ"
3577 );
3578 assert_eq!(
3581 html, guitar_am_html,
3582 "{{diagrams: guitar}} must select guitar regardless of config default"
3583 );
3584 }
3585
3586 #[test]
3587 fn test_no_diagrams_suppresses_inline_define_diagrams() {
3588 let html = render("{no_diagrams}\n{define: Am base-fret 1 frets x 0 2 2 1 0}\n[Am]Hello");
3591 assert!(
3592 !html.contains("<svg"),
3593 "{{no_diagrams}} should suppress inline define diagram SVG"
3594 );
3595 }
3596
3597 #[test]
3598 fn test_define_chord_not_duplicated_in_auto_inject_grid() {
3599 let html =
3603 render("{define: Am base-fret 1 frets x 0 2 2 1 0}\n{diagrams}\n[Am]Hello [G]world\n");
3604 let am_svg_count = html.match_indices("font-weight=\"bold\">Am</text>").count();
3606 assert_eq!(
3607 am_svg_count, 1,
3608 "Am diagram should appear exactly once (inline via {{define}}), not also in auto-inject grid"
3609 );
3610 assert!(
3612 html.contains("font-weight=\"bold\">G</text>"),
3613 "G diagram should appear in the auto-inject grid"
3614 );
3615 }
3616
3617 #[test]
3618 fn test_define_after_nodiagrams_appears_in_grid() {
3619 let html = render(
3623 "{no_diagrams}\n{define: Am base-fret 1 frets x 0 2 2 1 0}\n{diagrams}\n[Am]Hello\n",
3624 );
3625 assert!(
3628 html.contains("class=\"chord-diagrams\""),
3629 "auto-inject grid should appear since Am was not rendered inline"
3630 );
3631 assert!(
3632 html.contains("font-weight=\"bold\">Am</text>"),
3633 "Am should appear in the auto-inject grid"
3634 );
3635 }
3636
3637 #[test]
3638 fn test_enharmonic_define_dedup() {
3639 let html = render("{define: Bb base-fret 1 frets x 1 3 3 3 1}\n{diagrams}\n[A#]Hello\n");
3643 let bb_count = html.match_indices("font-weight=\"bold\">Bb</text>").count();
3645 let as_count = html.match_indices("font-weight=\"bold\">A#</text>").count();
3646 assert_eq!(bb_count, 1, "Bb should appear once (inline)");
3647 assert_eq!(
3648 as_count, 0,
3649 "A# should NOT appear in the auto-inject grid (same chord as Bb)"
3650 );
3651 }
3652
3653 #[test]
3654 fn test_chord_directive_appears_in_auto_inject_grid() {
3655 let html = render("{chord: Am base-fret 1 frets x 0 2 2 1 0}\n{diagrams}\n[Am]Hello\n");
3658 assert!(
3661 html.contains("class=\"chord-diagrams\""),
3662 "auto-inject grid should appear since {{chord}} does not render inline"
3663 );
3664 assert!(
3665 html.contains("font-weight=\"bold\">Am</text>"),
3666 "Am should appear in the auto-inject grid via {{chord}} voicing"
3667 );
3668 }
3669
3670 #[test]
3673 fn test_abc_section_disabled_by_config() {
3674 let input = "{start_of_abc}\nX:1\n{end_of_abc}";
3676 let song = chordsketch_chordpro::parse(input).unwrap();
3677 let config = chordsketch_chordpro::config::Config::defaults()
3678 .with_define("delegates.abc2svg=false")
3679 .unwrap();
3680 let html = render_song_with_transpose(&song, 0, &config);
3681 assert!(html.contains("<section class=\"abc\">"));
3682 assert!(html.contains("ABC"));
3683 assert!(html.contains("</section>"));
3684 }
3685
3686 #[test]
3687 fn test_abc_section_null_config_auto_detect_disabled() {
3688 if chordsketch_chordpro::external_tool::has_abc2svg() {
3691 return; }
3693 let input = "{start_of_abc}\nX:1\n{end_of_abc}";
3694 let song = chordsketch_chordpro::parse(input).unwrap();
3695 let config = chordsketch_chordpro::config::Config::defaults();
3697 assert!(
3698 config.get_path("delegates.abc2svg").is_null(),
3699 "default config should have null delegates.abc2svg"
3700 );
3701 let html = render_song_with_transpose(&song, 0, &config);
3702 assert!(
3703 html.contains("<section class=\"abc\">"),
3704 "null auto-detect with no abc2svg should render as text section"
3705 );
3706 }
3707
3708 #[test]
3709 fn test_abc_section_fallback_preformatted() {
3710 if chordsketch_chordpro::external_tool::has_abc2svg() {
3712 return; }
3714 let input = "{start_of_abc}\nX:1\nT:Test\nK:C\n{end_of_abc}";
3715 let song = chordsketch_chordpro::parse(input).unwrap();
3716 let config = chordsketch_chordpro::config::Config::defaults()
3717 .with_define("delegates.abc2svg=true")
3718 .unwrap();
3719 let html = render_song_with_transpose(&song, 0, &config);
3720 assert!(html.contains("<section class=\"abc\">"));
3721 assert!(html.contains("<pre>"));
3722 assert!(html.contains("X:1"));
3723 assert!(html.contains("</pre>"));
3724 }
3725
3726 #[test]
3727 fn test_abc_section_with_label_delegate_fallback() {
3728 if chordsketch_chordpro::external_tool::has_abc2svg() {
3729 return;
3730 }
3731 let input = "{start_of_abc: Melody}\nX:1\n{end_of_abc}";
3732 let song = chordsketch_chordpro::parse(input).unwrap();
3733 let config = chordsketch_chordpro::config::Config::defaults()
3734 .with_define("delegates.abc2svg=true")
3735 .unwrap();
3736 let html = render_song_with_transpose(&song, 0, &config);
3737 assert!(html.contains("ABC: Melody"));
3738 assert!(html.contains("<pre>"));
3739 }
3740
3741 #[test]
3742 #[ignore]
3743 fn test_abc_section_renders_svg_with_abc2svg() {
3744 let input = "{start_of_abc}\nX:1\nT:Test\nM:4/4\nK:C\nCDEF|GABc|\n{end_of_abc}";
3746 let song = chordsketch_chordpro::parse(input).unwrap();
3747 let config = chordsketch_chordpro::config::Config::defaults()
3748 .with_define("delegates.abc2svg=true")
3749 .unwrap();
3750 let html = render_song_with_transpose(&song, 0, &config);
3751 assert!(html.contains("<section class=\"abc\">"));
3752 assert!(
3753 html.contains("<svg"),
3754 "should contain rendered SVG from abc2svg"
3755 );
3756 assert!(html.contains("</section>"));
3757 }
3758
3759 #[test]
3760 fn test_abc_section_auto_detect_default_config() {
3761 let input = "{start_of_abc}\nX:1\nT:Test\nK:C\n{end_of_abc}";
3765 let song = chordsketch_chordpro::parse(input).unwrap();
3766 let config = chordsketch_chordpro::config::Config::defaults();
3767 let html = render_song_with_transpose(&song, 0, &config);
3768 assert!(
3769 html.contains("<section class=\"abc\">"),
3770 "auto-detect should produce abc section"
3771 );
3772 if !chordsketch_chordpro::external_tool::has_abc2svg() {
3773 assert!(
3774 html.contains("X:1"),
3775 "raw ABC content should be present without tool"
3776 );
3777 assert!(
3778 !html.contains("<svg"),
3779 "no SVG should be generated without abc2svg"
3780 );
3781 }
3782 }
3783
3784 #[test]
3787 fn test_ly_section_auto_detect_default_config() {
3788 let input = "{start_of_ly}\n\\relative c' { c4 }\n{end_of_ly}";
3790 let song = chordsketch_chordpro::parse(input).unwrap();
3791 let config = chordsketch_chordpro::config::Config::defaults();
3792 let html = render_song_with_transpose(&song, 0, &config);
3793 assert!(
3794 html.contains("<section class=\"ly\">"),
3795 "auto-detect should produce ly section"
3796 );
3797 if !chordsketch_chordpro::external_tool::has_lilypond() {
3798 assert!(
3799 html.contains("\\relative"),
3800 "raw Lilypond content should be present without tool"
3801 );
3802 assert!(
3803 !html.contains("<svg"),
3804 "no SVG should be generated without lilypond"
3805 );
3806 }
3807 }
3808
3809 #[test]
3810 fn test_ly_section_disabled_by_config() {
3811 let input = "{start_of_ly}\n\\relative c' { c4 }\n{end_of_ly}";
3813 let song = chordsketch_chordpro::parse(input).unwrap();
3814 let config = chordsketch_chordpro::config::Config::defaults()
3815 .with_define("delegates.lilypond=false")
3816 .unwrap();
3817 let html = render_song_with_transpose(&song, 0, &config);
3818 assert!(html.contains("<section class=\"ly\">"));
3819 assert!(html.contains("Lilypond"));
3820 assert!(html.contains("</section>"));
3821 }
3822
3823 #[test]
3824 fn test_ly_section_fallback_preformatted() {
3825 if chordsketch_chordpro::external_tool::has_lilypond() {
3826 return;
3827 }
3828 let input = "{start_of_ly}\n\\relative c' { c4 }\n{end_of_ly}";
3829 let song = chordsketch_chordpro::parse(input).unwrap();
3830 let config = chordsketch_chordpro::config::Config::defaults()
3831 .with_define("delegates.lilypond=true")
3832 .unwrap();
3833 let html = render_song_with_transpose(&song, 0, &config);
3834 assert!(html.contains("<section class=\"ly\">"));
3835 assert!(html.contains("<pre>"));
3836 assert!(html.contains("</pre>"));
3837 }
3838
3839 #[test]
3840 #[ignore]
3841 fn test_ly_section_renders_svg_with_lilypond() {
3842 let input = "{start_of_ly}\n\\relative c' { c4 d e f | g2 g | }\n{end_of_ly}";
3844 let song = chordsketch_chordpro::parse(input).unwrap();
3845 let config = chordsketch_chordpro::config::Config::defaults()
3846 .with_define("delegates.lilypond=true")
3847 .unwrap();
3848 let html = render_song_with_transpose(&song, 0, &config);
3849 assert!(html.contains("<section class=\"ly\">"));
3850 assert!(
3851 html.contains("<svg"),
3852 "should contain rendered SVG from lilypond"
3853 );
3854 assert!(html.contains("</section>"));
3855 }
3856}
3857
3858#[cfg(test)]
3859mod delegate_tests {
3860 use super::*;
3861
3862 #[test]
3863 fn test_render_abc_section() {
3864 let html = render("{start_of_abc}\nX:1\n{end_of_abc}");
3865 assert!(html.contains("<section class=\"abc\">"));
3866 assert!(html.contains("ABC"));
3867 assert!(html.contains("</section>"));
3868 }
3869
3870 #[test]
3871 fn test_render_abc_section_with_label() {
3872 let html = render("{start_of_abc: Melody}\nX:1\n{end_of_abc}");
3873 assert!(html.contains("<section class=\"abc\">"));
3874 assert!(html.contains("ABC: Melody"));
3875 }
3876
3877 #[test]
3878 fn test_render_ly_section() {
3879 let html = render("{start_of_ly}\nnotes\n{end_of_ly}");
3880 assert!(html.contains("<section class=\"ly\">"));
3881 assert!(html.contains("Lilypond"));
3882 assert!(html.contains("</section>"));
3883 }
3884
3885 #[test]
3888 fn test_render_musicxml_section_disabled() {
3889 let input = "{start_of_musicxml}\n<score-partwise/>\n{end_of_musicxml}";
3891 let song = chordsketch_chordpro::parse(input).unwrap();
3892 let config = chordsketch_chordpro::config::Config::defaults()
3893 .with_define("delegates.musescore=false")
3894 .unwrap();
3895 let html = render_song_with_transpose(&song, 0, &config);
3896 assert!(
3897 html.contains("<section class=\"musicxml\">"),
3898 "fallback section should render when musescore is disabled: {html}"
3899 );
3900 assert!(html.contains("MusicXML"), "section label should appear");
3901 assert!(html.contains("</section>"), "section should be closed");
3902 }
3903
3904 #[test]
3905 fn test_render_musicxml_section_no_musescore_installed() {
3906 if chordsketch_chordpro::external_tool::has_musescore() {
3909 return; }
3911
3912 let input = "{start_of_musicxml}\n<score-partwise/>\n{end_of_musicxml}";
3913 let song = chordsketch_chordpro::parse(input).unwrap();
3914 let config = chordsketch_chordpro::config::Config::defaults();
3915 assert!(
3916 config.get_path("delegates.musescore").is_null(),
3917 "default config should have null delegates.musescore"
3918 );
3919 let html = render_song_with_transpose(&song, 0, &config);
3920 assert!(
3921 html.contains("<section class=\"musicxml\">"),
3922 "null auto-detect with no musescore should render as text section"
3923 );
3924 }
3925
3926 #[test]
3927 fn test_render_musicxml_section_with_label() {
3928 let input = "{start_of_musicxml: Score}\n<score-partwise/>\n{end_of_musicxml}";
3929 let song = chordsketch_chordpro::parse(input).unwrap();
3930 let config = chordsketch_chordpro::config::Config::defaults()
3931 .with_define("delegates.musescore=false")
3932 .unwrap();
3933 let html = render_song_with_transpose(&song, 0, &config);
3934 assert!(
3935 html.contains("Score"),
3936 "label should appear in section header"
3937 );
3938 }
3939
3940 #[test]
3941 fn test_abc_fallback_sanitizes_would_be_script_in_svg() {
3942 let malicious_svg = "<svg><script>alert(1)</script><circle r=\"5\"/></svg>";
3946 let sanitized = sanitize_svg_content(malicious_svg);
3947 assert!(
3948 !sanitized.contains("<script>"),
3949 "script tags must be stripped from delegate SVG output"
3950 );
3951 assert!(sanitized.contains("<circle"));
3952 }
3953
3954 #[test]
3955 fn test_sanitize_svg_strips_event_handlers_from_delegate_output() {
3956 let svg_with_handler = "<svg><rect onmouseover=\"alert(1)\" width=\"10\"/></svg>";
3957 let sanitized = sanitize_svg_content(svg_with_handler);
3958 assert!(
3959 !sanitized.contains("onmouseover"),
3960 "event handlers must be stripped from delegate SVG output"
3961 );
3962 assert!(sanitized.contains("<rect"));
3963 }
3964
3965 #[test]
3966 fn test_sanitize_svg_strips_foreignobject_from_delegate_output() {
3967 let svg = "<svg><foreignObject><body xmlns=\"http://www.w3.org/1999/xhtml\"><script>alert(1)</script></body></foreignObject></svg>";
3968 let sanitized = sanitize_svg_content(svg);
3969 assert!(
3970 !sanitized.contains("<foreignObject"),
3971 "foreignObject must be stripped from delegate SVG output"
3972 );
3973 }
3974
3975 #[test]
3976 fn test_sanitize_svg_strips_math_element() {
3977 let svg = "<svg><math><mi>x</mi></math></svg>";
3978 let sanitized = sanitize_svg_content(svg);
3979 assert!(
3980 !sanitized.contains("<math"),
3981 "math element must be stripped from delegate SVG output"
3982 );
3983 }
3984
3985 #[test]
3988 fn test_sanitize_svg_strips_namespaced_script() {
3989 let svg = "<svg:script>alert(1)</svg:script><circle r=\"5\"/>";
3994 let sanitized = sanitize_svg_content(svg);
3995 assert!(
3996 !sanitized.to_ascii_lowercase().contains("script"),
3997 "namespaced <svg:script> must be stripped, got: {sanitized}"
3998 );
3999 assert!(sanitized.contains("<circle"));
4000 }
4001
4002 #[test]
4003 fn test_sanitize_svg_strips_namespaced_iframe_case_insensitive() {
4004 let svg = "<XHTML:Iframe src=\"javascript:alert(1)\"></XHTML:Iframe>text";
4005 let sanitized = sanitize_svg_content(svg);
4006 assert!(
4007 !sanitized.to_ascii_lowercase().contains("iframe"),
4008 "namespaced iframe must be stripped, got: {sanitized}"
4009 );
4010 assert!(sanitized.contains("text"));
4011 }
4012
4013 #[test]
4014 fn test_sanitize_svg_strips_namespaced_foreignobject() {
4015 let svg = "<svg:foreignObject><body><script>x()</script></body></svg:foreignObject>safe";
4016 let sanitized = sanitize_svg_content(svg);
4017 assert!(
4018 !sanitized.to_ascii_lowercase().contains("foreignobject"),
4019 "namespaced foreignObject must be stripped, got: {sanitized}"
4020 );
4021 assert!(!sanitized.to_ascii_lowercase().contains("script"));
4022 assert!(sanitized.contains("safe"));
4023 }
4024
4025 #[test]
4026 fn test_sanitize_svg_strips_stray_namespaced_closing_tag() {
4027 let svg = "lyrics</svg:script>more";
4030 let sanitized = sanitize_svg_content(svg);
4031 assert!(
4032 !sanitized.to_ascii_lowercase().contains("script"),
4033 "stray namespaced closing tag must be stripped, got: {sanitized}"
4034 );
4035 }
4036
4037 #[test]
4038 fn test_render_svg_section() {
4039 let html = render("{start_of_svg}\n<svg/>\n{end_of_svg}");
4040 assert!(html.contains("<div class=\"svg-section\">"));
4042 assert!(html.contains("<svg/>"));
4043 assert!(html.contains("</div>"));
4044 }
4045
4046 #[test]
4047 fn test_render_svg_inline_content() {
4048 let svg = r#"<svg width="100" height="100"><circle cx="50" cy="50" r="40"/></svg>"#;
4049 let input = format!("{{start_of_svg}}\n{svg}\n{{end_of_svg}}");
4050 let html = render(&input);
4051 assert!(html.contains(svg));
4052 }
4053
4054 #[test]
4055 fn test_svg_section_strips_script_tags() {
4056 let input = "{start_of_svg}\n<svg><script>alert('xss')</script><circle r=\"10\"/></svg>\n{end_of_svg}";
4057 let html = render(input);
4058 assert!(!html.contains("<script>"), "script tags must be stripped");
4059 assert!(!html.contains("alert"), "script content must be stripped");
4060 assert!(
4061 html.contains("<circle r=\"10\"/>"),
4062 "safe SVG content must be preserved"
4063 );
4064 }
4065
4066 #[test]
4067 fn test_svg_section_strips_event_handlers() {
4068 let input = "{start_of_svg}\n<svg onload=\"alert(1)\"><rect width=\"10\" onerror=\"hack()\"/></svg>\n{end_of_svg}";
4069 let html = render(input);
4070 assert!(!html.contains("onload"), "onload handler must be stripped");
4071 assert!(
4072 !html.contains("onerror"),
4073 "onerror handler must be stripped"
4074 );
4075 assert!(
4076 html.contains("width=\"10\""),
4077 "safe attributes must be preserved"
4078 );
4079 }
4080
4081 #[test]
4082 fn test_svg_section_preserves_safe_content() {
4083 let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><text x="10" y="20">Hello</text></svg>"#;
4084 let input = format!("{{start_of_svg}}\n{svg}\n{{end_of_svg}}");
4085 let html = render(&input);
4086 assert!(html.contains("xmlns=\"http://www.w3.org/2000/svg\""));
4087 assert!(html.contains("<text x=\"10\" y=\"20\">Hello</text>"));
4088 }
4089
4090 #[test]
4091 fn test_svg_section_strips_case_insensitive_script() {
4092 let input = "{start_of_svg}\n<SCRIPT>alert(1)</SCRIPT><svg/>\n{end_of_svg}";
4093 let html = render(input);
4094 assert!(!html.contains("SCRIPT"), "case-insensitive script removal");
4095 assert!(!html.contains("alert"));
4096 assert!(html.contains("<svg/>"));
4097 }
4098
4099 #[test]
4100 fn test_svg_section_strips_foreignobject() {
4101 let input = "{start_of_svg}\n<svg><foreignObject><body onload=\"alert(1)\"></body></foreignObject><rect width=\"10\"/></svg>\n{end_of_svg}";
4102 let html = render(input);
4103 assert!(
4104 !html.contains("foreignObject"),
4105 "foreignObject must be stripped"
4106 );
4107 assert!(
4108 !html.contains("foreignobject"),
4109 "foreignObject (lowercase) must be stripped"
4110 );
4111 assert!(
4112 html.contains("<rect width=\"10\"/>"),
4113 "safe content must be preserved"
4114 );
4115 }
4116
4117 #[test]
4118 fn test_svg_section_strips_iframe() {
4119 let input = "{start_of_svg}\n<svg><iframe src=\"javascript:alert(1)\"></iframe><circle r=\"5\"/></svg>\n{end_of_svg}";
4120 let html = render(input);
4121 assert!(!html.contains("iframe"), "iframe must be stripped");
4122 assert!(html.contains("<circle r=\"5\"/>"));
4123 }
4124
4125 #[test]
4126 fn test_svg_section_strips_object_and_embed() {
4127 let input = "{start_of_svg}\n<svg><object data=\"evil.swf\"></object><embed src=\"evil.swf\"></embed><rect/></svg>\n{end_of_svg}";
4128 let html = render(input);
4129 assert!(!html.contains("object"), "object must be stripped");
4130 assert!(!html.contains("embed"), "embed must be stripped");
4131 assert!(html.contains("<rect/>"));
4132 }
4133
4134 #[test]
4135 fn test_svg_section_strips_javascript_uri_in_href() {
4136 let input = "{start_of_svg}\n<svg><a href=\"javascript:alert(1)\"><text>Click</text></a></svg>\n{end_of_svg}";
4137 let html = render(input);
4138 assert!(
4139 !html.contains("javascript:"),
4140 "javascript: URI must be stripped from href"
4141 );
4142 assert!(html.contains("<text>Click</text>"));
4143 }
4144
4145 #[test]
4146 fn test_svg_section_strips_vbscript_uri() {
4147 let input = "{start_of_svg}\n<svg><a href=\"vbscript:MsgBox\"><text>Click</text></a></svg>\n{end_of_svg}";
4148 let html = render(input);
4149 assert!(
4150 !html.contains("vbscript:"),
4151 "vbscript: URI must be stripped"
4152 );
4153 }
4154
4155 #[test]
4156 fn test_svg_section_strips_data_uri_in_use() {
4157 let input = "{start_of_svg}\n<svg><use href=\"data:image/svg+xml;base64,PHN2Zy8+\"/></svg>\n{end_of_svg}";
4158 let html = render(input);
4159 assert!(
4160 !html.contains("data:"),
4161 "data: URI must be stripped from use href"
4162 );
4163 }
4164
4165 #[test]
4166 fn test_svg_section_strips_javascript_uri_case_insensitive() {
4167 let input = "{start_of_svg}\n<svg><a href=\"JaVaScRiPt:alert(1)\"><text>X</text></a></svg>\n{end_of_svg}";
4168 let html = render(input);
4169 assert!(
4170 !html.to_lowercase().contains("javascript:"),
4171 "case-insensitive javascript: URI must be stripped"
4172 );
4173 }
4174
4175 #[test]
4176 fn test_svg_section_strips_xlink_href_dangerous_uri() {
4177 let input =
4178 "{start_of_svg}\n<svg><use xlink:href=\"javascript:alert(1)\"/></svg>\n{end_of_svg}";
4179 let html = render(input);
4180 assert!(
4181 !html.contains("javascript:"),
4182 "javascript: URI in xlink:href must be stripped"
4183 );
4184 }
4185
4186 #[test]
4187 fn test_svg_section_preserves_safe_href() {
4188 let input = "{start_of_svg}\n<svg><a href=\"https://example.com\"><text>Link</text></a></svg>\n{end_of_svg}";
4189 let html = render(input);
4190 assert!(
4191 html.contains("href=\"https://example.com\""),
4192 "safe https: href must be preserved"
4193 );
4194 }
4195
4196 #[test]
4197 fn test_svg_section_preserves_fragment_href() {
4198 let input = "{start_of_svg}\n<svg><use href=\"#myShape\"/></svg>\n{end_of_svg}";
4199 let html = render(input);
4200 assert!(
4201 html.contains("href=\"#myShape\""),
4202 "fragment-only href must be preserved"
4203 );
4204 }
4205
4206 #[test]
4207 fn test_svg_section_strips_use_external_https() {
4208 let input = "{start_of_svg}\n<svg><use href=\"https://attacker.example.com/x.svg#sym\"/></svg>\n{end_of_svg}";
4212 let html = render(input);
4213 assert!(
4214 !html.contains("attacker.example.com"),
4215 "external https: URI in <use href> must be stripped; got: {html}"
4216 );
4217 }
4218
4219 #[test]
4220 fn test_svg_section_strips_use_external_xlink_href() {
4221 let input = "{start_of_svg}\n<svg><use xlink:href=\"https://tracker.example/pixel.svg\"/></svg>\n{end_of_svg}";
4223 let html = render(input);
4224 assert!(
4225 !html.contains("tracker.example"),
4226 "external https: URI in <use xlink:href> must be stripped; got: {html}"
4227 );
4228 }
4229
4230 #[test]
4231 fn test_svg_section_preserves_fragment_xlink_href() {
4232 let input = "{start_of_svg}\n<svg><use xlink:href=\"#mySymbol\"/></svg>\n{end_of_svg}";
4233 let html = render(input);
4234 assert!(
4235 html.contains("xlink:href=\"#mySymbol\""),
4236 "fragment-only xlink:href must be preserved"
4237 );
4238 }
4239
4240 #[test]
4241 fn test_render_textblock_section() {
4242 let html = render("{start_of_textblock}\nPreformatted\n{end_of_textblock}");
4243 assert!(html.contains("<section class=\"textblock\">"));
4244 assert!(html.contains("Textblock"));
4245 assert!(html.contains("</section>"));
4246 }
4247
4248 #[test]
4251 fn test_render_songs_single() {
4252 let songs = chordsketch_chordpro::parse_multi("{title: Only}").unwrap();
4253 let html = render_songs(&songs);
4254 assert_eq!(html, render_song(&songs[0]));
4256 }
4257
4258 #[test]
4259 fn test_render_songs_two_songs_with_hr_separator() {
4260 let songs = chordsketch_chordpro::parse_multi(
4261 "{title: Song A}\n[Am]Hello\n{new_song}\n{title: Song B}\n[G]World",
4262 )
4263 .unwrap();
4264 let html = render_songs(&songs);
4265 assert!(html.contains("<title>Song A</title>"));
4267 assert!(html.contains("<h1>Song A</h1>"));
4269 assert!(html.contains("<h1>Song B</h1>"));
4270 assert!(html.contains("<hr class=\"song-separator\">"));
4272 assert_eq!(html.matches("<div class=\"song\">").count(), 2);
4274 assert_eq!(html.matches("<!DOCTYPE html>").count(), 1);
4276 assert_eq!(html.matches("</html>").count(), 1);
4277 }
4278
4279 #[test]
4280 fn test_image_scale_css_injection_prevented() {
4281 let html = render("{image: src=photo.jpg scale=0.5); position: fixed; z-index: 9999}");
4284 assert!(!html.contains("position"));
4285 assert!(!html.contains("z-index"));
4286 assert!(!html.contains("position: fixed"));
4288 }
4289
4290 #[test]
4291 fn test_render_songs_with_transpose() {
4292 let songs =
4293 chordsketch_chordpro::parse_multi("{title: S1}\n[C]Do\n{new_song}\n{title: S2}\n[G]Re")
4294 .unwrap();
4295 let html = render_songs_with_transpose(&songs, 2, &Config::defaults());
4296 assert!(html.contains(">D<"));
4298 assert!(html.contains(">A<"));
4299 }
4300
4301 #[test]
4304 fn test_sanitize_svg_strips_set_element() {
4305 let svg = r##"<svg><a href="#"><set attributeName="href" to="javascript:alert(1)"/><text>Click</text></a></svg>"##;
4306 let sanitized = sanitize_svg_content(svg);
4307 assert!(
4308 !sanitized.contains("<set"),
4309 "set element must be stripped to prevent SVG animation XSS"
4310 );
4311 assert!(sanitized.contains("<text>Click</text>"));
4312 }
4313
4314 #[test]
4315 fn test_sanitize_svg_strips_animate_element() {
4316 let svg =
4317 r#"<svg><animate attributeName="href" values="javascript:alert(1)"/><rect/></svg>"#;
4318 let sanitized = sanitize_svg_content(svg);
4319 assert!(
4320 !sanitized.contains("<animate"),
4321 "animate element must be stripped"
4322 );
4323 assert!(sanitized.contains("<rect/>"));
4324 }
4325
4326 #[test]
4327 fn test_sanitize_svg_strips_animatetransform() {
4328 let svg =
4329 "<svg><animateTransform attributeName=\"transform\" type=\"rotate\"/><rect/></svg>";
4330 let sanitized = sanitize_svg_content(svg);
4331 assert!(
4332 !sanitized.contains("animateTransform"),
4333 "animateTransform must be stripped"
4334 );
4335 assert!(
4336 !sanitized.contains("animatetransform"),
4337 "animatetransform (lowercase) must be stripped"
4338 );
4339 }
4340
4341 #[test]
4342 fn test_sanitize_svg_strips_animatemotion() {
4343 let svg = "<svg><animateMotion path=\"M0,0 L100,100\"/><rect/></svg>";
4344 let sanitized = sanitize_svg_content(svg);
4345 assert!(
4346 !sanitized.contains("animateMotion"),
4347 "animateMotion must be stripped"
4348 );
4349 }
4350
4351 #[test]
4352 fn test_sanitize_svg_strips_to_attr_with_dangerous_uri() {
4353 let svg = r#"<svg><a to="javascript:alert(1)"><text>X</text></a></svg>"#;
4354 let sanitized = sanitize_svg_content(svg);
4355 assert!(
4356 !sanitized.contains("javascript:"),
4357 "dangerous URI in 'to' attr must be stripped"
4358 );
4359 }
4360
4361 #[test]
4362 fn test_sanitize_svg_strips_values_attr_with_dangerous_uri() {
4363 let svg = r#"<svg><a values="javascript:alert(1)"><text>X</text></a></svg>"#;
4364 let sanitized = sanitize_svg_content(svg);
4365 assert!(
4366 !sanitized.contains("javascript:"),
4367 "dangerous URI in 'values' attr must be stripped"
4368 );
4369 }
4370
4371 #[test]
4374 fn test_strip_dangerous_attrs_preserves_cjk_text() {
4375 let input = "<svg><text x=\"10\">日本語テスト</text></svg>";
4376 let result = strip_dangerous_attrs(input);
4377 assert!(
4378 result.contains("日本語テスト"),
4379 "CJK characters must not be corrupted"
4380 );
4381 }
4382
4383 #[test]
4384 fn test_strip_dangerous_attrs_preserves_emoji() {
4385 let input = "<svg><text>🎵🎸🎹</text></svg>";
4386 let result = strip_dangerous_attrs(input);
4387 assert!(result.contains("🎵🎸🎹"), "emoji must not be corrupted");
4388 }
4389
4390 #[test]
4391 fn test_strip_dangerous_attrs_preserves_accented_chars() {
4392 let input = "<svg><text>café résumé naïve</text></svg>";
4393 let result = strip_dangerous_attrs(input);
4394 assert!(
4395 result.contains("café résumé naïve"),
4396 "accented characters must not be corrupted"
4397 );
4398 }
4399
4400 #[test]
4401 fn test_sanitize_svg_full_roundtrip_with_non_ascii() {
4402 let input = "<svg><text x=\"10\">コード譜 🎵</text><rect width=\"100\"/></svg>";
4403 let sanitized = sanitize_svg_content(input);
4404 assert!(sanitized.contains("コード譜 🎵"));
4405 assert!(sanitized.contains("<rect width=\"100\"/>"));
4406 }
4407
4408 #[test]
4409 fn test_sanitize_svg_self_closing_with_gt_in_attr_value() {
4410 let svg = r#"<svg><set to="a>b"/><text>safe</text></svg>"#;
4412 let sanitized = sanitize_svg_content(svg);
4413 assert!(
4414 !sanitized.contains("<set"),
4415 "dangerous <set> element must be stripped"
4416 );
4417 assert!(
4418 sanitized.contains("<text>safe</text>"),
4419 "content after stripped self-closing element must be preserved"
4420 );
4421 }
4422
4423 #[test]
4426 fn test_strip_dangerous_attrs_gt_in_double_quoted_attr() {
4427 let input = r#"<rect title=">" onload="alert(1)"/>"#;
4429 let result = strip_dangerous_attrs(input);
4430 assert!(
4431 !result.contains("onload"),
4432 "onload after quoted > must be stripped"
4433 );
4434 assert!(result.contains("title"));
4435 }
4436
4437 #[test]
4438 fn test_strip_dangerous_attrs_gt_in_single_quoted_attr() {
4439 let input = "<rect title='>' onload=\"alert(1)\"/>";
4440 let result = strip_dangerous_attrs(input);
4441 assert!(
4442 !result.contains("onload"),
4443 "onload after single-quoted > must be stripped"
4444 );
4445 }
4446
4447 #[test]
4450 fn test_dangerous_uri_scheme_with_embedded_tab() {
4451 assert!(has_dangerous_uri_scheme("java\tscript:alert(1)"));
4452 }
4453
4454 #[test]
4455 fn test_dangerous_uri_scheme_with_embedded_newline() {
4456 assert!(has_dangerous_uri_scheme("java\nscript:alert(1)"));
4457 }
4458
4459 #[test]
4460 fn test_dangerous_uri_scheme_with_control_chars() {
4461 assert!(has_dangerous_uri_scheme("java\x00script:alert(1)"));
4462 }
4463
4464 #[test]
4465 fn test_safe_uri_not_flagged() {
4466 assert!(!has_dangerous_uri_scheme("https://example.com"));
4467 }
4468
4469 #[test]
4470 fn test_dangerous_uri_scheme_with_many_embedded_whitespace() {
4471 let payload = "j\ta\tv\ta\ts\tc\tr\ti\tp\tt\t:\ta\tl\te\tr\tt\t(\t1\t)\t";
4474 assert!(
4475 has_dangerous_uri_scheme(payload),
4476 "1 tab between letters should not bypass javascript: detection"
4477 );
4478 }
4479
4480 #[test]
4481 fn test_dangerous_uri_scheme_whitespace_bypass_regression() {
4482 let payload = "j\t\t\ta\t\t\tv\t\t\ta\t\t\ts\t\t\tc\t\t\tr\t\t\ti\t\t\tp\t\t\tt\t\t\t:";
4487 assert!(
4488 has_dangerous_uri_scheme(payload),
4489 "3 tabs between letters (colon at raw position 40) must still be detected"
4490 );
4491 }
4492
4493 #[test]
4496 fn test_dangerous_uri_scheme_with_zero_width_space() {
4497 assert!(
4498 has_dangerous_uri_scheme("java\u{200B}script:alert(1)"),
4499 "ZWSP embedded in javascript: scheme must still be blocked"
4500 );
4501 }
4502
4503 #[test]
4504 fn test_dangerous_uri_scheme_with_zero_width_joiner() {
4505 assert!(
4506 has_dangerous_uri_scheme("vb\u{200D}script:alert(1)"),
4507 "ZWJ embedded in vbscript: scheme must still be blocked"
4508 );
4509 }
4510
4511 #[test]
4512 fn test_dangerous_uri_scheme_with_byte_order_mark() {
4513 assert!(
4514 has_dangerous_uri_scheme("java\u{FEFF}script:alert(1)"),
4515 "BOM/ZWNBSP embedded in javascript: scheme must still be blocked"
4516 );
4517 }
4518
4519 #[test]
4520 fn test_dangerous_uri_scheme_with_soft_hyphen() {
4521 assert!(
4522 has_dangerous_uri_scheme("data\u{00AD}:text/html,xss"),
4523 "soft hyphen embedded in data: scheme must still be blocked"
4524 );
4525 }
4526
4527 #[test]
4528 fn test_dangerous_uri_scheme_with_bidi_override() {
4529 assert!(
4530 has_dangerous_uri_scheme("\u{202E}javascript:alert(1)"),
4531 "leading bidi override must not hide the scheme"
4532 );
4533 assert!(
4534 has_dangerous_uri_scheme("java\u{202A}script:alert(1)"),
4535 "embedded bidi override must not hide the scheme"
4536 );
4537 }
4538
4539 #[test]
4540 fn test_dangerous_uri_scheme_safe_after_unicode_filter() {
4541 assert!(!has_dangerous_uri_scheme("https://example.com/a\u{200B}b"));
4544 }
4545
4546 #[test]
4547 fn test_dangerous_uri_scheme_with_lrm() {
4548 assert!(
4552 has_dangerous_uri_scheme("java\u{200E}script:alert(1)"),
4553 "LRM embedded in javascript: scheme must still be blocked"
4554 );
4555 }
4556
4557 #[test]
4558 fn test_dangerous_uri_scheme_with_rlm() {
4559 assert!(
4561 has_dangerous_uri_scheme("vb\u{200F}script:alert(1)"),
4562 "RLM embedded in vbscript: scheme must still be blocked"
4563 );
4564 }
4565
4566 #[test]
4569 fn test_sanitize_svg_strips_namespaced_script_with_dot_in_prefix() {
4570 let svg = "<foo.bar:script>alert(1)</foo.bar:script>text";
4575 let sanitized = sanitize_svg_content(svg);
4576 assert!(
4577 !sanitized.to_ascii_lowercase().contains("script"),
4578 "`foo.bar:script` must be stripped, got: {sanitized}"
4579 );
4580 assert!(sanitized.contains("text"));
4581 }
4582
4583 #[test]
4586 fn test_svg_section_blocks_multiline_script_tag_splitting() {
4587 let input = "{start_of_svg}\n<script\n>alert(1)</script>\n{end_of_svg}";
4589 let html = render(input);
4590 assert!(
4591 !html.contains("alert(1)"),
4592 "multi-line <script> tag splitting must not execute JS"
4593 );
4594 assert!(
4595 !html.to_lowercase().contains("<script"),
4596 "multi-line <script> tag must be stripped"
4597 );
4598 }
4599
4600 #[test]
4601 fn test_svg_section_blocks_multiline_iframe_tag_splitting() {
4602 let input =
4603 "{start_of_svg}\n<iframe\nsrc=\"javascript:alert(1)\">\n</iframe>\n{end_of_svg}";
4604 let html = render(input);
4605 assert!(
4606 !html.to_lowercase().contains("<iframe"),
4607 "multi-line <iframe> tag splitting must be stripped"
4608 );
4609 assert!(
4610 !html.contains("javascript:"),
4611 "javascript: URI in split iframe must be stripped"
4612 );
4613 }
4614
4615 #[test]
4616 fn test_svg_section_blocks_multiline_foreignobject_splitting() {
4617 let input = "{start_of_svg}\n<foreignObject\n><script>alert(1)</script></foreignObject>\n{end_of_svg}";
4618 let html = render(input);
4619 assert!(
4620 !html.to_lowercase().contains("<foreignobject"),
4621 "multi-line <foreignObject> splitting must be stripped"
4622 );
4623 }
4624
4625 #[test]
4628 fn test_dangerous_uri_file_scheme_blocked() {
4629 assert!(
4631 has_dangerous_uri_scheme("file:///etc/passwd"),
4632 "file: URI scheme must be detected as dangerous"
4633 );
4634 assert!(
4635 has_dangerous_uri_scheme("FILE:///etc/passwd"),
4636 "FILE: (uppercase) must be detected as dangerous"
4637 );
4638 }
4639
4640 #[test]
4641 fn test_dangerous_uri_blob_scheme_blocked() {
4642 assert!(
4643 has_dangerous_uri_scheme("blob:https://example.com/uuid"),
4644 "blob: URI scheme must be detected as dangerous"
4645 );
4646 assert!(
4647 has_dangerous_uri_scheme("BLOB:https://example.com/uuid"),
4648 "BLOB: (uppercase) must be detected as dangerous"
4649 );
4650 }
4651
4652 #[test]
4653 fn test_svg_section_strips_file_uri_in_use_href() {
4654 let input = "{start_of_svg}\n<svg><use href=\"file:///etc/passwd\"/></svg>\n{end_of_svg}";
4656 let html = render(input);
4657 assert!(
4658 !html.contains("file:///"),
4659 "file: URI in <use href> must be stripped; got: {html}"
4660 );
4661 }
4662
4663 #[test]
4664 fn test_svg_section_strips_file_uri_in_xlink_href() {
4665 let input =
4666 "{start_of_svg}\n<svg><use xlink:href=\"file:///etc/passwd\"/></svg>\n{end_of_svg}";
4667 let html = render(input);
4668 assert!(
4669 !html.contains("file:///"),
4670 "file: URI in xlink:href must be stripped; got: {html}"
4671 );
4672 }
4673
4674 #[test]
4677 fn test_svg_section_strips_feimage_element() {
4678 let input =
4680 "{start_of_svg}\n<svg><feImage href=\"file:///etc/passwd\"/></svg>\n{end_of_svg}";
4681 let html = render(input);
4682 assert!(
4683 !html.to_lowercase().contains("<feimage"),
4684 "feImage element must be stripped entirely; got: {html}"
4685 );
4686 assert!(
4687 !html.contains("file:///"),
4688 "file: URI inside feImage must not appear in output; got: {html}"
4689 );
4690 }
4691
4692 #[test]
4693 fn test_svg_section_strips_feimage_with_http_href() {
4694 let input = "{start_of_svg}\n<svg><feImage href=\"https://evil.example.com/spy.svg\"/></svg>\n{end_of_svg}";
4696 let html = render(input);
4697 assert!(
4698 !html.to_lowercase().contains("<feimage"),
4699 "feImage element must be stripped even with http href; got: {html}"
4700 );
4701 }
4702
4703 #[test]
4706 fn test_svg_section_strips_action_javascript_uri() {
4707 let input =
4709 "{start_of_svg}\n<svg><a action=\"javascript:alert(1)\">click</a></svg>\n{end_of_svg}";
4710 let html = render(input);
4711 assert!(
4712 !html.contains("javascript:"),
4713 "javascript: URI in action attribute must be stripped; got: {html}"
4714 );
4715 }
4716
4717 #[test]
4718 fn test_svg_section_strips_formaction_javascript_uri() {
4719 let input = "{start_of_svg}\n<svg><a formaction=\"javascript:alert(1)\">click</a></svg>\n{end_of_svg}";
4720 let html = render(input);
4721 assert!(
4722 !html.contains("javascript:"),
4723 "javascript: URI in formaction attribute must be stripped; got: {html}"
4724 );
4725 }
4726
4727 #[test]
4728 fn test_svg_section_strips_ping_javascript_uri() {
4729 let input =
4731 "{start_of_svg}\n<svg><a ping=\"javascript:alert(1)\">click</a></svg>\n{end_of_svg}";
4732 let html = render(input);
4733 assert!(
4734 !html.contains("javascript:"),
4735 "javascript: URI in ping attribute must be stripped; got: {html}"
4736 );
4737 }
4738
4739 #[test]
4740 fn test_svg_section_strips_poster_file_uri() {
4741 let input =
4743 "{start_of_svg}\n<svg><video poster=\"file:///etc/passwd\"/></svg>\n{end_of_svg}";
4744 let html = render(input);
4745 assert!(
4746 !html.contains("file:///"),
4747 "file: URI in poster attribute must be stripped; got: {html}"
4748 );
4749 }
4750
4751 #[test]
4752 fn test_svg_section_strips_background_file_uri() {
4753 let input =
4755 "{start_of_svg}\n<svg><body background=\"file:///etc/passwd\"/></svg>\n{end_of_svg}";
4756 let html = render(input);
4757 assert!(
4758 !html.contains("file:///"),
4759 "file: URI in background attribute must be stripped; got: {html}"
4760 );
4761 }
4762
4763 #[test]
4766 fn test_dangerous_uri_mhtml_scheme_blocked() {
4767 assert!(
4769 has_dangerous_uri_scheme("mhtml:file://C:/page.mhtml"),
4770 "mhtml: URI scheme must be detected as dangerous"
4771 );
4772 assert!(
4773 has_dangerous_uri_scheme("MHTML:file://C:/page.mhtml"),
4774 "MHTML: (uppercase) must be detected as dangerous"
4775 );
4776 }
4777
4778 #[test]
4781 fn test_svg_section_strips_image_element() {
4782 let input =
4785 "{start_of_svg}\n<svg><image href=\"https://evil.com/spy.png\"/></svg>\n{end_of_svg}";
4786 let html = render(input);
4787 assert!(
4788 !html.to_lowercase().contains("<image"),
4789 "SVG <image> element must be stripped entirely; got: {html}"
4790 );
4791 }
4792
4793 #[test]
4796 fn test_extreme_textsize_is_clamped_to_max() {
4797 let input = "{title: T}\n{textsize: 99999}\n[C]Hello";
4800 let html = render(input);
4801 assert!(
4802 !html.contains("99999"),
4803 "extreme textsize should be clamped, not passed through"
4804 );
4805 assert!(
4806 html.contains("200"),
4807 "extreme textsize should be clamped to MAX_FONT_SIZE (200)"
4808 );
4809 }
4810
4811 #[test]
4812 fn test_negative_textsize_is_clamped_to_min() {
4813 let input = "{title: T}\n{textsize: -10}\n[C]Hello";
4816 let html = render(input);
4817 assert!(
4818 html.contains("0.5"),
4819 "negative textsize should be clamped to MIN_FONT_SIZE (0.5)"
4820 );
4821 }
4822
4823 #[test]
4824 fn test_extreme_chordsize_is_clamped_to_max() {
4825 let input = "{title: T}\n{chordsize: 50000}\n[C]Hello";
4826 let html = render(input);
4827 assert!(
4828 !html.contains("50000"),
4829 "extreme chordsize should be clamped"
4830 );
4831 assert!(
4832 html.contains("200"),
4833 "extreme chordsize should be clamped to MAX_FONT_SIZE (200)"
4834 );
4835 }
4836}