use std::fmt::Write;
use chordsketch_core::ast::{CommentStyle, DirectiveKind, Line, LyricsLine, Song};
use chordsketch_core::canonical_chord_name;
use chordsketch_core::config::Config;
use chordsketch_core::escape::escape_xml as escape;
use chordsketch_core::inline_markup::{SpanAttributes, TextSpan};
use chordsketch_core::render_result::RenderResult;
use chordsketch_core::resolve_diagrams_instrument;
use chordsketch_core::transpose::transpose_chord;
const MAX_CHORUS_RECALLS: usize = 1000;
const MAX_COLUMNS: u32 = 32;
const MIN_FONT_SIZE: f32 = 0.5;
const MAX_FONT_SIZE: f32 = 200.0;
#[derive(Default, Clone)]
struct ElementStyle {
font: Option<String>,
size: Option<String>,
colour: Option<String>,
}
impl ElementStyle {
fn to_css(&self) -> String {
let mut css = String::new();
if let Some(ref font) = self.font {
let _ = write!(css, "font-family: {};", sanitize_css_value(font));
}
if let Some(ref size) = self.size {
let safe = sanitize_css_value(size);
if safe.chars().all(|c| c.is_ascii_digit()) {
let _ = write!(css, "font-size: {safe}pt;");
} else {
let _ = write!(css, "font-size: {safe};");
}
}
if let Some(ref colour) = self.colour {
let _ = write!(css, "color: {};", sanitize_css_value(colour));
}
css
}
}
#[derive(Default, Clone)]
struct FormattingState {
text: ElementStyle,
chord: ElementStyle,
tab: ElementStyle,
title: ElementStyle,
chorus: ElementStyle,
label: ElementStyle,
grid: ElementStyle,
}
impl FormattingState {
fn apply(&mut self, kind: &DirectiveKind, value: &Option<String>) {
let val = value.clone();
let clamped_size = || -> Option<String> {
value
.as_deref()
.and_then(|v| v.parse::<f32>().ok())
.map(|s| s.clamp(MIN_FONT_SIZE, MAX_FONT_SIZE).to_string())
};
match kind {
DirectiveKind::TextFont => self.text.font = val,
DirectiveKind::TextSize => self.text.size = clamped_size(),
DirectiveKind::TextColour => self.text.colour = val,
DirectiveKind::ChordFont => self.chord.font = val,
DirectiveKind::ChordSize => self.chord.size = clamped_size(),
DirectiveKind::ChordColour => self.chord.colour = val,
DirectiveKind::TabFont => self.tab.font = val,
DirectiveKind::TabSize => self.tab.size = clamped_size(),
DirectiveKind::TabColour => self.tab.colour = val,
DirectiveKind::TitleFont => self.title.font = val,
DirectiveKind::TitleSize => self.title.size = clamped_size(),
DirectiveKind::TitleColour => self.title.colour = val,
DirectiveKind::ChorusFont => self.chorus.font = val,
DirectiveKind::ChorusSize => self.chorus.size = clamped_size(),
DirectiveKind::ChorusColour => self.chorus.colour = val,
DirectiveKind::LabelFont => self.label.font = val,
DirectiveKind::LabelSize => self.label.size = clamped_size(),
DirectiveKind::LabelColour => self.label.colour = val,
DirectiveKind::GridFont => self.grid.font = val,
DirectiveKind::GridSize => self.grid.size = clamped_size(),
DirectiveKind::GridColour => self.grid.colour = val,
_ => {}
}
}
}
#[must_use]
pub fn render_song(song: &Song) -> String {
render_song_with_transpose(song, 0, &Config::defaults())
}
#[must_use]
pub fn render_song_with_transpose(song: &Song, cli_transpose: i8, config: &Config) -> String {
let result = render_song_with_warnings(song, cli_transpose, config);
for w in &result.warnings {
eprintln!("warning: {w}");
}
result.output
}
#[must_use = "caller must check warnings in the returned RenderResult"]
pub fn render_song_with_warnings(
song: &Song,
cli_transpose: i8,
config: &Config,
) -> RenderResult<String> {
let mut warnings = Vec::new();
let title = song.metadata.title.as_deref().unwrap_or("Untitled");
let mut html = String::new();
let _ = write!(
html,
"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>{}</title>\n",
escape(title)
);
html.push_str("<style>\n");
html.push_str(CSS);
html.push_str("</style>\n</head>\n<body>\n");
render_song_body(song, cli_transpose, config, &mut html, &mut warnings);
html.push_str("</body>\n</html>\n");
RenderResult::with_warnings(html, warnings)
}
fn render_song_body(
song: &Song,
cli_transpose: i8,
config: &Config,
html: &mut String,
warnings: &mut Vec<String>,
) {
let song_overrides = song.config_overrides();
let song_config;
let config = if song_overrides.is_empty() {
config
} else {
song_config = config
.clone()
.with_song_overrides(&song_overrides, warnings);
&song_config
};
let song_transpose_delta = Config::song_transpose_delta(&song_overrides);
let (combined_transpose, _) =
chordsketch_core::transpose::combine_transpose(cli_transpose, song_transpose_delta);
let mut transpose_offset: i8 = combined_transpose;
let mut fmt_state = FormattingState::default();
html.push_str("<div class=\"song\">\n");
render_metadata(&song.metadata, html);
let mut columns_open = false;
let mut svg_buf: Option<String> = None;
let mut abc2svg_resolved: Option<bool> = config.get_path("delegates.abc2svg").as_bool();
let mut lilypond_resolved: Option<bool> = config.get_path("delegates.lilypond").as_bool();
let mut musescore_resolved: Option<bool> = config.get_path("delegates.musescore").as_bool();
let mut abc_buf: Option<String> = None;
let mut abc_label: Option<String> = None;
let mut ly_buf: Option<String> = None;
let mut ly_label: Option<String> = None;
let mut musicxml_buf: Option<String> = None;
let mut musicxml_label: Option<String> = None;
let mut show_diagrams = true;
let diagram_frets = config
.get_path("diagrams.frets")
.as_f64()
.map_or(chordsketch_core::chord_diagram::DEFAULT_FRETS_SHOWN, |n| {
(n as usize).max(1)
});
let default_instrument = config
.get_path("diagrams.instrument")
.as_str()
.map(str::to_ascii_lowercase)
.unwrap_or_else(|| "guitar".to_string());
let mut auto_diagrams_instrument: Option<String> = None;
let mut inline_defined: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut chorus_body: Vec<Line> = Vec::new();
let mut chorus_buf: Option<Vec<Line>> = None;
let mut saved_fmt_state: Option<FormattingState> = None;
let mut chorus_recall_count: usize = 0;
for line in &song.lines {
match line {
Line::Lyrics(lyrics_line) => {
if let Some(ref mut buf) = svg_buf {
let raw = lyrics_line.text();
buf.push_str(&raw);
buf.push('\n');
} else if let Some(ref mut buf) = abc_buf {
let raw = lyrics_line.text();
buf.push_str(&raw);
buf.push('\n');
} else if let Some(ref mut buf) = ly_buf {
let raw = lyrics_line.text();
buf.push_str(&raw);
buf.push('\n');
} else if let Some(ref mut buf) = musicxml_buf {
let raw = lyrics_line.text();
buf.push_str(&raw);
buf.push('\n');
} else {
if let Some(buf) = chorus_buf.as_mut() {
buf.push(line.clone());
}
render_lyrics(lyrics_line, transpose_offset, &fmt_state, html);
}
}
Line::Directive(directive) => {
if directive.kind.is_metadata() {
continue;
}
if directive.kind == DirectiveKind::Diagrams {
auto_diagrams_instrument = resolve_diagrams_instrument(
directive.value.as_deref(),
&default_instrument,
);
show_diagrams = auto_diagrams_instrument.is_some();
continue;
}
if directive.kind == DirectiveKind::NoDiagrams {
show_diagrams = false;
auto_diagrams_instrument = None;
continue;
}
if directive.kind == DirectiveKind::Transpose {
let file_offset: i8 = match directive.value.as_deref() {
None | Some("") => 0,
Some(raw) => match raw.parse() {
Ok(v) => v,
Err(_) => {
warnings.push(format!(
"{{transpose}} value {raw:?} cannot be \
parsed as i8, ignored (using 0)"
));
0
}
},
};
let (combined, saturated) =
chordsketch_core::transpose::combine_transpose(file_offset, cli_transpose);
if saturated {
warnings.push(format!(
"transpose offset {file_offset} + {cli_transpose} \
exceeds i8 range, clamped to {combined}"
));
}
transpose_offset = combined;
continue;
}
if directive.kind.is_font_size_color() {
if let Some(buf) = chorus_buf.as_mut() {
buf.push(line.clone());
}
fmt_state.apply(&directive.kind, &directive.value);
continue;
}
match &directive.kind {
DirectiveKind::StartOfChorus => {
render_section_open("chorus", "Chorus", &directive.value, html);
chorus_buf = Some(Vec::new());
saved_fmt_state = Some(fmt_state.clone());
}
DirectiveKind::EndOfChorus => {
html.push_str("</section>\n");
if let Some(buf) = chorus_buf.take() {
chorus_body = buf;
}
if let Some(saved) = saved_fmt_state.take() {
fmt_state = saved;
}
}
DirectiveKind::Chorus => {
if chorus_recall_count < MAX_CHORUS_RECALLS {
render_chorus_recall(
&directive.value,
&chorus_body,
transpose_offset,
&fmt_state,
show_diagrams,
diagram_frets,
html,
);
chorus_recall_count += 1;
} else if chorus_recall_count == MAX_CHORUS_RECALLS {
warnings.push(format!(
"chorus recall limit ({MAX_CHORUS_RECALLS}) reached, \
further recalls suppressed"
));
chorus_recall_count += 1;
}
}
DirectiveKind::Columns => {
let n: u32 = directive
.value
.as_deref()
.and_then(|v| v.trim().parse().ok())
.unwrap_or(1)
.clamp(1, MAX_COLUMNS);
if columns_open {
html.push_str("</div>\n");
columns_open = false;
}
if n > 1 {
let _ = writeln!(
html,
"<div style=\"column-count: {n};column-gap: 2em;\">"
);
columns_open = true;
}
}
DirectiveKind::ColumnBreak => {
html.push_str("<div style=\"break-before: column;\"></div>\n");
}
DirectiveKind::NewPage => {
html.push_str("<div style=\"break-before: page;\"></div>\n");
}
DirectiveKind::NewPhysicalPage => {
html.push_str("<div style=\"break-before: recto;\"></div>\n");
}
DirectiveKind::StartOfAbc => {
#[cfg(not(target_arch = "wasm32"))]
let enabled = *abc2svg_resolved
.get_or_insert_with(chordsketch_core::external_tool::has_abc2svg);
#[cfg(target_arch = "wasm32")]
let enabled = *abc2svg_resolved.get_or_insert(false);
if enabled {
abc_buf = Some(String::new());
abc_label = directive.value.clone();
} else {
if let Some(buf) = chorus_buf.as_mut() {
buf.push(line.clone());
}
render_directive_inner(directive, show_diagrams, diagram_frets, html);
}
}
DirectiveKind::EndOfAbc if abc_buf.is_some() => {
if let Some(abc_content) = abc_buf.take() {
render_abc_with_fallback(&abc_content, &abc_label, html, warnings);
abc_label = None;
}
}
DirectiveKind::StartOfLy => {
#[cfg(not(target_arch = "wasm32"))]
let enabled = *lilypond_resolved
.get_or_insert_with(chordsketch_core::external_tool::has_lilypond);
#[cfg(target_arch = "wasm32")]
let enabled = *lilypond_resolved.get_or_insert(false);
if enabled {
ly_buf = Some(String::new());
ly_label = directive.value.clone();
} else {
if let Some(buf) = chorus_buf.as_mut() {
buf.push(line.clone());
}
render_directive_inner(directive, show_diagrams, diagram_frets, html);
}
}
DirectiveKind::EndOfLy if ly_buf.is_some() => {
if let Some(ly_content) = ly_buf.take() {
render_ly_with_fallback(&ly_content, &ly_label, html, warnings);
ly_label = None;
}
}
DirectiveKind::StartOfMusicxml => {
#[cfg(not(target_arch = "wasm32"))]
let enabled = *musescore_resolved
.get_or_insert_with(chordsketch_core::external_tool::has_musescore);
#[cfg(target_arch = "wasm32")]
let enabled = *musescore_resolved.get_or_insert(false);
if enabled {
musicxml_buf = Some(String::new());
musicxml_label = directive.value.clone();
} else {
if let Some(buf) = chorus_buf.as_mut() {
buf.push(line.clone());
}
render_directive_inner(directive, show_diagrams, diagram_frets, html);
}
}
DirectiveKind::EndOfMusicxml if musicxml_buf.is_some() => {
if let Some(musicxml_content) = musicxml_buf.take() {
render_musicxml_with_fallback(
&musicxml_content,
&musicxml_label,
html,
warnings,
);
musicxml_label = None;
}
}
DirectiveKind::StartOfSvg => {
svg_buf = Some(String::new());
}
DirectiveKind::EndOfSvg if svg_buf.is_some() => {
if let Some(svg_content) = svg_buf.take() {
html.push_str("<div class=\"svg-section\">\n");
html.push_str(&sanitize_svg_content(&svg_content));
html.push('\n');
html.push_str("</div>\n");
}
}
_ => {
if let Some(buf) = chorus_buf.as_mut() {
buf.push(line.clone());
}
if directive.kind == DirectiveKind::Define && show_diagrams {
if let Some(ref val) = directive.value {
let name =
chordsketch_core::ast::ChordDefinition::parse_value(val).name;
if !name.is_empty() {
inline_defined.insert(canonical_chord_name(&name));
}
}
}
render_directive_inner(directive, show_diagrams, diagram_frets, html);
}
}
}
Line::Comment(style, text) => {
if let Some(buf) = chorus_buf.as_mut() {
buf.push(line.clone());
}
render_comment(*style, text, html);
}
Line::Empty => {
if let Some(buf) = chorus_buf.as_mut() {
buf.push(line.clone());
}
html.push_str("<div class=\"empty-line\"></div>\n");
}
}
}
if columns_open {
html.push_str("</div>\n");
}
if let Some(ref instrument) = auto_diagrams_instrument {
let chord_names: Vec<String> = song
.used_chord_names()
.into_iter()
.filter(|name| !inline_defined.contains(&canonical_chord_name(name)))
.collect();
if instrument == "piano" {
let kbd_defines = song.keyboard_defines();
let voicings: Vec<_> = chord_names
.into_iter()
.filter_map(|name| chordsketch_core::lookup_keyboard_voicing(&name, &kbd_defines))
.collect();
if !voicings.is_empty() {
html.push_str("<section class=\"chord-diagrams\">\n");
html.push_str("<div class=\"section-label\">Chord Diagrams</div>\n");
html.push_str("<div class=\"chord-diagrams-grid\">\n");
for voicing in &voicings {
html.push_str("<div class=\"chord-diagram-container\">");
html.push_str(&chordsketch_core::chord_diagram::render_keyboard_svg(
voicing,
));
html.push_str("</div>\n");
}
html.push_str("</div>\n");
html.push_str("</section>\n");
}
} else {
let defines = song.fretted_defines();
let diagrams: Vec<_> = chord_names
.into_iter()
.filter_map(|name| {
chordsketch_core::lookup_diagram(&name, &defines, instrument, diagram_frets)
})
.collect();
if !diagrams.is_empty() {
html.push_str("<section class=\"chord-diagrams\">\n");
html.push_str("<div class=\"section-label\">Chord Diagrams</div>\n");
html.push_str("<div class=\"chord-diagrams-grid\">\n");
for diagram in &diagrams {
html.push_str("<div class=\"chord-diagram-container\">");
html.push_str(&chordsketch_core::chord_diagram::render_svg(diagram));
html.push_str("</div>\n");
}
html.push_str("</div>\n");
html.push_str("</section>\n");
}
}
}
html.push_str("</div>\n");
}
#[must_use]
pub fn render_songs(songs: &[Song]) -> String {
render_songs_with_transpose(songs, 0, &Config::defaults())
}
#[must_use]
pub fn render_songs_with_transpose(songs: &[Song], cli_transpose: i8, config: &Config) -> String {
let result = render_songs_with_warnings(songs, cli_transpose, config);
for w in &result.warnings {
eprintln!("warning: {w}");
}
result.output
}
#[must_use = "caller must check warnings in the returned RenderResult"]
pub fn render_songs_with_warnings(
songs: &[Song],
cli_transpose: i8,
config: &Config,
) -> RenderResult<String> {
let mut warnings = Vec::new();
if songs.len() <= 1 {
let output = songs
.first()
.map(|s| {
let r = render_song_with_warnings(s, cli_transpose, config);
warnings = r.warnings;
r.output
})
.unwrap_or_default();
return RenderResult::with_warnings(output, warnings);
}
let mut html = String::new();
let title = songs
.first()
.and_then(|s| s.metadata.title.as_deref())
.unwrap_or("Untitled");
let _ = write!(
html,
"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>{}</title>\n",
escape(title)
);
html.push_str("<style>\n");
html.push_str(CSS);
html.push_str("</style>\n</head>\n<body>\n");
for (i, song) in songs.iter().enumerate() {
if i > 0 {
html.push_str("<hr class=\"song-separator\">\n");
}
render_song_body(song, cli_transpose, config, &mut html, &mut warnings);
}
html.push_str("</body>\n</html>\n");
RenderResult::with_warnings(html, warnings)
}
#[must_use = "parse errors should be handled"]
pub fn try_render(input: &str) -> Result<String, chordsketch_core::ParseError> {
let song = chordsketch_core::parse(input)?;
Ok(render_song(&song))
}
#[must_use]
pub fn render(input: &str) -> String {
match try_render(input) {
Ok(html) => html,
Err(e) => format!(
"<!DOCTYPE html><html><body><pre>Parse error at line {} column {}: {}</pre></body></html>\n",
e.line(),
e.column(),
escape(&e.message)
),
}
}
const CSS: &str = "\
body { font-family: serif; max-width: 800px; margin: 2em auto; padding: 0 1em; }
h1 { margin-bottom: 0.2em; }
h2 { margin-top: 0; font-weight: normal; color: #555; }
.line { display: flex; flex-wrap: wrap; margin: 0.1em 0; }
.chord-block { display: inline-flex; flex-direction: column; align-items: flex-start; }
.chord { font-weight: bold; color: #b00; font-size: 0.9em; min-height: 1.2em; }
.lyrics { white-space: pre; }
.empty-line { height: 1em; }
section { margin: 1em 0; }
section > .section-label { font-weight: bold; font-style: italic; margin-bottom: 0.3em; }
.comment { font-style: italic; color: #666; margin: 0.3em 0; }
.comment-box { border: 1px solid #999; padding: 0.2em 0.5em; display: inline-block; margin: 0.3em 0; }
.chorus-recall { margin: 1em 0; }
.chorus-recall > .section-label { font-weight: bold; font-style: italic; margin-bottom: 0.3em; }
img { max-width: 100%; height: auto; }
.chord-diagrams-grid { display: flex; flex-wrap: wrap; gap: 0.5em; margin: 0.5em 0; }
.chord-diagram-container { display: inline-block; vertical-align: top; }
.chord-diagram { display: block; }
";
fn render_metadata(metadata: &chordsketch_core::ast::Metadata, html: &mut String) {
if let Some(title) = &metadata.title {
let _ = writeln!(html, "<h1>{}</h1>", escape(title));
}
for subtitle in &metadata.subtitles {
let _ = writeln!(html, "<h2>{}</h2>", escape(subtitle));
}
}
fn render_lyrics(
lyrics_line: &LyricsLine,
transpose_offset: i8,
fmt_state: &FormattingState,
html: &mut String,
) {
html.push_str("<div class=\"line\">");
for segment in &lyrics_line.segments {
html.push_str("<span class=\"chord-block\">");
if let Some(chord) = &segment.chord {
let display_name = if transpose_offset != 0 {
let transposed = transpose_chord(chord, transpose_offset);
transposed.display_name().to_string()
} else {
chord.display_name().to_string()
};
let chord_css = fmt_state.chord.to_css();
if chord_css.is_empty() {
let _ = write!(
html,
"<span class=\"chord\">{}</span>",
escape(&display_name)
);
} else {
let _ = write!(
html,
"<span class=\"chord\" style=\"{}\">{}</span>",
escape(&chord_css),
escape(&display_name)
);
}
} else if lyrics_line.has_chords() {
html.push_str("<span class=\"chord\"></span>");
}
let text_css = fmt_state.text.to_css();
if text_css.is_empty() {
html.push_str("<span class=\"lyrics\">");
} else {
let _ = write!(
html,
"<span class=\"lyrics\" style=\"{}\">",
escape(&text_css)
);
}
if segment.has_markup() {
render_spans(&segment.spans, html);
} else {
html.push_str(&escape(&segment.text));
}
html.push_str("</span>");
html.push_str("</span>");
}
html.push_str("</div>\n");
}
fn render_spans(spans: &[TextSpan], html: &mut String) {
for span in spans {
match span {
TextSpan::Plain(text) => html.push_str(&escape(text)),
TextSpan::Bold(children) => {
html.push_str("<b>");
render_spans(children, html);
html.push_str("</b>");
}
TextSpan::Italic(children) => {
html.push_str("<i>");
render_spans(children, html);
html.push_str("</i>");
}
TextSpan::Highlight(children) => {
html.push_str("<mark>");
render_spans(children, html);
html.push_str("</mark>");
}
TextSpan::Comment(children) => {
html.push_str("<span class=\"comment\">");
render_spans(children, html);
html.push_str("</span>");
}
TextSpan::Span(attrs, children) => {
let css = span_attrs_to_css(attrs);
if css.is_empty() {
html.push_str("<span>");
} else {
let _ = write!(html, "<span style=\"{}\">", escape(&css));
}
render_spans(children, html);
html.push_str("</span>");
}
}
}
}
fn span_attrs_to_css(attrs: &SpanAttributes) -> String {
let mut css = String::new();
if let Some(ref font_family) = attrs.font_family {
let _ = write!(css, "font-family: {};", sanitize_css_value(font_family));
}
if let Some(ref size) = attrs.size {
let safe = sanitize_css_value(size);
if safe.chars().all(|c| c.is_ascii_digit()) {
let _ = write!(css, "font-size: {safe}pt;");
} else {
let _ = write!(css, "font-size: {safe};");
}
}
if let Some(ref fg) = attrs.foreground {
let _ = write!(css, "color: {};", sanitize_css_value(fg));
}
if let Some(ref bg) = attrs.background {
let _ = write!(css, "background-color: {};", sanitize_css_value(bg));
}
if let Some(ref weight) = attrs.weight {
let _ = write!(css, "font-weight: {};", sanitize_css_value(weight));
}
if let Some(ref style) = attrs.style {
let _ = write!(css, "font-style: {};", sanitize_css_value(style));
}
css
}
fn sanitize_css_value(s: &str) -> String {
s.chars()
.filter(|c| {
c.is_ascii_alphanumeric() || matches!(c, '#' | '.' | '-' | ' ' | ',' | '%' | '+')
})
.collect()
}
fn sanitize_css_class(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'-'
}
})
.collect()
}
fn sanitize_svg_content(input: &str) -> String {
const DANGEROUS_TAGS: &[&str] = &[
"script",
"foreignobject",
"iframe",
"object",
"embed",
"math",
"feimage",
"image",
"set",
"animate",
"animatetransform",
"animatemotion",
];
let mut result = String::with_capacity(input.len());
let mut chars = input.char_indices().peekable();
let bytes = input.as_bytes();
while let Some((i, c)) = chars.next() {
if c == '<' {
let rest = &input[i..];
let limit = rest
.char_indices()
.map(|(idx, _)| idx)
.find(|&idx| idx >= 30)
.unwrap_or(rest.len());
let rest_upper = &rest[..limit];
let mut matched = false;
for tag in DANGEROUS_TAGS {
let prefix = format!("<{tag}");
if starts_with_ignore_case(rest_upper, &prefix)
&& rest.len() > prefix.len()
&& bytes
.get(i + prefix.len())
.is_none_or(|b| b.is_ascii_whitespace() || *b == b'>' || *b == b'/')
{
let is_self_closing = {
let tag_bytes = rest.as_bytes();
let mut in_quote: Option<u8> = None;
let mut gt_pos = None;
for (idx, &b) in tag_bytes.iter().enumerate() {
match in_quote {
Some(q) if b == q => in_quote = None,
Some(_) => {}
None if b == b'"' || b == b'\'' => in_quote = Some(b),
None if b == b'>' => {
gt_pos = Some(idx);
break;
}
_ => {}
}
}
gt_pos.is_some_and(|gt| gt > 0 && tag_bytes[gt - 1] == b'/')
};
if is_self_closing {
let mut skip_quote: Option<char> = None;
while let Some(&(_, ch)) = chars.peek() {
chars.next();
match skip_quote {
Some(q) if ch == q => skip_quote = None,
Some(_) => {}
None if ch == '"' || ch == '\'' => {
skip_quote = Some(ch);
}
None if ch == '>' => break,
_ => {}
}
}
} else if let Some(end) = find_end_tag_ignore_case(input, i, tag) {
while let Some(&(j, _)) = chars.peek() {
if j >= end {
break;
}
chars.next();
}
} else {
return result;
}
matched = true;
break;
}
}
if matched {
continue;
}
for tag in DANGEROUS_TAGS {
let prefix = format!("</{tag}");
if starts_with_ignore_case(rest_upper, &prefix)
&& rest.len() > prefix.len()
&& bytes
.get(i + prefix.len())
.is_none_or(|b| b.is_ascii_whitespace() || *b == b'>')
{
while let Some(&(_, ch)) = chars.peek() {
chars.next();
if ch == '>' {
break;
}
}
matched = true;
break;
}
}
if matched {
continue;
}
result.push(c);
} else {
result.push(c);
}
}
strip_dangerous_attrs(&result)
}
fn starts_with_ignore_case(s: &str, prefix: &str) -> bool {
if s.len() < prefix.len() {
return false;
}
s.as_bytes()[..prefix.len()]
.iter()
.zip(prefix.as_bytes())
.all(|(a, b)| a.eq_ignore_ascii_case(b))
}
fn find_end_tag_ignore_case(input: &str, start: usize, tag: &str) -> Option<usize> {
let search = &input.as_bytes()[start..];
let tag_bytes = tag.as_bytes();
let close_prefix_len = 2 + tag_bytes.len();
for i in 0..search.len() {
if search[i] == b'<'
&& i + 1 < search.len()
&& search[i + 1] == b'/'
&& i + close_prefix_len <= search.len()
{
let candidate = &search[i + 2..i + close_prefix_len];
if candidate
.iter()
.zip(tag_bytes)
.all(|(a, b)| a.eq_ignore_ascii_case(b))
{
if let Some(gt) = search[i + close_prefix_len..]
.iter()
.position(|&b| b == b'>')
{
return Some(start + i + close_prefix_len + gt + 1);
}
}
}
}
None
}
fn strip_dangerous_attrs(input: &str) -> String {
let mut result = String::with_capacity(input.len());
let bytes = input.as_bytes();
let mut pos = 0;
while pos < bytes.len() {
if bytes[pos] == b'<' && pos + 1 < bytes.len() && bytes[pos + 1] != b'/' {
if let Some(gt) = find_tag_end(&bytes[pos..]) {
let tag_end = pos + gt + 1;
let tag_content = &input[pos..tag_end];
result.push_str(&sanitize_tag_attrs(tag_content));
pos = tag_end;
} else {
result.push_str(&input[pos..]);
break;
}
} else {
let ch = &input[pos..];
let c = ch.chars().next().expect("pos is within bounds");
result.push(c);
pos += c.len_utf8();
}
}
result
}
fn find_tag_end(bytes: &[u8]) -> Option<usize> {
let mut i = 0;
let mut in_quote: Option<u8> = None;
while i < bytes.len() {
let b = bytes[i];
if let Some(q) = in_quote {
if b == q {
in_quote = None;
}
} else if b == b'"' || b == b'\'' {
in_quote = Some(b);
} else if b == b'>' {
return Some(i);
}
i += 1;
}
None
}
fn has_dangerous_uri_scheme(value: &str) -> bool {
let lower: String = value
.trim_start()
.chars()
.filter(|c| !c.is_ascii_whitespace() && !c.is_ascii_control())
.take(30)
.flat_map(|c| c.to_lowercase())
.collect();
lower.starts_with("javascript:")
|| lower.starts_with("vbscript:")
|| lower.starts_with("data:")
|| lower.starts_with("file:")
|| lower.starts_with("blob:")
|| lower.starts_with("mhtml:")
}
fn is_uri_attr(name: &str) -> bool {
let lower: String = name.chars().flat_map(|c| c.to_lowercase()).collect();
lower == "href"
|| lower == "src"
|| lower == "xlink:href"
|| lower == "to"
|| lower == "values"
|| lower == "from"
|| lower == "by"
|| lower == "action"
|| lower == "formaction"
|| lower == "poster"
|| lower == "background"
|| lower == "ping"
}
fn sanitize_tag_attrs(tag: &str) -> String {
let mut result = String::with_capacity(tag.len());
let bytes = tag.as_bytes();
let mut i = 0;
while i < bytes.len() && bytes[i] != b' ' && bytes[i] != b'>' && bytes[i] != b'/' {
result.push(bytes[i] as char);
i += 1;
}
while i < bytes.len() {
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
result.push(bytes[i] as char);
i += 1;
}
if i >= bytes.len() || bytes[i] == b'>' || bytes[i] == b'/' {
result.push_str(&tag[i..]);
return result;
}
let attr_start = i;
while i < bytes.len()
&& bytes[i] != b'='
&& bytes[i] != b' '
&& bytes[i] != b'>'
&& bytes[i] != b'/'
{
i += 1;
}
let attr_name = &tag[attr_start..i];
let is_event_handler = attr_name.len() > 2
&& attr_name.as_bytes()[..2].eq_ignore_ascii_case(b"on")
&& attr_name.as_bytes()[2].is_ascii_alphabetic();
let value_start = i;
let mut attr_value: Option<String> = None;
if i < bytes.len() && bytes[i] == b'=' {
i += 1; if i < bytes.len() && (bytes[i] == b'"' || bytes[i] == b'\'') {
let quote = bytes[i];
i += 1;
let val_start = i;
while i < bytes.len() && bytes[i] != quote {
i += 1;
}
attr_value = Some(tag[val_start..i].to_string());
if i < bytes.len() {
i += 1; }
} else {
let val_start = i;
while i < bytes.len() && !bytes[i].is_ascii_whitespace() && bytes[i] != b'>' {
i += 1;
}
attr_value = Some(tag[val_start..i].to_string());
}
}
if is_event_handler {
continue;
}
if is_uri_attr(attr_name) {
if let Some(ref val) = attr_value {
if has_dangerous_uri_scheme(val) {
continue;
}
}
}
if attr_name.eq_ignore_ascii_case("style") {
if let Some(ref val) = attr_value {
let lower_val: String = val.chars().flat_map(|c| c.to_lowercase()).collect();
if lower_val.contains("url(")
|| lower_val.contains("expression(")
|| lower_val.contains("@import")
{
continue;
}
}
}
result.push_str(&tag[attr_start..value_start]);
if attr_value.is_some() {
result.push_str(&tag[value_start..i]);
}
}
result
}
fn render_directive_inner(
directive: &chordsketch_core::ast::Directive,
show_diagrams: bool,
diagram_frets: usize,
html: &mut String,
) {
match &directive.kind {
DirectiveKind::StartOfChorus => {
render_section_open("chorus", "Chorus", &directive.value, html);
}
DirectiveKind::StartOfVerse => {
render_section_open("verse", "Verse", &directive.value, html);
}
DirectiveKind::StartOfBridge => {
render_section_open("bridge", "Bridge", &directive.value, html);
}
DirectiveKind::StartOfTab => {
render_section_open("tab", "Tab", &directive.value, html);
}
DirectiveKind::StartOfGrid => {
render_section_open("grid", "Grid", &directive.value, html);
}
DirectiveKind::StartOfAbc => {
render_section_open("abc", "ABC", &directive.value, html);
}
DirectiveKind::StartOfLy => {
render_section_open("ly", "Lilypond", &directive.value, html);
}
DirectiveKind::StartOfTextblock => {
render_section_open("textblock", "Textblock", &directive.value, html);
}
DirectiveKind::StartOfMusicxml => {
render_section_open("musicxml", "MusicXML", &directive.value, html);
}
DirectiveKind::StartOfSection(section_name) => {
let class = format!("section-{}", sanitize_css_class(section_name));
let label = escape(&chordsketch_core::capitalize(section_name));
render_section_open(&class, &label, &directive.value, html);
}
DirectiveKind::EndOfChorus
| DirectiveKind::EndOfVerse
| DirectiveKind::EndOfBridge
| DirectiveKind::EndOfTab
| DirectiveKind::EndOfGrid
| DirectiveKind::EndOfAbc
| DirectiveKind::EndOfLy
| DirectiveKind::EndOfMusicxml
| DirectiveKind::EndOfSvg
| DirectiveKind::EndOfTextblock
| DirectiveKind::EndOfSection(_) => {
html.push_str("</section>\n");
}
DirectiveKind::Image(attrs) => {
render_image(attrs, html);
}
DirectiveKind::Define if show_diagrams => {
if let Some(ref value) = directive.value {
let def = chordsketch_core::ast::ChordDefinition::parse_value(value);
if let Some(ref keys_raw) = def.keys {
let keys_u8: Vec<u8> = keys_raw
.iter()
.filter_map(|&k| {
if (0i32..=127).contains(&k) {
Some(k as u8)
} else {
None
}
})
.collect();
if !keys_u8.is_empty() {
let root = keys_u8[0];
let voicing = chordsketch_core::chord_diagram::KeyboardVoicing {
name: def.name.clone(),
display_name: def.display.clone(),
keys: keys_u8,
root_key: root,
};
html.push_str("<div class=\"chord-diagram-container\">");
html.push_str(&chordsketch_core::chord_diagram::render_keyboard_svg(
&voicing,
));
html.push_str("</div>\n");
}
} else if let Some(ref raw) = def.raw {
if let Some(mut diagram) =
chordsketch_core::chord_diagram::DiagramData::from_raw_infer_frets(
&def.name,
raw,
diagram_frets,
)
{
diagram.display_name = def.display.clone();
html.push_str("<div class=\"chord-diagram-container\">");
html.push_str(&chordsketch_core::chord_diagram::render_svg(&diagram));
html.push_str("</div>\n");
}
}
}
}
DirectiveKind::Define => {}
_ => {}
}
}
#[cfg(not(target_arch = "wasm32"))]
fn render_abc_with_fallback(
abc_content: &str,
label: &Option<String>,
html: &mut String,
warnings: &mut Vec<String>,
) {
match chordsketch_core::external_tool::invoke_abc2svg(abc_content) {
Ok(svg_fragment) => {
render_section_open("abc", "ABC", label, html);
html.push_str(&sanitize_svg_content(&svg_fragment));
html.push('\n');
html.push_str("</section>\n");
}
Err(e) => {
warnings.push(format!("abc2svg invocation failed: {e}"));
render_section_open("abc", "ABC", label, html);
html.push_str("<pre>");
html.push_str(&escape(abc_content));
html.push_str("</pre>\n");
html.push_str("</section>\n");
}
}
}
#[cfg(target_arch = "wasm32")]
fn render_abc_with_fallback(
abc_content: &str,
label: &Option<String>,
html: &mut String,
_warnings: &mut Vec<String>,
) {
render_section_open("abc", "ABC", label, html);
html.push_str("<pre>");
html.push_str(&escape(abc_content));
html.push_str("</pre>\n");
html.push_str("</section>\n");
}
fn is_safe_image_src(src: &str) -> bool {
if src.is_empty() {
return false;
}
if src.contains('\0') {
return false;
}
let normalised = src.trim_start().to_ascii_lowercase();
if normalised.starts_with('/') {
return false;
}
if is_windows_absolute(src.trim_start()) {
return false;
}
if has_traversal(src) {
return false;
}
if let Some(colon_pos) = normalised.find(':') {
let before_colon = &normalised[..colon_pos];
if !before_colon.contains('/') {
return before_colon == "http" || before_colon == "https";
}
}
true
}
use chordsketch_core::image_path::{has_traversal, is_windows_absolute};
#[cfg(not(target_arch = "wasm32"))]
fn render_ly_with_fallback(
ly_content: &str,
label: &Option<String>,
html: &mut String,
warnings: &mut Vec<String>,
) {
match chordsketch_core::external_tool::invoke_lilypond(ly_content) {
Ok(svg) => {
render_section_open("ly", "Lilypond", label, html);
html.push_str(&sanitize_svg_content(&svg));
html.push('\n');
html.push_str("</section>\n");
}
Err(e) => {
warnings.push(format!("lilypond invocation failed: {e}"));
render_section_open("ly", "Lilypond", label, html);
html.push_str("<pre>");
html.push_str(&escape(ly_content));
html.push_str("</pre>\n");
html.push_str("</section>\n");
}
}
}
#[cfg(target_arch = "wasm32")]
fn render_ly_with_fallback(
ly_content: &str,
label: &Option<String>,
html: &mut String,
_warnings: &mut Vec<String>,
) {
render_section_open("ly", "Lilypond", label, html);
html.push_str("<pre>");
html.push_str(&escape(ly_content));
html.push_str("</pre>\n");
html.push_str("</section>\n");
}
#[cfg(not(target_arch = "wasm32"))]
fn render_musicxml_with_fallback(
musicxml_content: &str,
label: &Option<String>,
html: &mut String,
warnings: &mut Vec<String>,
) {
match chordsketch_core::external_tool::invoke_musescore(musicxml_content) {
Ok(svg) => {
render_section_open("musicxml", "MusicXML", label, html);
html.push_str(&sanitize_svg_content(&svg));
html.push('\n');
html.push_str("</section>\n");
}
Err(e) => {
warnings.push(format!("musescore invocation failed: {e}"));
render_section_open("musicxml", "MusicXML", label, html);
html.push_str("<pre>");
html.push_str(&escape(musicxml_content));
html.push_str("</pre>\n");
html.push_str("</section>\n");
}
}
}
#[cfg(target_arch = "wasm32")]
fn render_musicxml_with_fallback(
musicxml_content: &str,
label: &Option<String>,
html: &mut String,
_warnings: &mut Vec<String>,
) {
render_section_open("musicxml", "MusicXML", label, html);
html.push_str("<pre>");
html.push_str(&escape(musicxml_content));
html.push_str("</pre>\n");
html.push_str("</section>\n");
}
fn render_image(attrs: &chordsketch_core::ast::ImageAttributes, html: &mut String) {
if !is_safe_image_src(&attrs.src) {
return;
}
let mut style = String::new();
let mut img_attrs = format!("src=\"{}\"", escape(&attrs.src));
if let Some(ref title) = attrs.title {
let _ = write!(img_attrs, " alt=\"{}\"", escape(title));
}
if let Some(ref width) = attrs.width {
let _ = write!(img_attrs, " width=\"{}\"", escape(width));
}
if let Some(ref height) = attrs.height {
let _ = write!(img_attrs, " height=\"{}\"", escape(height));
}
if let Some(ref scale) = attrs.scale {
let _ = write!(
style,
"transform: scale({});transform-origin: top left;",
sanitize_css_value(scale)
);
}
let align_css = match attrs.anchor.as_deref() {
Some("column") | Some("paper") => "text-align: center;",
_ => "",
};
if !align_css.is_empty() {
let _ = write!(html, "<div style=\"{align_css}\">");
} else {
html.push_str("<div>");
}
let _ = write!(html, "<img {img_attrs}");
if !style.is_empty() {
let _ = write!(html, " style=\"{}\"", escape(&style));
}
html.push_str("></div>\n");
}
fn render_section_open(class: &str, label: &str, value: &Option<String>, html: &mut String) {
let safe_class = sanitize_css_class(class);
let _ = writeln!(html, "<section class=\"{safe_class}\">");
let display_label = match value {
Some(v) if !v.is_empty() => format!("{label}: {}", escape(v)),
_ => label.to_string(),
};
let _ = writeln!(html, "<div class=\"section-label\">{display_label}</div>");
}
fn render_chorus_recall(
value: &Option<String>,
chorus_body: &[Line],
transpose_offset: i8,
fmt_state: &FormattingState,
show_diagrams: bool,
diagram_frets: usize,
html: &mut String,
) {
html.push_str("<div class=\"chorus-recall\">\n");
let display_label = match value {
Some(v) if !v.is_empty() => format!("Chorus: {}", escape(v)),
_ => "Chorus".to_string(),
};
let _ = writeln!(html, "<div class=\"section-label\">{display_label}</div>");
let mut local_fmt = fmt_state.clone();
for line in chorus_body {
match line {
Line::Lyrics(lyrics) => render_lyrics(lyrics, transpose_offset, &local_fmt, html),
Line::Comment(style, text) => render_comment(*style, text, html),
Line::Empty => html.push_str("<div class=\"empty-line\"></div>\n"),
Line::Directive(d) if d.kind.is_font_size_color() => {
local_fmt.apply(&d.kind, &d.value);
}
Line::Directive(d) if !d.kind.is_metadata() => {
render_directive_inner(d, show_diagrams, diagram_frets, html);
}
_ => {}
}
}
html.push_str("</div>\n");
}
fn render_comment(style: CommentStyle, text: &str, html: &mut String) {
match style {
CommentStyle::Normal => {
let _ = writeln!(html, "<p class=\"comment\">{}</p>", escape(text));
}
CommentStyle::Italic => {
let _ = writeln!(html, "<p class=\"comment\"><em>{}</em></p>", escape(text));
}
CommentStyle::Boxed => {
let _ = writeln!(html, "<div class=\"comment-box\">{}</div>", escape(text));
}
}
}
#[cfg(test)]
mod sanitize_tag_attrs_tests {
use super::*;
#[test]
fn test_preserves_normal_attrs() {
let tag = "<svg width=\"100\" height=\"50\">";
assert_eq!(sanitize_tag_attrs(tag), tag);
}
#[test]
fn test_strips_event_handler() {
let tag = "<svg onclick=\"alert(1)\" width=\"100\">";
let result = sanitize_tag_attrs(tag);
assert!(!result.contains("onclick"));
assert!(result.contains("width"));
}
#[test]
fn test_non_ascii_in_attr_value_preserved() {
let tag = "<text title=\"日本語テスト\" x=\"10\">";
let result = sanitize_tag_attrs(tag);
assert!(result.contains("日本語テスト"));
assert!(result.contains("x=\"10\""));
}
#[test]
fn test_strips_mixed_case_event_handler() {
let tag = "<svg OnClick=\"alert(1)\" width=\"100\">";
let result = sanitize_tag_attrs(tag);
assert!(!result.contains("OnClick"));
assert!(result.contains("width"));
}
#[test]
fn test_strips_uppercase_event_handler() {
let tag = "<svg ONLOAD=\"alert(1)\">";
let result = sanitize_tag_attrs(tag);
assert!(!result.contains("ONLOAD"));
}
#[test]
fn test_strips_style_with_url() {
let tag =
"<rect style=\"background-image: url('https://attacker.com/exfil')\" width=\"10\">";
let result = sanitize_tag_attrs(tag);
assert!(!result.contains("style"));
assert!(result.contains("width"));
}
#[test]
fn test_strips_style_with_expression() {
let tag = "<rect style=\"width: expression(alert(1))\">";
let result = sanitize_tag_attrs(tag);
assert!(!result.contains("style"));
}
#[test]
fn test_strips_style_with_import() {
let tag = "<rect style=\"@import url(evil.css)\">";
let result = sanitize_tag_attrs(tag);
assert!(!result.contains("style"));
}
#[test]
fn test_preserves_safe_style() {
let tag = "<rect style=\"fill: red; stroke: blue\" width=\"10\">";
let result = sanitize_tag_attrs(tag);
assert!(result.contains("style"));
assert!(result.contains("fill: red"));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_render_empty() {
let song = chordsketch_core::parse("").unwrap();
let html = render_song(&song);
assert!(html.contains("<!DOCTYPE html>"));
assert!(html.contains("</html>"));
}
#[test]
fn test_render_title() {
let html = render("{title: My Song}");
assert!(html.contains("<h1>My Song</h1>"));
assert!(html.contains("<title>My Song</title>"));
}
#[test]
fn test_render_subtitle() {
let html = render("{title: Song}\n{subtitle: By Someone}");
assert!(html.contains("<h2>By Someone</h2>"));
}
#[test]
fn test_render_lyrics_with_chords() {
let html = render("[Am]Hello [G]world");
assert!(html.contains("chord-block"));
assert!(html.contains("<span class=\"chord\">Am</span>"));
assert!(html.contains("<span class=\"lyrics\">Hello </span>"));
assert!(html.contains("<span class=\"chord\">G</span>"));
}
#[test]
fn test_render_lyrics_no_chords() {
let html = render("Just plain text");
assert!(html.contains("<span class=\"lyrics\">Just plain text</span>"));
assert!(!html.contains("class=\"chord\""));
}
#[test]
fn test_render_chorus_section() {
let html = render("{start_of_chorus}\n[G]La la\n{end_of_chorus}");
assert!(html.contains("<section class=\"chorus\">"));
assert!(html.contains("</section>"));
assert!(html.contains("Chorus"));
}
#[test]
fn test_render_verse_with_label() {
let html = render("{start_of_verse: Verse 1}\nLyrics\n{end_of_verse}");
assert!(html.contains("<section class=\"verse\">"));
assert!(html.contains("Verse: Verse 1"));
}
#[test]
fn test_render_comment() {
let html = render("{comment: A note}");
assert!(html.contains("<p class=\"comment\">A note</p>"));
}
#[test]
fn test_render_comment_italic() {
let html = render("{comment_italic: Softly}");
assert!(html.contains("<em>Softly</em>"));
}
#[test]
fn test_render_comment_box() {
let html = render("{comment_box: Important}");
assert!(html.contains("<div class=\"comment-box\">Important</div>"));
}
#[test]
fn test_html_escaping() {
let html = render("{title: Tom & Jerry <3}");
assert!(html.contains("Tom & Jerry <3"));
}
#[test]
fn test_try_render_success() {
let result = try_render("{title: Test}");
assert!(result.is_ok());
}
#[test]
fn test_try_render_error() {
let result = try_render("{unclosed");
assert!(result.is_err());
}
#[test]
fn test_render_valid_html_structure() {
let html = render("{title: Test}\n\n{start_of_verse}\n[G]Hello [C]world\n{end_of_verse}");
assert!(html.starts_with("<!DOCTYPE html>"));
assert!(html.contains("<html"));
assert!(html.contains("<head>"));
assert!(html.contains("<style>"));
assert!(html.contains("<body>"));
assert!(html.contains("</html>"));
}
#[test]
fn test_text_before_first_chord() {
let html = render("Hello [Am]world");
assert!(html.contains("<span class=\"chord\"></span><span class=\"lyrics\">Hello </span>"));
}
#[test]
fn test_empty_line() {
let html = render("Line one\n\nLine two");
assert!(html.contains("empty-line"));
}
#[test]
fn test_render_grid_section() {
let html = render("{start_of_grid}\n| Am . | C . |\n{end_of_grid}");
assert!(html.contains("<section class=\"grid\">"));
assert!(html.contains("Grid"));
assert!(html.contains("</section>"));
}
#[test]
fn test_render_custom_section_intro() {
let html = render("{start_of_intro}\n[Am]Da da\n{end_of_intro}");
assert!(html.contains("<section class=\"section-intro\">"));
assert!(html.contains("Intro"));
assert!(html.contains("</section>"));
}
#[test]
fn test_render_grid_section_with_label() {
let html = render("{start_of_grid: Intro}\n| Am |\n{end_of_grid}");
assert!(html.contains("<section class=\"grid\">"));
assert!(html.contains("Grid: Intro"));
}
#[test]
fn test_render_grid_short_alias() {
let html = render("{sog}\n| G . |\n{eog}");
assert!(html.contains("<section class=\"grid\">"));
assert!(html.contains("</section>"));
}
#[test]
fn test_render_custom_section_with_label() {
let html = render("{start_of_intro: Guitar}\nNotes\n{end_of_intro}");
assert!(html.contains("<section class=\"section-intro\">"));
assert!(html.contains("Intro: Guitar"));
}
#[test]
fn test_render_custom_section_outro() {
let html = render("{start_of_outro}\nFinal\n{end_of_outro}");
assert!(html.contains("<section class=\"section-outro\">"));
assert!(html.contains("Outro"));
}
#[test]
fn test_render_custom_section_solo() {
let html = render("{start_of_solo}\n[Em]Solo\n{end_of_solo}");
assert!(html.contains("<section class=\"section-solo\">"));
assert!(html.contains("Solo"));
assert!(html.contains("</section>"));
}
#[test]
fn test_custom_section_name_escaped() {
let html = render(
"{start_of_x<script>alert(1)</script>}\ntext\n{end_of_x<script>alert(1)</script>}",
);
assert!(!html.contains("<script>"));
assert!(html.contains("<script>"));
}
#[test]
fn test_custom_section_name_quotes_escaped() {
let html =
render("{start_of_x\" onclick=\"alert(1)}\ntext\n{end_of_x\" onclick=\"alert(1)}");
assert!(html.contains("""));
assert!(!html.contains("class=\"section-x\""));
}
#[test]
fn test_custom_section_name_single_quotes_escaped() {
let html = render("{start_of_x' onclick='alert(1)}\ntext\n{end_of_x' onclick='alert(1)}");
assert!(html.contains("'") || html.contains("'"));
assert!(!html.contains("onclick='alert"));
}
#[test]
fn test_custom_section_name_space_sanitized_in_class() {
let html = render("{start_of_foo bar}\ntext\n{end_of_foo bar}");
assert!(html.contains("section-foo-bar"));
assert!(!html.contains("class=\"section-foo bar\""));
}
#[test]
fn test_custom_section_name_special_chars_sanitized_in_class() {
let html = render("{start_of_a&b<c>d}\ntext\n{end_of_a&b<c>d}");
assert!(html.contains("section-a-b-c-d"));
assert!(html.contains("&"));
}
#[test]
fn test_custom_section_capitalize_before_escape() {
let html = render("{start_of_&test}\ntext\n{end_of_&test}");
assert!(html.contains("&test"));
assert!(!html.contains("&Amp;"));
}
#[test]
fn test_define_display_name_in_html_output() {
let html = render("{define: Am base-fret 1 frets x 0 2 2 1 0 display=\"A minor\"}");
assert!(
html.contains("A minor"),
"display name should appear in rendered HTML output"
);
}
}
#[cfg(test)]
mod transpose_tests {
use super::*;
#[test]
fn test_transpose_directive_up_2() {
let input = "{transpose: 2}\n[G]Hello [C]world";
let song = chordsketch_core::parse(input).unwrap();
let html = render_song(&song);
assert!(html.contains("<span class=\"chord\">A</span>"));
assert!(html.contains("<span class=\"chord\">D</span>"));
assert!(!html.contains("<span class=\"chord\">G</span>"));
assert!(!html.contains("<span class=\"chord\">C</span>"));
}
#[test]
fn test_transpose_directive_replaces_previous() {
let input = "{transpose: 2}\n[G]First\n{transpose: 0}\n[G]Second";
let song = chordsketch_core::parse(input).unwrap();
let html = render_song(&song);
assert!(html.contains("<span class=\"chord\">A</span>"));
assert!(html.contains("<span class=\"chord\">G</span>"));
}
#[test]
fn test_transpose_directive_with_cli_offset() {
let input = "{transpose: 2}\n[C]Hello";
let song = chordsketch_core::parse(input).unwrap();
let html = render_song_with_transpose(&song, 3, &Config::defaults());
assert!(html.contains("<span class=\"chord\">F</span>"));
}
#[test]
fn test_transpose_out_of_i8_range_emits_warning() {
let input = "{transpose: 999}\n[G]Hello";
let song = chordsketch_core::parse(input).unwrap();
let result = render_song_with_warnings(&song, 0, &Config::defaults());
assert!(
result.output.contains("<span class=\"chord\">G</span>"),
"chord should be untransposed"
);
assert!(
result.warnings.iter().any(|w| w.contains("\"999\"")),
"expected warning about out-of-range value, got: {:?}",
result.warnings
);
}
#[test]
fn test_transpose_no_value_treated_as_zero() {
let input = "{transpose}\n[G]Hello";
let song = chordsketch_core::parse(input).unwrap();
let result = render_song_with_warnings(&song, 0, &Config::defaults());
assert!(
result.output.contains("<span class=\"chord\">G</span>"),
"chord should be untransposed"
);
assert!(
result.warnings.is_empty(),
"missing {{transpose}} value should not emit a warning; got: {:?}",
result.warnings
);
}
#[test]
fn test_transpose_whitespace_value_treated_as_zero() {
let input = "{transpose: }\n[G]Hello";
let song = chordsketch_core::parse(input).unwrap();
let result = render_song_with_warnings(&song, 0, &Config::defaults());
assert!(
result.output.contains("<span class=\"chord\">G</span>"),
"chord should be untransposed"
);
assert!(
result.warnings.is_empty(),
"whitespace-only {{transpose}} value should not emit a warning; got: {:?}",
result.warnings
);
}
#[test]
fn test_render_chorus_recall_basic() {
let html = render("{start_of_chorus}\n[G]La la\n{end_of_chorus}\n\n{chorus}");
assert!(html.contains("<div class=\"chorus-recall\">"));
assert!(html.contains("chorus-recall"));
assert!(html.contains("<section class=\"chorus\">"));
}
#[test]
fn test_render_chorus_recall_with_label() {
let html = render("{start_of_chorus}\nSing\n{end_of_chorus}\n{chorus: Repeat}");
assert!(html.contains("Chorus: Repeat"));
assert!(html.contains("chorus-recall"));
}
#[test]
fn test_render_chorus_recall_no_chorus_defined() {
let html = render("{chorus}");
assert!(html.contains("<div class=\"chorus-recall\">"));
assert!(html.contains("Chorus"));
}
#[test]
fn test_render_chorus_recall_content_replayed() {
let html = render("{start_of_chorus}\nChorus text\n{end_of_chorus}\n{chorus}");
let count = html.matches("Chorus text").count();
assert_eq!(count, 2, "chorus content should appear twice");
}
#[test]
fn test_chorus_recall_applies_current_transpose() {
let html = render("{start_of_chorus}\n[G]La la\n{end_of_chorus}\n{transpose: 2}\n{chorus}");
assert!(
html.contains("<span class=\"chord\">G</span>"),
"original chorus should have G"
);
assert!(
html.contains("<span class=\"chord\">A</span>"),
"recalled chorus should have transposed chord A, got:\n{html}"
);
}
#[test]
fn test_chorus_recall_preserves_formatting_directives() {
let html =
render("{start_of_chorus}\n{textsize: 20}\n[Am]Big text\n{end_of_chorus}\n{chorus}");
let recall_start = html.find("chorus-recall").expect("should have recall");
let recall_section = &html[recall_start..];
assert!(
recall_section.contains("font-size"),
"recalled chorus should apply in-chorus formatting directives"
);
}
#[test]
fn test_chorus_formatting_does_not_leak_to_outer_scope() {
let html =
render("{start_of_chorus}\n{textsize: 20}\n[Am]Big\n{end_of_chorus}\n[G]Normal text");
let after_chorus = html
.rfind("Normal text")
.expect("should have post-chorus text");
let line_start = html[..after_chorus].rfind("<div class=\"line\"").unwrap();
let line_end = html[line_start..]
.find("</div>")
.map_or(html.len(), |i| line_start + i + 6);
let post_chorus_line = &html[line_start..line_end];
assert!(
!post_chorus_line.contains("font-size"),
"in-chorus {{textsize}} should not leak to post-chorus content: {post_chorus_line}"
);
}
#[test]
fn test_render_bold_markup() {
let html = render("Hello <b>bold</b> world");
assert!(html.contains("<b>bold</b>"));
assert!(html.contains("Hello "));
assert!(html.contains(" world"));
}
#[test]
fn test_render_italic_markup() {
let html = render("Hello <i>italic</i> text");
assert!(html.contains("<i>italic</i>"));
}
#[test]
fn test_render_highlight_markup() {
let html = render("<highlight>important</highlight>");
assert!(html.contains("<mark>important</mark>"));
}
#[test]
fn test_render_comment_inline_markup() {
let html = render("<comment>note</comment>");
assert!(html.contains("<span class=\"comment\">note</span>"));
}
#[test]
fn test_render_span_with_foreground() {
let html = render(r#"<span foreground="red">red text</span>"#);
assert!(html.contains("color: red;"));
assert!(html.contains("red text"));
}
#[test]
fn test_render_span_with_multiple_attrs() {
let html = render(
r#"<span font_family="Serif" size="14" foreground="blue" weight="bold">styled</span>"#,
);
assert!(html.contains("font-family: Serif;"));
assert!(html.contains("font-size: 14pt;"));
assert!(html.contains("color: blue;"));
assert!(html.contains("font-weight: bold;"));
assert!(html.contains("styled"));
}
#[test]
fn test_span_css_injection_url_prevented() {
let html = render(
r#"<span foreground="red; background-image: url('https://evil.com/')">text</span>"#,
);
assert!(!html.contains("url("));
assert!(!html.contains(";background-image"));
}
#[test]
fn test_span_css_injection_semicolon_stripped() {
let html =
render(r#"<span foreground="red; position: absolute; z-index: 9999">text</span>"#);
assert!(!html.contains(";position"));
assert!(!html.contains("; position"));
assert!(html.contains("color:"));
}
#[test]
fn test_render_nested_markup() {
let html = render("<b><i>bold italic</i></b>");
assert!(html.contains("<b><i>bold italic</i></b>"));
}
#[test]
fn test_render_markup_with_chord() {
let html = render("[Am]Hello <b>bold</b> world");
assert!(html.contains("<b>bold</b>"));
assert!(html.contains("<span class=\"chord\">Am</span>"));
}
#[test]
fn test_render_no_markup_unchanged() {
let html = render("Just plain text");
assert!(!html.contains("<b>"));
assert!(!html.contains("<i>"));
assert!(html.contains("Just plain text"));
}
#[test]
fn test_textfont_directive_applies_css() {
let html = render("{textfont: Courier}\nHello world");
assert!(html.contains("font-family: Courier;"));
}
#[test]
fn test_textsize_directive_applies_css() {
let html = render("{textsize: 14}\nHello world");
assert!(html.contains("font-size: 14pt;"));
}
#[test]
fn test_textcolour_directive_applies_css() {
let html = render("{textcolour: blue}\nHello world");
assert!(html.contains("color: blue;"));
}
#[test]
fn test_chordfont_directive_applies_css() {
let html = render("{chordfont: Monospace}\n[Am]Hello");
assert!(html.contains("font-family: Monospace;"));
}
#[test]
fn test_chordsize_directive_applies_css() {
let html = render("{chordsize: 16}\n[Am]Hello");
assert!(html.contains("font-size: 16pt;"));
}
#[test]
fn test_chordcolour_directive_applies_css() {
let html = render("{chordcolour: green}\n[Am]Hello");
assert!(html.contains("color: green;"));
}
#[test]
fn test_formatting_persists_across_lines() {
let html = render("{textcolour: red}\nLine one\nLine two");
let count = html.matches("color: red;").count();
assert!(
count >= 2,
"formatting should persist: found {count} matches"
);
}
#[test]
fn test_formatting_overridden_by_later_directive() {
let html = render("{textcolour: red}\nRed text\n{textcolour: blue}\nBlue text");
assert!(html.contains("color: red;"));
assert!(html.contains("color: blue;"));
}
#[test]
fn test_no_formatting_no_style_attr() {
let html = render("Plain text");
assert!(!html.contains("<span class=\"lyrics\" style="));
}
#[test]
fn test_formatting_directive_css_injection_prevented() {
let html = render("{textcolour: red; position: fixed; z-index: 9999}\nHello");
assert!(!html.contains(";position"));
assert!(!html.contains("; position"));
assert!(html.contains("color:"));
}
#[test]
fn test_formatting_directive_url_injection_prevented() {
let html = render("{textcolour: red; background-image: url('https://evil.com/')}\nHello");
assert!(!html.contains("url("));
}
#[test]
fn test_columns_directive_generates_css() {
let html = render("{columns: 2}\nLine one\nLine two");
assert!(html.contains("column-count: 2"));
}
#[test]
fn test_columns_reset_to_one() {
let html = render("{columns: 2}\nTwo cols\n{columns: 1}\nOne col");
let count = html.matches("column-count: 2").count();
assert_eq!(count, 1);
assert!(html.contains("One col"));
}
#[test]
fn test_column_break_generates_css() {
let html = render("{columns: 2}\nCol 1\n{column_break}\nCol 2");
assert!(html.contains("break-before: column;"));
}
#[test]
fn test_columns_clamped_to_max() {
let html = render("{columns: 999}\nContent");
assert!(html.contains("column-count: 32"));
}
#[test]
fn test_columns_zero_treated_as_one() {
let html = render("{columns: 0}\nContent");
assert!(!html.contains("column-count"));
}
#[test]
fn test_columns_non_numeric_defaults_to_one() {
let html = render("{columns: abc}\nHello");
assert!(!html.contains("column-count"));
}
#[test]
fn test_new_page_generates_page_break() {
let html = render("Page 1\n{new_page}\nPage 2");
assert!(html.contains("break-before: page;"));
}
#[test]
fn test_new_physical_page_generates_recto_break() {
let html = render("Page 1\n{new_physical_page}\nPage 2");
assert!(
html.contains("break-before: recto;"),
"new_physical_page should use break-before: recto for duplex printing"
);
assert!(
!html.contains("break-before: page;"),
"new_physical_page should not emit generic page break"
);
}
#[test]
fn test_page_control_not_replayed_in_chorus_recall() {
let input = "\
{start_of_chorus}\n\
{new_page}\n\
[G]La la la\n\
{end_of_chorus}\n\
Verse text\n\
{chorus}";
let html = render(input);
assert!(html.contains("break-before: page;"));
let count = html.matches("break-before: page;").count();
assert_eq!(count, 1, "page break must not be replayed in chorus recall");
}
#[test]
fn test_image_basic() {
let html = render("{image: src=photo.jpg}");
assert!(html.contains("<img src=\"photo.jpg\""));
}
#[test]
fn test_image_with_dimensions() {
let html = render("{image: src=photo.jpg width=200 height=100}");
assert!(html.contains("width=\"200\""));
assert!(html.contains("height=\"100\""));
}
#[test]
fn test_image_with_title() {
let html = render("{image: src=photo.jpg title=\"My Photo\"}");
assert!(html.contains("alt=\"My Photo\""));
}
#[test]
fn test_image_with_scale() {
let html = render("{image: src=photo.jpg scale=0.5}");
assert!(html.contains("scale(0.5)"));
}
#[test]
fn test_image_empty_src_skipped() {
let html = render("{image: src=}");
assert!(
!html.contains("<img"),
"empty src should not produce an img element"
);
}
#[test]
fn test_image_javascript_uri_rejected() {
let html = render("{image: src=javascript:alert(1)}");
assert!(!html.contains("<img"), "javascript: URI must be rejected");
}
#[test]
fn test_image_data_uri_rejected() {
let html = render("{image: src=data:text/html,<script>alert(1)</script>}");
assert!(!html.contains("<img"), "data: URI must be rejected");
}
#[test]
fn test_image_vbscript_uri_rejected() {
let html = render("{image: src=vbscript:MsgBox}");
assert!(!html.contains("<img"), "vbscript: URI must be rejected");
}
#[test]
fn test_image_javascript_uri_case_insensitive() {
let html = render("{image: src=JaVaScRiPt:alert(1)}");
assert!(
!html.contains("<img"),
"scheme check must be case-insensitive"
);
}
#[test]
fn test_image_safe_relative_path_allowed() {
let html = render("{image: src=images/photo.jpg}");
assert!(html.contains("<img src=\"images/photo.jpg\""));
}
#[test]
fn test_is_safe_image_src() {
assert!(is_safe_image_src("photo.jpg"));
assert!(is_safe_image_src("images/photo.jpg"));
assert!(is_safe_image_src("path/to:file.jpg"));
assert!(is_safe_image_src("http://example.com/photo.jpg"));
assert!(is_safe_image_src("https://example.com/photo.jpg"));
assert!(is_safe_image_src("HTTP://EXAMPLE.COM/PHOTO.JPG"));
assert!(!is_safe_image_src(""));
assert!(!is_safe_image_src("javascript:alert(1)"));
assert!(!is_safe_image_src("JAVASCRIPT:alert(1)"));
assert!(!is_safe_image_src(" javascript:alert(1)"));
assert!(!is_safe_image_src("data:image/png;base64,abc"));
assert!(!is_safe_image_src("vbscript:MsgBox"));
assert!(!is_safe_image_src("file:///etc/passwd"));
assert!(!is_safe_image_src("FILE:///etc/passwd"));
assert!(!is_safe_image_src("blob:https://example.com/uuid"));
assert!(!is_safe_image_src("mhtml:file://C:/page.mhtml"));
assert!(!is_safe_image_src("/etc/passwd"));
assert!(!is_safe_image_src("/home/user/photo.jpg"));
assert!(!is_safe_image_src("photo\0.jpg"));
assert!(!is_safe_image_src("\0"));
assert!(!is_safe_image_src("../photo.jpg"));
assert!(!is_safe_image_src("images/../../etc/passwd"));
assert!(!is_safe_image_src(r"..\photo.jpg"));
assert!(!is_safe_image_src(r"images\..\..\photo.jpg"));
assert!(!is_safe_image_src(r"C:\photo.jpg"));
assert!(!is_safe_image_src(r"D:\Users\photo.jpg"));
assert!(!is_safe_image_src(r"\\server\share\photo.jpg"));
assert!(!is_safe_image_src("C:/photo.jpg"));
}
#[test]
fn test_image_anchor_column_centers() {
let html = render("{image: src=photo.jpg anchor=column}");
assert!(
html.contains("<div style=\"text-align: center;\">"),
"anchor=column should produce centered div"
);
}
#[test]
fn test_image_anchor_paper_centers() {
let html = render("{image: src=photo.jpg anchor=paper}");
assert!(
html.contains("<div style=\"text-align: center;\">"),
"anchor=paper should produce centered div"
);
}
#[test]
fn test_image_anchor_line_no_style() {
let html = render("{image: src=photo.jpg anchor=line}");
assert!(html.contains("<div><img"));
assert!(!html.contains("text-align"));
}
#[test]
fn test_image_no_anchor_no_style() {
let html = render("{image: src=photo.jpg}");
assert!(html.contains("<div><img"));
assert!(!html.contains("text-align"));
}
#[test]
fn test_image_max_width_css_present() {
let html = render("{image: src=photo.jpg}");
assert!(
html.contains("img { max-width: 100%; height: auto; }"),
"CSS should include img max-width rule to prevent overflow"
);
}
#[test]
fn test_chord_diagram_css_rules_present() {
let html = render("{define: Am base-fret 1 frets x 0 2 2 1 0}");
assert!(
html.contains(".chord-diagram-container"),
"CSS should include .chord-diagram-container rule"
);
assert!(
html.contains(".chord-diagram {"),
"CSS should include .chord-diagram rule"
);
}
#[test]
fn test_define_renders_svg_diagram() {
let html = render("{define: Am base-fret 1 frets x 0 2 2 1 0}");
assert!(html.contains("<svg"));
assert!(html.contains("Am"));
assert!(html.contains("chord-diagram"));
}
#[test]
fn test_define_keyboard_renders_keyboard_svg() {
let html = render("{define: Am keys 0 3 7}");
assert!(
html.contains("<svg"),
"keyboard define should produce an SVG"
);
assert!(
html.contains("keyboard-diagram"),
"should use keyboard-diagram CSS class"
);
assert!(html.contains("Am"), "chord name should appear in SVG");
}
#[test]
fn test_define_keyboard_absolute_midi_renders_svg() {
let html = render("{define: Cmaj7 keys 60 64 67 71}");
assert!(html.contains("<svg"));
assert!(html.contains("keyboard-diagram"));
assert!(html.contains("Cmaj7"));
}
#[test]
fn test_diagrams_piano_auto_inject() {
let input = "{diagrams: piano}\n[Am]Hello [C]world";
let html = render(input);
assert!(
html.contains("keyboard-diagram"),
"piano instrument should use keyboard diagrams"
);
assert!(
html.contains("chord-diagrams"),
"diagram section should be present"
);
}
#[test]
fn test_define_ukulele_diagram() {
let html = render("{define: C frets 0 0 0 3}");
assert!(html.contains("<svg"));
assert!(html.contains("chord-diagram"));
assert!(
html.contains("width=\"88\""),
"Expected 4-string SVG width (88)"
);
}
#[test]
fn test_define_banjo_diagram() {
let html = render("{define: G frets 0 0 0 0 0}");
assert!(html.contains("<svg"));
assert!(
html.contains("width=\"104\""),
"Expected 5-string SVG width (104)"
);
}
#[test]
fn test_diagrams_frets_config_controls_svg_height() {
let input = "{define: Am base-fret 1 frets x 0 2 2 1 0}";
let song = chordsketch_core::parse(input).unwrap();
let config = chordsketch_core::config::Config::defaults()
.with_define("diagrams.frets=4")
.unwrap();
let html = render_song_with_transpose(&song, 0, &config);
assert!(
html.contains("height=\"140\""),
"SVG height should reflect diagrams.frets=4 (expected 140)"
);
}
#[test]
fn test_diagrams_off_suppresses_chord_diagrams() {
let html = render("{diagrams: off}\n{define: Am base-fret 1 frets x 0 2 2 1 0}");
assert!(
!html.contains("<svg"),
"chord diagram SVG should be suppressed when diagrams=off"
);
}
#[test]
fn test_diagrams_on_shows_chord_diagrams() {
let html = render("{diagrams: on}\n{define: Am base-fret 1 frets x 0 2 2 1 0}");
assert!(
html.contains("<svg"),
"chord diagram SVG should be shown when diagrams=on"
);
}
#[test]
fn test_diagrams_default_shows_chord_diagrams() {
let html = render("{define: Am base-fret 1 frets x 0 2 2 1 0}");
assert!(
html.contains("<svg"),
"chord diagram SVG should be shown by default"
);
}
#[test]
fn test_diagrams_off_then_on_restores() {
let html = render(
"{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}",
);
assert!(!html.contains(">Am<"), "Am diagram should be suppressed");
assert!(html.contains(">G<"), "G diagram should be rendered");
}
#[test]
fn test_diagrams_parsed_as_known_directive() {
let song = chordsketch_core::parse("{diagrams: off}").unwrap();
if let chordsketch_core::ast::Line::Directive(d) = &song.lines[0] {
assert_eq!(
d.kind,
chordsketch_core::ast::DirectiveKind::Diagrams,
"diagrams should parse as DirectiveKind::Diagrams"
);
assert_eq!(d.value, Some("off".to_string()));
} else {
panic!("expected a directive line, got: {:?}", &song.lines[0]);
}
}
#[test]
fn test_diagrams_off_case_insensitive() {
let html = render("{diagrams: Off}\n{define: Am base-fret 1 frets x 0 2 2 1 0}");
assert!(
!html.contains("<svg"),
"diagrams=Off should suppress diagrams (case-insensitive)"
);
}
#[test]
fn test_diagrams_off_uppercase() {
let html = render("{diagrams: OFF}\n{define: Am base-fret 1 frets x 0 2 2 1 0}");
assert!(
!html.contains("<svg"),
"diagrams=OFF should suppress diagrams (case-insensitive)"
);
}
#[test]
fn test_diagrams_auto_inject_from_builtin_db() {
let html = render("{diagrams}\n[Am]Hello [G]World");
assert!(
html.contains("class=\"chord-diagrams\""),
"should render chord-diagrams section"
);
assert!(html.contains(">Am<"), "Am diagram expected");
assert!(html.contains(">G<"), "G diagram expected");
}
#[test]
fn test_diagrams_auto_inject_unknown_chord_skipped() {
let html = render("{diagrams}\n[Xyzzy]Hello");
assert!(
!html.contains("class=\"chord-diagrams\""),
"no diagram section for unknown chord"
);
}
#[test]
fn test_no_diagrams_suppresses_auto_inject() {
let html = render("{no_diagrams}\n[Am]Hello");
assert!(
!html.contains("class=\"chord-diagrams\""),
"{{no_diagrams}} should suppress auto-inject"
);
}
#[test]
fn test_diagrams_define_takes_priority_over_builtin() {
let html = render("{diagrams}\n{define: Am base-fret 1 frets x 0 2 2 1 0}\n[Am]Hello");
assert!(
html.contains("font-weight=\"bold\">Am</text>"),
"Am diagram should appear inline at the {{define}} position"
);
assert!(
!html.contains("class=\"chord-diagrams\""),
"auto-inject section should be absent when all used chords are defined"
);
}
#[test]
fn test_diagrams_off_suppresses_auto_inject() {
let html = render("{diagrams: off}\n[Am]Hello");
assert!(
!html.contains("class=\"chord-diagrams\""),
"{{diagrams: off}} should suppress auto-inject grid"
);
}
#[test]
fn test_diagrams_ukulele_instrument() {
let html = render("{diagrams: ukulele}\n[Am]Hello");
assert!(
html.contains("class=\"chord-diagrams\""),
"ukulele diagrams section expected"
);
assert!(html.contains(">Am<"), "Am diagram expected");
}
#[test]
fn test_diagrams_guitar_explicit_overrides_config_default() {
let song = chordsketch_core::parse("{diagrams: guitar}\n[Am]Hello").unwrap();
let config = chordsketch_core::config::Config::defaults()
.with_define("diagrams.instrument=ukulele")
.unwrap();
let html = render_song_with_transpose(&song, 0, &config);
assert!(
html.contains("class=\"chord-diagrams\""),
"guitar diagrams section expected"
);
assert!(html.contains(">Am<"), "Am diagram expected");
let guitar_am_html = render_song_with_transpose(
&chordsketch_core::parse("{diagrams: guitar}\n[Am]Hello").unwrap(),
0,
&chordsketch_core::config::Config::defaults(),
);
let uke_am_html = render_song_with_transpose(
&chordsketch_core::parse("{diagrams: ukulele}\n[Am]Hello").unwrap(),
0,
&chordsketch_core::config::Config::defaults(),
);
assert_ne!(
guitar_am_html, uke_am_html,
"guitar and ukulele Am diagrams should differ"
);
assert_eq!(
html, guitar_am_html,
"{{diagrams: guitar}} must select guitar regardless of config default"
);
}
#[test]
fn test_no_diagrams_suppresses_inline_define_diagrams() {
let html = render("{no_diagrams}\n{define: Am base-fret 1 frets x 0 2 2 1 0}\n[Am]Hello");
assert!(
!html.contains("<svg"),
"{{no_diagrams}} should suppress inline define diagram SVG"
);
}
#[test]
fn test_define_chord_not_duplicated_in_auto_inject_grid() {
let html =
render("{define: Am base-fret 1 frets x 0 2 2 1 0}\n{diagrams}\n[Am]Hello [G]world\n");
let am_svg_count = html.match_indices("font-weight=\"bold\">Am</text>").count();
assert_eq!(
am_svg_count, 1,
"Am diagram should appear exactly once (inline via {{define}}), not also in auto-inject grid"
);
assert!(
html.contains("font-weight=\"bold\">G</text>"),
"G diagram should appear in the auto-inject grid"
);
}
#[test]
fn test_define_after_nodiagrams_appears_in_grid() {
let html = render(
"{no_diagrams}\n{define: Am base-fret 1 frets x 0 2 2 1 0}\n{diagrams}\n[Am]Hello\n",
);
assert!(
html.contains("class=\"chord-diagrams\""),
"auto-inject grid should appear since Am was not rendered inline"
);
assert!(
html.contains("font-weight=\"bold\">Am</text>"),
"Am should appear in the auto-inject grid"
);
}
#[test]
fn test_enharmonic_define_dedup() {
let html = render("{define: Bb base-fret 1 frets x 1 3 3 3 1}\n{diagrams}\n[A#]Hello\n");
let bb_count = html.match_indices("font-weight=\"bold\">Bb</text>").count();
let as_count = html.match_indices("font-weight=\"bold\">A#</text>").count();
assert_eq!(bb_count, 1, "Bb should appear once (inline)");
assert_eq!(
as_count, 0,
"A# should NOT appear in the auto-inject grid (same chord as Bb)"
);
}
#[test]
fn test_chord_directive_appears_in_auto_inject_grid() {
let html = render("{chord: Am base-fret 1 frets x 0 2 2 1 0}\n{diagrams}\n[Am]Hello\n");
assert!(
html.contains("class=\"chord-diagrams\""),
"auto-inject grid should appear since {{chord}} does not render inline"
);
assert!(
html.contains("font-weight=\"bold\">Am</text>"),
"Am should appear in the auto-inject grid via {{chord}} voicing"
);
}
#[test]
fn test_abc_section_disabled_by_config() {
let input = "{start_of_abc}\nX:1\n{end_of_abc}";
let song = chordsketch_core::parse(input).unwrap();
let config = chordsketch_core::config::Config::defaults()
.with_define("delegates.abc2svg=false")
.unwrap();
let html = render_song_with_transpose(&song, 0, &config);
assert!(html.contains("<section class=\"abc\">"));
assert!(html.contains("ABC"));
assert!(html.contains("</section>"));
}
#[test]
fn test_abc_section_null_config_auto_detect_disabled() {
if chordsketch_core::external_tool::has_abc2svg() {
return; }
let input = "{start_of_abc}\nX:1\n{end_of_abc}";
let song = chordsketch_core::parse(input).unwrap();
let config = chordsketch_core::config::Config::defaults();
assert!(
config.get_path("delegates.abc2svg").is_null(),
"default config should have null delegates.abc2svg"
);
let html = render_song_with_transpose(&song, 0, &config);
assert!(
html.contains("<section class=\"abc\">"),
"null auto-detect with no abc2svg should render as text section"
);
}
#[test]
fn test_abc_section_fallback_preformatted() {
if chordsketch_core::external_tool::has_abc2svg() {
return; }
let input = "{start_of_abc}\nX:1\nT:Test\nK:C\n{end_of_abc}";
let song = chordsketch_core::parse(input).unwrap();
let config = chordsketch_core::config::Config::defaults()
.with_define("delegates.abc2svg=true")
.unwrap();
let html = render_song_with_transpose(&song, 0, &config);
assert!(html.contains("<section class=\"abc\">"));
assert!(html.contains("<pre>"));
assert!(html.contains("X:1"));
assert!(html.contains("</pre>"));
}
#[test]
fn test_abc_section_with_label_delegate_fallback() {
if chordsketch_core::external_tool::has_abc2svg() {
return;
}
let input = "{start_of_abc: Melody}\nX:1\n{end_of_abc}";
let song = chordsketch_core::parse(input).unwrap();
let config = chordsketch_core::config::Config::defaults()
.with_define("delegates.abc2svg=true")
.unwrap();
let html = render_song_with_transpose(&song, 0, &config);
assert!(html.contains("ABC: Melody"));
assert!(html.contains("<pre>"));
}
#[test]
#[ignore]
fn test_abc_section_renders_svg_with_abc2svg() {
let input = "{start_of_abc}\nX:1\nT:Test\nM:4/4\nK:C\nCDEF|GABc|\n{end_of_abc}";
let song = chordsketch_core::parse(input).unwrap();
let config = chordsketch_core::config::Config::defaults()
.with_define("delegates.abc2svg=true")
.unwrap();
let html = render_song_with_transpose(&song, 0, &config);
assert!(html.contains("<section class=\"abc\">"));
assert!(
html.contains("<svg"),
"should contain rendered SVG from abc2svg"
);
assert!(html.contains("</section>"));
}
#[test]
fn test_abc_section_auto_detect_default_config() {
let input = "{start_of_abc}\nX:1\nT:Test\nK:C\n{end_of_abc}";
let song = chordsketch_core::parse(input).unwrap();
let config = chordsketch_core::config::Config::defaults();
let html = render_song_with_transpose(&song, 0, &config);
assert!(
html.contains("<section class=\"abc\">"),
"auto-detect should produce abc section"
);
if !chordsketch_core::external_tool::has_abc2svg() {
assert!(
html.contains("X:1"),
"raw ABC content should be present without tool"
);
assert!(
!html.contains("<svg"),
"no SVG should be generated without abc2svg"
);
}
}
#[test]
fn test_ly_section_auto_detect_default_config() {
let input = "{start_of_ly}\n\\relative c' { c4 }\n{end_of_ly}";
let song = chordsketch_core::parse(input).unwrap();
let config = chordsketch_core::config::Config::defaults();
let html = render_song_with_transpose(&song, 0, &config);
assert!(
html.contains("<section class=\"ly\">"),
"auto-detect should produce ly section"
);
if !chordsketch_core::external_tool::has_lilypond() {
assert!(
html.contains("\\relative"),
"raw Lilypond content should be present without tool"
);
assert!(
!html.contains("<svg"),
"no SVG should be generated without lilypond"
);
}
}
#[test]
fn test_ly_section_disabled_by_config() {
let input = "{start_of_ly}\n\\relative c' { c4 }\n{end_of_ly}";
let song = chordsketch_core::parse(input).unwrap();
let config = chordsketch_core::config::Config::defaults()
.with_define("delegates.lilypond=false")
.unwrap();
let html = render_song_with_transpose(&song, 0, &config);
assert!(html.contains("<section class=\"ly\">"));
assert!(html.contains("Lilypond"));
assert!(html.contains("</section>"));
}
#[test]
fn test_ly_section_fallback_preformatted() {
if chordsketch_core::external_tool::has_lilypond() {
return;
}
let input = "{start_of_ly}\n\\relative c' { c4 }\n{end_of_ly}";
let song = chordsketch_core::parse(input).unwrap();
let config = chordsketch_core::config::Config::defaults()
.with_define("delegates.lilypond=true")
.unwrap();
let html = render_song_with_transpose(&song, 0, &config);
assert!(html.contains("<section class=\"ly\">"));
assert!(html.contains("<pre>"));
assert!(html.contains("</pre>"));
}
#[test]
#[ignore]
fn test_ly_section_renders_svg_with_lilypond() {
let input = "{start_of_ly}\n\\relative c' { c4 d e f | g2 g | }\n{end_of_ly}";
let song = chordsketch_core::parse(input).unwrap();
let config = chordsketch_core::config::Config::defaults()
.with_define("delegates.lilypond=true")
.unwrap();
let html = render_song_with_transpose(&song, 0, &config);
assert!(html.contains("<section class=\"ly\">"));
assert!(
html.contains("<svg"),
"should contain rendered SVG from lilypond"
);
assert!(html.contains("</section>"));
}
}
#[cfg(test)]
mod delegate_tests {
use super::*;
#[test]
fn test_render_abc_section() {
let html = render("{start_of_abc}\nX:1\n{end_of_abc}");
assert!(html.contains("<section class=\"abc\">"));
assert!(html.contains("ABC"));
assert!(html.contains("</section>"));
}
#[test]
fn test_render_abc_section_with_label() {
let html = render("{start_of_abc: Melody}\nX:1\n{end_of_abc}");
assert!(html.contains("<section class=\"abc\">"));
assert!(html.contains("ABC: Melody"));
}
#[test]
fn test_render_ly_section() {
let html = render("{start_of_ly}\nnotes\n{end_of_ly}");
assert!(html.contains("<section class=\"ly\">"));
assert!(html.contains("Lilypond"));
assert!(html.contains("</section>"));
}
#[test]
fn test_render_musicxml_section_disabled() {
let input = "{start_of_musicxml}\n<score-partwise/>\n{end_of_musicxml}";
let song = chordsketch_core::parse(input).unwrap();
let config = chordsketch_core::config::Config::defaults()
.with_define("delegates.musescore=false")
.unwrap();
let html = render_song_with_transpose(&song, 0, &config);
assert!(
html.contains("<section class=\"musicxml\">"),
"fallback section should render when musescore is disabled: {html}"
);
assert!(html.contains("MusicXML"), "section label should appear");
assert!(html.contains("</section>"), "section should be closed");
}
#[test]
fn test_render_musicxml_section_no_musescore_installed() {
if chordsketch_core::external_tool::has_musescore() {
return; }
let input = "{start_of_musicxml}\n<score-partwise/>\n{end_of_musicxml}";
let song = chordsketch_core::parse(input).unwrap();
let config = chordsketch_core::config::Config::defaults();
assert!(
config.get_path("delegates.musescore").is_null(),
"default config should have null delegates.musescore"
);
let html = render_song_with_transpose(&song, 0, &config);
assert!(
html.contains("<section class=\"musicxml\">"),
"null auto-detect with no musescore should render as text section"
);
}
#[test]
fn test_render_musicxml_section_with_label() {
let input = "{start_of_musicxml: Score}\n<score-partwise/>\n{end_of_musicxml}";
let song = chordsketch_core::parse(input).unwrap();
let config = chordsketch_core::config::Config::defaults()
.with_define("delegates.musescore=false")
.unwrap();
let html = render_song_with_transpose(&song, 0, &config);
assert!(
html.contains("Score"),
"label should appear in section header"
);
}
#[test]
fn test_abc_fallback_sanitizes_would_be_script_in_svg() {
let malicious_svg = "<svg><script>alert(1)</script><circle r=\"5\"/></svg>";
let sanitized = sanitize_svg_content(malicious_svg);
assert!(
!sanitized.contains("<script>"),
"script tags must be stripped from delegate SVG output"
);
assert!(sanitized.contains("<circle"));
}
#[test]
fn test_sanitize_svg_strips_event_handlers_from_delegate_output() {
let svg_with_handler = "<svg><rect onmouseover=\"alert(1)\" width=\"10\"/></svg>";
let sanitized = sanitize_svg_content(svg_with_handler);
assert!(
!sanitized.contains("onmouseover"),
"event handlers must be stripped from delegate SVG output"
);
assert!(sanitized.contains("<rect"));
}
#[test]
fn test_sanitize_svg_strips_foreignobject_from_delegate_output() {
let svg = "<svg><foreignObject><body xmlns=\"http://www.w3.org/1999/xhtml\"><script>alert(1)</script></body></foreignObject></svg>";
let sanitized = sanitize_svg_content(svg);
assert!(
!sanitized.contains("<foreignObject"),
"foreignObject must be stripped from delegate SVG output"
);
}
#[test]
fn test_sanitize_svg_strips_math_element() {
let svg = "<svg><math><mi>x</mi></math></svg>";
let sanitized = sanitize_svg_content(svg);
assert!(
!sanitized.contains("<math"),
"math element must be stripped from delegate SVG output"
);
}
#[test]
fn test_render_svg_section() {
let html = render("{start_of_svg}\n<svg/>\n{end_of_svg}");
assert!(html.contains("<div class=\"svg-section\">"));
assert!(html.contains("<svg/>"));
assert!(html.contains("</div>"));
}
#[test]
fn test_render_svg_inline_content() {
let svg = r#"<svg width="100" height="100"><circle cx="50" cy="50" r="40"/></svg>"#;
let input = format!("{{start_of_svg}}\n{svg}\n{{end_of_svg}}");
let html = render(&input);
assert!(html.contains(svg));
}
#[test]
fn test_svg_section_strips_script_tags() {
let input = "{start_of_svg}\n<svg><script>alert('xss')</script><circle r=\"10\"/></svg>\n{end_of_svg}";
let html = render(input);
assert!(!html.contains("<script>"), "script tags must be stripped");
assert!(!html.contains("alert"), "script content must be stripped");
assert!(
html.contains("<circle r=\"10\"/>"),
"safe SVG content must be preserved"
);
}
#[test]
fn test_svg_section_strips_event_handlers() {
let input = "{start_of_svg}\n<svg onload=\"alert(1)\"><rect width=\"10\" onerror=\"hack()\"/></svg>\n{end_of_svg}";
let html = render(input);
assert!(!html.contains("onload"), "onload handler must be stripped");
assert!(
!html.contains("onerror"),
"onerror handler must be stripped"
);
assert!(
html.contains("width=\"10\""),
"safe attributes must be preserved"
);
}
#[test]
fn test_svg_section_preserves_safe_content() {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><text x="10" y="20">Hello</text></svg>"#;
let input = format!("{{start_of_svg}}\n{svg}\n{{end_of_svg}}");
let html = render(&input);
assert!(html.contains("xmlns=\"http://www.w3.org/2000/svg\""));
assert!(html.contains("<text x=\"10\" y=\"20\">Hello</text>"));
}
#[test]
fn test_svg_section_strips_case_insensitive_script() {
let input = "{start_of_svg}\n<SCRIPT>alert(1)</SCRIPT><svg/>\n{end_of_svg}";
let html = render(input);
assert!(!html.contains("SCRIPT"), "case-insensitive script removal");
assert!(!html.contains("alert"));
assert!(html.contains("<svg/>"));
}
#[test]
fn test_svg_section_strips_foreignobject() {
let input = "{start_of_svg}\n<svg><foreignObject><body onload=\"alert(1)\"></body></foreignObject><rect width=\"10\"/></svg>\n{end_of_svg}";
let html = render(input);
assert!(
!html.contains("foreignObject"),
"foreignObject must be stripped"
);
assert!(
!html.contains("foreignobject"),
"foreignObject (lowercase) must be stripped"
);
assert!(
html.contains("<rect width=\"10\"/>"),
"safe content must be preserved"
);
}
#[test]
fn test_svg_section_strips_iframe() {
let input = "{start_of_svg}\n<svg><iframe src=\"javascript:alert(1)\"></iframe><circle r=\"5\"/></svg>\n{end_of_svg}";
let html = render(input);
assert!(!html.contains("iframe"), "iframe must be stripped");
assert!(html.contains("<circle r=\"5\"/>"));
}
#[test]
fn test_svg_section_strips_object_and_embed() {
let input = "{start_of_svg}\n<svg><object data=\"evil.swf\"></object><embed src=\"evil.swf\"></embed><rect/></svg>\n{end_of_svg}";
let html = render(input);
assert!(!html.contains("object"), "object must be stripped");
assert!(!html.contains("embed"), "embed must be stripped");
assert!(html.contains("<rect/>"));
}
#[test]
fn test_svg_section_strips_javascript_uri_in_href() {
let input = "{start_of_svg}\n<svg><a href=\"javascript:alert(1)\"><text>Click</text></a></svg>\n{end_of_svg}";
let html = render(input);
assert!(
!html.contains("javascript:"),
"javascript: URI must be stripped from href"
);
assert!(html.contains("<text>Click</text>"));
}
#[test]
fn test_svg_section_strips_vbscript_uri() {
let input = "{start_of_svg}\n<svg><a href=\"vbscript:MsgBox\"><text>Click</text></a></svg>\n{end_of_svg}";
let html = render(input);
assert!(
!html.contains("vbscript:"),
"vbscript: URI must be stripped"
);
}
#[test]
fn test_svg_section_strips_data_uri_in_use() {
let input = "{start_of_svg}\n<svg><use href=\"data:image/svg+xml;base64,PHN2Zy8+\"/></svg>\n{end_of_svg}";
let html = render(input);
assert!(
!html.contains("data:"),
"data: URI must be stripped from use href"
);
}
#[test]
fn test_svg_section_strips_javascript_uri_case_insensitive() {
let input = "{start_of_svg}\n<svg><a href=\"JaVaScRiPt:alert(1)\"><text>X</text></a></svg>\n{end_of_svg}";
let html = render(input);
assert!(
!html.to_lowercase().contains("javascript:"),
"case-insensitive javascript: URI must be stripped"
);
}
#[test]
fn test_svg_section_strips_xlink_href_dangerous_uri() {
let input =
"{start_of_svg}\n<svg><use xlink:href=\"javascript:alert(1)\"/></svg>\n{end_of_svg}";
let html = render(input);
assert!(
!html.contains("javascript:"),
"javascript: URI in xlink:href must be stripped"
);
}
#[test]
fn test_svg_section_preserves_safe_href() {
let input = "{start_of_svg}\n<svg><a href=\"https://example.com\"><text>Link</text></a></svg>\n{end_of_svg}";
let html = render(input);
assert!(
html.contains("href=\"https://example.com\""),
"safe https: href must be preserved"
);
}
#[test]
fn test_svg_section_preserves_fragment_href() {
let input = "{start_of_svg}\n<svg><use href=\"#myShape\"/></svg>\n{end_of_svg}";
let html = render(input);
assert!(
html.contains("href=\"#myShape\""),
"fragment-only href must be preserved"
);
}
#[test]
fn test_render_textblock_section() {
let html = render("{start_of_textblock}\nPreformatted\n{end_of_textblock}");
assert!(html.contains("<section class=\"textblock\">"));
assert!(html.contains("Textblock"));
assert!(html.contains("</section>"));
}
#[test]
fn test_render_songs_single() {
let songs = chordsketch_core::parse_multi("{title: Only}").unwrap();
let html = render_songs(&songs);
assert_eq!(html, render_song(&songs[0]));
}
#[test]
fn test_render_songs_two_songs_with_hr_separator() {
let songs = chordsketch_core::parse_multi(
"{title: Song A}\n[Am]Hello\n{new_song}\n{title: Song B}\n[G]World",
)
.unwrap();
let html = render_songs(&songs);
assert!(html.contains("<title>Song A</title>"));
assert!(html.contains("<h1>Song A</h1>"));
assert!(html.contains("<h1>Song B</h1>"));
assert!(html.contains("<hr class=\"song-separator\">"));
assert_eq!(html.matches("<div class=\"song\">").count(), 2);
assert_eq!(html.matches("<!DOCTYPE html>").count(), 1);
assert_eq!(html.matches("</html>").count(), 1);
}
#[test]
fn test_image_scale_css_injection_prevented() {
let html = render("{image: src=photo.jpg scale=0.5); position: fixed; z-index: 9999}");
assert!(!html.contains("position"));
assert!(!html.contains("z-index"));
assert!(!html.contains("position: fixed"));
}
#[test]
fn test_render_songs_with_transpose() {
let songs =
chordsketch_core::parse_multi("{title: S1}\n[C]Do\n{new_song}\n{title: S2}\n[G]Re")
.unwrap();
let html = render_songs_with_transpose(&songs, 2, &Config::defaults());
assert!(html.contains(">D<"));
assert!(html.contains(">A<"));
}
#[test]
fn test_sanitize_svg_strips_set_element() {
let svg = r##"<svg><a href="#"><set attributeName="href" to="javascript:alert(1)"/><text>Click</text></a></svg>"##;
let sanitized = sanitize_svg_content(svg);
assert!(
!sanitized.contains("<set"),
"set element must be stripped to prevent SVG animation XSS"
);
assert!(sanitized.contains("<text>Click</text>"));
}
#[test]
fn test_sanitize_svg_strips_animate_element() {
let svg =
r#"<svg><animate attributeName="href" values="javascript:alert(1)"/><rect/></svg>"#;
let sanitized = sanitize_svg_content(svg);
assert!(
!sanitized.contains("<animate"),
"animate element must be stripped"
);
assert!(sanitized.contains("<rect/>"));
}
#[test]
fn test_sanitize_svg_strips_animatetransform() {
let svg =
"<svg><animateTransform attributeName=\"transform\" type=\"rotate\"/><rect/></svg>";
let sanitized = sanitize_svg_content(svg);
assert!(
!sanitized.contains("animateTransform"),
"animateTransform must be stripped"
);
assert!(
!sanitized.contains("animatetransform"),
"animatetransform (lowercase) must be stripped"
);
}
#[test]
fn test_sanitize_svg_strips_animatemotion() {
let svg = "<svg><animateMotion path=\"M0,0 L100,100\"/><rect/></svg>";
let sanitized = sanitize_svg_content(svg);
assert!(
!sanitized.contains("animateMotion"),
"animateMotion must be stripped"
);
}
#[test]
fn test_sanitize_svg_strips_to_attr_with_dangerous_uri() {
let svg = r#"<svg><a to="javascript:alert(1)"><text>X</text></a></svg>"#;
let sanitized = sanitize_svg_content(svg);
assert!(
!sanitized.contains("javascript:"),
"dangerous URI in 'to' attr must be stripped"
);
}
#[test]
fn test_sanitize_svg_strips_values_attr_with_dangerous_uri() {
let svg = r#"<svg><a values="javascript:alert(1)"><text>X</text></a></svg>"#;
let sanitized = sanitize_svg_content(svg);
assert!(
!sanitized.contains("javascript:"),
"dangerous URI in 'values' attr must be stripped"
);
}
#[test]
fn test_strip_dangerous_attrs_preserves_cjk_text() {
let input = "<svg><text x=\"10\">日本語テスト</text></svg>";
let result = strip_dangerous_attrs(input);
assert!(
result.contains("日本語テスト"),
"CJK characters must not be corrupted"
);
}
#[test]
fn test_strip_dangerous_attrs_preserves_emoji() {
let input = "<svg><text>🎵🎸🎹</text></svg>";
let result = strip_dangerous_attrs(input);
assert!(result.contains("🎵🎸🎹"), "emoji must not be corrupted");
}
#[test]
fn test_strip_dangerous_attrs_preserves_accented_chars() {
let input = "<svg><text>café résumé naïve</text></svg>";
let result = strip_dangerous_attrs(input);
assert!(
result.contains("café résumé naïve"),
"accented characters must not be corrupted"
);
}
#[test]
fn test_sanitize_svg_full_roundtrip_with_non_ascii() {
let input = "<svg><text x=\"10\">コード譜 🎵</text><rect width=\"100\"/></svg>";
let sanitized = sanitize_svg_content(input);
assert!(sanitized.contains("コード譜 🎵"));
assert!(sanitized.contains("<rect width=\"100\"/>"));
}
#[test]
fn test_sanitize_svg_self_closing_with_gt_in_attr_value() {
let svg = r#"<svg><set to="a>b"/><text>safe</text></svg>"#;
let sanitized = sanitize_svg_content(svg);
assert!(
!sanitized.contains("<set"),
"dangerous <set> element must be stripped"
);
assert!(
sanitized.contains("<text>safe</text>"),
"content after stripped self-closing element must be preserved"
);
}
#[test]
fn test_strip_dangerous_attrs_gt_in_double_quoted_attr() {
let input = r#"<rect title=">" onload="alert(1)"/>"#;
let result = strip_dangerous_attrs(input);
assert!(
!result.contains("onload"),
"onload after quoted > must be stripped"
);
assert!(result.contains("title"));
}
#[test]
fn test_strip_dangerous_attrs_gt_in_single_quoted_attr() {
let input = "<rect title='>' onload=\"alert(1)\"/>";
let result = strip_dangerous_attrs(input);
assert!(
!result.contains("onload"),
"onload after single-quoted > must be stripped"
);
}
#[test]
fn test_dangerous_uri_scheme_with_embedded_tab() {
assert!(has_dangerous_uri_scheme("java\tscript:alert(1)"));
}
#[test]
fn test_dangerous_uri_scheme_with_embedded_newline() {
assert!(has_dangerous_uri_scheme("java\nscript:alert(1)"));
}
#[test]
fn test_dangerous_uri_scheme_with_control_chars() {
assert!(has_dangerous_uri_scheme("java\x00script:alert(1)"));
}
#[test]
fn test_safe_uri_not_flagged() {
assert!(!has_dangerous_uri_scheme("https://example.com"));
}
#[test]
fn test_dangerous_uri_scheme_with_many_embedded_whitespace() {
let payload = "j\ta\tv\ta\ts\tc\tr\ti\tp\tt\t:\ta\tl\te\tr\tt\t(\t1\t)\t";
assert!(
has_dangerous_uri_scheme(payload),
"1 tab between letters should not bypass javascript: detection"
);
}
#[test]
fn test_dangerous_uri_scheme_whitespace_bypass_regression() {
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:";
assert!(
has_dangerous_uri_scheme(payload),
"3 tabs between letters (colon at raw position 40) must still be detected"
);
}
#[test]
fn test_svg_section_blocks_multiline_script_tag_splitting() {
let input = "{start_of_svg}\n<script\n>alert(1)</script>\n{end_of_svg}";
let html = render(input);
assert!(
!html.contains("alert(1)"),
"multi-line <script> tag splitting must not execute JS"
);
assert!(
!html.to_lowercase().contains("<script"),
"multi-line <script> tag must be stripped"
);
}
#[test]
fn test_svg_section_blocks_multiline_iframe_tag_splitting() {
let input =
"{start_of_svg}\n<iframe\nsrc=\"javascript:alert(1)\">\n</iframe>\n{end_of_svg}";
let html = render(input);
assert!(
!html.to_lowercase().contains("<iframe"),
"multi-line <iframe> tag splitting must be stripped"
);
assert!(
!html.contains("javascript:"),
"javascript: URI in split iframe must be stripped"
);
}
#[test]
fn test_svg_section_blocks_multiline_foreignobject_splitting() {
let input = "{start_of_svg}\n<foreignObject\n><script>alert(1)</script></foreignObject>\n{end_of_svg}";
let html = render(input);
assert!(
!html.to_lowercase().contains("<foreignobject"),
"multi-line <foreignObject> splitting must be stripped"
);
}
#[test]
fn test_dangerous_uri_file_scheme_blocked() {
assert!(
has_dangerous_uri_scheme("file:///etc/passwd"),
"file: URI scheme must be detected as dangerous"
);
assert!(
has_dangerous_uri_scheme("FILE:///etc/passwd"),
"FILE: (uppercase) must be detected as dangerous"
);
}
#[test]
fn test_dangerous_uri_blob_scheme_blocked() {
assert!(
has_dangerous_uri_scheme("blob:https://example.com/uuid"),
"blob: URI scheme must be detected as dangerous"
);
assert!(
has_dangerous_uri_scheme("BLOB:https://example.com/uuid"),
"BLOB: (uppercase) must be detected as dangerous"
);
}
#[test]
fn test_svg_section_strips_file_uri_in_use_href() {
let input = "{start_of_svg}\n<svg><use href=\"file:///etc/passwd\"/></svg>\n{end_of_svg}";
let html = render(input);
assert!(
!html.contains("file:///"),
"file: URI in <use href> must be stripped; got: {html}"
);
}
#[test]
fn test_svg_section_strips_file_uri_in_xlink_href() {
let input =
"{start_of_svg}\n<svg><use xlink:href=\"file:///etc/passwd\"/></svg>\n{end_of_svg}";
let html = render(input);
assert!(
!html.contains("file:///"),
"file: URI in xlink:href must be stripped; got: {html}"
);
}
#[test]
fn test_svg_section_strips_feimage_element() {
let input =
"{start_of_svg}\n<svg><feImage href=\"file:///etc/passwd\"/></svg>\n{end_of_svg}";
let html = render(input);
assert!(
!html.to_lowercase().contains("<feimage"),
"feImage element must be stripped entirely; got: {html}"
);
assert!(
!html.contains("file:///"),
"file: URI inside feImage must not appear in output; got: {html}"
);
}
#[test]
fn test_svg_section_strips_feimage_with_http_href() {
let input = "{start_of_svg}\n<svg><feImage href=\"https://evil.example.com/spy.svg\"/></svg>\n{end_of_svg}";
let html = render(input);
assert!(
!html.to_lowercase().contains("<feimage"),
"feImage element must be stripped even with http href; got: {html}"
);
}
#[test]
fn test_svg_section_strips_action_javascript_uri() {
let input =
"{start_of_svg}\n<svg><a action=\"javascript:alert(1)\">click</a></svg>\n{end_of_svg}";
let html = render(input);
assert!(
!html.contains("javascript:"),
"javascript: URI in action attribute must be stripped; got: {html}"
);
}
#[test]
fn test_svg_section_strips_formaction_javascript_uri() {
let input = "{start_of_svg}\n<svg><a formaction=\"javascript:alert(1)\">click</a></svg>\n{end_of_svg}";
let html = render(input);
assert!(
!html.contains("javascript:"),
"javascript: URI in formaction attribute must be stripped; got: {html}"
);
}
#[test]
fn test_svg_section_strips_ping_javascript_uri() {
let input =
"{start_of_svg}\n<svg><a ping=\"javascript:alert(1)\">click</a></svg>\n{end_of_svg}";
let html = render(input);
assert!(
!html.contains("javascript:"),
"javascript: URI in ping attribute must be stripped; got: {html}"
);
}
#[test]
fn test_svg_section_strips_poster_file_uri() {
let input =
"{start_of_svg}\n<svg><video poster=\"file:///etc/passwd\"/></svg>\n{end_of_svg}";
let html = render(input);
assert!(
!html.contains("file:///"),
"file: URI in poster attribute must be stripped; got: {html}"
);
}
#[test]
fn test_svg_section_strips_background_file_uri() {
let input =
"{start_of_svg}\n<svg><body background=\"file:///etc/passwd\"/></svg>\n{end_of_svg}";
let html = render(input);
assert!(
!html.contains("file:///"),
"file: URI in background attribute must be stripped; got: {html}"
);
}
#[test]
fn test_dangerous_uri_mhtml_scheme_blocked() {
assert!(
has_dangerous_uri_scheme("mhtml:file://C:/page.mhtml"),
"mhtml: URI scheme must be detected as dangerous"
);
assert!(
has_dangerous_uri_scheme("MHTML:file://C:/page.mhtml"),
"MHTML: (uppercase) must be detected as dangerous"
);
}
#[test]
fn test_svg_section_strips_image_element() {
let input =
"{start_of_svg}\n<svg><image href=\"https://evil.com/spy.png\"/></svg>\n{end_of_svg}";
let html = render(input);
assert!(
!html.to_lowercase().contains("<image"),
"SVG <image> element must be stripped entirely; got: {html}"
);
}
#[test]
fn test_extreme_textsize_is_clamped_to_max() {
let input = "{title: T}\n{textsize: 99999}\n[C]Hello";
let html = render(input);
assert!(
!html.contains("99999"),
"extreme textsize should be clamped, not passed through"
);
assert!(
html.contains("200"),
"extreme textsize should be clamped to MAX_FONT_SIZE (200)"
);
}
#[test]
fn test_negative_textsize_is_clamped_to_min() {
let input = "{title: T}\n{textsize: -10}\n[C]Hello";
let html = render(input);
assert!(
html.contains("0.5"),
"negative textsize should be clamped to MIN_FONT_SIZE (0.5)"
);
}
#[test]
fn test_extreme_chordsize_is_clamped_to_max() {
let input = "{title: T}\n{chordsize: 50000}\n[C]Hello";
let html = render(input);
assert!(
!html.contains("50000"),
"extreme chordsize should be clamped"
);
assert!(
html.contains("200"),
"extreme chordsize should be clamped to MAX_FONT_SIZE (200)"
);
}
}