use twig::{Document, Format, Kind, Target};
pub(crate) fn render_fragment(source: &str, format: Format) -> Option<String> {
let mut doc = Document::parse_str(source, format).ok()?;
let html = String::from_utf8(doc.render_html().ok()?).ok()?;
let html = html.trim();
(!html.is_empty()).then(|| html.to_string())
}
pub(crate) fn parse_fragment(html: &str, format: Format) -> Option<String> {
let cleaned = sanitize(html);
let mut doc = Document::parse_str(&cleaned, Format::Html).ok()?;
let warnings = doc.diagnostics(Target::from(format)).ok()?;
if warnings.iter().any(|w| w.kind != Kind::Section) {
return None;
}
let source = String::from_utf8(doc.serialize(format).ok()?).ok()?;
let source = source.trim_matches('\n');
if source.is_empty() {
return None;
}
Some(source.to_string())
}
pub(crate) fn strip_sole_paragraph(html: String) -> String {
let trimmed = html.trim();
let Some(inner) = trimmed
.strip_prefix("<p>")
.and_then(|h| h.strip_suffix("</p>"))
else {
return html;
};
match inner.contains("<p>") {
true => html,
false => inner.to_string(),
}
}
const DROP_TREE: [&str; 4] = ["script", "style", "head", "title"];
const DROP_TAG: [&str; 3] = ["meta", "link", "base"];
fn namespaced(name: &str) -> bool {
name.contains(':')
}
fn sanitize(html: &str) -> String {
let mut out = String::with_capacity(html.len());
let mut owed: Vec<(String, Option<&'static str>)> = Vec::new();
let bytes = html.as_bytes();
let mut i = 0;
while i < bytes.len() {
let Some(lt) = html[i..].find('<').map(|p| i + p) else {
out.push_str(&html[i..]);
break;
};
out.push_str(&html[i..lt]);
if html[lt..].starts_with("<!--") {
i = html[lt + 4..]
.find("-->")
.map_or(bytes.len(), |p| lt + 4 + p + 3);
continue;
}
if html[lt..].starts_with("<!") {
i = html[lt..].find('>').map_or(bytes.len(), |p| lt + p + 1);
continue;
}
let Some(tag) = parse_tag(html, lt) else {
out.push('<');
i = lt + 1;
continue;
};
i = tag.end;
if tag.close {
match owed.iter().rposition(|(n, _)| *n == tag.name) {
Some(at) => {
match owed[at].1 {
Some(close) => out.push_str(close),
None => out.push_str(&html[lt..tag.end]),
}
owed.truncate(at);
}
None if DROP_TREE.contains(&tag.name.as_str())
|| DROP_TAG.contains(&tag.name.as_str())
|| namespaced(&tag.name) => {}
None => out.push_str(&html[lt..tag.end]),
}
continue;
}
if DROP_TREE.contains(&tag.name.as_str()) {
i = skip_tree(html, tag.end, &tag.name);
continue;
}
if DROP_TAG.contains(&tag.name.as_str()) || namespaced(&tag.name) {
continue;
}
let rewrite = match tag.name.as_str() {
"div" => Some(("<p>", "</p>")),
"span" => match css_emphasis(&tag.attrs) {
None => Some(("", "")),
Some(pair) => Some(pair),
},
"b" | "strong" if is_unbolded(&tag.attrs) => Some(("", "")),
_ => None,
};
match rewrite {
Some((open, close)) => {
out.push_str(open);
if !tag.self_closing {
owed.push((tag.name, Some(close)));
}
}
None => {
out.push_str(&html[lt..tag.end]);
if !tag.self_closing && !VOID.contains(&tag.name.as_str()) {
owed.push((tag.name, None));
}
}
}
}
out
}
const VOID: [&str; 9] = [
"br", "img", "hr", "input", "col", "area", "source", "wbr", "embed",
];
struct Tag {
name: String,
attrs: String,
close: bool,
self_closing: bool,
end: usize,
}
fn parse_tag(html: &str, lt: usize) -> Option<Tag> {
let rest = &html[lt + 1..];
let close = rest.starts_with('/');
let after_slash = lt + 1 + close as usize;
let name_len = html[after_slash..]
.find(|c: char| !(c.is_ascii_alphanumeric() || c == ':' || c == '-' || c == '_'))
.unwrap_or(html.len() - after_slash);
if name_len == 0 {
return None;
}
let name = html[after_slash..after_slash + name_len].to_ascii_lowercase();
let mut j = after_slash + name_len;
let b = html.as_bytes();
let mut quote: Option<u8> = None;
while j < b.len() {
match (quote, b[j]) {
(Some(q), c) if c == q => quote = None,
(Some(_), _) => {}
(None, c @ (b'"' | b'\'')) => quote = Some(c),
(None, b'>') => break,
(None, _) => {}
}
j += 1;
}
if j >= b.len() {
return None;
}
let attrs = &html[after_slash + name_len..j];
Some(Tag {
name,
attrs: attrs.to_string(),
close,
self_closing: attrs.trim_end().ends_with('/'),
end: j + 1,
})
}
fn skip_tree(html: &str, from: usize, name: &str) -> usize {
let needle = format!("</{name}");
let lower = html.to_ascii_lowercase();
match lower[from..].find(&needle) {
Some(p) => lower[from + p..]
.find('>')
.map_or(html.len(), |q| from + p + q + 1),
None => html.len(),
}
}
fn style_of(attrs: &str) -> String {
let lower = attrs.to_ascii_lowercase();
let Some(at) = lower.find("style") else {
return String::new();
};
let Some(eq) = lower[at..].find('=').map(|p| at + p + 1) else {
return String::new();
};
let rest = lower[eq..].trim_start();
match rest.starts_with(['"', '\'']) {
true => rest[1..]
.find(rest.chars().next().unwrap())
.map_or(String::new(), |e| rest[1..1 + e].to_string()),
false => rest
.split_whitespace()
.next()
.unwrap_or_default()
.to_string(),
}
}
fn css_emphasis(attrs: &str) -> Option<(&'static str, &'static str)> {
let style = style_of(attrs);
let weight = css_value(&style, "font-weight");
let bold = weight == Some("bold".into())
|| weight
.as_deref()
.and_then(|w| w.parse::<u32>().ok())
.is_some_and(|w| w >= 600);
if bold {
return Some(("<strong>", "</strong>"));
}
match css_value(&style, "font-style").as_deref() {
Some("italic") | Some("oblique") => Some(("<em>", "</em>")),
_ => None,
}
}
fn is_unbolded(attrs: &str) -> bool {
matches!(
css_value(&style_of(attrs), "font-weight").as_deref(),
Some("normal") | Some("400")
)
}
fn css_value(style: &str, prop: &str) -> Option<String> {
style.split(';').find_map(|decl| {
let (k, v) = decl.split_once(':')?;
(k.trim() == prop).then(|| v.trim().to_string())
})
}
#[cfg(test)]
mod tests {
use super::*;
fn md(html: &str) -> Option<String> {
parse_fragment(html, Format::Markdown)
}
fn html(src: &str) -> Option<String> {
render_fragment(src, Format::Markdown)
}
#[test]
fn renders_inline_and_block_markdown() {
assert_eq!(
html("a **b** c").as_deref(),
Some("<p>a <strong>b</strong> c</p>")
);
assert_eq!(
html("- one\n- two").as_deref(),
Some("<ul>\n<li>one</li>\n<li>two</li>\n</ul>")
);
assert_eq!(html("# head").as_deref(), Some("<h1>head</h1>"));
}
#[test]
fn empty_selection_renders_nothing() {
assert_eq!(html(" "), None);
}
#[test]
fn strip_sole_paragraph_unwraps_only_a_lone_paragraph() {
assert_eq!(
strip_sole_paragraph("<p><strong>b</strong></p>".into()),
"<strong>b</strong>"
);
let two = "<p>a</p>\n<p>b</p>".to_string();
assert_eq!(strip_sole_paragraph(two.clone()), two);
let list = "<ul>\n<li>one</li>\n</ul>".to_string();
assert_eq!(strip_sole_paragraph(list.clone()), list);
}
#[test]
fn converts_clean_fragments() {
assert_eq!(md("<ul><li>one</li></ul>").as_deref(), Some("- one"));
assert_eq!(
md("<p>a <strong>b</strong> c</p>").as_deref(),
Some("a **b** c")
);
assert_eq!(md("<strong>bold</strong>").as_deref(), Some("**bold**"));
assert_eq!(md("<h1>head</h1>").as_deref(), Some("# head"));
assert_eq!(
md(r#"<a href="https://x.dev">l</a>"#).as_deref(),
Some("[l](https://x.dev)")
);
}
#[test]
fn round_trips_through_html() {
for src in [
"a **b** and [l](https://x.dev)",
"- one\n- two",
"# head",
"> quote",
] {
let rendered = html(src).expect("render");
assert_eq!(md(&rendered).as_deref(), Some(src), "round trip of {src:?}");
}
}
#[test]
fn paste_does_not_carry_the_serializer_s_trailing_newline() {
assert_eq!(md("<p>word</p>").as_deref(), Some("word"));
}
#[test]
fn google_docs_paste_keeps_its_emphasis_and_drops_the_wrapper() {
let clip =
r#"<meta charset='utf-8'><b style="font-weight:normal;" id="docs-internal-guid-9c1">"#
.to_string()
+ r#"<p dir="ltr" style="line-height:1.38;margin-top:0pt;"><span style="font-size:11pt;font-family:Arial;font-weight:400;">Hello </span>"#
+ r#"<span style="font-size:11pt;font-weight:700;">bold</span><span style="font-size:11pt;"> world</span></p></b>"#;
assert_eq!(md(&clip).as_deref(), Some("Hello **bold** world"));
}
#[test]
fn word_paste_drops_the_head_and_the_office_cruft() {
let clip = r#"<html xmlns:o="urn:schemas-microsoft-com:office:office"><head>"#.to_string()
+ r#"<meta http-equiv=Content-Type content="text/html; charset=utf-8">"#
+ r#"<meta name=Generator content="Microsoft Word 15">"#
+ r#"<!--[if gte mso 9]><xml><o:OfficeDocumentSettings/></xml><![endif]-->"#
+ r#"<style><!-- p.MsoNormal {margin:0in;font-size:11.0pt;} --></style></head>"#
+ r#"<body lang=EN-US><p class=MsoNormal><span style='font-size:12.0pt'>Word <b>bold</b> text<o:p></o:p></span></p></body></html>"#;
assert_eq!(md(&clip).as_deref(), Some("Word **bold** text"));
}
#[test]
fn div_per_line_html_becomes_paragraphs_not_djot_fences() {
assert_eq!(
md(r#"<div class="p-rich_text_section">hi <b>there</b></div>"#).as_deref(),
Some("hi **there**")
);
assert_eq!(md("<div>a</div><div>b</div>").as_deref(), Some("a\n\nb"));
}
#[test]
fn arboard_s_own_wrapper_survives_the_round_trip() {
let clip = r#"<html><head><meta http-equiv="content-type" content="text/html; charset=utf-8"></head><body><p>a <strong>b</strong> c</p></body></html>"#;
assert_eq!(md(clip).as_deref(), Some("a **b** c"));
}
#[test]
fn script_and_style_never_reach_the_document() {
assert_eq!(
md("<p>ok</p><script>alert(1)</script><style>p{color:red}</style>").as_deref(),
Some("ok")
);
}
#[test]
fn fragment_comments_are_dropped() {
assert_eq!(
md("<!--StartFragment--><p>frag</p><!--EndFragment-->").as_deref(),
Some("frag")
);
}
#[test]
fn plain_text_and_entities_convert() {
assert_eq!(
md("just some plain text").as_deref(),
Some("just some plain text")
);
assert_eq!(
md("<p>a & b <c></p>").as_deref(),
Some("a & b <c>")
);
}
#[test]
fn unclosed_tags_are_tolerated() {
assert_eq!(
md("<p>unclosed <strong>bold").as_deref(),
Some("unclosed **bold**")
);
}
#[test]
fn code_and_hard_breaks_survive() {
assert_eq!(
md("<pre><code>fn x() {}</code></pre>").as_deref(),
Some("```\nfn x() {}\n```")
);
assert_eq!(
md("<p>line1<br>line2</p>").as_deref(),
Some("line1 \nline2")
);
}
#[test]
fn a_headed_table_converts_and_a_headless_one_declines() {
assert_eq!(
md("<table><thead><tr><th>h1</th><th>h2</th></tr></thead>\
<tbody><tr><td>a</td><td>b</td></tr></tbody></table>")
.as_deref(),
Some("| h1 | h2 |\n| --- | --- |\n| a | b |")
);
assert_eq!(md("<table><tr><td>a</td><td>b</td></tr></table>"), None);
assert_eq!(
md("<table><tr><td>a</td></tr><tr><td>b</td></tr></table>"),
None
);
}
#[test]
fn an_unknown_element_declines_rather_than_inventing_a_directive() {
assert_eq!(md("<p>a<my-widget></my-widget>b</p>"), None);
assert_eq!(md("<p>a<o:p></o:p>b</p>").as_deref(), Some("ab"));
assert_eq!(
md("<p>see :below for the ratio</p>").as_deref(),
Some("see :below for the ratio")
);
assert_eq!(
md("<p>the key is foo:bar here</p>").as_deref(),
Some("the key is foo:bar here")
);
}
#[test]
fn empty_and_whitespace_html_declines() {
assert_eq!(md(""), None);
assert_eq!(md(" "), None);
assert_eq!(md("<meta charset='utf-8'>"), None);
}
#[test]
fn a_lossy_conversion_declines_and_a_lossless_one_does_not() {
for lossy in [
"<my-widget>y</my-widget>",
"<dl><dt>t</dt><dd>d</dd></dl>",
"<p>see <my-widget>y</my-widget> here</p>",
"<p>x<sup>2</sup></p>",
] {
assert_eq!(md(lossy), None, "{lossy:?} should decline");
}
for (good, want) in [
("<p>a <strong>b</strong> c</p>", "a **b** c"),
("<ul><li>one</li></ul>", "- one"),
("<h1>head</h1>", "# head"),
("<blockquote><p>quote</p></blockquote>", "> quote"),
("<pre><code>x</code></pre>", "```\nx\n```"),
("<p><img src=\"u\" alt=\"p\"></p>", ""),
] {
assert_eq!(md(good).as_deref(), Some(want), "{good:?} should convert");
}
}
#[test]
fn a_sectioning_wrapper_is_the_one_loss_a_paste_wants() {
assert_eq!(md("<section><p>a</p></section>").as_deref(), Some("a"));
assert_eq!(md("<main><p>a</p></main>").as_deref(), Some("a"));
assert_eq!(
md("<html><body><p>a</p></body></html>").as_deref(),
Some("a")
);
assert_eq!(md("<article><p>a</p></article>"), None);
assert_eq!(md("<nav><p>a</p></nav>"), None);
}
#[test]
fn sanitize_rewrites_divs_and_keeps_nesting_straight() {
assert_eq!(sanitize("<div>a</div>"), "<p>a</p>");
assert_eq!(sanitize("<div><b>a</b></div>"), "<p><b>a</b></p>");
assert_eq!(
sanitize(r#"<b style="font-weight:normal"><b>x</b></b>"#),
"<b>x</b>"
);
}
#[test]
fn sanitize_maps_css_emphasis_onto_tags() {
assert_eq!(
sanitize(r#"<span style="font-weight:700">b</span>"#),
"<strong>b</strong>"
);
assert_eq!(
sanitize(r#"<span style="font-weight:bold">b</span>"#),
"<strong>b</strong>"
);
assert_eq!(
sanitize(r#"<span style="font-style:italic">i</span>"#),
"<em>i</em>"
);
assert_eq!(sanitize(r#"<span style="color:red">x</span>"#), "x");
assert_eq!(sanitize("<span>x</span>"), "x");
}
#[test]
fn sanitize_steps_over_a_gt_inside_a_quoted_attribute() {
assert_eq!(sanitize(r#"<span style="font-family:'a>b'">x</span>"#), "x");
}
#[test]
fn sanitize_leaves_a_bare_less_than_as_text() {
assert_eq!(sanitize("a < b"), "a < b");
}
#[test]
fn sanitize_leaves_unknown_elements_for_twig() {
assert_eq!(sanitize("<figure>x</figure>"), "<figure>x</figure>");
}
}