use std::borrow::Cow;
use std::collections::hash_map::RandomState;
use std::collections::{HashMap, HashSet};
use std::hash::BuildHasher;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use anyhow::{Context, Result};
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine;
use pulldown_cmark::{
html as cmark_html, CodeBlockKind, CowStr, Event, Options, Parser, Tag, TagEnd,
};
use super::runner::{write_atomically, ExportOutcome, Exported};
use crate::diagram;
use crate::document::parsed_doc::{gfm_slug, uniquify_slug};
use crate::image::normalize_svg;
use crate::markdown::highlight::{self, TokenClass};
use crate::markdown::parser::post_pass::is_html_comment_only;
use images::{local_image, ImageResolver};
mod images;
pub use images::ImageHandling;
pub const BUILTIN_STYLESHEET: &str = include_str!("../../config/export/default.css");
#[derive(Debug, Clone)]
pub enum Stylesheet {
Builtin,
Path(PathBuf),
#[allow(dead_code)]
Inline(String),
}
impl Stylesheet {
pub fn from_config_value(value: &str) -> Self {
if value.eq_ignore_ascii_case("builtin") {
Self::Builtin
} else {
Self::Path(PathBuf::from(value))
}
}
fn load(&self) -> Result<String> {
match self {
Self::Builtin => Ok(BUILTIN_STYLESHEET.to_owned()),
Self::Path(p) => std::fs::read_to_string(p)
.with_context(|| format!("Failed to read stylesheet: {}", p.display())),
Self::Inline(s) => Ok(s.clone()),
}
}
}
#[derive(Debug, Clone)]
pub struct HtmlExportOptions {
pub stylesheet: Stylesheet,
pub images: ImageHandling,
pub source_dir: Option<PathBuf>,
pub approved_outside: Vec<PathBuf>,
pub title: Option<String>,
pub render_figures: bool,
}
impl Default for HtmlExportOptions {
fn default() -> Self {
Self {
stylesheet: Stylesheet::Builtin,
images: ImageHandling::Link,
source_dir: None,
approved_outside: Vec::new(),
title: None,
render_figures: true,
}
}
}
pub fn render_html(markdown: &str, opts: &HtmlExportOptions) -> Result<String> {
render(markdown, opts).map(|rendered| rendered.html)
}
pub(super) struct Rendered {
pub(super) html: String,
pub(super) images_left_out: usize,
}
pub(super) fn render(markdown: &str, opts: &HtmlExportOptions) -> Result<Rendered> {
let mut events: Vec<Event> = Parser::new_ext(markdown, parser_options(markdown)).collect();
assign_heading_ids(&mut events);
let mut figures = Figures::new();
if opts.render_figures {
events = replace_mermaid_with_figure(events, &mut figures);
}
events = replace_math(events, opts.render_figures, &mut figures);
events = highlight_code_blocks(events);
let mut body = String::new();
cmark_html::push_html(&mut body, events.into_iter());
let images = Arc::new(ImageResolver::new(
opts.images,
opts.source_dir.as_deref(),
&opts.approved_outside,
));
let hook = Arc::clone(&images);
let body = figures.insert_into(&sanitize_body(&body, move |src| hook.resolve(src)));
let css = opts.stylesheet.load()?;
let title = opts.title.as_deref().unwrap_or("Document");
let html = format!(
"<!doctype html>\n\
<html lang=\"en\">\n\
<head>\n\
<meta charset=\"utf-8\">\n\
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\
<title>{title}</title>\n\
<style>\n{css}\n</style>\n\
</head>\n\
<body>\n\
<main class=\"markdown-body\">\n\
{body}\n\
</main>\n\
</body>\n\
</html>\n",
title = html_escape(title),
);
Ok(Rendered {
html,
images_left_out: images.left_out(),
})
}
pub fn spawn_html_export(
markdown: String,
target: PathBuf,
opts: HtmlExportOptions,
on_done: impl FnOnce(ExportOutcome) + Send + 'static,
) {
std::thread::spawn(move || {
let result = render_and_write(&markdown, &target, &opts).map(|images_left_out| Exported {
path: target.clone(),
images_left_out,
});
on_done(result.map_err(|e| format!("{e:#}")));
});
}
fn render_and_write(markdown: &str, target: &Path, opts: &HtmlExportOptions) -> Result<usize> {
let rendered = render(markdown, opts)?;
write_atomically(target, rendered.html.as_bytes())
.with_context(|| format!("Failed to write export: {}", target.display()))?;
Ok(rendered.images_left_out)
}
fn parser_options(markdown: &str) -> Options {
let mut options = Options::empty();
options.insert(Options::ENABLE_TABLES);
options.insert(Options::ENABLE_FOOTNOTES);
options.insert(Options::ENABLE_STRIKETHROUGH);
options.insert(Options::ENABLE_TASKLISTS);
options.insert(Options::ENABLE_SMART_PUNCTUATION);
options.insert(Options::ENABLE_MATH);
options |= crate::markdown::parse_offsets::metadata_options_for(markdown);
options
}
fn assign_heading_ids(events: &mut [Event<'_>]) {
let mut counts: HashMap<String, usize> = HashMap::new();
let mut i = 0;
while i < events.len() {
if !matches!(events[i], Event::Start(Tag::Heading { .. })) {
i += 1;
continue;
}
let mut text = String::new();
let mut j = i + 1;
while let Some(event) = events.get(j) {
match event {
Event::End(TagEnd::Heading(_)) => break,
Event::Text(t) | Event::Code(t) => text.push_str(t),
Event::InlineMath(m) => text.push_str(&literal_math(m, false)),
Event::DisplayMath(m) => text.push_str(&literal_math(m, true)),
Event::SoftBreak => text.push(' '),
Event::HardBreak => text.push('\n'),
Event::InlineHtml(h) if !is_html_comment_only(h) => text.push_str(h),
_ => {}
}
j += 1;
}
let base = gfm_slug(&text);
if !base.is_empty() {
let slug = uniquify_slug(&base, &mut counts);
if let Event::Start(Tag::Heading { id, .. }) = &mut events[i] {
*id = Some(CowStr::Boxed(slug.into_boxed_str()));
}
}
i = j + 1;
}
}
const BLOCKED_URL_SCHEMES: &[&str] = &["javascript", "vbscript", "data"];
fn sanitize_body<F>(html: &str, img_src: F) -> String
where
F: for<'u> Fn(&'u str) -> Option<Cow<'u, str>> + Send + Sync + 'static,
{
let schemes = url_schemes_in(html);
let mut builder = ammonia::Builder::default();
builder
.url_schemes(schemes.iter().map(String::as_str).collect())
.add_url_schemes(["data"])
.link_rel(None)
.add_generic_attributes(["id", "class", "align"])
.add_tag_attributes("a", ["name"])
.add_tag_attributes("details", ["open"])
.add_tags(["input"])
.add_tag_attributes("input", ["checked", "disabled"])
.add_tag_attribute_values("input", "type", ["checkbox"])
.add_tag_attributes("th", ["style"])
.add_tag_attributes("td", ["style"])
.filter_style_properties(HashSet::from(["text-align"]))
.attribute_filter(move |element, attribute, value| {
if (element, attribute) == ("img", "src") {
return img_src(value);
}
let is_url_attribute = matches!(attribute, "href" | "src" | "xlink:href" | "cite");
if is_url_attribute && has_data_scheme(value) {
None
} else {
Some(Cow::Borrowed(value))
}
});
builder.clean(html).to_string()
}
fn url_schemes_in(html: &str) -> HashSet<String> {
let is_scheme_char = |b: u8| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.');
let bytes = html.as_bytes();
let mut schemes = HashSet::new();
for (colon, _) in html.match_indices(':') {
let start = bytes[..colon]
.iter()
.rposition(|&b| !is_scheme_char(b))
.map_or(0, |i| i + 1);
let Some(first_alpha) = bytes[start..colon].iter().position(u8::is_ascii_alphabetic) else {
continue;
};
let scheme = html[start + first_alpha..colon].to_ascii_lowercase();
if !BLOCKED_URL_SCHEMES.contains(&scheme.as_str()) {
schemes.insert(scheme);
}
}
schemes
}
fn has_data_scheme(value: &str) -> bool {
let normalized: String = value
.trim_start_matches(|c: char| c <= ' ')
.chars()
.filter(|c| !matches!(c, '\t' | '\n' | '\r'))
.take(5)
.collect();
normalized.eq_ignore_ascii_case("data:")
}
struct Figures {
open: String,
html: Vec<String>,
}
const PLACEHOLDER_CLOSE: &str = "\"></div>";
impl Figures {
fn new() -> Self {
let nonce = (
RandomState::new().hash_one(0u8),
RandomState::new().hash_one(1u8),
);
Self {
open: format!(
"<div class=\"edamame-figure-{:016x}{:016x}-",
nonce.0, nonce.1
),
html: Vec::new(),
}
}
fn placeholder(&mut self, html: String) -> Event<'static> {
let token = format!("{}{}{PLACEHOLDER_CLOSE}", self.open, self.html.len());
self.html.push(html);
Event::Html(CowStr::Boxed(token.into_boxed_str()))
}
fn insert_into(&self, body: &str) -> String {
if self.html.is_empty() {
return body.to_owned();
}
let mut out =
String::with_capacity(body.len() + self.html.iter().map(String::len).sum::<usize>());
let mut rest = body;
while let Some(pos) = rest.find(&self.open) {
out.push_str(&rest[..pos]);
let after = &rest[pos + self.open.len()..];
let digits = after.bytes().take_while(u8::is_ascii_digit).count();
let figure = after[..digits]
.parse::<usize>()
.ok()
.filter(|_| after[digits..].starts_with(PLACEHOLDER_CLOSE))
.and_then(|i| self.html.get(i));
match figure {
Some(html) => {
out.push_str(html);
rest = &after[digits + PLACEHOLDER_CLOSE.len()..];
}
None => {
out.push_str(&self.open);
rest = after;
}
}
}
out.push_str(rest);
out
}
}
fn svg_figure(svg: &str, class: &str, alt: &str) -> Option<String> {
let svg = normalize_svg(svg).ok()?;
Some(format!(
"<figure class=\"{class}\"><img alt=\"{alt}\" src=\"data:image/svg+xml;base64,{}\"></figure>",
BASE64.encode(svg)
))
}
fn replace_mermaid_with_figure<'a>(
events: Vec<Event<'a>>,
figures: &mut Figures,
) -> Vec<Event<'a>> {
let mut out: Vec<Event<'_>> = Vec::with_capacity(events.len());
let mut iter = events.into_iter();
while let Some(event) = iter.next() {
let lang = match &event {
Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(lang)))
if lang.as_ref().eq_ignore_ascii_case("mermaid") =>
{
Some(lang.clone())
}
_ => None,
};
if lang.is_none() {
out.push(event);
continue;
}
let mut buffered: Vec<Event<'_>> = vec![event];
let mut source = String::new();
for inner in iter.by_ref() {
match inner {
Event::End(TagEnd::CodeBlock) => {
buffered.push(Event::End(TagEnd::CodeBlock));
break;
}
Event::Text(ref t) => {
source.push_str(t);
buffered.push(inner);
}
other => {
buffered.push(other);
}
}
}
match render_mermaid_figure(&source) {
Some(html) => out.push(figures.placeholder(html)),
None => {
out.extend(buffered);
}
}
}
out
}
fn highlight_code_blocks(events: Vec<Event<'_>>) -> Vec<Event<'_>> {
let mut out: Vec<Event<'_>> = Vec::with_capacity(events.len());
let mut iter = events.into_iter();
while let Some(event) = iter.next() {
let lang = match &event {
Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(lang))) if !lang.is_empty() => {
lang.clone()
}
_ => {
out.push(event);
continue;
}
};
out.push(event);
let mut buffered: Vec<Event<'_>> = Vec::new();
let mut source = String::new();
let mut end = None;
for inner in iter.by_ref() {
match inner {
Event::End(TagEnd::CodeBlock) => {
end = Some(inner);
break;
}
Event::Text(ref t) => {
source.push_str(t);
buffered.push(inner);
}
other => buffered.push(other),
}
}
let all_text = buffered.iter().all(|e| matches!(e, Event::Text(_)));
match all_text
.then(|| highlighted_code_html(&lang, &source))
.flatten()
{
Some(html) => out.push(Event::Html(CowStr::Boxed(html.into_boxed_str()))),
None => out.extend(buffered),
}
out.extend(end);
}
out
}
fn highlighted_code_html(lang: &str, source: &str) -> Option<String> {
let mut lines: Vec<&str> = source.split('\n').collect();
let trailing_newline = lines.last() == Some(&"");
if trailing_newline {
lines.pop();
}
let tokens = highlight::highlight_block_unbudgeted(Some(lang), &lines);
if tokens.is_empty() {
return None;
}
let mut html = String::with_capacity(source.len() * 2);
for (i, line) in lines.iter().enumerate() {
let byte: Vec<usize> = line
.char_indices()
.map(|(b, _)| b)
.chain([line.len()])
.collect();
let chars = byte.len() - 1;
let mut col = 0;
for token in tokens.get(i).map(Vec::as_slice).unwrap_or(&[]) {
let start = token.range.start.min(chars);
let end = token.range.end.min(chars);
if start < col || start >= end {
continue;
}
push_html_escaped(&mut html, &line[byte[col]..byte[start]]);
html.push_str("<span class=\"");
html.push_str(token_css_class(token.class));
html.push_str("\">");
push_html_escaped(&mut html, &line[byte[start]..byte[end]]);
html.push_str("</span>");
col = end;
}
push_html_escaped(&mut html, &line[byte[col]..]);
if i + 1 < lines.len() || trailing_newline {
html.push('\n');
}
}
Some(html)
}
fn token_css_class(class: TokenClass) -> &'static str {
match class {
TokenClass::Keyword => "hl-keyword",
TokenClass::String => "hl-string",
TokenClass::Comment => "hl-comment",
TokenClass::Number => "hl-number",
TokenClass::Type => "hl-type",
TokenClass::Function => "hl-function",
TokenClass::Attribute => "hl-attribute",
}
}
fn render_mermaid_figure(source: &str) -> Option<String> {
let svg = diagram::render_mermaid_svg(source).ok()?;
svg_figure(&svg, "mermaid-diagram", "mermaid diagram")
}
fn replace_math<'a>(
events: Vec<Event<'a>>,
render_figures: bool,
figures: &mut Figures,
) -> Vec<Event<'a>> {
let mut out: Vec<Event<'_>> = Vec::with_capacity(events.len());
let mut iter = events.into_iter();
while let Some(event) = iter.next() {
match event {
Event::Start(Tag::Paragraph) => {
let mut body: Vec<Event<'_>> = Vec::new();
for inner in iter.by_ref() {
if matches!(inner, Event::End(TagEnd::Paragraph)) {
break;
}
body.push(inner);
}
if is_display_math_only(&body) {
for inner in body {
if let Event::DisplayMath(source) = inner {
if render_figures {
push_display_math_figure(&mut out, &source, figures);
} else {
push_display_math_source_block(&mut out, &source);
}
}
}
} else {
out.push(Event::Start(Tag::Paragraph));
for inner in body {
push_math_as_literal(&mut out, inner);
}
out.push(Event::End(TagEnd::Paragraph));
}
}
other => push_math_as_literal(&mut out, other),
}
}
out
}
fn is_display_math_only(body: &[Event<'_>]) -> bool {
let mut saw_display = false;
for ev in body {
match ev {
Event::DisplayMath(_) => saw_display = true,
Event::Text(t) if t.trim().is_empty() => {}
Event::SoftBreak | Event::HardBreak => {}
_ => return false,
}
}
saw_display
}
fn push_math_as_literal<'a>(out: &mut Vec<Event<'a>>, event: Event<'a>) {
match event {
Event::InlineMath(source) => out.push(Event::Text(literal_math(&source, false))),
Event::DisplayMath(source) => out.push(Event::Text(literal_math(&source, true))),
other => out.push(other),
}
}
fn push_display_math_figure(out: &mut Vec<Event<'_>>, source: &str, figures: &mut Figures) {
match render_math_figure(source) {
Some(html) => out.push(figures.placeholder(html)),
None => push_display_math_source_block(out, source),
}
}
fn push_display_math_source_block(out: &mut Vec<Event<'_>>, source: &str) {
let trimmed = source.strip_prefix('\n').unwrap_or(source);
let body = trimmed.strip_suffix('\n').unwrap_or(trimmed);
out.push(Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(
CowStr::Borrowed("math"),
))));
out.push(Event::Text(CowStr::Boxed(
body.to_string().into_boxed_str(),
)));
out.push(Event::End(TagEnd::CodeBlock));
}
fn literal_math(source: &str, display: bool) -> CowStr<'static> {
let delim = if display { "$$" } else { "$" };
CowStr::Boxed(format!("{delim}{source}{delim}").into_boxed_str())
}
const HTML_EXPORT_MATH_CELL_PX: u16 = 24;
fn render_math_figure(source: &str) -> Option<String> {
let svg = diagram::render_latex_svg(
source,
[0, 0, 0, 255],
Some((HTML_EXPORT_MATH_CELL_PX, HTML_EXPORT_MATH_CELL_PX)),
)
.ok()?;
svg_figure(&svg, "math-formula", "math formula")
}
pub fn outside_images(markdown: &str, source_dir: &Path) -> Vec<PathBuf> {
let mut body = String::new();
cmark_html::push_html(
&mut body,
Parser::new_ext(markdown, parser_options(markdown)),
);
let sources = Arc::new(Mutex::new(Vec::<String>::new()));
let record = Arc::clone(&sources);
sanitize_body(&body, move |src| {
record
.lock()
.unwrap_or_else(|e| e.into_inner())
.push(src.to_owned());
Some(Cow::Borrowed(src))
});
let sources = std::mem::take(&mut *sources.lock().unwrap_or_else(|e| e.into_inner()));
let canon_dir = source_dir.canonicalize().ok();
let mut out: Vec<PathBuf> = Vec::new();
for src in sources {
if let Some(image) = local_image(&src, source_dir, canon_dir.as_deref()) {
if !image.inside && !out.contains(&image.canonical) {
out.push(image.canonical);
}
}
}
out
}
fn html_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
push_html_escaped(&mut out, s);
out
}
fn push_html_escaped(out: &mut String, s: &str) {
for ch in s.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(ch),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::tempdir;
fn opts_inline_css() -> HtmlExportOptions {
HtmlExportOptions {
stylesheet: Stylesheet::Inline("body { color: red; }".into()),
..HtmlExportOptions::default()
}
}
#[test]
fn renders_basic_markdown() {
let html = render_html("# Hello\n\nWorld", &opts_inline_css()).unwrap();
assert!(html.contains("<h1 id=\"hello\">Hello</h1>"));
assert!(html.contains("<p>World</p>"));
}
#[test]
fn fenced_code_with_a_known_language_is_highlighted() {
let md = "```rust\nfn main() { let s = \"<b>\"; } // done\n```\n";
let html = render_html(md, &opts_inline_css()).unwrap();
assert!(
html.contains(
"<pre><code class=\"language-rust\"><span class=\"hl-keyword\">fn</span>"
),
"wrapper kept and keyword classified:\n{html}"
);
assert!(
html.contains("<span class=\"hl-function\">main</span>"),
"{html}"
);
assert!(
html.contains("<span class=\"hl-comment\">// done</span>"),
"{html}"
);
assert!(html.contains("<b>"), "{html}");
assert!(!html.contains("<b>"), "{html}");
assert!(html.contains("// done</span>\n</code></pre>"), "{html}");
}
#[test]
fn non_ascii_code_keeps_token_boundaries() {
let md = "```rust\nlet é = \"ü→\"; // ñ\n```\n";
let html = render_html(md, &opts_inline_css()).unwrap();
assert!(
html.contains("<span class=\"hl-string\">\"ü→\"</span>"),
"{html}"
);
assert!(
html.contains("<span class=\"hl-comment\">// ñ</span>\n</code>"),
"{html}"
);
}
#[test]
fn multi_line_code_keeps_every_line_and_blank_lines() {
let md = "```python\ndef f():\n\n return 1\n```\n";
let html = render_html(md, &opts_inline_css()).unwrap();
assert!(
html.contains(
"<span class=\"hl-keyword\">def</span> <span class=\"hl-function\">f</span>"
),
"{html}"
);
assert!(
html.contains(":\n\n <span class=\"hl-keyword\">return</span> "),
"{html}"
);
assert!(
html.contains("<span class=\"hl-number\">1</span>\n</code></pre>"),
"{html}"
);
}
#[test]
fn unknown_language_and_unlabeled_code_export_plain() {
let md = "```no-such-lang\nfn x\n```\n\n```\nfn y\n```\n\n fn z\n";
let html = render_html(md, &opts_inline_css()).unwrap();
assert!(!html.contains("hl-"), "{html}");
assert!(html.contains("<pre><code class=\"language-no-such-lang\">fn x\n</code></pre>"));
assert!(html.contains("<pre><code>fn y\n</code></pre>"), "{html}");
assert!(html.contains("<pre><code>fn z\n</code></pre>"), "{html}");
}
#[test]
fn highlighted_code_cannot_inject_markup() {
let md = "```html\n<script>alert(1)</script><img src=x onerror=alert(1)>\n```\n";
let html = render_html(md, &opts_inline_css()).unwrap();
assert!(html.contains("hl-"), "html should highlight:\n{html}");
assert!(!html.contains("<script"), "{html}");
assert!(!html.contains("<img"), "{html}");
assert!(html.contains("<"), "{html}");
}
#[test]
fn builtin_stylesheet_styles_every_token_class() {
for class in [
TokenClass::Keyword,
TokenClass::String,
TokenClass::Comment,
TokenClass::Number,
TokenClass::Type,
TokenClass::Function,
TokenClass::Attribute,
] {
let selector = format!(".{} ", token_css_class(class));
assert!(
BUILTIN_STYLESHEET.contains(&selector),
"default.css has no rule for {selector}"
);
}
}
#[test]
fn frontmatter_is_omitted_from_the_export() {
let md = "---\ntitle: Foo\ndate: 2026-01-01\n---\n\n# Heading\n";
let html = render_html(md, &opts_inline_css()).unwrap();
assert!(html.contains("<h1 id=\"heading\">Heading</h1>"));
assert!(!html.contains("title: Foo"), "got: {html}");
assert!(!html.contains("<h2>"), "got: {html}");
}
#[test]
fn a_mid_document_rule_pair_is_not_dropped_from_the_export() {
let md = "Intro.\n\n---\n## Section 2\n\nText.\n\n---\n## Section 3\n";
let html = render_html(md, &opts_inline_css()).unwrap();
assert!(html.contains("Section 2"), "got: {html}");
assert!(html.contains("Text."), "got: {html}");
assert!(html.contains("Section 3"), "got: {html}");
}
#[test]
fn a_toml_opening_file_does_not_drop_a_later_dash_pair() {
let md = "+++\na = 1\n+++\n\n---\nSection\n---\n\nEnd.\n";
let html = render_html(md, &opts_inline_css()).unwrap();
assert!(!html.contains("a = 1"), "got: {html}");
assert!(html.contains("Section"), "got: {html}");
}
#[test]
fn renders_gfm_table() {
let md = "| a | b |\n|---|---|\n| 1 | 2 |\n";
let html = render_html(md, &opts_inline_css()).unwrap();
assert!(html.contains("<table>"));
assert!(html.contains("<th>a</th>"));
assert!(html.contains("<td>1</td>"));
}
#[test]
fn renders_task_list_and_strikethrough() {
let md = "- [x] done\n- [ ] todo\n\n~~gone~~";
let html = render_html(md, &opts_inline_css()).unwrap();
assert!(html.contains("type=\"checkbox\""));
assert!(html.contains("<del>gone</del>"));
}
#[test]
fn strips_raw_html_block() {
let md = "text\n\n<script>alert('x')</script>\n\nmore";
let html = render_html(md, &opts_inline_css()).unwrap();
assert!(
!html.contains("<script>") && !html.contains("alert"),
"raw <script> must be stripped with its contents — got:\n{html}"
);
assert!(html.contains("more"));
}
#[test]
fn strips_raw_html_inline() {
let md = "a <b onclick=\"x\">inline</b> c";
let html = render_html(md, &opts_inline_css()).unwrap();
assert!(
!html.contains("onclick"),
"inline HTML event handlers must be stripped — got:\n{html}"
);
assert!(
html.contains("<b>inline</b>"),
"the tag itself is allowed:\n{html}"
);
}
#[test]
fn keeps_readme_html() {
let md = "<p align=\"center\"><img src=\"logo.png\" width=\"120\" alt=\"logo\"></p>\n\n\
<details open><summary>More</summary>\n\nHidden *text*.\n\n</details>\n\n\
Press <kbd>Ctrl</kbd>+<kbd>S</kbd>, H<sub>2</sub>O, x<sup>2</sup>,<br>next line.\n";
let html = render_html(md, &opts_inline_css()).unwrap();
for needle in [
"<p align=\"center\">",
"width=\"120\"",
"src=\"logo.png\"",
"<details open=\"\">",
"<summary>More</summary>",
"<em>text</em>",
"<kbd>Ctrl</kbd>",
"<sub>2</sub>",
"<sup>2</sup>",
"<br>",
] {
assert!(html.contains(needle), "missing {needle}:\n{html}");
}
}
#[test]
fn strips_css_except_table_alignment() {
let md = "<style>body { background: url(https://t.example/x) }</style>\n\n\
<p style=\"position: fixed; background: url(https://t.example/y)\">p</p>\n\n\
| a | b |\n|:-:|--:|\n| 1 | 2 |\n";
let html = render_html(md, &opts_inline_css()).unwrap();
assert!(
!html.contains("t.example"),
"no CSS fetch may survive:\n{html}"
);
assert!(!html.contains("position"), "{html}");
assert!(html.contains("text-align:center"), "{html}");
assert!(html.contains("text-align:right"), "{html}");
}
#[test]
fn task_list_checkboxes_survive() {
let md = "- [x] done\n- [ ] todo\n";
let html = render_html(md, &opts_inline_css()).unwrap();
assert_eq!(html.matches("type=\"checkbox\"").count(), 2, "{html}");
assert!(html.contains("checked"), "{html}");
assert!(html.contains("disabled"), "{html}");
}
#[test]
fn footnotes_keep_their_anchors() {
let md = "Claim.[^1]\n\n[^1]: Source.\n";
let html = render_html(md, &opts_inline_css()).unwrap();
assert!(html.contains("class=\"footnote-reference\""), "{html}");
assert!(html.contains("href=\"#1\""), "{html}");
assert!(html.contains("id=\"1\""), "{html}");
}
#[test]
fn escapes_title() {
let mut opts = opts_inline_css();
opts.title = Some("A <script>x</script> & B".into());
let html = render_html("", &opts).unwrap();
assert!(html.contains("<title>A <script>x</script> & B</title>"));
assert!(!html.contains("<title>A <script>"));
}
#[test]
fn embeds_builtin_stylesheet() {
let opts = HtmlExportOptions {
stylesheet: Stylesheet::Builtin,
..HtmlExportOptions::default()
};
let html = render_html("hi", &opts).unwrap();
assert!(html.contains("markdown-body"));
assert!(html.contains("<style>"));
}
#[test]
fn footnotes_render_with_bracket_convention() {
let opts = HtmlExportOptions {
stylesheet: Stylesheet::Builtin,
..HtmlExportOptions::default()
};
let html = render_html("Claim.[^1]\n\n[^1]: Source.\n", &opts).unwrap();
assert!(
html.contains("<sup class=\"footnote-reference\"><a href=\"#1\">1</a></sup>"),
"expected footnote-reference markup, got:\n{html}"
);
assert!(
html.contains("sup.footnote-reference a::before { content: \"[\"; }"),
"builtin CSS must add the opening bracket"
);
assert!(
html.contains("sup.footnote-reference a::after { content: \"]\"; }"),
"builtin CSS must add the closing bracket"
);
}
#[test]
fn stylesheet_from_config_value_parses() {
assert!(matches!(
Stylesheet::from_config_value("builtin"),
Stylesheet::Builtin
));
assert!(matches!(
Stylesheet::from_config_value("BUILTIN"),
Stylesheet::Builtin
));
match Stylesheet::from_config_value("/etc/custom.css") {
Stylesheet::Path(p) => assert_eq!(p, PathBuf::from("/etc/custom.css")),
other => panic!("expected Path, got {other:?}"),
}
}
#[test]
fn stylesheet_path_read_failure_surfaces_error() {
let opts = HtmlExportOptions {
stylesheet: Stylesheet::Path(PathBuf::from("/this/does/not/exist.css")),
..HtmlExportOptions::default()
};
let err = render_html("hi", &opts).unwrap_err();
assert!(format!("{err:#}").contains("stylesheet"));
}
#[test]
fn inline_images_embeds_local_png() {
const ONE_PX_PNG: &[u8] = &[
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00,
0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x44, 0x41, 0x54, 0x78,
0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00,
0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
];
let dir = tempdir().unwrap();
let img_path = dir.path().join("pixel.png");
let mut f = std::fs::File::create(&img_path).unwrap();
f.write_all(ONE_PX_PNG).unwrap();
drop(f);
let md = "";
let opts = HtmlExportOptions {
stylesheet: Stylesheet::Inline(String::new()),
images: ImageHandling::Embed,
source_dir: Some(dir.path().to_path_buf()),
approved_outside: Vec::new(),
title: None,
render_figures: false,
};
let html = render_html(md, &opts).unwrap();
assert!(
html.contains("src=\"data:image/png;base64,"),
"expected base64 data URI, got:\n{html}"
);
assert!(!html.contains("src=\"pixel.png\""));
}
#[test]
fn inline_images_leaves_remote_urls_untouched() {
let md = "";
let opts = HtmlExportOptions {
stylesheet: Stylesheet::Inline(String::new()),
images: ImageHandling::Embed,
source_dir: Some(PathBuf::from("/tmp")),
approved_outside: Vec::new(),
title: None,
render_figures: false,
};
let html = render_html(md, &opts).unwrap();
assert!(html.contains("src=\"https://example.com/cat.png\""));
}
#[test]
fn inline_images_disabled_by_default() {
let md = "";
let html = render_html(md, &opts_inline_css()).unwrap();
assert!(html.contains("src=\"local.png\""));
}
#[test]
fn neutralizes_javascript_link_scheme() {
let md = "[click](javascript:alert(document.cookie))";
let html = render_html(md, &opts_inline_css()).unwrap();
assert!(
!html.contains("javascript:"),
"javascript: href must be neutralized — got:\n{html}"
);
assert!(html.contains(">click</a>"), "{html}");
assert!(!html.contains("href"), "{html}");
}
#[test]
fn neutralizes_obfuscated_script_schemes() {
let md = "[a](JaVaScRiPt:alert(1)) <a href=\"java	script:alert(1)\">b</a> [c](vbscript:msgbox) \
[d](javascript:alert(1)) \
<a href=\"javascript:alert(1)\">e</a> <a href=\" javascript:alert(1)\">f</a> \
<a href=\"java script:alert(1)\">g</a>";
let html = render_html(md, &opts_inline_css()).unwrap();
assert!(!html.contains("href"), "no href may survive:\n{html}");
for text in ["a", "b", "c", "d", "e", "f", "g"] {
assert!(
html.contains(&format!(">{text}</a>")),
"{text} lost:\n{html}"
);
}
}
#[test]
fn neutralizes_data_html_link_scheme() {
let md = "[x](data:text/html;base64,PHNjcmlwdD4=) \
<a href=\"data:text/html,<script>alert(1)</script>\">y</a> \
<a href=\"data:image/svg+xml,x\">z</a>";
let html = render_html(md, &opts_inline_css()).unwrap();
assert!(
!html.contains("data:"),
"data: link must be neutralized:\n{html}"
);
}
#[test]
fn keeps_data_image_sources() {
let md = "<img src=\"data:image/png;base64,iVBORw0KGgo=\" alt=\"a\">";
let html = render_html(md, &opts_inline_css()).unwrap();
assert!(
html.contains("src=\"data:image/png;base64,iVBORw0KGgo=\""),
"{html}"
);
}
#[test]
fn preserves_link_schemes_and_relative_targets() {
let md = "[a](https://example.com) [b](mailto:x@y.z) [c](./page.md) [d](#anchor) \
[e](foo/bar:baz) [f](file:///home/me/notes.md) [g](obsidian://open?vault=v) \
[h](vscode://file/src/main.rs) [i](zotero://select/items/ABC) \
<a href=\"x-devonthink-item://123\">j</a>";
let html = render_html(md, &opts_inline_css()).unwrap();
for href in [
"https://example.com",
"mailto:x@y.z",
"./page.md",
"#anchor",
"foo/bar:baz",
"file:///home/me/notes.md",
"obsidian://open?vault=v",
"vscode://file/src/main.rs",
"zotero://select/items/ABC",
"x-devonthink-item://123",
] {
assert!(
html.contains(&format!("href=\"{href}\"")),
"{href} lost:\n{html}"
);
}
}
#[test]
fn url_schemes_in_collects_scheme_tokens_minus_the_blocked_ones() {
let found = url_schemes_in(
"<a href=\"Obsidian://x\">a</a> 12:30 x-dt.item+1:y JavaScript: vbscript: data: 9file:z",
);
for scheme in ["obsidian", "x-dt.item+1", "file"] {
assert!(found.contains(scheme), "{scheme} missing from {found:?}");
}
for scheme in BLOCKED_URL_SCHEMES {
assert!(!found.contains(*scheme), "{scheme} must never be allowed");
}
assert!(
!found.contains("12"),
"a scheme starts with a letter: {found:?}"
);
}
#[test]
fn has_data_scheme_reads_urls_as_a_browser_does() {
assert!(has_data_scheme("data:text/html,x"));
assert!(has_data_scheme(" DATA:text/html,x"));
assert!(has_data_scheme("\u{1}da\tta:x"));
assert!(has_data_scheme("d\na\rta:x"));
assert!(!has_data_scheme("dat"));
assert!(!has_data_scheme("./data:x"));
assert!(!has_data_scheme("https://data:x"));
}
#[test]
fn headings_carry_the_gfm_slug_an_anchor_link_names() {
let md = "[Go](#getting-started)\n\n## Getting Started\n\n## Getting Started\n\n\
### The `--doctor` *flag*\n\n# !!!\n";
let html = render_html(md, &opts_inline_css()).unwrap();
assert!(html.contains("href=\"#getting-started\""), "{html}");
assert!(html.contains("<h2 id=\"getting-started\">"), "{html}");
assert!(html.contains("<h2 id=\"getting-started-1\">"), "{html}");
assert!(html.contains("<h3 id=\"the---doctor-flag\">"), "{html}");
assert!(html.contains("<h1>!!!</h1>"), "{html}");
}
#[test]
fn heading_ids_match_the_in_app_anchor_table() {
let md =
"# Intro\n\n## Math $x^2$ and <kbd>K</kbd>\n\n## Intro\n\n## Note[^1]\n\n[^1]: n\n";
let html = render_html(md, &opts_inline_css()).unwrap();
let theme = crate::config::Theme::default();
let parsed = crate::document::ParsedDoc::build(md, &theme, false, 4);
for slug in parsed.heading_anchors.keys() {
assert!(
html.contains(&format!("id=\"{slug}\"")),
"{slug} missing:\n{html}"
);
}
assert_eq!(parsed.heading_anchors.len(), 4);
}
#[test]
fn figure_placeholders_cannot_be_forged() {
let mut figures = Figures::new();
let Event::Html(token) = figures.placeholder("<figure>F</figure>".into()) else {
panic!("placeholder is raw HTML");
};
let forged = format!("{}0{PLACEHOLDER_CLOSE}", Figures::new().open);
let body = format!("{forged}{token}");
assert_eq!(
figures.insert_into(&body),
format!("{forged}<figure>F</figure>")
);
}
#[test]
fn figure_placeholders_stay_out_of_attribute_values() {
let mut figures = Figures::new();
let Event::Html(token) = figures.placeholder("<figure>F</figure>".into()) else {
panic!("placeholder is raw HTML");
};
let body = format!("<div title=\"\n{token}\n<p class=\"a\">after</p>");
let html = figures.insert_into(&sanitize_body(&body, |src| Some(Cow::Borrowed(src))));
assert!(!html.contains("<figure>"), "{html}");
}
#[test]
fn figure_placeholders_survive_sanitization() {
let mut figures = Figures::new();
let Event::Html(token) = figures.placeholder("<figure>F</figure>".into()) else {
panic!("placeholder is raw HTML");
};
let body = format!("<ul><li>a\n{token}</li></ul>");
assert_eq!(
figures.insert_into(&sanitize_body(&body, |src| Some(Cow::Borrowed(src)))),
"<ul><li>a\n<figure>F</figure></li></ul>"
);
}
fn first_figure_svg(html: &str) -> Option<String> {
let marker = "src=\"data:image/svg+xml;base64,";
let start = html.find(marker)? + marker.len();
let end = start + html[start..].find('"')?;
let bytes = BASE64.decode(&html.as_bytes()[start..end]).ok()?;
String::from_utf8(bytes).ok()
}
#[test]
fn mermaid_export_never_emits_raw_svg_or_script() {
let md = "```mermaid\nflowchart TD\n A[\"<script>alert(1)</script>\"] --> B\n```";
let opts = HtmlExportOptions {
stylesheet: Stylesheet::Inline(String::new()),
render_figures: true,
..HtmlExportOptions::default()
};
let html = render_html(md, &opts).unwrap();
assert!(
!html.contains("<svg"),
"no inline SVG may reach the export:\n{html}"
);
assert!(!html.contains("foreignObject"));
assert!(
!html.contains("<script>"),
"no executable <script> may reach the export:\n{html}"
);
if let Some(svg) = first_figure_svg(&html) {
assert!(!svg.contains("<script"), "{svg}");
assert!(!svg.contains("foreignObject"), "{svg}");
assert!(!svg.contains("<text"), "text must be outlined:\n{svg}");
}
}
#[test]
fn display_math_exports_as_an_svg_figure() {
let md = "$$\nx^2 + y^2 = z^2\n$$\n";
let opts = HtmlExportOptions {
stylesheet: Stylesheet::Inline(String::new()),
render_figures: true,
..HtmlExportOptions::default()
};
let html = render_html(md, &opts).unwrap();
assert!(
html.contains("<figure class=\"math-formula\">"),
"expected a math-formula figure:\n{html}"
);
let svg = first_figure_svg(&html).expect("formula is an SVG data URI");
assert!(!svg.contains("<text"), "text must be outlined:\n{svg}");
assert!(svg.contains("<path"), "{svg}");
assert!(!html.contains("<svg"), "no inline SVG:\n{html}");
assert!(
!html.contains("class=\"math math-"),
"pulldown's math span must not survive:\n{html}"
);
}
#[test]
fn exported_display_math_is_rendered_large_enough_to_read() {
let md = "$$\nx^2 + y^2 = z^2\n$$\n";
let opts = HtmlExportOptions {
stylesheet: Stylesheet::Inline(String::new()),
render_figures: true,
..HtmlExportOptions::default()
};
let html = render_html(md, &opts).unwrap();
let svg = first_figure_svg(&html).expect("formula is an SVG data URI");
let attr = "height=\"";
let start = svg.find(attr).expect("svg height") + attr.len();
let h: f32 = svg[start..start + svg[start..].find('"').unwrap()]
.parse()
.expect("numeric height");
assert!(
(28.0..=160.0).contains(&h),
"exported formula height {h}px out of range"
);
}
#[test]
fn display_math_off_renders_as_a_math_code_block() {
let md = "$$\na + b\n$$\n";
let opts = HtmlExportOptions {
stylesheet: Stylesheet::Inline(String::new()),
render_figures: false,
..HtmlExportOptions::default()
};
let html = render_html(md, &opts).unwrap();
assert!(
html.contains("<pre><code class=\"language-math\">"),
"expected a math code block:\n{html}"
);
assert!(html.contains("a + b"), "formula body kept:\n{html}");
assert!(!html.contains("$$"), "delimiters must be stripped:\n{html}");
assert!(!html.contains("<figure"), "no figure when off:\n{html}");
assert!(
!html.contains("class=\"math math-"),
"no math span:\n{html}"
);
}
#[test]
fn oversized_display_math_falls_back_to_a_code_block() {
let huge = "1+".repeat(64 * 1024); let md = format!("$$\n{huge}1\n$$\n");
let opts = HtmlExportOptions {
stylesheet: Stylesheet::Inline(String::new()),
render_figures: true,
..HtmlExportOptions::default()
};
let html = render_html(&md, &opts).unwrap();
assert!(
html.contains("<pre><code class=\"language-math\">"),
"render failure must fall back to a math code block, not a figure or literal text"
);
assert!(
!html.contains("data:image/png"),
"no PNG when the render failed:\n{}",
&html[..html.len().min(400)]
);
}
#[test]
fn inline_math_stays_literal_source() {
let md = "Solve $a^2 + b^2$ please.\n";
for render_figures in [true, false] {
let opts = HtmlExportOptions {
stylesheet: Stylesheet::Inline(String::new()),
render_figures,
..HtmlExportOptions::default()
};
let html = render_html(md, &opts).unwrap();
assert!(
html.contains("$a^2 + b^2$"),
"inline math must read back as literal source (figures={render_figures}):\n{html}"
);
assert!(
!html.contains("class=\"math math-"),
"no math span (figures={render_figures}):\n{html}"
);
}
}
fn write_one_px_png(path: &Path) {
const ONE_PX_PNG: &[u8] = &[
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00,
0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x44, 0x41, 0x54, 0x78,
0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00,
0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
];
std::fs::write(path, ONE_PX_PNG).unwrap();
}
fn inline_opts(source: &Path, approved_outside: Vec<PathBuf>) -> HtmlExportOptions {
HtmlExportOptions {
stylesheet: Stylesheet::Inline(String::new()),
images: ImageHandling::Embed,
source_dir: Some(source.to_path_buf()),
approved_outside,
render_figures: false,
..HtmlExportOptions::default()
}
}
fn root_with_docs() -> (tempfile::TempDir, PathBuf, PathBuf) {
let root = tempdir().unwrap();
let secret = root.path().join("secret.png");
write_one_px_png(&secret);
let source = root.path().join("docs");
std::fs::create_dir(&source).unwrap();
(root, secret.canonicalize().unwrap(), source)
}
#[test]
fn inline_images_skips_an_unapproved_absolute_path() {
let (_root, secret, source) = root_with_docs();
let md = format!("", secret.display());
let html = render_html(&md, &inline_opts(&source, Vec::new())).unwrap();
assert!(
!html.contains("data:image/png"),
"an unapproved out-of-folder path must not be inlined:\n{html}"
);
}
#[test]
fn inline_images_skips_unapproved_parent_traversal() {
let (_root, _secret, source) = root_with_docs();
let html = render_html("", &inline_opts(&source, Vec::new())).unwrap();
assert!(
!html.contains("data:image/png"),
"unapproved ../ traversal must not be inlined:\n{html}"
);
assert!(
html.contains("src=\"../secret.png\""),
"left as a link:\n{html}"
);
}
#[test]
fn inline_images_embeds_an_approved_outside_image() {
let (_root, secret, source) = root_with_docs();
let opts = inline_opts(&source, vec![secret]);
let html = render_html("", &opts).unwrap();
assert!(html.contains("src=\"data:image/png;base64,"), "{html}");
}
#[test]
fn approval_does_not_extend_to_an_unlisted_outside_image() {
let (root, secret, source) = root_with_docs();
write_one_px_png(&root.path().join("other.png"));
let md = " ";
let html = render_html(md, &inline_opts(&source, vec![secret])).unwrap();
assert_eq!(html.matches("data:image/png").count(), 1, "{html}");
assert!(html.contains("src=\"../other.png\""), "{html}");
}
#[test]
fn outside_images_lists_parent_and_absolute_references_once() {
let (_root, secret, source) = root_with_docs();
write_one_px_png(&source.join("inside.png"));
let md = format!(
"    \
 ",
secret.display()
);
assert_eq!(outside_images(&md, &source), vec![secret]);
}
#[test]
fn outside_images_ignores_non_image_files() {
let root = tempdir().unwrap();
std::fs::write(root.path().join("notes.txt"), "private").unwrap();
let source = root.path().join("docs");
std::fs::create_dir(&source).unwrap();
assert!(outside_images("", &source).is_empty());
}
#[cfg(unix)]
#[test]
fn outside_images_sees_through_a_symlink_leading_out() {
let (_root, secret, source) = root_with_docs();
std::os::unix::fs::symlink(&secret, source.join("link.png")).unwrap();
assert_eq!(outside_images("", &source), vec![secret]);
let html = render_html("", &inline_opts(&source, Vec::new())).unwrap();
assert!(!html.contains("data:image/png"), "{html}");
}
#[test]
fn raw_html_images_are_listed_and_embedded_like_markdown_ones() {
let (_root, secret, source) = root_with_docs();
write_one_px_png(&source.join("inside.png"));
let md = "<img src=\"../secret.png\" width=\"10\"> <img src=\"inside.png\">";
assert_eq!(outside_images(md, &source), vec![secret.clone()]);
let html = render_html(md, &inline_opts(&source, Vec::new())).unwrap();
assert_eq!(
html.matches("data:image/png").count(),
1,
"inside only:\n{html}"
);
assert!(
html.contains("src=\"../secret.png\""),
"left as a link:\n{html}"
);
let html = render_html(md, &inline_opts(&source, vec![secret])).unwrap();
assert_eq!(html.matches("data:image/png").count(), 2, "{html}");
}
#[test]
fn embedded_paths_are_percent_decoded() {
let dir = tempdir().unwrap();
write_one_px_png(&dir.path().join("my pic.png"));
let md = " ";
let html = render_html(md, &inline_opts(dir.path(), Vec::new())).unwrap();
assert_eq!(html.matches("data:image/png").count(), 2, "{html}");
}
fn slash_path(path: &Path) -> String {
let s = path.display().to_string();
s.strip_prefix(r"\\?\").unwrap_or(&s).replace('\\', "/")
}
fn file_url(path: &Path) -> String {
let s = slash_path(path);
format!("file://{}{s}", if s.starts_with('/') { "" } else { "/" })
}
fn sealed_opts(source: &Path, approved_outside: Vec<PathBuf>) -> HtmlExportOptions {
HtmlExportOptions {
images: ImageHandling::Sealed {
fetch_remote: false,
},
..inline_opts(source, approved_outside)
}
}
#[test]
fn a_sealed_export_leaves_no_reference_for_a_converter_to_follow() {
let (_root, secret, source) = root_with_docs();
write_one_px_png(&source.join("inside.png"));
let md = format!(
"    \
<img src=\"../secret.png\">   \
  \
",
abs = slash_path(&secret),
url = file_url(&secret),
);
let rendered = render(&md, &sealed_opts(&source, Vec::new())).unwrap();
assert_eq!(rendered.images_left_out, 8);
let html = rendered.html;
let srcs: Vec<&str> = html
.split("src=\"")
.skip(1)
.map(|rest| &rest[..rest.find('"').unwrap()])
.collect();
assert_eq!(
srcs.len(),
2,
"only the inside image and the raster data URI:\n{html}"
);
assert!(
srcs.iter().all(|s| s.starts_with("data:image/png;base64,")),
"{srcs:?}"
);
assert!(
srcs.contains(&"data:image/png;base64,iVBORw0KGgo="),
"{srcs:?}"
);
assert!(
!html.contains("secret") && !html.contains("example.com"),
"{html}"
);
assert!(html.contains("alt=\"b\""), "{html}");
}
#[test]
fn a_sealed_export_embeds_approved_outside_images() {
let (_root, secret, source) = root_with_docs();
let md = format!(" ", file_url(&secret));
let html = render_html(&md, &sealed_opts(&source, vec![secret])).unwrap();
assert_eq!(html.matches("data:image/png;base64,").count(), 2, "{html}");
}
#[test]
fn only_a_sealed_export_reports_images_left_out() {
let (_root, _secret, source) = root_with_docs();
let md = "  ";
let left_out = |opts| render(md, &opts).unwrap().images_left_out;
assert_eq!(left_out(sealed_opts(&source, Vec::new())), 3);
assert_eq!(left_out(inline_opts(&source, Vec::new())), 0);
assert_eq!(
left_out(HtmlExportOptions {
images: ImageHandling::Link,
..inline_opts(&source, Vec::new())
}),
0
);
}
fn svg_referencing(target: &Path) -> String {
write_one_px_png(target);
format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="4" height="4"><rect width="4" height="4"/><image width="4" height="4" xlink:href="{}"/></svg>"#,
slash_path(target)
)
}
fn embedded_svg(html: &str) -> String {
let marker = "src=\"data:image/svg+xml;base64,";
let start = html.find(marker).expect("svg embedded") + marker.len();
let end = start + html[start..].find('"').unwrap();
String::from_utf8(BASE64.decode(&html[start..end]).unwrap()).unwrap()
}
#[test]
fn embedded_svg_is_normalized() {
let dir = tempdir().unwrap();
let svg = svg_referencing(&dir.path().join("secret.png"));
std::fs::write(dir.path().join("pic.svg"), svg).unwrap();
for opts in [
inline_opts(dir.path(), Vec::new()),
sealed_opts(dir.path(), Vec::new()),
] {
let svg = embedded_svg(&render_html("", &opts).unwrap());
assert!(svg.contains("<path"), "{svg}");
assert!(!svg.contains("<image") && !svg.contains("secret"), "{svg}");
}
}
#[test]
fn a_disguised_svg_is_typed_by_its_content() {
let dir = tempdir().unwrap();
let svg = svg_referencing(&dir.path().join("secret.png"));
std::fs::write(dir.path().join("pic.png"), &svg).unwrap();
let md = format!(
" ",
BASE64.encode(&svg)
);
for opts in [
inline_opts(dir.path(), Vec::new()),
sealed_opts(dir.path(), Vec::new()),
] {
let html = render_html(&md, &opts).unwrap();
let embedded = embedded_svg(&html);
assert!(
!embedded.contains("<image") && !embedded.contains("secret"),
"{embedded}"
);
assert_eq!(html.matches("data:image/svg+xml").count(), 1, "{html}");
}
let html = render_html(&md, &sealed_opts(dir.path(), Vec::new())).unwrap();
assert!(!html.contains("data:image/png"), "{html}");
}
#[test]
fn no_attachment_link_reaches_a_converter() {
let md = "<a rel=\"attachment\" href=\"../secret.txt\">a</a>\n\n\
<link rel=\"attachment\" href=\"../secret.txt\">";
let html = render_html(md, &opts_inline_css()).unwrap();
assert!(!html.contains("rel="), "{html}");
assert!(!html.contains("<link"), "{html}");
}
#[test]
fn spawn_html_export_writes_file_and_reports_success() {
use std::sync::mpsc;
let dir = tempdir().unwrap();
let target = dir.path().join("out.html");
let (tx, rx) = mpsc::channel();
spawn_html_export(
"# hi\n".into(),
target.clone(),
HtmlExportOptions {
stylesheet: Stylesheet::Inline("body{}".into()),
..HtmlExportOptions::default()
},
move |outcome| {
tx.send(outcome).unwrap();
},
);
let outcome = rx.recv().unwrap();
assert_eq!(outcome.unwrap().path, target);
let written = std::fs::read_to_string(&target).unwrap();
assert!(written.contains("<h1 id=\"hi\">hi</h1>"));
}
}