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 => {
1432 if show_diagrams {
1433 if let Some(ref value) = directive.value {
1434 let def = chordsketch_core::ast::ChordDefinition::parse_value(value);
1435 if let Some(ref keys_raw) = def.keys {
1437 let keys_u8: Vec<u8> = keys_raw
1438 .iter()
1439 .filter_map(|&k| {
1440 if (0i32..=127).contains(&k) {
1441 Some(k as u8)
1442 } else {
1443 None
1444 }
1445 })
1446 .collect();
1447 if !keys_u8.is_empty() {
1448 let root = keys_u8[0];
1449 let voicing = chordsketch_core::chord_diagram::KeyboardVoicing {
1450 name: def.name.clone(),
1451 display_name: def.display.clone(),
1452 keys: keys_u8,
1453 root_key: root,
1454 };
1455 html.push_str("<div class=\"chord-diagram-container\">");
1456 html.push_str(&chordsketch_core::chord_diagram::render_keyboard_svg(
1457 &voicing,
1458 ));
1459 html.push_str("</div>\n");
1460 }
1461 } else if let Some(ref raw) = def.raw {
1462 if let Some(mut diagram) =
1464 chordsketch_core::chord_diagram::DiagramData::from_raw_infer_frets(
1465 &def.name,
1466 raw,
1467 diagram_frets,
1468 )
1469 {
1470 diagram.display_name = def.display.clone();
1471 html.push_str("<div class=\"chord-diagram-container\">");
1472 html.push_str(&chordsketch_core::chord_diagram::render_svg(&diagram));
1473 html.push_str("</div>\n");
1474 }
1475 }
1476 }
1477 }
1478 }
1479 _ => {}
1480 }
1481}
1482
1483#[cfg(not(target_arch = "wasm32"))]
1489fn render_abc_with_fallback(
1490 abc_content: &str,
1491 label: &Option<String>,
1492 html: &mut String,
1493 warnings: &mut Vec<String>,
1494) {
1495 match chordsketch_core::external_tool::invoke_abc2svg(abc_content) {
1496 Ok(svg_fragment) => {
1497 render_section_open("abc", "ABC", label, html);
1498 html.push_str(&sanitize_svg_content(&svg_fragment));
1499 html.push('\n');
1500 html.push_str("</section>\n");
1501 }
1502 Err(e) => {
1503 warnings.push(format!("abc2svg invocation failed: {e}"));
1504 render_section_open("abc", "ABC", label, html);
1505 html.push_str("<pre>");
1506 html.push_str(&escape(abc_content));
1507 html.push_str("</pre>\n");
1508 html.push_str("</section>\n");
1509 }
1510 }
1511}
1512
1513#[cfg(target_arch = "wasm32")]
1517fn render_abc_with_fallback(
1518 abc_content: &str,
1519 label: &Option<String>,
1520 html: &mut String,
1521 _warnings: &mut Vec<String>,
1522) {
1523 render_section_open("abc", "ABC", label, html);
1524 html.push_str("<pre>");
1525 html.push_str(&escape(abc_content));
1526 html.push_str("</pre>\n");
1527 html.push_str("</section>\n");
1528}
1529
1530fn is_safe_image_src(src: &str) -> bool {
1538 if src.is_empty() {
1539 return false;
1540 }
1541
1542 if src.contains('\0') {
1544 return false;
1545 }
1546
1547 let normalised = src.trim_start().to_ascii_lowercase();
1550
1551 if normalised.starts_with('/') {
1554 return false;
1555 }
1556
1557 if is_windows_absolute(src.trim_start()) {
1559 return false;
1560 }
1561
1562 if has_traversal(src) {
1564 return false;
1565 }
1566
1567 if let Some(colon_pos) = normalised.find(':') {
1570 let before_colon = &normalised[..colon_pos];
1571 if !before_colon.contains('/') {
1573 return before_colon == "http" || before_colon == "https";
1574 }
1575 }
1576
1577 true
1578}
1579
1580use chordsketch_core::image_path::{has_traversal, is_windows_absolute};
1582
1583#[cfg(not(target_arch = "wasm32"))]
1589fn render_ly_with_fallback(
1590 ly_content: &str,
1591 label: &Option<String>,
1592 html: &mut String,
1593 warnings: &mut Vec<String>,
1594) {
1595 match chordsketch_core::external_tool::invoke_lilypond(ly_content) {
1596 Ok(svg) => {
1597 render_section_open("ly", "Lilypond", label, html);
1598 html.push_str(&sanitize_svg_content(&svg));
1599 html.push('\n');
1600 html.push_str("</section>\n");
1601 }
1602 Err(e) => {
1603 warnings.push(format!("lilypond invocation failed: {e}"));
1604 render_section_open("ly", "Lilypond", label, html);
1605 html.push_str("<pre>");
1606 html.push_str(&escape(ly_content));
1607 html.push_str("</pre>\n");
1608 html.push_str("</section>\n");
1609 }
1610 }
1611}
1612
1613#[cfg(target_arch = "wasm32")]
1617fn render_ly_with_fallback(
1618 ly_content: &str,
1619 label: &Option<String>,
1620 html: &mut String,
1621 _warnings: &mut Vec<String>,
1622) {
1623 render_section_open("ly", "Lilypond", label, html);
1624 html.push_str("<pre>");
1625 html.push_str(&escape(ly_content));
1626 html.push_str("</pre>\n");
1627 html.push_str("</section>\n");
1628}
1629
1630#[cfg(not(target_arch = "wasm32"))]
1636fn render_musicxml_with_fallback(
1637 musicxml_content: &str,
1638 label: &Option<String>,
1639 html: &mut String,
1640 warnings: &mut Vec<String>,
1641) {
1642 match chordsketch_core::external_tool::invoke_musescore(musicxml_content) {
1643 Ok(svg) => {
1644 render_section_open("musicxml", "MusicXML", label, html);
1645 html.push_str(&sanitize_svg_content(&svg));
1646 html.push('\n');
1647 html.push_str("</section>\n");
1648 }
1649 Err(e) => {
1650 warnings.push(format!("musescore invocation failed: {e}"));
1651 render_section_open("musicxml", "MusicXML", label, html);
1652 html.push_str("<pre>");
1653 html.push_str(&escape(musicxml_content));
1654 html.push_str("</pre>\n");
1655 html.push_str("</section>\n");
1656 }
1657 }
1658}
1659
1660#[cfg(target_arch = "wasm32")]
1664fn render_musicxml_with_fallback(
1665 musicxml_content: &str,
1666 label: &Option<String>,
1667 html: &mut String,
1668 _warnings: &mut Vec<String>,
1669) {
1670 render_section_open("musicxml", "MusicXML", label, html);
1671 html.push_str("<pre>");
1672 html.push_str(&escape(musicxml_content));
1673 html.push_str("</pre>\n");
1674 html.push_str("</section>\n");
1675}
1676
1677fn render_image(attrs: &chordsketch_core::ast::ImageAttributes, html: &mut String) {
1686 if !is_safe_image_src(&attrs.src) {
1687 return;
1688 }
1689
1690 let mut style = String::new();
1691 let mut img_attrs = format!("src=\"{}\"", escape(&attrs.src));
1692
1693 if let Some(ref title) = attrs.title {
1694 let _ = write!(img_attrs, " alt=\"{}\"", escape(title));
1695 }
1696
1697 if let Some(ref width) = attrs.width {
1698 let _ = write!(img_attrs, " width=\"{}\"", escape(width));
1699 }
1700 if let Some(ref height) = attrs.height {
1701 let _ = write!(img_attrs, " height=\"{}\"", escape(height));
1702 }
1703 if let Some(ref scale) = attrs.scale {
1704 let _ = write!(
1706 style,
1707 "transform: scale({});transform-origin: top left;",
1708 sanitize_css_value(scale)
1709 );
1710 }
1711
1712 let align_css = match attrs.anchor.as_deref() {
1714 Some("column") | Some("paper") => "text-align: center;",
1715 _ => "",
1716 };
1717
1718 if !align_css.is_empty() {
1719 let _ = write!(html, "<div style=\"{align_css}\">");
1720 } else {
1721 html.push_str("<div>");
1722 }
1723
1724 let _ = write!(html, "<img {img_attrs}");
1725 if !style.is_empty() {
1726 let _ = write!(html, " style=\"{}\"", escape(&style));
1732 }
1733 html.push_str("></div>\n");
1734}
1735
1736fn render_section_open(class: &str, label: &str, value: &Option<String>, html: &mut String) {
1738 let safe_class = sanitize_css_class(class);
1739 let _ = writeln!(html, "<section class=\"{safe_class}\">");
1740 let display_label = match value {
1741 Some(v) if !v.is_empty() => format!("{label}: {}", escape(v)),
1742 _ => label.to_string(),
1743 };
1744 let _ = writeln!(html, "<div class=\"section-label\">{display_label}</div>");
1745}
1746
1747fn render_chorus_recall(
1753 value: &Option<String>,
1754 chorus_body: &[Line],
1755 transpose_offset: i8,
1756 fmt_state: &FormattingState,
1757 show_diagrams: bool,
1758 diagram_frets: usize,
1759 html: &mut String,
1760) {
1761 html.push_str("<div class=\"chorus-recall\">\n");
1762 let display_label = match value {
1763 Some(v) if !v.is_empty() => format!("Chorus: {}", escape(v)),
1764 _ => "Chorus".to_string(),
1765 };
1766 let _ = writeln!(html, "<div class=\"section-label\">{display_label}</div>");
1767 let mut local_fmt = fmt_state.clone();
1771 for line in chorus_body {
1772 match line {
1773 Line::Lyrics(lyrics) => render_lyrics(lyrics, transpose_offset, &local_fmt, html),
1774 Line::Comment(style, text) => render_comment(*style, text, html),
1775 Line::Empty => html.push_str("<div class=\"empty-line\"></div>\n"),
1776 Line::Directive(d) if d.kind.is_font_size_color() => {
1777 local_fmt.apply(&d.kind, &d.value);
1778 }
1779 Line::Directive(d) if !d.kind.is_metadata() => {
1780 render_directive_inner(d, show_diagrams, diagram_frets, html);
1781 }
1782 _ => {}
1783 }
1784 }
1785 html.push_str("</div>\n");
1786}
1787
1788fn render_comment(style: CommentStyle, text: &str, html: &mut String) {
1794 match style {
1795 CommentStyle::Normal => {
1796 let _ = writeln!(html, "<p class=\"comment\">{}</p>", escape(text));
1797 }
1798 CommentStyle::Italic => {
1799 let _ = writeln!(html, "<p class=\"comment\"><em>{}</em></p>", escape(text));
1800 }
1801 CommentStyle::Boxed => {
1802 let _ = writeln!(html, "<div class=\"comment-box\">{}</div>", escape(text));
1803 }
1804 }
1805}
1806
1807#[cfg(test)]
1812mod sanitize_tag_attrs_tests {
1813 use super::*;
1814
1815 #[test]
1816 fn test_preserves_normal_attrs() {
1817 let tag = "<svg width=\"100\" height=\"50\">";
1818 assert_eq!(sanitize_tag_attrs(tag), tag);
1819 }
1820
1821 #[test]
1822 fn test_strips_event_handler() {
1823 let tag = "<svg onclick=\"alert(1)\" width=\"100\">";
1824 let result = sanitize_tag_attrs(tag);
1825 assert!(!result.contains("onclick"));
1826 assert!(result.contains("width"));
1827 }
1828
1829 #[test]
1830 fn test_non_ascii_in_attr_value_preserved() {
1831 let tag = "<text title=\"日本語テスト\" x=\"10\">";
1832 let result = sanitize_tag_attrs(tag);
1833 assert!(result.contains("日本語テスト"));
1834 assert!(result.contains("x=\"10\""));
1835 }
1836
1837 #[test]
1840 fn test_strips_mixed_case_event_handler() {
1841 let tag = "<svg OnClick=\"alert(1)\" width=\"100\">";
1842 let result = sanitize_tag_attrs(tag);
1843 assert!(!result.contains("OnClick"));
1844 assert!(result.contains("width"));
1845 }
1846
1847 #[test]
1848 fn test_strips_uppercase_event_handler() {
1849 let tag = "<svg ONLOAD=\"alert(1)\">";
1850 let result = sanitize_tag_attrs(tag);
1851 assert!(!result.contains("ONLOAD"));
1852 }
1853
1854 #[test]
1857 fn test_strips_style_with_url() {
1858 let tag =
1859 "<rect style=\"background-image: url('https://attacker.com/exfil')\" width=\"10\">";
1860 let result = sanitize_tag_attrs(tag);
1861 assert!(!result.contains("style"));
1862 assert!(result.contains("width"));
1863 }
1864
1865 #[test]
1866 fn test_strips_style_with_expression() {
1867 let tag = "<rect style=\"width: expression(alert(1))\">";
1868 let result = sanitize_tag_attrs(tag);
1869 assert!(!result.contains("style"));
1870 }
1871
1872 #[test]
1873 fn test_strips_style_with_import() {
1874 let tag = "<rect style=\"@import url(evil.css)\">";
1875 let result = sanitize_tag_attrs(tag);
1876 assert!(!result.contains("style"));
1877 }
1878
1879 #[test]
1880 fn test_preserves_safe_style() {
1881 let tag = "<rect style=\"fill: red; stroke: blue\" width=\"10\">";
1882 let result = sanitize_tag_attrs(tag);
1883 assert!(result.contains("style"));
1884 assert!(result.contains("fill: red"));
1885 }
1886}
1887
1888#[cfg(test)]
1889mod tests {
1890 use super::*;
1891
1892 #[test]
1893 fn test_render_empty() {
1894 let song = chordsketch_core::parse("").unwrap();
1895 let html = render_song(&song);
1896 assert!(html.contains("<!DOCTYPE html>"));
1897 assert!(html.contains("</html>"));
1898 }
1899
1900 #[test]
1901 fn test_render_title() {
1902 let html = render("{title: My Song}");
1903 assert!(html.contains("<h1>My Song</h1>"));
1904 assert!(html.contains("<title>My Song</title>"));
1905 }
1906
1907 #[test]
1908 fn test_render_subtitle() {
1909 let html = render("{title: Song}\n{subtitle: By Someone}");
1910 assert!(html.contains("<h2>By Someone</h2>"));
1911 }
1912
1913 #[test]
1914 fn test_render_lyrics_with_chords() {
1915 let html = render("[Am]Hello [G]world");
1916 assert!(html.contains("chord-block"));
1917 assert!(html.contains("<span class=\"chord\">Am</span>"));
1918 assert!(html.contains("<span class=\"lyrics\">Hello </span>"));
1919 assert!(html.contains("<span class=\"chord\">G</span>"));
1920 }
1921
1922 #[test]
1923 fn test_render_lyrics_no_chords() {
1924 let html = render("Just plain text");
1925 assert!(html.contains("<span class=\"lyrics\">Just plain text</span>"));
1926 assert!(!html.contains("class=\"chord\""));
1928 }
1929
1930 #[test]
1931 fn test_render_chorus_section() {
1932 let html = render("{start_of_chorus}\n[G]La la\n{end_of_chorus}");
1933 assert!(html.contains("<section class=\"chorus\">"));
1934 assert!(html.contains("</section>"));
1935 assert!(html.contains("Chorus"));
1936 }
1937
1938 #[test]
1939 fn test_render_verse_with_label() {
1940 let html = render("{start_of_verse: Verse 1}\nLyrics\n{end_of_verse}");
1941 assert!(html.contains("<section class=\"verse\">"));
1942 assert!(html.contains("Verse: Verse 1"));
1943 }
1944
1945 #[test]
1946 fn test_render_comment() {
1947 let html = render("{comment: A note}");
1948 assert!(html.contains("<p class=\"comment\">A note</p>"));
1949 }
1950
1951 #[test]
1952 fn test_render_comment_italic() {
1953 let html = render("{comment_italic: Softly}");
1954 assert!(html.contains("<em>Softly</em>"));
1955 }
1956
1957 #[test]
1958 fn test_render_comment_box() {
1959 let html = render("{comment_box: Important}");
1960 assert!(html.contains("<div class=\"comment-box\">Important</div>"));
1961 }
1962
1963 #[test]
1964 fn test_html_escaping() {
1965 let html = render("{title: Tom & Jerry <3}");
1966 assert!(html.contains("Tom & Jerry <3"));
1967 }
1968
1969 #[test]
1970 fn test_try_render_success() {
1971 let result = try_render("{title: Test}");
1972 assert!(result.is_ok());
1973 }
1974
1975 #[test]
1976 fn test_try_render_error() {
1977 let result = try_render("{unclosed");
1978 assert!(result.is_err());
1979 }
1980
1981 #[test]
1982 fn test_render_valid_html_structure() {
1983 let html = render("{title: Test}\n\n{start_of_verse}\n[G]Hello [C]world\n{end_of_verse}");
1984 assert!(html.starts_with("<!DOCTYPE html>"));
1985 assert!(html.contains("<html"));
1986 assert!(html.contains("<head>"));
1987 assert!(html.contains("<style>"));
1988 assert!(html.contains("<body>"));
1989 assert!(html.contains("</html>"));
1990 }
1991
1992 #[test]
1993 fn test_text_before_first_chord() {
1994 let html = render("Hello [Am]world");
1995 assert!(html.contains("<span class=\"chord\"></span><span class=\"lyrics\">Hello </span>"));
1997 }
1998
1999 #[test]
2000 fn test_empty_line() {
2001 let html = render("Line one\n\nLine two");
2002 assert!(html.contains("empty-line"));
2003 }
2004
2005 #[test]
2006 fn test_render_grid_section() {
2007 let html = render("{start_of_grid}\n| Am . | C . |\n{end_of_grid}");
2008 assert!(html.contains("<section class=\"grid\">"));
2009 assert!(html.contains("Grid"));
2010 assert!(html.contains("</section>"));
2011 }
2012
2013 #[test]
2016 fn test_render_custom_section_intro() {
2017 let html = render("{start_of_intro}\n[Am]Da da\n{end_of_intro}");
2018 assert!(html.contains("<section class=\"section-intro\">"));
2019 assert!(html.contains("Intro"));
2020 assert!(html.contains("</section>"));
2021 }
2022
2023 #[test]
2024 fn test_render_grid_section_with_label() {
2025 let html = render("{start_of_grid: Intro}\n| Am |\n{end_of_grid}");
2026 assert!(html.contains("<section class=\"grid\">"));
2027 assert!(html.contains("Grid: Intro"));
2028 }
2029
2030 #[test]
2031 fn test_render_grid_short_alias() {
2032 let html = render("{sog}\n| G . |\n{eog}");
2033 assert!(html.contains("<section class=\"grid\">"));
2034 assert!(html.contains("</section>"));
2035 }
2036
2037 #[test]
2038 fn test_render_custom_section_with_label() {
2039 let html = render("{start_of_intro: Guitar}\nNotes\n{end_of_intro}");
2040 assert!(html.contains("<section class=\"section-intro\">"));
2041 assert!(html.contains("Intro: Guitar"));
2042 }
2043
2044 #[test]
2045 fn test_render_custom_section_outro() {
2046 let html = render("{start_of_outro}\nFinal\n{end_of_outro}");
2047 assert!(html.contains("<section class=\"section-outro\">"));
2048 assert!(html.contains("Outro"));
2049 }
2050
2051 #[test]
2052 fn test_render_custom_section_solo() {
2053 let html = render("{start_of_solo}\n[Em]Solo\n{end_of_solo}");
2054 assert!(html.contains("<section class=\"section-solo\">"));
2055 assert!(html.contains("Solo"));
2056 assert!(html.contains("</section>"));
2057 }
2058
2059 #[test]
2060 fn test_custom_section_name_escaped() {
2061 let html = render(
2062 "{start_of_x<script>alert(1)</script>}\ntext\n{end_of_x<script>alert(1)</script>}",
2063 );
2064 assert!(!html.contains("<script>"));
2065 assert!(html.contains("<script>"));
2066 }
2067
2068 #[test]
2069 fn test_custom_section_name_quotes_escaped() {
2070 let html =
2071 render("{start_of_x\" onclick=\"alert(1)}\ntext\n{end_of_x\" onclick=\"alert(1)}");
2072 assert!(html.contains("""));
2074 assert!(!html.contains("class=\"section-x\""));
2075 }
2076
2077 #[test]
2078 fn test_custom_section_name_single_quotes_escaped() {
2079 let html = render("{start_of_x' onclick='alert(1)}\ntext\n{end_of_x' onclick='alert(1)}");
2080 assert!(html.contains("'") || html.contains("'"));
2083 assert!(!html.contains("onclick='alert"));
2084 }
2085
2086 #[test]
2087 fn test_custom_section_name_space_sanitized_in_class() {
2088 let html = render("{start_of_foo bar}\ntext\n{end_of_foo bar}");
2090 assert!(html.contains("section-foo-bar"));
2092 assert!(!html.contains("class=\"section-foo bar\""));
2093 }
2094
2095 #[test]
2096 fn test_custom_section_name_special_chars_sanitized_in_class() {
2097 let html = render("{start_of_a&b<c>d}\ntext\n{end_of_a&b<c>d}");
2098 assert!(html.contains("section-a-b-c-d"));
2100 assert!(html.contains("&"));
2102 }
2103
2104 #[test]
2105 fn test_custom_section_capitalize_before_escape() {
2106 let html = render("{start_of_&test}\ntext\n{end_of_&test}");
2110 assert!(html.contains("&test"));
2113 assert!(!html.contains("&Amp;"));
2114 }
2115
2116 #[test]
2117 fn test_define_display_name_in_html_output() {
2118 let html = render("{define: Am base-fret 1 frets x 0 2 2 1 0 display=\"A minor\"}");
2119 assert!(
2120 html.contains("A minor"),
2121 "display name should appear in rendered HTML output"
2122 );
2123 }
2124}
2125
2126#[cfg(test)]
2127mod transpose_tests {
2128 use super::*;
2129
2130 #[test]
2131 fn test_transpose_directive_up_2() {
2132 let input = "{transpose: 2}\n[G]Hello [C]world";
2133 let song = chordsketch_core::parse(input).unwrap();
2134 let html = render_song(&song);
2135 assert!(html.contains("<span class=\"chord\">A</span>"));
2137 assert!(html.contains("<span class=\"chord\">D</span>"));
2138 assert!(!html.contains("<span class=\"chord\">G</span>"));
2139 assert!(!html.contains("<span class=\"chord\">C</span>"));
2140 }
2141
2142 #[test]
2143 fn test_transpose_directive_replaces_previous() {
2144 let input = "{transpose: 2}\n[G]First\n{transpose: 0}\n[G]Second";
2145 let song = chordsketch_core::parse(input).unwrap();
2146 let html = render_song(&song);
2147 assert!(html.contains("<span class=\"chord\">A</span>"));
2149 assert!(html.contains("<span class=\"chord\">G</span>"));
2150 }
2151
2152 #[test]
2153 fn test_transpose_directive_with_cli_offset() {
2154 let input = "{transpose: 2}\n[C]Hello";
2155 let song = chordsketch_core::parse(input).unwrap();
2156 let html = render_song_with_transpose(&song, 3, &Config::defaults());
2157 assert!(html.contains("<span class=\"chord\">F</span>"));
2159 }
2160
2161 #[test]
2162 fn test_transpose_out_of_i8_range_emits_warning() {
2163 let input = "{transpose: 999}\n[G]Hello";
2165 let song = chordsketch_core::parse(input).unwrap();
2166 let result = render_song_with_warnings(&song, 0, &Config::defaults());
2167 assert!(
2168 result.output.contains("<span class=\"chord\">G</span>"),
2169 "chord should be untransposed"
2170 );
2171 assert!(
2172 result.warnings.iter().any(|w| w.contains("\"999\"")),
2173 "expected warning about out-of-range value, got: {:?}",
2174 result.warnings
2175 );
2176 }
2177
2178 #[test]
2179 fn test_transpose_no_value_treated_as_zero() {
2180 let input = "{transpose}\n[G]Hello";
2182 let song = chordsketch_core::parse(input).unwrap();
2183 let result = render_song_with_warnings(&song, 0, &Config::defaults());
2184 assert!(
2185 result.output.contains("<span class=\"chord\">G</span>"),
2186 "chord should be untransposed"
2187 );
2188 assert!(
2189 result.warnings.is_empty(),
2190 "missing {{transpose}} value should not emit a warning; got: {:?}",
2191 result.warnings
2192 );
2193 }
2194
2195 #[test]
2196 fn test_transpose_whitespace_value_treated_as_zero() {
2197 let input = "{transpose: }\n[G]Hello";
2201 let song = chordsketch_core::parse(input).unwrap();
2202 let result = render_song_with_warnings(&song, 0, &Config::defaults());
2203 assert!(
2204 result.output.contains("<span class=\"chord\">G</span>"),
2205 "chord should be untransposed"
2206 );
2207 assert!(
2208 result.warnings.is_empty(),
2209 "whitespace-only {{transpose}} value should not emit a warning; got: {:?}",
2210 result.warnings
2211 );
2212 }
2213
2214 #[test]
2217 fn test_render_chorus_recall_basic() {
2218 let html = render("{start_of_chorus}\n[G]La la\n{end_of_chorus}\n\n{chorus}");
2219 assert!(html.contains("<div class=\"chorus-recall\">"));
2221 assert!(html.contains("chorus-recall"));
2223 assert!(html.contains("<section class=\"chorus\">"));
2225 }
2226
2227 #[test]
2228 fn test_render_chorus_recall_with_label() {
2229 let html = render("{start_of_chorus}\nSing\n{end_of_chorus}\n{chorus: Repeat}");
2230 assert!(html.contains("Chorus: Repeat"));
2231 assert!(html.contains("chorus-recall"));
2232 }
2233
2234 #[test]
2235 fn test_render_chorus_recall_no_chorus_defined() {
2236 let html = render("{chorus}");
2237 assert!(html.contains("<div class=\"chorus-recall\">"));
2239 assert!(html.contains("Chorus"));
2240 }
2241
2242 #[test]
2243 fn test_render_chorus_recall_content_replayed() {
2244 let html = render("{start_of_chorus}\nChorus text\n{end_of_chorus}\n{chorus}");
2245 let count = html.matches("Chorus text").count();
2247 assert_eq!(count, 2, "chorus content should appear twice");
2248 }
2249
2250 #[test]
2251 fn test_chorus_recall_applies_current_transpose() {
2252 let html = render("{start_of_chorus}\n[G]La la\n{end_of_chorus}\n{transpose: 2}\n{chorus}");
2255 assert!(
2257 html.contains("<span class=\"chord\">G</span>"),
2258 "original chorus should have G"
2259 );
2260 assert!(
2262 html.contains("<span class=\"chord\">A</span>"),
2263 "recalled chorus should have transposed chord A, got:\n{html}"
2264 );
2265 }
2266
2267 #[test]
2268 fn test_chorus_recall_preserves_formatting_directives() {
2269 let html =
2271 render("{start_of_chorus}\n{textsize: 20}\n[Am]Big text\n{end_of_chorus}\n{chorus}");
2272 let recall_start = html.find("chorus-recall").expect("should have recall");
2274 let recall_section = &html[recall_start..];
2275 assert!(
2276 recall_section.contains("font-size"),
2277 "recalled chorus should apply in-chorus formatting directives"
2278 );
2279 }
2280
2281 #[test]
2282 fn test_chorus_formatting_does_not_leak_to_outer_scope() {
2283 let html =
2285 render("{start_of_chorus}\n{textsize: 20}\n[Am]Big\n{end_of_chorus}\n[G]Normal text");
2286 let after_chorus = html
2288 .rfind("Normal text")
2289 .expect("should have post-chorus text");
2290 let line_start = html[..after_chorus].rfind("<div class=\"line\"").unwrap();
2292 let line_end = html[line_start..]
2293 .find("</div>")
2294 .map_or(html.len(), |i| line_start + i + 6);
2295 let post_chorus_line = &html[line_start..line_end];
2296 assert!(
2297 !post_chorus_line.contains("font-size"),
2298 "in-chorus {{textsize}} should not leak to post-chorus content: {post_chorus_line}"
2299 );
2300 }
2301
2302 #[test]
2305 fn test_render_bold_markup() {
2306 let html = render("Hello <b>bold</b> world");
2307 assert!(html.contains("<b>bold</b>"));
2308 assert!(html.contains("Hello "));
2309 assert!(html.contains(" world"));
2310 }
2311
2312 #[test]
2313 fn test_render_italic_markup() {
2314 let html = render("Hello <i>italic</i> text");
2315 assert!(html.contains("<i>italic</i>"));
2316 }
2317
2318 #[test]
2319 fn test_render_highlight_markup() {
2320 let html = render("<highlight>important</highlight>");
2321 assert!(html.contains("<mark>important</mark>"));
2322 }
2323
2324 #[test]
2325 fn test_render_comment_inline_markup() {
2326 let html = render("<comment>note</comment>");
2327 assert!(html.contains("<span class=\"comment\">note</span>"));
2328 }
2329
2330 #[test]
2331 fn test_render_span_with_foreground() {
2332 let html = render(r#"<span foreground="red">red text</span>"#);
2333 assert!(html.contains("color: red;"));
2334 assert!(html.contains("red text"));
2335 }
2336
2337 #[test]
2338 fn test_render_span_with_multiple_attrs() {
2339 let html = render(
2340 r#"<span font_family="Serif" size="14" foreground="blue" weight="bold">styled</span>"#,
2341 );
2342 assert!(html.contains("font-family: Serif;"));
2343 assert!(html.contains("font-size: 14pt;"));
2344 assert!(html.contains("color: blue;"));
2345 assert!(html.contains("font-weight: bold;"));
2346 assert!(html.contains("styled"));
2347 }
2348
2349 #[test]
2350 fn test_span_css_injection_url_prevented() {
2351 let html = render(
2352 r#"<span foreground="red; background-image: url('https://evil.com/')">text</span>"#,
2353 );
2354 assert!(!html.contains("url("));
2356 assert!(!html.contains(";background-image"));
2357 }
2358
2359 #[test]
2360 fn test_span_css_injection_semicolon_stripped() {
2361 let html =
2362 render(r#"<span foreground="red; position: absolute; z-index: 9999">text</span>"#);
2363 assert!(!html.contains(";position"));
2367 assert!(!html.contains("; position"));
2368 assert!(html.contains("color:"));
2369 }
2370
2371 #[test]
2372 fn test_render_nested_markup() {
2373 let html = render("<b><i>bold italic</i></b>");
2374 assert!(html.contains("<b><i>bold italic</i></b>"));
2375 }
2376
2377 #[test]
2378 fn test_render_markup_with_chord() {
2379 let html = render("[Am]Hello <b>bold</b> world");
2380 assert!(html.contains("<b>bold</b>"));
2381 assert!(html.contains("<span class=\"chord\">Am</span>"));
2382 }
2383
2384 #[test]
2385 fn test_render_no_markup_unchanged() {
2386 let html = render("Just plain text");
2387 assert!(!html.contains("<b>"));
2389 assert!(!html.contains("<i>"));
2390 assert!(html.contains("Just plain text"));
2391 }
2392
2393 #[test]
2396 fn test_textfont_directive_applies_css() {
2397 let html = render("{textfont: Courier}\nHello world");
2398 assert!(html.contains("font-family: Courier;"));
2399 }
2400
2401 #[test]
2402 fn test_textsize_directive_applies_css() {
2403 let html = render("{textsize: 14}\nHello world");
2404 assert!(html.contains("font-size: 14pt;"));
2405 }
2406
2407 #[test]
2408 fn test_textcolour_directive_applies_css() {
2409 let html = render("{textcolour: blue}\nHello world");
2410 assert!(html.contains("color: blue;"));
2411 }
2412
2413 #[test]
2414 fn test_chordfont_directive_applies_css() {
2415 let html = render("{chordfont: Monospace}\n[Am]Hello");
2416 assert!(html.contains("font-family: Monospace;"));
2417 }
2418
2419 #[test]
2420 fn test_chordsize_directive_applies_css() {
2421 let html = render("{chordsize: 16}\n[Am]Hello");
2422 assert!(html.contains("font-size: 16pt;"));
2424 }
2425
2426 #[test]
2427 fn test_chordcolour_directive_applies_css() {
2428 let html = render("{chordcolour: green}\n[Am]Hello");
2429 assert!(html.contains("color: green;"));
2430 }
2431
2432 #[test]
2433 fn test_formatting_persists_across_lines() {
2434 let html = render("{textcolour: red}\nLine one\nLine two");
2435 let count = html.matches("color: red;").count();
2437 assert!(
2438 count >= 2,
2439 "formatting should persist: found {count} matches"
2440 );
2441 }
2442
2443 #[test]
2444 fn test_formatting_overridden_by_later_directive() {
2445 let html = render("{textcolour: red}\nRed text\n{textcolour: blue}\nBlue text");
2446 assert!(html.contains("color: red;"));
2447 assert!(html.contains("color: blue;"));
2448 }
2449
2450 #[test]
2451 fn test_no_formatting_no_style_attr() {
2452 let html = render("Plain text");
2453 assert!(!html.contains("<span class=\"lyrics\" style="));
2455 }
2456
2457 #[test]
2458 fn test_formatting_directive_css_injection_prevented() {
2459 let html = render("{textcolour: red; position: fixed; z-index: 9999}\nHello");
2460 assert!(!html.contains(";position"));
2462 assert!(!html.contains("; position"));
2463 assert!(html.contains("color:"));
2464 }
2465
2466 #[test]
2467 fn test_formatting_directive_url_injection_prevented() {
2468 let html = render("{textcolour: red; background-image: url('https://evil.com/')}\nHello");
2469 assert!(!html.contains("url("));
2471 }
2472
2473 #[test]
2476 fn test_columns_directive_generates_css() {
2477 let html = render("{columns: 2}\nLine one\nLine two");
2478 assert!(html.contains("column-count: 2"));
2479 }
2480
2481 #[test]
2482 fn test_columns_reset_to_one() {
2483 let html = render("{columns: 2}\nTwo cols\n{columns: 1}\nOne col");
2484 let count = html.matches("column-count: 2").count();
2486 assert_eq!(count, 1);
2487 assert!(html.contains("One col"));
2488 }
2489
2490 #[test]
2491 fn test_column_break_generates_css() {
2492 let html = render("{columns: 2}\nCol 1\n{column_break}\nCol 2");
2493 assert!(html.contains("break-before: column;"));
2494 }
2495
2496 #[test]
2497 fn test_columns_clamped_to_max() {
2498 let html = render("{columns: 999}\nContent");
2499 assert!(html.contains("column-count: 32"));
2501 }
2502
2503 #[test]
2504 fn test_columns_zero_treated_as_one() {
2505 let html = render("{columns: 0}\nContent");
2506 assert!(!html.contains("column-count"));
2508 }
2509
2510 #[test]
2511 fn test_columns_non_numeric_defaults_to_one() {
2512 let html = render("{columns: abc}\nHello");
2513 assert!(!html.contains("column-count"));
2515 }
2516
2517 #[test]
2518 fn test_new_page_generates_page_break() {
2519 let html = render("Page 1\n{new_page}\nPage 2");
2520 assert!(html.contains("break-before: page;"));
2521 }
2522
2523 #[test]
2524 fn test_new_physical_page_generates_recto_break() {
2525 let html = render("Page 1\n{new_physical_page}\nPage 2");
2526 assert!(
2527 html.contains("break-before: recto;"),
2528 "new_physical_page should use break-before: recto for duplex printing"
2529 );
2530 assert!(
2531 !html.contains("break-before: page;"),
2532 "new_physical_page should not emit generic page break"
2533 );
2534 }
2535
2536 #[test]
2537 fn test_page_control_not_replayed_in_chorus_recall() {
2538 let input = "\
2540{start_of_chorus}\n\
2541{new_page}\n\
2542[G]La la la\n\
2543{end_of_chorus}\n\
2544Verse text\n\
2545{chorus}";
2546 let html = render(input);
2547 assert!(html.contains("break-before: page;"));
2549 let count = html.matches("break-before: page;").count();
2552 assert_eq!(count, 1, "page break must not be replayed in chorus recall");
2553 }
2554
2555 #[test]
2558 fn test_image_basic() {
2559 let html = render("{image: src=photo.jpg}");
2560 assert!(html.contains("<img src=\"photo.jpg\""));
2561 }
2562
2563 #[test]
2564 fn test_image_with_dimensions() {
2565 let html = render("{image: src=photo.jpg width=200 height=100}");
2566 assert!(html.contains("width=\"200\""));
2567 assert!(html.contains("height=\"100\""));
2568 }
2569
2570 #[test]
2571 fn test_image_with_title() {
2572 let html = render("{image: src=photo.jpg title=\"My Photo\"}");
2573 assert!(html.contains("alt=\"My Photo\""));
2574 }
2575
2576 #[test]
2577 fn test_image_with_scale() {
2578 let html = render("{image: src=photo.jpg scale=0.5}");
2579 assert!(html.contains("scale(0.5)"));
2580 }
2581
2582 #[test]
2583 fn test_image_empty_src_skipped() {
2584 let html = render("{image: src=}");
2585 assert!(
2586 !html.contains("<img"),
2587 "empty src should not produce an img element"
2588 );
2589 }
2590
2591 #[test]
2592 fn test_image_javascript_uri_rejected() {
2593 let html = render("{image: src=javascript:alert(1)}");
2594 assert!(!html.contains("<img"), "javascript: URI must be rejected");
2595 }
2596
2597 #[test]
2598 fn test_image_data_uri_rejected() {
2599 let html = render("{image: src=data:text/html,<script>alert(1)</script>}");
2600 assert!(!html.contains("<img"), "data: URI must be rejected");
2601 }
2602
2603 #[test]
2604 fn test_image_vbscript_uri_rejected() {
2605 let html = render("{image: src=vbscript:MsgBox}");
2606 assert!(!html.contains("<img"), "vbscript: URI must be rejected");
2607 }
2608
2609 #[test]
2610 fn test_image_javascript_uri_case_insensitive() {
2611 let html = render("{image: src=JaVaScRiPt:alert(1)}");
2612 assert!(
2613 !html.contains("<img"),
2614 "scheme check must be case-insensitive"
2615 );
2616 }
2617
2618 #[test]
2619 fn test_image_safe_relative_path_allowed() {
2620 let html = render("{image: src=images/photo.jpg}");
2621 assert!(html.contains("<img src=\"images/photo.jpg\""));
2622 }
2623
2624 #[test]
2625 fn test_is_safe_image_src() {
2626 assert!(is_safe_image_src("photo.jpg"));
2628 assert!(is_safe_image_src("images/photo.jpg"));
2629 assert!(is_safe_image_src("path/to:file.jpg")); assert!(is_safe_image_src("http://example.com/photo.jpg"));
2633 assert!(is_safe_image_src("https://example.com/photo.jpg"));
2634 assert!(is_safe_image_src("HTTP://EXAMPLE.COM/PHOTO.JPG"));
2635
2636 assert!(!is_safe_image_src(""));
2638
2639 assert!(!is_safe_image_src("javascript:alert(1)"));
2641 assert!(!is_safe_image_src("JAVASCRIPT:alert(1)"));
2642 assert!(!is_safe_image_src(" javascript:alert(1)"));
2643 assert!(!is_safe_image_src("data:image/png;base64,abc"));
2644 assert!(!is_safe_image_src("vbscript:MsgBox"));
2645
2646 assert!(!is_safe_image_src("file:///etc/passwd"));
2648 assert!(!is_safe_image_src("FILE:///etc/passwd"));
2649 assert!(!is_safe_image_src("blob:https://example.com/uuid"));
2650 assert!(!is_safe_image_src("mhtml:file://C:/page.mhtml"));
2651
2652 assert!(!is_safe_image_src("/etc/passwd"));
2654 assert!(!is_safe_image_src("/home/user/photo.jpg"));
2655
2656 assert!(!is_safe_image_src("photo\0.jpg"));
2658 assert!(!is_safe_image_src("\0"));
2659
2660 assert!(!is_safe_image_src("../photo.jpg"));
2662 assert!(!is_safe_image_src("images/../../etc/passwd"));
2663 assert!(!is_safe_image_src(r"..\photo.jpg"));
2664 assert!(!is_safe_image_src(r"images\..\..\photo.jpg"));
2665
2666 assert!(!is_safe_image_src(r"C:\photo.jpg"));
2668 assert!(!is_safe_image_src(r"D:\Users\photo.jpg"));
2669 assert!(!is_safe_image_src(r"\\server\share\photo.jpg"));
2670 assert!(!is_safe_image_src("C:/photo.jpg"));
2671 }
2672
2673 #[test]
2674 fn test_image_anchor_column_centers() {
2675 let html = render("{image: src=photo.jpg anchor=column}");
2676 assert!(
2677 html.contains("<div style=\"text-align: center;\">"),
2678 "anchor=column should produce centered div"
2679 );
2680 }
2681
2682 #[test]
2683 fn test_image_anchor_paper_centers() {
2684 let html = render("{image: src=photo.jpg anchor=paper}");
2685 assert!(
2686 html.contains("<div style=\"text-align: center;\">"),
2687 "anchor=paper should produce centered div"
2688 );
2689 }
2690
2691 #[test]
2692 fn test_image_anchor_line_no_style() {
2693 let html = render("{image: src=photo.jpg anchor=line}");
2694 assert!(html.contains("<div><img"));
2696 assert!(!html.contains("text-align"));
2697 }
2698
2699 #[test]
2700 fn test_image_no_anchor_no_style() {
2701 let html = render("{image: src=photo.jpg}");
2702 assert!(html.contains("<div><img"));
2704 assert!(!html.contains("text-align"));
2705 }
2706
2707 #[test]
2708 fn test_image_max_width_css_present() {
2709 let html = render("{image: src=photo.jpg}");
2710 assert!(
2711 html.contains("img { max-width: 100%; height: auto; }"),
2712 "CSS should include img max-width rule to prevent overflow"
2713 );
2714 }
2715
2716 #[test]
2717 fn test_chord_diagram_css_rules_present() {
2718 let html = render("{define: Am base-fret 1 frets x 0 2 2 1 0}");
2719 assert!(
2720 html.contains(".chord-diagram-container"),
2721 "CSS should include .chord-diagram-container rule"
2722 );
2723 assert!(
2724 html.contains(".chord-diagram {"),
2725 "CSS should include .chord-diagram rule"
2726 );
2727 }
2728
2729 #[test]
2732 fn test_define_renders_svg_diagram() {
2733 let html = render("{define: Am base-fret 1 frets x 0 2 2 1 0}");
2734 assert!(html.contains("<svg"));
2735 assert!(html.contains("Am"));
2736 assert!(html.contains("chord-diagram"));
2737 }
2738
2739 #[test]
2740 fn test_define_keyboard_renders_keyboard_svg() {
2741 let html = render("{define: Am keys 0 3 7}");
2743 assert!(
2744 html.contains("<svg"),
2745 "keyboard define should produce an SVG"
2746 );
2747 assert!(
2748 html.contains("keyboard-diagram"),
2749 "should use keyboard-diagram CSS class"
2750 );
2751 assert!(html.contains("Am"), "chord name should appear in SVG");
2752 }
2753
2754 #[test]
2755 fn test_define_keyboard_absolute_midi_renders_svg() {
2756 let html = render("{define: Cmaj7 keys 60 64 67 71}");
2758 assert!(html.contains("<svg"));
2759 assert!(html.contains("keyboard-diagram"));
2760 assert!(html.contains("Cmaj7"));
2761 }
2762
2763 #[test]
2764 fn test_diagrams_piano_auto_inject() {
2765 let input = "{diagrams: piano}\n[Am]Hello [C]world";
2766 let html = render(input);
2767 assert!(
2769 html.contains("keyboard-diagram"),
2770 "piano instrument should use keyboard diagrams"
2771 );
2772 assert!(
2773 html.contains("chord-diagrams"),
2774 "diagram section should be present"
2775 );
2776 }
2777
2778 #[test]
2779 fn test_define_ukulele_diagram() {
2780 let html = render("{define: C frets 0 0 0 3}");
2781 assert!(html.contains("<svg"));
2782 assert!(html.contains("chord-diagram"));
2783 assert!(
2785 html.contains("width=\"88\""),
2786 "Expected 4-string SVG width (88)"
2787 );
2788 }
2789
2790 #[test]
2791 fn test_define_banjo_diagram() {
2792 let html = render("{define: G frets 0 0 0 0 0}");
2793 assert!(html.contains("<svg"));
2794 assert!(
2796 html.contains("width=\"104\""),
2797 "Expected 5-string SVG width (104)"
2798 );
2799 }
2800
2801 #[test]
2802 fn test_diagrams_frets_config_controls_svg_height() {
2803 let input = "{define: Am base-fret 1 frets x 0 2 2 1 0}";
2804 let song = chordsketch_core::parse(input).unwrap();
2805 let config = chordsketch_core::config::Config::defaults()
2806 .with_define("diagrams.frets=4")
2807 .unwrap();
2808 let html = render_song_with_transpose(&song, 0, &config);
2809 assert!(
2811 html.contains("height=\"140\""),
2812 "SVG height should reflect diagrams.frets=4 (expected 140)"
2813 );
2814 }
2815
2816 #[test]
2819 fn test_diagrams_off_suppresses_chord_diagrams() {
2820 let html = render("{diagrams: off}\n{define: Am base-fret 1 frets x 0 2 2 1 0}");
2821 assert!(
2822 !html.contains("<svg"),
2823 "chord diagram SVG should be suppressed when diagrams=off"
2824 );
2825 }
2826
2827 #[test]
2828 fn test_diagrams_on_shows_chord_diagrams() {
2829 let html = render("{diagrams: on}\n{define: Am base-fret 1 frets x 0 2 2 1 0}");
2830 assert!(
2831 html.contains("<svg"),
2832 "chord diagram SVG should be shown when diagrams=on"
2833 );
2834 }
2835
2836 #[test]
2837 fn test_diagrams_default_shows_chord_diagrams() {
2838 let html = render("{define: Am base-fret 1 frets x 0 2 2 1 0}");
2839 assert!(
2840 html.contains("<svg"),
2841 "chord diagram SVG should be shown by default"
2842 );
2843 }
2844
2845 #[test]
2846 fn test_diagrams_off_then_on_restores() {
2847 let html = render(
2848 "{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}",
2849 );
2850 assert!(!html.contains(">Am<"), "Am diagram should be suppressed");
2852 assert!(html.contains(">G<"), "G diagram should be rendered");
2853 }
2854
2855 #[test]
2856 fn test_diagrams_parsed_as_known_directive() {
2857 let song = chordsketch_core::parse("{diagrams: off}").unwrap();
2858 if let chordsketch_core::ast::Line::Directive(d) = &song.lines[0] {
2859 assert_eq!(
2860 d.kind,
2861 chordsketch_core::ast::DirectiveKind::Diagrams,
2862 "diagrams should parse as DirectiveKind::Diagrams"
2863 );
2864 assert_eq!(d.value, Some("off".to_string()));
2865 } else {
2866 panic!("expected a directive line, got: {:?}", &song.lines[0]);
2867 }
2868 }
2869
2870 #[test]
2873 fn test_diagrams_off_case_insensitive() {
2874 let html = render("{diagrams: Off}\n{define: Am base-fret 1 frets x 0 2 2 1 0}");
2875 assert!(
2876 !html.contains("<svg"),
2877 "diagrams=Off should suppress diagrams (case-insensitive)"
2878 );
2879 }
2880
2881 #[test]
2882 fn test_diagrams_off_uppercase() {
2883 let html = render("{diagrams: OFF}\n{define: Am base-fret 1 frets x 0 2 2 1 0}");
2884 assert!(
2885 !html.contains("<svg"),
2886 "diagrams=OFF should suppress diagrams (case-insensitive)"
2887 );
2888 }
2889
2890 #[test]
2893 fn test_diagrams_auto_inject_from_builtin_db() {
2894 let html = render("{diagrams}\n[Am]Hello [G]World");
2896 assert!(
2897 html.contains("class=\"chord-diagrams\""),
2898 "should render chord-diagrams section"
2899 );
2900 assert!(html.contains(">Am<"), "Am diagram expected");
2902 assert!(html.contains(">G<"), "G diagram expected");
2903 }
2904
2905 #[test]
2906 fn test_diagrams_auto_inject_unknown_chord_skipped() {
2907 let html = render("{diagrams}\n[Xyzzy]Hello");
2909 assert!(
2911 !html.contains("class=\"chord-diagrams\""),
2912 "no diagram section for unknown chord"
2913 );
2914 }
2915
2916 #[test]
2917 fn test_no_diagrams_suppresses_auto_inject() {
2918 let html = render("{no_diagrams}\n[Am]Hello");
2919 assert!(
2920 !html.contains("class=\"chord-diagrams\""),
2921 "{{no_diagrams}} should suppress auto-inject"
2922 );
2923 }
2924
2925 #[test]
2926 fn test_diagrams_define_takes_priority_over_builtin() {
2927 let html = render("{diagrams}\n{define: Am base-fret 1 frets x 0 2 2 1 0}\n[Am]Hello");
2931 assert!(
2933 html.contains("font-weight=\"bold\">Am</text>"),
2934 "Am diagram should appear inline at the {{define}} position"
2935 );
2936 assert!(
2938 !html.contains("class=\"chord-diagrams\""),
2939 "auto-inject section should be absent when all used chords are defined"
2940 );
2941 }
2942
2943 #[test]
2944 fn test_diagrams_off_suppresses_auto_inject() {
2945 let html = render("{diagrams: off}\n[Am]Hello");
2946 assert!(
2947 !html.contains("class=\"chord-diagrams\""),
2948 "{{diagrams: off}} should suppress auto-inject grid"
2949 );
2950 }
2951
2952 #[test]
2953 fn test_diagrams_ukulele_instrument() {
2954 let html = render("{diagrams: ukulele}\n[Am]Hello");
2955 assert!(
2956 html.contains("class=\"chord-diagrams\""),
2957 "ukulele diagrams section expected"
2958 );
2959 assert!(html.contains(">Am<"), "Am diagram expected");
2961 }
2962
2963 #[test]
2964 fn test_diagrams_guitar_explicit_overrides_config_default() {
2965 let song = chordsketch_core::parse("{diagrams: guitar}\n[Am]Hello").unwrap();
2968 let config = chordsketch_core::config::Config::defaults()
2969 .with_define("diagrams.instrument=ukulele")
2970 .unwrap();
2971 let html = render_song_with_transpose(&song, 0, &config);
2972 assert!(
2973 html.contains("class=\"chord-diagrams\""),
2974 "guitar diagrams section expected"
2975 );
2976 assert!(html.contains(">Am<"), "Am diagram expected");
2977 let guitar_am_html = render_song_with_transpose(
2978 &chordsketch_core::parse("{diagrams: guitar}\n[Am]Hello").unwrap(),
2979 0,
2980 &chordsketch_core::config::Config::defaults(),
2981 );
2982 let uke_am_html = render_song_with_transpose(
2983 &chordsketch_core::parse("{diagrams: ukulele}\n[Am]Hello").unwrap(),
2984 0,
2985 &chordsketch_core::config::Config::defaults(),
2986 );
2987 assert_ne!(
2989 guitar_am_html, uke_am_html,
2990 "guitar and ukulele Am diagrams should differ"
2991 );
2992 assert_eq!(
2995 html, guitar_am_html,
2996 "{{diagrams: guitar}} must select guitar regardless of config default"
2997 );
2998 }
2999
3000 #[test]
3001 fn test_no_diagrams_suppresses_inline_define_diagrams() {
3002 let html = render("{no_diagrams}\n{define: Am base-fret 1 frets x 0 2 2 1 0}\n[Am]Hello");
3005 assert!(
3006 !html.contains("<svg"),
3007 "{{no_diagrams}} should suppress inline define diagram SVG"
3008 );
3009 }
3010
3011 #[test]
3012 fn test_define_chord_not_duplicated_in_auto_inject_grid() {
3013 let html =
3017 render("{define: Am base-fret 1 frets x 0 2 2 1 0}\n{diagrams}\n[Am]Hello [G]world\n");
3018 let am_svg_count = html.match_indices("font-weight=\"bold\">Am</text>").count();
3020 assert_eq!(
3021 am_svg_count, 1,
3022 "Am diagram should appear exactly once (inline via {{define}}), not also in auto-inject grid"
3023 );
3024 assert!(
3026 html.contains("font-weight=\"bold\">G</text>"),
3027 "G diagram should appear in the auto-inject grid"
3028 );
3029 }
3030
3031 #[test]
3032 fn test_define_after_nodiagrams_appears_in_grid() {
3033 let html = render(
3037 "{no_diagrams}\n{define: Am base-fret 1 frets x 0 2 2 1 0}\n{diagrams}\n[Am]Hello\n",
3038 );
3039 assert!(
3042 html.contains("class=\"chord-diagrams\""),
3043 "auto-inject grid should appear since Am was not rendered inline"
3044 );
3045 assert!(
3046 html.contains("font-weight=\"bold\">Am</text>"),
3047 "Am should appear in the auto-inject grid"
3048 );
3049 }
3050
3051 #[test]
3052 fn test_enharmonic_define_dedup() {
3053 let html = render("{define: Bb base-fret 1 frets x 1 3 3 3 1}\n{diagrams}\n[A#]Hello\n");
3057 let bb_count = html.match_indices("font-weight=\"bold\">Bb</text>").count();
3059 let as_count = html.match_indices("font-weight=\"bold\">A#</text>").count();
3060 assert_eq!(bb_count, 1, "Bb should appear once (inline)");
3061 assert_eq!(
3062 as_count, 0,
3063 "A# should NOT appear in the auto-inject grid (same chord as Bb)"
3064 );
3065 }
3066
3067 #[test]
3068 fn test_chord_directive_appears_in_auto_inject_grid() {
3069 let html = render("{chord: Am base-fret 1 frets x 0 2 2 1 0}\n{diagrams}\n[Am]Hello\n");
3072 assert!(
3075 html.contains("class=\"chord-diagrams\""),
3076 "auto-inject grid should appear since {{chord}} does not render inline"
3077 );
3078 assert!(
3079 html.contains("font-weight=\"bold\">Am</text>"),
3080 "Am should appear in the auto-inject grid via {{chord}} voicing"
3081 );
3082 }
3083
3084 #[test]
3087 fn test_abc_section_disabled_by_config() {
3088 let input = "{start_of_abc}\nX:1\n{end_of_abc}";
3090 let song = chordsketch_core::parse(input).unwrap();
3091 let config = chordsketch_core::config::Config::defaults()
3092 .with_define("delegates.abc2svg=false")
3093 .unwrap();
3094 let html = render_song_with_transpose(&song, 0, &config);
3095 assert!(html.contains("<section class=\"abc\">"));
3096 assert!(html.contains("ABC"));
3097 assert!(html.contains("</section>"));
3098 }
3099
3100 #[test]
3101 fn test_abc_section_null_config_auto_detect_disabled() {
3102 if chordsketch_core::external_tool::has_abc2svg() {
3105 return; }
3107 let input = "{start_of_abc}\nX:1\n{end_of_abc}";
3108 let song = chordsketch_core::parse(input).unwrap();
3109 let config = chordsketch_core::config::Config::defaults();
3111 assert!(
3112 config.get_path("delegates.abc2svg").is_null(),
3113 "default config should have null delegates.abc2svg"
3114 );
3115 let html = render_song_with_transpose(&song, 0, &config);
3116 assert!(
3117 html.contains("<section class=\"abc\">"),
3118 "null auto-detect with no abc2svg should render as text section"
3119 );
3120 }
3121
3122 #[test]
3123 fn test_abc_section_fallback_preformatted() {
3124 if chordsketch_core::external_tool::has_abc2svg() {
3126 return; }
3128 let input = "{start_of_abc}\nX:1\nT:Test\nK:C\n{end_of_abc}";
3129 let song = chordsketch_core::parse(input).unwrap();
3130 let config = chordsketch_core::config::Config::defaults()
3131 .with_define("delegates.abc2svg=true")
3132 .unwrap();
3133 let html = render_song_with_transpose(&song, 0, &config);
3134 assert!(html.contains("<section class=\"abc\">"));
3135 assert!(html.contains("<pre>"));
3136 assert!(html.contains("X:1"));
3137 assert!(html.contains("</pre>"));
3138 }
3139
3140 #[test]
3141 fn test_abc_section_with_label_delegate_fallback() {
3142 if chordsketch_core::external_tool::has_abc2svg() {
3143 return;
3144 }
3145 let input = "{start_of_abc: Melody}\nX:1\n{end_of_abc}";
3146 let song = chordsketch_core::parse(input).unwrap();
3147 let config = chordsketch_core::config::Config::defaults()
3148 .with_define("delegates.abc2svg=true")
3149 .unwrap();
3150 let html = render_song_with_transpose(&song, 0, &config);
3151 assert!(html.contains("ABC: Melody"));
3152 assert!(html.contains("<pre>"));
3153 }
3154
3155 #[test]
3156 #[ignore]
3157 fn test_abc_section_renders_svg_with_abc2svg() {
3158 let input = "{start_of_abc}\nX:1\nT:Test\nM:4/4\nK:C\nCDEF|GABc|\n{end_of_abc}";
3160 let song = chordsketch_core::parse(input).unwrap();
3161 let config = chordsketch_core::config::Config::defaults()
3162 .with_define("delegates.abc2svg=true")
3163 .unwrap();
3164 let html = render_song_with_transpose(&song, 0, &config);
3165 assert!(html.contains("<section class=\"abc\">"));
3166 assert!(
3167 html.contains("<svg"),
3168 "should contain rendered SVG from abc2svg"
3169 );
3170 assert!(html.contains("</section>"));
3171 }
3172
3173 #[test]
3174 fn test_abc_section_auto_detect_default_config() {
3175 let input = "{start_of_abc}\nX:1\nT:Test\nK:C\n{end_of_abc}";
3179 let song = chordsketch_core::parse(input).unwrap();
3180 let config = chordsketch_core::config::Config::defaults();
3181 let html = render_song_with_transpose(&song, 0, &config);
3182 assert!(
3183 html.contains("<section class=\"abc\">"),
3184 "auto-detect should produce abc section"
3185 );
3186 if !chordsketch_core::external_tool::has_abc2svg() {
3187 assert!(
3188 html.contains("X:1"),
3189 "raw ABC content should be present without tool"
3190 );
3191 assert!(
3192 !html.contains("<svg"),
3193 "no SVG should be generated without abc2svg"
3194 );
3195 }
3196 }
3197
3198 #[test]
3201 fn test_ly_section_auto_detect_default_config() {
3202 let input = "{start_of_ly}\n\\relative c' { c4 }\n{end_of_ly}";
3204 let song = chordsketch_core::parse(input).unwrap();
3205 let config = chordsketch_core::config::Config::defaults();
3206 let html = render_song_with_transpose(&song, 0, &config);
3207 assert!(
3208 html.contains("<section class=\"ly\">"),
3209 "auto-detect should produce ly section"
3210 );
3211 if !chordsketch_core::external_tool::has_lilypond() {
3212 assert!(
3213 html.contains("\\relative"),
3214 "raw Lilypond content should be present without tool"
3215 );
3216 assert!(
3217 !html.contains("<svg"),
3218 "no SVG should be generated without lilypond"
3219 );
3220 }
3221 }
3222
3223 #[test]
3224 fn test_ly_section_disabled_by_config() {
3225 let input = "{start_of_ly}\n\\relative c' { c4 }\n{end_of_ly}";
3227 let song = chordsketch_core::parse(input).unwrap();
3228 let config = chordsketch_core::config::Config::defaults()
3229 .with_define("delegates.lilypond=false")
3230 .unwrap();
3231 let html = render_song_with_transpose(&song, 0, &config);
3232 assert!(html.contains("<section class=\"ly\">"));
3233 assert!(html.contains("Lilypond"));
3234 assert!(html.contains("</section>"));
3235 }
3236
3237 #[test]
3238 fn test_ly_section_fallback_preformatted() {
3239 if chordsketch_core::external_tool::has_lilypond() {
3240 return;
3241 }
3242 let input = "{start_of_ly}\n\\relative c' { c4 }\n{end_of_ly}";
3243 let song = chordsketch_core::parse(input).unwrap();
3244 let config = chordsketch_core::config::Config::defaults()
3245 .with_define("delegates.lilypond=true")
3246 .unwrap();
3247 let html = render_song_with_transpose(&song, 0, &config);
3248 assert!(html.contains("<section class=\"ly\">"));
3249 assert!(html.contains("<pre>"));
3250 assert!(html.contains("</pre>"));
3251 }
3252
3253 #[test]
3254 #[ignore]
3255 fn test_ly_section_renders_svg_with_lilypond() {
3256 let input = "{start_of_ly}\n\\relative c' { c4 d e f | g2 g | }\n{end_of_ly}";
3258 let song = chordsketch_core::parse(input).unwrap();
3259 let config = chordsketch_core::config::Config::defaults()
3260 .with_define("delegates.lilypond=true")
3261 .unwrap();
3262 let html = render_song_with_transpose(&song, 0, &config);
3263 assert!(html.contains("<section class=\"ly\">"));
3264 assert!(
3265 html.contains("<svg"),
3266 "should contain rendered SVG from lilypond"
3267 );
3268 assert!(html.contains("</section>"));
3269 }
3270}
3271
3272#[cfg(test)]
3273mod delegate_tests {
3274 use super::*;
3275
3276 #[test]
3277 fn test_render_abc_section() {
3278 let html = render("{start_of_abc}\nX:1\n{end_of_abc}");
3279 assert!(html.contains("<section class=\"abc\">"));
3280 assert!(html.contains("ABC"));
3281 assert!(html.contains("</section>"));
3282 }
3283
3284 #[test]
3285 fn test_render_abc_section_with_label() {
3286 let html = render("{start_of_abc: Melody}\nX:1\n{end_of_abc}");
3287 assert!(html.contains("<section class=\"abc\">"));
3288 assert!(html.contains("ABC: Melody"));
3289 }
3290
3291 #[test]
3292 fn test_render_ly_section() {
3293 let html = render("{start_of_ly}\nnotes\n{end_of_ly}");
3294 assert!(html.contains("<section class=\"ly\">"));
3295 assert!(html.contains("Lilypond"));
3296 assert!(html.contains("</section>"));
3297 }
3298
3299 #[test]
3302 fn test_render_musicxml_section_disabled() {
3303 let input = "{start_of_musicxml}\n<score-partwise/>\n{end_of_musicxml}";
3305 let song = chordsketch_core::parse(input).unwrap();
3306 let config = chordsketch_core::config::Config::defaults()
3307 .with_define("delegates.musescore=false")
3308 .unwrap();
3309 let html = render_song_with_transpose(&song, 0, &config);
3310 assert!(
3311 html.contains("<section class=\"musicxml\">"),
3312 "fallback section should render when musescore is disabled: {html}"
3313 );
3314 assert!(html.contains("MusicXML"), "section label should appear");
3315 assert!(html.contains("</section>"), "section should be closed");
3316 }
3317
3318 #[test]
3319 fn test_render_musicxml_section_no_musescore_installed() {
3320 if chordsketch_core::external_tool::has_musescore() {
3323 return; }
3325
3326 let input = "{start_of_musicxml}\n<score-partwise/>\n{end_of_musicxml}";
3327 let song = chordsketch_core::parse(input).unwrap();
3328 let config = chordsketch_core::config::Config::defaults();
3329 assert!(
3330 config.get_path("delegates.musescore").is_null(),
3331 "default config should have null delegates.musescore"
3332 );
3333 let html = render_song_with_transpose(&song, 0, &config);
3334 assert!(
3335 html.contains("<section class=\"musicxml\">"),
3336 "null auto-detect with no musescore should render as text section"
3337 );
3338 }
3339
3340 #[test]
3341 fn test_render_musicxml_section_with_label() {
3342 let input = "{start_of_musicxml: Score}\n<score-partwise/>\n{end_of_musicxml}";
3343 let song = chordsketch_core::parse(input).unwrap();
3344 let config = chordsketch_core::config::Config::defaults()
3345 .with_define("delegates.musescore=false")
3346 .unwrap();
3347 let html = render_song_with_transpose(&song, 0, &config);
3348 assert!(
3349 html.contains("Score"),
3350 "label should appear in section header"
3351 );
3352 }
3353
3354 #[test]
3355 fn test_abc_fallback_sanitizes_would_be_script_in_svg() {
3356 let malicious_svg = "<svg><script>alert(1)</script><circle r=\"5\"/></svg>";
3360 let sanitized = sanitize_svg_content(malicious_svg);
3361 assert!(
3362 !sanitized.contains("<script>"),
3363 "script tags must be stripped from delegate SVG output"
3364 );
3365 assert!(sanitized.contains("<circle"));
3366 }
3367
3368 #[test]
3369 fn test_sanitize_svg_strips_event_handlers_from_delegate_output() {
3370 let svg_with_handler = "<svg><rect onmouseover=\"alert(1)\" width=\"10\"/></svg>";
3371 let sanitized = sanitize_svg_content(svg_with_handler);
3372 assert!(
3373 !sanitized.contains("onmouseover"),
3374 "event handlers must be stripped from delegate SVG output"
3375 );
3376 assert!(sanitized.contains("<rect"));
3377 }
3378
3379 #[test]
3380 fn test_sanitize_svg_strips_foreignobject_from_delegate_output() {
3381 let svg = "<svg><foreignObject><body xmlns=\"http://www.w3.org/1999/xhtml\"><script>alert(1)</script></body></foreignObject></svg>";
3382 let sanitized = sanitize_svg_content(svg);
3383 assert!(
3384 !sanitized.contains("<foreignObject"),
3385 "foreignObject must be stripped from delegate SVG output"
3386 );
3387 }
3388
3389 #[test]
3390 fn test_sanitize_svg_strips_math_element() {
3391 let svg = "<svg><math><mi>x</mi></math></svg>";
3392 let sanitized = sanitize_svg_content(svg);
3393 assert!(
3394 !sanitized.contains("<math"),
3395 "math element must be stripped from delegate SVG output"
3396 );
3397 }
3398
3399 #[test]
3400 fn test_render_svg_section() {
3401 let html = render("{start_of_svg}\n<svg/>\n{end_of_svg}");
3402 assert!(html.contains("<div class=\"svg-section\">"));
3404 assert!(html.contains("<svg/>"));
3405 assert!(html.contains("</div>"));
3406 }
3407
3408 #[test]
3409 fn test_render_svg_inline_content() {
3410 let svg = r#"<svg width="100" height="100"><circle cx="50" cy="50" r="40"/></svg>"#;
3411 let input = format!("{{start_of_svg}}\n{svg}\n{{end_of_svg}}");
3412 let html = render(&input);
3413 assert!(html.contains(svg));
3414 }
3415
3416 #[test]
3417 fn test_svg_section_strips_script_tags() {
3418 let input = "{start_of_svg}\n<svg><script>alert('xss')</script><circle r=\"10\"/></svg>\n{end_of_svg}";
3419 let html = render(input);
3420 assert!(!html.contains("<script>"), "script tags must be stripped");
3421 assert!(!html.contains("alert"), "script content must be stripped");
3422 assert!(
3423 html.contains("<circle r=\"10\"/>"),
3424 "safe SVG content must be preserved"
3425 );
3426 }
3427
3428 #[test]
3429 fn test_svg_section_strips_event_handlers() {
3430 let input = "{start_of_svg}\n<svg onload=\"alert(1)\"><rect width=\"10\" onerror=\"hack()\"/></svg>\n{end_of_svg}";
3431 let html = render(input);
3432 assert!(!html.contains("onload"), "onload handler must be stripped");
3433 assert!(
3434 !html.contains("onerror"),
3435 "onerror handler must be stripped"
3436 );
3437 assert!(
3438 html.contains("width=\"10\""),
3439 "safe attributes must be preserved"
3440 );
3441 }
3442
3443 #[test]
3444 fn test_svg_section_preserves_safe_content() {
3445 let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><text x="10" y="20">Hello</text></svg>"#;
3446 let input = format!("{{start_of_svg}}\n{svg}\n{{end_of_svg}}");
3447 let html = render(&input);
3448 assert!(html.contains("xmlns=\"http://www.w3.org/2000/svg\""));
3449 assert!(html.contains("<text x=\"10\" y=\"20\">Hello</text>"));
3450 }
3451
3452 #[test]
3453 fn test_svg_section_strips_case_insensitive_script() {
3454 let input = "{start_of_svg}\n<SCRIPT>alert(1)</SCRIPT><svg/>\n{end_of_svg}";
3455 let html = render(input);
3456 assert!(!html.contains("SCRIPT"), "case-insensitive script removal");
3457 assert!(!html.contains("alert"));
3458 assert!(html.contains("<svg/>"));
3459 }
3460
3461 #[test]
3462 fn test_svg_section_strips_foreignobject() {
3463 let input = "{start_of_svg}\n<svg><foreignObject><body onload=\"alert(1)\"></body></foreignObject><rect width=\"10\"/></svg>\n{end_of_svg}";
3464 let html = render(input);
3465 assert!(
3466 !html.contains("foreignObject"),
3467 "foreignObject must be stripped"
3468 );
3469 assert!(
3470 !html.contains("foreignobject"),
3471 "foreignObject (lowercase) must be stripped"
3472 );
3473 assert!(
3474 html.contains("<rect width=\"10\"/>"),
3475 "safe content must be preserved"
3476 );
3477 }
3478
3479 #[test]
3480 fn test_svg_section_strips_iframe() {
3481 let input = "{start_of_svg}\n<svg><iframe src=\"javascript:alert(1)\"></iframe><circle r=\"5\"/></svg>\n{end_of_svg}";
3482 let html = render(input);
3483 assert!(!html.contains("iframe"), "iframe must be stripped");
3484 assert!(html.contains("<circle r=\"5\"/>"));
3485 }
3486
3487 #[test]
3488 fn test_svg_section_strips_object_and_embed() {
3489 let input = "{start_of_svg}\n<svg><object data=\"evil.swf\"></object><embed src=\"evil.swf\"></embed><rect/></svg>\n{end_of_svg}";
3490 let html = render(input);
3491 assert!(!html.contains("object"), "object must be stripped");
3492 assert!(!html.contains("embed"), "embed must be stripped");
3493 assert!(html.contains("<rect/>"));
3494 }
3495
3496 #[test]
3497 fn test_svg_section_strips_javascript_uri_in_href() {
3498 let input = "{start_of_svg}\n<svg><a href=\"javascript:alert(1)\"><text>Click</text></a></svg>\n{end_of_svg}";
3499 let html = render(input);
3500 assert!(
3501 !html.contains("javascript:"),
3502 "javascript: URI must be stripped from href"
3503 );
3504 assert!(html.contains("<text>Click</text>"));
3505 }
3506
3507 #[test]
3508 fn test_svg_section_strips_vbscript_uri() {
3509 let input = "{start_of_svg}\n<svg><a href=\"vbscript:MsgBox\"><text>Click</text></a></svg>\n{end_of_svg}";
3510 let html = render(input);
3511 assert!(
3512 !html.contains("vbscript:"),
3513 "vbscript: URI must be stripped"
3514 );
3515 }
3516
3517 #[test]
3518 fn test_svg_section_strips_data_uri_in_use() {
3519 let input = "{start_of_svg}\n<svg><use href=\"data:image/svg+xml;base64,PHN2Zy8+\"/></svg>\n{end_of_svg}";
3520 let html = render(input);
3521 assert!(
3522 !html.contains("data:"),
3523 "data: URI must be stripped from use href"
3524 );
3525 }
3526
3527 #[test]
3528 fn test_svg_section_strips_javascript_uri_case_insensitive() {
3529 let input = "{start_of_svg}\n<svg><a href=\"JaVaScRiPt:alert(1)\"><text>X</text></a></svg>\n{end_of_svg}";
3530 let html = render(input);
3531 assert!(
3532 !html.to_lowercase().contains("javascript:"),
3533 "case-insensitive javascript: URI must be stripped"
3534 );
3535 }
3536
3537 #[test]
3538 fn test_svg_section_strips_xlink_href_dangerous_uri() {
3539 let input =
3540 "{start_of_svg}\n<svg><use xlink:href=\"javascript:alert(1)\"/></svg>\n{end_of_svg}";
3541 let html = render(input);
3542 assert!(
3543 !html.contains("javascript:"),
3544 "javascript: URI in xlink:href must be stripped"
3545 );
3546 }
3547
3548 #[test]
3549 fn test_svg_section_preserves_safe_href() {
3550 let input = "{start_of_svg}\n<svg><a href=\"https://example.com\"><text>Link</text></a></svg>\n{end_of_svg}";
3551 let html = render(input);
3552 assert!(
3553 html.contains("href=\"https://example.com\""),
3554 "safe https: href must be preserved"
3555 );
3556 }
3557
3558 #[test]
3559 fn test_svg_section_preserves_fragment_href() {
3560 let input = "{start_of_svg}\n<svg><use href=\"#myShape\"/></svg>\n{end_of_svg}";
3561 let html = render(input);
3562 assert!(
3563 html.contains("href=\"#myShape\""),
3564 "fragment-only href must be preserved"
3565 );
3566 }
3567
3568 #[test]
3569 fn test_render_textblock_section() {
3570 let html = render("{start_of_textblock}\nPreformatted\n{end_of_textblock}");
3571 assert!(html.contains("<section class=\"textblock\">"));
3572 assert!(html.contains("Textblock"));
3573 assert!(html.contains("</section>"));
3574 }
3575
3576 #[test]
3579 fn test_render_songs_single() {
3580 let songs = chordsketch_core::parse_multi("{title: Only}").unwrap();
3581 let html = render_songs(&songs);
3582 assert_eq!(html, render_song(&songs[0]));
3584 }
3585
3586 #[test]
3587 fn test_render_songs_two_songs_with_hr_separator() {
3588 let songs = chordsketch_core::parse_multi(
3589 "{title: Song A}\n[Am]Hello\n{new_song}\n{title: Song B}\n[G]World",
3590 )
3591 .unwrap();
3592 let html = render_songs(&songs);
3593 assert!(html.contains("<title>Song A</title>"));
3595 assert!(html.contains("<h1>Song A</h1>"));
3597 assert!(html.contains("<h1>Song B</h1>"));
3598 assert!(html.contains("<hr class=\"song-separator\">"));
3600 assert_eq!(html.matches("<div class=\"song\">").count(), 2);
3602 assert_eq!(html.matches("<!DOCTYPE html>").count(), 1);
3604 assert_eq!(html.matches("</html>").count(), 1);
3605 }
3606
3607 #[test]
3608 fn test_image_scale_css_injection_prevented() {
3609 let html = render("{image: src=photo.jpg scale=0.5); position: fixed; z-index: 9999}");
3612 assert!(!html.contains("position"));
3613 assert!(!html.contains("z-index"));
3614 assert!(!html.contains("position: fixed"));
3616 }
3617
3618 #[test]
3619 fn test_render_songs_with_transpose() {
3620 let songs =
3621 chordsketch_core::parse_multi("{title: S1}\n[C]Do\n{new_song}\n{title: S2}\n[G]Re")
3622 .unwrap();
3623 let html = render_songs_with_transpose(&songs, 2, &Config::defaults());
3624 assert!(html.contains(">D<"));
3626 assert!(html.contains(">A<"));
3627 }
3628
3629 #[test]
3632 fn test_sanitize_svg_strips_set_element() {
3633 let svg = r##"<svg><a href="#"><set attributeName="href" to="javascript:alert(1)"/><text>Click</text></a></svg>"##;
3634 let sanitized = sanitize_svg_content(svg);
3635 assert!(
3636 !sanitized.contains("<set"),
3637 "set element must be stripped to prevent SVG animation XSS"
3638 );
3639 assert!(sanitized.contains("<text>Click</text>"));
3640 }
3641
3642 #[test]
3643 fn test_sanitize_svg_strips_animate_element() {
3644 let svg =
3645 r#"<svg><animate attributeName="href" values="javascript:alert(1)"/><rect/></svg>"#;
3646 let sanitized = sanitize_svg_content(svg);
3647 assert!(
3648 !sanitized.contains("<animate"),
3649 "animate element must be stripped"
3650 );
3651 assert!(sanitized.contains("<rect/>"));
3652 }
3653
3654 #[test]
3655 fn test_sanitize_svg_strips_animatetransform() {
3656 let svg =
3657 "<svg><animateTransform attributeName=\"transform\" type=\"rotate\"/><rect/></svg>";
3658 let sanitized = sanitize_svg_content(svg);
3659 assert!(
3660 !sanitized.contains("animateTransform"),
3661 "animateTransform must be stripped"
3662 );
3663 assert!(
3664 !sanitized.contains("animatetransform"),
3665 "animatetransform (lowercase) must be stripped"
3666 );
3667 }
3668
3669 #[test]
3670 fn test_sanitize_svg_strips_animatemotion() {
3671 let svg = "<svg><animateMotion path=\"M0,0 L100,100\"/><rect/></svg>";
3672 let sanitized = sanitize_svg_content(svg);
3673 assert!(
3674 !sanitized.contains("animateMotion"),
3675 "animateMotion must be stripped"
3676 );
3677 }
3678
3679 #[test]
3680 fn test_sanitize_svg_strips_to_attr_with_dangerous_uri() {
3681 let svg = r#"<svg><a to="javascript:alert(1)"><text>X</text></a></svg>"#;
3682 let sanitized = sanitize_svg_content(svg);
3683 assert!(
3684 !sanitized.contains("javascript:"),
3685 "dangerous URI in 'to' attr must be stripped"
3686 );
3687 }
3688
3689 #[test]
3690 fn test_sanitize_svg_strips_values_attr_with_dangerous_uri() {
3691 let svg = r#"<svg><a values="javascript:alert(1)"><text>X</text></a></svg>"#;
3692 let sanitized = sanitize_svg_content(svg);
3693 assert!(
3694 !sanitized.contains("javascript:"),
3695 "dangerous URI in 'values' attr must be stripped"
3696 );
3697 }
3698
3699 #[test]
3702 fn test_strip_dangerous_attrs_preserves_cjk_text() {
3703 let input = "<svg><text x=\"10\">日本語テスト</text></svg>";
3704 let result = strip_dangerous_attrs(input);
3705 assert!(
3706 result.contains("日本語テスト"),
3707 "CJK characters must not be corrupted"
3708 );
3709 }
3710
3711 #[test]
3712 fn test_strip_dangerous_attrs_preserves_emoji() {
3713 let input = "<svg><text>🎵🎸🎹</text></svg>";
3714 let result = strip_dangerous_attrs(input);
3715 assert!(result.contains("🎵🎸🎹"), "emoji must not be corrupted");
3716 }
3717
3718 #[test]
3719 fn test_strip_dangerous_attrs_preserves_accented_chars() {
3720 let input = "<svg><text>café résumé naïve</text></svg>";
3721 let result = strip_dangerous_attrs(input);
3722 assert!(
3723 result.contains("café résumé naïve"),
3724 "accented characters must not be corrupted"
3725 );
3726 }
3727
3728 #[test]
3729 fn test_sanitize_svg_full_roundtrip_with_non_ascii() {
3730 let input = "<svg><text x=\"10\">コード譜 🎵</text><rect width=\"100\"/></svg>";
3731 let sanitized = sanitize_svg_content(input);
3732 assert!(sanitized.contains("コード譜 🎵"));
3733 assert!(sanitized.contains("<rect width=\"100\"/>"));
3734 }
3735
3736 #[test]
3737 fn test_sanitize_svg_self_closing_with_gt_in_attr_value() {
3738 let svg = r#"<svg><set to="a>b"/><text>safe</text></svg>"#;
3740 let sanitized = sanitize_svg_content(svg);
3741 assert!(
3742 !sanitized.contains("<set"),
3743 "dangerous <set> element must be stripped"
3744 );
3745 assert!(
3746 sanitized.contains("<text>safe</text>"),
3747 "content after stripped self-closing element must be preserved"
3748 );
3749 }
3750
3751 #[test]
3754 fn test_strip_dangerous_attrs_gt_in_double_quoted_attr() {
3755 let input = r#"<rect title=">" onload="alert(1)"/>"#;
3757 let result = strip_dangerous_attrs(input);
3758 assert!(
3759 !result.contains("onload"),
3760 "onload after quoted > must be stripped"
3761 );
3762 assert!(result.contains("title"));
3763 }
3764
3765 #[test]
3766 fn test_strip_dangerous_attrs_gt_in_single_quoted_attr() {
3767 let input = "<rect title='>' onload=\"alert(1)\"/>";
3768 let result = strip_dangerous_attrs(input);
3769 assert!(
3770 !result.contains("onload"),
3771 "onload after single-quoted > must be stripped"
3772 );
3773 }
3774
3775 #[test]
3778 fn test_dangerous_uri_scheme_with_embedded_tab() {
3779 assert!(has_dangerous_uri_scheme("java\tscript:alert(1)"));
3780 }
3781
3782 #[test]
3783 fn test_dangerous_uri_scheme_with_embedded_newline() {
3784 assert!(has_dangerous_uri_scheme("java\nscript:alert(1)"));
3785 }
3786
3787 #[test]
3788 fn test_dangerous_uri_scheme_with_control_chars() {
3789 assert!(has_dangerous_uri_scheme("java\x00script:alert(1)"));
3790 }
3791
3792 #[test]
3793 fn test_safe_uri_not_flagged() {
3794 assert!(!has_dangerous_uri_scheme("https://example.com"));
3795 }
3796
3797 #[test]
3798 fn test_dangerous_uri_scheme_with_many_embedded_whitespace() {
3799 let payload = "j\ta\tv\ta\ts\tc\tr\ti\tp\tt\t:\ta\tl\te\tr\tt\t(\t1\t)\t";
3802 assert!(
3803 has_dangerous_uri_scheme(payload),
3804 "1 tab between letters should not bypass javascript: detection"
3805 );
3806 }
3807
3808 #[test]
3809 fn test_dangerous_uri_scheme_whitespace_bypass_regression() {
3810 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:";
3815 assert!(
3816 has_dangerous_uri_scheme(payload),
3817 "3 tabs between letters (colon at raw position 40) must still be detected"
3818 );
3819 }
3820
3821 #[test]
3824 fn test_svg_section_blocks_multiline_script_tag_splitting() {
3825 let input = "{start_of_svg}\n<script\n>alert(1)</script>\n{end_of_svg}";
3827 let html = render(input);
3828 assert!(
3829 !html.contains("alert(1)"),
3830 "multi-line <script> tag splitting must not execute JS"
3831 );
3832 assert!(
3833 !html.to_lowercase().contains("<script"),
3834 "multi-line <script> tag must be stripped"
3835 );
3836 }
3837
3838 #[test]
3839 fn test_svg_section_blocks_multiline_iframe_tag_splitting() {
3840 let input =
3841 "{start_of_svg}\n<iframe\nsrc=\"javascript:alert(1)\">\n</iframe>\n{end_of_svg}";
3842 let html = render(input);
3843 assert!(
3844 !html.to_lowercase().contains("<iframe"),
3845 "multi-line <iframe> tag splitting must be stripped"
3846 );
3847 assert!(
3848 !html.contains("javascript:"),
3849 "javascript: URI in split iframe must be stripped"
3850 );
3851 }
3852
3853 #[test]
3854 fn test_svg_section_blocks_multiline_foreignobject_splitting() {
3855 let input = "{start_of_svg}\n<foreignObject\n><script>alert(1)</script></foreignObject>\n{end_of_svg}";
3856 let html = render(input);
3857 assert!(
3858 !html.to_lowercase().contains("<foreignobject"),
3859 "multi-line <foreignObject> splitting must be stripped"
3860 );
3861 }
3862
3863 #[test]
3866 fn test_dangerous_uri_file_scheme_blocked() {
3867 assert!(
3869 has_dangerous_uri_scheme("file:///etc/passwd"),
3870 "file: URI scheme must be detected as dangerous"
3871 );
3872 assert!(
3873 has_dangerous_uri_scheme("FILE:///etc/passwd"),
3874 "FILE: (uppercase) must be detected as dangerous"
3875 );
3876 }
3877
3878 #[test]
3879 fn test_dangerous_uri_blob_scheme_blocked() {
3880 assert!(
3881 has_dangerous_uri_scheme("blob:https://example.com/uuid"),
3882 "blob: URI scheme must be detected as dangerous"
3883 );
3884 assert!(
3885 has_dangerous_uri_scheme("BLOB:https://example.com/uuid"),
3886 "BLOB: (uppercase) must be detected as dangerous"
3887 );
3888 }
3889
3890 #[test]
3891 fn test_svg_section_strips_file_uri_in_use_href() {
3892 let input = "{start_of_svg}\n<svg><use href=\"file:///etc/passwd\"/></svg>\n{end_of_svg}";
3894 let html = render(input);
3895 assert!(
3896 !html.contains("file:///"),
3897 "file: URI in <use href> must be stripped; got: {html}"
3898 );
3899 }
3900
3901 #[test]
3902 fn test_svg_section_strips_file_uri_in_xlink_href() {
3903 let input =
3904 "{start_of_svg}\n<svg><use xlink:href=\"file:///etc/passwd\"/></svg>\n{end_of_svg}";
3905 let html = render(input);
3906 assert!(
3907 !html.contains("file:///"),
3908 "file: URI in xlink:href must be stripped; got: {html}"
3909 );
3910 }
3911
3912 #[test]
3915 fn test_svg_section_strips_feimage_element() {
3916 let input =
3918 "{start_of_svg}\n<svg><feImage href=\"file:///etc/passwd\"/></svg>\n{end_of_svg}";
3919 let html = render(input);
3920 assert!(
3921 !html.to_lowercase().contains("<feimage"),
3922 "feImage element must be stripped entirely; got: {html}"
3923 );
3924 assert!(
3925 !html.contains("file:///"),
3926 "file: URI inside feImage must not appear in output; got: {html}"
3927 );
3928 }
3929
3930 #[test]
3931 fn test_svg_section_strips_feimage_with_http_href() {
3932 let input = "{start_of_svg}\n<svg><feImage href=\"https://evil.example.com/spy.svg\"/></svg>\n{end_of_svg}";
3934 let html = render(input);
3935 assert!(
3936 !html.to_lowercase().contains("<feimage"),
3937 "feImage element must be stripped even with http href; got: {html}"
3938 );
3939 }
3940
3941 #[test]
3944 fn test_svg_section_strips_action_javascript_uri() {
3945 let input =
3947 "{start_of_svg}\n<svg><a action=\"javascript:alert(1)\">click</a></svg>\n{end_of_svg}";
3948 let html = render(input);
3949 assert!(
3950 !html.contains("javascript:"),
3951 "javascript: URI in action attribute must be stripped; got: {html}"
3952 );
3953 }
3954
3955 #[test]
3956 fn test_svg_section_strips_formaction_javascript_uri() {
3957 let input = "{start_of_svg}\n<svg><a formaction=\"javascript:alert(1)\">click</a></svg>\n{end_of_svg}";
3958 let html = render(input);
3959 assert!(
3960 !html.contains("javascript:"),
3961 "javascript: URI in formaction attribute must be stripped; got: {html}"
3962 );
3963 }
3964
3965 #[test]
3966 fn test_svg_section_strips_ping_javascript_uri() {
3967 let input =
3969 "{start_of_svg}\n<svg><a ping=\"javascript:alert(1)\">click</a></svg>\n{end_of_svg}";
3970 let html = render(input);
3971 assert!(
3972 !html.contains("javascript:"),
3973 "javascript: URI in ping attribute must be stripped; got: {html}"
3974 );
3975 }
3976
3977 #[test]
3978 fn test_svg_section_strips_poster_file_uri() {
3979 let input =
3981 "{start_of_svg}\n<svg><video poster=\"file:///etc/passwd\"/></svg>\n{end_of_svg}";
3982 let html = render(input);
3983 assert!(
3984 !html.contains("file:///"),
3985 "file: URI in poster attribute must be stripped; got: {html}"
3986 );
3987 }
3988
3989 #[test]
3990 fn test_svg_section_strips_background_file_uri() {
3991 let input =
3993 "{start_of_svg}\n<svg><body background=\"file:///etc/passwd\"/></svg>\n{end_of_svg}";
3994 let html = render(input);
3995 assert!(
3996 !html.contains("file:///"),
3997 "file: URI in background attribute must be stripped; got: {html}"
3998 );
3999 }
4000
4001 #[test]
4004 fn test_dangerous_uri_mhtml_scheme_blocked() {
4005 assert!(
4007 has_dangerous_uri_scheme("mhtml:file://C:/page.mhtml"),
4008 "mhtml: URI scheme must be detected as dangerous"
4009 );
4010 assert!(
4011 has_dangerous_uri_scheme("MHTML:file://C:/page.mhtml"),
4012 "MHTML: (uppercase) must be detected as dangerous"
4013 );
4014 }
4015
4016 #[test]
4019 fn test_svg_section_strips_image_element() {
4020 let input =
4023 "{start_of_svg}\n<svg><image href=\"https://evil.com/spy.png\"/></svg>\n{end_of_svg}";
4024 let html = render(input);
4025 assert!(
4026 !html.to_lowercase().contains("<image"),
4027 "SVG <image> element must be stripped entirely; got: {html}"
4028 );
4029 }
4030
4031 #[test]
4034 fn test_extreme_textsize_is_clamped_to_max() {
4035 let input = "{title: T}\n{textsize: 99999}\n[C]Hello";
4038 let html = render(input);
4039 assert!(
4040 !html.contains("99999"),
4041 "extreme textsize should be clamped, not passed through"
4042 );
4043 assert!(
4044 html.contains("200"),
4045 "extreme textsize should be clamped to MAX_FONT_SIZE (200)"
4046 );
4047 }
4048
4049 #[test]
4050 fn test_negative_textsize_is_clamped_to_min() {
4051 let input = "{title: T}\n{textsize: -10}\n[C]Hello";
4054 let html = render(input);
4055 assert!(
4056 html.contains("0.5"),
4057 "negative textsize should be clamped to MIN_FONT_SIZE (0.5)"
4058 );
4059 }
4060
4061 #[test]
4062 fn test_extreme_chordsize_is_clamped_to_max() {
4063 let input = "{title: T}\n{chordsize: 50000}\n[C]Hello";
4064 let html = render(input);
4065 assert!(
4066 !html.contains("50000"),
4067 "extreme chordsize should be clamped"
4068 );
4069 assert!(
4070 html.contains("200"),
4071 "extreme chordsize should be clamped to MAX_FONT_SIZE (200)"
4072 );
4073 }
4074}