pub(crate) fn typst(html: &str) -> String {
let mut out = String::with_capacity(html.len());
let mut rest = html;
while let Some(at) = rest.find('<') {
out.push_str(&escape(&rest[..at]));
let Some(close) = rest[at..].find('>') else {
out.push_str(&escape(&rest[at..]));
return out;
};
let tag = &rest[at + 1..at + close];
rest = &rest[at + close + 1..];
out.push_str(&element(tag, &mut rest));
}
out.push_str(&escape(rest));
out
}
fn element(tag: &str, rest: &mut &str) -> String {
let name = tag
.split([' ', '/'])
.next()
.unwrap_or_default()
.to_lowercase();
match name.as_str() {
"br" => return "\\\n".to_string(),
"img" => return picture(tag),
"hr" | "wbr" => return String::new(),
_ => {}
}
if name.starts_with('/') || tag.starts_with('/') {
return String::new();
}
let content = take_until_close(&name, rest);
let inner = typst(&content);
match name.as_str() {
"strong" | "b" => format!("#strong[{inner}]"),
"em" | "i" => format!("#emph[{inner}]"),
"code" => format!("#raw({})", string(&text(&content))),
"mark" => format!("#highlight[{inner}]"),
"sup" if is_footnote(tag) => footnote(&content).unwrap_or(format!("#super[{inner}]")),
"sup" => format!("#super[{inner}]"),
"sub" => format!("#sub[{inner}]"),
"del" | "s" => format!("#strike[{inner}]"),
"u" => format!("#underline[{inner}]"),
"a" => link(tag, &inner),
_ => inner,
}
}
pub(crate) const PICTURE: &str = "\u{2}";
pub(crate) const FIELD: &str = "\u{1}";
fn picture(tag: &str) -> String {
let Some(source) = attribute(tag, "src") else {
return String::new();
};
let words = attribute(tag, "alt")
.map(|alt| escape(&alt))
.unwrap_or_default();
let height = attribute(tag, "height").unwrap_or_default();
format!("{PICTURE}{source}{FIELD}{height}{FIELD}{words}{PICTURE}")
}
fn is_footnote(tag: &str) -> bool {
attribute(tag, "class").is_some_and(|class| class == "footnote" || class == "footnoteref")
}
fn footnote(content: &str) -> Option<String> {
let at = content.find("#_footnotedef_")?;
let digits = &content[at + "#_footnotedef_".len()..];
let end = digits
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(digits.len());
let index: usize = digits[..end].parse().ok()?;
Some(format!("#adocfootnote({index})"))
}
fn link(tag: &str, inner: &str) -> String {
match attribute(tag, "href") {
Some(href) if href.starts_with('#') => {
format!("#link(label({}))[{inner}]", string(&href[1..]))
}
Some(href) => format!("#link({})[{inner}]", string(&href)),
None => match attribute(tag, "id") {
Some(id) => format!("#box[]#label({}){inner}", string(&id)),
None => inner.to_string(),
},
}
}
fn attribute(tag: &str, name: &str) -> Option<String> {
let at = tag.find(&format!("{name}=\""))?;
let value = &tag[at + name.len() + 2..];
let end = value.find('"')?;
Some(unescape(&value[..end]))
}
fn take_until_close(name: &str, rest: &mut &str) -> String {
let open = format!("<{name}");
let close = format!("</{name}>");
let mut depth = 1usize;
let mut at = 0usize;
while depth > 0 {
let next_open = rest[at..].find(&open).map(|i| at + i);
let next_close = rest[at..].find(&close).map(|i| at + i);
match (next_open, next_close) {
(Some(o), Some(c)) if o < c => {
depth += 1;
at = o + open.len();
}
(_, Some(c)) => {
depth -= 1;
if depth == 0 {
let content = rest[..c].to_string();
*rest = &rest[c + close.len()..];
return content;
}
at = c + close.len();
}
(_, None) => {
let content = (*rest).to_string();
*rest = "";
return content;
}
}
}
String::new()
}
pub(crate) fn text(html: &str) -> String {
let mut out = String::with_capacity(html.len());
let mut rest = html;
while let Some(at) = rest.find('<') {
out.push_str(&rest[..at]);
match rest[at..].find('>') {
Some(close) => rest = &rest[at + close + 1..],
None => return unescape(&out),
}
}
out.push_str(rest);
unescape(&out)
}
pub(crate) fn unescape(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(at) = rest.find('&') {
out.push_str(&rest[..at]);
rest = &rest[at..];
let Some(end) = rest.find(';').filter(|end| *end <= 10) else {
out.push('&');
rest = &rest[1..];
continue;
};
match entity(&rest[1..end]) {
Some(character) => out.push_str(&character),
None => out.push_str(&rest[..=end]),
}
rest = &rest[end + 1..];
}
out.push_str(rest);
out
}
fn entity(name: &str) -> Option<String> {
if let Some(digits) = name.strip_prefix('#') {
let point = match digits.strip_prefix(['x', 'X']) {
Some(hex) => u32::from_str_radix(hex, 16).ok()?,
None => digits.parse().ok()?,
};
if point == 0x200b {
return Some(String::new());
}
return char::from_u32(point).map(String::from);
}
let character = match name {
"lt" => "<",
"gt" => ">",
"quot" => "\"",
"apos" => "\u{2019}",
"amp" => "&",
"nbsp" => "\u{a0}",
"hellip" => "\u{2026}",
"mdash" => "\u{2014}",
"ndash" => "\u{2013}",
_ => return None,
};
Some(character.to_string())
}
fn escape(html: &str) -> String {
let text: Vec<char> = unescape(html).chars().collect();
let mut out = String::with_capacity(text.len());
for (at, &c) in text.iter().enumerate() {
let next = |n: usize| text.get(at + n).copied();
let markup = match c {
'*' | '_' | '`' | '$' | '#' | '<' | '>' | '@' | '\\' | '[' | ']' | '~' | '/' => true,
'.' => next(1) == Some('.') && next(2) == Some('.'),
'-' => matches!(next(1), Some('-' | '?')),
_ => false,
};
if markup {
out.push('\\');
}
out.push(c);
}
out
}
pub(crate) fn string(text: &str) -> String {
let escaped = text
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\r', "\\r")
.replace('\n', "\\n")
.replace('\t', "\\t");
format!("\"{escaped}\"")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn translates_the_marks_asciidoc_emits() {
assert_eq!(typst("<strong>a</strong>"), "#strong[a]");
assert_eq!(typst("<em>a</em>"), "#emph[a]");
assert_eq!(typst("plain"), "plain");
}
#[test]
fn marks_a_word_that_goes_on() {
assert_eq!(typst("<strong>b</strong>old"), "#strong[b]old");
assert_eq!(typst("<em>i</em>talic"), "#emph[i]talic");
}
#[test]
fn keeps_nesting() {
assert_eq!(
typst("<strong>a <em>b</em></strong>"),
"#strong[a #emph[b]]"
);
}
#[test]
fn turns_a_link_into_one() {
assert_eq!(
typst(r#"<a href="https://example.com">text</a>"#),
"#link(\"https://example.com\")[text]"
);
}
#[test]
fn points_an_internal_reference_at_a_label() {
assert_eq!(
typst(r##"<a href="#tables">Tables</a>"##),
"#link(label(\"tables\"))[Tables]"
);
}
#[test]
fn escapes_what_typst_would_read_as_markup() {
assert_eq!(typst("2 * 3"), "2 \\* 3");
assert_eq!(typst("a_b"), "a\\_b");
}
#[test]
fn keeps_a_shorthand_from_forming() {
assert_eq!(typst("5b8a949...HEAD"), "5b8a949\\...HEAD");
assert_eq!(typst("0001..0009"), "0001..0009");
assert_eq!(typst("a--b c---d"), "a\\--b c\\-\\--d");
assert_eq!(typst("a-b"), "a-b");
assert_eq!(typst("a~b"), "a\\~b");
assert_eq!(typst("a // b"), "a \\/\\/ b");
assert_eq!(typst("g/<strong>h</strong>/i"), "g\\/#strong[h]\\/i");
assert_eq!(typst("The end."), "The end.");
}
#[test]
fn takes_the_markup_out_of_verbatim_content() {
assert_eq!(
text("let v: Vec<String>; // <b class=\"conum\">(1)</b>"),
"let v: Vec<String>; // (1)"
);
}
#[test]
fn turns_a_footnote_mark_into_a_footnote() {
let mark = r##"<sup class="footnote">[<a id="_footnoteref_2" class="footnote" href="#_footnotedef_2" title="View footnote.">2</a>]</sup>"##;
assert_eq!(typst(mark), "#adocfootnote(2)");
}
#[test]
fn leaves_an_ordinary_superscript_alone() {
assert_eq!(typst("x<sup>2</sup>"), "x#super[2]");
}
#[test]
fn gives_an_anchor_a_label_to_be_found_by() {
assert_eq!(
typst(r#"<a id="block"></a>block"#),
"#box[]#label(\"block\")block"
);
}
#[test]
fn marks_a_picture_for_the_caller_to_place() {
assert_eq!(
typst(r#"<img src="tip.svg" alt="Tip" height="16">"#),
"\u{2}tip.svg\u{1}16\u{1}Tip\u{2}"
);
}
#[test]
fn unwraps_what_it_does_not_know() {
assert_eq!(typst("<span class=\"x\">text</span>"), "text");
}
#[test]
fn turns_entities_back_into_characters() {
assert_eq!(typst("a & b"), "a & b");
assert_eq!(typst("it’s"), "it\u{2019}s");
}
#[test]
fn resolves_any_numeric_reference() {
assert_eq!(typst("→"), "\u{2192}");
assert_eq!(typst("→"), "\u{2192}");
assert_eq!(typst("…"), "\u{2026}");
}
#[test]
fn leaves_an_ampersand_that_starts_nothing() {
assert_eq!(typst("a & b"), "a & b");
assert_eq!(typst("R&D; more"), "R&D; more");
}
}