1use std::fmt::Write;
23
24mod music_glyphs;
25
26use chordsketch_chordpro::ast::{CommentStyle, DirectiveKind, Line, LyricsLine, Song};
27use chordsketch_chordpro::canonical_chord_name;
28use chordsketch_chordpro::config::Config;
29use chordsketch_chordpro::escape::escape_xml as escape;
30use chordsketch_chordpro::inline_markup::{SpanAttributes, TextSpan};
31use chordsketch_chordpro::render_result::{
32 RenderResult, push_warning, validate_capo, validate_multiple_capo, validate_strict_key,
33};
34use chordsketch_chordpro::resolve_diagrams_instrument;
35use chordsketch_chordpro::transpose::{transpose_chord_with_style, transposed_key_prefers_flat};
36use chordsketch_chordpro::typography::{tempo_marking_for, unicode_accidentals};
37
38const MAX_CHORUS_RECALLS: usize = 1000;
41
42pub use chordsketch_chordpro::render_result::MAX_WARNINGS;
47
48const MAX_COLUMNS: u32 = 32;
51
52const MIN_FONT_SIZE: f32 = 0.5;
55const MAX_FONT_SIZE: f32 = 200.0;
58
59#[derive(Default, Clone)]
69struct ElementStyle {
70 font: Option<String>,
71 size: Option<String>,
72 colour: Option<String>,
73}
74
75impl ElementStyle {
76 fn to_css(&self) -> String {
81 let mut css = String::new();
82 if let Some(ref font) = self.font {
83 let _ = write!(css, "font-family: {};", sanitize_css_value(font));
84 }
85 if let Some(ref size) = self.size {
86 let safe = sanitize_css_value(size);
87 if safe.chars().all(|c| c.is_ascii_digit()) {
88 let _ = write!(css, "font-size: {safe}pt;");
89 } else {
90 let _ = write!(css, "font-size: {safe};");
91 }
92 }
93 if let Some(ref colour) = self.colour {
94 let _ = write!(css, "color: {};", sanitize_css_value(colour));
95 }
96 css
97 }
98}
99
100#[derive(Default, Clone)]
102struct FormattingState {
103 text: ElementStyle,
104 chord: ElementStyle,
105 tab: ElementStyle,
106 title: ElementStyle,
107 chorus: ElementStyle,
108 label: ElementStyle,
109 grid: ElementStyle,
110}
111
112impl FormattingState {
113 fn apply(&mut self, kind: &DirectiveKind, value: &Option<String>) {
119 let val = value.clone();
120 let clamped_size = || -> Option<String> {
121 value
122 .as_deref()
123 .and_then(|v| v.parse::<f32>().ok())
124 .map(|s| s.clamp(MIN_FONT_SIZE, MAX_FONT_SIZE).to_string())
125 };
126 match kind {
127 DirectiveKind::TextFont => self.text.font = val,
128 DirectiveKind::TextSize => self.text.size = clamped_size(),
129 DirectiveKind::TextColour => self.text.colour = val,
130 DirectiveKind::ChordFont => self.chord.font = val,
131 DirectiveKind::ChordSize => self.chord.size = clamped_size(),
132 DirectiveKind::ChordColour => self.chord.colour = val,
133 DirectiveKind::TabFont => self.tab.font = val,
134 DirectiveKind::TabSize => self.tab.size = clamped_size(),
135 DirectiveKind::TabColour => self.tab.colour = val,
136 DirectiveKind::TitleFont => self.title.font = val,
137 DirectiveKind::TitleSize => self.title.size = clamped_size(),
138 DirectiveKind::TitleColour => self.title.colour = val,
139 DirectiveKind::ChorusFont => self.chorus.font = val,
140 DirectiveKind::ChorusSize => self.chorus.size = clamped_size(),
141 DirectiveKind::ChorusColour => self.chorus.colour = val,
142 DirectiveKind::LabelFont => self.label.font = val,
143 DirectiveKind::LabelSize => self.label.size = clamped_size(),
144 DirectiveKind::LabelColour => self.label.colour = val,
145 DirectiveKind::GridFont => self.grid.font = val,
146 DirectiveKind::GridSize => self.grid.size = clamped_size(),
147 DirectiveKind::GridColour => self.grid.colour = val,
148 _ => {}
150 }
151 }
152}
153
154#[must_use]
163pub fn render_song(song: &Song) -> String {
164 render_song_with_transpose(song, 0, &Config::defaults())
165}
166
167#[must_use]
175pub fn render_song_with_transpose(song: &Song, cli_transpose: i8, config: &Config) -> String {
176 let result = render_song_with_warnings(song, cli_transpose, config);
177 for w in &result.warnings {
178 eprintln!("warning: {w}");
179 }
180 result.output
181}
182
183#[must_use = "caller must check warnings in the returned RenderResult"]
189pub fn render_song_with_warnings(
190 song: &Song,
191 cli_transpose: i8,
192 config: &Config,
193) -> RenderResult<String> {
194 let mut warnings = Vec::new();
195 let title = song.metadata.title.as_deref().unwrap_or("Untitled");
196 let mut html = String::new();
197 let _ = write!(
198 html,
199 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>{}</title>\n",
200 escape(title)
201 );
202 html.push_str("<style>\n");
203 html.push_str(&css_for_wraplines(read_wraplines(config)));
204 html.push_str("</style>\n</head>\n<body>\n");
205 render_song_body_into(song, cli_transpose, config, &mut html, &mut warnings);
206 html.push_str("</body>\n</html>\n");
207 RenderResult::with_warnings(html, warnings)
208}
209
210fn render_song_body_into(
216 song: &Song,
217 cli_transpose: i8,
218 config: &Config,
219 html: &mut String,
220 warnings: &mut Vec<String>,
221) {
222 let song_overrides = song.config_overrides();
224 let song_config;
225 let config = if song_overrides.is_empty() {
226 config
227 } else {
228 song_config = config
229 .clone()
230 .with_song_overrides(&song_overrides, warnings);
231 &song_config
232 };
233 let song_transpose_delta = Config::song_transpose_delta(&song_overrides);
236 let (combined_transpose, _) =
237 chordsketch_chordpro::transpose::combine_transpose(cli_transpose, song_transpose_delta);
238 let mut transpose_offset: i8 = combined_transpose;
239 let mut fmt_state = FormattingState::default();
240 html.push_str("<article class=\"song\">\n");
247
248 validate_capo(&song.metadata, warnings);
249 validate_multiple_capo(song, warnings);
250 validate_strict_key(&song.metadata, config, warnings);
251 render_metadata(&song.metadata, html);
252
253 let mut columns_open = false;
255 let mut svg_buf: Option<String> = None;
258 let mut in_grid = false;
263 let mut abc2svg_resolved: Option<bool> = config.get_path("delegates.abc2svg").as_bool();
268 let mut lilypond_resolved: Option<bool> = config.get_path("delegates.lilypond").as_bool();
269 let mut musescore_resolved: Option<bool> = config.get_path("delegates.musescore").as_bool();
270 let mut abc_buf: Option<String> = None;
271 let mut abc_label: Option<String> = None;
272 let mut ly_buf: Option<String> = None;
273 let mut ly_label: Option<String> = None;
274 let mut musicxml_buf: Option<String> = None;
275 let mut musicxml_label: Option<String> = None;
276
277 let mut show_diagrams = true;
279
280 let diagram_frets = config.get_path("diagrams.frets").as_f64().map_or(
282 chordsketch_chordpro::chord_diagram::DEFAULT_FRETS_SHOWN,
283 |n| (n as usize).max(1),
284 );
285
286 let default_instrument = config
290 .get_path("diagrams.instrument")
291 .as_str()
292 .map(str::to_ascii_lowercase)
293 .unwrap_or_else(|| "guitar".to_string());
294 let mut auto_diagrams_instrument: Option<String> = None;
295 let mut inline_defined: std::collections::HashSet<String> = std::collections::HashSet::new();
305
306 let mut chorus_body: Vec<Line> = Vec::new();
309 let mut chorus_buf: Option<Vec<Line>> = None;
311 let mut saved_fmt_state: Option<FormattingState> = None;
314 let mut chorus_recall_count: usize = 0;
315
316 for line in &song.lines {
317 match line {
318 Line::Lyrics(lyrics_line) => {
319 if let Some(ref mut buf) = svg_buf {
320 let raw = lyrics_line.text();
324 buf.push_str(&raw);
325 buf.push('\n');
326 } else if let Some(ref mut buf) = abc_buf {
327 let raw = lyrics_line.text();
329 buf.push_str(&raw);
330 buf.push('\n');
331 } else if let Some(ref mut buf) = ly_buf {
332 let raw = lyrics_line.text();
334 buf.push_str(&raw);
335 buf.push('\n');
336 } else if let Some(ref mut buf) = musicxml_buf {
337 let raw = lyrics_line.text();
339 buf.push_str(&raw);
340 buf.push('\n');
341 } else if in_grid {
342 if let Some(buf) = chorus_buf.as_mut() {
343 buf.push(line.clone());
344 }
345 render_grid_line(&lyrics_line.text(), html);
346 } else {
347 if let Some(buf) = chorus_buf.as_mut() {
348 buf.push(line.clone());
349 }
350 let prefer_flat = transposed_key_prefers_flat(&song.metadata, transpose_offset);
351 render_lyrics(lyrics_line, transpose_offset, prefer_flat, &fmt_state, html);
352 }
353 }
354 Line::Directive(directive) => {
355 if directive.kind.is_metadata() {
356 if let Some(value) = directive
366 .value
367 .as_deref()
368 .map(str::trim)
369 .filter(|v| !v.is_empty())
370 {
371 match directive.kind {
388 DirectiveKind::Key => {
389 html.push_str(&format!(
390 "<span class=\"meta-inline meta-inline--key\">\
391 {glyph}\
392 <span class=\"meta-inline__label\">Key:</span> \
393 <span class=\"meta-inline__value\">{val}</span></span>\n",
394 glyph = music_glyphs::key_signature_svg(value),
395 val = escape(&unicode_accidentals(value)),
396 ));
397 }
398 DirectiveKind::Tempo => {
399 let marking = value
407 .trim()
408 .parse::<f32>()
409 .ok()
410 .and_then(tempo_marking_for)
411 .map(|m| {
412 format!(
413 " <span class=\"meta-inline__marking\">({m})</span>"
414 )
415 })
416 .unwrap_or_default();
417 html.push_str(&format!(
418 "<span class=\"meta-inline meta-inline--tempo\">\
419 {glyph}\
420 <span class=\"meta-inline__value\">{val} BPM{marking}</span></span>\n",
421 glyph = music_glyphs::metronome_svg(value),
422 val = escape(value),
423 ));
424 }
425 DirectiveKind::Time => {
426 html.push_str(&format!(
427 "<span class=\"meta-inline meta-inline--time\">\
428 <span class=\"meta-inline__label\">Time:</span> \
429 {glyph}</span>\n",
430 glyph = music_glyphs::time_signature_html(value),
431 ));
432 }
433 _ => {}
434 }
435 }
436 continue;
437 }
438 if directive.kind == DirectiveKind::Diagrams {
439 auto_diagrams_instrument = resolve_diagrams_instrument(
440 directive.value.as_deref(),
441 &default_instrument,
442 );
443 show_diagrams = auto_diagrams_instrument.is_some();
444 continue;
445 }
446 if directive.kind == DirectiveKind::NoDiagrams {
447 show_diagrams = false;
448 auto_diagrams_instrument = None;
449 continue;
450 }
451 if directive.kind == DirectiveKind::Transpose {
452 let file_offset: i8 = match directive.value.as_deref() {
455 None | Some("") => 0,
456 Some(raw) => match raw.parse() {
457 Ok(v) => v,
458 Err(_) => {
459 push_warning(
460 warnings,
461 format!(
462 "{{transpose}} value {raw:?} cannot be \
463 parsed as i8, ignored (using 0)"
464 ),
465 );
466 0
467 }
468 },
469 };
470 let (combined, saturated) = chordsketch_chordpro::transpose::combine_transpose(
471 file_offset,
472 cli_transpose,
473 );
474 if saturated {
475 push_warning(
476 warnings,
477 format!(
478 "transpose offset {file_offset} + {cli_transpose} \
479 exceeds i8 range, clamped to {combined}"
480 ),
481 );
482 }
483 transpose_offset = combined;
484 continue;
485 }
486 if directive.kind.is_font_size_color() {
487 if let Some(buf) = chorus_buf.as_mut() {
488 buf.push(line.clone());
489 }
490 fmt_state.apply(&directive.kind, &directive.value);
491 continue;
492 }
493 match &directive.kind {
494 DirectiveKind::StartOfChorus => {
495 render_section_open("chorus", "Chorus", &directive.value, html);
496 chorus_buf = Some(Vec::new());
497 saved_fmt_state = Some(fmt_state.clone());
500 }
501 DirectiveKind::EndOfChorus => {
502 html.push_str("</section>\n");
503 if let Some(buf) = chorus_buf.take() {
504 chorus_body = buf;
505 }
506 if let Some(saved) = saved_fmt_state.take() {
508 fmt_state = saved;
509 }
510 }
511 DirectiveKind::Chorus => {
512 if chorus_recall_count < MAX_CHORUS_RECALLS {
513 let prefer_flat =
514 transposed_key_prefers_flat(&song.metadata, transpose_offset);
515 render_chorus_recall(
516 &directive.value,
517 &ChorusRecallCtx {
518 chorus_body: &chorus_body,
519 transpose_offset,
520 prefer_flat,
521 fmt_state: &fmt_state,
522 show_diagrams,
523 diagram_frets,
524 },
525 html,
526 );
527 chorus_recall_count += 1;
528 } else if chorus_recall_count == MAX_CHORUS_RECALLS {
529 push_warning(
530 warnings,
531 format!(
532 "chorus recall limit ({MAX_CHORUS_RECALLS}) reached, \
533 further recalls suppressed"
534 ),
535 );
536 chorus_recall_count += 1;
537 }
538 }
539 DirectiveKind::Columns => {
540 let n: u32 = directive
544 .value
545 .as_deref()
546 .and_then(|v| v.trim().parse().ok())
547 .unwrap_or(1)
548 .clamp(1, MAX_COLUMNS);
549 if columns_open {
550 html.push_str("</div>\n");
551 columns_open = false;
552 }
553 if n > 1 {
554 let _ = writeln!(
555 html,
556 "<div style=\"column-count: {n};column-gap: 2em;\">"
557 );
558 columns_open = true;
559 }
560 }
561 DirectiveKind::ColumnBreak => {
567 html.push_str("<div style=\"break-before: column;\"></div>\n");
568 }
569 DirectiveKind::NewPage => {
570 html.push_str("<div style=\"break-before: page;\"></div>\n");
571 }
572 DirectiveKind::NewPhysicalPage => {
573 html.push_str("<div style=\"break-before: recto;\"></div>\n");
577 }
578 DirectiveKind::StartOfAbc => {
579 #[cfg(not(target_arch = "wasm32"))]
580 let enabled = *abc2svg_resolved
581 .get_or_insert_with(chordsketch_chordpro::external_tool::has_abc2svg);
582 #[cfg(target_arch = "wasm32")]
583 let enabled = *abc2svg_resolved.get_or_insert(false);
584 if enabled {
585 abc_buf = Some(String::new());
586 abc_label = directive.value.clone();
587 } else {
588 if let Some(buf) = chorus_buf.as_mut() {
589 buf.push(line.clone());
590 }
591 render_directive_inner(directive, show_diagrams, diagram_frets, html);
592 }
593 }
594 DirectiveKind::EndOfAbc if abc_buf.is_some() => {
595 if let Some(abc_content) = abc_buf.take() {
596 render_abc_with_fallback(&abc_content, &abc_label, html, warnings);
597 abc_label = None;
598 }
599 }
600 DirectiveKind::StartOfLy => {
601 #[cfg(not(target_arch = "wasm32"))]
602 let enabled = *lilypond_resolved
603 .get_or_insert_with(chordsketch_chordpro::external_tool::has_lilypond);
604 #[cfg(target_arch = "wasm32")]
605 let enabled = *lilypond_resolved.get_or_insert(false);
606 if enabled {
607 ly_buf = Some(String::new());
608 ly_label = directive.value.clone();
609 } else {
610 if let Some(buf) = chorus_buf.as_mut() {
611 buf.push(line.clone());
612 }
613 render_directive_inner(directive, show_diagrams, diagram_frets, html);
614 }
615 }
616 DirectiveKind::EndOfLy if ly_buf.is_some() => {
617 if let Some(ly_content) = ly_buf.take() {
618 render_ly_with_fallback(&ly_content, &ly_label, html, warnings);
619 ly_label = None;
620 }
621 }
622 DirectiveKind::StartOfMusicxml => {
623 #[cfg(not(target_arch = "wasm32"))]
624 let enabled = *musescore_resolved
625 .get_or_insert_with(chordsketch_chordpro::external_tool::has_musescore);
626 #[cfg(target_arch = "wasm32")]
627 let enabled = *musescore_resolved.get_or_insert(false);
628 if enabled {
629 musicxml_buf = Some(String::new());
630 musicxml_label = directive.value.clone();
631 } else {
632 if let Some(buf) = chorus_buf.as_mut() {
633 buf.push(line.clone());
634 }
635 render_directive_inner(directive, show_diagrams, diagram_frets, html);
636 }
637 }
638 DirectiveKind::EndOfMusicxml if musicxml_buf.is_some() => {
639 if let Some(musicxml_content) = musicxml_buf.take() {
640 render_musicxml_with_fallback(
641 &musicxml_content,
642 &musicxml_label,
643 html,
644 warnings,
645 );
646 musicxml_label = None;
647 }
648 }
649 DirectiveKind::StartOfSvg => {
650 svg_buf = Some(String::new());
651 }
652 DirectiveKind::EndOfSvg if svg_buf.is_some() => {
653 if let Some(svg_content) = svg_buf.take() {
654 html.push_str("<div class=\"svg-section\">\n");
655 html.push_str(&sanitize_svg_content(&svg_content));
656 html.push('\n');
657 html.push_str("</div>\n");
658 }
659 }
660 DirectiveKind::StartOfGrid => {
661 if let Some(buf) = chorus_buf.as_mut() {
662 buf.push(line.clone());
663 }
664 let label_value = directive.value.as_ref().and_then(|v| {
681 if let Some(label) = chordsketch_chordpro::grid::extract_grid_label(v) {
682 Some(label)
683 } else if !v.contains('=') {
684 Some(v.clone())
685 } else {
686 None
687 }
688 });
689 render_section_open("grid", "Grid", &label_value, html);
690 in_grid = true;
691 }
692 DirectiveKind::EndOfGrid => {
693 if let Some(buf) = chorus_buf.as_mut() {
694 buf.push(line.clone());
695 }
696 html.push_str("</section>\n");
697 in_grid = false;
698 }
699 _ => {
700 if let Some(buf) = chorus_buf.as_mut() {
701 buf.push(line.clone());
702 }
703 if directive.kind == DirectiveKind::Define && show_diagrams {
706 if let Some(ref val) = directive.value {
707 let name =
708 chordsketch_chordpro::ast::ChordDefinition::parse_value(val)
709 .name;
710 if !name.is_empty() {
711 inline_defined.insert(canonical_chord_name(&name));
712 }
713 }
714 }
715 render_directive_inner(directive, show_diagrams, diagram_frets, html);
716 }
717 }
718 }
719 Line::Comment(style, text) => {
720 if let Some(buf) = chorus_buf.as_mut() {
721 buf.push(line.clone());
722 }
723 render_comment(*style, text, html);
724 }
725 Line::Empty => {
726 if let Some(buf) = chorus_buf.as_mut() {
727 buf.push(line.clone());
728 }
729 html.push_str("<div class=\"empty-line\" aria-hidden=\"true\"></div>\n");
730 }
731 }
732 }
733
734 if columns_open {
736 html.push_str("</div>\n");
737 }
738
739 if let Some(ref instrument) = auto_diagrams_instrument {
741 let chord_names: Vec<String> = song
745 .used_chord_names()
746 .into_iter()
747 .filter(|name| !inline_defined.contains(&canonical_chord_name(name)))
748 .collect();
749
750 if instrument == "piano" {
751 let kbd_defines = song.keyboard_defines();
753 let voicings: Vec<_> = chord_names
754 .into_iter()
755 .filter_map(|name| {
756 chordsketch_chordpro::lookup_keyboard_voicing(&name, &kbd_defines)
757 })
758 .collect();
759 if !voicings.is_empty() {
760 html.push_str("<section class=\"chord-diagrams\" aria-labelledby=\"cs-chord-diagrams-label\">\n");
761 html.push_str("<h3 id=\"cs-chord-diagrams-label\" class=\"section-label\">Chord Diagrams</h3>\n");
762 html.push_str("<div class=\"chord-diagrams-grid\">\n");
763 for voicing in &voicings {
764 html.push_str("<figure class=\"chord-diagram-container\">");
765 html.push_str(&chordsketch_chordpro::chord_diagram::render_keyboard_svg(
766 voicing,
767 ));
768 html.push_str("</figure>\n");
769 }
770 html.push_str("</div>\n");
771 html.push_str("</section>\n");
772 }
773 } else {
774 let defines = song.fretted_defines();
776 let diagrams: Vec<_> = chord_names
777 .into_iter()
778 .filter_map(|name| {
779 chordsketch_chordpro::lookup_diagram(&name, &defines, instrument, diagram_frets)
780 })
781 .collect();
782 if !diagrams.is_empty() {
783 html.push_str("<section class=\"chord-diagrams\" aria-labelledby=\"cs-chord-diagrams-label\">\n");
784 html.push_str("<h3 id=\"cs-chord-diagrams-label\" class=\"section-label\">Chord Diagrams</h3>\n");
785 html.push_str("<div class=\"chord-diagrams-grid\">\n");
786 for diagram in &diagrams {
787 html.push_str("<figure class=\"chord-diagram-container\">");
788 html.push_str(&chordsketch_chordpro::chord_diagram::render_svg(diagram));
789 html.push_str("</figure>\n");
790 }
791 html.push_str("</div>\n");
792 html.push_str("</section>\n");
793 }
794 }
795 }
796
797 html.push_str("</article>\n");
801}
802
803#[must_use]
805pub fn render_songs(songs: &[Song]) -> String {
806 render_songs_with_transpose(songs, 0, &Config::defaults())
807}
808
809#[must_use]
818pub fn render_songs_with_transpose(songs: &[Song], cli_transpose: i8, config: &Config) -> String {
819 let result = render_songs_with_warnings(songs, cli_transpose, config);
820 for w in &result.warnings {
821 eprintln!("warning: {w}");
822 }
823 result.output
824}
825
826#[must_use = "caller must check warnings in the returned RenderResult"]
833pub fn render_songs_with_warnings(
834 songs: &[Song],
835 cli_transpose: i8,
836 config: &Config,
837) -> RenderResult<String> {
838 let mut warnings = Vec::new();
839 if songs.len() <= 1 {
840 let output = songs
841 .first()
842 .map(|s| {
843 let r = render_song_with_warnings(s, cli_transpose, config);
844 warnings = r.warnings;
845 r.output
846 })
847 .unwrap_or_default();
848 return RenderResult::with_warnings(output, warnings);
849 }
850 let mut html = String::new();
852 let title = songs
853 .first()
854 .and_then(|s| s.metadata.title.as_deref())
855 .unwrap_or("Untitled");
856 let _ = write!(
857 html,
858 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>{}</title>\n",
859 escape(title)
860 );
861 html.push_str("<style>\n");
862 html.push_str(&css_for_wraplines(read_wraplines(config)));
863 html.push_str("</style>\n</head>\n<body>\n");
864
865 for (i, song) in songs.iter().enumerate() {
866 if i > 0 {
867 html.push_str("<hr class=\"song-separator\">\n");
868 }
869 render_song_body_into(song, cli_transpose, config, &mut html, &mut warnings);
870 }
871
872 html.push_str("</body>\n</html>\n");
873 RenderResult::with_warnings(html, warnings)
874}
875
876#[must_use]
891pub fn render_song_body(song: &Song) -> String {
892 render_song_body_with_transpose(song, 0, &Config::defaults())
893}
894
895#[must_use]
902pub fn render_song_body_with_transpose(song: &Song, cli_transpose: i8, config: &Config) -> String {
903 let result = render_song_body_with_warnings(song, cli_transpose, config);
904 for w in &result.warnings {
905 eprintln!("warning: {w}");
906 }
907 result.output
908}
909
910#[must_use = "caller must check warnings in the returned RenderResult"]
916pub fn render_song_body_with_warnings(
917 song: &Song,
918 cli_transpose: i8,
919 config: &Config,
920) -> RenderResult<String> {
921 let mut warnings = Vec::new();
922 let mut html = String::new();
923 render_song_body_into(song, cli_transpose, config, &mut html, &mut warnings);
924 RenderResult::with_warnings(html, warnings)
925}
926
927#[must_use]
933pub fn render_songs_body(songs: &[Song]) -> String {
934 render_songs_body_with_transpose(songs, 0, &Config::defaults())
935}
936
937#[must_use]
944pub fn render_songs_body_with_transpose(
945 songs: &[Song],
946 cli_transpose: i8,
947 config: &Config,
948) -> String {
949 let result = render_songs_body_with_warnings(songs, cli_transpose, config);
950 for w in &result.warnings {
951 eprintln!("warning: {w}");
952 }
953 result.output
954}
955
956#[must_use = "caller must check warnings in the returned RenderResult"]
961pub fn render_songs_body_with_warnings(
962 songs: &[Song],
963 cli_transpose: i8,
964 config: &Config,
965) -> RenderResult<String> {
966 let mut warnings = Vec::new();
967 if songs.len() <= 1 {
968 let output = songs
969 .first()
970 .map(|s| {
971 let r = render_song_body_with_warnings(s, cli_transpose, config);
972 warnings = r.warnings;
973 r.output
974 })
975 .unwrap_or_default();
976 return RenderResult::with_warnings(output, warnings);
977 }
978 let mut html = String::new();
979 for (i, song) in songs.iter().enumerate() {
980 if i > 0 {
981 html.push_str("<hr class=\"song-separator\">\n");
982 }
983 render_song_body_into(song, cli_transpose, config, &mut html, &mut warnings);
984 }
985 RenderResult::with_warnings(html, warnings)
986}
987
988#[must_use]
1003pub fn render_html_css() -> String {
1004 css_for_wraplines(true)
1005}
1006
1007#[must_use]
1012pub fn render_html_css_with_config(config: &Config) -> String {
1013 css_for_wraplines(read_wraplines(config))
1014}
1015
1016fn read_wraplines(config: &Config) -> bool {
1019 config.get_path("settings.wraplines").as_bool() != Some(false)
1020}
1021
1022fn css_for_wraplines(wraplines: bool) -> String {
1029 CSS_TEMPLATE.replace(
1030 "__LINE_FLEX_WRAP__",
1031 if wraplines { "wrap" } else { "nowrap" },
1032 )
1033}
1034
1035#[must_use = "parse errors should be handled"]
1040pub fn try_render(input: &str) -> Result<String, chordsketch_chordpro::ParseError> {
1041 let song = chordsketch_chordpro::parse(input)?;
1042 Ok(render_song(&song))
1043}
1044
1045#[must_use]
1050pub fn render(input: &str) -> String {
1051 match try_render(input) {
1052 Ok(html) => html,
1053 Err(e) => format!(
1054 "<!DOCTYPE html><html><body><pre>Parse error at line {} column {}: {}</pre></body></html>\n",
1055 e.line(),
1056 e.column(),
1057 escape(&e.message)
1058 ),
1059 }
1060}
1061
1062const CSS_TEMPLATE: &str = "\
1102body { font-family: \"Noto Sans JP\", system-ui, -apple-system, \"Helvetica Neue\", Arial, sans-serif; font-size: 1rem; line-height: 1.6875; color: #0A0A0B; max-width: 720px; margin: 2em auto; padding: 0 1em; }
1103h1 { font-family: \"Noto Sans JP\", system-ui, -apple-system, sans-serif; font-weight: 700; font-size: 1.875rem; letter-spacing: -0.02em; color: #0A0A0B; margin-bottom: 0.2em; }
1104h2 { font-family: \"Noto Sans JP\", system-ui, -apple-system, sans-serif; font-weight: 400; font-size: 1rem; color: #67646D; margin-top: 0; }
1105.meta { font-family: \"JetBrains Mono\", ui-monospace, \"SF Mono\", Menlo, Consolas, monospace; font-size: 0.8125rem; color: #67646D; margin: 0 0 0.4em; font-feature-settings: \"tnum\" 1; }
1106.song-header > .meta:last-of-type { margin-bottom: 1.5em; }
1107.meta--attribution { font-family: \"Inter\", system-ui, sans-serif; font-size: 1rem; color: #4A4750; margin: 0.1em 0; }
1108.meta--attribution-secondary { font-size: 0.8125rem; color: #8A8790; margin-bottom: 0.8em; }
1109.meta__label { color: #8A8790; font-weight: 400; }
1110.meta--params { display: flex; flex-wrap: wrap; gap: 0.4em; margin: 0.2em 0 0.8em; }
1111.meta__chip { display: inline-block; padding: 0.15em 0.6em; border: 1px solid #D4D1D6; border-radius: 4px; background-color: #FAFAFA; color: #2A262E; font-family: \"JetBrains Mono\", ui-monospace, monospace; font-size: 0.8125rem; font-weight: 500; line-height: 1.4; font-feature-settings: \"tnum\" 1; }
1112.meta--supplementary { font-size: 0.75rem; color: #A8A4AD; margin-bottom: 0.4em; }
1113.meta-inline { display: inline-flex; align-items: center; gap: 0.25rem; margin: 0.15em 0.3em 0.15em 0; padding: 0 0.5rem; min-height: 1.6rem; border-radius: 2px; background-color: #F6F4F7; border: 1px solid #E8E6EA; font-family: \"JetBrains Mono\", ui-monospace, monospace; font-size: 0.75rem; color: #44424A; line-height: 1.2; letter-spacing: 0.02em; font-feature-settings: \"tnum\" 1; vertical-align: middle; }
1114.meta-inline__label { color: #8A8790; font-weight: 500; }
1115.meta-inline__value { color: #0A0A0B; font-weight: 600; }
1116.meta-inline__marking { color: #8A8790; font-weight: 400; font-style: italic; }
1117.meta-inline .music-glyph { display: inline-flex; align-items: center; flex-shrink: 0; color: #0A0A0B; height: 1.1em; }
1118.meta-inline svg.music-glyph { height: 1.1em; width: auto; display: block; }
1119.meta-inline span.music-glyph--time { font-size: 0.85em; }
1120.music-glyph { display: inline-block; flex-shrink: 0; vertical-align: middle; color: #1A1718; }
1121.music-glyph--time { display: inline-flex; flex-direction: column; align-items: center; justify-content: center; line-height: 1; font-family: \"Source Serif Pro\", serif; font-weight: 700; font-size: 1.1em; letter-spacing: 0; }
1122.music-glyph--time__num, .music-glyph--time__den { display: block; line-height: 0.9; font-feature-settings: \"tnum\" 1; }
1123.music-glyph--time__bar { display: block; width: 0.9em; height: 1.5px; margin: 0.05em 0; background-color: currentColor; border-radius: 1px; flex-shrink: 0; }
1124.music-glyph--metronome__pendulum { transform-origin: 9px 19px; animation: cs-metronome-swing var(--cs-metronome-period, 1s) cubic-bezier(0.55, 0, 0.45, 1) infinite alternate; }
1125@keyframes cs-metronome-swing { from { transform: rotate(-28deg); } to { transform: rotate(28deg); } }
1126@media (prefers-reduced-motion: reduce) { .music-glyph--metronome__pendulum { animation: none; transform: rotate(0deg); } }
1127.line { display: flex; flex-wrap: __LINE_FLEX_WRAP__; margin: 0.1em 0; }
1128.chord-block { display: inline-flex; flex-direction: column; align-items: flex-start; }
1129.chord { font-family: \"Roboto\", system-ui, -apple-system, \"Helvetica Neue\", Arial, sans-serif; font-weight: 700; color: #BD1642; font-size: 1rem; letter-spacing: 0.01em; line-height: 1; min-height: 1em; }
1130.lyrics { font-family: \"Noto Sans JP\", system-ui, -apple-system, \"Helvetica Neue\", Arial, sans-serif; font-weight: 400; font-size: 1.125rem; white-space: pre; }
1131.empty-line { height: 1em; }
1132section { margin: 1em 0; }
1133section > .section-label, .chorus-recall > .section-label { font-family: \"Inter\", system-ui, -apple-system, sans-serif; font-weight: 600; font-size: 0.75rem; color: #67646D; margin: 0 0 0.5em; line-height: 1.4; }
1134.comment { font-family: \"Inter\", system-ui, -apple-system, sans-serif; font-style: italic; color: #8A8790; margin: 0.3em 0; }
1135.comment-box { border: 1px solid #D4D1D6; border-radius: 4px; padding: 0.2em 0.5em; display: block; width: fit-content; margin: 0.3em 0; }
1136.comment.comment--highlight { background-color: #FFF3A3; color: #1A1718; font-style: normal; font-weight: 600; padding: 0.15em 0.4em; border-radius: 3px; display: block; width: fit-content; }
1137.comment.comment--highlight mark { background: none; color: inherit; }
1138section.tab .lyrics, section.grid .lyrics, section.abc .lyrics, section.ly .lyrics, section.textblock .lyrics { font-family: \"JetBrains Mono\", ui-monospace, \"SF Mono\", Menlo, Consolas, monospace; font-size: 0.875rem; font-feature-settings: \"tnum\" 1; }
1139.chorus-recall { margin: 1em 0; }
1140img { max-width: 100%; height: auto; }
1141.chord-diagrams-grid { display: flex; flex-wrap: wrap; gap: 0.5em; margin: 0.5em 0; }
1142.chord-diagram-container { display: inline-block; vertical-align: top; }
1143.chord-diagram { display: block; }
1144.grid-line { display: flex; align-items: stretch; margin: 0.35em 0; min-height: 2.4em; font-family: \"Roboto\", system-ui, -apple-system, \"Helvetica Neue\", Arial, sans-serif; font-weight: 600; font-size: 1rem; line-height: 1.4; color: #1A1718; }
1145.grid-bar { flex: 1 1 0; display: flex; align-items: center; padding: 0.2em 0.4em; min-width: 3em; }
1146.grid-beat { flex: 1 1 0; display: flex; align-items: center; justify-content: flex-start; min-width: 0; }
1147.grid-chord { color: #BD1642; }
1148.grid-no-chord { color: #67646D; font-style: italic; font-weight: 500; }
1149.grid-barline { display: inline-flex; align-items: stretch; align-self: stretch; gap: 1.5px; flex-shrink: 0; }
1150.grid-barline:not([class*='--']) { width: 1.5px; background-color: #1A1718; }
1151.grid-barline__line { display: inline-block; width: 1.5px; background-color: #1A1718; }
1152.grid-barline__line--thick { width: 3.5px; }
1153.grid-barline__dots { display: inline-flex; flex-direction: column; justify-content: center; gap: 0.18em; padding: 0 2.5px; }
1154.grid-barline__dots > span { width: 3px; height: 3px; border-radius: 50%; background-color: #1A1718; display: inline-block; }
1155.grid-volta { position: relative; display: inline-flex; align-items: stretch; flex-shrink: 0; }
1156.grid-volta .grid-barline__line { background-color: #1A1718; }
1157.grid-volta__bracket { position: absolute; top: -0.1em; left: 0; display: flex; flex-direction: column; align-items: flex-start; justify-content: flex-start; }
1158.grid-volta__cap { display: block; width: 1.5em; height: 1.5px; background-color: #1A1718; }
1159.grid-volta__label { display: inline-block; margin-left: 0.15em; font-size: 0.7em; font-weight: 600; color: #1A1718; line-height: 1; padding-top: 0.1em; }
1160.grid-row__label { display: inline-flex; align-items: center; flex-shrink: 0; padding-right: 0.6em; font-weight: 700; color: #44424A; text-transform: uppercase; font-size: 0.85em; letter-spacing: 0.04em; min-width: 2em; }
1161.grid-row__comment { display: inline-flex; align-items: center; flex-shrink: 0; padding-left: 0.6em; font-style: italic; font-weight: 500; font-size: 0.85em; color: #67646D; }
1162.grid-percent { font-weight: 700; font-size: 0.9em; color: #44424A; }
1163.grid-beat--percent1, .grid-beat--percent2 { justify-content: center; }
1164.grid-chord__sep { display: inline-block; margin: 0 0.15em; color: #8A8790; font-weight: 400; font-size: 0.85em; }
1165.grid-beat--multi { gap: 0; }
1166.grid-line--strum { margin-top: -0.4em; min-height: 1.6em; font-size: 0.85em; color: #67646D; }
1167.grid-strum { font-weight: 600; }
1168.grid-strum__glyph { font-family: \"JetBrains Mono\", ui-monospace, \"SF Mono\", Menlo, Consolas, monospace; }
1169.grid-strum--up { color: #BD1642; }
1170.grid-strum--down { color: #1A1718; }
1171.grid-strum--up-accent, .grid-strum--down-accent { font-weight: 700; }
1172.grid-strum--anticipated { font-style: italic; }
1173.grid-strum--custom { color: #67646D; font-style: italic; }
1174.sr-only { position: absolute; clip: rect(0,0,0,0); width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; white-space: nowrap; border: 0; }
1175";
1176
1177fn render_metadata(metadata: &chordsketch_chordpro::ast::Metadata, html: &mut String) {
1198 let mut inner = String::new();
1202 if let Some(title) = &metadata.title {
1203 let _ = writeln!(inner, "<h1>{}</h1>", escape(title));
1204 }
1205 for subtitle in &metadata.subtitles {
1206 let _ = writeln!(inner, "<h2>{}</h2>", escape(subtitle));
1207 }
1208
1209 if !metadata.artists.is_empty() {
1232 let _ = writeln!(
1233 inner,
1234 "<p class=\"meta meta--attribution\"><span class=\"meta__label\" aria-hidden=\"true\">by </span>{}</p>",
1235 escape(&metadata.artists.join(", "))
1236 );
1237 }
1238 let mut attribution_secondary: Vec<String> = Vec::new();
1239 if !metadata.composers.is_empty() {
1240 attribution_secondary.push(format!(
1241 "<span class=\"meta__label\" aria-hidden=\"true\">Music </span>{}",
1242 escape(&metadata.composers.join(", "))
1243 ));
1244 }
1245 if !metadata.lyricists.is_empty() {
1246 attribution_secondary.push(format!(
1247 "<span class=\"meta__label\" aria-hidden=\"true\">Lyrics </span>{}",
1248 escape(&metadata.lyricists.join(", "))
1249 ));
1250 }
1251 if !metadata.arrangers.is_empty() {
1252 attribution_secondary.push(format!(
1253 "<span class=\"meta__label\" aria-hidden=\"true\">Arr. </span>{}",
1254 escape(&metadata.arrangers.join(", "))
1255 ));
1256 }
1257 if !attribution_secondary.is_empty() {
1258 let _ = writeln!(
1259 inner,
1260 "<p class=\"meta meta--attribution meta--attribution-secondary\">{}</p>",
1261 attribution_secondary.join(" · ")
1262 );
1263 }
1264
1265 let mut chips: Vec<String> = Vec::new();
1281 if let Some(capo) = metadata.capo.as_deref().filter(|s| !s.trim().is_empty()) {
1282 chips.push(format!(
1283 "<span class=\"meta__chip\">Capo {}</span>",
1284 escape(capo)
1285 ));
1286 }
1287 if let Some(duration) = metadata
1288 .duration
1289 .as_deref()
1290 .filter(|s| !s.trim().is_empty())
1291 {
1292 chips.push(format!(
1293 "<span class=\"meta__chip\">{}</span>",
1294 escape(duration)
1295 ));
1296 }
1297 if !chips.is_empty() {
1298 let _ = writeln!(
1299 inner,
1300 "<p class=\"meta meta--params\">{}</p>",
1301 chips.join("")
1302 );
1303 }
1304
1305 let mut supplementary: Vec<String> = Vec::new();
1307 if let Some(album) = metadata.album.as_deref().filter(|s| !s.trim().is_empty()) {
1308 supplementary.push(escape(album));
1309 }
1310 if let Some(year) = metadata.year.as_deref().filter(|s| !s.trim().is_empty()) {
1311 supplementary.push(escape(year));
1312 }
1313 if let Some(copyright) = metadata
1314 .copyright
1315 .as_deref()
1316 .filter(|s| !s.trim().is_empty())
1317 {
1318 supplementary.push(escape(copyright));
1319 }
1320 if !supplementary.is_empty() {
1321 let _ = writeln!(
1322 inner,
1323 "<p class=\"meta meta--supplementary\">{}</p>",
1324 supplementary.join(" · ")
1325 );
1326 }
1327
1328 if !inner.is_empty() {
1333 html.push_str("<header class=\"song-header\">\n");
1334 html.push_str(&inner);
1335 html.push_str("</header>\n");
1336 }
1337}
1338
1339fn render_lyrics(
1364 lyrics_line: &LyricsLine,
1365 transpose_offset: i8,
1366 prefer_flat: bool,
1367 fmt_state: &FormattingState,
1368 html: &mut String,
1369) {
1370 html.push_str("<div class=\"line\">");
1371
1372 for segment in &lyrics_line.segments {
1373 html.push_str("<span class=\"chord-block\">");
1374
1375 if let Some(chord) = &segment.chord {
1376 let raw_display = if transpose_offset != 0 {
1377 let transposed = transpose_chord_with_style(chord, transpose_offset, prefer_flat);
1378 transposed.display_name().to_string()
1379 } else {
1380 chord.display_name().to_string()
1381 };
1382 let display_name = unicode_accidentals(&raw_display);
1388 let chord_css = fmt_state.chord.to_css();
1389 if chord_css.is_empty() {
1390 let _ = write!(
1391 html,
1392 "<span class=\"chord\">{}</span>",
1393 escape(&display_name)
1394 );
1395 } else {
1396 let _ = write!(
1397 html,
1398 "<span class=\"chord\" style=\"{}\">{}</span>",
1399 escape(&chord_css),
1400 escape(&display_name)
1401 );
1402 }
1403 } else if lyrics_line.has_chords() {
1404 html.push_str("<span class=\"chord\" aria-hidden=\"true\">\u{00A0}</span>");
1412 }
1413
1414 let text_css = fmt_state.text.to_css();
1415 if text_css.is_empty() {
1416 html.push_str("<span class=\"lyrics\">");
1417 } else {
1418 let _ = write!(
1419 html,
1420 "<span class=\"lyrics\" style=\"{}\">",
1421 escape(&text_css)
1422 );
1423 }
1424 if segment.has_markup() {
1425 render_spans(&segment.spans, html);
1426 } else {
1427 html.push_str(&escape(&segment.text));
1428 }
1429 html.push_str("</span>");
1430
1431 html.push_str("</span>");
1432 }
1433
1434 html.push_str("</div>\n");
1435}
1436
1437fn render_spans(spans: &[TextSpan], html: &mut String) {
1446 for span in spans {
1447 match span {
1448 TextSpan::Plain(text) => html.push_str(&escape(text)),
1449 TextSpan::Bold(children) => {
1450 html.push_str("<b>");
1451 render_spans(children, html);
1452 html.push_str("</b>");
1453 }
1454 TextSpan::Italic(children) => {
1455 html.push_str("<i>");
1456 render_spans(children, html);
1457 html.push_str("</i>");
1458 }
1459 TextSpan::Highlight(children) => {
1460 html.push_str("<mark>");
1461 render_spans(children, html);
1462 html.push_str("</mark>");
1463 }
1464 TextSpan::Comment(children) => {
1465 html.push_str("<span class=\"comment\">");
1466 render_spans(children, html);
1467 html.push_str("</span>");
1468 }
1469 TextSpan::Span(attrs, children) => {
1470 let css = span_attrs_to_css(attrs);
1471 if css.is_empty() {
1472 html.push_str("<span>");
1473 } else {
1474 let _ = write!(html, "<span style=\"{}\">", escape(&css));
1475 }
1476 render_spans(children, html);
1477 html.push_str("</span>");
1478 }
1479 }
1480 }
1481}
1482
1483fn span_attrs_to_css(attrs: &SpanAttributes) -> String {
1485 let mut css = String::new();
1486 if let Some(ref font_family) = attrs.font_family {
1487 let _ = write!(css, "font-family: {};", sanitize_css_value(font_family));
1488 }
1489 if let Some(ref size) = attrs.size {
1490 let safe = sanitize_css_value(size);
1491 if safe.chars().all(|c| c.is_ascii_digit()) {
1493 let _ = write!(css, "font-size: {safe}pt;");
1494 } else {
1495 let _ = write!(css, "font-size: {safe};");
1496 }
1497 }
1498 if let Some(ref fg) = attrs.foreground {
1499 let _ = write!(css, "color: {};", sanitize_css_value(fg));
1500 }
1501 if let Some(ref bg) = attrs.background {
1502 let _ = write!(css, "background-color: {};", sanitize_css_value(bg));
1503 }
1504 if let Some(ref weight) = attrs.weight {
1505 let _ = write!(css, "font-weight: {};", sanitize_css_value(weight));
1506 }
1507 if let Some(ref style) = attrs.style {
1508 let _ = write!(css, "font-style: {};", sanitize_css_value(style));
1509 }
1510 css
1511}
1512
1513fn sanitize_css_value(s: &str) -> String {
1520 s.chars()
1521 .filter(|c| {
1522 c.is_ascii_alphanumeric() || matches!(c, '#' | '.' | '-' | ' ' | ',' | '%' | '+')
1523 })
1524 .collect()
1525}
1526
1527fn sanitize_css_class(s: &str) -> String {
1534 s.chars()
1535 .map(|c| {
1536 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
1537 c
1538 } else {
1539 '-'
1540 }
1541 })
1542 .collect()
1543}
1544
1545fn sanitize_svg_content(input: &str) -> String {
1552 const DANGEROUS_TAGS: &[&str] = &[
1561 "script",
1562 "foreignobject",
1563 "iframe",
1564 "object",
1565 "embed",
1566 "math",
1567 "feimage",
1571 "image",
1575 "set",
1576 "animate",
1577 "animatetransform",
1578 "animatemotion",
1579 ];
1580
1581 let mut result = String::with_capacity(input.len());
1582 let mut chars = input.char_indices().peekable();
1583 let bytes = input.as_bytes();
1584
1585 while let Some((i, c)) = chars.next() {
1586 if c == '<' {
1587 let rest = &input[i..];
1588 let limit = rest
1591 .char_indices()
1592 .map(|(idx, _)| idx)
1593 .find(|&idx| idx >= 30)
1594 .unwrap_or(rest.len());
1595 let rest_upper = &rest[..limit];
1596
1597 let ns_open = namespace_prefix_len(&rest.as_bytes()[1..]);
1605 let tag_start_in_rest = 1 + ns_open;
1606
1607 let mut matched = false;
1609 for tag in DANGEROUS_TAGS {
1610 let tag_end_in_rest = tag_start_in_rest + tag.len();
1611 if rest.len() > tag_end_in_rest
1612 && rest_upper.len() >= tag_end_in_rest
1613 && starts_with_ignore_case(&rest_upper[tag_start_in_rest..], tag)
1614 && bytes
1615 .get(i + tag_end_in_rest)
1616 .is_none_or(|b| b.is_ascii_whitespace() || *b == b'>' || *b == b'/')
1617 {
1618 let is_self_closing = {
1622 let tag_bytes = rest.as_bytes();
1623 let mut in_quote: Option<u8> = None;
1624 let mut gt_pos = None;
1625 for (idx, &b) in tag_bytes.iter().enumerate() {
1626 match in_quote {
1627 Some(q) if b == q => in_quote = None,
1628 Some(_) => {}
1629 None if b == b'"' || b == b'\'' => in_quote = Some(b),
1630 None if b == b'>' => {
1631 gt_pos = Some(idx);
1632 break;
1633 }
1634 _ => {}
1635 }
1636 }
1637 gt_pos.is_some_and(|gt| gt > 0 && tag_bytes[gt - 1] == b'/')
1638 };
1639
1640 if is_self_closing {
1641 let mut skip_quote: Option<char> = None;
1645 while let Some(&(_, ch)) = chars.peek() {
1646 chars.next();
1647 match skip_quote {
1648 Some(q) if ch == q => skip_quote = None,
1649 Some(_) => {}
1650 None if ch == '"' || ch == '\'' => {
1651 skip_quote = Some(ch);
1652 }
1653 None if ch == '>' => break,
1654 _ => {}
1655 }
1656 }
1657 } else if let Some(end) = find_end_tag_ignore_case(input, i, tag) {
1658 while let Some(&(j, _)) = chars.peek() {
1660 if j >= end {
1661 break;
1662 }
1663 chars.next();
1664 }
1665 } else {
1666 return result;
1668 }
1669 matched = true;
1670 break;
1671 }
1672 }
1673 if matched {
1674 continue;
1675 }
1676
1677 let ns_close = namespace_prefix_len(rest.as_bytes().get(2..).unwrap_or(&[]));
1679 let tag_start_in_close = 2 + ns_close;
1680 for tag in DANGEROUS_TAGS {
1681 let tag_end_in_close = tag_start_in_close + tag.len();
1682 if rest_upper.len() >= tag_end_in_close
1683 && rest.len() > tag_end_in_close
1684 && rest.starts_with("</")
1685 && starts_with_ignore_case(&rest_upper[tag_start_in_close..], tag)
1686 && bytes
1687 .get(i + tag_end_in_close)
1688 .is_none_or(|b| b.is_ascii_whitespace() || *b == b'>')
1689 {
1690 while let Some(&(_, ch)) = chars.peek() {
1692 chars.next();
1693 if ch == '>' {
1694 break;
1695 }
1696 }
1697 matched = true;
1698 break;
1699 }
1700 }
1701 if matched {
1702 continue;
1703 }
1704
1705 result.push(c);
1706 } else {
1707 result.push(c);
1708 }
1709 }
1710
1711 strip_dangerous_attrs(&result)
1713}
1714
1715fn starts_with_ignore_case(s: &str, prefix: &str) -> bool {
1717 if s.len() < prefix.len() {
1718 return false;
1719 }
1720 s.as_bytes()[..prefix.len()]
1721 .iter()
1722 .zip(prefix.as_bytes())
1723 .all(|(a, b)| a.eq_ignore_ascii_case(b))
1724}
1725
1726fn is_invisible_format_char(c: char) -> bool {
1731 matches!(
1732 c,
1733 '\u{00AD}' | '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{200E}' | '\u{200F}' | '\u{2060}' | '\u{FEFF}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' )
1744}
1745
1746fn namespace_prefix_len(bytes: &[u8]) -> usize {
1751 let mut idx = 0;
1752 match bytes.first() {
1753 Some(b) if b.is_ascii_alphabetic() => idx += 1,
1754 _ => return 0,
1755 }
1756 while let Some(&b) = bytes.get(idx) {
1760 if b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.' {
1761 idx += 1;
1762 } else {
1763 break;
1764 }
1765 }
1766 if bytes.get(idx) == Some(&b':') {
1767 idx + 1
1768 } else {
1769 0
1770 }
1771}
1772
1773fn find_end_tag_ignore_case(input: &str, start: usize, tag: &str) -> Option<usize> {
1780 let search = &input.as_bytes()[start..];
1781 let tag_bytes = tag.as_bytes();
1782
1783 for i in 0..search.len() {
1784 if search[i] == b'<' && search.get(i + 1) == Some(&b'/') {
1785 let after_slash = &search[i + 2..];
1786 let ns = namespace_prefix_len(after_slash);
1787 let tag_end = ns + tag_bytes.len();
1788 if after_slash.len() >= tag_end {
1789 let candidate = &after_slash[ns..tag_end];
1790 if candidate
1791 .iter()
1792 .zip(tag_bytes)
1793 .all(|(a, b)| a.eq_ignore_ascii_case(b))
1794 {
1795 if let Some(gt) = after_slash[tag_end..].iter().position(|&b| b == b'>') {
1797 return Some(start + i + 2 + tag_end + gt + 1);
1798 }
1799 }
1800 }
1801 }
1802 }
1803 None
1804}
1805
1806fn strip_dangerous_attrs(input: &str) -> String {
1811 let mut result = String::with_capacity(input.len());
1812 let bytes = input.as_bytes();
1813 let mut pos = 0;
1814
1815 while pos < bytes.len() {
1816 if bytes[pos] == b'<' && pos + 1 < bytes.len() && bytes[pos + 1] != b'/' {
1817 if let Some(gt) = find_tag_end(&bytes[pos..]) {
1821 let tag_end = pos + gt + 1;
1822 let tag_content = &input[pos..tag_end];
1823 result.push_str(&sanitize_tag_attrs(tag_content));
1824 pos = tag_end;
1825 } else {
1826 result.push_str(&input[pos..]);
1827 break;
1828 }
1829 } else {
1830 debug_assert!(
1833 input.is_char_boundary(pos),
1834 "pos must land on a char boundary; advancing by c.len_utf8() is the invariant"
1835 );
1836 let ch = &input[pos..];
1837 let c = ch
1838 .chars()
1839 .next()
1840 .expect("pos is on a char boundary and within bounds");
1841 result.push(c);
1842 pos += c.len_utf8();
1843 }
1844 }
1845 result
1846}
1847
1848fn find_tag_end(bytes: &[u8]) -> Option<usize> {
1851 let mut i = 0;
1852 let mut in_quote: Option<u8> = None;
1853 while i < bytes.len() {
1854 let b = bytes[i];
1855 if let Some(q) = in_quote {
1856 if b == q {
1857 in_quote = None;
1858 }
1859 } else if b == b'"' || b == b'\'' {
1860 in_quote = Some(b);
1861 } else if b == b'>' {
1862 return Some(i);
1863 }
1864 i += 1;
1865 }
1866 None
1867}
1868
1869fn has_dangerous_uri_scheme(value: &str) -> bool {
1872 let lower: String = value
1881 .trim_start()
1882 .chars()
1883 .filter(|&c| {
1884 !c.is_ascii_whitespace() && !c.is_ascii_control() && !is_invisible_format_char(c)
1885 })
1886 .take(30)
1887 .flat_map(|c| c.to_lowercase())
1888 .collect();
1889 lower.starts_with("javascript:")
1896 || lower.starts_with("vbscript:")
1897 || lower.starts_with("data:")
1898 || lower.starts_with("file:")
1899 || lower.starts_with("blob:")
1900 || lower.starts_with("mhtml:")
1901}
1902
1903fn is_uri_attr(name: &str) -> bool {
1910 let lower: String = name.chars().flat_map(|c| c.to_lowercase()).collect();
1911 lower == "href"
1912 || lower == "src"
1913 || lower == "xlink:href"
1914 || lower == "to"
1916 || lower == "values"
1917 || lower == "from"
1918 || lower == "by"
1919 || lower == "action"
1921 || lower == "formaction"
1922 || lower == "poster"
1924 || lower == "background"
1925 || lower == "ping"
1927}
1928
1929fn sanitize_tag_attrs(tag: &str) -> String {
1940 let mut result = String::with_capacity(tag.len());
1941 let bytes = tag.as_bytes();
1942 let mut i = 0;
1943
1944 while i < bytes.len() && bytes[i] != b' ' && bytes[i] != b'>' && bytes[i] != b'/' {
1946 result.push(bytes[i] as char);
1947 i += 1;
1948 }
1949
1950 let tag_name = &result[1..];
1953 let is_use_tag =
1954 tag_name.eq_ignore_ascii_case("use") || tag_name.eq_ignore_ascii_case("svg:use");
1955
1956 while i < bytes.len() {
1957 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
1959 result.push(bytes[i] as char);
1960 i += 1;
1961 }
1962
1963 if i >= bytes.len() || bytes[i] == b'>' || bytes[i] == b'/' {
1964 result.push_str(&tag[i..]);
1965 return result;
1966 }
1967
1968 let attr_start = i;
1970 while i < bytes.len()
1971 && bytes[i] != b'='
1972 && bytes[i] != b' '
1973 && bytes[i] != b'>'
1974 && bytes[i] != b'/'
1975 {
1976 i += 1;
1977 }
1978 let attr_name = &tag[attr_start..i];
1979
1980 let is_event_handler = attr_name.len() > 2
1981 && attr_name.as_bytes()[..2].eq_ignore_ascii_case(b"on")
1982 && attr_name.as_bytes()[2].is_ascii_alphabetic();
1983
1984 let value_start = i;
1986 let mut attr_value: Option<String> = None;
1987 if i < bytes.len() && bytes[i] == b'=' {
1988 i += 1; if i < bytes.len() && (bytes[i] == b'"' || bytes[i] == b'\'') {
1990 let quote = bytes[i];
1991 i += 1;
1992 let val_start = i;
1993 while i < bytes.len() && bytes[i] != quote {
1994 i += 1;
1995 }
1996 attr_value = Some(tag[val_start..i].to_string());
1997 if i < bytes.len() {
1998 i += 1; }
2000 } else {
2001 let val_start = i;
2003 while i < bytes.len() && !bytes[i].is_ascii_whitespace() && bytes[i] != b'>' {
2004 i += 1;
2005 }
2006 attr_value = Some(tag[val_start..i].to_string());
2007 }
2008 }
2009
2010 if is_event_handler {
2011 continue;
2013 }
2014
2015 if is_uri_attr(attr_name) {
2016 if let Some(ref val) = attr_value {
2017 if has_dangerous_uri_scheme(val) {
2018 continue;
2020 }
2021 if is_use_tag
2028 && (attr_name.eq_ignore_ascii_case("href")
2029 || attr_name.eq_ignore_ascii_case("xlink:href"))
2030 && !val.trim_start().starts_with('#')
2031 {
2032 continue;
2033 }
2034 }
2035 }
2036
2037 if attr_name.eq_ignore_ascii_case("style") {
2040 if let Some(ref val) = attr_value {
2041 let lower_val: String = val.chars().flat_map(|c| c.to_lowercase()).collect();
2042 if lower_val.contains("url(")
2043 || lower_val.contains("expression(")
2044 || lower_val.contains("@import")
2045 {
2046 continue;
2047 }
2048 }
2049 }
2050
2051 result.push_str(&tag[attr_start..value_start]);
2053 if attr_value.is_some() {
2054 result.push_str(&tag[value_start..i]);
2055 }
2056 }
2057
2058 result
2059}
2060
2061fn render_directive_inner(
2070 directive: &chordsketch_chordpro::ast::Directive,
2071 show_diagrams: bool,
2072 diagram_frets: usize,
2073 html: &mut String,
2074) {
2075 match &directive.kind {
2076 DirectiveKind::StartOfChorus => {
2077 render_section_open("chorus", "Chorus", &directive.value, html);
2078 }
2079 DirectiveKind::StartOfVerse => {
2080 render_section_open("verse", "Verse", &directive.value, html);
2081 }
2082 DirectiveKind::StartOfBridge => {
2083 render_section_open("bridge", "Bridge", &directive.value, html);
2084 }
2085 DirectiveKind::StartOfTab => {
2086 render_section_open("tab", "Tab", &directive.value, html);
2087 }
2088 DirectiveKind::StartOfGrid => {
2089 let label_value = directive.value.as_ref().and_then(|v| {
2095 if let Some(label) = chordsketch_chordpro::grid::extract_grid_label(v) {
2096 Some(label)
2097 } else if !v.contains('=') {
2098 Some(v.clone())
2099 } else {
2100 None
2101 }
2102 });
2103 render_section_open("grid", "Grid", &label_value, html);
2104 }
2105 DirectiveKind::StartOfAbc => {
2106 render_section_open("abc", "ABC", &directive.value, html);
2107 }
2108 DirectiveKind::StartOfLy => {
2109 render_section_open("ly", "Lilypond", &directive.value, html);
2110 }
2111 DirectiveKind::StartOfTextblock => {
2114 render_section_open("textblock", "Textblock", &directive.value, html);
2115 }
2116 DirectiveKind::StartOfMusicxml => {
2117 render_section_open("musicxml", "MusicXML", &directive.value, html);
2118 }
2119 DirectiveKind::StartOfSection(section_name) => {
2120 let class = format!("section-{}", sanitize_css_class(section_name));
2121 let label = escape(&chordsketch_chordpro::capitalize(section_name));
2122 render_section_open(&class, &label, &directive.value, html);
2123 }
2124 DirectiveKind::EndOfChorus
2125 | DirectiveKind::EndOfVerse
2126 | DirectiveKind::EndOfBridge
2127 | DirectiveKind::EndOfTab
2128 | DirectiveKind::EndOfGrid
2129 | DirectiveKind::EndOfAbc
2130 | DirectiveKind::EndOfLy
2131 | DirectiveKind::EndOfMusicxml
2132 | DirectiveKind::EndOfSvg
2133 | DirectiveKind::EndOfTextblock
2134 | DirectiveKind::EndOfSection(_) => {
2135 html.push_str("</section>\n");
2136 }
2137 DirectiveKind::Image(attrs) => {
2138 render_image(attrs, html);
2139 }
2140 DirectiveKind::Define if show_diagrams => {
2141 if let Some(ref value) = directive.value {
2142 let def = chordsketch_chordpro::ast::ChordDefinition::parse_value(value);
2143 if let Some(ref keys_raw) = def.keys {
2145 let keys_u8: Vec<u8> = keys_raw
2146 .iter()
2147 .filter_map(|&k| {
2148 if (0i32..=127).contains(&k) {
2149 Some(k as u8)
2150 } else {
2151 None
2152 }
2153 })
2154 .collect();
2155 if !keys_u8.is_empty() {
2156 let root = keys_u8[0];
2157 let voicing = chordsketch_chordpro::chord_diagram::KeyboardVoicing {
2158 name: def.name.clone(),
2159 display_name: def.display.clone(),
2160 keys: keys_u8,
2161 root_key: root,
2162 };
2163 html.push_str("<figure class=\"chord-diagram-container\">");
2164 html.push_str(&chordsketch_chordpro::chord_diagram::render_keyboard_svg(
2165 &voicing,
2166 ));
2167 html.push_str("</figure>\n");
2168 }
2169 } else if let Some(ref raw) = def.raw {
2170 if let Some(mut diagram) =
2172 chordsketch_chordpro::chord_diagram::DiagramData::from_raw_infer_frets(
2173 &def.name,
2174 raw,
2175 diagram_frets,
2176 )
2177 {
2178 diagram.display_name = def.display.clone();
2179 html.push_str("<figure class=\"chord-diagram-container\">");
2180 html.push_str(&chordsketch_chordpro::chord_diagram::render_svg(&diagram));
2181 html.push_str("</figure>\n");
2182 }
2183 }
2184 }
2185 }
2186 DirectiveKind::Define => {}
2187 _ => {}
2188 }
2189}
2190
2191#[cfg(not(target_arch = "wasm32"))]
2197fn render_abc_with_fallback(
2198 abc_content: &str,
2199 label: &Option<String>,
2200 html: &mut String,
2201 warnings: &mut Vec<String>,
2202) {
2203 match chordsketch_chordpro::external_tool::invoke_abc2svg(abc_content) {
2204 Ok(svg_fragment) => {
2205 render_section_open("abc", "ABC", label, html);
2206 html.push_str(&sanitize_svg_content(&svg_fragment));
2207 html.push('\n');
2208 html.push_str("</section>\n");
2209 }
2210 Err(e) => {
2211 push_warning(warnings, format!("abc2svg invocation failed: {e}"));
2212 render_section_open("abc", "ABC", label, html);
2213 html.push_str("<pre>");
2214 html.push_str(&escape(abc_content));
2215 html.push_str("</pre>\n");
2216 html.push_str("</section>\n");
2217 }
2218 }
2219}
2220
2221#[cfg(target_arch = "wasm32")]
2225fn render_abc_with_fallback(
2226 abc_content: &str,
2227 label: &Option<String>,
2228 html: &mut String,
2229 _warnings: &mut Vec<String>,
2230) {
2231 render_section_open("abc", "ABC", label, html);
2232 html.push_str("<pre>");
2233 html.push_str(&escape(abc_content));
2234 html.push_str("</pre>\n");
2235 html.push_str("</section>\n");
2236}
2237
2238use chordsketch_chordpro::image_path::is_safe_image_src;
2246
2247#[cfg(not(target_arch = "wasm32"))]
2253fn render_ly_with_fallback(
2254 ly_content: &str,
2255 label: &Option<String>,
2256 html: &mut String,
2257 warnings: &mut Vec<String>,
2258) {
2259 match chordsketch_chordpro::external_tool::invoke_lilypond(ly_content) {
2260 Ok(svg) => {
2261 render_section_open("ly", "Lilypond", label, html);
2262 html.push_str(&sanitize_svg_content(&svg));
2263 html.push('\n');
2264 html.push_str("</section>\n");
2265 }
2266 Err(e) => {
2267 push_warning(warnings, format!("lilypond invocation failed: {e}"));
2268 render_section_open("ly", "Lilypond", label, html);
2269 html.push_str("<pre>");
2270 html.push_str(&escape(ly_content));
2271 html.push_str("</pre>\n");
2272 html.push_str("</section>\n");
2273 }
2274 }
2275}
2276
2277#[cfg(target_arch = "wasm32")]
2281fn render_ly_with_fallback(
2282 ly_content: &str,
2283 label: &Option<String>,
2284 html: &mut String,
2285 _warnings: &mut Vec<String>,
2286) {
2287 render_section_open("ly", "Lilypond", label, html);
2288 html.push_str("<pre>");
2289 html.push_str(&escape(ly_content));
2290 html.push_str("</pre>\n");
2291 html.push_str("</section>\n");
2292}
2293
2294#[cfg(not(target_arch = "wasm32"))]
2300fn render_musicxml_with_fallback(
2301 musicxml_content: &str,
2302 label: &Option<String>,
2303 html: &mut String,
2304 warnings: &mut Vec<String>,
2305) {
2306 match chordsketch_chordpro::external_tool::invoke_musescore(musicxml_content) {
2307 Ok(svg) => {
2308 render_section_open("musicxml", "MusicXML", label, html);
2309 html.push_str(&sanitize_svg_content(&svg));
2310 html.push('\n');
2311 html.push_str("</section>\n");
2312 }
2313 Err(e) => {
2314 push_warning(warnings, format!("musescore invocation failed: {e}"));
2315 render_section_open("musicxml", "MusicXML", label, html);
2316 html.push_str("<pre>");
2317 html.push_str(&escape(musicxml_content));
2318 html.push_str("</pre>\n");
2319 html.push_str("</section>\n");
2320 }
2321 }
2322}
2323
2324#[cfg(target_arch = "wasm32")]
2328fn render_musicxml_with_fallback(
2329 musicxml_content: &str,
2330 label: &Option<String>,
2331 html: &mut String,
2332 _warnings: &mut Vec<String>,
2333) {
2334 render_section_open("musicxml", "MusicXML", label, html);
2335 html.push_str("<pre>");
2336 html.push_str(&escape(musicxml_content));
2337 html.push_str("</pre>\n");
2338 html.push_str("</section>\n");
2339}
2340
2341fn render_image(attrs: &chordsketch_chordpro::ast::ImageAttributes, html: &mut String) {
2350 if !is_safe_image_src(&attrs.src) {
2351 return;
2352 }
2353
2354 let mut style = String::new();
2355 let mut img_attrs = format!("src=\"{}\"", escape(&attrs.src));
2356
2357 if let Some(ref title) = attrs.title {
2358 let _ = write!(img_attrs, " alt=\"{}\"", escape(title));
2359 }
2360
2361 if let Some(ref width) = attrs.width {
2362 let _ = write!(img_attrs, " width=\"{}\"", escape(width));
2363 }
2364 if let Some(ref height) = attrs.height {
2365 let _ = write!(img_attrs, " height=\"{}\"", escape(height));
2366 }
2367 if let Some(ref scale) = attrs.scale {
2368 let _ = write!(
2370 style,
2371 "transform: scale({});transform-origin: top left;",
2372 sanitize_css_value(scale)
2373 );
2374 }
2375
2376 let align_css = match attrs.anchor.as_deref() {
2378 Some("column") | Some("paper") => "text-align: center;",
2379 _ => "",
2380 };
2381
2382 if !align_css.is_empty() {
2383 let _ = write!(html, "<div style=\"{align_css}\">");
2384 } else {
2385 html.push_str("<div>");
2386 }
2387
2388 let _ = write!(html, "<img {img_attrs}");
2389 if !style.is_empty() {
2390 let _ = write!(html, " style=\"{}\"", escape(&style));
2396 }
2397 html.push_str("></div>\n");
2398}
2399
2400fn render_section_open(class: &str, label: &str, value: &Option<String>, html: &mut String) {
2412 let safe_class = sanitize_css_class(class);
2413 let _ = writeln!(html, "<section class=\"{safe_class}\">");
2414 let display_label = match value {
2415 Some(v) if !v.is_empty() => format!("{label}: {}", escape(v)),
2416 _ => label.to_string(),
2417 };
2418 let _ = writeln!(html, "<h3 class=\"section-label\">{display_label}</h3>");
2419}
2420
2421fn render_grid_line(raw: &str, html: &mut String) {
2427 use chordsketch_chordpro::grid::{GridBarline, GridRowKind, GridToken, classify_grid_row};
2428 use chordsketch_chordpro::typography::unicode_accidentals;
2429
2430 let row = classify_grid_row(raw);
2431 let row_class = if matches!(row.kind, GridRowKind::Strum) {
2432 "grid-line grid-line--strum"
2433 } else {
2434 "grid-line"
2435 };
2436 let _ = write!(html, "<div class=\"{row_class}\">");
2437
2438 if let Some(ref label) = row.label {
2439 let _ = write!(
2440 html,
2441 "<span class=\"grid-row__label\">{}</span>",
2442 escape(label)
2443 );
2444 }
2445
2446 enum BeatSlot {
2450 Chord(Vec<String>),
2451 Strum(String),
2452 Continuation,
2453 Percent1,
2454 Percent2,
2455 }
2456 enum Cell {
2457 Bar {
2458 beats: Vec<BeatSlot>,
2459 no_chord: bool,
2460 },
2461 Barline(GridBarline),
2462 Volta(u8),
2463 }
2464 let mut cells: Vec<Cell> = Vec::new();
2465 let mut current: Option<(Vec<BeatSlot>, bool)> = None;
2466 let flush = |current: &mut Option<(Vec<BeatSlot>, bool)>, cells: &mut Vec<Cell>| {
2467 if let Some((beats, no_chord)) = current.take() {
2468 if !beats.is_empty() || no_chord {
2469 cells.push(Cell::Bar { beats, no_chord });
2470 }
2471 }
2472 };
2473 let strum_row = matches!(row.kind, GridRowKind::Strum);
2474 for tok in &row.body {
2475 match tok {
2476 GridToken::Space => {}
2477 GridToken::Cell(names) => {
2478 let entry = current.get_or_insert_with(|| (Vec::new(), false));
2479 if strum_row {
2480 entry.0.push(BeatSlot::Strum(names.join("~")));
2481 } else {
2482 entry.0.push(BeatSlot::Chord(names.clone()));
2483 }
2484 }
2485 GridToken::Percent1 => {
2486 current
2487 .get_or_insert_with(|| (Vec::new(), false))
2488 .0
2489 .push(BeatSlot::Percent1);
2490 }
2491 GridToken::Percent2 => {
2492 current
2493 .get_or_insert_with(|| (Vec::new(), false))
2494 .0
2495 .push(BeatSlot::Percent2);
2496 }
2497 GridToken::Continuation => {
2498 current
2499 .get_or_insert_with(|| (Vec::new(), false))
2500 .0
2501 .push(BeatSlot::Continuation);
2502 }
2503 GridToken::NoChord => {
2504 let entry = current.get_or_insert_with(|| (Vec::new(), false));
2505 entry.1 = true;
2506 }
2507 GridToken::Barline(b) => {
2508 flush(&mut current, &mut cells);
2509 cells.push(Cell::Barline(*b));
2510 }
2511 GridToken::Volta(n) => {
2512 flush(&mut current, &mut cells);
2513 cells.push(Cell::Volta(*n));
2514 }
2515 }
2516 }
2517 flush(&mut current, &mut cells);
2518
2519 for cell in &cells {
2520 match cell {
2521 Cell::Bar { beats, no_chord } => {
2522 let slots: &[BeatSlot] = if beats.is_empty() {
2523 &[BeatSlot::Continuation]
2524 } else {
2525 beats
2526 };
2527 let _ = write!(
2528 html,
2529 "<span class=\"grid-bar\" data-beats=\"{}\">",
2530 slots.len()
2531 );
2532 if *no_chord {
2533 html.push_str(
2534 "<span class=\"grid-no-chord\" aria-label=\"no chord\">N.C.</span>",
2535 );
2536 } else {
2537 for slot in slots {
2538 match slot {
2539 BeatSlot::Chord(names) => {
2540 if names.len() == 1 {
2541 let _ = write!(
2542 html,
2543 "<span class=\"grid-beat\"><span class=\"grid-chord\">{}</span></span>",
2544 escape(&unicode_accidentals(&names[0]))
2545 );
2546 } else {
2547 html.push_str("<span class=\"grid-beat grid-beat--multi\">");
2548 for (i, name) in names.iter().enumerate() {
2549 if i > 0 {
2550 html.push_str(
2551 "<span class=\"grid-chord__sep\" aria-hidden=\"true\">~</span>",
2552 );
2553 }
2554 let _ = write!(
2555 html,
2556 "<span class=\"grid-chord\">{}</span>",
2557 escape(&unicode_accidentals(name))
2558 );
2559 }
2560 html.push_str("</span>");
2561 }
2562 }
2563 BeatSlot::Strum(raw) => {
2564 let (class, glyph) = strum_class_and_glyph(raw);
2565 let _ = write!(
2566 html,
2567 "<span class=\"grid-beat grid-strum {}\">\
2568 <span class=\"grid-strum__glyph\" aria-hidden=\"true\">{}</span>\
2569 <span class=\"sr-only\">{}</span>\
2570 </span>",
2571 class,
2572 escape(&glyph),
2573 escape(raw),
2574 );
2575 }
2576 BeatSlot::Continuation => {
2577 html.push_str("<span class=\"grid-beat\"></span>");
2578 }
2579 BeatSlot::Percent1 => {
2580 html.push_str(
2581 "<span class=\"grid-beat grid-beat--percent1\" \
2582 aria-label=\"repeat previous bar\">\
2583 <span class=\"grid-percent\" aria-hidden=\"true\">%</span>\
2584 </span>",
2585 );
2586 }
2587 BeatSlot::Percent2 => {
2588 html.push_str(
2589 "<span class=\"grid-beat grid-beat--percent2\" \
2590 aria-label=\"repeat previous two bars\">\
2591 <span class=\"grid-percent\" aria-hidden=\"true\">%%</span>\
2592 </span>",
2593 );
2594 }
2595 }
2596 }
2597 }
2598 html.push_str("</span>");
2599 }
2600 Cell::Barline(b) => match b {
2601 GridBarline::Single => {
2602 html.push_str("<span class=\"grid-barline\" aria-hidden=\"true\"></span>");
2603 }
2604 GridBarline::Double => {
2605 html.push_str(
2606 "<span class=\"grid-barline grid-barline--double\" aria-hidden=\"true\">\
2607 <span class=\"grid-barline__line\"></span>\
2608 <span class=\"grid-barline__line\"></span>\
2609 </span>",
2610 );
2611 }
2612 GridBarline::Final => {
2613 html.push_str(
2614 "<span class=\"grid-barline grid-barline--final\" aria-label=\"final barline\">\
2615 <span class=\"grid-barline__line\"></span>\
2616 <span class=\"grid-barline__line grid-barline__line--thick\"></span>\
2617 </span>",
2618 );
2619 }
2620 GridBarline::RepeatStart => {
2621 html.push_str(
2622 "<span class=\"grid-barline grid-barline--repeat-start\" aria-label=\"repeat start\">\
2623 <span class=\"grid-barline__line grid-barline__line--thick\"></span>\
2624 <span class=\"grid-barline__line\"></span>\
2625 <span class=\"grid-barline__dots\"><span></span><span></span></span>\
2626 </span>",
2627 );
2628 }
2629 GridBarline::RepeatEnd => {
2630 html.push_str(
2631 "<span class=\"grid-barline grid-barline--repeat-end\" aria-label=\"repeat end\">\
2632 <span class=\"grid-barline__dots\"><span></span><span></span></span>\
2633 <span class=\"grid-barline__line\"></span>\
2634 <span class=\"grid-barline__line grid-barline__line--thick\"></span>\
2635 </span>",
2636 );
2637 }
2638 GridBarline::RepeatBoth => {
2639 html.push_str(
2640 "<span class=\"grid-barline grid-barline--repeat-both\" aria-label=\"repeat end and start\">\
2641 <span class=\"grid-barline__dots\"><span></span><span></span></span>\
2642 <span class=\"grid-barline__line grid-barline__line--thick\"></span>\
2643 <span class=\"grid-barline__line grid-barline__line--thick\"></span>\
2644 <span class=\"grid-barline__dots\"><span></span><span></span></span>\
2645 </span>",
2646 );
2647 }
2648 },
2649 Cell::Volta(n) => {
2650 let _ = write!(
2651 html,
2652 "<span class=\"grid-volta\" aria-label=\"{n} ending\">\
2653 <span class=\"grid-volta__bracket\">\
2654 <span class=\"grid-volta__cap\"></span>\
2655 <span class=\"grid-volta__label\">{n}.</span>\
2656 </span>\
2657 <span class=\"grid-barline__line\"></span>\
2658 </span>",
2659 );
2660 }
2661 }
2662 }
2663
2664 if let Some(ref comment) = row.trailing_comment {
2665 let _ = write!(
2666 html,
2667 "<span class=\"grid-row__comment\">{}</span>",
2668 escape(comment)
2669 );
2670 }
2671
2672 html.push_str("</div>\n");
2673}
2674
2675fn strum_class_and_glyph(raw: &str) -> (String, String) {
2681 let anticipated = raw.starts_with('~');
2682 let stripped = if anticipated { &raw[1..] } else { raw };
2683 let lower = stripped.to_ascii_lowercase();
2684 let (base, glyph): (&str, String) = match lower.as_str() {
2685 "up" | "u" => (
2686 "grid-strum--up",
2687 if anticipated { "~↑" } else { "↑" }.to_string(),
2688 ),
2689 "dn" | "d" => (
2690 "grid-strum--down",
2691 if anticipated { "~↓" } else { "↓" }.to_string(),
2692 ),
2693 "u+" => (
2694 "grid-strum--up-accent",
2695 if anticipated { "~↑+" } else { "↑+" }.to_string(),
2696 ),
2697 "d+" => (
2698 "grid-strum--down-accent",
2699 if anticipated { "~↓+" } else { "↓+" }.to_string(),
2700 ),
2701 "ua" => (
2702 "grid-strum--up-arpeggio",
2703 if anticipated { "~↑·" } else { "↑·" }.to_string(),
2704 ),
2705 "da" => (
2706 "grid-strum--down-arpeggio",
2707 if anticipated { "~↓·" } else { "↓·" }.to_string(),
2708 ),
2709 _ => ("grid-strum--custom", raw.to_string()),
2713 };
2714 let class = if anticipated {
2715 format!("{base} grid-strum--anticipated")
2716 } else {
2717 base.to_string()
2718 };
2719 (class, glyph)
2720}
2721
2722struct ChorusRecallCtx<'a> {
2736 chorus_body: &'a [Line],
2737 transpose_offset: i8,
2738 prefer_flat: bool,
2739 fmt_state: &'a FormattingState,
2740 show_diagrams: bool,
2741 diagram_frets: usize,
2742}
2743
2744fn render_chorus_recall(value: &Option<String>, ctx: &ChorusRecallCtx<'_>, html: &mut String) {
2745 let chorus_body = ctx.chorus_body;
2746 let transpose_offset = ctx.transpose_offset;
2747 let prefer_flat = ctx.prefer_flat;
2748 let fmt_state = ctx.fmt_state;
2749 let show_diagrams = ctx.show_diagrams;
2750 let diagram_frets = ctx.diagram_frets;
2751 html.push_str("<div class=\"chorus-recall\">\n");
2752 let display_label = match value {
2753 Some(v) if !v.is_empty() => format!("Chorus: {}", escape(v)),
2754 _ => "Chorus".to_string(),
2755 };
2756 let _ = writeln!(html, "<h3 class=\"section-label\">{display_label}</h3>");
2757 let mut local_fmt = fmt_state.clone();
2761 for line in chorus_body {
2762 match line {
2763 Line::Lyrics(lyrics) => {
2764 render_lyrics(lyrics, transpose_offset, prefer_flat, &local_fmt, html)
2765 }
2766 Line::Comment(style, text) => render_comment(*style, text, html),
2767 Line::Empty => html.push_str("<div class=\"empty-line\" aria-hidden=\"true\"></div>\n"),
2768 Line::Directive(d) if d.kind.is_font_size_color() => {
2769 local_fmt.apply(&d.kind, &d.value);
2770 }
2771 Line::Directive(d) if !d.kind.is_metadata() => {
2772 render_directive_inner(d, show_diagrams, diagram_frets, html);
2773 }
2774 _ => {}
2775 }
2776 }
2777 html.push_str("</div>\n");
2778}
2779
2780fn render_comment(style: CommentStyle, text: &str, html: &mut String) {
2786 match style {
2787 CommentStyle::Normal => {
2788 let _ = writeln!(html, "<p class=\"comment\">{}</p>", escape(text));
2789 }
2790 CommentStyle::Italic => {
2791 let _ = writeln!(html, "<p class=\"comment\"><em>{}</em></p>", escape(text));
2792 }
2793 CommentStyle::Boxed => {
2794 let _ = writeln!(html, "<div class=\"comment-box\">{}</div>", escape(text));
2795 }
2796 CommentStyle::Highlight => {
2797 let _ = writeln!(
2805 html,
2806 "<p class=\"comment comment--highlight\"><mark>{}</mark></p>",
2807 escape(text)
2808 );
2809 }
2810 }
2811}
2812
2813#[cfg(test)]
2818mod sanitize_tag_attrs_tests {
2819 use super::*;
2820
2821 #[test]
2822 fn test_preserves_normal_attrs() {
2823 let tag = "<svg width=\"100\" height=\"50\">";
2824 assert_eq!(sanitize_tag_attrs(tag), tag);
2825 }
2826
2827 #[test]
2828 fn test_strips_event_handler() {
2829 let tag = "<svg onclick=\"alert(1)\" width=\"100\">";
2830 let result = sanitize_tag_attrs(tag);
2831 assert!(!result.contains("onclick"));
2832 assert!(result.contains("width"));
2833 }
2834
2835 #[test]
2836 fn test_non_ascii_in_attr_value_preserved() {
2837 let tag = "<text title=\"日本語テスト\" x=\"10\">";
2838 let result = sanitize_tag_attrs(tag);
2839 assert!(result.contains("日本語テスト"));
2840 assert!(result.contains("x=\"10\""));
2841 }
2842
2843 #[test]
2846 fn test_strips_mixed_case_event_handler() {
2847 let tag = "<svg OnClick=\"alert(1)\" width=\"100\">";
2848 let result = sanitize_tag_attrs(tag);
2849 assert!(!result.contains("OnClick"));
2850 assert!(result.contains("width"));
2851 }
2852
2853 #[test]
2854 fn test_strips_uppercase_event_handler() {
2855 let tag = "<svg ONLOAD=\"alert(1)\">";
2856 let result = sanitize_tag_attrs(tag);
2857 assert!(!result.contains("ONLOAD"));
2858 }
2859
2860 #[test]
2863 fn test_strips_style_with_url() {
2864 let tag =
2865 "<rect style=\"background-image: url('https://attacker.com/exfil')\" width=\"10\">";
2866 let result = sanitize_tag_attrs(tag);
2867 assert!(!result.contains("style"));
2868 assert!(result.contains("width"));
2869 }
2870
2871 #[test]
2872 fn test_strips_style_with_expression() {
2873 let tag = "<rect style=\"width: expression(alert(1))\">";
2874 let result = sanitize_tag_attrs(tag);
2875 assert!(!result.contains("style"));
2876 }
2877
2878 #[test]
2879 fn test_strips_style_with_import() {
2880 let tag = "<rect style=\"@import url(evil.css)\">";
2881 let result = sanitize_tag_attrs(tag);
2882 assert!(!result.contains("style"));
2883 }
2884
2885 #[test]
2886 fn test_preserves_safe_style() {
2887 let tag = "<rect style=\"fill: red; stroke: blue\" width=\"10\">";
2888 let result = sanitize_tag_attrs(tag);
2889 assert!(result.contains("style"));
2890 assert!(result.contains("fill: red"));
2891 }
2892
2893 #[test]
2894 fn test_use_strips_relative_url_href() {
2895 let tag = "<use href=\"sprites.svg#icon\">";
2902 let result = sanitize_tag_attrs(tag);
2903 assert!(
2904 !result.contains("href="),
2905 "relative URL must be stripped for <use>; got {result:?}"
2906 );
2907 }
2908
2909 #[test]
2910 fn test_use_preserves_whitespace_prefixed_fragment_href() {
2911 let tag = "<use href=\" #myShape\">";
2915 let result = sanitize_tag_attrs(tag);
2916 assert!(
2920 result.contains("href="),
2921 "whitespace-prefixed fragment href must be preserved; got {result:?}"
2922 );
2923 }
2924}
2925
2926#[cfg(test)]
2927mod tests {
2928 use super::*;
2929
2930 #[test]
2935 fn test_transposed_chord_uses_canonical_spelling_per_target_key() {
2936 let song = chordsketch_chordpro::parse("{key: C}\n[D#]hi").unwrap();
2943 let result = render_song_with_warnings(&song, 3, &Config::defaults());
2944 assert!(
2945 result.output.contains(">G\u{266D}<"),
2946 "expected `Gb` (flat-side spelling) in transposed Eb song; got: {}",
2947 result.output
2948 );
2949 assert!(
2950 !result.output.contains(">F\u{266F}<"),
2951 "must not emit sharp-side spelling for a flat-side song; got: {}",
2952 result.output
2953 );
2954 }
2955
2956 #[test]
2957 fn test_chord_block_uses_unicode_accidentals() {
2958 let html = render("[Bb]hi");
2959 assert!(
2960 html.contains("<span class=\"chord\">B\u{266D}</span>"),
2961 "expected `B♭` in chord block, got: {html}"
2962 );
2963 }
2964
2965 #[test]
2966 fn test_inline_key_marker_uses_unicode_accidentals_for_flat_key() {
2967 let html = render("{key: Bb}");
2971 assert!(
2972 html.contains("<span class=\"meta-inline__value\">B\u{266D}</span>"),
2973 "expected `B♭` in inline key value, got: {html}"
2974 );
2975 }
2976
2977 #[test]
2978 fn test_inline_key_marker_uses_unicode_accidentals() {
2979 let html = render("[G]a\n{key: Eb}\n[Eb]b");
2980 assert!(
2981 html.contains("<span class=\"meta-inline__value\">E\u{266D}</span>"),
2982 "expected `E♭` in inline key value, got: {html}"
2983 );
2984 }
2985
2986 #[test]
2987 fn test_render_empty() {
2988 let song = chordsketch_chordpro::parse("").unwrap();
2989 let html = render_song(&song);
2990 assert!(html.contains("<!DOCTYPE html>"));
2991 assert!(html.contains("</html>"));
2992 }
2993
2994 #[test]
2995 fn test_render_song_body_omits_document_envelope() {
2996 let song =
3002 chordsketch_chordpro::parse("{title: Sample}\nWas [G]blind but [D]now I [G]see.")
3003 .unwrap();
3004 let body = render_song_body(&song);
3005 assert!(!body.contains("<!DOCTYPE"));
3006 assert!(!body.contains("<html"));
3007 assert!(!body.contains("</html>"));
3008 assert!(!body.contains("<head>"));
3013 assert!(!body.contains("<style"));
3014 assert!(!body.contains("<title>"));
3015 assert!(body.contains("<article class=\"song\">"));
3019 assert!(body.contains("<h1>Sample</h1>"));
3020 assert!(body.contains("class=\"chord-block\""));
3025 }
3026
3027 #[test]
3028 fn test_render_song_body_byte_stable_with_full_render_body_section() {
3029 let song = chordsketch_chordpro::parse(
3035 "{title: Amazing Grace}\nA[G]mazing [D]grace, how [G]sweet the sound.",
3036 )
3037 .unwrap();
3038 let full = render_song(&song);
3039 let body = render_song_body(&song);
3040 let body_start = full
3041 .find("<body>\n")
3042 .expect("full-document render must have <body>")
3043 + "<body>\n".len();
3044 let body_end = full
3045 .rfind("</body>")
3046 .expect("full-document render must have </body>");
3047 let extracted = &full[body_start..body_end];
3048 assert_eq!(
3049 extracted, body,
3050 "body-only output must match the body slice of the full document"
3051 );
3052 }
3053
3054 #[test]
3055 fn test_render_html_css_returns_canonical_block() {
3056 let css = render_html_css();
3064 assert!(css.contains(".chord-block"));
3065 assert!(css.contains(".chord "));
3066 assert!(css.contains(".lyrics"));
3067 assert!(css.contains(".line { display: flex; flex-wrap: wrap;"));
3070 let song = chordsketch_chordpro::parse("{title: t}").unwrap();
3074 let full = render_song(&song);
3075 assert!(full.contains(&css));
3076 }
3077
3078 #[test]
3081 fn test_wraplines_default_is_wrap() {
3082 let css = render_html_css_with_config(&Config::defaults());
3084 assert!(
3085 css.contains(".line { display: flex; flex-wrap: wrap;"),
3086 "default settings.wraplines must emit flex-wrap: wrap; got: {css}"
3087 );
3088 assert!(
3090 css.contains(".chord-diagrams-grid { display: flex; flex-wrap: wrap;"),
3091 ".chord-diagrams-grid wrap must never be substituted"
3092 );
3093 assert!(
3095 !css.contains("__LINE_FLEX_WRAP__"),
3096 "the sentinel must always be replaced; got: {css}"
3097 );
3098 }
3099
3100 #[test]
3101 fn test_wraplines_false_emits_nowrap() {
3102 let cfg = Config::defaults()
3103 .with_define("settings.wraplines=false")
3104 .unwrap();
3105 let css = render_html_css_with_config(&cfg);
3106 assert!(
3107 css.contains(".line { display: flex; flex-wrap: nowrap;"),
3108 "settings.wraplines=false must emit flex-wrap: nowrap; got: {css}"
3109 );
3110 assert!(
3112 css.contains(".chord-diagrams-grid { display: flex; flex-wrap: wrap;"),
3113 ".chord-diagrams-grid wrap must NOT change with settings.wraplines"
3114 );
3115 }
3116
3117 #[test]
3118 fn test_wraplines_full_document_embeds_configured_value() {
3119 let cfg = Config::defaults()
3122 .with_define("settings.wraplines=false")
3123 .unwrap();
3124 let song = chordsketch_chordpro::parse("{title: t}").unwrap();
3125 let full = render_song_with_warnings(&song, 0, &cfg).output;
3126 assert!(
3127 full.contains(".line { display: flex; flex-wrap: nowrap;"),
3128 "full document must embed nowrap when settings.wraplines=false"
3129 );
3130 }
3131
3132 #[test]
3133 fn test_wraplines_true_explicit_matches_default() {
3134 let cfg = Config::defaults()
3135 .with_define("settings.wraplines=true")
3136 .unwrap();
3137 assert_eq!(
3138 render_html_css_with_config(&cfg),
3139 render_html_css_with_config(&Config::defaults())
3140 );
3141 }
3142
3143 #[test]
3144 fn test_render_songs_body_separator_between_songs() {
3145 let parsed =
3146 chordsketch_chordpro::parse_multi_lenient("{title: A}\n{new_song}\n{title: B}");
3147 let songs: Vec<_> = parsed.results.into_iter().map(|r| r.song).collect();
3148 assert_eq!(songs.len(), 2, "expected two songs in the parsed output");
3149 let body = render_songs_body(&songs);
3150 assert!(body.contains("<hr class=\"song-separator\">"));
3151 assert!(!body.contains("<!DOCTYPE"));
3152 }
3153
3154 #[test]
3155 fn test_render_songs_body_empty_input() {
3156 let body = render_songs_body(&[]);
3157 assert!(body.is_empty());
3158 }
3159
3160 #[test]
3161 fn test_render_title() {
3162 let html = render("{title: My Song}");
3163 assert!(html.contains("<h1>My Song</h1>"));
3164 assert!(html.contains("<title>My Song</title>"));
3165 }
3166
3167 #[test]
3168 fn test_render_subtitle() {
3169 let html = render("{title: Song}\n{subtitle: By Someone}");
3170 assert!(html.contains("<h2>By Someone</h2>"));
3171 }
3172
3173 #[test]
3174 fn test_render_lyrics_with_chords() {
3175 let html = render("[Am]Hello [G]world");
3176 assert!(html.contains("chord-block"));
3177 assert!(html.contains("<span class=\"chord\">Am</span>"));
3178 assert!(html.contains("<span class=\"lyrics\">Hello </span>"));
3179 assert!(html.contains("<span class=\"chord\">G</span>"));
3180 }
3181
3182 #[test]
3183 fn test_render_lyrics_no_chords() {
3184 let html = render("Just plain text");
3185 assert!(html.contains("<span class=\"lyrics\">Just plain text</span>"));
3186 assert!(!html.contains("class=\"chord\""));
3188 }
3189
3190 #[test]
3191 fn test_render_chorus_section() {
3192 let html = render("{start_of_chorus}\n[G]La la\n{end_of_chorus}");
3193 assert!(html.contains("<section class=\"chorus\">"));
3194 assert!(html.contains("</section>"));
3195 assert!(html.contains("Chorus"));
3196 }
3197
3198 #[test]
3199 fn test_render_verse_with_label() {
3200 let html = render("{start_of_verse: Verse 1}\nLyrics\n{end_of_verse}");
3201 assert!(html.contains("<section class=\"verse\">"));
3202 assert!(html.contains("Verse: Verse 1"));
3203 }
3204
3205 #[test]
3206 fn test_render_comment() {
3207 let html = render("{comment: A note}");
3208 assert!(html.contains("<p class=\"comment\">A note</p>"));
3209 }
3210
3211 #[test]
3212 fn test_render_comment_italic() {
3213 let html = render("{comment_italic: Softly}");
3214 assert!(html.contains("<em>Softly</em>"));
3215 }
3216
3217 #[test]
3218 fn test_render_comment_box() {
3219 let html = render("{comment_box: Important}");
3220 assert!(html.contains("<div class=\"comment-box\">Important</div>"));
3221 }
3222
3223 #[test]
3224 fn test_render_comment_highlight() {
3225 let html = render("{highlight: Watch out}");
3231 assert!(
3232 html.contains("<p class=\"comment comment--highlight\"><mark>Watch out</mark></p>"),
3233 "got: {html}"
3234 );
3235 }
3236
3237 #[test]
3238 fn test_html_escaping() {
3239 let html = render("{title: Tom & Jerry <3}");
3240 assert!(html.contains("Tom & Jerry <3"));
3241 }
3242
3243 #[test]
3244 fn test_try_render_success() {
3245 let result = try_render("{title: Test}");
3246 assert!(result.is_ok());
3247 }
3248
3249 #[test]
3250 fn test_try_render_error() {
3251 let result = try_render("{unclosed");
3252 assert!(result.is_err());
3253 }
3254
3255 #[test]
3256 fn test_render_valid_html_structure() {
3257 let html = render("{title: Test}\n\n{start_of_verse}\n[G]Hello [C]world\n{end_of_verse}");
3258 assert!(html.starts_with("<!DOCTYPE html>"));
3259 assert!(html.contains("<html"));
3260 assert!(html.contains("<head>"));
3261 assert!(html.contains("<style>"));
3262 assert!(html.contains("<body>"));
3263 assert!(html.contains("</html>"));
3264 }
3265
3266 #[test]
3267 fn test_text_before_first_chord() {
3268 let html = render("Hello [Am]world");
3269 assert!(
3275 html.contains(
3276 "<span class=\"chord\" aria-hidden=\"true\">\u{00A0}</span><span class=\"lyrics\">Hello </span>"
3277 )
3278 );
3279 }
3280
3281 #[test]
3282 fn test_chord_less_and_chord_bearing_segments_share_baseline_placeholder() {
3283 let html = render("Was [G]blind but [D]now I [G]see.");
3288 assert!(
3291 html.contains(
3292 "<span class=\"chord\" aria-hidden=\"true\">\u{00A0}</span><span class=\"lyrics\">Was </span>"
3293 ),
3294 "expected NBSP-bearing chord placeholder for \"Was \" segment, got: {html}"
3295 );
3296 assert!(html.contains("<span class=\"chord\">G</span>"));
3298 assert!(html.contains("<span class=\"chord\">D</span>"));
3299 }
3300
3301 #[test]
3302 fn test_empty_line() {
3303 let html = render("Line one\n\nLine two");
3304 assert!(html.contains("empty-line"));
3305 }
3306
3307 #[test]
3308 fn test_render_grid_section() {
3309 let html = render("{start_of_grid}\n| Am . | C . |\n{end_of_grid}");
3310 assert!(html.contains("<section class=\"grid\">"));
3311 assert!(html.contains("Grid"));
3312 assert!(html.contains("</section>"));
3313 }
3314
3315 #[test]
3318 fn test_render_custom_section_intro() {
3319 let html = render("{start_of_intro}\n[Am]Da da\n{end_of_intro}");
3320 assert!(html.contains("<section class=\"section-intro\">"));
3321 assert!(html.contains("Intro"));
3322 assert!(html.contains("</section>"));
3323 }
3324
3325 #[test]
3326 fn test_render_grid_section_with_label() {
3327 let html = render("{start_of_grid: Intro}\n| Am |\n{end_of_grid}");
3328 assert!(html.contains("<section class=\"grid\">"));
3329 assert!(html.contains("Grid: Intro"));
3330 }
3331
3332 #[test]
3333 fn test_render_grid_short_alias() {
3334 let html = render("{sog}\n| G . |\n{eog}");
3335 assert!(html.contains("<section class=\"grid\">"));
3336 assert!(html.contains("</section>"));
3337 }
3338
3339 #[test]
3340 fn test_render_custom_section_with_label() {
3341 let html = render("{start_of_intro: Guitar}\nNotes\n{end_of_intro}");
3342 assert!(html.contains("<section class=\"section-intro\">"));
3343 assert!(html.contains("Intro: Guitar"));
3344 }
3345
3346 #[test]
3347 fn test_render_custom_section_outro() {
3348 let html = render("{start_of_outro}\nFinal\n{end_of_outro}");
3349 assert!(html.contains("<section class=\"section-outro\">"));
3350 assert!(html.contains("Outro"));
3351 }
3352
3353 #[test]
3354 fn test_render_custom_section_solo() {
3355 let html = render("{start_of_solo}\n[Em]Solo\n{end_of_solo}");
3356 assert!(html.contains("<section class=\"section-solo\">"));
3357 assert!(html.contains("Solo"));
3358 assert!(html.contains("</section>"));
3359 }
3360
3361 #[test]
3362 fn test_custom_section_name_escaped() {
3363 let html = render(
3364 "{start_of_x<script>alert(1)</script>}\ntext\n{end_of_x<script>alert(1)</script>}",
3365 );
3366 assert!(!html.contains("<script>"));
3367 assert!(html.contains("<script>"));
3368 }
3369
3370 #[test]
3371 fn test_custom_section_name_quotes_escaped() {
3372 let html =
3373 render("{start_of_x\" onclick=\"alert(1)}\ntext\n{end_of_x\" onclick=\"alert(1)}");
3374 assert!(html.contains("""));
3376 assert!(!html.contains("class=\"section-x\""));
3377 }
3378
3379 #[test]
3380 fn test_custom_section_name_single_quotes_escaped() {
3381 let html = render("{start_of_x' onclick='alert(1)}\ntext\n{end_of_x' onclick='alert(1)}");
3382 assert!(html.contains("'") || html.contains("'"));
3385 assert!(!html.contains("onclick='alert"));
3386 }
3387
3388 #[test]
3389 fn test_custom_section_name_space_sanitized_in_class() {
3390 let html = render("{start_of_foo bar}\ntext\n{end_of_foo bar}");
3392 assert!(html.contains("section-foo-bar"));
3394 assert!(!html.contains("class=\"section-foo bar\""));
3395 }
3396
3397 #[test]
3398 fn test_custom_section_name_special_chars_sanitized_in_class() {
3399 let html = render("{start_of_a&b<c>d}\ntext\n{end_of_a&b<c>d}");
3400 assert!(html.contains("section-a-b-c-d"));
3402 assert!(html.contains("&"));
3404 }
3405
3406 #[test]
3407 fn test_custom_section_capitalize_before_escape() {
3408 let html = render("{start_of_&test}\ntext\n{end_of_&test}");
3412 assert!(html.contains("&test"));
3415 assert!(!html.contains("&Amp;"));
3416 }
3417
3418 #[test]
3419 fn test_define_display_name_in_html_output() {
3420 let html = render("{define: Am base-fret 1 frets x 0 2 2 1 0 display=\"A minor\"}");
3421 assert!(
3422 html.contains("A minor"),
3423 "display name should appear in rendered HTML output"
3424 );
3425 }
3426}
3427
3428#[cfg(test)]
3429mod transpose_tests {
3430 use super::*;
3431
3432 #[test]
3433 fn test_transpose_directive_up_2() {
3434 let input = "{transpose: 2}\n[G]Hello [C]world";
3435 let song = chordsketch_chordpro::parse(input).unwrap();
3436 let html = render_song(&song);
3437 assert!(html.contains("<span class=\"chord\">A</span>"));
3439 assert!(html.contains("<span class=\"chord\">D</span>"));
3440 assert!(!html.contains("<span class=\"chord\">G</span>"));
3441 assert!(!html.contains("<span class=\"chord\">C</span>"));
3442 }
3443
3444 #[test]
3445 fn test_transpose_directive_replaces_previous() {
3446 let input = "{transpose: 2}\n[G]First\n{transpose: 0}\n[G]Second";
3447 let song = chordsketch_chordpro::parse(input).unwrap();
3448 let html = render_song(&song);
3449 assert!(html.contains("<span class=\"chord\">A</span>"));
3451 assert!(html.contains("<span class=\"chord\">G</span>"));
3452 }
3453
3454 #[test]
3455 fn test_transpose_directive_with_cli_offset() {
3456 let input = "{transpose: 2}\n[C]Hello";
3457 let song = chordsketch_chordpro::parse(input).unwrap();
3458 let html = render_song_with_transpose(&song, 3, &Config::defaults());
3459 assert!(html.contains("<span class=\"chord\">F</span>"));
3461 }
3462
3463 #[test]
3464 fn test_transpose_out_of_i8_range_emits_warning() {
3465 let input = "{transpose: 999}\n[G]Hello";
3467 let song = chordsketch_chordpro::parse(input).unwrap();
3468 let result = render_song_with_warnings(&song, 0, &Config::defaults());
3469 assert!(
3470 result.output.contains("<span class=\"chord\">G</span>"),
3471 "chord should be untransposed"
3472 );
3473 assert!(
3474 result.warnings.iter().any(|w| w.contains("\"999\"")),
3475 "expected warning about out-of-range value, got: {:?}",
3476 result.warnings
3477 );
3478 }
3479
3480 #[test]
3481 fn test_transpose_no_value_treated_as_zero() {
3482 let input = "{transpose}\n[G]Hello";
3484 let song = chordsketch_chordpro::parse(input).unwrap();
3485 let result = render_song_with_warnings(&song, 0, &Config::defaults());
3486 assert!(
3487 result.output.contains("<span class=\"chord\">G</span>"),
3488 "chord should be untransposed"
3489 );
3490 assert!(
3491 result.warnings.is_empty(),
3492 "missing {{transpose}} value should not emit a warning; got: {:?}",
3493 result.warnings
3494 );
3495 }
3496
3497 #[test]
3498 fn test_transpose_whitespace_value_treated_as_zero() {
3499 let input = "{transpose: }\n[G]Hello";
3503 let song = chordsketch_chordpro::parse(input).unwrap();
3504 let result = render_song_with_warnings(&song, 0, &Config::defaults());
3505 assert!(
3506 result.output.contains("<span class=\"chord\">G</span>"),
3507 "chord should be untransposed"
3508 );
3509 assert!(
3510 result.warnings.is_empty(),
3511 "whitespace-only {{transpose}} value should not emit a warning; got: {:?}",
3512 result.warnings
3513 );
3514 }
3515
3516 #[test]
3519 fn test_render_chorus_recall_basic() {
3520 let html = render("{start_of_chorus}\n[G]La la\n{end_of_chorus}\n\n{chorus}");
3521 assert!(html.contains("<div class=\"chorus-recall\">"));
3523 assert!(html.contains("chorus-recall"));
3525 assert!(html.contains("<section class=\"chorus\">"));
3527 }
3528
3529 #[test]
3530 fn test_render_chorus_recall_with_label() {
3531 let html = render("{start_of_chorus}\nSing\n{end_of_chorus}\n{chorus: Repeat}");
3532 assert!(html.contains("Chorus: Repeat"));
3533 assert!(html.contains("chorus-recall"));
3534 }
3535
3536 #[test]
3537 fn test_render_chorus_recall_no_chorus_defined() {
3538 let html = render("{chorus}");
3539 assert!(html.contains("<div class=\"chorus-recall\">"));
3541 assert!(html.contains("Chorus"));
3542 }
3543
3544 #[test]
3545 fn test_render_chorus_recall_content_replayed() {
3546 let html = render("{start_of_chorus}\nChorus text\n{end_of_chorus}\n{chorus}");
3547 let count = html.matches("Chorus text").count();
3549 assert_eq!(count, 2, "chorus content should appear twice");
3550 }
3551
3552 #[test]
3553 fn test_chorus_recall_applies_current_transpose() {
3554 let html = render("{start_of_chorus}\n[G]La la\n{end_of_chorus}\n{transpose: 2}\n{chorus}");
3557 assert!(
3559 html.contains("<span class=\"chord\">G</span>"),
3560 "original chorus should have G"
3561 );
3562 assert!(
3564 html.contains("<span class=\"chord\">A</span>"),
3565 "recalled chorus should have transposed chord A, got:\n{html}"
3566 );
3567 }
3568
3569 #[test]
3570 fn test_chorus_recall_preserves_formatting_directives() {
3571 let html =
3573 render("{start_of_chorus}\n{textsize: 20}\n[Am]Big text\n{end_of_chorus}\n{chorus}");
3574 let recall_start = html.find("chorus-recall").expect("should have recall");
3576 let recall_section = &html[recall_start..];
3577 assert!(
3578 recall_section.contains("font-size"),
3579 "recalled chorus should apply in-chorus formatting directives"
3580 );
3581 }
3582
3583 #[test]
3584 fn test_chorus_formatting_does_not_leak_to_outer_scope() {
3585 let html =
3587 render("{start_of_chorus}\n{textsize: 20}\n[Am]Big\n{end_of_chorus}\n[G]Normal text");
3588 let after_chorus = html
3590 .rfind("Normal text")
3591 .expect("should have post-chorus text");
3592 let line_start = html[..after_chorus].rfind("<div class=\"line\"").unwrap();
3594 let line_end = html[line_start..]
3595 .find("</div>")
3596 .map_or(html.len(), |i| line_start + i + 6);
3597 let post_chorus_line = &html[line_start..line_end];
3598 assert!(
3599 !post_chorus_line.contains("font-size"),
3600 "in-chorus {{textsize}} should not leak to post-chorus content: {post_chorus_line}"
3601 );
3602 }
3603
3604 #[test]
3607 fn test_render_bold_markup() {
3608 let html = render("Hello <b>bold</b> world");
3609 assert!(html.contains("<b>bold</b>"));
3610 assert!(html.contains("Hello "));
3611 assert!(html.contains(" world"));
3612 }
3613
3614 #[test]
3615 fn test_render_italic_markup() {
3616 let html = render("Hello <i>italic</i> text");
3617 assert!(html.contains("<i>italic</i>"));
3618 }
3619
3620 #[test]
3621 fn test_render_highlight_markup() {
3622 let html = render("<highlight>important</highlight>");
3623 assert!(html.contains("<mark>important</mark>"));
3624 }
3625
3626 #[test]
3627 fn test_render_comment_inline_markup() {
3628 let html = render("<comment>note</comment>");
3629 assert!(html.contains("<span class=\"comment\">note</span>"));
3630 }
3631
3632 #[test]
3633 fn test_render_span_with_foreground() {
3634 let html = render(r#"<span foreground="red">red text</span>"#);
3635 assert!(html.contains("color: red;"));
3636 assert!(html.contains("red text"));
3637 }
3638
3639 #[test]
3640 fn test_render_span_with_multiple_attrs() {
3641 let html = render(
3642 r#"<span font_family="Serif" size="14" foreground="blue" weight="bold">styled</span>"#,
3643 );
3644 assert!(html.contains("font-family: Serif;"));
3645 assert!(html.contains("font-size: 14pt;"));
3646 assert!(html.contains("color: blue;"));
3647 assert!(html.contains("font-weight: bold;"));
3648 assert!(html.contains("styled"));
3649 }
3650
3651 #[test]
3652 fn test_span_css_injection_url_prevented() {
3653 let html = render(
3654 r#"<span foreground="red; background-image: url('https://evil.com/')">text</span>"#,
3655 );
3656 assert!(!html.contains("url("));
3658 assert!(!html.contains(";background-image"));
3659 }
3660
3661 #[test]
3662 fn test_span_css_injection_semicolon_stripped() {
3663 let html =
3664 render(r#"<span foreground="red; position: absolute; z-index: 9999">text</span>"#);
3665 assert!(!html.contains(";position"));
3669 assert!(!html.contains("; position"));
3670 assert!(html.contains("color:"));
3671 }
3672
3673 #[test]
3674 fn test_render_nested_markup() {
3675 let html = render("<b><i>bold italic</i></b>");
3676 assert!(html.contains("<b><i>bold italic</i></b>"));
3677 }
3678
3679 #[test]
3680 fn test_render_markup_with_chord() {
3681 let html = render("[Am]Hello <b>bold</b> world");
3682 assert!(html.contains("<b>bold</b>"));
3683 assert!(html.contains("<span class=\"chord\">Am</span>"));
3684 }
3685
3686 #[test]
3687 fn test_render_no_markup_unchanged() {
3688 let html = render("Just plain text");
3689 assert!(!html.contains("<b>"));
3691 assert!(!html.contains("<i>"));
3692 assert!(html.contains("Just plain text"));
3693 }
3694
3695 #[test]
3698 fn test_textfont_directive_applies_css() {
3699 let html = render("{textfont: Courier}\nHello world");
3700 assert!(html.contains("font-family: Courier;"));
3701 }
3702
3703 #[test]
3704 fn test_textsize_directive_applies_css() {
3705 let html = render("{textsize: 14}\nHello world");
3706 assert!(html.contains("font-size: 14pt;"));
3707 }
3708
3709 #[test]
3710 fn test_textcolour_directive_applies_css() {
3711 let html = render("{textcolour: blue}\nHello world");
3712 assert!(html.contains("color: blue;"));
3713 }
3714
3715 #[test]
3716 fn test_chordfont_directive_applies_css() {
3717 let html = render("{chordfont: Monospace}\n[Am]Hello");
3718 assert!(html.contains("font-family: Monospace;"));
3719 }
3720
3721 #[test]
3722 fn test_chordsize_directive_applies_css() {
3723 let html = render("{chordsize: 16}\n[Am]Hello");
3724 assert!(html.contains("font-size: 16pt;"));
3726 }
3727
3728 #[test]
3729 fn test_chordcolour_directive_applies_css() {
3730 let html = render("{chordcolour: green}\n[Am]Hello");
3731 assert!(html.contains("color: green;"));
3732 }
3733
3734 #[test]
3735 fn test_formatting_persists_across_lines() {
3736 let html = render("{textcolour: red}\nLine one\nLine two");
3737 let count = html.matches("color: red;").count();
3739 assert!(
3740 count >= 2,
3741 "formatting should persist: found {count} matches"
3742 );
3743 }
3744
3745 #[test]
3746 fn test_formatting_overridden_by_later_directive() {
3747 let html = render("{textcolour: red}\nRed text\n{textcolour: blue}\nBlue text");
3748 assert!(html.contains("color: red;"));
3749 assert!(html.contains("color: blue;"));
3750 }
3751
3752 #[test]
3753 fn test_no_formatting_no_style_attr() {
3754 let html = render("Plain text");
3755 assert!(!html.contains("<span class=\"lyrics\" style="));
3757 }
3758
3759 #[test]
3760 fn test_formatting_directive_css_injection_prevented() {
3761 let html = render("{textcolour: red; position: fixed; z-index: 9999}\nHello");
3762 assert!(!html.contains(";position"));
3764 assert!(!html.contains("; position"));
3765 assert!(html.contains("color:"));
3766 }
3767
3768 #[test]
3769 fn test_formatting_directive_url_injection_prevented() {
3770 let html = render("{textcolour: red; background-image: url('https://evil.com/')}\nHello");
3771 assert!(!html.contains("url("));
3773 }
3774
3775 #[test]
3778 fn test_columns_directive_generates_css() {
3779 let html = render("{columns: 2}\nLine one\nLine two");
3780 assert!(html.contains("column-count: 2"));
3781 }
3782
3783 #[test]
3784 fn test_columns_reset_to_one() {
3785 let html = render("{columns: 2}\nTwo cols\n{columns: 1}\nOne col");
3786 let count = html.matches("column-count: 2").count();
3788 assert_eq!(count, 1);
3789 assert!(html.contains("One col"));
3790 }
3791
3792 #[test]
3793 fn test_column_break_generates_css() {
3794 let html = render("{columns: 2}\nCol 1\n{column_break}\nCol 2");
3795 assert!(html.contains("break-before: column;"));
3796 }
3797
3798 #[test]
3799 fn test_columns_clamped_to_max() {
3800 let html = render("{columns: 999}\nContent");
3801 assert!(html.contains("column-count: 32"));
3803 }
3804
3805 #[test]
3806 fn test_columns_zero_treated_as_one() {
3807 let html = render("{columns: 0}\nContent");
3808 assert!(!html.contains("column-count"));
3810 }
3811
3812 #[test]
3813 fn test_columns_non_numeric_defaults_to_one() {
3814 let html = render("{columns: abc}\nHello");
3815 assert!(!html.contains("column-count"));
3817 }
3818
3819 #[test]
3820 fn test_new_page_generates_page_break() {
3821 let html = render("Page 1\n{new_page}\nPage 2");
3822 assert!(html.contains("break-before: page;"));
3823 }
3824
3825 #[test]
3826 fn test_new_physical_page_generates_recto_break() {
3827 let html = render("Page 1\n{new_physical_page}\nPage 2");
3828 assert!(
3829 html.contains("break-before: recto;"),
3830 "new_physical_page should use break-before: recto for duplex printing"
3831 );
3832 assert!(
3833 !html.contains("break-before: page;"),
3834 "new_physical_page should not emit generic page break"
3835 );
3836 }
3837
3838 #[test]
3839 fn test_page_control_not_replayed_in_chorus_recall() {
3840 let input = "\
3842{start_of_chorus}\n\
3843{new_page}\n\
3844[G]La la la\n\
3845{end_of_chorus}\n\
3846Verse text\n\
3847{chorus}";
3848 let html = render(input);
3849 assert!(html.contains("break-before: page;"));
3851 let count = html.matches("break-before: page;").count();
3854 assert_eq!(count, 1, "page break must not be replayed in chorus recall");
3855 }
3856
3857 #[test]
3860 fn test_image_basic() {
3861 let html = render("{image: src=photo.jpg}");
3862 assert!(html.contains("<img src=\"photo.jpg\""));
3863 }
3864
3865 #[test]
3866 fn test_image_with_dimensions() {
3867 let html = render("{image: src=photo.jpg width=200 height=100}");
3868 assert!(html.contains("width=\"200\""));
3869 assert!(html.contains("height=\"100\""));
3870 }
3871
3872 #[test]
3873 fn test_image_with_title() {
3874 let html = render("{image: src=photo.jpg title=\"My Photo\"}");
3875 assert!(html.contains("alt=\"My Photo\""));
3876 }
3877
3878 #[test]
3879 fn test_image_with_scale() {
3880 let html = render("{image: src=photo.jpg scale=0.5}");
3881 assert!(html.contains("scale(0.5)"));
3882 }
3883
3884 #[test]
3885 fn test_image_empty_src_skipped() {
3886 let html = render("{image: src=}");
3887 assert!(
3888 !html.contains("<img"),
3889 "empty src should not produce an img element"
3890 );
3891 }
3892
3893 #[test]
3894 fn test_image_javascript_uri_rejected() {
3895 let html = render("{image: src=javascript:alert(1)}");
3896 assert!(!html.contains("<img"), "javascript: URI must be rejected");
3897 }
3898
3899 #[test]
3900 fn test_image_data_uri_rejected() {
3901 let html = render("{image: src=data:text/html,<script>alert(1)</script>}");
3902 assert!(!html.contains("<img"), "data: URI must be rejected");
3903 }
3904
3905 #[test]
3906 fn test_image_vbscript_uri_rejected() {
3907 let html = render("{image: src=vbscript:MsgBox}");
3908 assert!(!html.contains("<img"), "vbscript: URI must be rejected");
3909 }
3910
3911 #[test]
3912 fn test_image_javascript_uri_case_insensitive() {
3913 let html = render("{image: src=JaVaScRiPt:alert(1)}");
3914 assert!(
3915 !html.contains("<img"),
3916 "scheme check must be case-insensitive"
3917 );
3918 }
3919
3920 #[test]
3921 fn test_image_safe_relative_path_allowed() {
3922 let html = render("{image: src=images/photo.jpg}");
3923 assert!(html.contains("<img src=\"images/photo.jpg\""));
3924 }
3925
3926 #[test]
3929 fn test_capo_out_of_range_emits_warning() {
3930 let song = chordsketch_chordpro::parse("{title: T}\n{capo: 999}").unwrap();
3931 let result = render_song_with_warnings(&song, 0, &Config::defaults());
3932 assert!(
3933 result
3934 .warnings
3935 .iter()
3936 .any(|w| w.contains("capo") && w.contains("999")),
3937 "expected out-of-range {{capo}} warning; got {:?}",
3938 result.warnings
3939 );
3940 }
3941
3942 #[test]
3943 fn test_capo_non_numeric_emits_warning() {
3944 let song = chordsketch_chordpro::parse("{title: T}\n{capo: foo}").unwrap();
3945 let result = render_song_with_warnings(&song, 0, &Config::defaults());
3946 assert!(
3947 result
3948 .warnings
3949 .iter()
3950 .any(|w| w.contains("capo") && w.contains("foo")),
3951 "expected non-integer {{capo}} warning; got {:?}",
3952 result.warnings
3953 );
3954 }
3955
3956 #[test]
3957 fn test_capo_in_range_is_silent() {
3958 let song = chordsketch_chordpro::parse("{title: T}\n{capo: 5}").unwrap();
3959 let result = render_song_with_warnings(&song, 0, &Config::defaults());
3960 assert!(
3961 !result.warnings.iter().any(|w| w.contains("capo")),
3962 "valid {{capo: 5}} should not warn; got {:?}",
3963 result.warnings
3964 );
3965 }
3966
3967 #[test]
3978 fn test_multi_value_keys_do_not_appear_in_chip_strip() {
3979 let html = render("{key: G}\n{key: D}\n[D]hi");
3980 assert!(
3981 !html.contains("class=\"meta__chip\">Key"),
3982 "Key chip should be omitted from header (positional inline marker covers it); got: {html}"
3983 );
3984 assert!(html.contains("meta-inline--key"));
3986 }
3987
3988 #[test]
3989 fn test_multi_value_tempos_do_not_appear_in_chip_strip() {
3990 let html = render("{tempo: 120}\n{tempo: 140}\n[G]a");
3991 assert!(
3992 !html.contains("BPM</span>") || !html.contains("class=\"meta__chip\""),
3993 "BPM chip should be omitted; got: {html}"
3994 );
3995 assert!(html.contains("meta-inline--tempo"));
3996 }
3997
3998 #[test]
3999 fn test_multi_value_times_do_not_appear_in_chip_strip() {
4000 let html = render("{time: 4/4}\n{time: 6/8}\n[G]a");
4001 assert!(
4003 !html.contains("<span class=\"meta__chip\">4/4"),
4004 "Time chip should be omitted; got: {html}"
4005 );
4006 assert!(html.contains("meta-inline--time"));
4008 }
4009
4010 #[test]
4017 fn test_inline_meta_marker_for_key() {
4018 let html = render("[G]first\n{key: D}\n[D]second");
4019 assert!(
4024 html.contains("meta-inline meta-inline--key"),
4025 "expected key marker class; got: {html}"
4026 );
4027 assert!(
4028 html.contains("music-glyph music-glyph--key"),
4029 "expected key-signature glyph SVG; got: {html}"
4030 );
4031 assert!(
4032 html.contains("<span class=\"meta-inline__label\">Key:</span>"),
4033 "expected label span; got: {html}"
4034 );
4035 assert!(
4036 html.contains("<span class=\"meta-inline__value\">D</span>"),
4037 "expected value span; got: {html}"
4038 );
4039 }
4040
4041 #[test]
4042 fn test_inline_meta_marker_for_tempo() {
4043 let html = render("[G]a\n{tempo: 140}\n[C]b");
4044 assert!(
4045 html.contains("meta-inline--tempo"),
4046 "expected inline tempo marker class; got: {html}"
4047 );
4048 assert!(
4049 html.contains("music-glyph music-glyph--metronome"),
4050 "expected metronome glyph SVG; got: {html}"
4051 );
4052 assert!(
4054 html.contains("--cs-metronome-period:0.429s"),
4057 "expected animation period derived from BPM; got: {html}"
4058 );
4059 assert!(
4060 html.contains("140 BPM"),
4061 "expected '140 BPM' in inline marker; got: {html}"
4062 );
4063 }
4064
4065 #[test]
4066 fn test_inline_meta_marker_for_time() {
4067 let html = render("{tempo: 120}\n[G]a\n{time: 6/8}\n[D]b");
4068 assert!(
4069 html.contains("meta-inline--time"),
4070 "expected inline time marker class; got: {html}"
4071 );
4072 assert!(
4076 html.contains("music-glyph--time__num\" aria-hidden=\"true\">6</span>"),
4077 "expected stacked numerator '6'; got: {html}"
4078 );
4079 assert!(
4080 html.contains("music-glyph--time__bar\""),
4081 "expected fraction bar between digits; got: {html}"
4082 );
4083 assert!(
4084 html.contains("music-glyph--time__den\" aria-hidden=\"true\">8</span>"),
4085 "expected stacked denominator '8'; got: {html}"
4086 );
4087 assert!(
4090 !html.contains("music-glyph--time--conduct-"),
4091 "conductor animation was retired; got: {html}"
4092 );
4093 assert!(
4094 !html.contains("<span class=\"meta-inline__value\">6/8</span>"),
4095 "redundant text value should not be emitted alongside the icon; got: {html}"
4096 );
4097 }
4098
4099 #[test]
4106 fn test_inline_meta_marker_skipped_for_empty_value() {
4107 let html = render("[G]a\n{key:}\n[D]b");
4108 assert!(
4109 !html.contains("meta-inline--key"),
4110 "empty {{key}} must not produce a marker; got: {html}"
4111 );
4112 }
4113
4114 #[test]
4118 fn test_single_value_key_emits_no_chip() {
4119 let html = render("{key: G}");
4120 assert!(
4121 !html.contains("<span class=\"meta__chip\">Key G</span>"),
4122 "Key chip should be omitted from header; got: {html}"
4123 );
4124 assert!(html.contains("meta-inline--key"));
4126 assert!(html.contains("<span class=\"meta-inline__value\">G</span>"));
4127 }
4128
4129 #[test]
4132 fn test_strict_off_with_missing_key_is_silent() {
4133 let song = chordsketch_chordpro::parse("{title: T}").unwrap();
4134 let result = render_song_with_warnings(&song, 0, &Config::defaults());
4135 assert!(
4136 !result
4137 .warnings
4138 .iter()
4139 .any(|w| w.contains("settings.strict")),
4140 "default settings.strict=false must not warn on missing {{key}}; got {:?}",
4141 result.warnings
4142 );
4143 }
4144
4145 #[test]
4146 fn test_strict_on_with_missing_key_warns() {
4147 let song = chordsketch_chordpro::parse("{title: T}").unwrap();
4148 let cfg = Config::defaults()
4149 .with_define("settings.strict=true")
4150 .unwrap();
4151 let result = render_song_with_warnings(&song, 0, &cfg);
4152 assert!(
4153 result
4154 .warnings
4155 .iter()
4156 .any(|w| w.contains("{key}") && w.contains("settings.strict")),
4157 "expected missing-{{key}} warning under settings.strict; got {:?}",
4158 result.warnings
4159 );
4160 }
4161
4162 #[test]
4163 fn test_strict_on_with_present_key_is_silent() {
4164 let song = chordsketch_chordpro::parse("{title: T}\n{key: G}").unwrap();
4165 let cfg = Config::defaults()
4166 .with_define("settings.strict=true")
4167 .unwrap();
4168 let result = render_song_with_warnings(&song, 0, &cfg);
4169 assert!(
4170 !result
4171 .warnings
4172 .iter()
4173 .any(|w| w.contains("settings.strict")),
4174 "settings.strict warning must not fire when {{key}} is present; got {:?}",
4175 result.warnings
4176 );
4177 }
4178
4179 #[test]
4182 fn test_max_warnings_truncates() {
4183 let mut input = String::from("{title: T}\n");
4184 for _ in 0..(MAX_WARNINGS + 50) {
4185 input.push_str("{transpose: not-a-number}\n");
4186 }
4187 let song = chordsketch_chordpro::parse(&input).unwrap();
4188 let result = render_song_with_warnings(&song, 0, &Config::defaults());
4189 assert_eq!(
4190 result.warnings.len(),
4191 MAX_WARNINGS + 1,
4192 "expected exactly MAX_WARNINGS warnings plus one truncation marker"
4193 );
4194 assert!(
4195 result.warnings.last().unwrap().contains("MAX_WARNINGS"),
4196 "last entry must be the truncation marker; got {:?}",
4197 result.warnings.last()
4198 );
4199 }
4200
4201 #[test]
4202 fn test_is_safe_image_src() {
4203 assert!(is_safe_image_src("photo.jpg"));
4205 assert!(is_safe_image_src("images/photo.jpg"));
4206 assert!(is_safe_image_src("path/to:file.jpg")); assert!(is_safe_image_src("http://example.com/photo.jpg"));
4210 assert!(is_safe_image_src("https://example.com/photo.jpg"));
4211 assert!(is_safe_image_src("HTTP://EXAMPLE.COM/PHOTO.JPG"));
4212
4213 assert!(!is_safe_image_src(""));
4215
4216 assert!(!is_safe_image_src("javascript:alert(1)"));
4218 assert!(!is_safe_image_src("JAVASCRIPT:alert(1)"));
4219 assert!(!is_safe_image_src(" javascript:alert(1)"));
4220 assert!(!is_safe_image_src("data:image/png;base64,abc"));
4221 assert!(!is_safe_image_src("vbscript:MsgBox"));
4222
4223 assert!(!is_safe_image_src("file:///etc/passwd"));
4225 assert!(!is_safe_image_src("FILE:///etc/passwd"));
4226 assert!(!is_safe_image_src("blob:https://example.com/uuid"));
4227 assert!(!is_safe_image_src("mhtml:file://C:/page.mhtml"));
4228
4229 assert!(!is_safe_image_src("/etc/passwd"));
4231 assert!(!is_safe_image_src("/home/user/photo.jpg"));
4232
4233 assert!(!is_safe_image_src("photo\0.jpg"));
4235 assert!(!is_safe_image_src("\0"));
4236
4237 assert!(!is_safe_image_src("../photo.jpg"));
4239 assert!(!is_safe_image_src("images/../../etc/passwd"));
4240 assert!(!is_safe_image_src(r"..\photo.jpg"));
4241 assert!(!is_safe_image_src(r"images\..\..\photo.jpg"));
4242
4243 assert!(!is_safe_image_src(r"C:\photo.jpg"));
4245 assert!(!is_safe_image_src(r"D:\Users\photo.jpg"));
4246 assert!(!is_safe_image_src(r"\\server\share\photo.jpg"));
4247 assert!(!is_safe_image_src("C:/photo.jpg"));
4248 }
4249
4250 #[test]
4251 fn test_image_anchor_column_centers() {
4252 let html = render("{image: src=photo.jpg anchor=column}");
4253 assert!(
4254 html.contains("<div style=\"text-align: center;\">"),
4255 "anchor=column should produce centered div"
4256 );
4257 }
4258
4259 #[test]
4260 fn test_image_anchor_paper_centers() {
4261 let html = render("{image: src=photo.jpg anchor=paper}");
4262 assert!(
4263 html.contains("<div style=\"text-align: center;\">"),
4264 "anchor=paper should produce centered div"
4265 );
4266 }
4267
4268 #[test]
4269 fn test_image_anchor_line_no_style() {
4270 let html = render("{image: src=photo.jpg anchor=line}");
4271 assert!(html.contains("<div><img"));
4273 assert!(!html.contains("text-align"));
4274 }
4275
4276 #[test]
4277 fn test_image_no_anchor_no_style() {
4278 let html = render("{image: src=photo.jpg}");
4279 assert!(html.contains("<div><img"));
4281 assert!(!html.contains("text-align"));
4282 }
4283
4284 #[test]
4285 fn test_image_max_width_css_present() {
4286 let html = render("{image: src=photo.jpg}");
4287 assert!(
4288 html.contains("img { max-width: 100%; height: auto; }"),
4289 "CSS should include img max-width rule to prevent overflow"
4290 );
4291 }
4292
4293 #[test]
4294 fn test_chord_diagram_css_rules_present() {
4295 let html = render("{define: Am base-fret 1 frets x 0 2 2 1 0}");
4296 assert!(
4297 html.contains(".chord-diagram-container"),
4298 "CSS should include .chord-diagram-container rule"
4299 );
4300 assert!(
4301 html.contains(".chord-diagram {"),
4302 "CSS should include .chord-diagram rule"
4303 );
4304 }
4305
4306 #[test]
4309 fn test_define_renders_svg_diagram() {
4310 let html = render("{define: Am base-fret 1 frets x 0 2 2 1 0}");
4311 assert!(html.contains("<svg"));
4312 assert!(html.contains("Am"));
4313 assert!(html.contains("chord-diagram"));
4314 }
4315
4316 #[test]
4317 fn test_define_keyboard_renders_keyboard_svg() {
4318 let html = render("{define: Am keys 0 3 7}");
4320 assert!(
4321 html.contains("<svg"),
4322 "keyboard define should produce an SVG"
4323 );
4324 assert!(
4325 html.contains("keyboard-diagram"),
4326 "should use keyboard-diagram CSS class"
4327 );
4328 assert!(html.contains("Am"), "chord name should appear in SVG");
4329 }
4330
4331 #[test]
4332 fn test_define_keyboard_absolute_midi_renders_svg() {
4333 let html = render("{define: Cmaj7 keys 60 64 67 71}");
4335 assert!(html.contains("<svg"));
4336 assert!(html.contains("keyboard-diagram"));
4337 assert!(html.contains("Cmaj7"));
4338 }
4339
4340 #[test]
4341 fn test_diagrams_piano_auto_inject() {
4342 let input = "{diagrams: piano}\n[Am]Hello [C]world";
4343 let html = render(input);
4344 assert!(
4346 html.contains("keyboard-diagram"),
4347 "piano instrument should use keyboard diagrams"
4348 );
4349 assert!(
4350 html.contains("chord-diagrams"),
4351 "diagram section should be present"
4352 );
4353 }
4354
4355 #[test]
4356 fn test_define_ukulele_diagram() {
4357 let html = render("{define: C frets 0 0 0 3}");
4358 assert!(html.contains("<svg"));
4359 assert!(html.contains("chord-diagram"));
4360 assert!(
4362 html.contains("width=\"88\""),
4363 "Expected 4-string SVG width (88)"
4364 );
4365 }
4366
4367 #[test]
4368 fn test_define_banjo_diagram() {
4369 let html = render("{define: G frets 0 0 0 0 0}");
4370 assert!(html.contains("<svg"));
4371 assert!(
4373 html.contains("width=\"104\""),
4374 "Expected 5-string SVG width (104)"
4375 );
4376 }
4377
4378 #[test]
4379 fn test_diagrams_frets_config_controls_svg_height() {
4380 let input = "{define: Am base-fret 1 frets x 0 2 2 1 0}";
4381 let song = chordsketch_chordpro::parse(input).unwrap();
4382 let config = chordsketch_chordpro::config::Config::defaults()
4383 .with_define("diagrams.frets=4")
4384 .unwrap();
4385 let html = render_song_with_transpose(&song, 0, &config);
4386 assert!(
4388 html.contains("height=\"140\""),
4389 "SVG height should reflect diagrams.frets=4 (expected 140)"
4390 );
4391 }
4392
4393 #[test]
4396 fn test_diagrams_off_suppresses_chord_diagrams() {
4397 let html = render("{diagrams: off}\n{define: Am base-fret 1 frets x 0 2 2 1 0}");
4398 assert!(
4399 !html.contains("<svg"),
4400 "chord diagram SVG should be suppressed when diagrams=off"
4401 );
4402 }
4403
4404 #[test]
4405 fn test_diagrams_on_shows_chord_diagrams() {
4406 let html = render("{diagrams: on}\n{define: Am base-fret 1 frets x 0 2 2 1 0}");
4407 assert!(
4408 html.contains("<svg"),
4409 "chord diagram SVG should be shown when diagrams=on"
4410 );
4411 }
4412
4413 #[test]
4414 fn test_diagrams_default_shows_chord_diagrams() {
4415 let html = render("{define: Am base-fret 1 frets x 0 2 2 1 0}");
4416 assert!(
4417 html.contains("<svg"),
4418 "chord diagram SVG should be shown by default"
4419 );
4420 }
4421
4422 #[test]
4423 fn test_diagrams_off_then_on_restores() {
4424 let html = render(
4425 "{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}",
4426 );
4427 assert!(!html.contains(">Am<"), "Am diagram should be suppressed");
4429 assert!(html.contains(">G<"), "G diagram should be rendered");
4430 }
4431
4432 #[test]
4433 fn test_diagrams_parsed_as_known_directive() {
4434 let song = chordsketch_chordpro::parse("{diagrams: off}").unwrap();
4435 if let chordsketch_chordpro::ast::Line::Directive(d) = &song.lines[0] {
4436 assert_eq!(
4437 d.kind,
4438 chordsketch_chordpro::ast::DirectiveKind::Diagrams,
4439 "diagrams should parse as DirectiveKind::Diagrams"
4440 );
4441 assert_eq!(d.value, Some("off".to_string()));
4442 } else {
4443 panic!("expected a directive line, got: {:?}", &song.lines[0]);
4444 }
4445 }
4446
4447 #[test]
4450 fn test_diagrams_off_case_insensitive() {
4451 let html = render("{diagrams: Off}\n{define: Am base-fret 1 frets x 0 2 2 1 0}");
4452 assert!(
4453 !html.contains("<svg"),
4454 "diagrams=Off should suppress diagrams (case-insensitive)"
4455 );
4456 }
4457
4458 #[test]
4459 fn test_diagrams_off_uppercase() {
4460 let html = render("{diagrams: OFF}\n{define: Am base-fret 1 frets x 0 2 2 1 0}");
4461 assert!(
4462 !html.contains("<svg"),
4463 "diagrams=OFF should suppress diagrams (case-insensitive)"
4464 );
4465 }
4466
4467 #[test]
4470 fn test_diagrams_auto_inject_from_builtin_db() {
4471 let html = render("{diagrams}\n[Am]Hello [G]World");
4473 assert!(
4474 html.contains("class=\"chord-diagrams\""),
4475 "should render chord-diagrams section"
4476 );
4477 assert!(html.contains(">Am<"), "Am diagram expected");
4479 assert!(html.contains(">G<"), "G diagram expected");
4480 }
4481
4482 #[test]
4483 fn test_diagrams_auto_inject_unknown_chord_skipped() {
4484 let html = render("{diagrams}\n[Xyzzy]Hello");
4486 assert!(
4488 !html.contains("class=\"chord-diagrams\""),
4489 "no diagram section for unknown chord"
4490 );
4491 }
4492
4493 #[test]
4494 fn test_no_diagrams_suppresses_auto_inject() {
4495 let html = render("{no_diagrams}\n[Am]Hello");
4496 assert!(
4497 !html.contains("class=\"chord-diagrams\""),
4498 "{{no_diagrams}} should suppress auto-inject"
4499 );
4500 }
4501
4502 #[test]
4503 fn test_diagrams_define_takes_priority_over_builtin() {
4504 let html = render("{diagrams}\n{define: Am base-fret 1 frets x 0 2 2 1 0}\n[Am]Hello");
4508 assert!(
4510 html.contains("font-weight=\"bold\">Am</text>"),
4511 "Am diagram should appear inline at the {{define}} position"
4512 );
4513 assert!(
4515 !html.contains("class=\"chord-diagrams\""),
4516 "auto-inject section should be absent when all used chords are defined"
4517 );
4518 }
4519
4520 #[test]
4521 fn test_diagrams_off_suppresses_auto_inject() {
4522 let html = render("{diagrams: off}\n[Am]Hello");
4523 assert!(
4524 !html.contains("class=\"chord-diagrams\""),
4525 "{{diagrams: off}} should suppress auto-inject grid"
4526 );
4527 }
4528
4529 #[test]
4530 fn test_diagrams_ukulele_instrument() {
4531 let html = render("{diagrams: ukulele}\n[Am]Hello");
4532 assert!(
4533 html.contains("class=\"chord-diagrams\""),
4534 "ukulele diagrams section expected"
4535 );
4536 assert!(html.contains(">Am<"), "Am diagram expected");
4538 }
4539
4540 #[test]
4541 fn test_diagrams_guitar_explicit_overrides_config_default() {
4542 let song = chordsketch_chordpro::parse("{diagrams: guitar}\n[Am]Hello").unwrap();
4545 let config = chordsketch_chordpro::config::Config::defaults()
4546 .with_define("diagrams.instrument=ukulele")
4547 .unwrap();
4548 let html = render_song_with_transpose(&song, 0, &config);
4549 assert!(
4550 html.contains("class=\"chord-diagrams\""),
4551 "guitar diagrams section expected"
4552 );
4553 assert!(html.contains(">Am<"), "Am diagram expected");
4554 let guitar_am_html = render_song_with_transpose(
4555 &chordsketch_chordpro::parse("{diagrams: guitar}\n[Am]Hello").unwrap(),
4556 0,
4557 &chordsketch_chordpro::config::Config::defaults(),
4558 );
4559 let uke_am_html = render_song_with_transpose(
4560 &chordsketch_chordpro::parse("{diagrams: ukulele}\n[Am]Hello").unwrap(),
4561 0,
4562 &chordsketch_chordpro::config::Config::defaults(),
4563 );
4564 assert_ne!(
4566 guitar_am_html, uke_am_html,
4567 "guitar and ukulele Am diagrams should differ"
4568 );
4569 assert_eq!(
4572 html, guitar_am_html,
4573 "{{diagrams: guitar}} must select guitar regardless of config default"
4574 );
4575 }
4576
4577 #[test]
4578 fn test_no_diagrams_suppresses_inline_define_diagrams() {
4579 let html = render("{no_diagrams}\n{define: Am base-fret 1 frets x 0 2 2 1 0}\n[Am]Hello");
4582 assert!(
4583 !html.contains("<svg"),
4584 "{{no_diagrams}} should suppress inline define diagram SVG"
4585 );
4586 }
4587
4588 #[test]
4589 fn test_define_chord_not_duplicated_in_auto_inject_grid() {
4590 let html =
4594 render("{define: Am base-fret 1 frets x 0 2 2 1 0}\n{diagrams}\n[Am]Hello [G]world\n");
4595 let am_svg_count = html.match_indices("font-weight=\"bold\">Am</text>").count();
4597 assert_eq!(
4598 am_svg_count, 1,
4599 "Am diagram should appear exactly once (inline via {{define}}), not also in auto-inject grid"
4600 );
4601 assert!(
4603 html.contains("font-weight=\"bold\">G</text>"),
4604 "G diagram should appear in the auto-inject grid"
4605 );
4606 }
4607
4608 #[test]
4609 fn test_define_after_nodiagrams_appears_in_grid() {
4610 let html = render(
4614 "{no_diagrams}\n{define: Am base-fret 1 frets x 0 2 2 1 0}\n{diagrams}\n[Am]Hello\n",
4615 );
4616 assert!(
4619 html.contains("class=\"chord-diagrams\""),
4620 "auto-inject grid should appear since Am was not rendered inline"
4621 );
4622 assert!(
4623 html.contains("font-weight=\"bold\">Am</text>"),
4624 "Am should appear in the auto-inject grid"
4625 );
4626 }
4627
4628 #[test]
4629 fn test_enharmonic_define_dedup() {
4630 let html = render("{define: Bb base-fret 1 frets x 1 3 3 3 1}\n{diagrams}\n[A#]Hello\n");
4634 let bb_count = html.match_indices("font-weight=\"bold\">Bb</text>").count();
4636 let as_count = html.match_indices("font-weight=\"bold\">A#</text>").count();
4637 assert_eq!(bb_count, 1, "Bb should appear once (inline)");
4638 assert_eq!(
4639 as_count, 0,
4640 "A# should NOT appear in the auto-inject grid (same chord as Bb)"
4641 );
4642 }
4643
4644 #[test]
4645 fn test_chord_directive_appears_in_auto_inject_grid() {
4646 let html = render("{chord: Am base-fret 1 frets x 0 2 2 1 0}\n{diagrams}\n[Am]Hello\n");
4649 assert!(
4652 html.contains("class=\"chord-diagrams\""),
4653 "auto-inject grid should appear since {{chord}} does not render inline"
4654 );
4655 assert!(
4656 html.contains("font-weight=\"bold\">Am</text>"),
4657 "Am should appear in the auto-inject grid via {{chord}} voicing"
4658 );
4659 }
4660
4661 #[test]
4664 fn test_abc_section_disabled_by_config() {
4665 let input = "{start_of_abc}\nX:1\n{end_of_abc}";
4667 let song = chordsketch_chordpro::parse(input).unwrap();
4668 let config = chordsketch_chordpro::config::Config::defaults()
4669 .with_define("delegates.abc2svg=false")
4670 .unwrap();
4671 let html = render_song_with_transpose(&song, 0, &config);
4672 assert!(html.contains("<section class=\"abc\">"));
4673 assert!(html.contains("ABC"));
4674 assert!(html.contains("</section>"));
4675 }
4676
4677 #[test]
4678 fn test_abc_section_null_config_auto_detect_disabled() {
4679 if chordsketch_chordpro::external_tool::has_abc2svg() {
4682 return; }
4684 let input = "{start_of_abc}\nX:1\n{end_of_abc}";
4685 let song = chordsketch_chordpro::parse(input).unwrap();
4686 let config = chordsketch_chordpro::config::Config::defaults();
4688 assert!(
4689 config.get_path("delegates.abc2svg").is_null(),
4690 "default config should have null delegates.abc2svg"
4691 );
4692 let html = render_song_with_transpose(&song, 0, &config);
4693 assert!(
4694 html.contains("<section class=\"abc\">"),
4695 "null auto-detect with no abc2svg should render as text section"
4696 );
4697 }
4698
4699 #[test]
4700 fn test_abc_section_fallback_preformatted() {
4701 if chordsketch_chordpro::external_tool::has_abc2svg() {
4703 return; }
4705 let input = "{start_of_abc}\nX:1\nT:Test\nK:C\n{end_of_abc}";
4706 let song = chordsketch_chordpro::parse(input).unwrap();
4707 let config = chordsketch_chordpro::config::Config::defaults()
4708 .with_define("delegates.abc2svg=true")
4709 .unwrap();
4710 let html = render_song_with_transpose(&song, 0, &config);
4711 assert!(html.contains("<section class=\"abc\">"));
4712 assert!(html.contains("<pre>"));
4713 assert!(html.contains("X:1"));
4714 assert!(html.contains("</pre>"));
4715 }
4716
4717 #[test]
4718 fn test_abc_section_with_label_delegate_fallback() {
4719 if chordsketch_chordpro::external_tool::has_abc2svg() {
4720 return;
4721 }
4722 let input = "{start_of_abc: Melody}\nX:1\n{end_of_abc}";
4723 let song = chordsketch_chordpro::parse(input).unwrap();
4724 let config = chordsketch_chordpro::config::Config::defaults()
4725 .with_define("delegates.abc2svg=true")
4726 .unwrap();
4727 let html = render_song_with_transpose(&song, 0, &config);
4728 assert!(html.contains("ABC: Melody"));
4729 assert!(html.contains("<pre>"));
4730 }
4731
4732 #[test]
4733 #[ignore]
4734 fn test_abc_section_renders_svg_with_abc2svg() {
4735 let input = "{start_of_abc}\nX:1\nT:Test\nM:4/4\nK:C\nCDEF|GABc|\n{end_of_abc}";
4737 let song = chordsketch_chordpro::parse(input).unwrap();
4738 let config = chordsketch_chordpro::config::Config::defaults()
4739 .with_define("delegates.abc2svg=true")
4740 .unwrap();
4741 let html = render_song_with_transpose(&song, 0, &config);
4742 assert!(html.contains("<section class=\"abc\">"));
4743 assert!(
4744 html.contains("<svg"),
4745 "should contain rendered SVG from abc2svg"
4746 );
4747 assert!(html.contains("</section>"));
4748 }
4749
4750 #[test]
4751 fn test_abc_section_auto_detect_default_config() {
4752 let input = "{start_of_abc}\nX:1\nT:Test\nK:C\n{end_of_abc}";
4756 let song = chordsketch_chordpro::parse(input).unwrap();
4757 let config = chordsketch_chordpro::config::Config::defaults();
4758 let html = render_song_with_transpose(&song, 0, &config);
4759 assert!(
4760 html.contains("<section class=\"abc\">"),
4761 "auto-detect should produce abc section"
4762 );
4763 if !chordsketch_chordpro::external_tool::has_abc2svg() {
4764 assert!(
4765 html.contains("X:1"),
4766 "raw ABC content should be present without tool"
4767 );
4768 assert!(
4769 !html.contains("<svg"),
4770 "no SVG should be generated without abc2svg"
4771 );
4772 }
4773 }
4774
4775 #[test]
4778 fn test_ly_section_auto_detect_default_config() {
4779 let input = "{start_of_ly}\n\\relative c' { c4 }\n{end_of_ly}";
4781 let song = chordsketch_chordpro::parse(input).unwrap();
4782 let config = chordsketch_chordpro::config::Config::defaults();
4783 let html = render_song_with_transpose(&song, 0, &config);
4784 assert!(
4785 html.contains("<section class=\"ly\">"),
4786 "auto-detect should produce ly section"
4787 );
4788 if !chordsketch_chordpro::external_tool::has_lilypond() {
4789 assert!(
4790 html.contains("\\relative"),
4791 "raw Lilypond content should be present without tool"
4792 );
4793 assert!(
4794 !html.contains("<svg"),
4795 "no SVG should be generated without lilypond"
4796 );
4797 }
4798 }
4799
4800 #[test]
4801 fn test_ly_section_disabled_by_config() {
4802 let input = "{start_of_ly}\n\\relative c' { c4 }\n{end_of_ly}";
4804 let song = chordsketch_chordpro::parse(input).unwrap();
4805 let config = chordsketch_chordpro::config::Config::defaults()
4806 .with_define("delegates.lilypond=false")
4807 .unwrap();
4808 let html = render_song_with_transpose(&song, 0, &config);
4809 assert!(html.contains("<section class=\"ly\">"));
4810 assert!(html.contains("Lilypond"));
4811 assert!(html.contains("</section>"));
4812 }
4813
4814 #[test]
4815 fn test_ly_section_fallback_preformatted() {
4816 if chordsketch_chordpro::external_tool::has_lilypond() {
4817 return;
4818 }
4819 let input = "{start_of_ly}\n\\relative c' { c4 }\n{end_of_ly}";
4820 let song = chordsketch_chordpro::parse(input).unwrap();
4821 let config = chordsketch_chordpro::config::Config::defaults()
4822 .with_define("delegates.lilypond=true")
4823 .unwrap();
4824 let html = render_song_with_transpose(&song, 0, &config);
4825 assert!(html.contains("<section class=\"ly\">"));
4826 assert!(html.contains("<pre>"));
4827 assert!(html.contains("</pre>"));
4828 }
4829
4830 #[test]
4831 #[ignore]
4832 fn test_ly_section_renders_svg_with_lilypond() {
4833 let input = "{start_of_ly}\n\\relative c' { c4 d e f | g2 g | }\n{end_of_ly}";
4835 let song = chordsketch_chordpro::parse(input).unwrap();
4836 let config = chordsketch_chordpro::config::Config::defaults()
4837 .with_define("delegates.lilypond=true")
4838 .unwrap();
4839 let html = render_song_with_transpose(&song, 0, &config);
4840 assert!(html.contains("<section class=\"ly\">"));
4841 assert!(
4842 html.contains("<svg"),
4843 "should contain rendered SVG from lilypond"
4844 );
4845 assert!(html.contains("</section>"));
4846 }
4847}
4848
4849#[cfg(test)]
4850mod delegate_tests {
4851 use super::*;
4852
4853 #[test]
4854 fn test_render_abc_section() {
4855 let html = render("{start_of_abc}\nX:1\n{end_of_abc}");
4856 assert!(html.contains("<section class=\"abc\">"));
4857 assert!(html.contains("ABC"));
4858 assert!(html.contains("</section>"));
4859 }
4860
4861 #[test]
4862 fn test_render_abc_section_with_label() {
4863 let html = render("{start_of_abc: Melody}\nX:1\n{end_of_abc}");
4864 assert!(html.contains("<section class=\"abc\">"));
4865 assert!(html.contains("ABC: Melody"));
4866 }
4867
4868 #[test]
4869 fn test_render_ly_section() {
4870 let html = render("{start_of_ly}\nnotes\n{end_of_ly}");
4871 assert!(html.contains("<section class=\"ly\">"));
4872 assert!(html.contains("Lilypond"));
4873 assert!(html.contains("</section>"));
4874 }
4875
4876 #[test]
4879 fn test_render_musicxml_section_disabled() {
4880 let input = "{start_of_musicxml}\n<score-partwise/>\n{end_of_musicxml}";
4882 let song = chordsketch_chordpro::parse(input).unwrap();
4883 let config = chordsketch_chordpro::config::Config::defaults()
4884 .with_define("delegates.musescore=false")
4885 .unwrap();
4886 let html = render_song_with_transpose(&song, 0, &config);
4887 assert!(
4888 html.contains("<section class=\"musicxml\">"),
4889 "fallback section should render when musescore is disabled: {html}"
4890 );
4891 assert!(html.contains("MusicXML"), "section label should appear");
4892 assert!(html.contains("</section>"), "section should be closed");
4893 }
4894
4895 #[test]
4896 fn test_render_musicxml_section_no_musescore_installed() {
4897 if chordsketch_chordpro::external_tool::has_musescore() {
4900 return; }
4902
4903 let input = "{start_of_musicxml}\n<score-partwise/>\n{end_of_musicxml}";
4904 let song = chordsketch_chordpro::parse(input).unwrap();
4905 let config = chordsketch_chordpro::config::Config::defaults();
4906 assert!(
4907 config.get_path("delegates.musescore").is_null(),
4908 "default config should have null delegates.musescore"
4909 );
4910 let html = render_song_with_transpose(&song, 0, &config);
4911 assert!(
4912 html.contains("<section class=\"musicxml\">"),
4913 "null auto-detect with no musescore should render as text section"
4914 );
4915 }
4916
4917 #[test]
4918 fn test_render_musicxml_section_with_label() {
4919 let input = "{start_of_musicxml: Score}\n<score-partwise/>\n{end_of_musicxml}";
4920 let song = chordsketch_chordpro::parse(input).unwrap();
4921 let config = chordsketch_chordpro::config::Config::defaults()
4922 .with_define("delegates.musescore=false")
4923 .unwrap();
4924 let html = render_song_with_transpose(&song, 0, &config);
4925 assert!(
4926 html.contains("Score"),
4927 "label should appear in section header"
4928 );
4929 }
4930
4931 #[test]
4932 fn test_abc_fallback_sanitizes_would_be_script_in_svg() {
4933 let malicious_svg = "<svg><script>alert(1)</script><circle r=\"5\"/></svg>";
4937 let sanitized = sanitize_svg_content(malicious_svg);
4938 assert!(
4939 !sanitized.contains("<script>"),
4940 "script tags must be stripped from delegate SVG output"
4941 );
4942 assert!(sanitized.contains("<circle"));
4943 }
4944
4945 #[test]
4946 fn test_sanitize_svg_strips_event_handlers_from_delegate_output() {
4947 let svg_with_handler = "<svg><rect onmouseover=\"alert(1)\" width=\"10\"/></svg>";
4948 let sanitized = sanitize_svg_content(svg_with_handler);
4949 assert!(
4950 !sanitized.contains("onmouseover"),
4951 "event handlers must be stripped from delegate SVG output"
4952 );
4953 assert!(sanitized.contains("<rect"));
4954 }
4955
4956 #[test]
4957 fn test_sanitize_svg_strips_foreignobject_from_delegate_output() {
4958 let svg = "<svg><foreignObject><body xmlns=\"http://www.w3.org/1999/xhtml\"><script>alert(1)</script></body></foreignObject></svg>";
4959 let sanitized = sanitize_svg_content(svg);
4960 assert!(
4961 !sanitized.contains("<foreignObject"),
4962 "foreignObject must be stripped from delegate SVG output"
4963 );
4964 }
4965
4966 #[test]
4967 fn test_sanitize_svg_strips_math_element() {
4968 let svg = "<svg><math><mi>x</mi></math></svg>";
4969 let sanitized = sanitize_svg_content(svg);
4970 assert!(
4971 !sanitized.contains("<math"),
4972 "math element must be stripped from delegate SVG output"
4973 );
4974 }
4975
4976 #[test]
4979 fn test_sanitize_svg_strips_namespaced_script() {
4980 let svg = "<svg:script>alert(1)</svg:script><circle r=\"5\"/>";
4985 let sanitized = sanitize_svg_content(svg);
4986 assert!(
4987 !sanitized.to_ascii_lowercase().contains("script"),
4988 "namespaced <svg:script> must be stripped, got: {sanitized}"
4989 );
4990 assert!(sanitized.contains("<circle"));
4991 }
4992
4993 #[test]
4994 fn test_sanitize_svg_strips_namespaced_iframe_case_insensitive() {
4995 let svg = "<XHTML:Iframe src=\"javascript:alert(1)\"></XHTML:Iframe>text";
4996 let sanitized = sanitize_svg_content(svg);
4997 assert!(
4998 !sanitized.to_ascii_lowercase().contains("iframe"),
4999 "namespaced iframe must be stripped, got: {sanitized}"
5000 );
5001 assert!(sanitized.contains("text"));
5002 }
5003
5004 #[test]
5005 fn test_sanitize_svg_strips_namespaced_foreignobject() {
5006 let svg = "<svg:foreignObject><body><script>x()</script></body></svg:foreignObject>safe";
5007 let sanitized = sanitize_svg_content(svg);
5008 assert!(
5009 !sanitized.to_ascii_lowercase().contains("foreignobject"),
5010 "namespaced foreignObject must be stripped, got: {sanitized}"
5011 );
5012 assert!(!sanitized.to_ascii_lowercase().contains("script"));
5013 assert!(sanitized.contains("safe"));
5014 }
5015
5016 #[test]
5017 fn test_sanitize_svg_strips_stray_namespaced_closing_tag() {
5018 let svg = "lyrics</svg:script>more";
5021 let sanitized = sanitize_svg_content(svg);
5022 assert!(
5023 !sanitized.to_ascii_lowercase().contains("script"),
5024 "stray namespaced closing tag must be stripped, got: {sanitized}"
5025 );
5026 }
5027
5028 #[test]
5029 fn test_render_svg_section() {
5030 let html = render("{start_of_svg}\n<svg/>\n{end_of_svg}");
5031 assert!(html.contains("<div class=\"svg-section\">"));
5033 assert!(html.contains("<svg/>"));
5034 assert!(html.contains("</div>"));
5035 }
5036
5037 #[test]
5038 fn test_render_svg_inline_content() {
5039 let svg = r#"<svg width="100" height="100"><circle cx="50" cy="50" r="40"/></svg>"#;
5040 let input = format!("{{start_of_svg}}\n{svg}\n{{end_of_svg}}");
5041 let html = render(&input);
5042 assert!(html.contains(svg));
5043 }
5044
5045 #[test]
5046 fn test_svg_section_strips_script_tags() {
5047 let input = "{start_of_svg}\n<svg><script>alert('xss')</script><circle r=\"10\"/></svg>\n{end_of_svg}";
5048 let html = render(input);
5049 assert!(!html.contains("<script>"), "script tags must be stripped");
5050 assert!(!html.contains("alert"), "script content must be stripped");
5051 assert!(
5052 html.contains("<circle r=\"10\"/>"),
5053 "safe SVG content must be preserved"
5054 );
5055 }
5056
5057 #[test]
5058 fn test_svg_section_strips_event_handlers() {
5059 let input = "{start_of_svg}\n<svg onload=\"alert(1)\"><rect width=\"10\" onerror=\"hack()\"/></svg>\n{end_of_svg}";
5060 let html = render(input);
5061 assert!(!html.contains("onload"), "onload handler must be stripped");
5062 assert!(
5063 !html.contains("onerror"),
5064 "onerror handler must be stripped"
5065 );
5066 assert!(
5067 html.contains("width=\"10\""),
5068 "safe attributes must be preserved"
5069 );
5070 }
5071
5072 #[test]
5073 fn test_svg_section_preserves_safe_content() {
5074 let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><text x="10" y="20">Hello</text></svg>"#;
5075 let input = format!("{{start_of_svg}}\n{svg}\n{{end_of_svg}}");
5076 let html = render(&input);
5077 assert!(html.contains("xmlns=\"http://www.w3.org/2000/svg\""));
5078 assert!(html.contains("<text x=\"10\" y=\"20\">Hello</text>"));
5079 }
5080
5081 #[test]
5082 fn test_svg_section_strips_case_insensitive_script() {
5083 let input = "{start_of_svg}\n<SCRIPT>alert(1)</SCRIPT><svg/>\n{end_of_svg}";
5084 let html = render(input);
5085 assert!(!html.contains("SCRIPT"), "case-insensitive script removal");
5086 assert!(!html.contains("alert"));
5087 assert!(html.contains("<svg/>"));
5088 }
5089
5090 #[test]
5091 fn test_svg_section_strips_foreignobject() {
5092 let input = "{start_of_svg}\n<svg><foreignObject><body onload=\"alert(1)\"></body></foreignObject><rect width=\"10\"/></svg>\n{end_of_svg}";
5093 let html = render(input);
5094 assert!(
5095 !html.contains("foreignObject"),
5096 "foreignObject must be stripped"
5097 );
5098 assert!(
5099 !html.contains("foreignobject"),
5100 "foreignObject (lowercase) must be stripped"
5101 );
5102 assert!(
5103 html.contains("<rect width=\"10\"/>"),
5104 "safe content must be preserved"
5105 );
5106 }
5107
5108 #[test]
5109 fn test_svg_section_strips_iframe() {
5110 let input = "{start_of_svg}\n<svg><iframe src=\"javascript:alert(1)\"></iframe><circle r=\"5\"/></svg>\n{end_of_svg}";
5111 let html = render(input);
5112 assert!(!html.contains("iframe"), "iframe must be stripped");
5113 assert!(html.contains("<circle r=\"5\"/>"));
5114 }
5115
5116 #[test]
5117 fn test_svg_section_strips_object_and_embed() {
5118 let input = "{start_of_svg}\n<svg><object data=\"evil.swf\"></object><embed src=\"evil.swf\"></embed><rect/></svg>\n{end_of_svg}";
5119 let html = render(input);
5120 assert!(!html.contains("object"), "object must be stripped");
5121 assert!(!html.contains("embed"), "embed must be stripped");
5122 assert!(html.contains("<rect/>"));
5123 }
5124
5125 #[test]
5126 fn test_svg_section_strips_javascript_uri_in_href() {
5127 let input = "{start_of_svg}\n<svg><a href=\"javascript:alert(1)\"><text>Click</text></a></svg>\n{end_of_svg}";
5128 let html = render(input);
5129 assert!(
5130 !html.contains("javascript:"),
5131 "javascript: URI must be stripped from href"
5132 );
5133 assert!(html.contains("<text>Click</text>"));
5134 }
5135
5136 #[test]
5137 fn test_svg_section_strips_vbscript_uri() {
5138 let input = "{start_of_svg}\n<svg><a href=\"vbscript:MsgBox\"><text>Click</text></a></svg>\n{end_of_svg}";
5139 let html = render(input);
5140 assert!(
5141 !html.contains("vbscript:"),
5142 "vbscript: URI must be stripped"
5143 );
5144 }
5145
5146 #[test]
5147 fn test_svg_section_strips_data_uri_in_use() {
5148 let input = "{start_of_svg}\n<svg><use href=\"data:image/svg+xml;base64,PHN2Zy8+\"/></svg>\n{end_of_svg}";
5149 let html = render(input);
5150 assert!(
5151 !html.contains("data:"),
5152 "data: URI must be stripped from use href"
5153 );
5154 }
5155
5156 #[test]
5157 fn test_svg_section_strips_javascript_uri_case_insensitive() {
5158 let input = "{start_of_svg}\n<svg><a href=\"JaVaScRiPt:alert(1)\"><text>X</text></a></svg>\n{end_of_svg}";
5159 let html = render(input);
5160 assert!(
5161 !html.to_lowercase().contains("javascript:"),
5162 "case-insensitive javascript: URI must be stripped"
5163 );
5164 }
5165
5166 #[test]
5167 fn test_svg_section_strips_xlink_href_dangerous_uri() {
5168 let input =
5169 "{start_of_svg}\n<svg><use xlink:href=\"javascript:alert(1)\"/></svg>\n{end_of_svg}";
5170 let html = render(input);
5171 assert!(
5172 !html.contains("javascript:"),
5173 "javascript: URI in xlink:href must be stripped"
5174 );
5175 }
5176
5177 #[test]
5178 fn test_svg_section_preserves_safe_href() {
5179 let input = "{start_of_svg}\n<svg><a href=\"https://example.com\"><text>Link</text></a></svg>\n{end_of_svg}";
5180 let html = render(input);
5181 assert!(
5182 html.contains("href=\"https://example.com\""),
5183 "safe https: href must be preserved"
5184 );
5185 }
5186
5187 #[test]
5188 fn test_svg_section_preserves_fragment_href() {
5189 let input = "{start_of_svg}\n<svg><use href=\"#myShape\"/></svg>\n{end_of_svg}";
5190 let html = render(input);
5191 assert!(
5192 html.contains("href=\"#myShape\""),
5193 "fragment-only href must be preserved"
5194 );
5195 }
5196
5197 #[test]
5198 fn test_svg_section_strips_use_external_https() {
5199 let input = "{start_of_svg}\n<svg><use href=\"https://attacker.example.com/x.svg#sym\"/></svg>\n{end_of_svg}";
5203 let html = render(input);
5204 assert!(
5205 !html.contains("attacker.example.com"),
5206 "external https: URI in <use href> must be stripped; got: {html}"
5207 );
5208 }
5209
5210 #[test]
5211 fn test_svg_section_strips_use_external_xlink_href() {
5212 let input = "{start_of_svg}\n<svg><use xlink:href=\"https://tracker.example/pixel.svg\"/></svg>\n{end_of_svg}";
5214 let html = render(input);
5215 assert!(
5216 !html.contains("tracker.example"),
5217 "external https: URI in <use xlink:href> must be stripped; got: {html}"
5218 );
5219 }
5220
5221 #[test]
5222 fn test_svg_section_preserves_fragment_xlink_href() {
5223 let input = "{start_of_svg}\n<svg><use xlink:href=\"#mySymbol\"/></svg>\n{end_of_svg}";
5224 let html = render(input);
5225 assert!(
5226 html.contains("xlink:href=\"#mySymbol\""),
5227 "fragment-only xlink:href must be preserved"
5228 );
5229 }
5230
5231 #[test]
5232 fn test_render_textblock_section() {
5233 let html = render("{start_of_textblock}\nPreformatted\n{end_of_textblock}");
5234 assert!(html.contains("<section class=\"textblock\">"));
5235 assert!(html.contains("Textblock"));
5236 assert!(html.contains("</section>"));
5237 }
5238
5239 #[test]
5242 fn test_render_songs_single() {
5243 let songs = chordsketch_chordpro::parse_multi("{title: Only}").unwrap();
5244 let html = render_songs(&songs);
5245 assert_eq!(html, render_song(&songs[0]));
5247 }
5248
5249 #[test]
5250 fn test_render_songs_two_songs_with_hr_separator() {
5251 let songs = chordsketch_chordpro::parse_multi(
5252 "{title: Song A}\n[Am]Hello\n{new_song}\n{title: Song B}\n[G]World",
5253 )
5254 .unwrap();
5255 let html = render_songs(&songs);
5256 assert!(html.contains("<title>Song A</title>"));
5258 assert!(html.contains("<h1>Song A</h1>"));
5260 assert!(html.contains("<h1>Song B</h1>"));
5261 assert!(html.contains("<hr class=\"song-separator\">"));
5263 assert_eq!(html.matches("<article class=\"song\">").count(), 2);
5265 assert_eq!(html.matches("<!DOCTYPE html>").count(), 1);
5267 assert_eq!(html.matches("</html>").count(), 1);
5268 }
5269
5270 #[test]
5271 fn test_image_scale_css_injection_prevented() {
5272 let html = render("{image: src=photo.jpg scale=0.5); position: fixed; z-index: 9999}");
5275 let img_style = extract_img_style(&html);
5281 assert!(!img_style.contains("position"), "got style: {img_style}");
5282 assert!(!img_style.contains("z-index"), "got style: {img_style}");
5283 assert!(
5284 !img_style.contains("position: fixed"),
5285 "got style: {img_style}"
5286 );
5287 }
5288
5289 fn extract_img_style(html: &str) -> String {
5294 let needle = "<img ";
5295 let img_start = html
5296 .find(needle)
5297 .expect("rendered html should contain an <img>");
5298 let after_img = &html[img_start..];
5299 let img_end = after_img.find('>').expect("<img> must close");
5300 let img_tag = &after_img[..=img_end];
5301 let style_marker = "style=\"";
5302 match img_tag.find(style_marker) {
5303 None => String::new(),
5304 Some(s) => {
5305 let after_open = &img_tag[s + style_marker.len()..];
5306 let end = after_open
5307 .find('"')
5308 .expect("style attribute must have a closing quote");
5309 after_open[..end].to_string()
5310 }
5311 }
5312 }
5313
5314 #[test]
5315 fn test_render_songs_with_transpose() {
5316 let songs =
5317 chordsketch_chordpro::parse_multi("{title: S1}\n[C]Do\n{new_song}\n{title: S2}\n[G]Re")
5318 .unwrap();
5319 let html = render_songs_with_transpose(&songs, 2, &Config::defaults());
5320 assert!(html.contains(">D<"));
5322 assert!(html.contains(">A<"));
5323 }
5324
5325 #[test]
5328 fn test_sanitize_svg_strips_set_element() {
5329 let svg = r##"<svg><a href="#"><set attributeName="href" to="javascript:alert(1)"/><text>Click</text></a></svg>"##;
5330 let sanitized = sanitize_svg_content(svg);
5331 assert!(
5332 !sanitized.contains("<set"),
5333 "set element must be stripped to prevent SVG animation XSS"
5334 );
5335 assert!(sanitized.contains("<text>Click</text>"));
5336 }
5337
5338 #[test]
5339 fn test_sanitize_svg_strips_animate_element() {
5340 let svg =
5341 r#"<svg><animate attributeName="href" values="javascript:alert(1)"/><rect/></svg>"#;
5342 let sanitized = sanitize_svg_content(svg);
5343 assert!(
5344 !sanitized.contains("<animate"),
5345 "animate element must be stripped"
5346 );
5347 assert!(sanitized.contains("<rect/>"));
5348 }
5349
5350 #[test]
5351 fn test_sanitize_svg_strips_animatetransform() {
5352 let svg =
5353 "<svg><animateTransform attributeName=\"transform\" type=\"rotate\"/><rect/></svg>";
5354 let sanitized = sanitize_svg_content(svg);
5355 assert!(
5356 !sanitized.contains("animateTransform"),
5357 "animateTransform must be stripped"
5358 );
5359 assert!(
5360 !sanitized.contains("animatetransform"),
5361 "animatetransform (lowercase) must be stripped"
5362 );
5363 }
5364
5365 #[test]
5366 fn test_sanitize_svg_strips_animatemotion() {
5367 let svg = "<svg><animateMotion path=\"M0,0 L100,100\"/><rect/></svg>";
5368 let sanitized = sanitize_svg_content(svg);
5369 assert!(
5370 !sanitized.contains("animateMotion"),
5371 "animateMotion must be stripped"
5372 );
5373 }
5374
5375 #[test]
5376 fn test_sanitize_svg_strips_to_attr_with_dangerous_uri() {
5377 let svg = r#"<svg><a to="javascript:alert(1)"><text>X</text></a></svg>"#;
5378 let sanitized = sanitize_svg_content(svg);
5379 assert!(
5380 !sanitized.contains("javascript:"),
5381 "dangerous URI in 'to' attr must be stripped"
5382 );
5383 }
5384
5385 #[test]
5386 fn test_sanitize_svg_strips_values_attr_with_dangerous_uri() {
5387 let svg = r#"<svg><a values="javascript:alert(1)"><text>X</text></a></svg>"#;
5388 let sanitized = sanitize_svg_content(svg);
5389 assert!(
5390 !sanitized.contains("javascript:"),
5391 "dangerous URI in 'values' attr must be stripped"
5392 );
5393 }
5394
5395 #[test]
5398 fn test_strip_dangerous_attrs_preserves_cjk_text() {
5399 let input = "<svg><text x=\"10\">日本語テスト</text></svg>";
5400 let result = strip_dangerous_attrs(input);
5401 assert!(
5402 result.contains("日本語テスト"),
5403 "CJK characters must not be corrupted"
5404 );
5405 }
5406
5407 #[test]
5408 fn test_strip_dangerous_attrs_preserves_emoji() {
5409 let input = "<svg><text>🎵🎸🎹</text></svg>";
5410 let result = strip_dangerous_attrs(input);
5411 assert!(result.contains("🎵🎸🎹"), "emoji must not be corrupted");
5412 }
5413
5414 #[test]
5415 fn test_strip_dangerous_attrs_preserves_accented_chars() {
5416 let input = "<svg><text>café résumé naïve</text></svg>";
5417 let result = strip_dangerous_attrs(input);
5418 assert!(
5419 result.contains("café résumé naïve"),
5420 "accented characters must not be corrupted"
5421 );
5422 }
5423
5424 #[test]
5425 fn test_sanitize_svg_full_roundtrip_with_non_ascii() {
5426 let input = "<svg><text x=\"10\">コード譜 🎵</text><rect width=\"100\"/></svg>";
5427 let sanitized = sanitize_svg_content(input);
5428 assert!(sanitized.contains("コード譜 🎵"));
5429 assert!(sanitized.contains("<rect width=\"100\"/>"));
5430 }
5431
5432 #[test]
5433 fn test_sanitize_svg_self_closing_with_gt_in_attr_value() {
5434 let svg = r#"<svg><set to="a>b"/><text>safe</text></svg>"#;
5436 let sanitized = sanitize_svg_content(svg);
5437 assert!(
5438 !sanitized.contains("<set"),
5439 "dangerous <set> element must be stripped"
5440 );
5441 assert!(
5442 sanitized.contains("<text>safe</text>"),
5443 "content after stripped self-closing element must be preserved"
5444 );
5445 }
5446
5447 #[test]
5450 fn test_strip_dangerous_attrs_gt_in_double_quoted_attr() {
5451 let input = r#"<rect title=">" onload="alert(1)"/>"#;
5453 let result = strip_dangerous_attrs(input);
5454 assert!(
5455 !result.contains("onload"),
5456 "onload after quoted > must be stripped"
5457 );
5458 assert!(result.contains("title"));
5459 }
5460
5461 #[test]
5462 fn test_strip_dangerous_attrs_gt_in_single_quoted_attr() {
5463 let input = "<rect title='>' onload=\"alert(1)\"/>";
5464 let result = strip_dangerous_attrs(input);
5465 assert!(
5466 !result.contains("onload"),
5467 "onload after single-quoted > must be stripped"
5468 );
5469 }
5470
5471 #[test]
5474 fn test_dangerous_uri_scheme_with_embedded_tab() {
5475 assert!(has_dangerous_uri_scheme("java\tscript:alert(1)"));
5476 }
5477
5478 #[test]
5479 fn test_dangerous_uri_scheme_with_embedded_newline() {
5480 assert!(has_dangerous_uri_scheme("java\nscript:alert(1)"));
5481 }
5482
5483 #[test]
5484 fn test_dangerous_uri_scheme_with_control_chars() {
5485 assert!(has_dangerous_uri_scheme("java\x00script:alert(1)"));
5486 }
5487
5488 #[test]
5489 fn test_safe_uri_not_flagged() {
5490 assert!(!has_dangerous_uri_scheme("https://example.com"));
5491 }
5492
5493 #[test]
5494 fn test_dangerous_uri_scheme_with_many_embedded_whitespace() {
5495 let payload = "j\ta\tv\ta\ts\tc\tr\ti\tp\tt\t:\ta\tl\te\tr\tt\t(\t1\t)\t";
5498 assert!(
5499 has_dangerous_uri_scheme(payload),
5500 "1 tab between letters should not bypass javascript: detection"
5501 );
5502 }
5503
5504 #[test]
5505 fn test_dangerous_uri_scheme_whitespace_bypass_regression() {
5506 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:";
5511 assert!(
5512 has_dangerous_uri_scheme(payload),
5513 "3 tabs between letters (colon at raw position 40) must still be detected"
5514 );
5515 }
5516
5517 #[test]
5520 fn test_dangerous_uri_scheme_with_zero_width_space() {
5521 assert!(
5522 has_dangerous_uri_scheme("java\u{200B}script:alert(1)"),
5523 "ZWSP embedded in javascript: scheme must still be blocked"
5524 );
5525 }
5526
5527 #[test]
5528 fn test_dangerous_uri_scheme_with_zero_width_joiner() {
5529 assert!(
5530 has_dangerous_uri_scheme("vb\u{200D}script:alert(1)"),
5531 "ZWJ embedded in vbscript: scheme must still be blocked"
5532 );
5533 }
5534
5535 #[test]
5536 fn test_dangerous_uri_scheme_with_byte_order_mark() {
5537 assert!(
5538 has_dangerous_uri_scheme("java\u{FEFF}script:alert(1)"),
5539 "BOM/ZWNBSP embedded in javascript: scheme must still be blocked"
5540 );
5541 }
5542
5543 #[test]
5544 fn test_dangerous_uri_scheme_with_soft_hyphen() {
5545 assert!(
5546 has_dangerous_uri_scheme("data\u{00AD}:text/html,xss"),
5547 "soft hyphen embedded in data: scheme must still be blocked"
5548 );
5549 }
5550
5551 #[test]
5552 fn test_dangerous_uri_scheme_with_bidi_override() {
5553 assert!(
5554 has_dangerous_uri_scheme("\u{202E}javascript:alert(1)"),
5555 "leading bidi override must not hide the scheme"
5556 );
5557 assert!(
5558 has_dangerous_uri_scheme("java\u{202A}script:alert(1)"),
5559 "embedded bidi override must not hide the scheme"
5560 );
5561 }
5562
5563 #[test]
5564 fn test_dangerous_uri_scheme_safe_after_unicode_filter() {
5565 assert!(!has_dangerous_uri_scheme("https://example.com/a\u{200B}b"));
5568 }
5569
5570 #[test]
5571 fn test_dangerous_uri_scheme_with_lrm() {
5572 assert!(
5576 has_dangerous_uri_scheme("java\u{200E}script:alert(1)"),
5577 "LRM embedded in javascript: scheme must still be blocked"
5578 );
5579 }
5580
5581 #[test]
5582 fn test_dangerous_uri_scheme_with_rlm() {
5583 assert!(
5585 has_dangerous_uri_scheme("vb\u{200F}script:alert(1)"),
5586 "RLM embedded in vbscript: scheme must still be blocked"
5587 );
5588 }
5589
5590 #[test]
5593 fn test_sanitize_svg_strips_namespaced_script_with_dot_in_prefix() {
5594 let svg = "<foo.bar:script>alert(1)</foo.bar:script>text";
5599 let sanitized = sanitize_svg_content(svg);
5600 assert!(
5601 !sanitized.to_ascii_lowercase().contains("script"),
5602 "`foo.bar:script` must be stripped, got: {sanitized}"
5603 );
5604 assert!(sanitized.contains("text"));
5605 }
5606
5607 #[test]
5610 fn test_svg_section_blocks_multiline_script_tag_splitting() {
5611 let input = "{start_of_svg}\n<script\n>alert(1)</script>\n{end_of_svg}";
5613 let html = render(input);
5614 assert!(
5615 !html.contains("alert(1)"),
5616 "multi-line <script> tag splitting must not execute JS"
5617 );
5618 assert!(
5619 !html.to_lowercase().contains("<script"),
5620 "multi-line <script> tag must be stripped"
5621 );
5622 }
5623
5624 #[test]
5625 fn test_svg_section_blocks_multiline_iframe_tag_splitting() {
5626 let input =
5627 "{start_of_svg}\n<iframe\nsrc=\"javascript:alert(1)\">\n</iframe>\n{end_of_svg}";
5628 let html = render(input);
5629 assert!(
5630 !html.to_lowercase().contains("<iframe"),
5631 "multi-line <iframe> tag splitting must be stripped"
5632 );
5633 assert!(
5634 !html.contains("javascript:"),
5635 "javascript: URI in split iframe must be stripped"
5636 );
5637 }
5638
5639 #[test]
5640 fn test_svg_section_blocks_multiline_foreignobject_splitting() {
5641 let input = "{start_of_svg}\n<foreignObject\n><script>alert(1)</script></foreignObject>\n{end_of_svg}";
5642 let html = render(input);
5643 assert!(
5644 !html.to_lowercase().contains("<foreignobject"),
5645 "multi-line <foreignObject> splitting must be stripped"
5646 );
5647 }
5648
5649 #[test]
5652 fn test_dangerous_uri_file_scheme_blocked() {
5653 assert!(
5655 has_dangerous_uri_scheme("file:///etc/passwd"),
5656 "file: URI scheme must be detected as dangerous"
5657 );
5658 assert!(
5659 has_dangerous_uri_scheme("FILE:///etc/passwd"),
5660 "FILE: (uppercase) must be detected as dangerous"
5661 );
5662 }
5663
5664 #[test]
5665 fn test_dangerous_uri_blob_scheme_blocked() {
5666 assert!(
5667 has_dangerous_uri_scheme("blob:https://example.com/uuid"),
5668 "blob: URI scheme must be detected as dangerous"
5669 );
5670 assert!(
5671 has_dangerous_uri_scheme("BLOB:https://example.com/uuid"),
5672 "BLOB: (uppercase) must be detected as dangerous"
5673 );
5674 }
5675
5676 #[test]
5677 fn test_svg_section_strips_file_uri_in_use_href() {
5678 let input = "{start_of_svg}\n<svg><use href=\"file:///etc/passwd\"/></svg>\n{end_of_svg}";
5680 let html = render(input);
5681 assert!(
5682 !html.contains("file:///"),
5683 "file: URI in <use href> must be stripped; got: {html}"
5684 );
5685 }
5686
5687 #[test]
5688 fn test_svg_section_strips_file_uri_in_xlink_href() {
5689 let input =
5690 "{start_of_svg}\n<svg><use xlink:href=\"file:///etc/passwd\"/></svg>\n{end_of_svg}";
5691 let html = render(input);
5692 assert!(
5693 !html.contains("file:///"),
5694 "file: URI in xlink:href must be stripped; got: {html}"
5695 );
5696 }
5697
5698 #[test]
5701 fn test_svg_section_strips_feimage_element() {
5702 let input =
5704 "{start_of_svg}\n<svg><feImage href=\"file:///etc/passwd\"/></svg>\n{end_of_svg}";
5705 let html = render(input);
5706 assert!(
5707 !html.to_lowercase().contains("<feimage"),
5708 "feImage element must be stripped entirely; got: {html}"
5709 );
5710 assert!(
5711 !html.contains("file:///"),
5712 "file: URI inside feImage must not appear in output; got: {html}"
5713 );
5714 }
5715
5716 #[test]
5717 fn test_svg_section_strips_feimage_with_http_href() {
5718 let input = "{start_of_svg}\n<svg><feImage href=\"https://evil.example.com/spy.svg\"/></svg>\n{end_of_svg}";
5720 let html = render(input);
5721 assert!(
5722 !html.to_lowercase().contains("<feimage"),
5723 "feImage element must be stripped even with http href; got: {html}"
5724 );
5725 }
5726
5727 #[test]
5730 fn test_svg_section_strips_action_javascript_uri() {
5731 let input =
5733 "{start_of_svg}\n<svg><a action=\"javascript:alert(1)\">click</a></svg>\n{end_of_svg}";
5734 let html = render(input);
5735 assert!(
5736 !html.contains("javascript:"),
5737 "javascript: URI in action attribute must be stripped; got: {html}"
5738 );
5739 }
5740
5741 #[test]
5742 fn test_svg_section_strips_formaction_javascript_uri() {
5743 let input = "{start_of_svg}\n<svg><a formaction=\"javascript:alert(1)\">click</a></svg>\n{end_of_svg}";
5744 let html = render(input);
5745 assert!(
5746 !html.contains("javascript:"),
5747 "javascript: URI in formaction attribute must be stripped; got: {html}"
5748 );
5749 }
5750
5751 #[test]
5752 fn test_svg_section_strips_ping_javascript_uri() {
5753 let input =
5755 "{start_of_svg}\n<svg><a ping=\"javascript:alert(1)\">click</a></svg>\n{end_of_svg}";
5756 let html = render(input);
5757 assert!(
5758 !html.contains("javascript:"),
5759 "javascript: URI in ping attribute must be stripped; got: {html}"
5760 );
5761 }
5762
5763 #[test]
5764 fn test_svg_section_strips_poster_file_uri() {
5765 let input =
5767 "{start_of_svg}\n<svg><video poster=\"file:///etc/passwd\"/></svg>\n{end_of_svg}";
5768 let html = render(input);
5769 assert!(
5770 !html.contains("file:///"),
5771 "file: URI in poster attribute must be stripped; got: {html}"
5772 );
5773 }
5774
5775 #[test]
5776 fn test_svg_section_strips_background_file_uri() {
5777 let input =
5779 "{start_of_svg}\n<svg><body background=\"file:///etc/passwd\"/></svg>\n{end_of_svg}";
5780 let html = render(input);
5781 assert!(
5782 !html.contains("file:///"),
5783 "file: URI in background attribute must be stripped; got: {html}"
5784 );
5785 }
5786
5787 #[test]
5790 fn test_dangerous_uri_mhtml_scheme_blocked() {
5791 assert!(
5793 has_dangerous_uri_scheme("mhtml:file://C:/page.mhtml"),
5794 "mhtml: URI scheme must be detected as dangerous"
5795 );
5796 assert!(
5797 has_dangerous_uri_scheme("MHTML:file://C:/page.mhtml"),
5798 "MHTML: (uppercase) must be detected as dangerous"
5799 );
5800 }
5801
5802 #[test]
5805 fn test_svg_section_strips_image_element() {
5806 let input =
5809 "{start_of_svg}\n<svg><image href=\"https://evil.com/spy.png\"/></svg>\n{end_of_svg}";
5810 let html = render(input);
5811 assert!(
5812 !html.to_lowercase().contains("<image"),
5813 "SVG <image> element must be stripped entirely; got: {html}"
5814 );
5815 }
5816
5817 #[test]
5820 fn test_extreme_textsize_is_clamped_to_max() {
5821 let input = "{title: T}\n{textsize: 99999}\n[C]Hello";
5824 let html = render(input);
5825 assert!(
5826 !html.contains("99999"),
5827 "extreme textsize should be clamped, not passed through"
5828 );
5829 assert!(
5830 html.contains("200"),
5831 "extreme textsize should be clamped to MAX_FONT_SIZE (200)"
5832 );
5833 }
5834
5835 #[test]
5836 fn test_negative_textsize_is_clamped_to_min() {
5837 let input = "{title: T}\n{textsize: -10}\n[C]Hello";
5840 let html = render(input);
5841 assert!(
5842 html.contains("0.5"),
5843 "negative textsize should be clamped to MIN_FONT_SIZE (0.5)"
5844 );
5845 }
5846
5847 #[test]
5848 fn test_extreme_chordsize_is_clamped_to_max() {
5849 let input = "{title: T}\n{chordsize: 50000}\n[C]Hello";
5850 let html = render(input);
5851 assert!(
5852 !html.contains("50000"),
5853 "extreme chordsize should be clamped"
5854 );
5855 assert!(
5856 html.contains("200"),
5857 "extreme chordsize should be clamped to MAX_FONT_SIZE (200)"
5858 );
5859 }
5860}