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
37const MIN_FONT_SIZE: f32 = 0.5;
40const MAX_FONT_SIZE: f32 = 200.0;
43
44#[derive(Default, Clone)]
54struct ElementStyle {
55 font: Option<String>,
56 size: Option<String>,
57 colour: Option<String>,
58}
59
60impl ElementStyle {
61 fn to_css(&self) -> String {
66 let mut css = String::new();
67 if let Some(ref font) = self.font {
68 let _ = write!(css, "font-family: {};", sanitize_css_value(font));
69 }
70 if let Some(ref size) = self.size {
71 let safe = sanitize_css_value(size);
72 if safe.chars().all(|c| c.is_ascii_digit()) {
73 let _ = write!(css, "font-size: {safe}pt;");
74 } else {
75 let _ = write!(css, "font-size: {safe};");
76 }
77 }
78 if let Some(ref colour) = self.colour {
79 let _ = write!(css, "color: {};", sanitize_css_value(colour));
80 }
81 css
82 }
83}
84
85#[derive(Default, Clone)]
87struct FormattingState {
88 text: ElementStyle,
89 chord: ElementStyle,
90 tab: ElementStyle,
91 title: ElementStyle,
92 chorus: ElementStyle,
93 label: ElementStyle,
94 grid: ElementStyle,
95}
96
97impl FormattingState {
98 fn apply(&mut self, kind: &DirectiveKind, value: &Option<String>) {
104 let val = value.clone();
105 let clamped_size = || -> Option<String> {
106 value
107 .as_deref()
108 .and_then(|v| v.parse::<f32>().ok())
109 .map(|s| s.clamp(MIN_FONT_SIZE, MAX_FONT_SIZE).to_string())
110 };
111 match kind {
112 DirectiveKind::TextFont => self.text.font = val,
113 DirectiveKind::TextSize => self.text.size = clamped_size(),
114 DirectiveKind::TextColour => self.text.colour = val,
115 DirectiveKind::ChordFont => self.chord.font = val,
116 DirectiveKind::ChordSize => self.chord.size = clamped_size(),
117 DirectiveKind::ChordColour => self.chord.colour = val,
118 DirectiveKind::TabFont => self.tab.font = val,
119 DirectiveKind::TabSize => self.tab.size = clamped_size(),
120 DirectiveKind::TabColour => self.tab.colour = val,
121 DirectiveKind::TitleFont => self.title.font = val,
122 DirectiveKind::TitleSize => self.title.size = clamped_size(),
123 DirectiveKind::TitleColour => self.title.colour = val,
124 DirectiveKind::ChorusFont => self.chorus.font = val,
125 DirectiveKind::ChorusSize => self.chorus.size = clamped_size(),
126 DirectiveKind::ChorusColour => self.chorus.colour = val,
127 DirectiveKind::LabelFont => self.label.font = val,
128 DirectiveKind::LabelSize => self.label.size = clamped_size(),
129 DirectiveKind::LabelColour => self.label.colour = val,
130 DirectiveKind::GridFont => self.grid.font = val,
131 DirectiveKind::GridSize => self.grid.size = clamped_size(),
132 DirectiveKind::GridColour => self.grid.colour = val,
133 _ => {}
135 }
136 }
137}
138
139#[must_use]
148pub fn render_song(song: &Song) -> String {
149 render_song_with_transpose(song, 0, &Config::defaults())
150}
151
152#[must_use]
160pub fn render_song_with_transpose(song: &Song, cli_transpose: i8, config: &Config) -> String {
161 let result = render_song_with_warnings(song, cli_transpose, config);
162 for w in &result.warnings {
163 eprintln!("warning: {w}");
164 }
165 result.output
166}
167
168#[must_use = "caller must check warnings in the returned RenderResult"]
174pub fn render_song_with_warnings(
175 song: &Song,
176 cli_transpose: i8,
177 config: &Config,
178) -> RenderResult<String> {
179 let mut warnings = Vec::new();
180 let title = song.metadata.title.as_deref().unwrap_or("Untitled");
181 let mut html = String::new();
182 let _ = write!(
183 html,
184 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>{}</title>\n",
185 escape(title)
186 );
187 html.push_str("<style>\n");
188 html.push_str(CSS);
189 html.push_str("</style>\n</head>\n<body>\n");
190 render_song_body(song, cli_transpose, config, &mut html, &mut warnings);
191 html.push_str("</body>\n</html>\n");
192 RenderResult::with_warnings(html, warnings)
193}
194
195fn render_song_body(
201 song: &Song,
202 cli_transpose: i8,
203 config: &Config,
204 html: &mut String,
205 warnings: &mut Vec<String>,
206) {
207 let song_overrides = song.config_overrides();
209 let song_config;
210 let config = if song_overrides.is_empty() {
211 config
212 } else {
213 song_config = config
214 .clone()
215 .with_song_overrides(&song_overrides, warnings);
216 &song_config
217 };
218 let song_transpose_delta = Config::song_transpose_delta(&song_overrides);
221 let (combined_transpose, _) =
222 chordsketch_core::transpose::combine_transpose(cli_transpose, song_transpose_delta);
223 let mut transpose_offset: i8 = combined_transpose;
224 let mut fmt_state = FormattingState::default();
225 html.push_str("<div class=\"song\">\n");
226
227 render_metadata(&song.metadata, html);
228
229 let mut columns_open = false;
231 let mut svg_buf: Option<String> = None;
234 let mut abc2svg_resolved: Option<bool> = config.get_path("delegates.abc2svg").as_bool();
239 let mut lilypond_resolved: Option<bool> = config.get_path("delegates.lilypond").as_bool();
240 let mut musescore_resolved: Option<bool> = config.get_path("delegates.musescore").as_bool();
241 let mut abc_buf: Option<String> = None;
242 let mut abc_label: Option<String> = None;
243 let mut ly_buf: Option<String> = None;
244 let mut ly_label: Option<String> = None;
245 let mut musicxml_buf: Option<String> = None;
246 let mut musicxml_label: Option<String> = None;
247
248 let mut show_diagrams = true;
250
251 let diagram_frets = config
253 .get_path("diagrams.frets")
254 .as_f64()
255 .map_or(chordsketch_core::chord_diagram::DEFAULT_FRETS_SHOWN, |n| {
256 (n as usize).max(1)
257 });
258
259 let default_instrument = config
263 .get_path("diagrams.instrument")
264 .as_str()
265 .map(str::to_ascii_lowercase)
266 .unwrap_or_else(|| "guitar".to_string());
267 let mut auto_diagrams_instrument: Option<String> = None;
268 let mut inline_defined: std::collections::HashSet<String> = std::collections::HashSet::new();
272
273 let mut chorus_body: Vec<Line> = Vec::new();
276 let mut chorus_buf: Option<Vec<Line>> = None;
278 let mut saved_fmt_state: Option<FormattingState> = None;
281 let mut chorus_recall_count: usize = 0;
282
283 for line in &song.lines {
284 match line {
285 Line::Lyrics(lyrics_line) => {
286 if let Some(ref mut buf) = svg_buf {
287 let raw = lyrics_line.text();
291 buf.push_str(&raw);
292 buf.push('\n');
293 } else if let Some(ref mut buf) = abc_buf {
294 let raw = lyrics_line.text();
296 buf.push_str(&raw);
297 buf.push('\n');
298 } else if let Some(ref mut buf) = ly_buf {
299 let raw = lyrics_line.text();
301 buf.push_str(&raw);
302 buf.push('\n');
303 } else if let Some(ref mut buf) = musicxml_buf {
304 let raw = lyrics_line.text();
306 buf.push_str(&raw);
307 buf.push('\n');
308 } else {
309 if let Some(buf) = chorus_buf.as_mut() {
310 buf.push(line.clone());
311 }
312 render_lyrics(lyrics_line, transpose_offset, &fmt_state, html);
313 }
314 }
315 Line::Directive(directive) => {
316 if directive.kind.is_metadata() {
317 continue;
318 }
319 if directive.kind == DirectiveKind::Diagrams {
320 auto_diagrams_instrument = resolve_diagrams_instrument(
321 directive.value.as_deref(),
322 &default_instrument,
323 );
324 show_diagrams = auto_diagrams_instrument.is_some();
325 continue;
326 }
327 if directive.kind == DirectiveKind::NoDiagrams {
328 show_diagrams = false;
329 auto_diagrams_instrument = None;
330 continue;
331 }
332 if directive.kind == DirectiveKind::Transpose {
333 let file_offset: i8 = match directive.value.as_deref() {
336 None | Some("") => 0,
337 Some(raw) => match raw.parse() {
338 Ok(v) => v,
339 Err(_) => {
340 warnings.push(format!(
341 "{{transpose}} value {raw:?} cannot be \
342 parsed as i8, ignored (using 0)"
343 ));
344 0
345 }
346 },
347 };
348 let (combined, saturated) =
349 chordsketch_core::transpose::combine_transpose(file_offset, cli_transpose);
350 if saturated {
351 warnings.push(format!(
352 "transpose offset {file_offset} + {cli_transpose} \
353 exceeds i8 range, clamped to {combined}"
354 ));
355 }
356 transpose_offset = combined;
357 continue;
358 }
359 if directive.kind.is_font_size_color() {
360 if let Some(buf) = chorus_buf.as_mut() {
361 buf.push(line.clone());
362 }
363 fmt_state.apply(&directive.kind, &directive.value);
364 continue;
365 }
366 match &directive.kind {
367 DirectiveKind::StartOfChorus => {
368 render_section_open("chorus", "Chorus", &directive.value, html);
369 chorus_buf = Some(Vec::new());
370 saved_fmt_state = Some(fmt_state.clone());
373 }
374 DirectiveKind::EndOfChorus => {
375 html.push_str("</section>\n");
376 if let Some(buf) = chorus_buf.take() {
377 chorus_body = buf;
378 }
379 if let Some(saved) = saved_fmt_state.take() {
381 fmt_state = saved;
382 }
383 }
384 DirectiveKind::Chorus => {
385 if chorus_recall_count < MAX_CHORUS_RECALLS {
386 render_chorus_recall(
387 &directive.value,
388 &chorus_body,
389 transpose_offset,
390 &fmt_state,
391 show_diagrams,
392 diagram_frets,
393 html,
394 );
395 chorus_recall_count += 1;
396 } else if chorus_recall_count == MAX_CHORUS_RECALLS {
397 warnings.push(format!(
398 "chorus recall limit ({MAX_CHORUS_RECALLS}) reached, \
399 further recalls suppressed"
400 ));
401 chorus_recall_count += 1;
402 }
403 }
404 DirectiveKind::Columns => {
405 let n: u32 = directive
409 .value
410 .as_deref()
411 .and_then(|v| v.trim().parse().ok())
412 .unwrap_or(1)
413 .clamp(1, MAX_COLUMNS);
414 if columns_open {
415 html.push_str("</div>\n");
416 columns_open = false;
417 }
418 if n > 1 {
419 let _ = writeln!(
420 html,
421 "<div style=\"column-count: {n};column-gap: 2em;\">"
422 );
423 columns_open = true;
424 }
425 }
426 DirectiveKind::ColumnBreak => {
432 html.push_str("<div style=\"break-before: column;\"></div>\n");
433 }
434 DirectiveKind::NewPage => {
435 html.push_str("<div style=\"break-before: page;\"></div>\n");
436 }
437 DirectiveKind::NewPhysicalPage => {
438 html.push_str("<div style=\"break-before: recto;\"></div>\n");
442 }
443 DirectiveKind::StartOfAbc => {
444 #[cfg(not(target_arch = "wasm32"))]
445 let enabled = *abc2svg_resolved
446 .get_or_insert_with(chordsketch_core::external_tool::has_abc2svg);
447 #[cfg(target_arch = "wasm32")]
448 let enabled = *abc2svg_resolved.get_or_insert(false);
449 if enabled {
450 abc_buf = Some(String::new());
451 abc_label = directive.value.clone();
452 } else {
453 if let Some(buf) = chorus_buf.as_mut() {
454 buf.push(line.clone());
455 }
456 render_directive_inner(directive, show_diagrams, diagram_frets, html);
457 }
458 }
459 DirectiveKind::EndOfAbc if abc_buf.is_some() => {
460 if let Some(abc_content) = abc_buf.take() {
461 render_abc_with_fallback(&abc_content, &abc_label, html, warnings);
462 abc_label = None;
463 }
464 }
465 DirectiveKind::StartOfLy => {
466 #[cfg(not(target_arch = "wasm32"))]
467 let enabled = *lilypond_resolved
468 .get_or_insert_with(chordsketch_core::external_tool::has_lilypond);
469 #[cfg(target_arch = "wasm32")]
470 let enabled = *lilypond_resolved.get_or_insert(false);
471 if enabled {
472 ly_buf = Some(String::new());
473 ly_label = directive.value.clone();
474 } else {
475 if let Some(buf) = chorus_buf.as_mut() {
476 buf.push(line.clone());
477 }
478 render_directive_inner(directive, show_diagrams, diagram_frets, html);
479 }
480 }
481 DirectiveKind::EndOfLy if ly_buf.is_some() => {
482 if let Some(ly_content) = ly_buf.take() {
483 render_ly_with_fallback(&ly_content, &ly_label, html, warnings);
484 ly_label = None;
485 }
486 }
487 DirectiveKind::StartOfMusicxml => {
488 #[cfg(not(target_arch = "wasm32"))]
489 let enabled = *musescore_resolved
490 .get_or_insert_with(chordsketch_core::external_tool::has_musescore);
491 #[cfg(target_arch = "wasm32")]
492 let enabled = *musescore_resolved.get_or_insert(false);
493 if enabled {
494 musicxml_buf = Some(String::new());
495 musicxml_label = directive.value.clone();
496 } else {
497 if let Some(buf) = chorus_buf.as_mut() {
498 buf.push(line.clone());
499 }
500 render_directive_inner(directive, show_diagrams, diagram_frets, html);
501 }
502 }
503 DirectiveKind::EndOfMusicxml if musicxml_buf.is_some() => {
504 if let Some(musicxml_content) = musicxml_buf.take() {
505 render_musicxml_with_fallback(
506 &musicxml_content,
507 &musicxml_label,
508 html,
509 warnings,
510 );
511 musicxml_label = None;
512 }
513 }
514 DirectiveKind::StartOfSvg => {
515 svg_buf = Some(String::new());
516 }
517 DirectiveKind::EndOfSvg if svg_buf.is_some() => {
518 if let Some(svg_content) = svg_buf.take() {
519 html.push_str("<div class=\"svg-section\">\n");
520 html.push_str(&sanitize_svg_content(&svg_content));
521 html.push('\n');
522 html.push_str("</div>\n");
523 }
524 }
525 _ => {
526 if let Some(buf) = chorus_buf.as_mut() {
527 buf.push(line.clone());
528 }
529 if directive.kind == DirectiveKind::Define && show_diagrams {
532 if let Some(ref val) = directive.value {
533 let name =
534 chordsketch_core::ast::ChordDefinition::parse_value(val).name;
535 if !name.is_empty() {
536 inline_defined.insert(canonical_chord_name(&name));
537 }
538 }
539 }
540 render_directive_inner(directive, show_diagrams, diagram_frets, html);
541 }
542 }
543 }
544 Line::Comment(style, text) => {
545 if let Some(buf) = chorus_buf.as_mut() {
546 buf.push(line.clone());
547 }
548 render_comment(*style, text, html);
549 }
550 Line::Empty => {
551 if let Some(buf) = chorus_buf.as_mut() {
552 buf.push(line.clone());
553 }
554 html.push_str("<div class=\"empty-line\"></div>\n");
555 }
556 }
557 }
558
559 if columns_open {
561 html.push_str("</div>\n");
562 }
563
564 if let Some(ref instrument) = auto_diagrams_instrument {
566 let chord_names: Vec<String> = song
570 .used_chord_names()
571 .into_iter()
572 .filter(|name| !inline_defined.contains(&canonical_chord_name(name)))
573 .collect();
574
575 if instrument == "piano" {
576 let kbd_defines = song.keyboard_defines();
578 let voicings: Vec<_> = chord_names
579 .into_iter()
580 .filter_map(|name| chordsketch_core::lookup_keyboard_voicing(&name, &kbd_defines))
581 .collect();
582 if !voicings.is_empty() {
583 html.push_str("<section class=\"chord-diagrams\">\n");
584 html.push_str("<div class=\"section-label\">Chord Diagrams</div>\n");
585 html.push_str("<div class=\"chord-diagrams-grid\">\n");
586 for voicing in &voicings {
587 html.push_str("<div class=\"chord-diagram-container\">");
588 html.push_str(&chordsketch_core::chord_diagram::render_keyboard_svg(
589 voicing,
590 ));
591 html.push_str("</div>\n");
592 }
593 html.push_str("</div>\n");
594 html.push_str("</section>\n");
595 }
596 } else {
597 let defines = song.fretted_defines();
599 let diagrams: Vec<_> = chord_names
600 .into_iter()
601 .filter_map(|name| {
602 chordsketch_core::lookup_diagram(&name, &defines, instrument, diagram_frets)
603 })
604 .collect();
605 if !diagrams.is_empty() {
606 html.push_str("<section class=\"chord-diagrams\">\n");
607 html.push_str("<div class=\"section-label\">Chord Diagrams</div>\n");
608 html.push_str("<div class=\"chord-diagrams-grid\">\n");
609 for diagram in &diagrams {
610 html.push_str("<div class=\"chord-diagram-container\">");
611 html.push_str(&chordsketch_core::chord_diagram::render_svg(diagram));
612 html.push_str("</div>\n");
613 }
614 html.push_str("</div>\n");
615 html.push_str("</section>\n");
616 }
617 }
618 }
619
620 html.push_str("</div>\n");
621}
622
623#[must_use]
625pub fn render_songs(songs: &[Song]) -> String {
626 render_songs_with_transpose(songs, 0, &Config::defaults())
627}
628
629#[must_use]
638pub fn render_songs_with_transpose(songs: &[Song], cli_transpose: i8, config: &Config) -> String {
639 let result = render_songs_with_warnings(songs, cli_transpose, config);
640 for w in &result.warnings {
641 eprintln!("warning: {w}");
642 }
643 result.output
644}
645
646#[must_use = "caller must check warnings in the returned RenderResult"]
653pub fn render_songs_with_warnings(
654 songs: &[Song],
655 cli_transpose: i8,
656 config: &Config,
657) -> RenderResult<String> {
658 let mut warnings = Vec::new();
659 if songs.len() <= 1 {
660 let output = songs
661 .first()
662 .map(|s| {
663 let r = render_song_with_warnings(s, cli_transpose, config);
664 warnings = r.warnings;
665 r.output
666 })
667 .unwrap_or_default();
668 return RenderResult::with_warnings(output, warnings);
669 }
670 let mut html = String::new();
672 let title = songs
673 .first()
674 .and_then(|s| s.metadata.title.as_deref())
675 .unwrap_or("Untitled");
676 let _ = write!(
677 html,
678 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>{}</title>\n",
679 escape(title)
680 );
681 html.push_str("<style>\n");
682 html.push_str(CSS);
683 html.push_str("</style>\n</head>\n<body>\n");
684
685 for (i, song) in songs.iter().enumerate() {
686 if i > 0 {
687 html.push_str("<hr class=\"song-separator\">\n");
688 }
689 render_song_body(song, cli_transpose, config, &mut html, &mut warnings);
690 }
691
692 html.push_str("</body>\n</html>\n");
693 RenderResult::with_warnings(html, warnings)
694}
695
696#[must_use = "parse errors should be handled"]
701pub fn try_render(input: &str) -> Result<String, chordsketch_core::ParseError> {
702 let song = chordsketch_core::parse(input)?;
703 Ok(render_song(&song))
704}
705
706#[must_use]
711pub fn render(input: &str) -> String {
712 match try_render(input) {
713 Ok(html) => html,
714 Err(e) => format!(
715 "<!DOCTYPE html><html><body><pre>Parse error at line {} column {}: {}</pre></body></html>\n",
716 e.line(),
717 e.column(),
718 escape(&e.message)
719 ),
720 }
721}
722
723const CSS: &str = "\
729body { font-family: serif; max-width: 800px; margin: 2em auto; padding: 0 1em; }
730h1 { margin-bottom: 0.2em; }
731h2 { margin-top: 0; font-weight: normal; color: #555; }
732.line { display: flex; flex-wrap: wrap; margin: 0.1em 0; }
733.chord-block { display: inline-flex; flex-direction: column; align-items: flex-start; }
734.chord { font-weight: bold; color: #b00; font-size: 0.9em; min-height: 1.2em; }
735.lyrics { white-space: pre; }
736.empty-line { height: 1em; }
737section { margin: 1em 0; }
738section > .section-label { font-weight: bold; font-style: italic; margin-bottom: 0.3em; }
739.comment { font-style: italic; color: #666; margin: 0.3em 0; }
740.comment-box { border: 1px solid #999; padding: 0.2em 0.5em; display: inline-block; margin: 0.3em 0; }
741.chorus-recall { margin: 1em 0; }
742.chorus-recall > .section-label { font-weight: bold; font-style: italic; margin-bottom: 0.3em; }
743img { max-width: 100%; height: auto; }
744.chord-diagrams-grid { display: flex; flex-wrap: wrap; gap: 0.5em; margin: 0.5em 0; }
745.chord-diagram-container { display: inline-block; vertical-align: top; }
746.chord-diagram { display: block; }
747";
748
749fn render_metadata(metadata: &chordsketch_core::ast::Metadata, html: &mut String) {
759 if let Some(title) = &metadata.title {
760 let _ = writeln!(html, "<h1>{}</h1>", escape(title));
761 }
762 for subtitle in &metadata.subtitles {
763 let _ = writeln!(html, "<h2>{}</h2>", escape(subtitle));
764 }
765}
766
767fn render_lyrics(
777 lyrics_line: &LyricsLine,
778 transpose_offset: i8,
779 fmt_state: &FormattingState,
780 html: &mut String,
781) {
782 html.push_str("<div class=\"line\">");
783
784 for segment in &lyrics_line.segments {
785 html.push_str("<span class=\"chord-block\">");
786
787 if let Some(chord) = &segment.chord {
788 let display_name = if transpose_offset != 0 {
789 let transposed = transpose_chord(chord, transpose_offset);
790 transposed.display_name().to_string()
791 } else {
792 chord.display_name().to_string()
793 };
794 let chord_css = fmt_state.chord.to_css();
795 if chord_css.is_empty() {
796 let _ = write!(
797 html,
798 "<span class=\"chord\">{}</span>",
799 escape(&display_name)
800 );
801 } else {
802 let _ = write!(
803 html,
804 "<span class=\"chord\" style=\"{}\">{}</span>",
805 escape(&chord_css),
806 escape(&display_name)
807 );
808 }
809 } else if lyrics_line.has_chords() {
810 html.push_str("<span class=\"chord\"></span>");
812 }
813
814 let text_css = fmt_state.text.to_css();
815 if text_css.is_empty() {
816 html.push_str("<span class=\"lyrics\">");
817 } else {
818 let _ = write!(
819 html,
820 "<span class=\"lyrics\" style=\"{}\">",
821 escape(&text_css)
822 );
823 }
824 if segment.has_markup() {
825 render_spans(&segment.spans, html);
826 } else {
827 html.push_str(&escape(&segment.text));
828 }
829 html.push_str("</span>");
830 html.push_str("</span>");
831 }
832
833 html.push_str("</div>\n");
834}
835
836fn render_spans(spans: &[TextSpan], html: &mut String) {
845 for span in spans {
846 match span {
847 TextSpan::Plain(text) => html.push_str(&escape(text)),
848 TextSpan::Bold(children) => {
849 html.push_str("<b>");
850 render_spans(children, html);
851 html.push_str("</b>");
852 }
853 TextSpan::Italic(children) => {
854 html.push_str("<i>");
855 render_spans(children, html);
856 html.push_str("</i>");
857 }
858 TextSpan::Highlight(children) => {
859 html.push_str("<mark>");
860 render_spans(children, html);
861 html.push_str("</mark>");
862 }
863 TextSpan::Comment(children) => {
864 html.push_str("<span class=\"comment\">");
865 render_spans(children, html);
866 html.push_str("</span>");
867 }
868 TextSpan::Span(attrs, children) => {
869 let css = span_attrs_to_css(attrs);
870 if css.is_empty() {
871 html.push_str("<span>");
872 } else {
873 let _ = write!(html, "<span style=\"{}\">", escape(&css));
874 }
875 render_spans(children, html);
876 html.push_str("</span>");
877 }
878 }
879 }
880}
881
882fn span_attrs_to_css(attrs: &SpanAttributes) -> String {
884 let mut css = String::new();
885 if let Some(ref font_family) = attrs.font_family {
886 let _ = write!(css, "font-family: {};", sanitize_css_value(font_family));
887 }
888 if let Some(ref size) = attrs.size {
889 let safe = sanitize_css_value(size);
890 if safe.chars().all(|c| c.is_ascii_digit()) {
892 let _ = write!(css, "font-size: {safe}pt;");
893 } else {
894 let _ = write!(css, "font-size: {safe};");
895 }
896 }
897 if let Some(ref fg) = attrs.foreground {
898 let _ = write!(css, "color: {};", sanitize_css_value(fg));
899 }
900 if let Some(ref bg) = attrs.background {
901 let _ = write!(css, "background-color: {};", sanitize_css_value(bg));
902 }
903 if let Some(ref weight) = attrs.weight {
904 let _ = write!(css, "font-weight: {};", sanitize_css_value(weight));
905 }
906 if let Some(ref style) = attrs.style {
907 let _ = write!(css, "font-style: {};", sanitize_css_value(style));
908 }
909 css
910}
911
912fn sanitize_css_value(s: &str) -> String {
919 s.chars()
920 .filter(|c| {
921 c.is_ascii_alphanumeric() || matches!(c, '#' | '.' | '-' | ' ' | ',' | '%' | '+')
922 })
923 .collect()
924}
925
926fn sanitize_css_class(s: &str) -> String {
933 s.chars()
934 .map(|c| {
935 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
936 c
937 } else {
938 '-'
939 }
940 })
941 .collect()
942}
943
944fn sanitize_svg_content(input: &str) -> String {
951 const DANGEROUS_TAGS: &[&str] = &[
960 "script",
961 "foreignobject",
962 "iframe",
963 "object",
964 "embed",
965 "math",
966 "feimage",
970 "image",
974 "set",
975 "animate",
976 "animatetransform",
977 "animatemotion",
978 ];
979
980 let mut result = String::with_capacity(input.len());
981 let mut chars = input.char_indices().peekable();
982 let bytes = input.as_bytes();
983
984 while let Some((i, c)) = chars.next() {
985 if c == '<' {
986 let rest = &input[i..];
987 let limit = rest
990 .char_indices()
991 .map(|(idx, _)| idx)
992 .find(|&idx| idx >= 30)
993 .unwrap_or(rest.len());
994 let rest_upper = &rest[..limit];
995
996 let mut matched = false;
998 for tag in DANGEROUS_TAGS {
999 let prefix = format!("<{tag}");
1000 if starts_with_ignore_case(rest_upper, &prefix)
1001 && rest.len() > prefix.len()
1002 && bytes
1003 .get(i + prefix.len())
1004 .is_none_or(|b| b.is_ascii_whitespace() || *b == b'>' || *b == b'/')
1005 {
1006 let is_self_closing = {
1010 let tag_bytes = rest.as_bytes();
1011 let mut in_quote: Option<u8> = None;
1012 let mut gt_pos = None;
1013 for (idx, &b) in tag_bytes.iter().enumerate() {
1014 match in_quote {
1015 Some(q) if b == q => in_quote = None,
1016 Some(_) => {}
1017 None if b == b'"' || b == b'\'' => in_quote = Some(b),
1018 None if b == b'>' => {
1019 gt_pos = Some(idx);
1020 break;
1021 }
1022 _ => {}
1023 }
1024 }
1025 gt_pos.is_some_and(|gt| gt > 0 && tag_bytes[gt - 1] == b'/')
1026 };
1027
1028 if is_self_closing {
1029 let mut skip_quote: Option<char> = None;
1033 while let Some(&(_, ch)) = chars.peek() {
1034 chars.next();
1035 match skip_quote {
1036 Some(q) if ch == q => skip_quote = None,
1037 Some(_) => {}
1038 None if ch == '"' || ch == '\'' => {
1039 skip_quote = Some(ch);
1040 }
1041 None if ch == '>' => break,
1042 _ => {}
1043 }
1044 }
1045 } else if let Some(end) = find_end_tag_ignore_case(input, i, tag) {
1046 while let Some(&(j, _)) = chars.peek() {
1048 if j >= end {
1049 break;
1050 }
1051 chars.next();
1052 }
1053 } else {
1054 return result;
1056 }
1057 matched = true;
1058 break;
1059 }
1060 }
1061 if matched {
1062 continue;
1063 }
1064
1065 for tag in DANGEROUS_TAGS {
1067 let prefix = format!("</{tag}");
1068 if starts_with_ignore_case(rest_upper, &prefix)
1069 && rest.len() > prefix.len()
1070 && bytes
1071 .get(i + prefix.len())
1072 .is_none_or(|b| b.is_ascii_whitespace() || *b == b'>')
1073 {
1074 while let Some(&(_, ch)) = chars.peek() {
1076 chars.next();
1077 if ch == '>' {
1078 break;
1079 }
1080 }
1081 matched = true;
1082 break;
1083 }
1084 }
1085 if matched {
1086 continue;
1087 }
1088
1089 result.push(c);
1090 } else {
1091 result.push(c);
1092 }
1093 }
1094
1095 strip_dangerous_attrs(&result)
1097}
1098
1099fn starts_with_ignore_case(s: &str, prefix: &str) -> bool {
1101 if s.len() < prefix.len() {
1102 return false;
1103 }
1104 s.as_bytes()[..prefix.len()]
1105 .iter()
1106 .zip(prefix.as_bytes())
1107 .all(|(a, b)| a.eq_ignore_ascii_case(b))
1108}
1109
1110fn find_end_tag_ignore_case(input: &str, start: usize, tag: &str) -> Option<usize> {
1113 let search = &input.as_bytes()[start..];
1114 let tag_bytes = tag.as_bytes();
1115 let close_prefix_len = 2 + tag_bytes.len(); for i in 0..search.len() {
1118 if search[i] == b'<'
1119 && i + 1 < search.len()
1120 && search[i + 1] == b'/'
1121 && i + close_prefix_len <= search.len()
1122 {
1123 let candidate = &search[i + 2..i + close_prefix_len];
1124 if candidate
1125 .iter()
1126 .zip(tag_bytes)
1127 .all(|(a, b)| a.eq_ignore_ascii_case(b))
1128 {
1129 if let Some(gt) = search[i + close_prefix_len..]
1131 .iter()
1132 .position(|&b| b == b'>')
1133 {
1134 return Some(start + i + close_prefix_len + gt + 1);
1135 }
1136 }
1137 }
1138 }
1139 None
1140}
1141
1142fn strip_dangerous_attrs(input: &str) -> String {
1147 let mut result = String::with_capacity(input.len());
1148 let bytes = input.as_bytes();
1149 let mut pos = 0;
1150
1151 while pos < bytes.len() {
1152 if bytes[pos] == b'<' && pos + 1 < bytes.len() && bytes[pos + 1] != b'/' {
1153 if let Some(gt) = find_tag_end(&bytes[pos..]) {
1157 let tag_end = pos + gt + 1;
1158 let tag_content = &input[pos..tag_end];
1159 result.push_str(&sanitize_tag_attrs(tag_content));
1160 pos = tag_end;
1161 } else {
1162 result.push_str(&input[pos..]);
1163 break;
1164 }
1165 } else {
1166 let ch = &input[pos..];
1169 let c = ch.chars().next().expect("pos is within bounds");
1170 result.push(c);
1171 pos += c.len_utf8();
1172 }
1173 }
1174 result
1175}
1176
1177fn find_tag_end(bytes: &[u8]) -> Option<usize> {
1180 let mut i = 0;
1181 let mut in_quote: Option<u8> = None;
1182 while i < bytes.len() {
1183 let b = bytes[i];
1184 if let Some(q) = in_quote {
1185 if b == q {
1186 in_quote = None;
1187 }
1188 } else if b == b'"' || b == b'\'' {
1189 in_quote = Some(b);
1190 } else if b == b'>' {
1191 return Some(i);
1192 }
1193 i += 1;
1194 }
1195 None
1196}
1197
1198fn has_dangerous_uri_scheme(value: &str) -> bool {
1201 let lower: String = value
1207 .trim_start()
1208 .chars()
1209 .filter(|c| !c.is_ascii_whitespace() && !c.is_ascii_control())
1210 .take(30)
1211 .flat_map(|c| c.to_lowercase())
1212 .collect();
1213 lower.starts_with("javascript:")
1220 || lower.starts_with("vbscript:")
1221 || lower.starts_with("data:")
1222 || lower.starts_with("file:")
1223 || lower.starts_with("blob:")
1224 || lower.starts_with("mhtml:")
1225}
1226
1227fn is_uri_attr(name: &str) -> bool {
1234 let lower: String = name.chars().flat_map(|c| c.to_lowercase()).collect();
1235 lower == "href"
1236 || lower == "src"
1237 || lower == "xlink:href"
1238 || lower == "to"
1240 || lower == "values"
1241 || lower == "from"
1242 || lower == "by"
1243 || lower == "action"
1245 || lower == "formaction"
1246 || lower == "poster"
1248 || lower == "background"
1249 || lower == "ping"
1251}
1252
1253fn sanitize_tag_attrs(tag: &str) -> String {
1264 let mut result = String::with_capacity(tag.len());
1265 let bytes = tag.as_bytes();
1266 let mut i = 0;
1267
1268 while i < bytes.len() && bytes[i] != b' ' && bytes[i] != b'>' && bytes[i] != b'/' {
1270 result.push(bytes[i] as char);
1271 i += 1;
1272 }
1273
1274 while i < bytes.len() {
1275 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
1277 result.push(bytes[i] as char);
1278 i += 1;
1279 }
1280
1281 if i >= bytes.len() || bytes[i] == b'>' || bytes[i] == b'/' {
1282 result.push_str(&tag[i..]);
1283 return result;
1284 }
1285
1286 let attr_start = i;
1288 while i < bytes.len()
1289 && bytes[i] != b'='
1290 && bytes[i] != b' '
1291 && bytes[i] != b'>'
1292 && bytes[i] != b'/'
1293 {
1294 i += 1;
1295 }
1296 let attr_name = &tag[attr_start..i];
1297
1298 let is_event_handler = attr_name.len() > 2
1299 && attr_name.as_bytes()[..2].eq_ignore_ascii_case(b"on")
1300 && attr_name.as_bytes()[2].is_ascii_alphabetic();
1301
1302 let value_start = i;
1304 let mut attr_value: Option<String> = None;
1305 if i < bytes.len() && bytes[i] == b'=' {
1306 i += 1; if i < bytes.len() && (bytes[i] == b'"' || bytes[i] == b'\'') {
1308 let quote = bytes[i];
1309 i += 1;
1310 let val_start = i;
1311 while i < bytes.len() && bytes[i] != quote {
1312 i += 1;
1313 }
1314 attr_value = Some(tag[val_start..i].to_string());
1315 if i < bytes.len() {
1316 i += 1; }
1318 } else {
1319 let val_start = i;
1321 while i < bytes.len() && !bytes[i].is_ascii_whitespace() && bytes[i] != b'>' {
1322 i += 1;
1323 }
1324 attr_value = Some(tag[val_start..i].to_string());
1325 }
1326 }
1327
1328 if is_event_handler {
1329 continue;
1331 }
1332
1333 if is_uri_attr(attr_name) {
1334 if let Some(ref val) = attr_value {
1335 if has_dangerous_uri_scheme(val) {
1336 continue;
1338 }
1339 }
1340 }
1341
1342 if attr_name.eq_ignore_ascii_case("style") {
1345 if let Some(ref val) = attr_value {
1346 let lower_val: String = val.chars().flat_map(|c| c.to_lowercase()).collect();
1347 if lower_val.contains("url(")
1348 || lower_val.contains("expression(")
1349 || lower_val.contains("@import")
1350 {
1351 continue;
1352 }
1353 }
1354 }
1355
1356 result.push_str(&tag[attr_start..value_start]);
1358 if attr_value.is_some() {
1359 result.push_str(&tag[value_start..i]);
1360 }
1361 }
1362
1363 result
1364}
1365
1366fn render_directive_inner(
1375 directive: &chordsketch_core::ast::Directive,
1376 show_diagrams: bool,
1377 diagram_frets: usize,
1378 html: &mut String,
1379) {
1380 match &directive.kind {
1381 DirectiveKind::StartOfChorus => {
1382 render_section_open("chorus", "Chorus", &directive.value, html);
1383 }
1384 DirectiveKind::StartOfVerse => {
1385 render_section_open("verse", "Verse", &directive.value, html);
1386 }
1387 DirectiveKind::StartOfBridge => {
1388 render_section_open("bridge", "Bridge", &directive.value, html);
1389 }
1390 DirectiveKind::StartOfTab => {
1391 render_section_open("tab", "Tab", &directive.value, html);
1392 }
1393 DirectiveKind::StartOfGrid => {
1394 render_section_open("grid", "Grid", &directive.value, html);
1395 }
1396 DirectiveKind::StartOfAbc => {
1397 render_section_open("abc", "ABC", &directive.value, html);
1398 }
1399 DirectiveKind::StartOfLy => {
1400 render_section_open("ly", "Lilypond", &directive.value, html);
1401 }
1402 DirectiveKind::StartOfTextblock => {
1405 render_section_open("textblock", "Textblock", &directive.value, html);
1406 }
1407 DirectiveKind::StartOfMusicxml => {
1408 render_section_open("musicxml", "MusicXML", &directive.value, html);
1409 }
1410 DirectiveKind::StartOfSection(section_name) => {
1411 let class = format!("section-{}", sanitize_css_class(section_name));
1412 let label = escape(&chordsketch_core::capitalize(section_name));
1413 render_section_open(&class, &label, &directive.value, html);
1414 }
1415 DirectiveKind::EndOfChorus
1416 | DirectiveKind::EndOfVerse
1417 | DirectiveKind::EndOfBridge
1418 | DirectiveKind::EndOfTab
1419 | DirectiveKind::EndOfGrid
1420 | DirectiveKind::EndOfAbc
1421 | DirectiveKind::EndOfLy
1422 | DirectiveKind::EndOfMusicxml
1423 | DirectiveKind::EndOfSvg
1424 | DirectiveKind::EndOfTextblock
1425 | DirectiveKind::EndOfSection(_) => {
1426 html.push_str("</section>\n");
1427 }
1428 DirectiveKind::Image(attrs) => {
1429 render_image(attrs, html);
1430 }
1431 DirectiveKind::Define if show_diagrams => {
1432 if let Some(ref value) = directive.value {
1433 let def = chordsketch_core::ast::ChordDefinition::parse_value(value);
1434 if let Some(ref keys_raw) = def.keys {
1436 let keys_u8: Vec<u8> = keys_raw
1437 .iter()
1438 .filter_map(|&k| {
1439 if (0i32..=127).contains(&k) {
1440 Some(k as u8)
1441 } else {
1442 None
1443 }
1444 })
1445 .collect();
1446 if !keys_u8.is_empty() {
1447 let root = keys_u8[0];
1448 let voicing = chordsketch_core::chord_diagram::KeyboardVoicing {
1449 name: def.name.clone(),
1450 display_name: def.display.clone(),
1451 keys: keys_u8,
1452 root_key: root,
1453 };
1454 html.push_str("<div class=\"chord-diagram-container\">");
1455 html.push_str(&chordsketch_core::chord_diagram::render_keyboard_svg(
1456 &voicing,
1457 ));
1458 html.push_str("</div>\n");
1459 }
1460 } else if let Some(ref raw) = def.raw {
1461 if let Some(mut diagram) =
1463 chordsketch_core::chord_diagram::DiagramData::from_raw_infer_frets(
1464 &def.name,
1465 raw,
1466 diagram_frets,
1467 )
1468 {
1469 diagram.display_name = def.display.clone();
1470 html.push_str("<div class=\"chord-diagram-container\">");
1471 html.push_str(&chordsketch_core::chord_diagram::render_svg(&diagram));
1472 html.push_str("</div>\n");
1473 }
1474 }
1475 }
1476 }
1477 DirectiveKind::Define => {}
1478 _ => {}
1479 }
1480}
1481
1482#[cfg(not(target_arch = "wasm32"))]
1488fn render_abc_with_fallback(
1489 abc_content: &str,
1490 label: &Option<String>,
1491 html: &mut String,
1492 warnings: &mut Vec<String>,
1493) {
1494 match chordsketch_core::external_tool::invoke_abc2svg(abc_content) {
1495 Ok(svg_fragment) => {
1496 render_section_open("abc", "ABC", label, html);
1497 html.push_str(&sanitize_svg_content(&svg_fragment));
1498 html.push('\n');
1499 html.push_str("</section>\n");
1500 }
1501 Err(e) => {
1502 warnings.push(format!("abc2svg invocation failed: {e}"));
1503 render_section_open("abc", "ABC", label, html);
1504 html.push_str("<pre>");
1505 html.push_str(&escape(abc_content));
1506 html.push_str("</pre>\n");
1507 html.push_str("</section>\n");
1508 }
1509 }
1510}
1511
1512#[cfg(target_arch = "wasm32")]
1516fn render_abc_with_fallback(
1517 abc_content: &str,
1518 label: &Option<String>,
1519 html: &mut String,
1520 _warnings: &mut Vec<String>,
1521) {
1522 render_section_open("abc", "ABC", label, html);
1523 html.push_str("<pre>");
1524 html.push_str(&escape(abc_content));
1525 html.push_str("</pre>\n");
1526 html.push_str("</section>\n");
1527}
1528
1529fn is_safe_image_src(src: &str) -> bool {
1537 if src.is_empty() {
1538 return false;
1539 }
1540
1541 if src.contains('\0') {
1543 return false;
1544 }
1545
1546 let normalised = src.trim_start().to_ascii_lowercase();
1549
1550 if normalised.starts_with('/') {
1553 return false;
1554 }
1555
1556 if is_windows_absolute(src.trim_start()) {
1558 return false;
1559 }
1560
1561 if has_traversal(src) {
1563 return false;
1564 }
1565
1566 if let Some(colon_pos) = normalised.find(':') {
1569 let before_colon = &normalised[..colon_pos];
1570 if !before_colon.contains('/') {
1572 return before_colon == "http" || before_colon == "https";
1573 }
1574 }
1575
1576 true
1577}
1578
1579use chordsketch_core::image_path::{has_traversal, is_windows_absolute};
1581
1582#[cfg(not(target_arch = "wasm32"))]
1588fn render_ly_with_fallback(
1589 ly_content: &str,
1590 label: &Option<String>,
1591 html: &mut String,
1592 warnings: &mut Vec<String>,
1593) {
1594 match chordsketch_core::external_tool::invoke_lilypond(ly_content) {
1595 Ok(svg) => {
1596 render_section_open("ly", "Lilypond", label, html);
1597 html.push_str(&sanitize_svg_content(&svg));
1598 html.push('\n');
1599 html.push_str("</section>\n");
1600 }
1601 Err(e) => {
1602 warnings.push(format!("lilypond invocation failed: {e}"));
1603 render_section_open("ly", "Lilypond", label, html);
1604 html.push_str("<pre>");
1605 html.push_str(&escape(ly_content));
1606 html.push_str("</pre>\n");
1607 html.push_str("</section>\n");
1608 }
1609 }
1610}
1611
1612#[cfg(target_arch = "wasm32")]
1616fn render_ly_with_fallback(
1617 ly_content: &str,
1618 label: &Option<String>,
1619 html: &mut String,
1620 _warnings: &mut Vec<String>,
1621) {
1622 render_section_open("ly", "Lilypond", label, html);
1623 html.push_str("<pre>");
1624 html.push_str(&escape(ly_content));
1625 html.push_str("</pre>\n");
1626 html.push_str("</section>\n");
1627}
1628
1629#[cfg(not(target_arch = "wasm32"))]
1635fn render_musicxml_with_fallback(
1636 musicxml_content: &str,
1637 label: &Option<String>,
1638 html: &mut String,
1639 warnings: &mut Vec<String>,
1640) {
1641 match chordsketch_core::external_tool::invoke_musescore(musicxml_content) {
1642 Ok(svg) => {
1643 render_section_open("musicxml", "MusicXML", label, html);
1644 html.push_str(&sanitize_svg_content(&svg));
1645 html.push('\n');
1646 html.push_str("</section>\n");
1647 }
1648 Err(e) => {
1649 warnings.push(format!("musescore invocation failed: {e}"));
1650 render_section_open("musicxml", "MusicXML", label, html);
1651 html.push_str("<pre>");
1652 html.push_str(&escape(musicxml_content));
1653 html.push_str("</pre>\n");
1654 html.push_str("</section>\n");
1655 }
1656 }
1657}
1658
1659#[cfg(target_arch = "wasm32")]
1663fn render_musicxml_with_fallback(
1664 musicxml_content: &str,
1665 label: &Option<String>,
1666 html: &mut String,
1667 _warnings: &mut Vec<String>,
1668) {
1669 render_section_open("musicxml", "MusicXML", label, html);
1670 html.push_str("<pre>");
1671 html.push_str(&escape(musicxml_content));
1672 html.push_str("</pre>\n");
1673 html.push_str("</section>\n");
1674}
1675
1676fn render_image(attrs: &chordsketch_core::ast::ImageAttributes, html: &mut String) {
1685 if !is_safe_image_src(&attrs.src) {
1686 return;
1687 }
1688
1689 let mut style = String::new();
1690 let mut img_attrs = format!("src=\"{}\"", escape(&attrs.src));
1691
1692 if let Some(ref title) = attrs.title {
1693 let _ = write!(img_attrs, " alt=\"{}\"", escape(title));
1694 }
1695
1696 if let Some(ref width) = attrs.width {
1697 let _ = write!(img_attrs, " width=\"{}\"", escape(width));
1698 }
1699 if let Some(ref height) = attrs.height {
1700 let _ = write!(img_attrs, " height=\"{}\"", escape(height));
1701 }
1702 if let Some(ref scale) = attrs.scale {
1703 let _ = write!(
1705 style,
1706 "transform: scale({});transform-origin: top left;",
1707 sanitize_css_value(scale)
1708 );
1709 }
1710
1711 let align_css = match attrs.anchor.as_deref() {
1713 Some("column") | Some("paper") => "text-align: center;",
1714 _ => "",
1715 };
1716
1717 if !align_css.is_empty() {
1718 let _ = write!(html, "<div style=\"{align_css}\">");
1719 } else {
1720 html.push_str("<div>");
1721 }
1722
1723 let _ = write!(html, "<img {img_attrs}");
1724 if !style.is_empty() {
1725 let _ = write!(html, " style=\"{}\"", escape(&style));
1731 }
1732 html.push_str("></div>\n");
1733}
1734
1735fn render_section_open(class: &str, label: &str, value: &Option<String>, html: &mut String) {
1737 let safe_class = sanitize_css_class(class);
1738 let _ = writeln!(html, "<section class=\"{safe_class}\">");
1739 let display_label = match value {
1740 Some(v) if !v.is_empty() => format!("{label}: {}", escape(v)),
1741 _ => label.to_string(),
1742 };
1743 let _ = writeln!(html, "<div class=\"section-label\">{display_label}</div>");
1744}
1745
1746fn render_chorus_recall(
1752 value: &Option<String>,
1753 chorus_body: &[Line],
1754 transpose_offset: i8,
1755 fmt_state: &FormattingState,
1756 show_diagrams: bool,
1757 diagram_frets: usize,
1758 html: &mut String,
1759) {
1760 html.push_str("<div class=\"chorus-recall\">\n");
1761 let display_label = match value {
1762 Some(v) if !v.is_empty() => format!("Chorus: {}", escape(v)),
1763 _ => "Chorus".to_string(),
1764 };
1765 let _ = writeln!(html, "<div class=\"section-label\">{display_label}</div>");
1766 let mut local_fmt = fmt_state.clone();
1770 for line in chorus_body {
1771 match line {
1772 Line::Lyrics(lyrics) => render_lyrics(lyrics, transpose_offset, &local_fmt, html),
1773 Line::Comment(style, text) => render_comment(*style, text, html),
1774 Line::Empty => html.push_str("<div class=\"empty-line\"></div>\n"),
1775 Line::Directive(d) if d.kind.is_font_size_color() => {
1776 local_fmt.apply(&d.kind, &d.value);
1777 }
1778 Line::Directive(d) if !d.kind.is_metadata() => {
1779 render_directive_inner(d, show_diagrams, diagram_frets, html);
1780 }
1781 _ => {}
1782 }
1783 }
1784 html.push_str("</div>\n");
1785}
1786
1787fn render_comment(style: CommentStyle, text: &str, html: &mut String) {
1793 match style {
1794 CommentStyle::Normal => {
1795 let _ = writeln!(html, "<p class=\"comment\">{}</p>", escape(text));
1796 }
1797 CommentStyle::Italic => {
1798 let _ = writeln!(html, "<p class=\"comment\"><em>{}</em></p>", escape(text));
1799 }
1800 CommentStyle::Boxed => {
1801 let _ = writeln!(html, "<div class=\"comment-box\">{}</div>", escape(text));
1802 }
1803 }
1804}
1805
1806#[cfg(test)]
1811mod sanitize_tag_attrs_tests {
1812 use super::*;
1813
1814 #[test]
1815 fn test_preserves_normal_attrs() {
1816 let tag = "<svg width=\"100\" height=\"50\">";
1817 assert_eq!(sanitize_tag_attrs(tag), tag);
1818 }
1819
1820 #[test]
1821 fn test_strips_event_handler() {
1822 let tag = "<svg onclick=\"alert(1)\" width=\"100\">";
1823 let result = sanitize_tag_attrs(tag);
1824 assert!(!result.contains("onclick"));
1825 assert!(result.contains("width"));
1826 }
1827
1828 #[test]
1829 fn test_non_ascii_in_attr_value_preserved() {
1830 let tag = "<text title=\"日本語テスト\" x=\"10\">";
1831 let result = sanitize_tag_attrs(tag);
1832 assert!(result.contains("日本語テスト"));
1833 assert!(result.contains("x=\"10\""));
1834 }
1835
1836 #[test]
1839 fn test_strips_mixed_case_event_handler() {
1840 let tag = "<svg OnClick=\"alert(1)\" width=\"100\">";
1841 let result = sanitize_tag_attrs(tag);
1842 assert!(!result.contains("OnClick"));
1843 assert!(result.contains("width"));
1844 }
1845
1846 #[test]
1847 fn test_strips_uppercase_event_handler() {
1848 let tag = "<svg ONLOAD=\"alert(1)\">";
1849 let result = sanitize_tag_attrs(tag);
1850 assert!(!result.contains("ONLOAD"));
1851 }
1852
1853 #[test]
1856 fn test_strips_style_with_url() {
1857 let tag =
1858 "<rect style=\"background-image: url('https://attacker.com/exfil')\" width=\"10\">";
1859 let result = sanitize_tag_attrs(tag);
1860 assert!(!result.contains("style"));
1861 assert!(result.contains("width"));
1862 }
1863
1864 #[test]
1865 fn test_strips_style_with_expression() {
1866 let tag = "<rect style=\"width: expression(alert(1))\">";
1867 let result = sanitize_tag_attrs(tag);
1868 assert!(!result.contains("style"));
1869 }
1870
1871 #[test]
1872 fn test_strips_style_with_import() {
1873 let tag = "<rect style=\"@import url(evil.css)\">";
1874 let result = sanitize_tag_attrs(tag);
1875 assert!(!result.contains("style"));
1876 }
1877
1878 #[test]
1879 fn test_preserves_safe_style() {
1880 let tag = "<rect style=\"fill: red; stroke: blue\" width=\"10\">";
1881 let result = sanitize_tag_attrs(tag);
1882 assert!(result.contains("style"));
1883 assert!(result.contains("fill: red"));
1884 }
1885}
1886
1887#[cfg(test)]
1888mod tests {
1889 use super::*;
1890
1891 #[test]
1892 fn test_render_empty() {
1893 let song = chordsketch_core::parse("").unwrap();
1894 let html = render_song(&song);
1895 assert!(html.contains("<!DOCTYPE html>"));
1896 assert!(html.contains("</html>"));
1897 }
1898
1899 #[test]
1900 fn test_render_title() {
1901 let html = render("{title: My Song}");
1902 assert!(html.contains("<h1>My Song</h1>"));
1903 assert!(html.contains("<title>My Song</title>"));
1904 }
1905
1906 #[test]
1907 fn test_render_subtitle() {
1908 let html = render("{title: Song}\n{subtitle: By Someone}");
1909 assert!(html.contains("<h2>By Someone</h2>"));
1910 }
1911
1912 #[test]
1913 fn test_render_lyrics_with_chords() {
1914 let html = render("[Am]Hello [G]world");
1915 assert!(html.contains("chord-block"));
1916 assert!(html.contains("<span class=\"chord\">Am</span>"));
1917 assert!(html.contains("<span class=\"lyrics\">Hello </span>"));
1918 assert!(html.contains("<span class=\"chord\">G</span>"));
1919 }
1920
1921 #[test]
1922 fn test_render_lyrics_no_chords() {
1923 let html = render("Just plain text");
1924 assert!(html.contains("<span class=\"lyrics\">Just plain text</span>"));
1925 assert!(!html.contains("class=\"chord\""));
1927 }
1928
1929 #[test]
1930 fn test_render_chorus_section() {
1931 let html = render("{start_of_chorus}\n[G]La la\n{end_of_chorus}");
1932 assert!(html.contains("<section class=\"chorus\">"));
1933 assert!(html.contains("</section>"));
1934 assert!(html.contains("Chorus"));
1935 }
1936
1937 #[test]
1938 fn test_render_verse_with_label() {
1939 let html = render("{start_of_verse: Verse 1}\nLyrics\n{end_of_verse}");
1940 assert!(html.contains("<section class=\"verse\">"));
1941 assert!(html.contains("Verse: Verse 1"));
1942 }
1943
1944 #[test]
1945 fn test_render_comment() {
1946 let html = render("{comment: A note}");
1947 assert!(html.contains("<p class=\"comment\">A note</p>"));
1948 }
1949
1950 #[test]
1951 fn test_render_comment_italic() {
1952 let html = render("{comment_italic: Softly}");
1953 assert!(html.contains("<em>Softly</em>"));
1954 }
1955
1956 #[test]
1957 fn test_render_comment_box() {
1958 let html = render("{comment_box: Important}");
1959 assert!(html.contains("<div class=\"comment-box\">Important</div>"));
1960 }
1961
1962 #[test]
1963 fn test_html_escaping() {
1964 let html = render("{title: Tom & Jerry <3}");
1965 assert!(html.contains("Tom & Jerry <3"));
1966 }
1967
1968 #[test]
1969 fn test_try_render_success() {
1970 let result = try_render("{title: Test}");
1971 assert!(result.is_ok());
1972 }
1973
1974 #[test]
1975 fn test_try_render_error() {
1976 let result = try_render("{unclosed");
1977 assert!(result.is_err());
1978 }
1979
1980 #[test]
1981 fn test_render_valid_html_structure() {
1982 let html = render("{title: Test}\n\n{start_of_verse}\n[G]Hello [C]world\n{end_of_verse}");
1983 assert!(html.starts_with("<!DOCTYPE html>"));
1984 assert!(html.contains("<html"));
1985 assert!(html.contains("<head>"));
1986 assert!(html.contains("<style>"));
1987 assert!(html.contains("<body>"));
1988 assert!(html.contains("</html>"));
1989 }
1990
1991 #[test]
1992 fn test_text_before_first_chord() {
1993 let html = render("Hello [Am]world");
1994 assert!(html.contains("<span class=\"chord\"></span><span class=\"lyrics\">Hello </span>"));
1996 }
1997
1998 #[test]
1999 fn test_empty_line() {
2000 let html = render("Line one\n\nLine two");
2001 assert!(html.contains("empty-line"));
2002 }
2003
2004 #[test]
2005 fn test_render_grid_section() {
2006 let html = render("{start_of_grid}\n| Am . | C . |\n{end_of_grid}");
2007 assert!(html.contains("<section class=\"grid\">"));
2008 assert!(html.contains("Grid"));
2009 assert!(html.contains("</section>"));
2010 }
2011
2012 #[test]
2015 fn test_render_custom_section_intro() {
2016 let html = render("{start_of_intro}\n[Am]Da da\n{end_of_intro}");
2017 assert!(html.contains("<section class=\"section-intro\">"));
2018 assert!(html.contains("Intro"));
2019 assert!(html.contains("</section>"));
2020 }
2021
2022 #[test]
2023 fn test_render_grid_section_with_label() {
2024 let html = render("{start_of_grid: Intro}\n| Am |\n{end_of_grid}");
2025 assert!(html.contains("<section class=\"grid\">"));
2026 assert!(html.contains("Grid: Intro"));
2027 }
2028
2029 #[test]
2030 fn test_render_grid_short_alias() {
2031 let html = render("{sog}\n| G . |\n{eog}");
2032 assert!(html.contains("<section class=\"grid\">"));
2033 assert!(html.contains("</section>"));
2034 }
2035
2036 #[test]
2037 fn test_render_custom_section_with_label() {
2038 let html = render("{start_of_intro: Guitar}\nNotes\n{end_of_intro}");
2039 assert!(html.contains("<section class=\"section-intro\">"));
2040 assert!(html.contains("Intro: Guitar"));
2041 }
2042
2043 #[test]
2044 fn test_render_custom_section_outro() {
2045 let html = render("{start_of_outro}\nFinal\n{end_of_outro}");
2046 assert!(html.contains("<section class=\"section-outro\">"));
2047 assert!(html.contains("Outro"));
2048 }
2049
2050 #[test]
2051 fn test_render_custom_section_solo() {
2052 let html = render("{start_of_solo}\n[Em]Solo\n{end_of_solo}");
2053 assert!(html.contains("<section class=\"section-solo\">"));
2054 assert!(html.contains("Solo"));
2055 assert!(html.contains("</section>"));
2056 }
2057
2058 #[test]
2059 fn test_custom_section_name_escaped() {
2060 let html = render(
2061 "{start_of_x<script>alert(1)</script>}\ntext\n{end_of_x<script>alert(1)</script>}",
2062 );
2063 assert!(!html.contains("<script>"));
2064 assert!(html.contains("<script>"));
2065 }
2066
2067 #[test]
2068 fn test_custom_section_name_quotes_escaped() {
2069 let html =
2070 render("{start_of_x\" onclick=\"alert(1)}\ntext\n{end_of_x\" onclick=\"alert(1)}");
2071 assert!(html.contains("""));
2073 assert!(!html.contains("class=\"section-x\""));
2074 }
2075
2076 #[test]
2077 fn test_custom_section_name_single_quotes_escaped() {
2078 let html = render("{start_of_x' onclick='alert(1)}\ntext\n{end_of_x' onclick='alert(1)}");
2079 assert!(html.contains("'") || html.contains("'"));
2082 assert!(!html.contains("onclick='alert"));
2083 }
2084
2085 #[test]
2086 fn test_custom_section_name_space_sanitized_in_class() {
2087 let html = render("{start_of_foo bar}\ntext\n{end_of_foo bar}");
2089 assert!(html.contains("section-foo-bar"));
2091 assert!(!html.contains("class=\"section-foo bar\""));
2092 }
2093
2094 #[test]
2095 fn test_custom_section_name_special_chars_sanitized_in_class() {
2096 let html = render("{start_of_a&b<c>d}\ntext\n{end_of_a&b<c>d}");
2097 assert!(html.contains("section-a-b-c-d"));
2099 assert!(html.contains("&"));
2101 }
2102
2103 #[test]
2104 fn test_custom_section_capitalize_before_escape() {
2105 let html = render("{start_of_&test}\ntext\n{end_of_&test}");
2109 assert!(html.contains("&test"));
2112 assert!(!html.contains("&Amp;"));
2113 }
2114
2115 #[test]
2116 fn test_define_display_name_in_html_output() {
2117 let html = render("{define: Am base-fret 1 frets x 0 2 2 1 0 display=\"A minor\"}");
2118 assert!(
2119 html.contains("A minor"),
2120 "display name should appear in rendered HTML output"
2121 );
2122 }
2123}
2124
2125#[cfg(test)]
2126mod transpose_tests {
2127 use super::*;
2128
2129 #[test]
2130 fn test_transpose_directive_up_2() {
2131 let input = "{transpose: 2}\n[G]Hello [C]world";
2132 let song = chordsketch_core::parse(input).unwrap();
2133 let html = render_song(&song);
2134 assert!(html.contains("<span class=\"chord\">A</span>"));
2136 assert!(html.contains("<span class=\"chord\">D</span>"));
2137 assert!(!html.contains("<span class=\"chord\">G</span>"));
2138 assert!(!html.contains("<span class=\"chord\">C</span>"));
2139 }
2140
2141 #[test]
2142 fn test_transpose_directive_replaces_previous() {
2143 let input = "{transpose: 2}\n[G]First\n{transpose: 0}\n[G]Second";
2144 let song = chordsketch_core::parse(input).unwrap();
2145 let html = render_song(&song);
2146 assert!(html.contains("<span class=\"chord\">A</span>"));
2148 assert!(html.contains("<span class=\"chord\">G</span>"));
2149 }
2150
2151 #[test]
2152 fn test_transpose_directive_with_cli_offset() {
2153 let input = "{transpose: 2}\n[C]Hello";
2154 let song = chordsketch_core::parse(input).unwrap();
2155 let html = render_song_with_transpose(&song, 3, &Config::defaults());
2156 assert!(html.contains("<span class=\"chord\">F</span>"));
2158 }
2159
2160 #[test]
2161 fn test_transpose_out_of_i8_range_emits_warning() {
2162 let input = "{transpose: 999}\n[G]Hello";
2164 let song = chordsketch_core::parse(input).unwrap();
2165 let result = render_song_with_warnings(&song, 0, &Config::defaults());
2166 assert!(
2167 result.output.contains("<span class=\"chord\">G</span>"),
2168 "chord should be untransposed"
2169 );
2170 assert!(
2171 result.warnings.iter().any(|w| w.contains("\"999\"")),
2172 "expected warning about out-of-range value, got: {:?}",
2173 result.warnings
2174 );
2175 }
2176
2177 #[test]
2178 fn test_transpose_no_value_treated_as_zero() {
2179 let input = "{transpose}\n[G]Hello";
2181 let song = chordsketch_core::parse(input).unwrap();
2182 let result = render_song_with_warnings(&song, 0, &Config::defaults());
2183 assert!(
2184 result.output.contains("<span class=\"chord\">G</span>"),
2185 "chord should be untransposed"
2186 );
2187 assert!(
2188 result.warnings.is_empty(),
2189 "missing {{transpose}} value should not emit a warning; got: {:?}",
2190 result.warnings
2191 );
2192 }
2193
2194 #[test]
2195 fn test_transpose_whitespace_value_treated_as_zero() {
2196 let input = "{transpose: }\n[G]Hello";
2200 let song = chordsketch_core::parse(input).unwrap();
2201 let result = render_song_with_warnings(&song, 0, &Config::defaults());
2202 assert!(
2203 result.output.contains("<span class=\"chord\">G</span>"),
2204 "chord should be untransposed"
2205 );
2206 assert!(
2207 result.warnings.is_empty(),
2208 "whitespace-only {{transpose}} value should not emit a warning; got: {:?}",
2209 result.warnings
2210 );
2211 }
2212
2213 #[test]
2216 fn test_render_chorus_recall_basic() {
2217 let html = render("{start_of_chorus}\n[G]La la\n{end_of_chorus}\n\n{chorus}");
2218 assert!(html.contains("<div class=\"chorus-recall\">"));
2220 assert!(html.contains("chorus-recall"));
2222 assert!(html.contains("<section class=\"chorus\">"));
2224 }
2225
2226 #[test]
2227 fn test_render_chorus_recall_with_label() {
2228 let html = render("{start_of_chorus}\nSing\n{end_of_chorus}\n{chorus: Repeat}");
2229 assert!(html.contains("Chorus: Repeat"));
2230 assert!(html.contains("chorus-recall"));
2231 }
2232
2233 #[test]
2234 fn test_render_chorus_recall_no_chorus_defined() {
2235 let html = render("{chorus}");
2236 assert!(html.contains("<div class=\"chorus-recall\">"));
2238 assert!(html.contains("Chorus"));
2239 }
2240
2241 #[test]
2242 fn test_render_chorus_recall_content_replayed() {
2243 let html = render("{start_of_chorus}\nChorus text\n{end_of_chorus}\n{chorus}");
2244 let count = html.matches("Chorus text").count();
2246 assert_eq!(count, 2, "chorus content should appear twice");
2247 }
2248
2249 #[test]
2250 fn test_chorus_recall_applies_current_transpose() {
2251 let html = render("{start_of_chorus}\n[G]La la\n{end_of_chorus}\n{transpose: 2}\n{chorus}");
2254 assert!(
2256 html.contains("<span class=\"chord\">G</span>"),
2257 "original chorus should have G"
2258 );
2259 assert!(
2261 html.contains("<span class=\"chord\">A</span>"),
2262 "recalled chorus should have transposed chord A, got:\n{html}"
2263 );
2264 }
2265
2266 #[test]
2267 fn test_chorus_recall_preserves_formatting_directives() {
2268 let html =
2270 render("{start_of_chorus}\n{textsize: 20}\n[Am]Big text\n{end_of_chorus}\n{chorus}");
2271 let recall_start = html.find("chorus-recall").expect("should have recall");
2273 let recall_section = &html[recall_start..];
2274 assert!(
2275 recall_section.contains("font-size"),
2276 "recalled chorus should apply in-chorus formatting directives"
2277 );
2278 }
2279
2280 #[test]
2281 fn test_chorus_formatting_does_not_leak_to_outer_scope() {
2282 let html =
2284 render("{start_of_chorus}\n{textsize: 20}\n[Am]Big\n{end_of_chorus}\n[G]Normal text");
2285 let after_chorus = html
2287 .rfind("Normal text")
2288 .expect("should have post-chorus text");
2289 let line_start = html[..after_chorus].rfind("<div class=\"line\"").unwrap();
2291 let line_end = html[line_start..]
2292 .find("</div>")
2293 .map_or(html.len(), |i| line_start + i + 6);
2294 let post_chorus_line = &html[line_start..line_end];
2295 assert!(
2296 !post_chorus_line.contains("font-size"),
2297 "in-chorus {{textsize}} should not leak to post-chorus content: {post_chorus_line}"
2298 );
2299 }
2300
2301 #[test]
2304 fn test_render_bold_markup() {
2305 let html = render("Hello <b>bold</b> world");
2306 assert!(html.contains("<b>bold</b>"));
2307 assert!(html.contains("Hello "));
2308 assert!(html.contains(" world"));
2309 }
2310
2311 #[test]
2312 fn test_render_italic_markup() {
2313 let html = render("Hello <i>italic</i> text");
2314 assert!(html.contains("<i>italic</i>"));
2315 }
2316
2317 #[test]
2318 fn test_render_highlight_markup() {
2319 let html = render("<highlight>important</highlight>");
2320 assert!(html.contains("<mark>important</mark>"));
2321 }
2322
2323 #[test]
2324 fn test_render_comment_inline_markup() {
2325 let html = render("<comment>note</comment>");
2326 assert!(html.contains("<span class=\"comment\">note</span>"));
2327 }
2328
2329 #[test]
2330 fn test_render_span_with_foreground() {
2331 let html = render(r#"<span foreground="red">red text</span>"#);
2332 assert!(html.contains("color: red;"));
2333 assert!(html.contains("red text"));
2334 }
2335
2336 #[test]
2337 fn test_render_span_with_multiple_attrs() {
2338 let html = render(
2339 r#"<span font_family="Serif" size="14" foreground="blue" weight="bold">styled</span>"#,
2340 );
2341 assert!(html.contains("font-family: Serif;"));
2342 assert!(html.contains("font-size: 14pt;"));
2343 assert!(html.contains("color: blue;"));
2344 assert!(html.contains("font-weight: bold;"));
2345 assert!(html.contains("styled"));
2346 }
2347
2348 #[test]
2349 fn test_span_css_injection_url_prevented() {
2350 let html = render(
2351 r#"<span foreground="red; background-image: url('https://evil.com/')">text</span>"#,
2352 );
2353 assert!(!html.contains("url("));
2355 assert!(!html.contains(";background-image"));
2356 }
2357
2358 #[test]
2359 fn test_span_css_injection_semicolon_stripped() {
2360 let html =
2361 render(r#"<span foreground="red; position: absolute; z-index: 9999">text</span>"#);
2362 assert!(!html.contains(";position"));
2366 assert!(!html.contains("; position"));
2367 assert!(html.contains("color:"));
2368 }
2369
2370 #[test]
2371 fn test_render_nested_markup() {
2372 let html = render("<b><i>bold italic</i></b>");
2373 assert!(html.contains("<b><i>bold italic</i></b>"));
2374 }
2375
2376 #[test]
2377 fn test_render_markup_with_chord() {
2378 let html = render("[Am]Hello <b>bold</b> world");
2379 assert!(html.contains("<b>bold</b>"));
2380 assert!(html.contains("<span class=\"chord\">Am</span>"));
2381 }
2382
2383 #[test]
2384 fn test_render_no_markup_unchanged() {
2385 let html = render("Just plain text");
2386 assert!(!html.contains("<b>"));
2388 assert!(!html.contains("<i>"));
2389 assert!(html.contains("Just plain text"));
2390 }
2391
2392 #[test]
2395 fn test_textfont_directive_applies_css() {
2396 let html = render("{textfont: Courier}\nHello world");
2397 assert!(html.contains("font-family: Courier;"));
2398 }
2399
2400 #[test]
2401 fn test_textsize_directive_applies_css() {
2402 let html = render("{textsize: 14}\nHello world");
2403 assert!(html.contains("font-size: 14pt;"));
2404 }
2405
2406 #[test]
2407 fn test_textcolour_directive_applies_css() {
2408 let html = render("{textcolour: blue}\nHello world");
2409 assert!(html.contains("color: blue;"));
2410 }
2411
2412 #[test]
2413 fn test_chordfont_directive_applies_css() {
2414 let html = render("{chordfont: Monospace}\n[Am]Hello");
2415 assert!(html.contains("font-family: Monospace;"));
2416 }
2417
2418 #[test]
2419 fn test_chordsize_directive_applies_css() {
2420 let html = render("{chordsize: 16}\n[Am]Hello");
2421 assert!(html.contains("font-size: 16pt;"));
2423 }
2424
2425 #[test]
2426 fn test_chordcolour_directive_applies_css() {
2427 let html = render("{chordcolour: green}\n[Am]Hello");
2428 assert!(html.contains("color: green;"));
2429 }
2430
2431 #[test]
2432 fn test_formatting_persists_across_lines() {
2433 let html = render("{textcolour: red}\nLine one\nLine two");
2434 let count = html.matches("color: red;").count();
2436 assert!(
2437 count >= 2,
2438 "formatting should persist: found {count} matches"
2439 );
2440 }
2441
2442 #[test]
2443 fn test_formatting_overridden_by_later_directive() {
2444 let html = render("{textcolour: red}\nRed text\n{textcolour: blue}\nBlue text");
2445 assert!(html.contains("color: red;"));
2446 assert!(html.contains("color: blue;"));
2447 }
2448
2449 #[test]
2450 fn test_no_formatting_no_style_attr() {
2451 let html = render("Plain text");
2452 assert!(!html.contains("<span class=\"lyrics\" style="));
2454 }
2455
2456 #[test]
2457 fn test_formatting_directive_css_injection_prevented() {
2458 let html = render("{textcolour: red; position: fixed; z-index: 9999}\nHello");
2459 assert!(!html.contains(";position"));
2461 assert!(!html.contains("; position"));
2462 assert!(html.contains("color:"));
2463 }
2464
2465 #[test]
2466 fn test_formatting_directive_url_injection_prevented() {
2467 let html = render("{textcolour: red; background-image: url('https://evil.com/')}\nHello");
2468 assert!(!html.contains("url("));
2470 }
2471
2472 #[test]
2475 fn test_columns_directive_generates_css() {
2476 let html = render("{columns: 2}\nLine one\nLine two");
2477 assert!(html.contains("column-count: 2"));
2478 }
2479
2480 #[test]
2481 fn test_columns_reset_to_one() {
2482 let html = render("{columns: 2}\nTwo cols\n{columns: 1}\nOne col");
2483 let count = html.matches("column-count: 2").count();
2485 assert_eq!(count, 1);
2486 assert!(html.contains("One col"));
2487 }
2488
2489 #[test]
2490 fn test_column_break_generates_css() {
2491 let html = render("{columns: 2}\nCol 1\n{column_break}\nCol 2");
2492 assert!(html.contains("break-before: column;"));
2493 }
2494
2495 #[test]
2496 fn test_columns_clamped_to_max() {
2497 let html = render("{columns: 999}\nContent");
2498 assert!(html.contains("column-count: 32"));
2500 }
2501
2502 #[test]
2503 fn test_columns_zero_treated_as_one() {
2504 let html = render("{columns: 0}\nContent");
2505 assert!(!html.contains("column-count"));
2507 }
2508
2509 #[test]
2510 fn test_columns_non_numeric_defaults_to_one() {
2511 let html = render("{columns: abc}\nHello");
2512 assert!(!html.contains("column-count"));
2514 }
2515
2516 #[test]
2517 fn test_new_page_generates_page_break() {
2518 let html = render("Page 1\n{new_page}\nPage 2");
2519 assert!(html.contains("break-before: page;"));
2520 }
2521
2522 #[test]
2523 fn test_new_physical_page_generates_recto_break() {
2524 let html = render("Page 1\n{new_physical_page}\nPage 2");
2525 assert!(
2526 html.contains("break-before: recto;"),
2527 "new_physical_page should use break-before: recto for duplex printing"
2528 );
2529 assert!(
2530 !html.contains("break-before: page;"),
2531 "new_physical_page should not emit generic page break"
2532 );
2533 }
2534
2535 #[test]
2536 fn test_page_control_not_replayed_in_chorus_recall() {
2537 let input = "\
2539{start_of_chorus}\n\
2540{new_page}\n\
2541[G]La la la\n\
2542{end_of_chorus}\n\
2543Verse text\n\
2544{chorus}";
2545 let html = render(input);
2546 assert!(html.contains("break-before: page;"));
2548 let count = html.matches("break-before: page;").count();
2551 assert_eq!(count, 1, "page break must not be replayed in chorus recall");
2552 }
2553
2554 #[test]
2557 fn test_image_basic() {
2558 let html = render("{image: src=photo.jpg}");
2559 assert!(html.contains("<img src=\"photo.jpg\""));
2560 }
2561
2562 #[test]
2563 fn test_image_with_dimensions() {
2564 let html = render("{image: src=photo.jpg width=200 height=100}");
2565 assert!(html.contains("width=\"200\""));
2566 assert!(html.contains("height=\"100\""));
2567 }
2568
2569 #[test]
2570 fn test_image_with_title() {
2571 let html = render("{image: src=photo.jpg title=\"My Photo\"}");
2572 assert!(html.contains("alt=\"My Photo\""));
2573 }
2574
2575 #[test]
2576 fn test_image_with_scale() {
2577 let html = render("{image: src=photo.jpg scale=0.5}");
2578 assert!(html.contains("scale(0.5)"));
2579 }
2580
2581 #[test]
2582 fn test_image_empty_src_skipped() {
2583 let html = render("{image: src=}");
2584 assert!(
2585 !html.contains("<img"),
2586 "empty src should not produce an img element"
2587 );
2588 }
2589
2590 #[test]
2591 fn test_image_javascript_uri_rejected() {
2592 let html = render("{image: src=javascript:alert(1)}");
2593 assert!(!html.contains("<img"), "javascript: URI must be rejected");
2594 }
2595
2596 #[test]
2597 fn test_image_data_uri_rejected() {
2598 let html = render("{image: src=data:text/html,<script>alert(1)</script>}");
2599 assert!(!html.contains("<img"), "data: URI must be rejected");
2600 }
2601
2602 #[test]
2603 fn test_image_vbscript_uri_rejected() {
2604 let html = render("{image: src=vbscript:MsgBox}");
2605 assert!(!html.contains("<img"), "vbscript: URI must be rejected");
2606 }
2607
2608 #[test]
2609 fn test_image_javascript_uri_case_insensitive() {
2610 let html = render("{image: src=JaVaScRiPt:alert(1)}");
2611 assert!(
2612 !html.contains("<img"),
2613 "scheme check must be case-insensitive"
2614 );
2615 }
2616
2617 #[test]
2618 fn test_image_safe_relative_path_allowed() {
2619 let html = render("{image: src=images/photo.jpg}");
2620 assert!(html.contains("<img src=\"images/photo.jpg\""));
2621 }
2622
2623 #[test]
2624 fn test_is_safe_image_src() {
2625 assert!(is_safe_image_src("photo.jpg"));
2627 assert!(is_safe_image_src("images/photo.jpg"));
2628 assert!(is_safe_image_src("path/to:file.jpg")); assert!(is_safe_image_src("http://example.com/photo.jpg"));
2632 assert!(is_safe_image_src("https://example.com/photo.jpg"));
2633 assert!(is_safe_image_src("HTTP://EXAMPLE.COM/PHOTO.JPG"));
2634
2635 assert!(!is_safe_image_src(""));
2637
2638 assert!(!is_safe_image_src("javascript:alert(1)"));
2640 assert!(!is_safe_image_src("JAVASCRIPT:alert(1)"));
2641 assert!(!is_safe_image_src(" javascript:alert(1)"));
2642 assert!(!is_safe_image_src("data:image/png;base64,abc"));
2643 assert!(!is_safe_image_src("vbscript:MsgBox"));
2644
2645 assert!(!is_safe_image_src("file:///etc/passwd"));
2647 assert!(!is_safe_image_src("FILE:///etc/passwd"));
2648 assert!(!is_safe_image_src("blob:https://example.com/uuid"));
2649 assert!(!is_safe_image_src("mhtml:file://C:/page.mhtml"));
2650
2651 assert!(!is_safe_image_src("/etc/passwd"));
2653 assert!(!is_safe_image_src("/home/user/photo.jpg"));
2654
2655 assert!(!is_safe_image_src("photo\0.jpg"));
2657 assert!(!is_safe_image_src("\0"));
2658
2659 assert!(!is_safe_image_src("../photo.jpg"));
2661 assert!(!is_safe_image_src("images/../../etc/passwd"));
2662 assert!(!is_safe_image_src(r"..\photo.jpg"));
2663 assert!(!is_safe_image_src(r"images\..\..\photo.jpg"));
2664
2665 assert!(!is_safe_image_src(r"C:\photo.jpg"));
2667 assert!(!is_safe_image_src(r"D:\Users\photo.jpg"));
2668 assert!(!is_safe_image_src(r"\\server\share\photo.jpg"));
2669 assert!(!is_safe_image_src("C:/photo.jpg"));
2670 }
2671
2672 #[test]
2673 fn test_image_anchor_column_centers() {
2674 let html = render("{image: src=photo.jpg anchor=column}");
2675 assert!(
2676 html.contains("<div style=\"text-align: center;\">"),
2677 "anchor=column should produce centered div"
2678 );
2679 }
2680
2681 #[test]
2682 fn test_image_anchor_paper_centers() {
2683 let html = render("{image: src=photo.jpg anchor=paper}");
2684 assert!(
2685 html.contains("<div style=\"text-align: center;\">"),
2686 "anchor=paper should produce centered div"
2687 );
2688 }
2689
2690 #[test]
2691 fn test_image_anchor_line_no_style() {
2692 let html = render("{image: src=photo.jpg anchor=line}");
2693 assert!(html.contains("<div><img"));
2695 assert!(!html.contains("text-align"));
2696 }
2697
2698 #[test]
2699 fn test_image_no_anchor_no_style() {
2700 let html = render("{image: src=photo.jpg}");
2701 assert!(html.contains("<div><img"));
2703 assert!(!html.contains("text-align"));
2704 }
2705
2706 #[test]
2707 fn test_image_max_width_css_present() {
2708 let html = render("{image: src=photo.jpg}");
2709 assert!(
2710 html.contains("img { max-width: 100%; height: auto; }"),
2711 "CSS should include img max-width rule to prevent overflow"
2712 );
2713 }
2714
2715 #[test]
2716 fn test_chord_diagram_css_rules_present() {
2717 let html = render("{define: Am base-fret 1 frets x 0 2 2 1 0}");
2718 assert!(
2719 html.contains(".chord-diagram-container"),
2720 "CSS should include .chord-diagram-container rule"
2721 );
2722 assert!(
2723 html.contains(".chord-diagram {"),
2724 "CSS should include .chord-diagram rule"
2725 );
2726 }
2727
2728 #[test]
2731 fn test_define_renders_svg_diagram() {
2732 let html = render("{define: Am base-fret 1 frets x 0 2 2 1 0}");
2733 assert!(html.contains("<svg"));
2734 assert!(html.contains("Am"));
2735 assert!(html.contains("chord-diagram"));
2736 }
2737
2738 #[test]
2739 fn test_define_keyboard_renders_keyboard_svg() {
2740 let html = render("{define: Am keys 0 3 7}");
2742 assert!(
2743 html.contains("<svg"),
2744 "keyboard define should produce an SVG"
2745 );
2746 assert!(
2747 html.contains("keyboard-diagram"),
2748 "should use keyboard-diagram CSS class"
2749 );
2750 assert!(html.contains("Am"), "chord name should appear in SVG");
2751 }
2752
2753 #[test]
2754 fn test_define_keyboard_absolute_midi_renders_svg() {
2755 let html = render("{define: Cmaj7 keys 60 64 67 71}");
2757 assert!(html.contains("<svg"));
2758 assert!(html.contains("keyboard-diagram"));
2759 assert!(html.contains("Cmaj7"));
2760 }
2761
2762 #[test]
2763 fn test_diagrams_piano_auto_inject() {
2764 let input = "{diagrams: piano}\n[Am]Hello [C]world";
2765 let html = render(input);
2766 assert!(
2768 html.contains("keyboard-diagram"),
2769 "piano instrument should use keyboard diagrams"
2770 );
2771 assert!(
2772 html.contains("chord-diagrams"),
2773 "diagram section should be present"
2774 );
2775 }
2776
2777 #[test]
2778 fn test_define_ukulele_diagram() {
2779 let html = render("{define: C frets 0 0 0 3}");
2780 assert!(html.contains("<svg"));
2781 assert!(html.contains("chord-diagram"));
2782 assert!(
2784 html.contains("width=\"88\""),
2785 "Expected 4-string SVG width (88)"
2786 );
2787 }
2788
2789 #[test]
2790 fn test_define_banjo_diagram() {
2791 let html = render("{define: G frets 0 0 0 0 0}");
2792 assert!(html.contains("<svg"));
2793 assert!(
2795 html.contains("width=\"104\""),
2796 "Expected 5-string SVG width (104)"
2797 );
2798 }
2799
2800 #[test]
2801 fn test_diagrams_frets_config_controls_svg_height() {
2802 let input = "{define: Am base-fret 1 frets x 0 2 2 1 0}";
2803 let song = chordsketch_core::parse(input).unwrap();
2804 let config = chordsketch_core::config::Config::defaults()
2805 .with_define("diagrams.frets=4")
2806 .unwrap();
2807 let html = render_song_with_transpose(&song, 0, &config);
2808 assert!(
2810 html.contains("height=\"140\""),
2811 "SVG height should reflect diagrams.frets=4 (expected 140)"
2812 );
2813 }
2814
2815 #[test]
2818 fn test_diagrams_off_suppresses_chord_diagrams() {
2819 let html = render("{diagrams: off}\n{define: Am base-fret 1 frets x 0 2 2 1 0}");
2820 assert!(
2821 !html.contains("<svg"),
2822 "chord diagram SVG should be suppressed when diagrams=off"
2823 );
2824 }
2825
2826 #[test]
2827 fn test_diagrams_on_shows_chord_diagrams() {
2828 let html = render("{diagrams: on}\n{define: Am base-fret 1 frets x 0 2 2 1 0}");
2829 assert!(
2830 html.contains("<svg"),
2831 "chord diagram SVG should be shown when diagrams=on"
2832 );
2833 }
2834
2835 #[test]
2836 fn test_diagrams_default_shows_chord_diagrams() {
2837 let html = render("{define: Am base-fret 1 frets x 0 2 2 1 0}");
2838 assert!(
2839 html.contains("<svg"),
2840 "chord diagram SVG should be shown by default"
2841 );
2842 }
2843
2844 #[test]
2845 fn test_diagrams_off_then_on_restores() {
2846 let html = render(
2847 "{diagrams: off}\n{define: Am base-fret 1 frets x 0 2 2 1 0}\n{diagrams: on}\n{define: G base-fret 1 frets 3 2 0 0 0 3}",
2848 );
2849 assert!(!html.contains(">Am<"), "Am diagram should be suppressed");
2851 assert!(html.contains(">G<"), "G diagram should be rendered");
2852 }
2853
2854 #[test]
2855 fn test_diagrams_parsed_as_known_directive() {
2856 let song = chordsketch_core::parse("{diagrams: off}").unwrap();
2857 if let chordsketch_core::ast::Line::Directive(d) = &song.lines[0] {
2858 assert_eq!(
2859 d.kind,
2860 chordsketch_core::ast::DirectiveKind::Diagrams,
2861 "diagrams should parse as DirectiveKind::Diagrams"
2862 );
2863 assert_eq!(d.value, Some("off".to_string()));
2864 } else {
2865 panic!("expected a directive line, got: {:?}", &song.lines[0]);
2866 }
2867 }
2868
2869 #[test]
2872 fn test_diagrams_off_case_insensitive() {
2873 let html = render("{diagrams: Off}\n{define: Am base-fret 1 frets x 0 2 2 1 0}");
2874 assert!(
2875 !html.contains("<svg"),
2876 "diagrams=Off should suppress diagrams (case-insensitive)"
2877 );
2878 }
2879
2880 #[test]
2881 fn test_diagrams_off_uppercase() {
2882 let html = render("{diagrams: OFF}\n{define: Am base-fret 1 frets x 0 2 2 1 0}");
2883 assert!(
2884 !html.contains("<svg"),
2885 "diagrams=OFF should suppress diagrams (case-insensitive)"
2886 );
2887 }
2888
2889 #[test]
2892 fn test_diagrams_auto_inject_from_builtin_db() {
2893 let html = render("{diagrams}\n[Am]Hello [G]World");
2895 assert!(
2896 html.contains("class=\"chord-diagrams\""),
2897 "should render chord-diagrams section"
2898 );
2899 assert!(html.contains(">Am<"), "Am diagram expected");
2901 assert!(html.contains(">G<"), "G diagram expected");
2902 }
2903
2904 #[test]
2905 fn test_diagrams_auto_inject_unknown_chord_skipped() {
2906 let html = render("{diagrams}\n[Xyzzy]Hello");
2908 assert!(
2910 !html.contains("class=\"chord-diagrams\""),
2911 "no diagram section for unknown chord"
2912 );
2913 }
2914
2915 #[test]
2916 fn test_no_diagrams_suppresses_auto_inject() {
2917 let html = render("{no_diagrams}\n[Am]Hello");
2918 assert!(
2919 !html.contains("class=\"chord-diagrams\""),
2920 "{{no_diagrams}} should suppress auto-inject"
2921 );
2922 }
2923
2924 #[test]
2925 fn test_diagrams_define_takes_priority_over_builtin() {
2926 let html = render("{diagrams}\n{define: Am base-fret 1 frets x 0 2 2 1 0}\n[Am]Hello");
2930 assert!(
2932 html.contains("font-weight=\"bold\">Am</text>"),
2933 "Am diagram should appear inline at the {{define}} position"
2934 );
2935 assert!(
2937 !html.contains("class=\"chord-diagrams\""),
2938 "auto-inject section should be absent when all used chords are defined"
2939 );
2940 }
2941
2942 #[test]
2943 fn test_diagrams_off_suppresses_auto_inject() {
2944 let html = render("{diagrams: off}\n[Am]Hello");
2945 assert!(
2946 !html.contains("class=\"chord-diagrams\""),
2947 "{{diagrams: off}} should suppress auto-inject grid"
2948 );
2949 }
2950
2951 #[test]
2952 fn test_diagrams_ukulele_instrument() {
2953 let html = render("{diagrams: ukulele}\n[Am]Hello");
2954 assert!(
2955 html.contains("class=\"chord-diagrams\""),
2956 "ukulele diagrams section expected"
2957 );
2958 assert!(html.contains(">Am<"), "Am diagram expected");
2960 }
2961
2962 #[test]
2963 fn test_diagrams_guitar_explicit_overrides_config_default() {
2964 let song = chordsketch_core::parse("{diagrams: guitar}\n[Am]Hello").unwrap();
2967 let config = chordsketch_core::config::Config::defaults()
2968 .with_define("diagrams.instrument=ukulele")
2969 .unwrap();
2970 let html = render_song_with_transpose(&song, 0, &config);
2971 assert!(
2972 html.contains("class=\"chord-diagrams\""),
2973 "guitar diagrams section expected"
2974 );
2975 assert!(html.contains(">Am<"), "Am diagram expected");
2976 let guitar_am_html = render_song_with_transpose(
2977 &chordsketch_core::parse("{diagrams: guitar}\n[Am]Hello").unwrap(),
2978 0,
2979 &chordsketch_core::config::Config::defaults(),
2980 );
2981 let uke_am_html = render_song_with_transpose(
2982 &chordsketch_core::parse("{diagrams: ukulele}\n[Am]Hello").unwrap(),
2983 0,
2984 &chordsketch_core::config::Config::defaults(),
2985 );
2986 assert_ne!(
2988 guitar_am_html, uke_am_html,
2989 "guitar and ukulele Am diagrams should differ"
2990 );
2991 assert_eq!(
2994 html, guitar_am_html,
2995 "{{diagrams: guitar}} must select guitar regardless of config default"
2996 );
2997 }
2998
2999 #[test]
3000 fn test_no_diagrams_suppresses_inline_define_diagrams() {
3001 let html = render("{no_diagrams}\n{define: Am base-fret 1 frets x 0 2 2 1 0}\n[Am]Hello");
3004 assert!(
3005 !html.contains("<svg"),
3006 "{{no_diagrams}} should suppress inline define diagram SVG"
3007 );
3008 }
3009
3010 #[test]
3011 fn test_define_chord_not_duplicated_in_auto_inject_grid() {
3012 let html =
3016 render("{define: Am base-fret 1 frets x 0 2 2 1 0}\n{diagrams}\n[Am]Hello [G]world\n");
3017 let am_svg_count = html.match_indices("font-weight=\"bold\">Am</text>").count();
3019 assert_eq!(
3020 am_svg_count, 1,
3021 "Am diagram should appear exactly once (inline via {{define}}), not also in auto-inject grid"
3022 );
3023 assert!(
3025 html.contains("font-weight=\"bold\">G</text>"),
3026 "G diagram should appear in the auto-inject grid"
3027 );
3028 }
3029
3030 #[test]
3031 fn test_define_after_nodiagrams_appears_in_grid() {
3032 let html = render(
3036 "{no_diagrams}\n{define: Am base-fret 1 frets x 0 2 2 1 0}\n{diagrams}\n[Am]Hello\n",
3037 );
3038 assert!(
3041 html.contains("class=\"chord-diagrams\""),
3042 "auto-inject grid should appear since Am was not rendered inline"
3043 );
3044 assert!(
3045 html.contains("font-weight=\"bold\">Am</text>"),
3046 "Am should appear in the auto-inject grid"
3047 );
3048 }
3049
3050 #[test]
3051 fn test_enharmonic_define_dedup() {
3052 let html = render("{define: Bb base-fret 1 frets x 1 3 3 3 1}\n{diagrams}\n[A#]Hello\n");
3056 let bb_count = html.match_indices("font-weight=\"bold\">Bb</text>").count();
3058 let as_count = html.match_indices("font-weight=\"bold\">A#</text>").count();
3059 assert_eq!(bb_count, 1, "Bb should appear once (inline)");
3060 assert_eq!(
3061 as_count, 0,
3062 "A# should NOT appear in the auto-inject grid (same chord as Bb)"
3063 );
3064 }
3065
3066 #[test]
3067 fn test_chord_directive_appears_in_auto_inject_grid() {
3068 let html = render("{chord: Am base-fret 1 frets x 0 2 2 1 0}\n{diagrams}\n[Am]Hello\n");
3071 assert!(
3074 html.contains("class=\"chord-diagrams\""),
3075 "auto-inject grid should appear since {{chord}} does not render inline"
3076 );
3077 assert!(
3078 html.contains("font-weight=\"bold\">Am</text>"),
3079 "Am should appear in the auto-inject grid via {{chord}} voicing"
3080 );
3081 }
3082
3083 #[test]
3086 fn test_abc_section_disabled_by_config() {
3087 let input = "{start_of_abc}\nX:1\n{end_of_abc}";
3089 let song = chordsketch_core::parse(input).unwrap();
3090 let config = chordsketch_core::config::Config::defaults()
3091 .with_define("delegates.abc2svg=false")
3092 .unwrap();
3093 let html = render_song_with_transpose(&song, 0, &config);
3094 assert!(html.contains("<section class=\"abc\">"));
3095 assert!(html.contains("ABC"));
3096 assert!(html.contains("</section>"));
3097 }
3098
3099 #[test]
3100 fn test_abc_section_null_config_auto_detect_disabled() {
3101 if chordsketch_core::external_tool::has_abc2svg() {
3104 return; }
3106 let input = "{start_of_abc}\nX:1\n{end_of_abc}";
3107 let song = chordsketch_core::parse(input).unwrap();
3108 let config = chordsketch_core::config::Config::defaults();
3110 assert!(
3111 config.get_path("delegates.abc2svg").is_null(),
3112 "default config should have null delegates.abc2svg"
3113 );
3114 let html = render_song_with_transpose(&song, 0, &config);
3115 assert!(
3116 html.contains("<section class=\"abc\">"),
3117 "null auto-detect with no abc2svg should render as text section"
3118 );
3119 }
3120
3121 #[test]
3122 fn test_abc_section_fallback_preformatted() {
3123 if chordsketch_core::external_tool::has_abc2svg() {
3125 return; }
3127 let input = "{start_of_abc}\nX:1\nT:Test\nK:C\n{end_of_abc}";
3128 let song = chordsketch_core::parse(input).unwrap();
3129 let config = chordsketch_core::config::Config::defaults()
3130 .with_define("delegates.abc2svg=true")
3131 .unwrap();
3132 let html = render_song_with_transpose(&song, 0, &config);
3133 assert!(html.contains("<section class=\"abc\">"));
3134 assert!(html.contains("<pre>"));
3135 assert!(html.contains("X:1"));
3136 assert!(html.contains("</pre>"));
3137 }
3138
3139 #[test]
3140 fn test_abc_section_with_label_delegate_fallback() {
3141 if chordsketch_core::external_tool::has_abc2svg() {
3142 return;
3143 }
3144 let input = "{start_of_abc: Melody}\nX:1\n{end_of_abc}";
3145 let song = chordsketch_core::parse(input).unwrap();
3146 let config = chordsketch_core::config::Config::defaults()
3147 .with_define("delegates.abc2svg=true")
3148 .unwrap();
3149 let html = render_song_with_transpose(&song, 0, &config);
3150 assert!(html.contains("ABC: Melody"));
3151 assert!(html.contains("<pre>"));
3152 }
3153
3154 #[test]
3155 #[ignore]
3156 fn test_abc_section_renders_svg_with_abc2svg() {
3157 let input = "{start_of_abc}\nX:1\nT:Test\nM:4/4\nK:C\nCDEF|GABc|\n{end_of_abc}";
3159 let song = chordsketch_core::parse(input).unwrap();
3160 let config = chordsketch_core::config::Config::defaults()
3161 .with_define("delegates.abc2svg=true")
3162 .unwrap();
3163 let html = render_song_with_transpose(&song, 0, &config);
3164 assert!(html.contains("<section class=\"abc\">"));
3165 assert!(
3166 html.contains("<svg"),
3167 "should contain rendered SVG from abc2svg"
3168 );
3169 assert!(html.contains("</section>"));
3170 }
3171
3172 #[test]
3173 fn test_abc_section_auto_detect_default_config() {
3174 let input = "{start_of_abc}\nX:1\nT:Test\nK:C\n{end_of_abc}";
3178 let song = chordsketch_core::parse(input).unwrap();
3179 let config = chordsketch_core::config::Config::defaults();
3180 let html = render_song_with_transpose(&song, 0, &config);
3181 assert!(
3182 html.contains("<section class=\"abc\">"),
3183 "auto-detect should produce abc section"
3184 );
3185 if !chordsketch_core::external_tool::has_abc2svg() {
3186 assert!(
3187 html.contains("X:1"),
3188 "raw ABC content should be present without tool"
3189 );
3190 assert!(
3191 !html.contains("<svg"),
3192 "no SVG should be generated without abc2svg"
3193 );
3194 }
3195 }
3196
3197 #[test]
3200 fn test_ly_section_auto_detect_default_config() {
3201 let input = "{start_of_ly}\n\\relative c' { c4 }\n{end_of_ly}";
3203 let song = chordsketch_core::parse(input).unwrap();
3204 let config = chordsketch_core::config::Config::defaults();
3205 let html = render_song_with_transpose(&song, 0, &config);
3206 assert!(
3207 html.contains("<section class=\"ly\">"),
3208 "auto-detect should produce ly section"
3209 );
3210 if !chordsketch_core::external_tool::has_lilypond() {
3211 assert!(
3212 html.contains("\\relative"),
3213 "raw Lilypond content should be present without tool"
3214 );
3215 assert!(
3216 !html.contains("<svg"),
3217 "no SVG should be generated without lilypond"
3218 );
3219 }
3220 }
3221
3222 #[test]
3223 fn test_ly_section_disabled_by_config() {
3224 let input = "{start_of_ly}\n\\relative c' { c4 }\n{end_of_ly}";
3226 let song = chordsketch_core::parse(input).unwrap();
3227 let config = chordsketch_core::config::Config::defaults()
3228 .with_define("delegates.lilypond=false")
3229 .unwrap();
3230 let html = render_song_with_transpose(&song, 0, &config);
3231 assert!(html.contains("<section class=\"ly\">"));
3232 assert!(html.contains("Lilypond"));
3233 assert!(html.contains("</section>"));
3234 }
3235
3236 #[test]
3237 fn test_ly_section_fallback_preformatted() {
3238 if chordsketch_core::external_tool::has_lilypond() {
3239 return;
3240 }
3241 let input = "{start_of_ly}\n\\relative c' { c4 }\n{end_of_ly}";
3242 let song = chordsketch_core::parse(input).unwrap();
3243 let config = chordsketch_core::config::Config::defaults()
3244 .with_define("delegates.lilypond=true")
3245 .unwrap();
3246 let html = render_song_with_transpose(&song, 0, &config);
3247 assert!(html.contains("<section class=\"ly\">"));
3248 assert!(html.contains("<pre>"));
3249 assert!(html.contains("</pre>"));
3250 }
3251
3252 #[test]
3253 #[ignore]
3254 fn test_ly_section_renders_svg_with_lilypond() {
3255 let input = "{start_of_ly}\n\\relative c' { c4 d e f | g2 g | }\n{end_of_ly}";
3257 let song = chordsketch_core::parse(input).unwrap();
3258 let config = chordsketch_core::config::Config::defaults()
3259 .with_define("delegates.lilypond=true")
3260 .unwrap();
3261 let html = render_song_with_transpose(&song, 0, &config);
3262 assert!(html.contains("<section class=\"ly\">"));
3263 assert!(
3264 html.contains("<svg"),
3265 "should contain rendered SVG from lilypond"
3266 );
3267 assert!(html.contains("</section>"));
3268 }
3269}
3270
3271#[cfg(test)]
3272mod delegate_tests {
3273 use super::*;
3274
3275 #[test]
3276 fn test_render_abc_section() {
3277 let html = render("{start_of_abc}\nX:1\n{end_of_abc}");
3278 assert!(html.contains("<section class=\"abc\">"));
3279 assert!(html.contains("ABC"));
3280 assert!(html.contains("</section>"));
3281 }
3282
3283 #[test]
3284 fn test_render_abc_section_with_label() {
3285 let html = render("{start_of_abc: Melody}\nX:1\n{end_of_abc}");
3286 assert!(html.contains("<section class=\"abc\">"));
3287 assert!(html.contains("ABC: Melody"));
3288 }
3289
3290 #[test]
3291 fn test_render_ly_section() {
3292 let html = render("{start_of_ly}\nnotes\n{end_of_ly}");
3293 assert!(html.contains("<section class=\"ly\">"));
3294 assert!(html.contains("Lilypond"));
3295 assert!(html.contains("</section>"));
3296 }
3297
3298 #[test]
3301 fn test_render_musicxml_section_disabled() {
3302 let input = "{start_of_musicxml}\n<score-partwise/>\n{end_of_musicxml}";
3304 let song = chordsketch_core::parse(input).unwrap();
3305 let config = chordsketch_core::config::Config::defaults()
3306 .with_define("delegates.musescore=false")
3307 .unwrap();
3308 let html = render_song_with_transpose(&song, 0, &config);
3309 assert!(
3310 html.contains("<section class=\"musicxml\">"),
3311 "fallback section should render when musescore is disabled: {html}"
3312 );
3313 assert!(html.contains("MusicXML"), "section label should appear");
3314 assert!(html.contains("</section>"), "section should be closed");
3315 }
3316
3317 #[test]
3318 fn test_render_musicxml_section_no_musescore_installed() {
3319 if chordsketch_core::external_tool::has_musescore() {
3322 return; }
3324
3325 let input = "{start_of_musicxml}\n<score-partwise/>\n{end_of_musicxml}";
3326 let song = chordsketch_core::parse(input).unwrap();
3327 let config = chordsketch_core::config::Config::defaults();
3328 assert!(
3329 config.get_path("delegates.musescore").is_null(),
3330 "default config should have null delegates.musescore"
3331 );
3332 let html = render_song_with_transpose(&song, 0, &config);
3333 assert!(
3334 html.contains("<section class=\"musicxml\">"),
3335 "null auto-detect with no musescore should render as text section"
3336 );
3337 }
3338
3339 #[test]
3340 fn test_render_musicxml_section_with_label() {
3341 let input = "{start_of_musicxml: Score}\n<score-partwise/>\n{end_of_musicxml}";
3342 let song = chordsketch_core::parse(input).unwrap();
3343 let config = chordsketch_core::config::Config::defaults()
3344 .with_define("delegates.musescore=false")
3345 .unwrap();
3346 let html = render_song_with_transpose(&song, 0, &config);
3347 assert!(
3348 html.contains("Score"),
3349 "label should appear in section header"
3350 );
3351 }
3352
3353 #[test]
3354 fn test_abc_fallback_sanitizes_would_be_script_in_svg() {
3355 let malicious_svg = "<svg><script>alert(1)</script><circle r=\"5\"/></svg>";
3359 let sanitized = sanitize_svg_content(malicious_svg);
3360 assert!(
3361 !sanitized.contains("<script>"),
3362 "script tags must be stripped from delegate SVG output"
3363 );
3364 assert!(sanitized.contains("<circle"));
3365 }
3366
3367 #[test]
3368 fn test_sanitize_svg_strips_event_handlers_from_delegate_output() {
3369 let svg_with_handler = "<svg><rect onmouseover=\"alert(1)\" width=\"10\"/></svg>";
3370 let sanitized = sanitize_svg_content(svg_with_handler);
3371 assert!(
3372 !sanitized.contains("onmouseover"),
3373 "event handlers must be stripped from delegate SVG output"
3374 );
3375 assert!(sanitized.contains("<rect"));
3376 }
3377
3378 #[test]
3379 fn test_sanitize_svg_strips_foreignobject_from_delegate_output() {
3380 let svg = "<svg><foreignObject><body xmlns=\"http://www.w3.org/1999/xhtml\"><script>alert(1)</script></body></foreignObject></svg>";
3381 let sanitized = sanitize_svg_content(svg);
3382 assert!(
3383 !sanitized.contains("<foreignObject"),
3384 "foreignObject must be stripped from delegate SVG output"
3385 );
3386 }
3387
3388 #[test]
3389 fn test_sanitize_svg_strips_math_element() {
3390 let svg = "<svg><math><mi>x</mi></math></svg>";
3391 let sanitized = sanitize_svg_content(svg);
3392 assert!(
3393 !sanitized.contains("<math"),
3394 "math element must be stripped from delegate SVG output"
3395 );
3396 }
3397
3398 #[test]
3399 fn test_render_svg_section() {
3400 let html = render("{start_of_svg}\n<svg/>\n{end_of_svg}");
3401 assert!(html.contains("<div class=\"svg-section\">"));
3403 assert!(html.contains("<svg/>"));
3404 assert!(html.contains("</div>"));
3405 }
3406
3407 #[test]
3408 fn test_render_svg_inline_content() {
3409 let svg = r#"<svg width="100" height="100"><circle cx="50" cy="50" r="40"/></svg>"#;
3410 let input = format!("{{start_of_svg}}\n{svg}\n{{end_of_svg}}");
3411 let html = render(&input);
3412 assert!(html.contains(svg));
3413 }
3414
3415 #[test]
3416 fn test_svg_section_strips_script_tags() {
3417 let input = "{start_of_svg}\n<svg><script>alert('xss')</script><circle r=\"10\"/></svg>\n{end_of_svg}";
3418 let html = render(input);
3419 assert!(!html.contains("<script>"), "script tags must be stripped");
3420 assert!(!html.contains("alert"), "script content must be stripped");
3421 assert!(
3422 html.contains("<circle r=\"10\"/>"),
3423 "safe SVG content must be preserved"
3424 );
3425 }
3426
3427 #[test]
3428 fn test_svg_section_strips_event_handlers() {
3429 let input = "{start_of_svg}\n<svg onload=\"alert(1)\"><rect width=\"10\" onerror=\"hack()\"/></svg>\n{end_of_svg}";
3430 let html = render(input);
3431 assert!(!html.contains("onload"), "onload handler must be stripped");
3432 assert!(
3433 !html.contains("onerror"),
3434 "onerror handler must be stripped"
3435 );
3436 assert!(
3437 html.contains("width=\"10\""),
3438 "safe attributes must be preserved"
3439 );
3440 }
3441
3442 #[test]
3443 fn test_svg_section_preserves_safe_content() {
3444 let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><text x="10" y="20">Hello</text></svg>"#;
3445 let input = format!("{{start_of_svg}}\n{svg}\n{{end_of_svg}}");
3446 let html = render(&input);
3447 assert!(html.contains("xmlns=\"http://www.w3.org/2000/svg\""));
3448 assert!(html.contains("<text x=\"10\" y=\"20\">Hello</text>"));
3449 }
3450
3451 #[test]
3452 fn test_svg_section_strips_case_insensitive_script() {
3453 let input = "{start_of_svg}\n<SCRIPT>alert(1)</SCRIPT><svg/>\n{end_of_svg}";
3454 let html = render(input);
3455 assert!(!html.contains("SCRIPT"), "case-insensitive script removal");
3456 assert!(!html.contains("alert"));
3457 assert!(html.contains("<svg/>"));
3458 }
3459
3460 #[test]
3461 fn test_svg_section_strips_foreignobject() {
3462 let input = "{start_of_svg}\n<svg><foreignObject><body onload=\"alert(1)\"></body></foreignObject><rect width=\"10\"/></svg>\n{end_of_svg}";
3463 let html = render(input);
3464 assert!(
3465 !html.contains("foreignObject"),
3466 "foreignObject must be stripped"
3467 );
3468 assert!(
3469 !html.contains("foreignobject"),
3470 "foreignObject (lowercase) must be stripped"
3471 );
3472 assert!(
3473 html.contains("<rect width=\"10\"/>"),
3474 "safe content must be preserved"
3475 );
3476 }
3477
3478 #[test]
3479 fn test_svg_section_strips_iframe() {
3480 let input = "{start_of_svg}\n<svg><iframe src=\"javascript:alert(1)\"></iframe><circle r=\"5\"/></svg>\n{end_of_svg}";
3481 let html = render(input);
3482 assert!(!html.contains("iframe"), "iframe must be stripped");
3483 assert!(html.contains("<circle r=\"5\"/>"));
3484 }
3485
3486 #[test]
3487 fn test_svg_section_strips_object_and_embed() {
3488 let input = "{start_of_svg}\n<svg><object data=\"evil.swf\"></object><embed src=\"evil.swf\"></embed><rect/></svg>\n{end_of_svg}";
3489 let html = render(input);
3490 assert!(!html.contains("object"), "object must be stripped");
3491 assert!(!html.contains("embed"), "embed must be stripped");
3492 assert!(html.contains("<rect/>"));
3493 }
3494
3495 #[test]
3496 fn test_svg_section_strips_javascript_uri_in_href() {
3497 let input = "{start_of_svg}\n<svg><a href=\"javascript:alert(1)\"><text>Click</text></a></svg>\n{end_of_svg}";
3498 let html = render(input);
3499 assert!(
3500 !html.contains("javascript:"),
3501 "javascript: URI must be stripped from href"
3502 );
3503 assert!(html.contains("<text>Click</text>"));
3504 }
3505
3506 #[test]
3507 fn test_svg_section_strips_vbscript_uri() {
3508 let input = "{start_of_svg}\n<svg><a href=\"vbscript:MsgBox\"><text>Click</text></a></svg>\n{end_of_svg}";
3509 let html = render(input);
3510 assert!(
3511 !html.contains("vbscript:"),
3512 "vbscript: URI must be stripped"
3513 );
3514 }
3515
3516 #[test]
3517 fn test_svg_section_strips_data_uri_in_use() {
3518 let input = "{start_of_svg}\n<svg><use href=\"data:image/svg+xml;base64,PHN2Zy8+\"/></svg>\n{end_of_svg}";
3519 let html = render(input);
3520 assert!(
3521 !html.contains("data:"),
3522 "data: URI must be stripped from use href"
3523 );
3524 }
3525
3526 #[test]
3527 fn test_svg_section_strips_javascript_uri_case_insensitive() {
3528 let input = "{start_of_svg}\n<svg><a href=\"JaVaScRiPt:alert(1)\"><text>X</text></a></svg>\n{end_of_svg}";
3529 let html = render(input);
3530 assert!(
3531 !html.to_lowercase().contains("javascript:"),
3532 "case-insensitive javascript: URI must be stripped"
3533 );
3534 }
3535
3536 #[test]
3537 fn test_svg_section_strips_xlink_href_dangerous_uri() {
3538 let input =
3539 "{start_of_svg}\n<svg><use xlink:href=\"javascript:alert(1)\"/></svg>\n{end_of_svg}";
3540 let html = render(input);
3541 assert!(
3542 !html.contains("javascript:"),
3543 "javascript: URI in xlink:href must be stripped"
3544 );
3545 }
3546
3547 #[test]
3548 fn test_svg_section_preserves_safe_href() {
3549 let input = "{start_of_svg}\n<svg><a href=\"https://example.com\"><text>Link</text></a></svg>\n{end_of_svg}";
3550 let html = render(input);
3551 assert!(
3552 html.contains("href=\"https://example.com\""),
3553 "safe https: href must be preserved"
3554 );
3555 }
3556
3557 #[test]
3558 fn test_svg_section_preserves_fragment_href() {
3559 let input = "{start_of_svg}\n<svg><use href=\"#myShape\"/></svg>\n{end_of_svg}";
3560 let html = render(input);
3561 assert!(
3562 html.contains("href=\"#myShape\""),
3563 "fragment-only href must be preserved"
3564 );
3565 }
3566
3567 #[test]
3568 fn test_render_textblock_section() {
3569 let html = render("{start_of_textblock}\nPreformatted\n{end_of_textblock}");
3570 assert!(html.contains("<section class=\"textblock\">"));
3571 assert!(html.contains("Textblock"));
3572 assert!(html.contains("</section>"));
3573 }
3574
3575 #[test]
3578 fn test_render_songs_single() {
3579 let songs = chordsketch_core::parse_multi("{title: Only}").unwrap();
3580 let html = render_songs(&songs);
3581 assert_eq!(html, render_song(&songs[0]));
3583 }
3584
3585 #[test]
3586 fn test_render_songs_two_songs_with_hr_separator() {
3587 let songs = chordsketch_core::parse_multi(
3588 "{title: Song A}\n[Am]Hello\n{new_song}\n{title: Song B}\n[G]World",
3589 )
3590 .unwrap();
3591 let html = render_songs(&songs);
3592 assert!(html.contains("<title>Song A</title>"));
3594 assert!(html.contains("<h1>Song A</h1>"));
3596 assert!(html.contains("<h1>Song B</h1>"));
3597 assert!(html.contains("<hr class=\"song-separator\">"));
3599 assert_eq!(html.matches("<div class=\"song\">").count(), 2);
3601 assert_eq!(html.matches("<!DOCTYPE html>").count(), 1);
3603 assert_eq!(html.matches("</html>").count(), 1);
3604 }
3605
3606 #[test]
3607 fn test_image_scale_css_injection_prevented() {
3608 let html = render("{image: src=photo.jpg scale=0.5); position: fixed; z-index: 9999}");
3611 assert!(!html.contains("position"));
3612 assert!(!html.contains("z-index"));
3613 assert!(!html.contains("position: fixed"));
3615 }
3616
3617 #[test]
3618 fn test_render_songs_with_transpose() {
3619 let songs =
3620 chordsketch_core::parse_multi("{title: S1}\n[C]Do\n{new_song}\n{title: S2}\n[G]Re")
3621 .unwrap();
3622 let html = render_songs_with_transpose(&songs, 2, &Config::defaults());
3623 assert!(html.contains(">D<"));
3625 assert!(html.contains(">A<"));
3626 }
3627
3628 #[test]
3631 fn test_sanitize_svg_strips_set_element() {
3632 let svg = r##"<svg><a href="#"><set attributeName="href" to="javascript:alert(1)"/><text>Click</text></a></svg>"##;
3633 let sanitized = sanitize_svg_content(svg);
3634 assert!(
3635 !sanitized.contains("<set"),
3636 "set element must be stripped to prevent SVG animation XSS"
3637 );
3638 assert!(sanitized.contains("<text>Click</text>"));
3639 }
3640
3641 #[test]
3642 fn test_sanitize_svg_strips_animate_element() {
3643 let svg =
3644 r#"<svg><animate attributeName="href" values="javascript:alert(1)"/><rect/></svg>"#;
3645 let sanitized = sanitize_svg_content(svg);
3646 assert!(
3647 !sanitized.contains("<animate"),
3648 "animate element must be stripped"
3649 );
3650 assert!(sanitized.contains("<rect/>"));
3651 }
3652
3653 #[test]
3654 fn test_sanitize_svg_strips_animatetransform() {
3655 let svg =
3656 "<svg><animateTransform attributeName=\"transform\" type=\"rotate\"/><rect/></svg>";
3657 let sanitized = sanitize_svg_content(svg);
3658 assert!(
3659 !sanitized.contains("animateTransform"),
3660 "animateTransform must be stripped"
3661 );
3662 assert!(
3663 !sanitized.contains("animatetransform"),
3664 "animatetransform (lowercase) must be stripped"
3665 );
3666 }
3667
3668 #[test]
3669 fn test_sanitize_svg_strips_animatemotion() {
3670 let svg = "<svg><animateMotion path=\"M0,0 L100,100\"/><rect/></svg>";
3671 let sanitized = sanitize_svg_content(svg);
3672 assert!(
3673 !sanitized.contains("animateMotion"),
3674 "animateMotion must be stripped"
3675 );
3676 }
3677
3678 #[test]
3679 fn test_sanitize_svg_strips_to_attr_with_dangerous_uri() {
3680 let svg = r#"<svg><a to="javascript:alert(1)"><text>X</text></a></svg>"#;
3681 let sanitized = sanitize_svg_content(svg);
3682 assert!(
3683 !sanitized.contains("javascript:"),
3684 "dangerous URI in 'to' attr must be stripped"
3685 );
3686 }
3687
3688 #[test]
3689 fn test_sanitize_svg_strips_values_attr_with_dangerous_uri() {
3690 let svg = r#"<svg><a values="javascript:alert(1)"><text>X</text></a></svg>"#;
3691 let sanitized = sanitize_svg_content(svg);
3692 assert!(
3693 !sanitized.contains("javascript:"),
3694 "dangerous URI in 'values' attr must be stripped"
3695 );
3696 }
3697
3698 #[test]
3701 fn test_strip_dangerous_attrs_preserves_cjk_text() {
3702 let input = "<svg><text x=\"10\">日本語テスト</text></svg>";
3703 let result = strip_dangerous_attrs(input);
3704 assert!(
3705 result.contains("日本語テスト"),
3706 "CJK characters must not be corrupted"
3707 );
3708 }
3709
3710 #[test]
3711 fn test_strip_dangerous_attrs_preserves_emoji() {
3712 let input = "<svg><text>🎵🎸🎹</text></svg>";
3713 let result = strip_dangerous_attrs(input);
3714 assert!(result.contains("🎵🎸🎹"), "emoji must not be corrupted");
3715 }
3716
3717 #[test]
3718 fn test_strip_dangerous_attrs_preserves_accented_chars() {
3719 let input = "<svg><text>café résumé naïve</text></svg>";
3720 let result = strip_dangerous_attrs(input);
3721 assert!(
3722 result.contains("café résumé naïve"),
3723 "accented characters must not be corrupted"
3724 );
3725 }
3726
3727 #[test]
3728 fn test_sanitize_svg_full_roundtrip_with_non_ascii() {
3729 let input = "<svg><text x=\"10\">コード譜 🎵</text><rect width=\"100\"/></svg>";
3730 let sanitized = sanitize_svg_content(input);
3731 assert!(sanitized.contains("コード譜 🎵"));
3732 assert!(sanitized.contains("<rect width=\"100\"/>"));
3733 }
3734
3735 #[test]
3736 fn test_sanitize_svg_self_closing_with_gt_in_attr_value() {
3737 let svg = r#"<svg><set to="a>b"/><text>safe</text></svg>"#;
3739 let sanitized = sanitize_svg_content(svg);
3740 assert!(
3741 !sanitized.contains("<set"),
3742 "dangerous <set> element must be stripped"
3743 );
3744 assert!(
3745 sanitized.contains("<text>safe</text>"),
3746 "content after stripped self-closing element must be preserved"
3747 );
3748 }
3749
3750 #[test]
3753 fn test_strip_dangerous_attrs_gt_in_double_quoted_attr() {
3754 let input = r#"<rect title=">" onload="alert(1)"/>"#;
3756 let result = strip_dangerous_attrs(input);
3757 assert!(
3758 !result.contains("onload"),
3759 "onload after quoted > must be stripped"
3760 );
3761 assert!(result.contains("title"));
3762 }
3763
3764 #[test]
3765 fn test_strip_dangerous_attrs_gt_in_single_quoted_attr() {
3766 let input = "<rect title='>' onload=\"alert(1)\"/>";
3767 let result = strip_dangerous_attrs(input);
3768 assert!(
3769 !result.contains("onload"),
3770 "onload after single-quoted > must be stripped"
3771 );
3772 }
3773
3774 #[test]
3777 fn test_dangerous_uri_scheme_with_embedded_tab() {
3778 assert!(has_dangerous_uri_scheme("java\tscript:alert(1)"));
3779 }
3780
3781 #[test]
3782 fn test_dangerous_uri_scheme_with_embedded_newline() {
3783 assert!(has_dangerous_uri_scheme("java\nscript:alert(1)"));
3784 }
3785
3786 #[test]
3787 fn test_dangerous_uri_scheme_with_control_chars() {
3788 assert!(has_dangerous_uri_scheme("java\x00script:alert(1)"));
3789 }
3790
3791 #[test]
3792 fn test_safe_uri_not_flagged() {
3793 assert!(!has_dangerous_uri_scheme("https://example.com"));
3794 }
3795
3796 #[test]
3797 fn test_dangerous_uri_scheme_with_many_embedded_whitespace() {
3798 let payload = "j\ta\tv\ta\ts\tc\tr\ti\tp\tt\t:\ta\tl\te\tr\tt\t(\t1\t)\t";
3801 assert!(
3802 has_dangerous_uri_scheme(payload),
3803 "1 tab between letters should not bypass javascript: detection"
3804 );
3805 }
3806
3807 #[test]
3808 fn test_dangerous_uri_scheme_whitespace_bypass_regression() {
3809 let payload = "j\t\t\ta\t\t\tv\t\t\ta\t\t\ts\t\t\tc\t\t\tr\t\t\ti\t\t\tp\t\t\tt\t\t\t:";
3814 assert!(
3815 has_dangerous_uri_scheme(payload),
3816 "3 tabs between letters (colon at raw position 40) must still be detected"
3817 );
3818 }
3819
3820 #[test]
3823 fn test_svg_section_blocks_multiline_script_tag_splitting() {
3824 let input = "{start_of_svg}\n<script\n>alert(1)</script>\n{end_of_svg}";
3826 let html = render(input);
3827 assert!(
3828 !html.contains("alert(1)"),
3829 "multi-line <script> tag splitting must not execute JS"
3830 );
3831 assert!(
3832 !html.to_lowercase().contains("<script"),
3833 "multi-line <script> tag must be stripped"
3834 );
3835 }
3836
3837 #[test]
3838 fn test_svg_section_blocks_multiline_iframe_tag_splitting() {
3839 let input =
3840 "{start_of_svg}\n<iframe\nsrc=\"javascript:alert(1)\">\n</iframe>\n{end_of_svg}";
3841 let html = render(input);
3842 assert!(
3843 !html.to_lowercase().contains("<iframe"),
3844 "multi-line <iframe> tag splitting must be stripped"
3845 );
3846 assert!(
3847 !html.contains("javascript:"),
3848 "javascript: URI in split iframe must be stripped"
3849 );
3850 }
3851
3852 #[test]
3853 fn test_svg_section_blocks_multiline_foreignobject_splitting() {
3854 let input = "{start_of_svg}\n<foreignObject\n><script>alert(1)</script></foreignObject>\n{end_of_svg}";
3855 let html = render(input);
3856 assert!(
3857 !html.to_lowercase().contains("<foreignobject"),
3858 "multi-line <foreignObject> splitting must be stripped"
3859 );
3860 }
3861
3862 #[test]
3865 fn test_dangerous_uri_file_scheme_blocked() {
3866 assert!(
3868 has_dangerous_uri_scheme("file:///etc/passwd"),
3869 "file: URI scheme must be detected as dangerous"
3870 );
3871 assert!(
3872 has_dangerous_uri_scheme("FILE:///etc/passwd"),
3873 "FILE: (uppercase) must be detected as dangerous"
3874 );
3875 }
3876
3877 #[test]
3878 fn test_dangerous_uri_blob_scheme_blocked() {
3879 assert!(
3880 has_dangerous_uri_scheme("blob:https://example.com/uuid"),
3881 "blob: URI scheme must be detected as dangerous"
3882 );
3883 assert!(
3884 has_dangerous_uri_scheme("BLOB:https://example.com/uuid"),
3885 "BLOB: (uppercase) must be detected as dangerous"
3886 );
3887 }
3888
3889 #[test]
3890 fn test_svg_section_strips_file_uri_in_use_href() {
3891 let input = "{start_of_svg}\n<svg><use href=\"file:///etc/passwd\"/></svg>\n{end_of_svg}";
3893 let html = render(input);
3894 assert!(
3895 !html.contains("file:///"),
3896 "file: URI in <use href> must be stripped; got: {html}"
3897 );
3898 }
3899
3900 #[test]
3901 fn test_svg_section_strips_file_uri_in_xlink_href() {
3902 let input =
3903 "{start_of_svg}\n<svg><use xlink:href=\"file:///etc/passwd\"/></svg>\n{end_of_svg}";
3904 let html = render(input);
3905 assert!(
3906 !html.contains("file:///"),
3907 "file: URI in xlink:href must be stripped; got: {html}"
3908 );
3909 }
3910
3911 #[test]
3914 fn test_svg_section_strips_feimage_element() {
3915 let input =
3917 "{start_of_svg}\n<svg><feImage href=\"file:///etc/passwd\"/></svg>\n{end_of_svg}";
3918 let html = render(input);
3919 assert!(
3920 !html.to_lowercase().contains("<feimage"),
3921 "feImage element must be stripped entirely; got: {html}"
3922 );
3923 assert!(
3924 !html.contains("file:///"),
3925 "file: URI inside feImage must not appear in output; got: {html}"
3926 );
3927 }
3928
3929 #[test]
3930 fn test_svg_section_strips_feimage_with_http_href() {
3931 let input = "{start_of_svg}\n<svg><feImage href=\"https://evil.example.com/spy.svg\"/></svg>\n{end_of_svg}";
3933 let html = render(input);
3934 assert!(
3935 !html.to_lowercase().contains("<feimage"),
3936 "feImage element must be stripped even with http href; got: {html}"
3937 );
3938 }
3939
3940 #[test]
3943 fn test_svg_section_strips_action_javascript_uri() {
3944 let input =
3946 "{start_of_svg}\n<svg><a action=\"javascript:alert(1)\">click</a></svg>\n{end_of_svg}";
3947 let html = render(input);
3948 assert!(
3949 !html.contains("javascript:"),
3950 "javascript: URI in action attribute must be stripped; got: {html}"
3951 );
3952 }
3953
3954 #[test]
3955 fn test_svg_section_strips_formaction_javascript_uri() {
3956 let input = "{start_of_svg}\n<svg><a formaction=\"javascript:alert(1)\">click</a></svg>\n{end_of_svg}";
3957 let html = render(input);
3958 assert!(
3959 !html.contains("javascript:"),
3960 "javascript: URI in formaction attribute must be stripped; got: {html}"
3961 );
3962 }
3963
3964 #[test]
3965 fn test_svg_section_strips_ping_javascript_uri() {
3966 let input =
3968 "{start_of_svg}\n<svg><a ping=\"javascript:alert(1)\">click</a></svg>\n{end_of_svg}";
3969 let html = render(input);
3970 assert!(
3971 !html.contains("javascript:"),
3972 "javascript: URI in ping attribute must be stripped; got: {html}"
3973 );
3974 }
3975
3976 #[test]
3977 fn test_svg_section_strips_poster_file_uri() {
3978 let input =
3980 "{start_of_svg}\n<svg><video poster=\"file:///etc/passwd\"/></svg>\n{end_of_svg}";
3981 let html = render(input);
3982 assert!(
3983 !html.contains("file:///"),
3984 "file: URI in poster attribute must be stripped; got: {html}"
3985 );
3986 }
3987
3988 #[test]
3989 fn test_svg_section_strips_background_file_uri() {
3990 let input =
3992 "{start_of_svg}\n<svg><body background=\"file:///etc/passwd\"/></svg>\n{end_of_svg}";
3993 let html = render(input);
3994 assert!(
3995 !html.contains("file:///"),
3996 "file: URI in background attribute must be stripped; got: {html}"
3997 );
3998 }
3999
4000 #[test]
4003 fn test_dangerous_uri_mhtml_scheme_blocked() {
4004 assert!(
4006 has_dangerous_uri_scheme("mhtml:file://C:/page.mhtml"),
4007 "mhtml: URI scheme must be detected as dangerous"
4008 );
4009 assert!(
4010 has_dangerous_uri_scheme("MHTML:file://C:/page.mhtml"),
4011 "MHTML: (uppercase) must be detected as dangerous"
4012 );
4013 }
4014
4015 #[test]
4018 fn test_svg_section_strips_image_element() {
4019 let input =
4022 "{start_of_svg}\n<svg><image href=\"https://evil.com/spy.png\"/></svg>\n{end_of_svg}";
4023 let html = render(input);
4024 assert!(
4025 !html.to_lowercase().contains("<image"),
4026 "SVG <image> element must be stripped entirely; got: {html}"
4027 );
4028 }
4029
4030 #[test]
4033 fn test_extreme_textsize_is_clamped_to_max() {
4034 let input = "{title: T}\n{textsize: 99999}\n[C]Hello";
4037 let html = render(input);
4038 assert!(
4039 !html.contains("99999"),
4040 "extreme textsize should be clamped, not passed through"
4041 );
4042 assert!(
4043 html.contains("200"),
4044 "extreme textsize should be clamped to MAX_FONT_SIZE (200)"
4045 );
4046 }
4047
4048 #[test]
4049 fn test_negative_textsize_is_clamped_to_min() {
4050 let input = "{title: T}\n{textsize: -10}\n[C]Hello";
4053 let html = render(input);
4054 assert!(
4055 html.contains("0.5"),
4056 "negative textsize should be clamped to MIN_FONT_SIZE (0.5)"
4057 );
4058 }
4059
4060 #[test]
4061 fn test_extreme_chordsize_is_clamped_to_max() {
4062 let input = "{title: T}\n{chordsize: 50000}\n[C]Hello";
4063 let html = render(input);
4064 assert!(
4065 !html.contains("50000"),
4066 "extreme chordsize should be clamped"
4067 );
4068 assert!(
4069 html.contains("200"),
4070 "extreme chordsize should be clamped to MAX_FONT_SIZE (200)"
4071 );
4072 }
4073}