fn line_is_heading(line: &str) -> Option<(usize, &str)> {
let bytes = line.as_bytes();
let mut eq_count: usize = 0;
while eq_count < bytes.len() && bytes[eq_count] == b'=' {
eq_count += 1;
}
if eq_count == 0 || eq_count > 6 {
return None;
}
if bytes.get(eq_count).copied() != Some(b' ') {
return None;
}
let rest = line[eq_count + 1..].trim();
Some((eq_count, rest))
}
fn convert_image_call(line: &str) -> Option<String> {
let trimmed = line.trim_start();
if !trimmed.starts_with("#image(") {
return None;
}
let after = trimmed.trim_start_matches("#image(");
let (path, after_path) = match read_quoted(after) {
Some(p) => p,
None => return None,
};
let mut alt = String::new();
if let Some(idx) = after_path.find("caption:") {
let after_caption = &after_path[idx + "caption:".len()..];
if let Some((cap, _)) = read_quoted(after_caption.trim_start()) {
alt = cap;
}
}
Some(format!(""))
}
fn read_quoted(s: &str) -> Option<(String, &str)> {
let s = s.trim_start();
let mut chars = s.char_indices();
if !matches!(chars.next(), Some((_, '"'))) {
return None;
}
let mut out = String::new();
while let Some((i, c)) = chars.next() {
match c {
'\\' => {
if let Some((_, next)) = chars.next() {
out.push(next);
}
}
'"' => return Some((out, &s[i + 1..])),
other => out.push(other),
}
}
None
}
fn convert_emphasis(line: &str) -> String {
let mut out = String::with_capacity(line.len() + 8);
let mut chars = line.chars().peekable();
while let Some(c) = chars.next() {
match c {
'*' => {
let mut body = String::new();
let mut closed = false;
for d in chars.by_ref() {
if d == '*' {
closed = true;
break;
}
body.push(d);
}
if closed && !body.is_empty() {
out.push_str("**");
out.push_str(&body);
out.push_str("**");
} else {
out.push('*');
out.push_str(&body);
}
}
'_' => {
let mut body = String::new();
let mut closed = false;
for d in chars.by_ref() {
if d == '_' {
closed = true;
break;
}
body.push(d);
}
if closed && !body.is_empty() {
out.push('*');
out.push_str(&body);
out.push('*');
} else {
out.push('_');
out.push_str(&body);
}
}
other => out.push(other),
}
}
out
}
fn single_line_raw_inner(line: &str) -> Option<String> {
let open = line.find('(')?;
let mut depth = 0i32;
let mut close = None;
for (i, c) in line.char_indices().skip_while(|&(i, _)| i < open) {
match c {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
close = Some(i);
break;
}
}
_ => {}
}
}
let close = close?;
let inner = line[open + 1..close].trim();
let inner = inner
.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.unwrap_or(inner);
Some(inner.to_string())
}
fn convert_figure_image(line: &str) -> Option<String> {
let trimmed = line.trim_start();
if !trimmed.starts_with("#figure(") {
return None;
}
let img_idx = trimmed.find("image(")?;
let after = &trimmed[img_idx + "image(".len()..];
let (path, _) = read_quoted(after)?;
let mut alt = String::new();
if let Some(idx) = trimmed.find("caption:") {
let after_cap = trimmed[idx + "caption:".len()..].trim_start();
if let Some((cap, _)) = read_quoted(after_cap) {
alt = cap;
} else if let Some(rest) = after_cap.strip_prefix('[') {
if let Some((cap, _)) = read_bracketed(rest) {
alt = cap.trim().to_string();
}
}
}
Some(format!("", alt.trim(), path))
}
fn read_bracketed(s: &str) -> Option<(String, &str)> {
let mut depth = 1i32;
for (i, c) in s.char_indices() {
match c {
'[' => depth += 1,
']' => {
depth -= 1;
if depth == 0 {
return Some((s[..i].to_string(), &s[i + c.len_utf8()..]));
}
}
_ => {}
}
}
None
}
fn extract_footnotes(text: &str, notes: &mut Vec<String>) -> String {
const OPEN: &str = "#footnote[";
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(pos) = rest.find(OPEN) {
out.push_str(&rest[..pos]);
let after = &rest[pos + OPEN.len()..];
match read_bracketed(after) {
Some((body, tail)) => {
notes.push(convert_emphasis(&convert_refs(&body)));
out.push_str(&format!("[^{}]", notes.len()));
rest = tail;
}
None => {
out.push_str(&rest[pos..pos + OPEN.len()]);
rest = after;
}
}
}
out.push_str(rest);
out
}
fn is_ref_char(c: char) -> bool {
c.is_alphanumeric() || matches!(c, '-' | '_' | ':')
}
fn convert_refs(text: &str) -> String {
let chars: Vec<char> = text.chars().collect();
let mut out = String::with_capacity(text.len() + 8);
let mut i = 0;
while i < chars.len() {
let c = chars[i];
let boundary =
i == 0 || matches!(chars[i - 1], ' ' | '\t' | '(' | '[' | '"' | '\u{201c}' | '\u{ab}');
if c == '@' && boundary && chars.get(i + 1).is_some_and(|d| d.is_alphabetic()) {
let mut j = i + 1;
while j < chars.len() && is_ref_char(chars[j]) {
j += 1;
}
let key: String = chars[i + 1..j].iter().collect();
if chars.get(j) == Some(&'[') {
if let Some(close) = chars[j + 1..].iter().position(|&d| d == ']') {
let locus: String = chars[j + 1..j + 1 + close].iter().collect();
out.push_str(&format!("[@{key}, {}]", locus.trim()));
i = j + 1 + close + 1;
continue;
}
}
out.push_str(&format!("[@{key}]"));
i = j;
continue;
}
out.push(c);
i += 1;
}
out
}
fn convert_inline(text: &str, notes: &mut Vec<String>) -> String {
let no_fn = extract_footnotes(text, notes);
convert_emphasis(&convert_refs(&no_fn))
}
pub fn typst_to_markdown(input: &str) -> String {
let mut out = String::with_capacity(input.len() + 64);
let mut in_raw_block = false;
let mut footnotes: Vec<String> = Vec::new();
for raw_line in input.lines() {
let trimmed = raw_line.trim();
if !in_raw_block && (trimmed.starts_with("#raw(") || trimmed == "#raw(block:true)") {
if let Some(inner) = single_line_raw_inner(trimmed) {
if inner.contains('`') {
out.push_str("`` ");
out.push_str(&inner);
out.push_str(" ``\n");
} else {
out.push('`');
out.push_str(&inner);
out.push_str("`\n");
}
continue;
}
in_raw_block = true;
out.push_str("```\n");
continue;
}
if in_raw_block && trimmed == ")" {
in_raw_block = false;
out.push_str("```\n");
continue;
}
if in_raw_block {
out.push_str(raw_line);
out.push('\n');
continue;
}
if let Some((level, rest)) = line_is_heading(raw_line) {
for _ in 0..level {
out.push('#');
}
out.push(' ');
out.push_str(&convert_inline(rest, &mut footnotes));
out.push('\n');
continue;
}
if let Some(img) = convert_image_call(raw_line).or_else(|| convert_figure_image(raw_line)) {
out.push_str(&img);
out.push('\n');
continue;
}
if let Some(rest) = raw_line.strip_prefix("- ") {
out.push_str("- ");
out.push_str(&convert_inline(rest, &mut footnotes));
out.push('\n');
continue;
}
if let Some(rest) = raw_line.strip_prefix("+ ") {
out.push_str("1. ");
out.push_str(&convert_inline(rest, &mut footnotes));
out.push('\n');
continue;
}
if raw_line.trim_start().starts_with('#') && !raw_line.trim_start().starts_with("#!") {
out.push('`');
out.push_str(raw_line);
out.push('`');
out.push('\n');
continue;
}
out.push_str(&convert_inline(raw_line, &mut footnotes));
out.push('\n');
}
if in_raw_block {
out.push_str("```\n");
}
if !footnotes.is_empty() {
out.push('\n');
for (i, body) in footnotes.iter().enumerate() {
out.push_str(&format!("[^{}]: {body}\n", i + 1));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn read_quoted_preserves_non_ascii() {
let (s, rest) = read_quoted("\"café Москва\" tail").unwrap();
assert_eq!(s, "café Москва");
assert_eq!(rest, " tail");
let (s2, _) = read_quoted("\"a\\\"b é\"").unwrap();
assert_eq!(s2, "a\"b é");
}
#[test]
fn single_line_raw_does_not_swallow_following_content() {
let md = typst_to_markdown("#raw(\"x = 1\")\n= Chapter Two\nbody\n");
assert!(md.contains("`x = 1`"), "raw should be inline: {md:?}");
assert!(md.contains("# Chapter Two"), "heading must survive: {md:?}");
assert!(!md.contains("```"), "no fence should open: {md:?}");
}
#[test]
fn single_line_raw_escapes_inner_backtick() {
let md = typst_to_markdown("#raw(\"a`b\")\n");
assert!(md.contains("`` a`b ``"), "backtick must be fenced wider: {md:?}");
}
#[test]
fn multiline_raw_block_still_fences() {
let md = typst_to_markdown("#raw(\ncode line\n)\n");
assert!(md.contains("```"), "multi-line raw should fence: {md:?}");
assert!(md.contains("code line"));
}
#[test]
fn headings_three_levels() {
let md = typst_to_markdown("= H1\n== H2\n=== H3\n");
assert!(md.contains("# H1"));
assert!(md.contains("## H2"));
assert!(md.contains("### H3"));
}
#[test]
fn bold_and_italic() {
let md = typst_to_markdown("*bold* and _italic_ words.\n");
assert!(md.contains("**bold**"));
assert!(md.contains("*italic*"));
}
#[test]
fn image_with_caption() {
let md = typst_to_markdown("#image(\"img/foo.png\", caption: \"Foo\")\n");
assert!(md.contains(""));
}
#[test]
fn unknown_directive_quoted() {
let md = typst_to_markdown("#set page(width: 10cm)\n");
assert!(md.contains("`#set page(width: 10cm)`"));
}
#[test]
fn figure_image_with_content_caption() {
let md = typst_to_markdown("#figure(image(\"img/map.png\"), caption: [The Reach])\n");
assert!(md.contains(""), "{md:?}");
assert!(!md.contains('`'), "no literal-code leak: {md:?}");
let md2 = typst_to_markdown("#figure(image(\"a.png\"), caption: \"Cap\")\n");
assert!(md2.contains(""), "{md2:?}");
}
#[test]
fn inline_footnote_becomes_marker_plus_definition() {
let md = typst_to_markdown("The bell rang#footnote[At dawn.] once.\n");
assert!(md.contains("The bell rang[^1] once."), "marker in place: {md:?}");
assert!(md.contains("[^1]: At dawn."), "definition emitted: {md:?}");
assert!(!md.contains("#footnote"), "no literal footnote source: {md:?}");
}
#[test]
fn footnote_body_may_contain_brackets_and_emphasis() {
let md = typst_to_markdown("x#footnote[see _note_ [ok]] y\n");
assert!(md.contains("x[^1] y"), "{md:?}");
assert!(md.contains("[^1]: see *note* [ok]"), "{md:?}");
}
#[test]
fn references_become_pandoc_citations() {
let md = typst_to_markdown("As in @einstein1905[p. 4] and see @fig-map here.\n");
assert!(md.contains("[@einstein1905, p. 4]"), "cite with locus: {md:?}");
assert!(md.contains("[@fig-map]"), "bare ref: {md:?}");
let md2 = typst_to_markdown("email a@b later\n");
assert!(md2.contains("a@b"), "non-boundary @ untouched: {md2:?}");
}
}