1use std::fmt::Write;
19
20use chordsketch_core::ast::{CommentStyle, DirectiveKind, Line, LyricsLine, Song};
21use chordsketch_core::canonical_chord_name;
22use chordsketch_core::config::Config;
23use chordsketch_core::escape::escape_xml as escape;
24use chordsketch_core::inline_markup::{SpanAttributes, TextSpan};
25use chordsketch_core::render_result::RenderResult;
26use chordsketch_core::resolve_diagrams_instrument;
27use chordsketch_core::transpose::transpose_chord;
28
29const MAX_CHORUS_RECALLS: usize = 1000;
32
33const MAX_COLUMNS: u32 = 32;
36
37#[derive(Default, Clone)]
47struct ElementStyle {
48 font: Option<String>,
49 size: Option<String>,
50 colour: Option<String>,
51}
52
53impl ElementStyle {
54 fn to_css(&self) -> String {
59 let mut css = String::new();
60 if let Some(ref font) = self.font {
61 let _ = write!(css, "font-family: {};", sanitize_css_value(font));
62 }
63 if let Some(ref size) = self.size {
64 let safe = sanitize_css_value(size);
65 if safe.chars().all(|c| c.is_ascii_digit()) {
66 let _ = write!(css, "font-size: {safe}pt;");
67 } else {
68 let _ = write!(css, "font-size: {safe};");
69 }
70 }
71 if let Some(ref colour) = self.colour {
72 let _ = write!(css, "color: {};", sanitize_css_value(colour));
73 }
74 css
75 }
76}
77
78#[derive(Default, Clone)]
80struct FormattingState {
81 text: ElementStyle,
82 chord: ElementStyle,
83 tab: ElementStyle,
84 title: ElementStyle,
85 chorus: ElementStyle,
86 label: ElementStyle,
87 grid: ElementStyle,
88}
89
90impl FormattingState {
91 fn apply(&mut self, kind: &DirectiveKind, value: &Option<String>) {
93 let val = value.clone();
94 match kind {
95 DirectiveKind::TextFont => self.text.font = val,
96 DirectiveKind::TextSize => self.text.size = val,
97 DirectiveKind::TextColour => self.text.colour = val,
98 DirectiveKind::ChordFont => self.chord.font = val,
99 DirectiveKind::ChordSize => self.chord.size = val,
100 DirectiveKind::ChordColour => self.chord.colour = val,
101 DirectiveKind::TabFont => self.tab.font = val,
102 DirectiveKind::TabSize => self.tab.size = val,
103 DirectiveKind::TabColour => self.tab.colour = val,
104 DirectiveKind::TitleFont => self.title.font = val,
105 DirectiveKind::TitleSize => self.title.size = val,
106 DirectiveKind::TitleColour => self.title.colour = val,
107 DirectiveKind::ChorusFont => self.chorus.font = val,
108 DirectiveKind::ChorusSize => self.chorus.size = val,
109 DirectiveKind::ChorusColour => self.chorus.colour = val,
110 DirectiveKind::LabelFont => self.label.font = val,
111 DirectiveKind::LabelSize => self.label.size = val,
112 DirectiveKind::LabelColour => self.label.colour = val,
113 DirectiveKind::GridFont => self.grid.font = val,
114 DirectiveKind::GridSize => self.grid.size = val,
115 DirectiveKind::GridColour => self.grid.colour = val,
116 _ => {}
118 }
119 }
120}
121
122#[must_use]
131pub fn render_song(song: &Song) -> String {
132 render_song_with_transpose(song, 0, &Config::defaults())
133}
134
135#[must_use]
143pub fn render_song_with_transpose(song: &Song, cli_transpose: i8, config: &Config) -> String {
144 let result = render_song_with_warnings(song, cli_transpose, config);
145 for w in &result.warnings {
146 eprintln!("warning: {w}");
147 }
148 result.output
149}
150
151pub fn render_song_with_warnings(
157 song: &Song,
158 cli_transpose: i8,
159 config: &Config,
160) -> RenderResult<String> {
161 let mut warnings = Vec::new();
162 let title = song.metadata.title.as_deref().unwrap_or("Untitled");
163 let mut html = String::new();
164 let _ = write!(
165 html,
166 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>{}</title>\n",
167 escape(title)
168 );
169 html.push_str("<style>\n");
170 html.push_str(CSS);
171 html.push_str("</style>\n</head>\n<body>\n");
172 render_song_body(song, cli_transpose, config, &mut html, &mut warnings);
173 html.push_str("</body>\n</html>\n");
174 RenderResult::with_warnings(html, warnings)
175}
176
177fn render_song_body(
183 song: &Song,
184 cli_transpose: i8,
185 config: &Config,
186 html: &mut String,
187 warnings: &mut Vec<String>,
188) {
189 let song_overrides = song.config_overrides();
191 let song_config;
192 let config = if song_overrides.is_empty() {
193 config
194 } else {
195 song_config = config
196 .clone()
197 .with_song_overrides(&song_overrides, warnings);
198 &song_config
199 };
200 let song_transpose_delta = Config::song_transpose_delta(&song_overrides);
203 let (combined_transpose, _) =
204 chordsketch_core::transpose::combine_transpose(cli_transpose, song_transpose_delta);
205 let mut transpose_offset: i8 = combined_transpose;
206 let mut fmt_state = FormattingState::default();
207 html.push_str("<div class=\"song\">\n");
208
209 render_metadata(&song.metadata, html);
210
211 let mut columns_open = false;
213 let mut svg_buf: Option<String> = None;
216 let mut abc2svg_resolved: Option<bool> = config.get_path("delegates.abc2svg").as_bool();
221 let mut lilypond_resolved: Option<bool> = config.get_path("delegates.lilypond").as_bool();
222 let mut musescore_resolved: Option<bool> = config.get_path("delegates.musescore").as_bool();
223 let mut abc_buf: Option<String> = None;
224 let mut abc_label: Option<String> = None;
225 let mut ly_buf: Option<String> = None;
226 let mut ly_label: Option<String> = None;
227 let mut musicxml_buf: Option<String> = None;
228 let mut musicxml_label: Option<String> = None;
229
230 let mut show_diagrams = true;
232
233 let diagram_frets = config
235 .get_path("diagrams.frets")
236 .as_f64()
237 .map_or(chordsketch_core::chord_diagram::DEFAULT_FRETS_SHOWN, |n| {
238 (n as usize).max(1)
239 });
240
241 let default_instrument = config
245 .get_path("diagrams.instrument")
246 .as_str()
247 .map(str::to_ascii_lowercase)
248 .unwrap_or_else(|| "guitar".to_string());
249 let mut auto_diagrams_instrument: Option<String> = None;
250 let mut inline_defined: std::collections::HashSet<String> = std::collections::HashSet::new();
254
255 let mut chorus_body: Vec<Line> = Vec::new();
258 let mut chorus_buf: Option<Vec<Line>> = None;
260 let mut saved_fmt_state: Option<FormattingState> = None;
263 let mut chorus_recall_count: usize = 0;
264
265 for line in &song.lines {
266 match line {
267 Line::Lyrics(lyrics_line) => {
268 if let Some(ref mut buf) = svg_buf {
269 let raw = lyrics_line.text();
273 buf.push_str(&raw);
274 buf.push('\n');
275 } else if let Some(ref mut buf) = abc_buf {
276 let raw = lyrics_line.text();
278 buf.push_str(&raw);
279 buf.push('\n');
280 } else if let Some(ref mut buf) = ly_buf {
281 let raw = lyrics_line.text();
283 buf.push_str(&raw);
284 buf.push('\n');
285 } else if let Some(ref mut buf) = musicxml_buf {
286 let raw = lyrics_line.text();
288 buf.push_str(&raw);
289 buf.push('\n');
290 } else {
291 if let Some(buf) = chorus_buf.as_mut() {
292 buf.push(line.clone());
293 }
294 render_lyrics(lyrics_line, transpose_offset, &fmt_state, html);
295 }
296 }
297 Line::Directive(directive) => {
298 if directive.kind.is_metadata() {
299 continue;
300 }
301 if directive.kind == DirectiveKind::Diagrams {
302 auto_diagrams_instrument = resolve_diagrams_instrument(
303 directive.value.as_deref(),
304 &default_instrument,
305 );
306 show_diagrams = auto_diagrams_instrument.is_some();
307 continue;
308 }
309 if directive.kind == DirectiveKind::NoDiagrams {
310 show_diagrams = false;
311 auto_diagrams_instrument = None;
312 continue;
313 }
314 if directive.kind == DirectiveKind::Transpose {
315 let file_offset: i8 = directive
316 .value
317 .as_deref()
318 .and_then(|v| v.parse().ok())
319 .unwrap_or(0);
320 let (combined, saturated) =
321 chordsketch_core::transpose::combine_transpose(file_offset, cli_transpose);
322 if saturated {
323 warnings.push(format!(
324 "transpose offset {file_offset} + {cli_transpose} \
325 exceeds i8 range, clamped to {combined}"
326 ));
327 }
328 transpose_offset = combined;
329 continue;
330 }
331 if directive.kind.is_font_size_color() {
332 if let Some(buf) = chorus_buf.as_mut() {
333 buf.push(line.clone());
334 }
335 fmt_state.apply(&directive.kind, &directive.value);
336 continue;
337 }
338 match &directive.kind {
339 DirectiveKind::StartOfChorus => {
340 render_section_open("chorus", "Chorus", &directive.value, html);
341 chorus_buf = Some(Vec::new());
342 saved_fmt_state = Some(fmt_state.clone());
345 }
346 DirectiveKind::EndOfChorus => {
347 html.push_str("</section>\n");
348 if let Some(buf) = chorus_buf.take() {
349 chorus_body = buf;
350 }
351 if let Some(saved) = saved_fmt_state.take() {
353 fmt_state = saved;
354 }
355 }
356 DirectiveKind::Chorus => {
357 if chorus_recall_count < MAX_CHORUS_RECALLS {
358 render_chorus_recall(
359 &directive.value,
360 &chorus_body,
361 transpose_offset,
362 &fmt_state,
363 show_diagrams,
364 diagram_frets,
365 html,
366 );
367 chorus_recall_count += 1;
368 } else if chorus_recall_count == MAX_CHORUS_RECALLS {
369 warnings.push(format!(
370 "chorus recall limit ({MAX_CHORUS_RECALLS}) reached, \
371 further recalls suppressed"
372 ));
373 chorus_recall_count += 1;
374 }
375 }
376 DirectiveKind::Columns => {
377 let n: u32 = directive
381 .value
382 .as_deref()
383 .and_then(|v| v.trim().parse().ok())
384 .unwrap_or(1)
385 .clamp(1, MAX_COLUMNS);
386 if columns_open {
387 html.push_str("</div>\n");
388 columns_open = false;
389 }
390 if n > 1 {
391 let _ = writeln!(
392 html,
393 "<div style=\"column-count: {n};column-gap: 2em;\">"
394 );
395 columns_open = true;
396 }
397 }
398 DirectiveKind::ColumnBreak => {
404 html.push_str("<div style=\"break-before: column;\"></div>\n");
405 }
406 DirectiveKind::NewPage => {
407 html.push_str("<div style=\"break-before: page;\"></div>\n");
408 }
409 DirectiveKind::NewPhysicalPage => {
410 html.push_str("<div style=\"break-before: recto;\"></div>\n");
414 }
415 DirectiveKind::StartOfAbc => {
416 #[cfg(not(target_arch = "wasm32"))]
417 let enabled = *abc2svg_resolved
418 .get_or_insert_with(chordsketch_core::external_tool::has_abc2svg);
419 #[cfg(target_arch = "wasm32")]
420 let enabled = *abc2svg_resolved.get_or_insert(false);
421 if enabled {
422 abc_buf = Some(String::new());
423 abc_label = directive.value.clone();
424 } else {
425 if let Some(buf) = chorus_buf.as_mut() {
426 buf.push(line.clone());
427 }
428 render_directive_inner(directive, show_diagrams, diagram_frets, html);
429 }
430 }
431 DirectiveKind::EndOfAbc if abc_buf.is_some() => {
432 if let Some(abc_content) = abc_buf.take() {
433 render_abc_with_fallback(&abc_content, &abc_label, html, warnings);
434 abc_label = None;
435 }
436 }
437 DirectiveKind::StartOfLy => {
438 #[cfg(not(target_arch = "wasm32"))]
439 let enabled = *lilypond_resolved
440 .get_or_insert_with(chordsketch_core::external_tool::has_lilypond);
441 #[cfg(target_arch = "wasm32")]
442 let enabled = *lilypond_resolved.get_or_insert(false);
443 if enabled {
444 ly_buf = Some(String::new());
445 ly_label = directive.value.clone();
446 } else {
447 if let Some(buf) = chorus_buf.as_mut() {
448 buf.push(line.clone());
449 }
450 render_directive_inner(directive, show_diagrams, diagram_frets, html);
451 }
452 }
453 DirectiveKind::EndOfLy if ly_buf.is_some() => {
454 if let Some(ly_content) = ly_buf.take() {
455 render_ly_with_fallback(&ly_content, &ly_label, html, warnings);
456 ly_label = None;
457 }
458 }
459 DirectiveKind::StartOfMusicxml => {
460 #[cfg(not(target_arch = "wasm32"))]
461 let enabled = *musescore_resolved
462 .get_or_insert_with(chordsketch_core::external_tool::has_musescore);
463 #[cfg(target_arch = "wasm32")]
464 let enabled = *musescore_resolved.get_or_insert(false);
465 if enabled {
466 musicxml_buf = Some(String::new());
467 musicxml_label = directive.value.clone();
468 } else {
469 if let Some(buf) = chorus_buf.as_mut() {
470 buf.push(line.clone());
471 }
472 render_directive_inner(directive, show_diagrams, diagram_frets, html);
473 }
474 }
475 DirectiveKind::EndOfMusicxml if musicxml_buf.is_some() => {
476 if let Some(musicxml_content) = musicxml_buf.take() {
477 render_musicxml_with_fallback(
478 &musicxml_content,
479 &musicxml_label,
480 html,
481 warnings,
482 );
483 musicxml_label = None;
484 }
485 }
486 DirectiveKind::StartOfSvg => {
487 svg_buf = Some(String::new());
488 }
489 DirectiveKind::EndOfSvg if svg_buf.is_some() => {
490 if let Some(svg_content) = svg_buf.take() {
491 html.push_str("<div class=\"svg-section\">\n");
492 html.push_str(&sanitize_svg_content(&svg_content));
493 html.push('\n');
494 html.push_str("</div>\n");
495 }
496 }
497 _ => {
498 if let Some(buf) = chorus_buf.as_mut() {
499 buf.push(line.clone());
500 }
501 if directive.kind == DirectiveKind::Define && show_diagrams {
504 if let Some(ref val) = directive.value {
505 let name =
506 chordsketch_core::ast::ChordDefinition::parse_value(val).name;
507 if !name.is_empty() {
508 inline_defined.insert(canonical_chord_name(&name));
509 }
510 }
511 }
512 render_directive_inner(directive, show_diagrams, diagram_frets, html);
513 }
514 }
515 }
516 Line::Comment(style, text) => {
517 if let Some(buf) = chorus_buf.as_mut() {
518 buf.push(line.clone());
519 }
520 render_comment(*style, text, html);
521 }
522 Line::Empty => {
523 if let Some(buf) = chorus_buf.as_mut() {
524 buf.push(line.clone());
525 }
526 html.push_str("<div class=\"empty-line\"></div>\n");
527 }
528 }
529 }
530
531 if columns_open {
533 html.push_str("</div>\n");
534 }
535
536 if let Some(ref instrument) = auto_diagrams_instrument {
538 let chord_names: Vec<String> = song
542 .used_chord_names()
543 .into_iter()
544 .filter(|name| !inline_defined.contains(&canonical_chord_name(name)))
545 .collect();
546
547 if instrument == "piano" {
548 let kbd_defines = song.keyboard_defines();
550 let voicings: Vec<_> = chord_names
551 .into_iter()
552 .filter_map(|name| chordsketch_core::lookup_keyboard_voicing(&name, &kbd_defines))
553 .collect();
554 if !voicings.is_empty() {
555 html.push_str("<section class=\"chord-diagrams\">\n");
556 html.push_str("<div class=\"section-label\">Chord Diagrams</div>\n");
557 html.push_str("<div class=\"chord-diagrams-grid\">\n");
558 for voicing in &voicings {
559 html.push_str("<div class=\"chord-diagram-container\">");
560 html.push_str(&chordsketch_core::chord_diagram::render_keyboard_svg(
561 voicing,
562 ));
563 html.push_str("</div>\n");
564 }
565 html.push_str("</div>\n");
566 html.push_str("</section>\n");
567 }
568 } else {
569 let defines = song.fretted_defines();
571 let diagrams: Vec<_> = chord_names
572 .into_iter()
573 .filter_map(|name| {
574 chordsketch_core::lookup_diagram(&name, &defines, instrument, diagram_frets)
575 })
576 .collect();
577 if !diagrams.is_empty() {
578 html.push_str("<section class=\"chord-diagrams\">\n");
579 html.push_str("<div class=\"section-label\">Chord Diagrams</div>\n");
580 html.push_str("<div class=\"chord-diagrams-grid\">\n");
581 for diagram in &diagrams {
582 html.push_str("<div class=\"chord-diagram-container\">");
583 html.push_str(&chordsketch_core::chord_diagram::render_svg(diagram));
584 html.push_str("</div>\n");
585 }
586 html.push_str("</div>\n");
587 html.push_str("</section>\n");
588 }
589 }
590 }
591
592 html.push_str("</div>\n");
593}
594
595#[must_use]
597pub fn render_songs(songs: &[Song]) -> String {
598 render_songs_with_transpose(songs, 0, &Config::defaults())
599}
600
601#[must_use]
610pub fn render_songs_with_transpose(songs: &[Song], cli_transpose: i8, config: &Config) -> String {
611 let result = render_songs_with_warnings(songs, cli_transpose, config);
612 for w in &result.warnings {
613 eprintln!("warning: {w}");
614 }
615 result.output
616}
617
618pub fn render_songs_with_warnings(
625 songs: &[Song],
626 cli_transpose: i8,
627 config: &Config,
628) -> RenderResult<String> {
629 let mut warnings = Vec::new();
630 if songs.len() <= 1 {
631 let output = songs
632 .first()
633 .map(|s| {
634 let r = render_song_with_warnings(s, cli_transpose, config);
635 warnings = r.warnings;
636 r.output
637 })
638 .unwrap_or_default();
639 return RenderResult::with_warnings(output, warnings);
640 }
641 let mut html = String::new();
643 let title = songs
644 .first()
645 .and_then(|s| s.metadata.title.as_deref())
646 .unwrap_or("Untitled");
647 let _ = write!(
648 html,
649 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>{}</title>\n",
650 escape(title)
651 );
652 html.push_str("<style>\n");
653 html.push_str(CSS);
654 html.push_str("</style>\n</head>\n<body>\n");
655
656 for (i, song) in songs.iter().enumerate() {
657 if i > 0 {
658 html.push_str("<hr class=\"song-separator\">\n");
659 }
660 render_song_body(song, cli_transpose, config, &mut html, &mut warnings);
661 }
662
663 html.push_str("</body>\n</html>\n");
664 RenderResult::with_warnings(html, warnings)
665}
666
667#[must_use = "parse errors should be handled"]
672pub fn try_render(input: &str) -> Result<String, chordsketch_core::ParseError> {
673 let song = chordsketch_core::parse(input)?;
674 Ok(render_song(&song))
675}
676
677#[must_use]
682pub fn render(input: &str) -> String {
683 match try_render(input) {
684 Ok(html) => html,
685 Err(e) => format!(
686 "<!DOCTYPE html><html><body><pre>Parse error at line {} column {}: {}</pre></body></html>\n",
687 e.line(),
688 e.column(),
689 escape(&e.message)
690 ),
691 }
692}
693
694const CSS: &str = "\
700body { font-family: serif; max-width: 800px; margin: 2em auto; padding: 0 1em; }
701h1 { margin-bottom: 0.2em; }
702h2 { margin-top: 0; font-weight: normal; color: #555; }
703.line { display: flex; flex-wrap: wrap; margin: 0.1em 0; }
704.chord-block { display: inline-flex; flex-direction: column; align-items: flex-start; }
705.chord { font-weight: bold; color: #b00; font-size: 0.9em; min-height: 1.2em; }
706.lyrics { white-space: pre; }
707.empty-line { height: 1em; }
708section { margin: 1em 0; }
709section > .section-label { font-weight: bold; font-style: italic; margin-bottom: 0.3em; }
710.comment { font-style: italic; color: #666; margin: 0.3em 0; }
711.comment-box { border: 1px solid #999; padding: 0.2em 0.5em; display: inline-block; margin: 0.3em 0; }
712.chorus-recall { margin: 1em 0; }
713.chorus-recall > .section-label { font-weight: bold; font-style: italic; margin-bottom: 0.3em; }
714img { max-width: 100%; height: auto; }
715.chord-diagrams-grid { display: flex; flex-wrap: wrap; gap: 0.5em; margin: 0.5em 0; }
716.chord-diagram-container { display: inline-block; vertical-align: top; }
717.chord-diagram { display: block; }
718";
719
720fn render_metadata(metadata: &chordsketch_core::ast::Metadata, html: &mut String) {
730 if let Some(title) = &metadata.title {
731 let _ = writeln!(html, "<h1>{}</h1>", escape(title));
732 }
733 for subtitle in &metadata.subtitles {
734 let _ = writeln!(html, "<h2>{}</h2>", escape(subtitle));
735 }
736}
737
738fn render_lyrics(
748 lyrics_line: &LyricsLine,
749 transpose_offset: i8,
750 fmt_state: &FormattingState,
751 html: &mut String,
752) {
753 html.push_str("<div class=\"line\">");
754
755 for segment in &lyrics_line.segments {
756 html.push_str("<span class=\"chord-block\">");
757
758 if let Some(chord) = &segment.chord {
759 let display_name = if transpose_offset != 0 {
760 let transposed = transpose_chord(chord, transpose_offset);
761 transposed.display_name().to_string()
762 } else {
763 chord.display_name().to_string()
764 };
765 let chord_css = fmt_state.chord.to_css();
766 if chord_css.is_empty() {
767 let _ = write!(
768 html,
769 "<span class=\"chord\">{}</span>",
770 escape(&display_name)
771 );
772 } else {
773 let _ = write!(
774 html,
775 "<span class=\"chord\" style=\"{}\">{}</span>",
776 escape(&chord_css),
777 escape(&display_name)
778 );
779 }
780 } else if lyrics_line.has_chords() {
781 html.push_str("<span class=\"chord\"></span>");
783 }
784
785 let text_css = fmt_state.text.to_css();
786 if text_css.is_empty() {
787 html.push_str("<span class=\"lyrics\">");
788 } else {
789 let _ = write!(
790 html,
791 "<span class=\"lyrics\" style=\"{}\">",
792 escape(&text_css)
793 );
794 }
795 if segment.has_markup() {
796 render_spans(&segment.spans, html);
797 } else {
798 html.push_str(&escape(&segment.text));
799 }
800 html.push_str("</span>");
801 html.push_str("</span>");
802 }
803
804 html.push_str("</div>\n");
805}
806
807fn render_spans(spans: &[TextSpan], html: &mut String) {
816 for span in spans {
817 match span {
818 TextSpan::Plain(text) => html.push_str(&escape(text)),
819 TextSpan::Bold(children) => {
820 html.push_str("<b>");
821 render_spans(children, html);
822 html.push_str("</b>");
823 }
824 TextSpan::Italic(children) => {
825 html.push_str("<i>");
826 render_spans(children, html);
827 html.push_str("</i>");
828 }
829 TextSpan::Highlight(children) => {
830 html.push_str("<mark>");
831 render_spans(children, html);
832 html.push_str("</mark>");
833 }
834 TextSpan::Comment(children) => {
835 html.push_str("<span class=\"comment\">");
836 render_spans(children, html);
837 html.push_str("</span>");
838 }
839 TextSpan::Span(attrs, children) => {
840 let css = span_attrs_to_css(attrs);
841 if css.is_empty() {
842 html.push_str("<span>");
843 } else {
844 let _ = write!(html, "<span style=\"{}\">", escape(&css));
845 }
846 render_spans(children, html);
847 html.push_str("</span>");
848 }
849 }
850 }
851}
852
853fn span_attrs_to_css(attrs: &SpanAttributes) -> String {
855 let mut css = String::new();
856 if let Some(ref font_family) = attrs.font_family {
857 let _ = write!(css, "font-family: {};", sanitize_css_value(font_family));
858 }
859 if let Some(ref size) = attrs.size {
860 let safe = sanitize_css_value(size);
861 if safe.chars().all(|c| c.is_ascii_digit()) {
863 let _ = write!(css, "font-size: {safe}pt;");
864 } else {
865 let _ = write!(css, "font-size: {safe};");
866 }
867 }
868 if let Some(ref fg) = attrs.foreground {
869 let _ = write!(css, "color: {};", sanitize_css_value(fg));
870 }
871 if let Some(ref bg) = attrs.background {
872 let _ = write!(css, "background-color: {};", sanitize_css_value(bg));
873 }
874 if let Some(ref weight) = attrs.weight {
875 let _ = write!(css, "font-weight: {};", sanitize_css_value(weight));
876 }
877 if let Some(ref style) = attrs.style {
878 let _ = write!(css, "font-style: {};", sanitize_css_value(style));
879 }
880 css
881}
882
883fn sanitize_css_value(s: &str) -> String {
890 s.chars()
891 .filter(|c| {
892 c.is_ascii_alphanumeric() || matches!(c, '#' | '.' | '-' | ' ' | ',' | '%' | '+')
893 })
894 .collect()
895}
896
897fn sanitize_css_class(s: &str) -> String {
904 s.chars()
905 .map(|c| {
906 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
907 c
908 } else {
909 '-'
910 }
911 })
912 .collect()
913}
914
915fn sanitize_svg_content(input: &str) -> String {
922 const DANGEROUS_TAGS: &[&str] = &[
924 "script",
925 "foreignobject",
926 "iframe",
927 "object",
928 "embed",
929 "math",
930 "set",
931 "animate",
932 "animatetransform",
933 "animatemotion",
934 ];
935
936 let mut result = String::with_capacity(input.len());
937 let mut chars = input.char_indices().peekable();
938 let bytes = input.as_bytes();
939
940 while let Some((i, c)) = chars.next() {
941 if c == '<' {
942 let rest = &input[i..];
943 let limit = rest
946 .char_indices()
947 .map(|(idx, _)| idx)
948 .find(|&idx| idx >= 30)
949 .unwrap_or(rest.len());
950 let rest_upper = &rest[..limit];
951
952 let mut matched = false;
954 for tag in DANGEROUS_TAGS {
955 let prefix = format!("<{tag}");
956 if starts_with_ignore_case(rest_upper, &prefix)
957 && rest.len() > prefix.len()
958 && bytes
959 .get(i + prefix.len())
960 .is_none_or(|b| b.is_ascii_whitespace() || *b == b'>' || *b == b'/')
961 {
962 let is_self_closing = {
966 let tag_bytes = rest.as_bytes();
967 let mut in_quote: Option<u8> = None;
968 let mut gt_pos = None;
969 for (idx, &b) in tag_bytes.iter().enumerate() {
970 match in_quote {
971 Some(q) if b == q => in_quote = None,
972 Some(_) => {}
973 None if b == b'"' || b == b'\'' => in_quote = Some(b),
974 None if b == b'>' => {
975 gt_pos = Some(idx);
976 break;
977 }
978 _ => {}
979 }
980 }
981 gt_pos.is_some_and(|gt| gt > 0 && tag_bytes[gt - 1] == b'/')
982 };
983
984 if is_self_closing {
985 let mut skip_quote: Option<char> = None;
989 while let Some(&(_, ch)) = chars.peek() {
990 chars.next();
991 match skip_quote {
992 Some(q) if ch == q => skip_quote = None,
993 Some(_) => {}
994 None if ch == '"' || ch == '\'' => {
995 skip_quote = Some(ch);
996 }
997 None if ch == '>' => break,
998 _ => {}
999 }
1000 }
1001 } else if let Some(end) = find_end_tag_ignore_case(input, i, tag) {
1002 while let Some(&(j, _)) = chars.peek() {
1004 if j >= end {
1005 break;
1006 }
1007 chars.next();
1008 }
1009 } else {
1010 return result;
1012 }
1013 matched = true;
1014 break;
1015 }
1016 }
1017 if matched {
1018 continue;
1019 }
1020
1021 for tag in DANGEROUS_TAGS {
1023 let prefix = format!("</{tag}");
1024 if starts_with_ignore_case(rest_upper, &prefix)
1025 && rest.len() > prefix.len()
1026 && bytes
1027 .get(i + prefix.len())
1028 .is_none_or(|b| b.is_ascii_whitespace() || *b == b'>')
1029 {
1030 while let Some(&(_, ch)) = chars.peek() {
1032 chars.next();
1033 if ch == '>' {
1034 break;
1035 }
1036 }
1037 matched = true;
1038 break;
1039 }
1040 }
1041 if matched {
1042 continue;
1043 }
1044
1045 result.push(c);
1046 } else {
1047 result.push(c);
1048 }
1049 }
1050
1051 strip_dangerous_attrs(&result)
1053}
1054
1055fn starts_with_ignore_case(s: &str, prefix: &str) -> bool {
1057 if s.len() < prefix.len() {
1058 return false;
1059 }
1060 s.as_bytes()[..prefix.len()]
1061 .iter()
1062 .zip(prefix.as_bytes())
1063 .all(|(a, b)| a.eq_ignore_ascii_case(b))
1064}
1065
1066fn find_end_tag_ignore_case(input: &str, start: usize, tag: &str) -> Option<usize> {
1069 let search = &input.as_bytes()[start..];
1070 let tag_bytes = tag.as_bytes();
1071 let close_prefix_len = 2 + tag_bytes.len(); for i in 0..search.len() {
1074 if search[i] == b'<'
1075 && i + 1 < search.len()
1076 && search[i + 1] == b'/'
1077 && i + close_prefix_len <= search.len()
1078 {
1079 let candidate = &search[i + 2..i + close_prefix_len];
1080 if candidate
1081 .iter()
1082 .zip(tag_bytes)
1083 .all(|(a, b)| a.eq_ignore_ascii_case(b))
1084 {
1085 if let Some(gt) = search[i + close_prefix_len..]
1087 .iter()
1088 .position(|&b| b == b'>')
1089 {
1090 return Some(start + i + close_prefix_len + gt + 1);
1091 }
1092 }
1093 }
1094 }
1095 None
1096}
1097
1098fn strip_dangerous_attrs(input: &str) -> String {
1103 let mut result = String::with_capacity(input.len());
1104 let bytes = input.as_bytes();
1105 let mut pos = 0;
1106
1107 while pos < bytes.len() {
1108 if bytes[pos] == b'<' && pos + 1 < bytes.len() && bytes[pos + 1] != b'/' {
1109 if let Some(gt) = find_tag_end(&bytes[pos..]) {
1113 let tag_end = pos + gt + 1;
1114 let tag_content = &input[pos..tag_end];
1115 result.push_str(&sanitize_tag_attrs(tag_content));
1116 pos = tag_end;
1117 } else {
1118 result.push_str(&input[pos..]);
1119 break;
1120 }
1121 } else {
1122 let ch = &input[pos..];
1125 let c = ch.chars().next().expect("pos is within bounds");
1126 result.push(c);
1127 pos += c.len_utf8();
1128 }
1129 }
1130 result
1131}
1132
1133fn find_tag_end(bytes: &[u8]) -> Option<usize> {
1136 let mut i = 0;
1137 let mut in_quote: Option<u8> = None;
1138 while i < bytes.len() {
1139 let b = bytes[i];
1140 if let Some(q) = in_quote {
1141 if b == q {
1142 in_quote = None;
1143 }
1144 } else if b == b'"' || b == b'\'' {
1145 in_quote = Some(b);
1146 } else if b == b'>' {
1147 return Some(i);
1148 }
1149 i += 1;
1150 }
1151 None
1152}
1153
1154fn has_dangerous_uri_scheme(value: &str) -> bool {
1157 let lower: String = value
1163 .trim_start()
1164 .chars()
1165 .filter(|c| !c.is_ascii_whitespace() && !c.is_ascii_control())
1166 .take(30)
1167 .flat_map(|c| c.to_lowercase())
1168 .collect();
1169 lower.starts_with("javascript:") || lower.starts_with("vbscript:") || lower.starts_with("data:")
1170}
1171
1172fn is_uri_attr(name: &str) -> bool {
1175 let lower: String = name.chars().flat_map(|c| c.to_lowercase()).collect();
1176 lower == "href"
1177 || lower == "src"
1178 || lower == "xlink:href"
1179 || lower == "to"
1180 || lower == "values"
1181 || lower == "from"
1182 || lower == "by"
1183}
1184
1185fn sanitize_tag_attrs(tag: &str) -> String {
1196 let mut result = String::with_capacity(tag.len());
1197 let bytes = tag.as_bytes();
1198 let mut i = 0;
1199
1200 while i < bytes.len() && bytes[i] != b' ' && bytes[i] != b'>' && bytes[i] != b'/' {
1202 result.push(bytes[i] as char);
1203 i += 1;
1204 }
1205
1206 while i < bytes.len() {
1207 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
1209 result.push(bytes[i] as char);
1210 i += 1;
1211 }
1212
1213 if i >= bytes.len() || bytes[i] == b'>' || bytes[i] == b'/' {
1214 result.push_str(&tag[i..]);
1215 return result;
1216 }
1217
1218 let attr_start = i;
1220 while i < bytes.len()
1221 && bytes[i] != b'='
1222 && bytes[i] != b' '
1223 && bytes[i] != b'>'
1224 && bytes[i] != b'/'
1225 {
1226 i += 1;
1227 }
1228 let attr_name = &tag[attr_start..i];
1229
1230 let is_event_handler = attr_name.len() > 2
1231 && attr_name.as_bytes()[..2].eq_ignore_ascii_case(b"on")
1232 && attr_name.as_bytes()[2].is_ascii_alphabetic();
1233
1234 let value_start = i;
1236 let mut attr_value: Option<String> = None;
1237 if i < bytes.len() && bytes[i] == b'=' {
1238 i += 1; if i < bytes.len() && (bytes[i] == b'"' || bytes[i] == b'\'') {
1240 let quote = bytes[i];
1241 i += 1;
1242 let val_start = i;
1243 while i < bytes.len() && bytes[i] != quote {
1244 i += 1;
1245 }
1246 attr_value = Some(tag[val_start..i].to_string());
1247 if i < bytes.len() {
1248 i += 1; }
1250 } else {
1251 let val_start = i;
1253 while i < bytes.len() && !bytes[i].is_ascii_whitespace() && bytes[i] != b'>' {
1254 i += 1;
1255 }
1256 attr_value = Some(tag[val_start..i].to_string());
1257 }
1258 }
1259
1260 if is_event_handler {
1261 continue;
1263 }
1264
1265 if is_uri_attr(attr_name) {
1266 if let Some(ref val) = attr_value {
1267 if has_dangerous_uri_scheme(val) {
1268 continue;
1270 }
1271 }
1272 }
1273
1274 if attr_name.eq_ignore_ascii_case("style") {
1277 if let Some(ref val) = attr_value {
1278 let lower_val: String = val.chars().flat_map(|c| c.to_lowercase()).collect();
1279 if lower_val.contains("url(")
1280 || lower_val.contains("expression(")
1281 || lower_val.contains("@import")
1282 {
1283 continue;
1284 }
1285 }
1286 }
1287
1288 result.push_str(&tag[attr_start..value_start]);
1290 if attr_value.is_some() {
1291 result.push_str(&tag[value_start..i]);
1292 }
1293 }
1294
1295 result
1296}
1297
1298fn render_directive_inner(
1307 directive: &chordsketch_core::ast::Directive,
1308 show_diagrams: bool,
1309 diagram_frets: usize,
1310 html: &mut String,
1311) {
1312 match &directive.kind {
1313 DirectiveKind::StartOfChorus => {
1314 render_section_open("chorus", "Chorus", &directive.value, html);
1315 }
1316 DirectiveKind::StartOfVerse => {
1317 render_section_open("verse", "Verse", &directive.value, html);
1318 }
1319 DirectiveKind::StartOfBridge => {
1320 render_section_open("bridge", "Bridge", &directive.value, html);
1321 }
1322 DirectiveKind::StartOfTab => {
1323 render_section_open("tab", "Tab", &directive.value, html);
1324 }
1325 DirectiveKind::StartOfGrid => {
1326 render_section_open("grid", "Grid", &directive.value, html);
1327 }
1328 DirectiveKind::StartOfAbc => {
1329 render_section_open("abc", "ABC", &directive.value, html);
1330 }
1331 DirectiveKind::StartOfLy => {
1332 render_section_open("ly", "Lilypond", &directive.value, html);
1333 }
1334 DirectiveKind::StartOfTextblock => {
1337 render_section_open("textblock", "Textblock", &directive.value, html);
1338 }
1339 DirectiveKind::StartOfMusicxml => {
1340 render_section_open("musicxml", "MusicXML", &directive.value, html);
1341 }
1342 DirectiveKind::StartOfSection(section_name) => {
1343 let class = format!("section-{}", sanitize_css_class(section_name));
1344 let label = escape(&chordsketch_core::capitalize(section_name));
1345 render_section_open(&class, &label, &directive.value, html);
1346 }
1347 DirectiveKind::EndOfChorus
1348 | DirectiveKind::EndOfVerse
1349 | DirectiveKind::EndOfBridge
1350 | DirectiveKind::EndOfTab
1351 | DirectiveKind::EndOfGrid
1352 | DirectiveKind::EndOfAbc
1353 | DirectiveKind::EndOfLy
1354 | DirectiveKind::EndOfMusicxml
1355 | DirectiveKind::EndOfSvg
1356 | DirectiveKind::EndOfTextblock
1357 | DirectiveKind::EndOfSection(_) => {
1358 html.push_str("</section>\n");
1359 }
1360 DirectiveKind::Image(attrs) => {
1361 render_image(attrs, html);
1362 }
1363 DirectiveKind::Define => {
1364 if show_diagrams {
1365 if let Some(ref value) = directive.value {
1366 let def = chordsketch_core::ast::ChordDefinition::parse_value(value);
1367 if let Some(ref keys_raw) = def.keys {
1369 let keys_u8: Vec<u8> = keys_raw
1370 .iter()
1371 .filter_map(|&k| {
1372 if (0i32..=127).contains(&k) {
1373 Some(k as u8)
1374 } else {
1375 None
1376 }
1377 })
1378 .collect();
1379 if !keys_u8.is_empty() {
1380 let root = keys_u8[0];
1381 let voicing = chordsketch_core::chord_diagram::KeyboardVoicing {
1382 name: def.name.clone(),
1383 display_name: def.display.clone(),
1384 keys: keys_u8,
1385 root_key: root,
1386 };
1387 html.push_str("<div class=\"chord-diagram-container\">");
1388 html.push_str(&chordsketch_core::chord_diagram::render_keyboard_svg(
1389 &voicing,
1390 ));
1391 html.push_str("</div>\n");
1392 }
1393 } else if let Some(ref raw) = def.raw {
1394 if let Some(mut diagram) =
1396 chordsketch_core::chord_diagram::DiagramData::from_raw_infer_frets(
1397 &def.name,
1398 raw,
1399 diagram_frets,
1400 )
1401 {
1402 diagram.display_name = def.display.clone();
1403 html.push_str("<div class=\"chord-diagram-container\">");
1404 html.push_str(&chordsketch_core::chord_diagram::render_svg(&diagram));
1405 html.push_str("</div>\n");
1406 }
1407 }
1408 }
1409 }
1410 }
1411 _ => {}
1412 }
1413}
1414
1415#[cfg(not(target_arch = "wasm32"))]
1421fn render_abc_with_fallback(
1422 abc_content: &str,
1423 label: &Option<String>,
1424 html: &mut String,
1425 warnings: &mut Vec<String>,
1426) {
1427 match chordsketch_core::external_tool::invoke_abc2svg(abc_content) {
1428 Ok(svg_fragment) => {
1429 render_section_open("abc", "ABC", label, html);
1430 html.push_str(&sanitize_svg_content(&svg_fragment));
1431 html.push('\n');
1432 html.push_str("</section>\n");
1433 }
1434 Err(e) => {
1435 warnings.push(format!("abc2svg invocation failed: {e}"));
1436 render_section_open("abc", "ABC", label, html);
1437 html.push_str("<pre>");
1438 html.push_str(&escape(abc_content));
1439 html.push_str("</pre>\n");
1440 html.push_str("</section>\n");
1441 }
1442 }
1443}
1444
1445#[cfg(target_arch = "wasm32")]
1449fn render_abc_with_fallback(
1450 abc_content: &str,
1451 label: &Option<String>,
1452 html: &mut String,
1453 _warnings: &mut Vec<String>,
1454) {
1455 render_section_open("abc", "ABC", label, html);
1456 html.push_str("<pre>");
1457 html.push_str(&escape(abc_content));
1458 html.push_str("</pre>\n");
1459 html.push_str("</section>\n");
1460}
1461
1462fn is_safe_image_src(src: &str) -> bool {
1470 if src.is_empty() {
1471 return false;
1472 }
1473
1474 if src.contains('\0') {
1476 return false;
1477 }
1478
1479 let normalised = src.trim_start().to_ascii_lowercase();
1482
1483 if normalised.starts_with('/') {
1486 return false;
1487 }
1488
1489 if is_windows_absolute(src.trim_start()) {
1491 return false;
1492 }
1493
1494 if has_traversal(src) {
1496 return false;
1497 }
1498
1499 if let Some(colon_pos) = normalised.find(':') {
1502 let before_colon = &normalised[..colon_pos];
1503 if !before_colon.contains('/') {
1505 return before_colon == "http" || before_colon == "https";
1506 }
1507 }
1508
1509 true
1510}
1511
1512use chordsketch_core::image_path::{has_traversal, is_windows_absolute};
1514
1515#[cfg(not(target_arch = "wasm32"))]
1521fn render_ly_with_fallback(
1522 ly_content: &str,
1523 label: &Option<String>,
1524 html: &mut String,
1525 warnings: &mut Vec<String>,
1526) {
1527 match chordsketch_core::external_tool::invoke_lilypond(ly_content) {
1528 Ok(svg) => {
1529 render_section_open("ly", "Lilypond", label, html);
1530 html.push_str(&sanitize_svg_content(&svg));
1531 html.push('\n');
1532 html.push_str("</section>\n");
1533 }
1534 Err(e) => {
1535 warnings.push(format!("lilypond invocation failed: {e}"));
1536 render_section_open("ly", "Lilypond", label, html);
1537 html.push_str("<pre>");
1538 html.push_str(&escape(ly_content));
1539 html.push_str("</pre>\n");
1540 html.push_str("</section>\n");
1541 }
1542 }
1543}
1544
1545#[cfg(target_arch = "wasm32")]
1549fn render_ly_with_fallback(
1550 ly_content: &str,
1551 label: &Option<String>,
1552 html: &mut String,
1553 _warnings: &mut Vec<String>,
1554) {
1555 render_section_open("ly", "Lilypond", label, html);
1556 html.push_str("<pre>");
1557 html.push_str(&escape(ly_content));
1558 html.push_str("</pre>\n");
1559 html.push_str("</section>\n");
1560}
1561
1562#[cfg(not(target_arch = "wasm32"))]
1568fn render_musicxml_with_fallback(
1569 musicxml_content: &str,
1570 label: &Option<String>,
1571 html: &mut String,
1572 warnings: &mut Vec<String>,
1573) {
1574 match chordsketch_core::external_tool::invoke_musescore(musicxml_content) {
1575 Ok(svg) => {
1576 render_section_open("musicxml", "MusicXML", label, html);
1577 html.push_str(&sanitize_svg_content(&svg));
1578 html.push('\n');
1579 html.push_str("</section>\n");
1580 }
1581 Err(e) => {
1582 warnings.push(format!("musescore invocation failed: {e}"));
1583 render_section_open("musicxml", "MusicXML", label, html);
1584 html.push_str("<pre>");
1585 html.push_str(&escape(musicxml_content));
1586 html.push_str("</pre>\n");
1587 html.push_str("</section>\n");
1588 }
1589 }
1590}
1591
1592#[cfg(target_arch = "wasm32")]
1596fn render_musicxml_with_fallback(
1597 musicxml_content: &str,
1598 label: &Option<String>,
1599 html: &mut String,
1600 _warnings: &mut Vec<String>,
1601) {
1602 render_section_open("musicxml", "MusicXML", label, html);
1603 html.push_str("<pre>");
1604 html.push_str(&escape(musicxml_content));
1605 html.push_str("</pre>\n");
1606 html.push_str("</section>\n");
1607}
1608
1609fn render_image(attrs: &chordsketch_core::ast::ImageAttributes, html: &mut String) {
1618 if !is_safe_image_src(&attrs.src) {
1619 return;
1620 }
1621
1622 let mut style = String::new();
1623 let mut img_attrs = format!("src=\"{}\"", escape(&attrs.src));
1624
1625 if let Some(ref title) = attrs.title {
1626 let _ = write!(img_attrs, " alt=\"{}\"", escape(title));
1627 }
1628
1629 if let Some(ref width) = attrs.width {
1630 let _ = write!(img_attrs, " width=\"{}\"", escape(width));
1631 }
1632 if let Some(ref height) = attrs.height {
1633 let _ = write!(img_attrs, " height=\"{}\"", escape(height));
1634 }
1635 if let Some(ref scale) = attrs.scale {
1636 let _ = write!(
1638 style,
1639 "transform: scale({});transform-origin: top left;",
1640 sanitize_css_value(scale)
1641 );
1642 }
1643
1644 let align_css = match attrs.anchor.as_deref() {
1646 Some("column") | Some("paper") => "text-align: center;",
1647 _ => "",
1648 };
1649
1650 if !align_css.is_empty() {
1651 let _ = write!(html, "<div style=\"{align_css}\">");
1652 } else {
1653 html.push_str("<div>");
1654 }
1655
1656 let _ = write!(html, "<img {img_attrs}");
1657 if !style.is_empty() {
1658 let _ = write!(html, " style=\"{}\"", escape(&style));
1664 }
1665 html.push_str("></div>\n");
1666}
1667
1668fn render_section_open(class: &str, label: &str, value: &Option<String>, html: &mut String) {
1670 let safe_class = sanitize_css_class(class);
1671 let _ = writeln!(html, "<section class=\"{safe_class}\">");
1672 let display_label = match value {
1673 Some(v) if !v.is_empty() => format!("{label}: {}", escape(v)),
1674 _ => label.to_string(),
1675 };
1676 let _ = writeln!(html, "<div class=\"section-label\">{display_label}</div>");
1677}
1678
1679fn render_chorus_recall(
1685 value: &Option<String>,
1686 chorus_body: &[Line],
1687 transpose_offset: i8,
1688 fmt_state: &FormattingState,
1689 show_diagrams: bool,
1690 diagram_frets: usize,
1691 html: &mut String,
1692) {
1693 html.push_str("<div class=\"chorus-recall\">\n");
1694 let display_label = match value {
1695 Some(v) if !v.is_empty() => format!("Chorus: {}", escape(v)),
1696 _ => "Chorus".to_string(),
1697 };
1698 let _ = writeln!(html, "<div class=\"section-label\">{display_label}</div>");
1699 let mut local_fmt = fmt_state.clone();
1703 for line in chorus_body {
1704 match line {
1705 Line::Lyrics(lyrics) => render_lyrics(lyrics, transpose_offset, &local_fmt, html),
1706 Line::Comment(style, text) => render_comment(*style, text, html),
1707 Line::Empty => html.push_str("<div class=\"empty-line\"></div>\n"),
1708 Line::Directive(d) if d.kind.is_font_size_color() => {
1709 local_fmt.apply(&d.kind, &d.value);
1710 }
1711 Line::Directive(d) if !d.kind.is_metadata() => {
1712 render_directive_inner(d, show_diagrams, diagram_frets, html);
1713 }
1714 _ => {}
1715 }
1716 }
1717 html.push_str("</div>\n");
1718}
1719
1720fn render_comment(style: CommentStyle, text: &str, html: &mut String) {
1726 match style {
1727 CommentStyle::Normal => {
1728 let _ = writeln!(html, "<p class=\"comment\">{}</p>", escape(text));
1729 }
1730 CommentStyle::Italic => {
1731 let _ = writeln!(html, "<p class=\"comment\"><em>{}</em></p>", escape(text));
1732 }
1733 CommentStyle::Boxed => {
1734 let _ = writeln!(html, "<div class=\"comment-box\">{}</div>", escape(text));
1735 }
1736 }
1737}
1738
1739#[cfg(test)]
1744mod sanitize_tag_attrs_tests {
1745 use super::*;
1746
1747 #[test]
1748 fn test_preserves_normal_attrs() {
1749 let tag = "<svg width=\"100\" height=\"50\">";
1750 assert_eq!(sanitize_tag_attrs(tag), tag);
1751 }
1752
1753 #[test]
1754 fn test_strips_event_handler() {
1755 let tag = "<svg onclick=\"alert(1)\" width=\"100\">";
1756 let result = sanitize_tag_attrs(tag);
1757 assert!(!result.contains("onclick"));
1758 assert!(result.contains("width"));
1759 }
1760
1761 #[test]
1762 fn test_non_ascii_in_attr_value_preserved() {
1763 let tag = "<text title=\"日本語テスト\" x=\"10\">";
1764 let result = sanitize_tag_attrs(tag);
1765 assert!(result.contains("日本語テスト"));
1766 assert!(result.contains("x=\"10\""));
1767 }
1768
1769 #[test]
1772 fn test_strips_mixed_case_event_handler() {
1773 let tag = "<svg OnClick=\"alert(1)\" width=\"100\">";
1774 let result = sanitize_tag_attrs(tag);
1775 assert!(!result.contains("OnClick"));
1776 assert!(result.contains("width"));
1777 }
1778
1779 #[test]
1780 fn test_strips_uppercase_event_handler() {
1781 let tag = "<svg ONLOAD=\"alert(1)\">";
1782 let result = sanitize_tag_attrs(tag);
1783 assert!(!result.contains("ONLOAD"));
1784 }
1785
1786 #[test]
1789 fn test_strips_style_with_url() {
1790 let tag =
1791 "<rect style=\"background-image: url('https://attacker.com/exfil')\" width=\"10\">";
1792 let result = sanitize_tag_attrs(tag);
1793 assert!(!result.contains("style"));
1794 assert!(result.contains("width"));
1795 }
1796
1797 #[test]
1798 fn test_strips_style_with_expression() {
1799 let tag = "<rect style=\"width: expression(alert(1))\">";
1800 let result = sanitize_tag_attrs(tag);
1801 assert!(!result.contains("style"));
1802 }
1803
1804 #[test]
1805 fn test_strips_style_with_import() {
1806 let tag = "<rect style=\"@import url(evil.css)\">";
1807 let result = sanitize_tag_attrs(tag);
1808 assert!(!result.contains("style"));
1809 }
1810
1811 #[test]
1812 fn test_preserves_safe_style() {
1813 let tag = "<rect style=\"fill: red; stroke: blue\" width=\"10\">";
1814 let result = sanitize_tag_attrs(tag);
1815 assert!(result.contains("style"));
1816 assert!(result.contains("fill: red"));
1817 }
1818}
1819
1820#[cfg(test)]
1821mod tests {
1822 use super::*;
1823
1824 #[test]
1825 fn test_render_empty() {
1826 let song = chordsketch_core::parse("").unwrap();
1827 let html = render_song(&song);
1828 assert!(html.contains("<!DOCTYPE html>"));
1829 assert!(html.contains("</html>"));
1830 }
1831
1832 #[test]
1833 fn test_render_title() {
1834 let html = render("{title: My Song}");
1835 assert!(html.contains("<h1>My Song</h1>"));
1836 assert!(html.contains("<title>My Song</title>"));
1837 }
1838
1839 #[test]
1840 fn test_render_subtitle() {
1841 let html = render("{title: Song}\n{subtitle: By Someone}");
1842 assert!(html.contains("<h2>By Someone</h2>"));
1843 }
1844
1845 #[test]
1846 fn test_render_lyrics_with_chords() {
1847 let html = render("[Am]Hello [G]world");
1848 assert!(html.contains("chord-block"));
1849 assert!(html.contains("<span class=\"chord\">Am</span>"));
1850 assert!(html.contains("<span class=\"lyrics\">Hello </span>"));
1851 assert!(html.contains("<span class=\"chord\">G</span>"));
1852 }
1853
1854 #[test]
1855 fn test_render_lyrics_no_chords() {
1856 let html = render("Just plain text");
1857 assert!(html.contains("<span class=\"lyrics\">Just plain text</span>"));
1858 assert!(!html.contains("class=\"chord\""));
1860 }
1861
1862 #[test]
1863 fn test_render_chorus_section() {
1864 let html = render("{start_of_chorus}\n[G]La la\n{end_of_chorus}");
1865 assert!(html.contains("<section class=\"chorus\">"));
1866 assert!(html.contains("</section>"));
1867 assert!(html.contains("Chorus"));
1868 }
1869
1870 #[test]
1871 fn test_render_verse_with_label() {
1872 let html = render("{start_of_verse: Verse 1}\nLyrics\n{end_of_verse}");
1873 assert!(html.contains("<section class=\"verse\">"));
1874 assert!(html.contains("Verse: Verse 1"));
1875 }
1876
1877 #[test]
1878 fn test_render_comment() {
1879 let html = render("{comment: A note}");
1880 assert!(html.contains("<p class=\"comment\">A note</p>"));
1881 }
1882
1883 #[test]
1884 fn test_render_comment_italic() {
1885 let html = render("{comment_italic: Softly}");
1886 assert!(html.contains("<em>Softly</em>"));
1887 }
1888
1889 #[test]
1890 fn test_render_comment_box() {
1891 let html = render("{comment_box: Important}");
1892 assert!(html.contains("<div class=\"comment-box\">Important</div>"));
1893 }
1894
1895 #[test]
1896 fn test_html_escaping() {
1897 let html = render("{title: Tom & Jerry <3}");
1898 assert!(html.contains("Tom & Jerry <3"));
1899 }
1900
1901 #[test]
1902 fn test_try_render_success() {
1903 let result = try_render("{title: Test}");
1904 assert!(result.is_ok());
1905 }
1906
1907 #[test]
1908 fn test_try_render_error() {
1909 let result = try_render("{unclosed");
1910 assert!(result.is_err());
1911 }
1912
1913 #[test]
1914 fn test_render_valid_html_structure() {
1915 let html = render("{title: Test}\n\n{start_of_verse}\n[G]Hello [C]world\n{end_of_verse}");
1916 assert!(html.starts_with("<!DOCTYPE html>"));
1917 assert!(html.contains("<html"));
1918 assert!(html.contains("<head>"));
1919 assert!(html.contains("<style>"));
1920 assert!(html.contains("<body>"));
1921 assert!(html.contains("</html>"));
1922 }
1923
1924 #[test]
1925 fn test_text_before_first_chord() {
1926 let html = render("Hello [Am]world");
1927 assert!(html.contains("<span class=\"chord\"></span><span class=\"lyrics\">Hello </span>"));
1929 }
1930
1931 #[test]
1932 fn test_empty_line() {
1933 let html = render("Line one\n\nLine two");
1934 assert!(html.contains("empty-line"));
1935 }
1936
1937 #[test]
1938 fn test_render_grid_section() {
1939 let html = render("{start_of_grid}\n| Am . | C . |\n{end_of_grid}");
1940 assert!(html.contains("<section class=\"grid\">"));
1941 assert!(html.contains("Grid"));
1942 assert!(html.contains("</section>"));
1943 }
1944
1945 #[test]
1948 fn test_render_custom_section_intro() {
1949 let html = render("{start_of_intro}\n[Am]Da da\n{end_of_intro}");
1950 assert!(html.contains("<section class=\"section-intro\">"));
1951 assert!(html.contains("Intro"));
1952 assert!(html.contains("</section>"));
1953 }
1954
1955 #[test]
1956 fn test_render_grid_section_with_label() {
1957 let html = render("{start_of_grid: Intro}\n| Am |\n{end_of_grid}");
1958 assert!(html.contains("<section class=\"grid\">"));
1959 assert!(html.contains("Grid: Intro"));
1960 }
1961
1962 #[test]
1963 fn test_render_grid_short_alias() {
1964 let html = render("{sog}\n| G . |\n{eog}");
1965 assert!(html.contains("<section class=\"grid\">"));
1966 assert!(html.contains("</section>"));
1967 }
1968
1969 #[test]
1970 fn test_render_custom_section_with_label() {
1971 let html = render("{start_of_intro: Guitar}\nNotes\n{end_of_intro}");
1972 assert!(html.contains("<section class=\"section-intro\">"));
1973 assert!(html.contains("Intro: Guitar"));
1974 }
1975
1976 #[test]
1977 fn test_render_custom_section_outro() {
1978 let html = render("{start_of_outro}\nFinal\n{end_of_outro}");
1979 assert!(html.contains("<section class=\"section-outro\">"));
1980 assert!(html.contains("Outro"));
1981 }
1982
1983 #[test]
1984 fn test_render_custom_section_solo() {
1985 let html = render("{start_of_solo}\n[Em]Solo\n{end_of_solo}");
1986 assert!(html.contains("<section class=\"section-solo\">"));
1987 assert!(html.contains("Solo"));
1988 assert!(html.contains("</section>"));
1989 }
1990
1991 #[test]
1992 fn test_custom_section_name_escaped() {
1993 let html = render(
1994 "{start_of_x<script>alert(1)</script>}\ntext\n{end_of_x<script>alert(1)</script>}",
1995 );
1996 assert!(!html.contains("<script>"));
1997 assert!(html.contains("<script>"));
1998 }
1999
2000 #[test]
2001 fn test_custom_section_name_quotes_escaped() {
2002 let html =
2003 render("{start_of_x\" onclick=\"alert(1)}\ntext\n{end_of_x\" onclick=\"alert(1)}");
2004 assert!(html.contains("""));
2006 assert!(!html.contains("class=\"section-x\""));
2007 }
2008
2009 #[test]
2010 fn test_custom_section_name_single_quotes_escaped() {
2011 let html = render("{start_of_x' onclick='alert(1)}\ntext\n{end_of_x' onclick='alert(1)}");
2012 assert!(html.contains("'") || html.contains("'"));
2015 assert!(!html.contains("onclick='alert"));
2016 }
2017
2018 #[test]
2019 fn test_custom_section_name_space_sanitized_in_class() {
2020 let html = render("{start_of_foo bar}\ntext\n{end_of_foo bar}");
2022 assert!(html.contains("section-foo-bar"));
2024 assert!(!html.contains("class=\"section-foo bar\""));
2025 }
2026
2027 #[test]
2028 fn test_custom_section_name_special_chars_sanitized_in_class() {
2029 let html = render("{start_of_a&b<c>d}\ntext\n{end_of_a&b<c>d}");
2030 assert!(html.contains("section-a-b-c-d"));
2032 assert!(html.contains("&"));
2034 }
2035
2036 #[test]
2037 fn test_custom_section_capitalize_before_escape() {
2038 let html = render("{start_of_&test}\ntext\n{end_of_&test}");
2042 assert!(html.contains("&test"));
2045 assert!(!html.contains("&Amp;"));
2046 }
2047
2048 #[test]
2049 fn test_define_display_name_in_html_output() {
2050 let html = render("{define: Am base-fret 1 frets x 0 2 2 1 0 display=\"A minor\"}");
2051 assert!(
2052 html.contains("A minor"),
2053 "display name should appear in rendered HTML output"
2054 );
2055 }
2056}
2057
2058#[cfg(test)]
2059mod transpose_tests {
2060 use super::*;
2061
2062 #[test]
2063 fn test_transpose_directive_up_2() {
2064 let input = "{transpose: 2}\n[G]Hello [C]world";
2065 let song = chordsketch_core::parse(input).unwrap();
2066 let html = render_song(&song);
2067 assert!(html.contains("<span class=\"chord\">A</span>"));
2069 assert!(html.contains("<span class=\"chord\">D</span>"));
2070 assert!(!html.contains("<span class=\"chord\">G</span>"));
2071 assert!(!html.contains("<span class=\"chord\">C</span>"));
2072 }
2073
2074 #[test]
2075 fn test_transpose_directive_replaces_previous() {
2076 let input = "{transpose: 2}\n[G]First\n{transpose: 0}\n[G]Second";
2077 let song = chordsketch_core::parse(input).unwrap();
2078 let html = render_song(&song);
2079 assert!(html.contains("<span class=\"chord\">A</span>"));
2081 assert!(html.contains("<span class=\"chord\">G</span>"));
2082 }
2083
2084 #[test]
2085 fn test_transpose_directive_with_cli_offset() {
2086 let input = "{transpose: 2}\n[C]Hello";
2087 let song = chordsketch_core::parse(input).unwrap();
2088 let html = render_song_with_transpose(&song, 3, &Config::defaults());
2089 assert!(html.contains("<span class=\"chord\">F</span>"));
2091 }
2092
2093 #[test]
2096 fn test_render_chorus_recall_basic() {
2097 let html = render("{start_of_chorus}\n[G]La la\n{end_of_chorus}\n\n{chorus}");
2098 assert!(html.contains("<div class=\"chorus-recall\">"));
2100 assert!(html.contains("chorus-recall"));
2102 assert!(html.contains("<section class=\"chorus\">"));
2104 }
2105
2106 #[test]
2107 fn test_render_chorus_recall_with_label() {
2108 let html = render("{start_of_chorus}\nSing\n{end_of_chorus}\n{chorus: Repeat}");
2109 assert!(html.contains("Chorus: Repeat"));
2110 assert!(html.contains("chorus-recall"));
2111 }
2112
2113 #[test]
2114 fn test_render_chorus_recall_no_chorus_defined() {
2115 let html = render("{chorus}");
2116 assert!(html.contains("<div class=\"chorus-recall\">"));
2118 assert!(html.contains("Chorus"));
2119 }
2120
2121 #[test]
2122 fn test_render_chorus_recall_content_replayed() {
2123 let html = render("{start_of_chorus}\nChorus text\n{end_of_chorus}\n{chorus}");
2124 let count = html.matches("Chorus text").count();
2126 assert_eq!(count, 2, "chorus content should appear twice");
2127 }
2128
2129 #[test]
2130 fn test_chorus_recall_applies_current_transpose() {
2131 let html = render("{start_of_chorus}\n[G]La la\n{end_of_chorus}\n{transpose: 2}\n{chorus}");
2134 assert!(
2136 html.contains("<span class=\"chord\">G</span>"),
2137 "original chorus should have G"
2138 );
2139 assert!(
2141 html.contains("<span class=\"chord\">A</span>"),
2142 "recalled chorus should have transposed chord A, got:\n{html}"
2143 );
2144 }
2145
2146 #[test]
2147 fn test_chorus_recall_preserves_formatting_directives() {
2148 let html =
2150 render("{start_of_chorus}\n{textsize: 20}\n[Am]Big text\n{end_of_chorus}\n{chorus}");
2151 let recall_start = html.find("chorus-recall").expect("should have recall");
2153 let recall_section = &html[recall_start..];
2154 assert!(
2155 recall_section.contains("font-size"),
2156 "recalled chorus should apply in-chorus formatting directives"
2157 );
2158 }
2159
2160 #[test]
2161 fn test_chorus_formatting_does_not_leak_to_outer_scope() {
2162 let html =
2164 render("{start_of_chorus}\n{textsize: 20}\n[Am]Big\n{end_of_chorus}\n[G]Normal text");
2165 let after_chorus = html
2167 .rfind("Normal text")
2168 .expect("should have post-chorus text");
2169 let line_start = html[..after_chorus].rfind("<div class=\"line\"").unwrap();
2171 let line_end = html[line_start..]
2172 .find("</div>")
2173 .map_or(html.len(), |i| line_start + i + 6);
2174 let post_chorus_line = &html[line_start..line_end];
2175 assert!(
2176 !post_chorus_line.contains("font-size"),
2177 "in-chorus {{textsize}} should not leak to post-chorus content: {post_chorus_line}"
2178 );
2179 }
2180
2181 #[test]
2184 fn test_render_bold_markup() {
2185 let html = render("Hello <b>bold</b> world");
2186 assert!(html.contains("<b>bold</b>"));
2187 assert!(html.contains("Hello "));
2188 assert!(html.contains(" world"));
2189 }
2190
2191 #[test]
2192 fn test_render_italic_markup() {
2193 let html = render("Hello <i>italic</i> text");
2194 assert!(html.contains("<i>italic</i>"));
2195 }
2196
2197 #[test]
2198 fn test_render_highlight_markup() {
2199 let html = render("<highlight>important</highlight>");
2200 assert!(html.contains("<mark>important</mark>"));
2201 }
2202
2203 #[test]
2204 fn test_render_comment_inline_markup() {
2205 let html = render("<comment>note</comment>");
2206 assert!(html.contains("<span class=\"comment\">note</span>"));
2207 }
2208
2209 #[test]
2210 fn test_render_span_with_foreground() {
2211 let html = render(r#"<span foreground="red">red text</span>"#);
2212 assert!(html.contains("color: red;"));
2213 assert!(html.contains("red text"));
2214 }
2215
2216 #[test]
2217 fn test_render_span_with_multiple_attrs() {
2218 let html = render(
2219 r#"<span font_family="Serif" size="14" foreground="blue" weight="bold">styled</span>"#,
2220 );
2221 assert!(html.contains("font-family: Serif;"));
2222 assert!(html.contains("font-size: 14pt;"));
2223 assert!(html.contains("color: blue;"));
2224 assert!(html.contains("font-weight: bold;"));
2225 assert!(html.contains("styled"));
2226 }
2227
2228 #[test]
2229 fn test_span_css_injection_url_prevented() {
2230 let html = render(
2231 r#"<span foreground="red; background-image: url('https://evil.com/')">text</span>"#,
2232 );
2233 assert!(!html.contains("url("));
2235 assert!(!html.contains(";background-image"));
2236 }
2237
2238 #[test]
2239 fn test_span_css_injection_semicolon_stripped() {
2240 let html =
2241 render(r#"<span foreground="red; position: absolute; z-index: 9999">text</span>"#);
2242 assert!(!html.contains(";position"));
2246 assert!(!html.contains("; position"));
2247 assert!(html.contains("color:"));
2248 }
2249
2250 #[test]
2251 fn test_render_nested_markup() {
2252 let html = render("<b><i>bold italic</i></b>");
2253 assert!(html.contains("<b><i>bold italic</i></b>"));
2254 }
2255
2256 #[test]
2257 fn test_render_markup_with_chord() {
2258 let html = render("[Am]Hello <b>bold</b> world");
2259 assert!(html.contains("<b>bold</b>"));
2260 assert!(html.contains("<span class=\"chord\">Am</span>"));
2261 }
2262
2263 #[test]
2264 fn test_render_no_markup_unchanged() {
2265 let html = render("Just plain text");
2266 assert!(!html.contains("<b>"));
2268 assert!(!html.contains("<i>"));
2269 assert!(html.contains("Just plain text"));
2270 }
2271
2272 #[test]
2275 fn test_textfont_directive_applies_css() {
2276 let html = render("{textfont: Courier}\nHello world");
2277 assert!(html.contains("font-family: Courier;"));
2278 }
2279
2280 #[test]
2281 fn test_textsize_directive_applies_css() {
2282 let html = render("{textsize: 14}\nHello world");
2283 assert!(html.contains("font-size: 14pt;"));
2284 }
2285
2286 #[test]
2287 fn test_textcolour_directive_applies_css() {
2288 let html = render("{textcolour: blue}\nHello world");
2289 assert!(html.contains("color: blue;"));
2290 }
2291
2292 #[test]
2293 fn test_chordfont_directive_applies_css() {
2294 let html = render("{chordfont: Monospace}\n[Am]Hello");
2295 assert!(html.contains("font-family: Monospace;"));
2296 }
2297
2298 #[test]
2299 fn test_chordsize_directive_applies_css() {
2300 let html = render("{chordsize: 16}\n[Am]Hello");
2301 assert!(html.contains("font-size: 16pt;"));
2303 }
2304
2305 #[test]
2306 fn test_chordcolour_directive_applies_css() {
2307 let html = render("{chordcolour: green}\n[Am]Hello");
2308 assert!(html.contains("color: green;"));
2309 }
2310
2311 #[test]
2312 fn test_formatting_persists_across_lines() {
2313 let html = render("{textcolour: red}\nLine one\nLine two");
2314 let count = html.matches("color: red;").count();
2316 assert!(
2317 count >= 2,
2318 "formatting should persist: found {count} matches"
2319 );
2320 }
2321
2322 #[test]
2323 fn test_formatting_overridden_by_later_directive() {
2324 let html = render("{textcolour: red}\nRed text\n{textcolour: blue}\nBlue text");
2325 assert!(html.contains("color: red;"));
2326 assert!(html.contains("color: blue;"));
2327 }
2328
2329 #[test]
2330 fn test_no_formatting_no_style_attr() {
2331 let html = render("Plain text");
2332 assert!(!html.contains("<span class=\"lyrics\" style="));
2334 }
2335
2336 #[test]
2337 fn test_formatting_directive_css_injection_prevented() {
2338 let html = render("{textcolour: red; position: fixed; z-index: 9999}\nHello");
2339 assert!(!html.contains(";position"));
2341 assert!(!html.contains("; position"));
2342 assert!(html.contains("color:"));
2343 }
2344
2345 #[test]
2346 fn test_formatting_directive_url_injection_prevented() {
2347 let html = render("{textcolour: red; background-image: url('https://evil.com/')}\nHello");
2348 assert!(!html.contains("url("));
2350 }
2351
2352 #[test]
2355 fn test_columns_directive_generates_css() {
2356 let html = render("{columns: 2}\nLine one\nLine two");
2357 assert!(html.contains("column-count: 2"));
2358 }
2359
2360 #[test]
2361 fn test_columns_reset_to_one() {
2362 let html = render("{columns: 2}\nTwo cols\n{columns: 1}\nOne col");
2363 let count = html.matches("column-count: 2").count();
2365 assert_eq!(count, 1);
2366 assert!(html.contains("One col"));
2367 }
2368
2369 #[test]
2370 fn test_column_break_generates_css() {
2371 let html = render("{columns: 2}\nCol 1\n{column_break}\nCol 2");
2372 assert!(html.contains("break-before: column;"));
2373 }
2374
2375 #[test]
2376 fn test_columns_clamped_to_max() {
2377 let html = render("{columns: 999}\nContent");
2378 assert!(html.contains("column-count: 32"));
2380 }
2381
2382 #[test]
2383 fn test_columns_zero_treated_as_one() {
2384 let html = render("{columns: 0}\nContent");
2385 assert!(!html.contains("column-count"));
2387 }
2388
2389 #[test]
2390 fn test_columns_non_numeric_defaults_to_one() {
2391 let html = render("{columns: abc}\nHello");
2392 assert!(!html.contains("column-count"));
2394 }
2395
2396 #[test]
2397 fn test_new_page_generates_page_break() {
2398 let html = render("Page 1\n{new_page}\nPage 2");
2399 assert!(html.contains("break-before: page;"));
2400 }
2401
2402 #[test]
2403 fn test_new_physical_page_generates_recto_break() {
2404 let html = render("Page 1\n{new_physical_page}\nPage 2");
2405 assert!(
2406 html.contains("break-before: recto;"),
2407 "new_physical_page should use break-before: recto for duplex printing"
2408 );
2409 assert!(
2410 !html.contains("break-before: page;"),
2411 "new_physical_page should not emit generic page break"
2412 );
2413 }
2414
2415 #[test]
2416 fn test_page_control_not_replayed_in_chorus_recall() {
2417 let input = "\
2419{start_of_chorus}\n\
2420{new_page}\n\
2421[G]La la la\n\
2422{end_of_chorus}\n\
2423Verse text\n\
2424{chorus}";
2425 let html = render(input);
2426 assert!(html.contains("break-before: page;"));
2428 let count = html.matches("break-before: page;").count();
2431 assert_eq!(count, 1, "page break must not be replayed in chorus recall");
2432 }
2433
2434 #[test]
2437 fn test_image_basic() {
2438 let html = render("{image: src=photo.jpg}");
2439 assert!(html.contains("<img src=\"photo.jpg\""));
2440 }
2441
2442 #[test]
2443 fn test_image_with_dimensions() {
2444 let html = render("{image: src=photo.jpg width=200 height=100}");
2445 assert!(html.contains("width=\"200\""));
2446 assert!(html.contains("height=\"100\""));
2447 }
2448
2449 #[test]
2450 fn test_image_with_title() {
2451 let html = render("{image: src=photo.jpg title=\"My Photo\"}");
2452 assert!(html.contains("alt=\"My Photo\""));
2453 }
2454
2455 #[test]
2456 fn test_image_with_scale() {
2457 let html = render("{image: src=photo.jpg scale=0.5}");
2458 assert!(html.contains("scale(0.5)"));
2459 }
2460
2461 #[test]
2462 fn test_image_empty_src_skipped() {
2463 let html = render("{image: src=}");
2464 assert!(
2465 !html.contains("<img"),
2466 "empty src should not produce an img element"
2467 );
2468 }
2469
2470 #[test]
2471 fn test_image_javascript_uri_rejected() {
2472 let html = render("{image: src=javascript:alert(1)}");
2473 assert!(!html.contains("<img"), "javascript: URI must be rejected");
2474 }
2475
2476 #[test]
2477 fn test_image_data_uri_rejected() {
2478 let html = render("{image: src=data:text/html,<script>alert(1)</script>}");
2479 assert!(!html.contains("<img"), "data: URI must be rejected");
2480 }
2481
2482 #[test]
2483 fn test_image_vbscript_uri_rejected() {
2484 let html = render("{image: src=vbscript:MsgBox}");
2485 assert!(!html.contains("<img"), "vbscript: URI must be rejected");
2486 }
2487
2488 #[test]
2489 fn test_image_javascript_uri_case_insensitive() {
2490 let html = render("{image: src=JaVaScRiPt:alert(1)}");
2491 assert!(
2492 !html.contains("<img"),
2493 "scheme check must be case-insensitive"
2494 );
2495 }
2496
2497 #[test]
2498 fn test_image_safe_relative_path_allowed() {
2499 let html = render("{image: src=images/photo.jpg}");
2500 assert!(html.contains("<img src=\"images/photo.jpg\""));
2501 }
2502
2503 #[test]
2504 fn test_is_safe_image_src() {
2505 assert!(is_safe_image_src("photo.jpg"));
2507 assert!(is_safe_image_src("images/photo.jpg"));
2508 assert!(is_safe_image_src("path/to:file.jpg")); assert!(is_safe_image_src("http://example.com/photo.jpg"));
2512 assert!(is_safe_image_src("https://example.com/photo.jpg"));
2513 assert!(is_safe_image_src("HTTP://EXAMPLE.COM/PHOTO.JPG"));
2514
2515 assert!(!is_safe_image_src(""));
2517
2518 assert!(!is_safe_image_src("javascript:alert(1)"));
2520 assert!(!is_safe_image_src("JAVASCRIPT:alert(1)"));
2521 assert!(!is_safe_image_src(" javascript:alert(1)"));
2522 assert!(!is_safe_image_src("data:image/png;base64,abc"));
2523 assert!(!is_safe_image_src("vbscript:MsgBox"));
2524
2525 assert!(!is_safe_image_src("file:///etc/passwd"));
2527 assert!(!is_safe_image_src("FILE:///etc/passwd"));
2528 assert!(!is_safe_image_src("blob:https://example.com/uuid"));
2529 assert!(!is_safe_image_src("mhtml:file://C:/page.mhtml"));
2530
2531 assert!(!is_safe_image_src("/etc/passwd"));
2533 assert!(!is_safe_image_src("/home/user/photo.jpg"));
2534
2535 assert!(!is_safe_image_src("photo\0.jpg"));
2537 assert!(!is_safe_image_src("\0"));
2538
2539 assert!(!is_safe_image_src("../photo.jpg"));
2541 assert!(!is_safe_image_src("images/../../etc/passwd"));
2542 assert!(!is_safe_image_src(r"..\photo.jpg"));
2543 assert!(!is_safe_image_src(r"images\..\..\photo.jpg"));
2544
2545 assert!(!is_safe_image_src(r"C:\photo.jpg"));
2547 assert!(!is_safe_image_src(r"D:\Users\photo.jpg"));
2548 assert!(!is_safe_image_src(r"\\server\share\photo.jpg"));
2549 assert!(!is_safe_image_src("C:/photo.jpg"));
2550 }
2551
2552 #[test]
2553 fn test_image_anchor_column_centers() {
2554 let html = render("{image: src=photo.jpg anchor=column}");
2555 assert!(
2556 html.contains("<div style=\"text-align: center;\">"),
2557 "anchor=column should produce centered div"
2558 );
2559 }
2560
2561 #[test]
2562 fn test_image_anchor_paper_centers() {
2563 let html = render("{image: src=photo.jpg anchor=paper}");
2564 assert!(
2565 html.contains("<div style=\"text-align: center;\">"),
2566 "anchor=paper should produce centered div"
2567 );
2568 }
2569
2570 #[test]
2571 fn test_image_anchor_line_no_style() {
2572 let html = render("{image: src=photo.jpg anchor=line}");
2573 assert!(html.contains("<div><img"));
2575 assert!(!html.contains("text-align"));
2576 }
2577
2578 #[test]
2579 fn test_image_no_anchor_no_style() {
2580 let html = render("{image: src=photo.jpg}");
2581 assert!(html.contains("<div><img"));
2583 assert!(!html.contains("text-align"));
2584 }
2585
2586 #[test]
2587 fn test_image_max_width_css_present() {
2588 let html = render("{image: src=photo.jpg}");
2589 assert!(
2590 html.contains("img { max-width: 100%; height: auto; }"),
2591 "CSS should include img max-width rule to prevent overflow"
2592 );
2593 }
2594
2595 #[test]
2596 fn test_chord_diagram_css_rules_present() {
2597 let html = render("{define: Am base-fret 1 frets x 0 2 2 1 0}");
2598 assert!(
2599 html.contains(".chord-diagram-container"),
2600 "CSS should include .chord-diagram-container rule"
2601 );
2602 assert!(
2603 html.contains(".chord-diagram {"),
2604 "CSS should include .chord-diagram rule"
2605 );
2606 }
2607
2608 #[test]
2611 fn test_define_renders_svg_diagram() {
2612 let html = render("{define: Am base-fret 1 frets x 0 2 2 1 0}");
2613 assert!(html.contains("<svg"));
2614 assert!(html.contains("Am"));
2615 assert!(html.contains("chord-diagram"));
2616 }
2617
2618 #[test]
2619 fn test_define_keyboard_renders_keyboard_svg() {
2620 let html = render("{define: Am keys 0 3 7}");
2622 assert!(
2623 html.contains("<svg"),
2624 "keyboard define should produce an SVG"
2625 );
2626 assert!(
2627 html.contains("keyboard-diagram"),
2628 "should use keyboard-diagram CSS class"
2629 );
2630 assert!(html.contains("Am"), "chord name should appear in SVG");
2631 }
2632
2633 #[test]
2634 fn test_define_keyboard_absolute_midi_renders_svg() {
2635 let html = render("{define: Cmaj7 keys 60 64 67 71}");
2637 assert!(html.contains("<svg"));
2638 assert!(html.contains("keyboard-diagram"));
2639 assert!(html.contains("Cmaj7"));
2640 }
2641
2642 #[test]
2643 fn test_diagrams_piano_auto_inject() {
2644 let input = "{diagrams: piano}\n[Am]Hello [C]world";
2645 let html = render(input);
2646 assert!(
2648 html.contains("keyboard-diagram"),
2649 "piano instrument should use keyboard diagrams"
2650 );
2651 assert!(
2652 html.contains("chord-diagrams"),
2653 "diagram section should be present"
2654 );
2655 }
2656
2657 #[test]
2658 fn test_define_ukulele_diagram() {
2659 let html = render("{define: C frets 0 0 0 3}");
2660 assert!(html.contains("<svg"));
2661 assert!(html.contains("chord-diagram"));
2662 assert!(
2664 html.contains("width=\"88\""),
2665 "Expected 4-string SVG width (88)"
2666 );
2667 }
2668
2669 #[test]
2670 fn test_define_banjo_diagram() {
2671 let html = render("{define: G frets 0 0 0 0 0}");
2672 assert!(html.contains("<svg"));
2673 assert!(
2675 html.contains("width=\"104\""),
2676 "Expected 5-string SVG width (104)"
2677 );
2678 }
2679
2680 #[test]
2681 fn test_diagrams_frets_config_controls_svg_height() {
2682 let input = "{define: Am base-fret 1 frets x 0 2 2 1 0}";
2683 let song = chordsketch_core::parse(input).unwrap();
2684 let config = chordsketch_core::config::Config::defaults()
2685 .with_define("diagrams.frets=4")
2686 .unwrap();
2687 let html = render_song_with_transpose(&song, 0, &config);
2688 assert!(
2690 html.contains("height=\"140\""),
2691 "SVG height should reflect diagrams.frets=4 (expected 140)"
2692 );
2693 }
2694
2695 #[test]
2698 fn test_diagrams_off_suppresses_chord_diagrams() {
2699 let html = render("{diagrams: off}\n{define: Am base-fret 1 frets x 0 2 2 1 0}");
2700 assert!(
2701 !html.contains("<svg"),
2702 "chord diagram SVG should be suppressed when diagrams=off"
2703 );
2704 }
2705
2706 #[test]
2707 fn test_diagrams_on_shows_chord_diagrams() {
2708 let html = render("{diagrams: on}\n{define: Am base-fret 1 frets x 0 2 2 1 0}");
2709 assert!(
2710 html.contains("<svg"),
2711 "chord diagram SVG should be shown when diagrams=on"
2712 );
2713 }
2714
2715 #[test]
2716 fn test_diagrams_default_shows_chord_diagrams() {
2717 let html = render("{define: Am base-fret 1 frets x 0 2 2 1 0}");
2718 assert!(
2719 html.contains("<svg"),
2720 "chord diagram SVG should be shown by default"
2721 );
2722 }
2723
2724 #[test]
2725 fn test_diagrams_off_then_on_restores() {
2726 let html = render(
2727 "{diagrams: off}\n{define: Am base-fret 1 frets x 0 2 2 1 0}\n{diagrams: on}\n{define: G base-fret 1 frets 3 2 0 0 0 3}",
2728 );
2729 assert!(!html.contains(">Am<"), "Am diagram should be suppressed");
2731 assert!(html.contains(">G<"), "G diagram should be rendered");
2732 }
2733
2734 #[test]
2735 fn test_diagrams_parsed_as_known_directive() {
2736 let song = chordsketch_core::parse("{diagrams: off}").unwrap();
2737 if let chordsketch_core::ast::Line::Directive(d) = &song.lines[0] {
2738 assert_eq!(
2739 d.kind,
2740 chordsketch_core::ast::DirectiveKind::Diagrams,
2741 "diagrams should parse as DirectiveKind::Diagrams"
2742 );
2743 assert_eq!(d.value, Some("off".to_string()));
2744 } else {
2745 panic!("expected a directive line, got: {:?}", &song.lines[0]);
2746 }
2747 }
2748
2749 #[test]
2752 fn test_diagrams_off_case_insensitive() {
2753 let html = render("{diagrams: Off}\n{define: Am base-fret 1 frets x 0 2 2 1 0}");
2754 assert!(
2755 !html.contains("<svg"),
2756 "diagrams=Off should suppress diagrams (case-insensitive)"
2757 );
2758 }
2759
2760 #[test]
2761 fn test_diagrams_off_uppercase() {
2762 let html = render("{diagrams: OFF}\n{define: Am base-fret 1 frets x 0 2 2 1 0}");
2763 assert!(
2764 !html.contains("<svg"),
2765 "diagrams=OFF should suppress diagrams (case-insensitive)"
2766 );
2767 }
2768
2769 #[test]
2772 fn test_diagrams_auto_inject_from_builtin_db() {
2773 let html = render("{diagrams}\n[Am]Hello [G]World");
2775 assert!(
2776 html.contains("class=\"chord-diagrams\""),
2777 "should render chord-diagrams section"
2778 );
2779 assert!(html.contains(">Am<"), "Am diagram expected");
2781 assert!(html.contains(">G<"), "G diagram expected");
2782 }
2783
2784 #[test]
2785 fn test_diagrams_auto_inject_unknown_chord_skipped() {
2786 let html = render("{diagrams}\n[Xyzzy]Hello");
2788 assert!(
2790 !html.contains("class=\"chord-diagrams\""),
2791 "no diagram section for unknown chord"
2792 );
2793 }
2794
2795 #[test]
2796 fn test_no_diagrams_suppresses_auto_inject() {
2797 let html = render("{no_diagrams}\n[Am]Hello");
2798 assert!(
2799 !html.contains("class=\"chord-diagrams\""),
2800 "{{no_diagrams}} should suppress auto-inject"
2801 );
2802 }
2803
2804 #[test]
2805 fn test_diagrams_define_takes_priority_over_builtin() {
2806 let html = render("{diagrams}\n{define: Am base-fret 1 frets x 0 2 2 1 0}\n[Am]Hello");
2810 assert!(
2812 html.contains("font-weight=\"bold\">Am</text>"),
2813 "Am diagram should appear inline at the {{define}} position"
2814 );
2815 assert!(
2817 !html.contains("class=\"chord-diagrams\""),
2818 "auto-inject section should be absent when all used chords are defined"
2819 );
2820 }
2821
2822 #[test]
2823 fn test_diagrams_off_suppresses_auto_inject() {
2824 let html = render("{diagrams: off}\n[Am]Hello");
2825 assert!(
2826 !html.contains("class=\"chord-diagrams\""),
2827 "{{diagrams: off}} should suppress auto-inject grid"
2828 );
2829 }
2830
2831 #[test]
2832 fn test_diagrams_ukulele_instrument() {
2833 let html = render("{diagrams: ukulele}\n[Am]Hello");
2834 assert!(
2835 html.contains("class=\"chord-diagrams\""),
2836 "ukulele diagrams section expected"
2837 );
2838 assert!(html.contains(">Am<"), "Am diagram expected");
2840 }
2841
2842 #[test]
2843 fn test_diagrams_guitar_explicit_overrides_config_default() {
2844 let song = chordsketch_core::parse("{diagrams: guitar}\n[Am]Hello").unwrap();
2847 let config = chordsketch_core::config::Config::defaults()
2848 .with_define("diagrams.instrument=ukulele")
2849 .unwrap();
2850 let html = render_song_with_transpose(&song, 0, &config);
2851 assert!(
2852 html.contains("class=\"chord-diagrams\""),
2853 "guitar diagrams section expected"
2854 );
2855 assert!(html.contains(">Am<"), "Am diagram expected");
2856 let guitar_am_html = render_song_with_transpose(
2857 &chordsketch_core::parse("{diagrams: guitar}\n[Am]Hello").unwrap(),
2858 0,
2859 &chordsketch_core::config::Config::defaults(),
2860 );
2861 let uke_am_html = render_song_with_transpose(
2862 &chordsketch_core::parse("{diagrams: ukulele}\n[Am]Hello").unwrap(),
2863 0,
2864 &chordsketch_core::config::Config::defaults(),
2865 );
2866 assert_ne!(
2868 guitar_am_html, uke_am_html,
2869 "guitar and ukulele Am diagrams should differ"
2870 );
2871 assert_eq!(
2874 html, guitar_am_html,
2875 "{{diagrams: guitar}} must select guitar regardless of config default"
2876 );
2877 }
2878
2879 #[test]
2880 fn test_no_diagrams_suppresses_inline_define_diagrams() {
2881 let html = render("{no_diagrams}\n{define: Am base-fret 1 frets x 0 2 2 1 0}\n[Am]Hello");
2884 assert!(
2885 !html.contains("<svg"),
2886 "{{no_diagrams}} should suppress inline define diagram SVG"
2887 );
2888 }
2889
2890 #[test]
2891 fn test_define_chord_not_duplicated_in_auto_inject_grid() {
2892 let html =
2896 render("{define: Am base-fret 1 frets x 0 2 2 1 0}\n{diagrams}\n[Am]Hello [G]world\n");
2897 let am_svg_count = html.match_indices("font-weight=\"bold\">Am</text>").count();
2899 assert_eq!(
2900 am_svg_count, 1,
2901 "Am diagram should appear exactly once (inline via {{define}}), not also in auto-inject grid"
2902 );
2903 assert!(
2905 html.contains("font-weight=\"bold\">G</text>"),
2906 "G diagram should appear in the auto-inject grid"
2907 );
2908 }
2909
2910 #[test]
2911 fn test_define_after_nodiagrams_appears_in_grid() {
2912 let html = render(
2916 "{no_diagrams}\n{define: Am base-fret 1 frets x 0 2 2 1 0}\n{diagrams}\n[Am]Hello\n",
2917 );
2918 assert!(
2921 html.contains("class=\"chord-diagrams\""),
2922 "auto-inject grid should appear since Am was not rendered inline"
2923 );
2924 assert!(
2925 html.contains("font-weight=\"bold\">Am</text>"),
2926 "Am should appear in the auto-inject grid"
2927 );
2928 }
2929
2930 #[test]
2931 fn test_enharmonic_define_dedup() {
2932 let html = render("{define: Bb base-fret 1 frets x 1 3 3 3 1}\n{diagrams}\n[A#]Hello\n");
2936 let bb_count = html.match_indices("font-weight=\"bold\">Bb</text>").count();
2938 let as_count = html.match_indices("font-weight=\"bold\">A#</text>").count();
2939 assert_eq!(bb_count, 1, "Bb should appear once (inline)");
2940 assert_eq!(
2941 as_count, 0,
2942 "A# should NOT appear in the auto-inject grid (same chord as Bb)"
2943 );
2944 }
2945
2946 #[test]
2947 fn test_chord_directive_appears_in_auto_inject_grid() {
2948 let html = render("{chord: Am base-fret 1 frets x 0 2 2 1 0}\n{diagrams}\n[Am]Hello\n");
2951 assert!(
2954 html.contains("class=\"chord-diagrams\""),
2955 "auto-inject grid should appear since {{chord}} does not render inline"
2956 );
2957 assert!(
2958 html.contains("font-weight=\"bold\">Am</text>"),
2959 "Am should appear in the auto-inject grid via {{chord}} voicing"
2960 );
2961 }
2962
2963 #[test]
2966 fn test_abc_section_disabled_by_config() {
2967 let input = "{start_of_abc}\nX:1\n{end_of_abc}";
2969 let song = chordsketch_core::parse(input).unwrap();
2970 let config = chordsketch_core::config::Config::defaults()
2971 .with_define("delegates.abc2svg=false")
2972 .unwrap();
2973 let html = render_song_with_transpose(&song, 0, &config);
2974 assert!(html.contains("<section class=\"abc\">"));
2975 assert!(html.contains("ABC"));
2976 assert!(html.contains("</section>"));
2977 }
2978
2979 #[test]
2980 fn test_abc_section_null_config_auto_detect_disabled() {
2981 if chordsketch_core::external_tool::has_abc2svg() {
2984 return; }
2986 let input = "{start_of_abc}\nX:1\n{end_of_abc}";
2987 let song = chordsketch_core::parse(input).unwrap();
2988 let config = chordsketch_core::config::Config::defaults();
2990 assert!(
2991 config.get_path("delegates.abc2svg").is_null(),
2992 "default config should have null delegates.abc2svg"
2993 );
2994 let html = render_song_with_transpose(&song, 0, &config);
2995 assert!(
2996 html.contains("<section class=\"abc\">"),
2997 "null auto-detect with no abc2svg should render as text section"
2998 );
2999 }
3000
3001 #[test]
3002 fn test_abc_section_fallback_preformatted() {
3003 if chordsketch_core::external_tool::has_abc2svg() {
3005 return; }
3007 let input = "{start_of_abc}\nX:1\nT:Test\nK:C\n{end_of_abc}";
3008 let song = chordsketch_core::parse(input).unwrap();
3009 let config = chordsketch_core::config::Config::defaults()
3010 .with_define("delegates.abc2svg=true")
3011 .unwrap();
3012 let html = render_song_with_transpose(&song, 0, &config);
3013 assert!(html.contains("<section class=\"abc\">"));
3014 assert!(html.contains("<pre>"));
3015 assert!(html.contains("X:1"));
3016 assert!(html.contains("</pre>"));
3017 }
3018
3019 #[test]
3020 fn test_abc_section_with_label_delegate_fallback() {
3021 if chordsketch_core::external_tool::has_abc2svg() {
3022 return;
3023 }
3024 let input = "{start_of_abc: Melody}\nX:1\n{end_of_abc}";
3025 let song = chordsketch_core::parse(input).unwrap();
3026 let config = chordsketch_core::config::Config::defaults()
3027 .with_define("delegates.abc2svg=true")
3028 .unwrap();
3029 let html = render_song_with_transpose(&song, 0, &config);
3030 assert!(html.contains("ABC: Melody"));
3031 assert!(html.contains("<pre>"));
3032 }
3033
3034 #[test]
3035 #[ignore]
3036 fn test_abc_section_renders_svg_with_abc2svg() {
3037 let input = "{start_of_abc}\nX:1\nT:Test\nM:4/4\nK:C\nCDEF|GABc|\n{end_of_abc}";
3039 let song = chordsketch_core::parse(input).unwrap();
3040 let config = chordsketch_core::config::Config::defaults()
3041 .with_define("delegates.abc2svg=true")
3042 .unwrap();
3043 let html = render_song_with_transpose(&song, 0, &config);
3044 assert!(html.contains("<section class=\"abc\">"));
3045 assert!(
3046 html.contains("<svg"),
3047 "should contain rendered SVG from abc2svg"
3048 );
3049 assert!(html.contains("</section>"));
3050 }
3051
3052 #[test]
3053 fn test_abc_section_auto_detect_default_config() {
3054 let input = "{start_of_abc}\nX:1\nT:Test\nK:C\n{end_of_abc}";
3058 let song = chordsketch_core::parse(input).unwrap();
3059 let config = chordsketch_core::config::Config::defaults();
3060 let html = render_song_with_transpose(&song, 0, &config);
3061 assert!(
3062 html.contains("<section class=\"abc\">"),
3063 "auto-detect should produce abc section"
3064 );
3065 if !chordsketch_core::external_tool::has_abc2svg() {
3066 assert!(
3067 html.contains("X:1"),
3068 "raw ABC content should be present without tool"
3069 );
3070 assert!(
3071 !html.contains("<svg"),
3072 "no SVG should be generated without abc2svg"
3073 );
3074 }
3075 }
3076
3077 #[test]
3080 fn test_ly_section_auto_detect_default_config() {
3081 let input = "{start_of_ly}\n\\relative c' { c4 }\n{end_of_ly}";
3083 let song = chordsketch_core::parse(input).unwrap();
3084 let config = chordsketch_core::config::Config::defaults();
3085 let html = render_song_with_transpose(&song, 0, &config);
3086 assert!(
3087 html.contains("<section class=\"ly\">"),
3088 "auto-detect should produce ly section"
3089 );
3090 if !chordsketch_core::external_tool::has_lilypond() {
3091 assert!(
3092 html.contains("\\relative"),
3093 "raw Lilypond content should be present without tool"
3094 );
3095 assert!(
3096 !html.contains("<svg"),
3097 "no SVG should be generated without lilypond"
3098 );
3099 }
3100 }
3101
3102 #[test]
3103 fn test_ly_section_disabled_by_config() {
3104 let input = "{start_of_ly}\n\\relative c' { c4 }\n{end_of_ly}";
3106 let song = chordsketch_core::parse(input).unwrap();
3107 let config = chordsketch_core::config::Config::defaults()
3108 .with_define("delegates.lilypond=false")
3109 .unwrap();
3110 let html = render_song_with_transpose(&song, 0, &config);
3111 assert!(html.contains("<section class=\"ly\">"));
3112 assert!(html.contains("Lilypond"));
3113 assert!(html.contains("</section>"));
3114 }
3115
3116 #[test]
3117 fn test_ly_section_fallback_preformatted() {
3118 if chordsketch_core::external_tool::has_lilypond() {
3119 return;
3120 }
3121 let input = "{start_of_ly}\n\\relative c' { c4 }\n{end_of_ly}";
3122 let song = chordsketch_core::parse(input).unwrap();
3123 let config = chordsketch_core::config::Config::defaults()
3124 .with_define("delegates.lilypond=true")
3125 .unwrap();
3126 let html = render_song_with_transpose(&song, 0, &config);
3127 assert!(html.contains("<section class=\"ly\">"));
3128 assert!(html.contains("<pre>"));
3129 assert!(html.contains("</pre>"));
3130 }
3131
3132 #[test]
3133 #[ignore]
3134 fn test_ly_section_renders_svg_with_lilypond() {
3135 let input = "{start_of_ly}\n\\relative c' { c4 d e f | g2 g | }\n{end_of_ly}";
3137 let song = chordsketch_core::parse(input).unwrap();
3138 let config = chordsketch_core::config::Config::defaults()
3139 .with_define("delegates.lilypond=true")
3140 .unwrap();
3141 let html = render_song_with_transpose(&song, 0, &config);
3142 assert!(html.contains("<section class=\"ly\">"));
3143 assert!(
3144 html.contains("<svg"),
3145 "should contain rendered SVG from lilypond"
3146 );
3147 assert!(html.contains("</section>"));
3148 }
3149}
3150
3151#[cfg(test)]
3152mod delegate_tests {
3153 use super::*;
3154
3155 #[test]
3156 fn test_render_abc_section() {
3157 let html = render("{start_of_abc}\nX:1\n{end_of_abc}");
3158 assert!(html.contains("<section class=\"abc\">"));
3159 assert!(html.contains("ABC"));
3160 assert!(html.contains("</section>"));
3161 }
3162
3163 #[test]
3164 fn test_render_abc_section_with_label() {
3165 let html = render("{start_of_abc: Melody}\nX:1\n{end_of_abc}");
3166 assert!(html.contains("<section class=\"abc\">"));
3167 assert!(html.contains("ABC: Melody"));
3168 }
3169
3170 #[test]
3171 fn test_render_ly_section() {
3172 let html = render("{start_of_ly}\nnotes\n{end_of_ly}");
3173 assert!(html.contains("<section class=\"ly\">"));
3174 assert!(html.contains("Lilypond"));
3175 assert!(html.contains("</section>"));
3176 }
3177
3178 #[test]
3181 fn test_render_musicxml_section_disabled() {
3182 let input = "{start_of_musicxml}\n<score-partwise/>\n{end_of_musicxml}";
3184 let song = chordsketch_core::parse(input).unwrap();
3185 let config = chordsketch_core::config::Config::defaults()
3186 .with_define("delegates.musescore=false")
3187 .unwrap();
3188 let html = render_song_with_transpose(&song, 0, &config);
3189 assert!(
3190 html.contains("<section class=\"musicxml\">"),
3191 "fallback section should render when musescore is disabled: {html}"
3192 );
3193 assert!(html.contains("MusicXML"), "section label should appear");
3194 assert!(html.contains("</section>"), "section should be closed");
3195 }
3196
3197 #[test]
3198 fn test_render_musicxml_section_no_musescore_installed() {
3199 if chordsketch_core::external_tool::has_musescore() {
3202 return; }
3204
3205 let input = "{start_of_musicxml}\n<score-partwise/>\n{end_of_musicxml}";
3206 let song = chordsketch_core::parse(input).unwrap();
3207 let config = chordsketch_core::config::Config::defaults();
3208 assert!(
3209 config.get_path("delegates.musescore").is_null(),
3210 "default config should have null delegates.musescore"
3211 );
3212 let html = render_song_with_transpose(&song, 0, &config);
3213 assert!(
3214 html.contains("<section class=\"musicxml\">"),
3215 "null auto-detect with no musescore should render as text section"
3216 );
3217 }
3218
3219 #[test]
3220 fn test_render_musicxml_section_with_label() {
3221 let input = "{start_of_musicxml: Score}\n<score-partwise/>\n{end_of_musicxml}";
3222 let song = chordsketch_core::parse(input).unwrap();
3223 let config = chordsketch_core::config::Config::defaults()
3224 .with_define("delegates.musescore=false")
3225 .unwrap();
3226 let html = render_song_with_transpose(&song, 0, &config);
3227 assert!(
3228 html.contains("Score"),
3229 "label should appear in section header"
3230 );
3231 }
3232
3233 #[test]
3234 fn test_abc_fallback_sanitizes_would_be_script_in_svg() {
3235 let malicious_svg = "<svg><script>alert(1)</script><circle r=\"5\"/></svg>";
3239 let sanitized = sanitize_svg_content(malicious_svg);
3240 assert!(
3241 !sanitized.contains("<script>"),
3242 "script tags must be stripped from delegate SVG output"
3243 );
3244 assert!(sanitized.contains("<circle"));
3245 }
3246
3247 #[test]
3248 fn test_sanitize_svg_strips_event_handlers_from_delegate_output() {
3249 let svg_with_handler = "<svg><rect onmouseover=\"alert(1)\" width=\"10\"/></svg>";
3250 let sanitized = sanitize_svg_content(svg_with_handler);
3251 assert!(
3252 !sanitized.contains("onmouseover"),
3253 "event handlers must be stripped from delegate SVG output"
3254 );
3255 assert!(sanitized.contains("<rect"));
3256 }
3257
3258 #[test]
3259 fn test_sanitize_svg_strips_foreignobject_from_delegate_output() {
3260 let svg = "<svg><foreignObject><body xmlns=\"http://www.w3.org/1999/xhtml\"><script>alert(1)</script></body></foreignObject></svg>";
3261 let sanitized = sanitize_svg_content(svg);
3262 assert!(
3263 !sanitized.contains("<foreignObject"),
3264 "foreignObject must be stripped from delegate SVG output"
3265 );
3266 }
3267
3268 #[test]
3269 fn test_sanitize_svg_strips_math_element() {
3270 let svg = "<svg><math><mi>x</mi></math></svg>";
3271 let sanitized = sanitize_svg_content(svg);
3272 assert!(
3273 !sanitized.contains("<math"),
3274 "math element must be stripped from delegate SVG output"
3275 );
3276 }
3277
3278 #[test]
3279 fn test_render_svg_section() {
3280 let html = render("{start_of_svg}\n<svg/>\n{end_of_svg}");
3281 assert!(html.contains("<div class=\"svg-section\">"));
3283 assert!(html.contains("<svg/>"));
3284 assert!(html.contains("</div>"));
3285 }
3286
3287 #[test]
3288 fn test_render_svg_inline_content() {
3289 let svg = r#"<svg width="100" height="100"><circle cx="50" cy="50" r="40"/></svg>"#;
3290 let input = format!("{{start_of_svg}}\n{svg}\n{{end_of_svg}}");
3291 let html = render(&input);
3292 assert!(html.contains(svg));
3293 }
3294
3295 #[test]
3296 fn test_svg_section_strips_script_tags() {
3297 let input = "{start_of_svg}\n<svg><script>alert('xss')</script><circle r=\"10\"/></svg>\n{end_of_svg}";
3298 let html = render(input);
3299 assert!(!html.contains("<script>"), "script tags must be stripped");
3300 assert!(!html.contains("alert"), "script content must be stripped");
3301 assert!(
3302 html.contains("<circle r=\"10\"/>"),
3303 "safe SVG content must be preserved"
3304 );
3305 }
3306
3307 #[test]
3308 fn test_svg_section_strips_event_handlers() {
3309 let input = "{start_of_svg}\n<svg onload=\"alert(1)\"><rect width=\"10\" onerror=\"hack()\"/></svg>\n{end_of_svg}";
3310 let html = render(input);
3311 assert!(!html.contains("onload"), "onload handler must be stripped");
3312 assert!(
3313 !html.contains("onerror"),
3314 "onerror handler must be stripped"
3315 );
3316 assert!(
3317 html.contains("width=\"10\""),
3318 "safe attributes must be preserved"
3319 );
3320 }
3321
3322 #[test]
3323 fn test_svg_section_preserves_safe_content() {
3324 let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><text x="10" y="20">Hello</text></svg>"#;
3325 let input = format!("{{start_of_svg}}\n{svg}\n{{end_of_svg}}");
3326 let html = render(&input);
3327 assert!(html.contains("xmlns=\"http://www.w3.org/2000/svg\""));
3328 assert!(html.contains("<text x=\"10\" y=\"20\">Hello</text>"));
3329 }
3330
3331 #[test]
3332 fn test_svg_section_strips_case_insensitive_script() {
3333 let input = "{start_of_svg}\n<SCRIPT>alert(1)</SCRIPT><svg/>\n{end_of_svg}";
3334 let html = render(input);
3335 assert!(!html.contains("SCRIPT"), "case-insensitive script removal");
3336 assert!(!html.contains("alert"));
3337 assert!(html.contains("<svg/>"));
3338 }
3339
3340 #[test]
3341 fn test_svg_section_strips_foreignobject() {
3342 let input = "{start_of_svg}\n<svg><foreignObject><body onload=\"alert(1)\"></body></foreignObject><rect width=\"10\"/></svg>\n{end_of_svg}";
3343 let html = render(input);
3344 assert!(
3345 !html.contains("foreignObject"),
3346 "foreignObject must be stripped"
3347 );
3348 assert!(
3349 !html.contains("foreignobject"),
3350 "foreignObject (lowercase) must be stripped"
3351 );
3352 assert!(
3353 html.contains("<rect width=\"10\"/>"),
3354 "safe content must be preserved"
3355 );
3356 }
3357
3358 #[test]
3359 fn test_svg_section_strips_iframe() {
3360 let input = "{start_of_svg}\n<svg><iframe src=\"javascript:alert(1)\"></iframe><circle r=\"5\"/></svg>\n{end_of_svg}";
3361 let html = render(input);
3362 assert!(!html.contains("iframe"), "iframe must be stripped");
3363 assert!(html.contains("<circle r=\"5\"/>"));
3364 }
3365
3366 #[test]
3367 fn test_svg_section_strips_object_and_embed() {
3368 let input = "{start_of_svg}\n<svg><object data=\"evil.swf\"></object><embed src=\"evil.swf\"></embed><rect/></svg>\n{end_of_svg}";
3369 let html = render(input);
3370 assert!(!html.contains("object"), "object must be stripped");
3371 assert!(!html.contains("embed"), "embed must be stripped");
3372 assert!(html.contains("<rect/>"));
3373 }
3374
3375 #[test]
3376 fn test_svg_section_strips_javascript_uri_in_href() {
3377 let input = "{start_of_svg}\n<svg><a href=\"javascript:alert(1)\"><text>Click</text></a></svg>\n{end_of_svg}";
3378 let html = render(input);
3379 assert!(
3380 !html.contains("javascript:"),
3381 "javascript: URI must be stripped from href"
3382 );
3383 assert!(html.contains("<text>Click</text>"));
3384 }
3385
3386 #[test]
3387 fn test_svg_section_strips_vbscript_uri() {
3388 let input = "{start_of_svg}\n<svg><a href=\"vbscript:MsgBox\"><text>Click</text></a></svg>\n{end_of_svg}";
3389 let html = render(input);
3390 assert!(
3391 !html.contains("vbscript:"),
3392 "vbscript: URI must be stripped"
3393 );
3394 }
3395
3396 #[test]
3397 fn test_svg_section_strips_data_uri_in_use() {
3398 let input = "{start_of_svg}\n<svg><use href=\"data:image/svg+xml;base64,PHN2Zy8+\"/></svg>\n{end_of_svg}";
3399 let html = render(input);
3400 assert!(
3401 !html.contains("data:"),
3402 "data: URI must be stripped from use href"
3403 );
3404 }
3405
3406 #[test]
3407 fn test_svg_section_strips_javascript_uri_case_insensitive() {
3408 let input = "{start_of_svg}\n<svg><a href=\"JaVaScRiPt:alert(1)\"><text>X</text></a></svg>\n{end_of_svg}";
3409 let html = render(input);
3410 assert!(
3411 !html.to_lowercase().contains("javascript:"),
3412 "case-insensitive javascript: URI must be stripped"
3413 );
3414 }
3415
3416 #[test]
3417 fn test_svg_section_strips_xlink_href_dangerous_uri() {
3418 let input =
3419 "{start_of_svg}\n<svg><use xlink:href=\"javascript:alert(1)\"/></svg>\n{end_of_svg}";
3420 let html = render(input);
3421 assert!(
3422 !html.contains("javascript:"),
3423 "javascript: URI in xlink:href must be stripped"
3424 );
3425 }
3426
3427 #[test]
3428 fn test_svg_section_preserves_safe_href() {
3429 let input = "{start_of_svg}\n<svg><a href=\"https://example.com\"><text>Link</text></a></svg>\n{end_of_svg}";
3430 let html = render(input);
3431 assert!(
3432 html.contains("href=\"https://example.com\""),
3433 "safe https: href must be preserved"
3434 );
3435 }
3436
3437 #[test]
3438 fn test_svg_section_preserves_fragment_href() {
3439 let input = "{start_of_svg}\n<svg><use href=\"#myShape\"/></svg>\n{end_of_svg}";
3440 let html = render(input);
3441 assert!(
3442 html.contains("href=\"#myShape\""),
3443 "fragment-only href must be preserved"
3444 );
3445 }
3446
3447 #[test]
3448 fn test_render_textblock_section() {
3449 let html = render("{start_of_textblock}\nPreformatted\n{end_of_textblock}");
3450 assert!(html.contains("<section class=\"textblock\">"));
3451 assert!(html.contains("Textblock"));
3452 assert!(html.contains("</section>"));
3453 }
3454
3455 #[test]
3458 fn test_render_songs_single() {
3459 let songs = chordsketch_core::parse_multi("{title: Only}").unwrap();
3460 let html = render_songs(&songs);
3461 assert_eq!(html, render_song(&songs[0]));
3463 }
3464
3465 #[test]
3466 fn test_render_songs_two_songs_with_hr_separator() {
3467 let songs = chordsketch_core::parse_multi(
3468 "{title: Song A}\n[Am]Hello\n{new_song}\n{title: Song B}\n[G]World",
3469 )
3470 .unwrap();
3471 let html = render_songs(&songs);
3472 assert!(html.contains("<title>Song A</title>"));
3474 assert!(html.contains("<h1>Song A</h1>"));
3476 assert!(html.contains("<h1>Song B</h1>"));
3477 assert!(html.contains("<hr class=\"song-separator\">"));
3479 assert_eq!(html.matches("<div class=\"song\">").count(), 2);
3481 assert_eq!(html.matches("<!DOCTYPE html>").count(), 1);
3483 assert_eq!(html.matches("</html>").count(), 1);
3484 }
3485
3486 #[test]
3487 fn test_image_scale_css_injection_prevented() {
3488 let html = render("{image: src=photo.jpg scale=0.5); position: fixed; z-index: 9999}");
3491 assert!(!html.contains("position"));
3492 assert!(!html.contains("z-index"));
3493 assert!(!html.contains("position: fixed"));
3495 }
3496
3497 #[test]
3498 fn test_render_songs_with_transpose() {
3499 let songs =
3500 chordsketch_core::parse_multi("{title: S1}\n[C]Do\n{new_song}\n{title: S2}\n[G]Re")
3501 .unwrap();
3502 let html = render_songs_with_transpose(&songs, 2, &Config::defaults());
3503 assert!(html.contains(">D<"));
3505 assert!(html.contains(">A<"));
3506 }
3507
3508 #[test]
3511 fn test_sanitize_svg_strips_set_element() {
3512 let svg = r##"<svg><a href="#"><set attributeName="href" to="javascript:alert(1)"/><text>Click</text></a></svg>"##;
3513 let sanitized = sanitize_svg_content(svg);
3514 assert!(
3515 !sanitized.contains("<set"),
3516 "set element must be stripped to prevent SVG animation XSS"
3517 );
3518 assert!(sanitized.contains("<text>Click</text>"));
3519 }
3520
3521 #[test]
3522 fn test_sanitize_svg_strips_animate_element() {
3523 let svg =
3524 r#"<svg><animate attributeName="href" values="javascript:alert(1)"/><rect/></svg>"#;
3525 let sanitized = sanitize_svg_content(svg);
3526 assert!(
3527 !sanitized.contains("<animate"),
3528 "animate element must be stripped"
3529 );
3530 assert!(sanitized.contains("<rect/>"));
3531 }
3532
3533 #[test]
3534 fn test_sanitize_svg_strips_animatetransform() {
3535 let svg =
3536 "<svg><animateTransform attributeName=\"transform\" type=\"rotate\"/><rect/></svg>";
3537 let sanitized = sanitize_svg_content(svg);
3538 assert!(
3539 !sanitized.contains("animateTransform"),
3540 "animateTransform must be stripped"
3541 );
3542 assert!(
3543 !sanitized.contains("animatetransform"),
3544 "animatetransform (lowercase) must be stripped"
3545 );
3546 }
3547
3548 #[test]
3549 fn test_sanitize_svg_strips_animatemotion() {
3550 let svg = "<svg><animateMotion path=\"M0,0 L100,100\"/><rect/></svg>";
3551 let sanitized = sanitize_svg_content(svg);
3552 assert!(
3553 !sanitized.contains("animateMotion"),
3554 "animateMotion must be stripped"
3555 );
3556 }
3557
3558 #[test]
3559 fn test_sanitize_svg_strips_to_attr_with_dangerous_uri() {
3560 let svg = r#"<svg><a to="javascript:alert(1)"><text>X</text></a></svg>"#;
3561 let sanitized = sanitize_svg_content(svg);
3562 assert!(
3563 !sanitized.contains("javascript:"),
3564 "dangerous URI in 'to' attr must be stripped"
3565 );
3566 }
3567
3568 #[test]
3569 fn test_sanitize_svg_strips_values_attr_with_dangerous_uri() {
3570 let svg = r#"<svg><a values="javascript:alert(1)"><text>X</text></a></svg>"#;
3571 let sanitized = sanitize_svg_content(svg);
3572 assert!(
3573 !sanitized.contains("javascript:"),
3574 "dangerous URI in 'values' attr must be stripped"
3575 );
3576 }
3577
3578 #[test]
3581 fn test_strip_dangerous_attrs_preserves_cjk_text() {
3582 let input = "<svg><text x=\"10\">日本語テスト</text></svg>";
3583 let result = strip_dangerous_attrs(input);
3584 assert!(
3585 result.contains("日本語テスト"),
3586 "CJK characters must not be corrupted"
3587 );
3588 }
3589
3590 #[test]
3591 fn test_strip_dangerous_attrs_preserves_emoji() {
3592 let input = "<svg><text>🎵🎸🎹</text></svg>";
3593 let result = strip_dangerous_attrs(input);
3594 assert!(result.contains("🎵🎸🎹"), "emoji must not be corrupted");
3595 }
3596
3597 #[test]
3598 fn test_strip_dangerous_attrs_preserves_accented_chars() {
3599 let input = "<svg><text>café résumé naïve</text></svg>";
3600 let result = strip_dangerous_attrs(input);
3601 assert!(
3602 result.contains("café résumé naïve"),
3603 "accented characters must not be corrupted"
3604 );
3605 }
3606
3607 #[test]
3608 fn test_sanitize_svg_full_roundtrip_with_non_ascii() {
3609 let input = "<svg><text x=\"10\">コード譜 🎵</text><rect width=\"100\"/></svg>";
3610 let sanitized = sanitize_svg_content(input);
3611 assert!(sanitized.contains("コード譜 🎵"));
3612 assert!(sanitized.contains("<rect width=\"100\"/>"));
3613 }
3614
3615 #[test]
3616 fn test_sanitize_svg_self_closing_with_gt_in_attr_value() {
3617 let svg = r#"<svg><set to="a>b"/><text>safe</text></svg>"#;
3619 let sanitized = sanitize_svg_content(svg);
3620 assert!(
3621 !sanitized.contains("<set"),
3622 "dangerous <set> element must be stripped"
3623 );
3624 assert!(
3625 sanitized.contains("<text>safe</text>"),
3626 "content after stripped self-closing element must be preserved"
3627 );
3628 }
3629
3630 #[test]
3633 fn test_strip_dangerous_attrs_gt_in_double_quoted_attr() {
3634 let input = r#"<rect title=">" onload="alert(1)"/>"#;
3636 let result = strip_dangerous_attrs(input);
3637 assert!(
3638 !result.contains("onload"),
3639 "onload after quoted > must be stripped"
3640 );
3641 assert!(result.contains("title"));
3642 }
3643
3644 #[test]
3645 fn test_strip_dangerous_attrs_gt_in_single_quoted_attr() {
3646 let input = "<rect title='>' onload=\"alert(1)\"/>";
3647 let result = strip_dangerous_attrs(input);
3648 assert!(
3649 !result.contains("onload"),
3650 "onload after single-quoted > must be stripped"
3651 );
3652 }
3653
3654 #[test]
3657 fn test_dangerous_uri_scheme_with_embedded_tab() {
3658 assert!(has_dangerous_uri_scheme("java\tscript:alert(1)"));
3659 }
3660
3661 #[test]
3662 fn test_dangerous_uri_scheme_with_embedded_newline() {
3663 assert!(has_dangerous_uri_scheme("java\nscript:alert(1)"));
3664 }
3665
3666 #[test]
3667 fn test_dangerous_uri_scheme_with_control_chars() {
3668 assert!(has_dangerous_uri_scheme("java\x00script:alert(1)"));
3669 }
3670
3671 #[test]
3672 fn test_safe_uri_not_flagged() {
3673 assert!(!has_dangerous_uri_scheme("https://example.com"));
3674 }
3675
3676 #[test]
3677 fn test_dangerous_uri_scheme_with_many_embedded_whitespace() {
3678 let payload = "j\ta\tv\ta\ts\tc\tr\ti\tp\tt\t:\ta\tl\te\tr\tt\t(\t1\t)\t";
3681 assert!(
3682 has_dangerous_uri_scheme(payload),
3683 "1 tab between letters should not bypass javascript: detection"
3684 );
3685 }
3686
3687 #[test]
3688 fn test_dangerous_uri_scheme_whitespace_bypass_regression() {
3689 let payload = "j\t\t\ta\t\t\tv\t\t\ta\t\t\ts\t\t\tc\t\t\tr\t\t\ti\t\t\tp\t\t\tt\t\t\t:";
3694 assert!(
3695 has_dangerous_uri_scheme(payload),
3696 "3 tabs between letters (colon at raw position 40) must still be detected"
3697 );
3698 }
3699
3700 #[test]
3703 fn test_svg_section_blocks_multiline_script_tag_splitting() {
3704 let input = "{start_of_svg}\n<script\n>alert(1)</script>\n{end_of_svg}";
3706 let html = render(input);
3707 assert!(
3708 !html.contains("alert(1)"),
3709 "multi-line <script> tag splitting must not execute JS"
3710 );
3711 assert!(
3712 !html.to_lowercase().contains("<script"),
3713 "multi-line <script> tag must be stripped"
3714 );
3715 }
3716
3717 #[test]
3718 fn test_svg_section_blocks_multiline_iframe_tag_splitting() {
3719 let input =
3720 "{start_of_svg}\n<iframe\nsrc=\"javascript:alert(1)\">\n</iframe>\n{end_of_svg}";
3721 let html = render(input);
3722 assert!(
3723 !html.to_lowercase().contains("<iframe"),
3724 "multi-line <iframe> tag splitting must be stripped"
3725 );
3726 assert!(
3727 !html.contains("javascript:"),
3728 "javascript: URI in split iframe must be stripped"
3729 );
3730 }
3731
3732 #[test]
3733 fn test_svg_section_blocks_multiline_foreignobject_splitting() {
3734 let input = "{start_of_svg}\n<foreignObject\n><script>alert(1)</script></foreignObject>\n{end_of_svg}";
3735 let html = render(input);
3736 assert!(
3737 !html.to_lowercase().contains("<foreignobject"),
3738 "multi-line <foreignObject> splitting must be stripped"
3739 );
3740 }
3741}