use std::collections::HashSet;
use std::ops::Range;
use std::time::Instant;
use log::info;
use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
use super::markup_util::{
escape_typst, escape_typst_string_literal, typst_image, typst_image_placeholder,
};
fn latex_to_typst_math(latex: &str) -> String {
match mitex::convert_math(latex, None) {
Ok(typst_math) => typst_math,
Err(_) => {
latex.to_string()
}
}
}
#[derive(Debug, Clone)]
pub struct BlockMapping {
pub typst_byte_range: Range<usize>,
pub md_byte_range: Range<usize>,
}
#[derive(Debug, Clone)]
pub struct SourceMap {
pub blocks: Vec<BlockMapping>,
}
impl SourceMap {
pub fn find_by_typst_offset(&self, typst_offset: usize) -> Option<&BlockMapping> {
let idx = self
.blocks
.binary_search_by(|b| {
if typst_offset < b.typst_byte_range.start {
std::cmp::Ordering::Greater
} else if typst_offset >= b.typst_byte_range.end {
std::cmp::Ordering::Less
} else {
std::cmp::Ordering::Equal
}
})
.ok()?;
Some(&self.blocks[idx])
}
}
#[derive(Debug)]
enum Container {
Heading,
Strong,
Emphasis,
Strikethrough,
Link { _url: String },
Image { path: String },
BlockQuote,
BlockQuoteCapped,
List { ordered: bool },
Item,
CodeBlock,
Table { _col_count: usize },
TableHead,
TableRow,
TableCell,
}
const MAX_BLOCKQUOTE_DEPTH: usize = 10;
pub fn extract_image_paths(markdown: &str) -> Vec<String> {
let mut options = Options::empty();
options.insert(Options::ENABLE_TABLES);
options.insert(Options::ENABLE_STRIKETHROUGH);
options.insert(Options::ENABLE_MATH);
let parser = Parser::new_ext(markdown, options);
let mut paths = Vec::new();
let mut seen = HashSet::new();
for event in parser {
match event {
Event::Start(Tag::Image { dest_url, .. }) => {
let url = dest_url.to_string();
if !url.is_empty() && seen.insert(url.clone()) {
paths.push(url);
}
}
Event::Html(html) | Event::InlineHtml(html) => {
for src in super::markup_html::extract_img_srcs(&html) {
if seen.insert(src.clone()) {
paths.push(src);
}
}
}
_ => {}
}
}
paths
}
pub fn markdown_to_typst(
markdown: &str,
available_images: Option<&HashSet<String>>,
) -> (String, SourceMap) {
let start = Instant::now();
let mut options = Options::empty();
options.insert(Options::ENABLE_TABLES);
options.insert(Options::ENABLE_STRIKETHROUGH);
options.insert(Options::ENABLE_MATH);
let parser = Parser::new_ext(markdown, options);
let mut output = String::new();
let mut stack: Vec<Container> = Vec::new();
let mut in_code_block = false;
let mut code_block_buf = String::new();
let mut code_block_lang = String::new();
let mut cell_buf: Option<String> = None;
let mut table_cells: Vec<String> = Vec::new();
let mut table_col_count: usize = 0;
let mut source_map_blocks: Vec<BlockMapping> = Vec::new();
let mut block_starts: Vec<(usize, Range<usize>)> = Vec::new();
let mut block_depth: usize = 0;
for (event, md_range) in parser.into_offset_iter() {
match event {
Event::Start(Tag::Paragraph) => {
if block_depth == 0 {
block_starts.push((output.len(), md_range));
}
block_depth += 1;
let in_item = stack.iter().any(|c| matches!(c, Container::Item));
if in_item {
if !output.ends_with("- ") && !output.ends_with("+ ") {
if !output.ends_with('\n') {
output.push('\n');
}
output.push('\n');
let list_depth = stack
.iter()
.filter(|c| matches!(c, Container::List { .. }))
.count();
let indent = list_depth.saturating_sub(1) * 2 + 2;
for _ in 0..indent {
output.push(' ');
}
}
} else {
if !output.is_empty() && !output.ends_with('\n') {
output.push('\n');
}
if !output.is_empty() {
output.push('\n');
}
}
}
Event::Start(Tag::Heading { level, .. }) => {
if block_depth == 0 {
block_starts.push((output.len(), md_range));
}
block_depth += 1;
if !output.is_empty() && !output.ends_with('\n') {
output.push('\n');
}
if !output.is_empty() {
output.push('\n');
}
let prefix = "=".repeat(level as usize);
output.push_str(&prefix);
output.push(' ');
stack.push(Container::Heading);
}
Event::Start(Tag::BlockQuote(_)) => {
if block_depth == 0 {
block_starts.push((output.len(), md_range));
}
block_depth += 1;
if !output.is_empty() && !output.ends_with('\n') {
output.push('\n');
}
if !output.is_empty() {
output.push('\n');
}
let bq_depth = stack
.iter()
.filter(|c| matches!(c, Container::BlockQuote | Container::BlockQuoteCapped))
.count();
if bq_depth < MAX_BLOCKQUOTE_DEPTH {
output.push_str("#quote(block: true)[");
stack.push(Container::BlockQuote);
} else {
stack.push(Container::BlockQuoteCapped);
}
}
Event::Start(Tag::CodeBlock(kind)) => {
if block_depth == 0 {
block_starts.push((output.len(), md_range));
}
block_depth += 1;
if !output.is_empty() && !output.ends_with('\n') {
output.push('\n');
}
if !output.is_empty() {
output.push('\n');
}
in_code_block = true;
code_block_lang = match &kind {
CodeBlockKind::Fenced(lang) if !lang.is_empty() => lang.to_string(),
_ => String::new(),
};
code_block_buf.clear();
stack.push(Container::CodeBlock);
}
Event::Start(Tag::List(start)) => {
if block_depth == 0 {
block_starts.push((output.len(), md_range));
}
block_depth += 1;
let is_nested = stack.iter().any(|c| matches!(c, Container::List { .. }));
if is_nested {
if !output.is_empty() && !output.ends_with('\n') {
output.push('\n');
}
} else {
if !output.is_empty() && !output.ends_with('\n') {
output.push('\n');
}
if !output.is_empty() {
output.push('\n');
}
}
stack.push(Container::List {
ordered: start.is_some(),
});
}
Event::Start(Tag::Item) => {
if !output.is_empty() && !output.ends_with('\n') {
output.push('\n');
}
let depth = stack
.iter()
.filter(|c| matches!(c, Container::List { .. }))
.count()
.saturating_sub(1)
.min(7);
for _ in 0..depth {
output.push_str(" ");
}
let marker = stack
.iter()
.rev()
.find_map(|c| match c {
Container::List { ordered: true } => Some("+ "),
Container::List { ordered: false } => Some("- "),
_ => None,
})
.unwrap_or("- ");
output.push_str(marker);
stack.push(Container::Item);
}
Event::Start(Tag::Table(alignments)) => {
if block_depth == 0 {
block_starts.push((output.len(), md_range));
}
block_depth += 1;
if !output.is_empty() && !output.ends_with('\n') {
output.push('\n');
}
if !output.is_empty() {
output.push('\n');
}
table_col_count = alignments.len();
table_cells.clear();
stack.push(Container::Table {
_col_count: table_col_count,
});
}
Event::Start(Tag::TableHead) => {
stack.push(Container::TableHead);
}
Event::Start(Tag::TableRow) => {
stack.push(Container::TableRow);
}
Event::Start(Tag::TableCell) => {
cell_buf = Some(String::new());
stack.push(Container::TableCell);
}
Event::Start(Tag::Strong) => {
push_to_target(&mut output, &mut cell_buf, "#strong[");
stack.push(Container::Strong);
}
Event::Start(Tag::Emphasis) => {
push_to_target(&mut output, &mut cell_buf, "#emph[");
stack.push(Container::Emphasis);
}
Event::Start(Tag::Strikethrough) => {
push_to_target(&mut output, &mut cell_buf, "#strike[");
stack.push(Container::Strikethrough);
}
Event::Start(Tag::Link { dest_url, .. }) => {
let url = dest_url.to_string();
if !url.is_empty() {
let escaped_url = escape_typst_string_literal(&url);
push_to_target(
&mut output,
&mut cell_buf,
&format!("#link(\"{escaped_url}\")["),
);
}
stack.push(Container::Link { _url: url });
}
Event::Start(Tag::Image { dest_url, .. }) => {
stack.push(Container::Image {
path: dest_url.to_string(),
});
}
Event::End(TagEnd::Paragraph) => {
if !output.ends_with('\n') {
output.push('\n');
}
block_depth -= 1;
if block_depth == 0
&& let Some((typst_start, md_range_start)) = block_starts.pop()
{
source_map_blocks.push(BlockMapping {
typst_byte_range: typst_start..output.len(),
md_byte_range: md_range_start,
});
}
}
Event::End(TagEnd::Heading(_)) => {
if !output.ends_with('\n') {
output.push('\n');
}
pop_expect(&mut stack, "Heading");
block_depth -= 1;
if block_depth == 0
&& let Some((typst_start, md_range_start)) = block_starts.pop()
{
source_map_blocks.push(BlockMapping {
typst_byte_range: typst_start..output.len(),
md_byte_range: md_range_start,
});
}
}
Event::End(TagEnd::BlockQuote(_)) => {
match stack.pop() {
Some(Container::BlockQuote) => {
let trimmed = output.trim_end().len();
output.truncate(trimmed);
output.push_str("]\n");
}
Some(Container::BlockQuoteCapped) => {
}
other => {
debug_assert!(false, "expected BlockQuote/BlockQuoteCapped, got {other:?}");
}
}
block_depth -= 1;
if block_depth == 0
&& let Some((typst_start, md_range_start)) = block_starts.pop()
{
source_map_blocks.push(BlockMapping {
typst_byte_range: typst_start..output.len(),
md_byte_range: md_range_start,
});
}
}
Event::End(TagEnd::CodeBlock) => {
in_code_block = false;
if code_block_lang == "mermaid" {
let key = crate::diagram::diagram_key(&code_block_buf);
let is_available = available_images.is_some_and(|set| set.contains(&key));
if is_available {
push_to_target(&mut output, &mut cell_buf, &typst_image(&key));
} else {
let fence_len = max_backtick_run(&code_block_buf).max(2) + 1;
let fence: String = "`".repeat(fence_len);
push_to_target(&mut output, &mut cell_buf, &fence);
push_to_target(&mut output, &mut cell_buf, "mermaid");
push_to_target(&mut output, &mut cell_buf, "\n");
push_to_target(&mut output, &mut cell_buf, &code_block_buf);
if !code_block_buf.ends_with('\n') {
push_to_target(&mut output, &mut cell_buf, "\n");
}
push_to_target(&mut output, &mut cell_buf, &fence);
push_to_target(&mut output, &mut cell_buf, "\n");
}
} else {
let fence_len = max_backtick_run(&code_block_buf).max(2) + 1;
let fence: String = "`".repeat(fence_len);
push_to_target(&mut output, &mut cell_buf, &fence);
push_to_target(&mut output, &mut cell_buf, &code_block_lang);
push_to_target(&mut output, &mut cell_buf, "\n");
push_to_target(&mut output, &mut cell_buf, &code_block_buf);
if !code_block_buf.ends_with('\n') {
push_to_target(&mut output, &mut cell_buf, "\n");
}
push_to_target(&mut output, &mut cell_buf, &fence);
push_to_target(&mut output, &mut cell_buf, "\n");
}
code_block_buf.clear();
code_block_lang.clear();
pop_expect(&mut stack, "CodeBlock");
block_depth -= 1;
if block_depth == 0
&& let Some((typst_start, md_range_start)) = block_starts.pop()
{
source_map_blocks.push(BlockMapping {
typst_byte_range: typst_start..output.len(),
md_byte_range: md_range_start,
});
}
}
Event::End(TagEnd::List(_)) => {
pop_expect(&mut stack, "List");
block_depth -= 1;
if block_depth == 0
&& let Some((typst_start, md_range_start)) = block_starts.pop()
{
source_map_blocks.push(BlockMapping {
typst_byte_range: typst_start..output.len(),
md_byte_range: md_range_start,
});
}
}
Event::End(TagEnd::Item) => {
if !output.ends_with('\n') {
output.push('\n');
}
pop_expect(&mut stack, "Item");
}
Event::End(TagEnd::Table) => {
let table_typst_start = output.len();
output.push_str(&format!("#table(columns: {table_col_count},\n"));
for cell in &table_cells {
output.push_str(&format!(" [{cell}],\n"));
}
output.push_str(")\n");
table_cells.clear();
pop_expect(&mut stack, "Table");
block_depth -= 1;
if block_depth == 0
&& let Some((_typst_start, md_range_start)) = block_starts.pop()
{
source_map_blocks.push(BlockMapping {
typst_byte_range: table_typst_start..output.len(),
md_byte_range: md_range_start,
});
}
}
Event::End(TagEnd::TableHead) => {
pop_expect(&mut stack, "TableHead");
}
Event::End(TagEnd::TableRow) => {
pop_expect(&mut stack, "TableRow");
}
Event::End(TagEnd::TableCell) => {
if let Some(buf) = cell_buf.take() {
table_cells.push(buf);
}
pop_expect(&mut stack, "TableCell");
}
Event::End(TagEnd::Strong) => {
push_to_target(&mut output, &mut cell_buf, "]");
pop_expect(&mut stack, "Strong");
}
Event::End(TagEnd::Emphasis) => {
push_to_target(&mut output, &mut cell_buf, "]");
pop_expect(&mut stack, "Emphasis");
}
Event::End(TagEnd::Strikethrough) => {
push_to_target(&mut output, &mut cell_buf, "]");
pop_expect(&mut stack, "Strikethrough");
}
Event::End(TagEnd::Link) => {
match stack.pop() {
Some(Container::Link { _url }) if !_url.is_empty() => {
push_to_target(&mut output, &mut cell_buf, "]");
}
Some(Container::Link { .. }) => {
}
other => {
debug_assert!(false, "Expected Link, got {other:?}");
}
}
}
Event::End(TagEnd::Image) => match stack.pop() {
Some(Container::Image { path }) => {
let is_available = available_images.is_some_and(|set| set.contains(&path));
if is_available {
push_to_target(&mut output, &mut cell_buf, &typst_image(&path));
} else {
push_to_target(&mut output, &mut cell_buf, &typst_image_placeholder(&path));
}
}
other => {
debug_assert!(false, "Expected Image, got {other:?}");
}
},
Event::Text(text) => {
if stack.iter().any(|c| matches!(c, Container::Image { .. })) {
continue;
}
if in_code_block {
let text = fill_blank_lines(&text);
code_block_buf.push_str(&text);
} else if cell_buf.is_some() {
let escaped = escape_typst(&text);
cell_buf.as_mut().unwrap().push_str(&escaped);
} else {
output.push_str(&escape_typst(&text));
}
}
Event::Code(code) => {
let s = if code.contains('`') {
let escaped = code.replace('\\', "\\\\").replace('"', "\\\"");
format!("#raw(\"{}\")", escaped)
} else {
format!("`{code}`")
};
push_to_target(&mut output, &mut cell_buf, &s);
}
Event::SoftBreak => {
if in_code_block {
code_block_buf.push('\n');
} else if cell_buf.is_some() {
cell_buf.as_mut().unwrap().push('\n');
} else {
output.push('\n');
let in_item = stack.iter().any(|c| matches!(c, Container::Item));
if in_item {
let list_depth = stack
.iter()
.filter(|c| matches!(c, Container::List { .. }))
.count();
let indent = list_depth.saturating_sub(1) * 2 + 2;
for _ in 0..indent {
output.push(' ');
}
}
}
}
Event::HardBreak => {
push_to_target(&mut output, &mut cell_buf, "\\ \n");
}
Event::Rule => {
let rule_start = output.len();
if !output.is_empty() && !output.ends_with('\n') {
output.push('\n');
}
if !output.is_empty() {
output.push('\n');
}
output.push_str("#line(length: 100%)\n");
if block_depth == 0 {
source_map_blocks.push(BlockMapping {
typst_byte_range: rule_start..output.len(),
md_byte_range: md_range,
});
}
}
Event::InlineMath(latex) => {
let typst_math = latex_to_typst_math(&latex);
let s = format!("${typst_math}$");
push_to_target(&mut output, &mut cell_buf, &s);
}
Event::DisplayMath(latex) => {
if block_depth == 0 {
block_starts.push((output.len(), md_range));
}
if !output.is_empty() && !output.ends_with('\n') {
output.push('\n');
}
if !output.is_empty() {
output.push('\n');
}
let typst_math = latex_to_typst_math(&latex);
output.push_str(&format!("$ {typst_math} $\n"));
if block_depth == 0
&& let Some((typst_start, md_range_start)) = block_starts.pop()
{
source_map_blocks.push(BlockMapping {
typst_byte_range: typst_start..output.len(),
md_byte_range: md_range_start,
});
}
}
Event::Html(html) | Event::InlineHtml(html) => {
let snippets = super::markup_html::render_html_imgs(&html, available_images);
if !snippets.is_empty() {
if block_depth == 0 {
block_starts.push((output.len(), md_range));
}
for s in &snippets {
push_to_target(&mut output, &mut cell_buf, s);
}
if block_depth == 0
&& let Some((typst_start, md_range_start)) = block_starts.pop()
{
source_map_blocks.push(BlockMapping {
typst_byte_range: typst_start..output.len(),
md_byte_range: md_range_start,
});
}
}
}
_ => {}
}
}
if !output.is_empty() && !output.ends_with('\n') {
output.push('\n');
}
let source_map = SourceMap {
blocks: source_map_blocks,
};
info!(
"convert: completed in {:.1}ms (input: {} bytes, output: {} bytes)",
start.elapsed().as_secs_f64() * 1000.0,
markdown.len(),
output.len()
);
(output, source_map)
}
fn push_to_target(output: &mut String, cell_buf: &mut Option<String>, s: &str) {
if let Some(buf) = cell_buf.as_mut() {
buf.push_str(s);
} else {
output.push_str(s);
}
}
fn pop_expect(stack: &mut Vec<Container>, expected: &str) {
if let Some(container) = stack.pop() {
debug_assert!(
matches!(
(&container, expected),
(Container::Heading, "Heading")
| (Container::Strong, "Strong")
| (Container::Emphasis, "Emphasis")
| (Container::Strikethrough, "Strikethrough")
| (Container::Link { .. }, "Link")
| (Container::Image { .. }, "Image")
| (Container::BlockQuote, "BlockQuote")
| (Container::BlockQuoteCapped, "BlockQuote")
| (Container::List { .. }, "List")
| (Container::Item, "Item")
| (Container::CodeBlock, "CodeBlock")
| (Container::Table { .. }, "Table")
| (Container::TableHead, "TableHead")
| (Container::TableRow, "TableRow")
| (Container::TableCell, "TableCell")
),
"Expected {expected}, got {container:?}"
);
}
}
fn max_backtick_run(s: &str) -> usize {
let mut max = 0;
let mut current = 0;
for ch in s.chars() {
if ch == '`' {
current += 1;
if current > max {
max = current;
}
} else {
current = 0;
}
}
max
}
fn fill_blank_lines(text: &str) -> String {
let lines: Vec<&str> = text.split('\n').collect();
let mut result = String::with_capacity(text.len() + lines.len());
for (i, line) in lines.iter().enumerate() {
if i > 0 {
result.push('\n');
}
if line.is_empty() && i < lines.len() - 1 {
result.push(' ');
} else {
result.push_str(line);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
fn md_to_typst(s: &str) -> String {
markdown_to_typst(s, None).0
}
#[test]
fn test_plain_text() {
let md = "Hello, world!";
let typst = md_to_typst(md);
assert_eq!(typst, "Hello, world!\n");
}
#[test]
fn test_emph_followed_by_paren() {
let md = "**Note*(: text";
let typst = md_to_typst(md);
assert!(
!typst.contains("]("),
"'](' は Typst が関数引数と解釈するため不可: {typst}"
);
assert!(
typst.contains("]\\("),
"']' の直後の '(' は '\\(' にエスケープされるべき: {typst}"
);
}
#[test]
fn test_bracket_in_heading() {
let md = "## エanguage](https://docs.invalid/book/) を参照。";
let typst = md_to_typst(md);
assert!(
!typst.contains("]("),
"'](' は Typst がコンテントブロック閉じと解釈するため不可: {typst}"
);
assert!(
typst.contains("\\]"),
"テキスト中の ']' は '\\]' にエスケープされるべき: {typst}"
);
}
#[test]
fn test_brackets_in_text() {
let md = "配列 arr[0] と [注釈] を含む文";
let typst = md_to_typst(md);
assert!(
typst.contains("arr\\[0\\]"),
"テキスト中の角括弧はエスケープされるべき: {typst}"
);
assert!(
typst.contains("\\[注釈\\]"),
"テキスト中の角括弧はエスケープされるべき: {typst}"
);
}
#[test]
fn test_link_text_with_bracket() {
let md = "[foo]bar](https://example.invalid/)";
let typst = md_to_typst(md);
assert!(
!typst.contains("bar]("),
"リンクテキスト内の ']' は '\\]' にエスケープされるべき: {typst}"
);
}
#[test]
fn test_parens_in_text() {
let md = "関数 foo(x, y) を呼ぶ";
let typst = md_to_typst(md);
assert!(
typst.contains("foo\\(x, y\\)"),
"テキスト中の括弧はエスケープされるべき: {typst}"
);
}
#[test]
fn test_strong_followed_by_paren() {
let md = "**bold** (note)";
let typst = md_to_typst(md);
assert!(
!typst.contains("]("),
"'](' は Typst が関数引数と解釈するため不可: {typst}"
);
}
#[test]
fn test_soft_break() {
let md = "line1\nline2";
let typst = md_to_typst(md);
assert_eq!(typst, "line1\nline2\n");
}
#[test]
fn test_hard_break() {
let md = "line1 \nline2";
let typst = md_to_typst(md);
assert_eq!(typst, "line1\\ \nline2\n");
}
#[test]
fn test_japanese_text() {
let md = "日本語のテスト。句読点「、」も正しく処理される。";
let typst = md_to_typst(md);
assert!(typst.contains("日本語のテスト。"));
}
#[test]
fn test_multiple_paragraphs() {
let md = "段落1。\n\n段落2。";
let typst = md_to_typst(md);
assert_eq!(typst, "段落1。\n\n段落2。\n");
}
#[test]
fn test_heading() {
let md = "# Title\n\n## Subtitle";
let typst = md_to_typst(md);
assert!(typst.contains("= Title\n"));
assert!(typst.contains("== Subtitle\n"));
}
#[test]
fn test_bold_italic() {
let md = "**bold** and *italic*";
let typst = md_to_typst(md);
assert!(typst.contains("#strong[bold]"));
assert!(typst.contains("#emph[italic]"));
}
#[test]
fn test_emphasis_function_syntax() {
let md = "**Note*ks*: hello";
let typst = md_to_typst(md);
assert!(
typst.contains("#emph[ks]"),
"should use #emph[] function syntax, got: {typst}"
);
assert!(
!typst.contains("_ks_"),
"should not produce _..._ delimiters, got: {typst}"
);
}
#[test]
fn test_strikethrough() {
let md = "~~deleted~~";
let typst = md_to_typst(md);
assert!(typst.contains("#strike[deleted]"));
}
#[test]
fn test_inline_code() {
let md = "Use `Result<T, E>` type";
let typst = md_to_typst(md);
assert!(typst.contains("`Result<T, E>`"));
}
#[test]
fn test_inline_code_with_backticks() {
let md = "Use `` ` `` in code";
let typst = md_to_typst(md);
assert!(
typst.contains("#raw(\"`\")"),
"expected #raw() call for backtick-containing code, got: {typst}"
);
}
#[test]
fn test_inline_code_with_triple_backticks() {
let md = "` ``` `";
let typst = md_to_typst(md);
assert!(
typst.contains("#raw(\"```\")"),
"expected #raw() for triple backticks, got: {typst}"
);
assert!(
!typst.contains("`````"),
"should not produce raw backtick delimiters, got: {typst}"
);
}
#[test]
fn test_inline_code_with_backticks_in_table() {
let md = "| Header |\n|--------|\n| `` ` `` |";
let typst = md_to_typst(md);
assert!(
typst.contains("#table("),
"expected table markup, got: {typst}"
);
assert!(
typst.contains("#raw(\"`\")"),
"expected #raw() in table cell, got: {typst}"
);
}
#[test]
fn test_link() {
let md = "[Rust](https://rust.invalid/)";
let typst = md_to_typst(md);
assert!(typst.contains("#link(\"https://rust.invalid/\")[Rust]"));
}
#[test]
fn test_link_empty_url() {
let md = "[link]()";
let typst = md_to_typst(md);
assert!(
!typst.contains("#link"),
"empty URL should not produce #link"
);
assert!(typst.contains("link"));
}
#[test]
fn test_link_url_with_quote() {
let md = "[t](https://x.invalid/?q=\"v\")";
let typst = md_to_typst(md);
assert!(
typst.contains("\\\""),
"double quote in URL must be escaped: {typst}"
);
assert!(
typst.contains("#link(\"https://x.invalid/?q=\\\"v\\\"\")[t]"),
"unexpected output: {typst}"
);
}
#[test]
fn test_link_url_injection_attempt() {
let md = "[t](https://x.invalid/\"#evil)";
let typst = md_to_typst(md);
assert!(
typst.contains("\\\"#evil"),
"quote must be escaped as \\\", got: {typst}"
);
assert!(
typst.contains(")[t]"),
"link must be properly closed, got: {typst}"
);
}
#[test]
fn test_code_block() {
let md = "```rust\nfn main() {}\n```";
let typst = md_to_typst(md);
assert!(typst.contains("```rust\nfn main() {}\n```"));
}
#[test]
fn test_unordered_list() {
let md = "- item1\n- item2";
let typst = md_to_typst(md);
assert!(typst.contains("- item1\n"));
assert!(typst.contains("- item2\n"));
}
#[test]
fn test_ordered_list() {
let md = "1. first\n2. second";
let typst = md_to_typst(md);
assert!(typst.contains("+ first\n"));
assert!(typst.contains("+ second\n"));
}
#[test]
fn test_blockquote() {
let md = "> quoted text";
let typst = md_to_typst(md);
assert!(typst.contains("#quote(block: true)["));
assert!(typst.contains("quoted text"));
}
#[test]
fn test_horizontal_rule() {
let md = "before\n\n---\n\nafter";
let typst = md_to_typst(md);
assert!(typst.contains("#line(length: 100%)"));
}
#[test]
fn test_rule_inside_list() {
let md = "+\t---\t\t";
let typst = md_to_typst(md);
assert!(typst.contains("- "), "should produce unordered list marker");
assert!(
typst.contains("#line(length: 100%)"),
"should produce horizontal rule"
);
}
#[test]
fn test_rule_inside_list_source_map() {
let md = "+\t---\t\t";
let (typst, map) = markdown_to_typst(md, None);
for pair in map.blocks.windows(2) {
assert!(
pair[0].typst_byte_range.end <= pair[1].typst_byte_range.start,
"overlapping typst ranges: {:?} and {:?}",
pair[0].typst_byte_range,
pair[1].typst_byte_range,
);
}
for block in &map.blocks {
assert!(
block.typst_byte_range.end <= typst.len(),
"typst_byte_range {:?} out of bounds",
block.typst_byte_range,
);
}
}
#[test]
fn test_table() {
let md = "| A | B |\n|---|---|\n| 1 | 2 |";
let typst = md_to_typst(md);
assert!(typst.contains("#table(columns: 2,"));
assert!(typst.contains("[A]"));
assert!(typst.contains("[B]"));
assert!(typst.contains("[1]"));
assert!(typst.contains("[2]"));
}
#[test]
fn test_code_block_no_escape() {
let md = "```\n#hello *world* $100\n```";
let typst = md_to_typst(md);
assert!(
typst.contains("#hello *world* $100"),
"Code block content should not be escaped, got: {typst}"
);
}
#[test]
fn test_code_block_blank_lines_filled() {
let md = "```\nline1\n\nline3\n```";
let typst = md_to_typst(md);
assert!(
typst.contains("line1\n \nline3"),
"Blank lines in code blocks should be filled with a space, got: {typst}"
);
}
#[test]
fn test_code_block_multiple_blank_lines() {
let md = "```\nline1\n\n\nline4\n```";
let typst = md_to_typst(md);
assert!(
typst.contains("line1\n \n \nline4"),
"Multiple blank lines should each be filled, got: {typst}"
);
}
#[test]
fn test_code_block_containing_backtick_fence() {
let md = "````\n```rust\nfn main() {}\n```\n````";
let typst = md_to_typst(md);
assert!(
typst.contains("````"),
"fence should be at least 4 backticks, got: {typst}"
);
assert!(
typst.contains("```rust\nfn main() {}\n```"),
"content should be preserved verbatim, got: {typst}"
);
}
#[test]
fn test_max_backtick_run() {
assert_eq!(max_backtick_run(""), 0);
assert_eq!(max_backtick_run("no backticks"), 0);
assert_eq!(max_backtick_run("a`b"), 1);
assert_eq!(max_backtick_run("```"), 3);
assert_eq!(max_backtick_run("a```b``c"), 3);
assert_eq!(max_backtick_run("``````"), 6);
}
#[test]
fn test_blockquote_depth_capped() {
let input = "> ".repeat(15) + "deep";
let result = md_to_typst(&input);
let quote_count = result.matches("#quote(block: true)[").count();
assert_eq!(quote_count, MAX_BLOCKQUOTE_DEPTH);
assert!(result.contains("deep"));
}
#[test]
fn test_fill_blank_lines() {
assert_eq!(fill_blank_lines("a\n\nb\n"), "a\n \nb\n");
assert_eq!(fill_blank_lines("a\n\n\nb\n"), "a\n \n \nb\n");
assert_eq!(fill_blank_lines("a\nb\n"), "a\nb\n"); assert_eq!(fill_blank_lines("a\n"), "a\n"); }
#[test]
fn test_nested_unordered_list() {
let md = "- a\n - b\n- c";
let typst = md_to_typst(md);
assert!(
typst.contains("- a\n - b\n- c"),
"nested unordered list should be indented, got: {typst}"
);
}
#[test]
fn test_nested_ordered_list() {
let md = "1. a\n 1. b\n2. c";
let typst = md_to_typst(md);
assert!(
typst.contains("+ a\n + b\n+ c"),
"nested ordered list should be indented, got: {typst}"
);
}
#[test]
fn test_nested_mixed_list() {
let md = "- a\n 1. b\n- c";
let typst = md_to_typst(md);
assert!(
typst.contains("- a\n + b\n- c"),
"nested mixed list should be indented, got: {typst}"
);
}
#[test]
fn test_deeply_nested_list() {
let md = "- L0\n - L1\n - L2\n - L3\n - L4\n - L5\n - L6\n - L7\n - L8\n - L9";
let typst = md_to_typst(md);
let max_indent = typst
.lines()
.map(|line| line.len() - line.trim_start().len())
.max()
.unwrap_or(0);
assert_eq!(
max_indent, 14,
"max indent should be 14 (7 levels × 2 spaces), got: {max_indent}\n{typst}"
);
}
#[test]
fn test_loose_unordered_list() {
let md = "- a\n\n- b";
let typst = md_to_typst(md);
assert!(
typst.contains("- a\n"),
"first item text should follow marker: {typst}"
);
assert!(
typst.contains("- b\n"),
"second item text should follow marker: {typst}"
);
assert!(
!typst.contains("- \n"),
"marker and text should not be separated: {typst}"
);
}
#[test]
fn test_loose_ordered_list() {
let md = "1. a\n\n2. b";
let typst = md_to_typst(md);
assert!(
typst.contains("+ a\n"),
"first item text should follow marker: {typst}"
);
assert!(
typst.contains("+ b\n"),
"second item text should follow marker: {typst}"
);
assert!(
!typst.contains("+ \n"),
"marker and text should not be separated: {typst}"
);
}
#[test]
fn test_list_item_softbreak() {
let md = "- first\nsecond";
let typst = md_to_typst(md);
assert!(
typst.contains("- first\n second"),
"continuation line should be indented by 2 spaces: {typst}"
);
}
#[test]
fn test_loose_nested_list() {
let md = "- outer\n\n - inner1\n\n - inner2";
let typst = md_to_typst(md);
assert!(typst.contains("- outer\n"), "outer item: {typst}");
assert!(
typst.contains(" - inner1\n"),
"nested items should be indented: {typst}"
);
assert!(
typst.contains(" - inner2\n"),
"nested items should be indented: {typst}"
);
}
#[test]
fn test_table_cell_softbreak() {
let md = "para line1\nline2";
let typst = md_to_typst(md);
assert_eq!(
typst, "para line1\nline2\n",
"non-list softbreak should not add indent"
);
}
#[test]
fn test_inline_math() {
let md = "The formula $\\frac{a}{b}$ is inline.";
let typst = md_to_typst(md);
assert!(
typst.contains("$") && typst.contains("frac"),
"inline math should produce Typst $...$ with converted content, got: {typst}"
);
assert!(
!typst.contains("\\$"),
"math delimiters should not be escaped, got: {typst}"
);
}
#[test]
fn test_display_math() {
let md = "$$\n\\sum_{i=1}^{n} x_i\n$$";
let typst = md_to_typst(md);
assert!(
typst.contains("$") && typst.contains("sum"),
"display math should produce Typst $ ... $ with converted content, got: {typst}"
);
}
#[test]
fn test_inline_math_simple() {
let md = "Value $x + y$ here.";
let typst = md_to_typst(md);
assert!(
typst.contains("$") && typst.contains("x") && typst.contains("y"),
"simple math should be wrapped in $...$, got: {typst}"
);
assert!(
!typst.contains("\\$"),
"math delimiters should not be escaped, got: {typst}"
);
}
#[test]
fn test_math_in_table() {
let md = "| Formula |\n|---------|\n| $x^2$ |";
let typst = md_to_typst(md);
assert!(
typst.contains("#table("),
"should produce table, got: {typst}"
);
assert!(
typst.contains("$"),
"table cell should contain math, got: {typst}"
);
}
#[test]
fn test_display_math_source_map() {
let md = "before\n\n$$\nx^2\n$$\n\nafter";
let (_typst, map) = markdown_to_typst(md, None);
assert!(
map.blocks.len() >= 2,
"should have at least 2 blocks (paragraph + math), got: {}",
map.blocks.len()
);
for pair in map.blocks.windows(2) {
assert!(
pair[0].typst_byte_range.end <= pair[1].typst_byte_range.start,
"overlapping typst ranges: {:?} and {:?}",
pair[0].typst_byte_range,
pair[1].typst_byte_range,
);
}
}
#[test]
fn test_image_basic() {
let md = "";
let available: HashSet<String> = ["photo.png".to_string()].into_iter().collect();
let typst = markdown_to_typst(md, Some(&available)).0;
assert!(
typst.contains("#align(center)[#image(\"photo.png\")]"),
"should contain #image() call, got: {typst}"
);
}
#[test]
fn test_image_alt_suppressed() {
let md = "";
let available: HashSet<String> = ["photo.png".to_string()].into_iter().collect();
let typst = markdown_to_typst(md, Some(&available)).0;
assert!(
!typst.contains("alt text"),
"alt text should be suppressed, got: {typst}"
);
}
#[test]
fn test_image_missing() {
let md = "";
let available: HashSet<String> = HashSet::new();
let typst = markdown_to_typst(md, Some(&available)).0;
assert!(
typst.contains("#image-placeholder("),
"missing image should produce placeholder, got: {typst}"
);
assert!(
typst.contains("missing.png"),
"placeholder should contain path, got: {typst}"
);
}
#[test]
fn test_image_no_available_images() {
let md = "";
let typst = markdown_to_typst(md, None).0;
assert!(
typst.contains("#image-placeholder("),
"no available images should produce placeholder, got: {typst}"
);
}
#[test]
fn test_extract_image_paths() {
let md = "\n\n\n\n";
let paths = extract_image_paths(md);
assert_eq!(paths.len(), 2, "should deduplicate: {paths:?}");
assert!(paths.contains(&"img1.png".to_string()));
assert!(paths.contains(&"img2.jpg".to_string()));
}
#[test]
fn test_extract_image_paths_empty() {
let md = "No images here.";
let paths = extract_image_paths(md);
assert!(paths.is_empty());
}
}