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 bytes = s.as_bytes();
if bytes.first().copied() != Some(b'"') {
return None;
}
let mut out = String::new();
let mut i = 1;
while i < bytes.len() {
match bytes[i] {
b'\\' if i + 1 < bytes.len() => {
out.push(bytes[i + 1] as char);
i += 2;
}
b'"' => return Some((out, &s[i + 1..])),
c => {
out.push(c as char);
i += 1;
}
}
}
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())
}
pub fn typst_to_markdown(input: &str) -> String {
let mut out = String::with_capacity(input.len() + 64);
let mut in_raw_block = false;
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_emphasis(rest));
out.push('\n');
continue;
}
if let Some(img) = convert_image_call(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_emphasis(rest));
out.push('\n');
continue;
}
if let Some(rest) = raw_line.strip_prefix("+ ") {
out.push_str("1. ");
out.push_str(&convert_emphasis(rest));
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_emphasis(raw_line));
out.push('\n');
}
if in_raw_block {
out.push_str("```\n");
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[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)`"));
}
}