use pulldown_cmark::{
CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd,
};
pub fn html_to_typst(html: &str) -> String {
let cleaned = strip_non_content(html);
let md = html2md::parse_html(&cleaned);
md_to_typst(&md)
}
fn strip_non_content(html: &str) -> String {
let lower = html.to_ascii_lowercase();
let mut out = String::with_capacity(html.len());
let mut i = 0;
while i < html.len() {
let rest_lower = &lower[i..];
let skip = ["head", "script", "style"]
.iter()
.copied()
.find(|name| matches_open_tag(rest_lower, name));
if let Some(name) = skip {
let close = format!("</{name}>");
if let Some(end_rel) = lower[i..].find(&close) {
i += end_rel + close.len();
continue;
} else {
break;
}
}
let next = html[i..].chars().next().unwrap();
let len = next.len_utf8();
out.push_str(&html[i..i + len]);
i += len;
}
out
}
fn matches_open_tag(rest: &str, name: &str) -> bool {
let Some(after_lt) = rest.strip_prefix('<') else {
return false;
};
let Some(after_name) = after_lt.strip_prefix(name) else {
return false;
};
matches!(
after_name.as_bytes().first(),
Some(b'>' | b'/' | b' ' | b'\t' | b'\n' | b'\r') | None
)
}
fn md_to_typst(md: &str) -> String {
let mut opts = Options::empty();
opts.insert(Options::ENABLE_TABLES);
opts.insert(Options::ENABLE_STRIKETHROUGH);
opts.insert(Options::ENABLE_TASKLISTS);
let parser = Parser::new_ext(md, opts);
let mut buffers: Vec<String> = vec![String::new()];
let mut list_stack: Vec<Option<u64>> = Vec::new();
let mut link_stack: Vec<String> = Vec::new();
let mut image_stack: Vec<String> = Vec::new();
let mut table_cols: usize = 0;
let mut table_cells: Vec<String> = Vec::new();
let mut in_code_block = false;
for ev in parser {
match ev {
Event::Start(Tag::Heading { level, .. }) => {
let depth = heading_depth(level);
let top = buffers.last_mut().unwrap();
ensure_blank_line(top);
for _ in 0..depth {
top.push('=');
}
top.push(' ');
}
Event::End(TagEnd::Heading(_)) => {
buffers.last_mut().unwrap().push('\n');
}
Event::Start(Tag::Paragraph) => {
ensure_blank_line(buffers.last_mut().unwrap());
}
Event::End(TagEnd::Paragraph) => {
buffers.last_mut().unwrap().push('\n');
}
Event::Start(Tag::BlockQuote(_)) => {
let top = buffers.last_mut().unwrap();
ensure_blank_line(top);
top.push_str("#quote(block: true)[");
}
Event::End(TagEnd::BlockQuote(_)) => {
buffers.last_mut().unwrap().push_str("]\n");
}
Event::Start(Tag::CodeBlock(kind)) => {
in_code_block = true;
let lang = match kind {
CodeBlockKind::Indented => String::new(),
CodeBlockKind::Fenced(l) => l.to_string(),
};
let top = buffers.last_mut().unwrap();
ensure_blank_line(top);
top.push_str("```");
top.push_str(&lang);
top.push('\n');
}
Event::End(TagEnd::CodeBlock) => {
in_code_block = false;
let top = buffers.last_mut().unwrap();
if !top.ends_with('\n') {
top.push('\n');
}
top.push_str("```\n");
}
Event::Start(Tag::List(start)) => {
list_stack.push(start);
let top = buffers.last_mut().unwrap();
if !top.ends_with('\n') {
top.push('\n');
}
}
Event::End(TagEnd::List(_)) => {
list_stack.pop();
if list_stack.is_empty() {
buffers.last_mut().unwrap().push('\n');
}
}
Event::Start(Tag::Item) => {
let depth = list_stack.len().saturating_sub(1);
let marker = match list_stack.last().copied().flatten() {
Some(_) => "+ ",
None => "- ",
};
let top = buffers.last_mut().unwrap();
for _ in 0..depth {
top.push_str(" ");
}
top.push_str(marker);
}
Event::End(TagEnd::Item) => {
let top = buffers.last_mut().unwrap();
if !top.ends_with('\n') {
top.push('\n');
}
}
Event::Start(Tag::Emphasis) => {
buffers.last_mut().unwrap().push_str("#emph[");
}
Event::End(TagEnd::Emphasis) => {
buffers.last_mut().unwrap().push(']');
}
Event::Start(Tag::Strong) => {
buffers.last_mut().unwrap().push_str("#strong[");
}
Event::End(TagEnd::Strong) => {
buffers.last_mut().unwrap().push(']');
}
Event::Start(Tag::Strikethrough) => {
buffers.last_mut().unwrap().push_str("#strike[");
}
Event::End(TagEnd::Strikethrough) => {
buffers.last_mut().unwrap().push(']');
}
Event::Start(Tag::Link { dest_url, .. }) => {
link_stack.push(dest_url.to_string());
buffers.push(String::new());
}
Event::End(TagEnd::Link) => {
let text = buffers.pop().unwrap_or_default();
let url = link_stack.pop().unwrap_or_default();
let rendered = if text.trim().is_empty() {
format!("#link(\"{}\")", escape_string(&url))
} else {
format!("#link(\"{}\")[{}]", escape_string(&url), text)
};
buffers.last_mut().unwrap().push_str(&rendered);
}
Event::Start(Tag::Image { dest_url, .. }) => {
image_stack.push(dest_url.to_string());
buffers.push(String::new());
}
Event::End(TagEnd::Image) => {
let alt = buffers.pop().unwrap_or_default();
let url = image_stack.pop().unwrap_or_default();
let top = buffers.last_mut().unwrap();
if is_remote(&url) || url.is_empty() {
if !alt.trim().is_empty() {
top.push_str("#emph[");
top.push_str(&alt);
top.push(']');
}
} else {
top.push_str(&format!("#image(\"{}\")", escape_string(&url)));
}
}
Event::Start(Tag::Table(aligns)) => {
table_cols = aligns.len().max(1);
table_cells.clear();
}
Event::End(TagEnd::Table) => {
let top = buffers.last_mut().unwrap();
ensure_blank_line(top);
top.push_str("#table(\n columns: ");
top.push_str(&table_cols.to_string());
top.push_str(",\n");
for (i, cell) in table_cells.iter().enumerate() {
top.push_str(" [");
top.push_str(cell.trim());
top.push_str("],");
if (i + 1) % table_cols == 0 {
top.push('\n');
} else {
top.push(' ');
}
}
if !top.ends_with('\n') {
top.push('\n');
}
top.push_str(")\n");
table_cells.clear();
table_cols = 0;
}
Event::Start(Tag::TableHead)
| Event::End(TagEnd::TableHead)
| Event::Start(Tag::TableRow)
| Event::End(TagEnd::TableRow) => {}
Event::Start(Tag::TableCell) => {
buffers.push(String::new());
}
Event::End(TagEnd::TableCell) => {
let cell = buffers.pop().unwrap_or_default();
table_cells.push(cell);
}
Event::Text(t) => {
let top = buffers.last_mut().unwrap();
if in_code_block {
top.push_str(&t);
} else {
top.push_str(&escape_markup(&t));
}
}
Event::Code(t) => {
let rendered = render_inline_raw(&t);
buffers.last_mut().unwrap().push_str(&rendered);
}
Event::SoftBreak => buffers.last_mut().unwrap().push(' '),
Event::HardBreak => {
buffers.last_mut().unwrap().push_str("#linebreak()\n");
}
Event::Rule => {
let top = buffers.last_mut().unwrap();
ensure_blank_line(top);
top.push_str("#line(length: 100%)\n");
}
Event::Html(_)
| Event::InlineHtml(_)
| Event::FootnoteReference(_)
| Event::TaskListMarker(_)
| Event::Start(Tag::FootnoteDefinition(_))
| Event::End(TagEnd::FootnoteDefinition)
| Event::Start(Tag::HtmlBlock)
| Event::End(TagEnd::HtmlBlock)
| Event::Start(Tag::MetadataBlock(_))
| Event::End(TagEnd::MetadataBlock(_))
| Event::Start(Tag::DefinitionList)
| Event::End(TagEnd::DefinitionList)
| Event::Start(Tag::DefinitionListTitle)
| Event::End(TagEnd::DefinitionListTitle)
| Event::Start(Tag::DefinitionListDefinition)
| Event::End(TagEnd::DefinitionListDefinition) => {}
Event::InlineMath(_) | Event::DisplayMath(_) => {}
}
}
buffers.pop().unwrap_or_default()
}
fn heading_depth(level: HeadingLevel) -> usize {
match level {
HeadingLevel::H1 => 1,
HeadingLevel::H2 => 2,
HeadingLevel::H3 => 3,
HeadingLevel::H4 => 4,
HeadingLevel::H5 => 5,
HeadingLevel::H6 => 6,
}
}
fn ensure_blank_line(s: &mut String) {
if s.is_empty() {
return;
}
if s.ends_with("\n\n") {
return;
}
if s.ends_with('\n') {
s.push('\n');
} else {
s.push_str("\n\n");
}
}
fn is_remote(url: &str) -> bool {
let u = url.trim_start();
u.starts_with("http://")
|| u.starts_with("https://")
|| u.starts_with("data:")
|| u.starts_with("//")
}
fn escape_markup(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' | '#' | '$' | '*' | '_' | '`' | '<' | '>' | '@' | '~' | '['
| ']' => {
out.push('\\');
out.push(c);
}
_ => out.push(c),
}
}
out
}
fn escape_string(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
fn render_inline_raw(text: &str) -> String {
let mut max_run = 0usize;
let mut current = 0usize;
for c in text.chars() {
if c == '`' {
current += 1;
max_run = max_run.max(current);
} else {
current = 0;
}
}
let fence: String = "`".repeat(max_run + 1);
let needs_padding =
text.starts_with('`') || text.ends_with('`') || max_run >= 1;
if needs_padding {
format!("{fence} {text} {fence}")
} else {
format!("{fence}{text}{fence}")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_does_not_swallow_header_tag() {
let html = "<html><head><title>t</title></head><body>\
<header>hi</header><p>keep</p></body></html>";
let stripped = strip_non_content(html);
assert!(
stripped.contains("<header>"),
"stripper lost <header>: {stripped}"
);
assert!(stripped.contains("keep"));
assert!(!stripped.contains("<title>"));
}
#[test]
fn strip_removes_head_script_style() {
let html = "<html><head><style>x{}</style></head>\
<body><script>z()</script><p>ok</p></body></html>";
let stripped = strip_non_content(html);
assert!(!stripped.contains("x{}"));
assert!(!stripped.contains("z()"));
assert!(stripped.contains("ok"));
}
#[test]
fn html_to_typst_produces_content_for_header_heavy_page() {
let html = "<html><head><title>t</title></head><body>\
<header><h1>Hello</h1></header>\
<p>world</p></body></html>";
let typ = html_to_typst(html);
assert!(typ.contains("Hello"), "missing heading: {typ}");
assert!(typ.contains("world"), "missing paragraph: {typ}");
}
}