use anyhow::{Result, anyhow};
use common::database::Store;
use common::database::rope_helpers::block_content_via_store;
use common::entities::{
Alignment, Block, CharVerticalAlignment, ListStyle, TableCell, TextDirection,
};
use common::format_runs::InlineContent;
use common::format_runs_query::inline_segments_for_block;
use common::parser_tools::{ExportImages, PdfExportOptions};
use common::types::EntityId;
pub(crate) fn typst_image_paths(
images: &ExportImages,
) -> std::collections::BTreeMap<String, String> {
images
.iter()
.enumerate()
.map(|(i, (src, image))| {
(
src.clone(),
format!("/images/img_{:03}.{}", i + 1, image.extension()),
)
})
.collect()
}
pub fn escape_typst(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'*' => out.push_str("\\*"),
'_' => out.push_str("\\_"),
'`' => out.push_str("\\`"),
'#' => out.push_str("\\#"),
'$' => out.push_str("\\$"),
'<' => out.push_str("\\<"),
'>' => out.push_str("\\>"),
'@' => out.push_str("\\@"),
'~' => out.push_str("\\~"),
'[' => out.push_str("\\["),
']' => out.push_str("\\]"),
'-' => out.push_str("\\-"),
'/' => out.push_str("\\/"),
'=' => out.push_str("\\="),
'+' => out.push_str("\\+"),
_ => out.push(ch),
}
}
guard_leading_numbered_list(out)
}
fn guard_leading_numbered_list(s: String) -> String {
let digit_run_end = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(0);
if digit_run_end > 0 && s[digit_run_end..].starts_with('.') {
let mut out = String::with_capacity(s.len() + 1);
out.push_str(&s[..digit_run_end]);
out.push('\\');
out.push_str(&s[digit_run_end..]);
out
} else {
s
}
}
fn typst_lang(tag: &str) -> Option<String> {
let primary = tag.trim().split(['-', '_']).next()?.trim();
let usable = (2..=3).contains(&primary.chars().count())
&& primary.chars().all(|c| c.is_ascii_alphabetic());
usable.then(|| primary.to_ascii_lowercase())
}
fn escape_typst_string(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
_ => out.push(ch),
}
}
out
}
pub fn typst_preamble(options: &PdfExportOptions) -> String {
if !options.include_preamble {
return String::new();
}
let mut out = String::new();
if options.title.is_some() || options.author.is_some() {
let mut meta_args: Vec<String> = Vec::new();
if let Some(title) = &options.title {
meta_args.push(format!("title: \"{}\"", escape_typst_string(title)));
}
if let Some(author) = &options.author {
meta_args.push(format!("author: \"{}\"", escape_typst_string(author)));
}
out.push_str(&format!("#set document({})\n", meta_args.join(", ")));
}
out.push_str(&format!(
"#set page(\n width: {w}mm, height: {h}mm,\n margin: (top: {mt}mm, bottom: {mb}mm, left: {ml}mm, right: {mr}mm),\n)\n",
w = options.page_width_mm,
h = options.page_height_mm,
mt = options.margin_top_mm,
mb = options.margin_bottom_mm,
ml = options.margin_left_mm,
mr = options.margin_right_mm,
));
let mut text_args: Vec<String> = Vec::new();
if !options.font_family.is_empty() {
text_args.push(format!(
"font: \"{}\"",
escape_typst_string(&options.font_family)
));
}
text_args.push(format!("size: {}pt", options.font_size_pt));
if let Some(lang) = options.lang.as_deref().and_then(typst_lang) {
text_args.push(format!("lang: \"{lang}\""));
}
text_args.push(format!(
"dir: {}",
if options.base_rtl { "rtl" } else { "ltr" }
));
out.push_str(&format!("#set text({})\n", text_args.join(", ")));
let mut par_args: Vec<String> = vec![
format!("justify: {}", options.justify),
format!("leading: {}em", options.line_spacing),
];
if let Some(indent) = options.first_line_indent_mm {
par_args.push(format!("first-line-indent: {indent}mm"));
}
out.push_str(&format!("#set par({})\n", par_args.join(", ")));
if let Some(spacing) = options.paragraph_spacing_pt {
out.push_str(&format!("#set block(spacing: {spacing}pt)\n"));
}
out.push_str("#set smartquote(enabled: false)\n");
out.push_str("#set heading(numbering: none)\n");
out
}
pub const TYPST_PAGEBREAK: &str = "#pagebreak(weak: true)";
pub fn hoist_leading_pagebreak(body: &str) -> (Option<&'static str>, &str) {
match body.strip_prefix(TYPST_PAGEBREAK) {
Some(rest) => (Some(TYPST_PAGEBREAK), rest.trim_start_matches('\n')),
None => (None, body),
}
}
#[derive(Debug, Default)]
pub struct TypstNotes {
bodies: std::collections::HashMap<String, String>,
emitted: std::cell::RefCell<std::collections::HashSet<String>>,
}
impl TypstNotes {
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, label: String, body: String) {
self.bodies.insert(label, body);
}
pub fn get(&self, label: &str) -> Option<&String> {
self.bodies.get(label)
}
fn mark_emitted(&self, label: &str) -> bool {
self.emitted.borrow_mut().insert(label.to_string())
}
}
pub fn render_blocks_typst(
store: &Store,
blocks: &[Block],
options: &PdfExportOptions,
notes: &TypstNotes,
) -> String {
let image_paths = typst_image_paths(&options.images);
let mut parts: Vec<String> = Vec::new();
let mut i = 0;
while i < blocks.len() {
let block = &blocks[i];
if block.fmt_page_break_before == Some(true) {
parts.push(TYPST_PAGEBREAK.to_string());
}
if block.fmt_is_code_block == Some(true) {
let raw_text = raw_block_text(store, block);
let lang_arg = block
.fmt_code_language
.as_deref()
.filter(|l| !l.is_empty())
.map(|l| format!(", lang: \"{}\"", escape_typst_string(l)))
.unwrap_or_default();
parts.push(format!(
"#raw(\"{}\"{}, block: true)",
escape_typst_string(&raw_text),
lang_arg
));
i += 1;
continue;
}
let list = block
.list
.and_then(|list_id| store.lists.read().get(&list_id).cloned());
if let Some(list_entity) = list {
let is_ordered = matches!(
list_entity.style,
ListStyle::Decimal
| ListStyle::LowerAlpha
| ListStyle::UpperAlpha
| ListStyle::LowerRoman
| ListStyle::UpperRoman
);
let mut items: Vec<String> = Vec::new();
while i < blocks.len() {
let b = &blocks[i];
let b_is_listed = b
.list
.is_some_and(|list_id| store.lists.read().contains_key(&list_id));
if (items.is_empty() || b.fmt_page_break_before != Some(true)) && b_is_listed {
let inline = render_inline_typst(store, b, &image_paths, notes);
items.push(format!("[{inline}]"));
i += 1;
} else {
break;
}
}
let call = if is_ordered {
format!(
"#enum(numbering: \"{}\")",
numbering_pattern(&list_entity.style)
)
} else {
format!("#list(marker: [{}])", bullet_marker(&list_entity.style))
};
parts.push(format!("{call}{}", items.join("")));
} else {
let inline = render_inline_typst(store, block, &image_paths, notes);
if inline.is_empty() {
i += 1;
continue;
}
let inline = if block.fmt_direction == Some(TextDirection::RightToLeft) {
format!("#text(dir: rtl)[{inline}]")
} else {
inline
};
let mut content = if let Some(level) = block.fmt_heading_level {
let level = level.clamp(1, 6) as usize;
format!("{} {}", "=".repeat(level), inline)
} else {
inline
};
if let Some(lh) = block.fmt_line_height {
let ratio = lh as f64 / 1000.0;
let leading_em = ratio * options.line_spacing as f64;
content = format!("#[#set par(leading: {leading_em}em)\n{content}]");
}
if let Some(ti) = block.fmt_text_indent {
let mm = ti as f64 * 25.4 / 96.0;
content = format!("#[#set par(first-line-indent: {mm:.3}mm)\n{content}]");
}
if let Some(tm) = block.fmt_top_margin.filter(|&t| t > 0) {
let pt = tm as f64 * 72.0 / 96.0;
content = format!("#block(above: {pt:.2}pt)[{content}]");
}
if let Some(ref color) = block.fmt_background_color
&& !color.is_empty()
{
content = format!(
"#block(fill: rgb(\"{}\"), width: 100%)[{content}]",
escape_typst_string(color)
);
}
if block.fmt_non_breakable_lines == Some(true) {
content = format!("#block(breakable: false)[{content}]");
}
content = wrap_alignment(content, block.fmt_alignment.as_ref(), options.justify);
parts.push(content);
i += 1;
}
}
parts.join("\n\n")
}
fn wrap_alignment(content: String, alignment: Option<&Alignment>, doc_justify: bool) -> String {
match alignment {
None => content,
Some(Alignment::Justify) => {
if doc_justify {
content
} else {
scoped_justify(true, content)
}
}
Some(side @ (Alignment::Left | Alignment::Right | Alignment::Center)) => {
let content = if doc_justify {
scoped_justify(false, content)
} else {
content
};
let align_arg = match side {
Alignment::Left => "left",
Alignment::Right => "right",
Alignment::Center => "center",
Alignment::Justify => unreachable!(),
};
format!("#align({align_arg})[{content}]")
}
}
}
fn scoped_justify(justify: bool, content: String) -> String {
format!("#[#set par(justify: {justify})\n{content}]")
}
fn numbering_pattern(style: &ListStyle) -> &'static str {
match style {
ListStyle::Decimal => "1.",
ListStyle::LowerAlpha => "a.",
ListStyle::UpperAlpha => "A.",
ListStyle::LowerRoman => "i.",
ListStyle::UpperRoman => "I.",
ListStyle::Disc | ListStyle::Circle | ListStyle::Square => "1.",
}
}
fn bullet_marker(style: &ListStyle) -> &'static str {
match style {
ListStyle::Disc => "\u{2022}", ListStyle::Circle => "\u{25CB}", ListStyle::Square => "\u{25AA}", _ => "\u{2022}",
}
}
pub fn render_inline_typst(
store: &Store,
block: &Block,
image_paths: &std::collections::BTreeMap<String, String>,
notes: &TypstNotes,
) -> String {
let block_text = block_content_via_store(block, store);
let elements = inline_segments_for_block(store, block.id, &block_text);
let mut out = String::new();
for elem in &elements {
let is_monospace = elem.fmt_font_family.as_deref() == Some("monospace");
let text = match &elem.content {
InlineContent::FootnoteRef { label } => {
match notes.get(label.as_str()) {
Some(body) => {
let anchor = crate::footnotes::safe_label_id(label);
if notes.mark_emitted(label) {
out.push_str(&format!("#footnote[{body}] <{anchor}>"));
} else {
out.push_str(&format!("#footnote(<{anchor}>)"));
}
}
None => out.push_str(&format!("#super[{}]", escape_typst(label))),
}
continue;
}
InlineContent::Text(t) => {
if is_monospace {
format!("#raw(\"{}\")", escape_typst_string(t))
} else {
escape_typst(t)
}
}
InlineContent::Image {
name,
alt,
width,
height,
..
} => match image_paths.get(name) {
Some(path) => {
let mut args = format!("\"{}\"", escape_typst_string(path));
if *width > 0 {
args.push_str(&format!(", width: {}pt", *width as f64 * 72.0 / 96.0));
}
if *height > 0 {
args.push_str(&format!(", height: {}pt", *height as f64 * 72.0 / 96.0));
}
if !alt.is_empty() {
args.push_str(&format!(", alt: \"{}\"", escape_typst_string(alt)));
}
format!("#image({args})")
}
None => escape_typst(alt),
},
InlineContent::Empty => String::new(),
};
if text.is_empty() {
continue;
}
let mut formatted = text;
if !is_monospace {
if elem.fmt_font_bold == Some(true) {
formatted = format!("*{formatted}*");
}
if elem.fmt_font_italic == Some(true) {
formatted = format!("_{formatted}_");
}
if elem.fmt_font_underline == Some(true) {
formatted = format!("#underline[{formatted}]");
}
if elem.fmt_font_strikeout == Some(true) {
formatted = format!("#strike[{formatted}]");
}
match elem.fmt_vertical_alignment {
Some(CharVerticalAlignment::SuperScript) => {
formatted = format!("#super[{formatted}]");
}
Some(CharVerticalAlignment::SubScript) => {
formatted = format!("#sub[{formatted}]");
}
_ => {}
}
}
if let Some(ref href) = elem.fmt_anchor_href
&& !href.is_empty()
{
formatted = format!("#link(\"{}\")[{formatted}]", escape_typst_string(href));
}
out.push_str(&formatted);
}
out
}
fn raw_block_text(store: &Store, block: &Block) -> String {
let block_text = block_content_via_store(block, store);
let elements = inline_segments_for_block(store, block.id, &block_text);
let mut raw_text = String::new();
for elem in &elements {
if let InlineContent::Text(t) = &elem.content {
raw_text.push_str(t);
}
}
raw_text
}
pub fn render_table_typst(
store: &Store,
table_id: EntityId,
image_paths: &std::collections::BTreeMap<String, String>,
notes: &TypstNotes,
) -> Result<String> {
let table = store
.tables
.read()
.get(&table_id)
.cloned()
.ok_or_else(|| anyhow!("Table not found"))?;
let mut cells: Vec<TableCell> = table
.cells
.iter()
.filter_map(|cid| store.table_cells.read().get(cid).cloned())
.collect();
cells.sort_by(|a, b| a.row.cmp(&b.row).then(a.column.cmp(&b.column)));
let rows = table.rows as usize;
let cols = table.columns as usize;
let mut covered = vec![vec![false; cols]; rows];
let mut items: Vec<String> = Vec::new();
for r in 0..rows {
let mut c = 0;
while c < cols {
if covered[r][c] {
c += 1;
continue;
}
let cell = cells
.iter()
.find(|cell| cell.row == r as i64 && cell.column == c as i64);
if let Some(cell) = cell {
let content = if let Some(cf_id) = cell.cell_frame {
let block_ids = store
.frames
.read()
.get(&cf_id)
.map(|f| f.blocks.clone())
.unwrap_or_default();
let blocks: Vec<Block> = block_ids
.iter()
.filter_map(|bid| store.blocks.read().get(bid).cloned())
.collect();
let mut cell_parts: Vec<String> = Vec::new();
for block in &blocks {
let inline = render_inline_typst(store, block, image_paths, notes);
if !inline.is_empty() {
cell_parts.push(inline);
}
}
cell_parts.join("#linebreak()")
} else {
String::new()
};
let row_span = cell.row_span.max(1) as usize;
let col_span = cell.column_span.max(1) as usize;
if row_span > 1 || col_span > 1 {
let mut span_args: Vec<String> = Vec::new();
if col_span > 1 {
span_args.push(format!("colspan: {col_span}"));
}
if row_span > 1 {
span_args.push(format!("rowspan: {row_span}"));
}
items.push(format!("table.cell({})[{content}]", span_args.join(", ")));
} else {
items.push(format!("[{content}]"));
}
for sr in 0..row_span {
for sc in 0..col_span {
if sr == 0 && sc == 0 {
continue;
}
if r + sr < rows && c + sc < cols {
covered[r + sr][c + sc] = true;
}
}
}
c += col_span;
} else {
items.push("[]".to_string());
c += 1;
}
}
}
Ok(format!(
"#table(\n columns: {cols},\n {}\n)",
items.join(",\n ")
))
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_FONT: &[u8] = include_bytes!("../tests/assets/DejaVuSerif.ttf");
#[test]
fn escapes_every_special_character() {
let cases: &[(char, &str)] = &[
('\\', "\\\\"),
('*', "\\*"),
('_', "\\_"),
('`', "\\`"),
('#', "\\#"),
('$', "\\$"),
('<', "\\<"),
('>', "\\>"),
('@', "\\@"),
('~', "\\~"),
('[', "\\["),
(']', "\\]"),
('-', "\\-"),
('/', "\\/"),
('=', "\\="),
('+', "\\+"),
];
for (ch, expected) in cases {
let input = format!("a{ch}b");
let want = format!("a{expected}b");
assert_eq!(escape_typst(&input), want, "escaping {ch:?}");
}
}
#[test]
fn plain_ascii_and_unicode_prose_is_left_untouched() {
assert_eq!(escape_typst("Hello, world!"), "Hello, world!");
assert_eq!(escape_typst("héllo wörld — café"), "héllo wörld — café");
assert_eq!(escape_typst("مرحبا بالعالم"), "مرحبا بالعالم");
}
#[test]
fn straight_quotes_are_never_escaped() {
assert_eq!(
escape_typst("She said \"hello\" and 'goodbye'."),
"She said \"hello\" and 'goodbye'."
);
}
#[test]
fn leading_numbered_list_guard_escapes_only_the_first_period() {
assert_eq!(escape_typst("12. Go left"), "12\\. Go left");
assert_eq!(escape_typst("1. one"), "1\\. one");
assert_eq!(escape_typst("123.456 more"), "123\\.456 more");
}
#[test]
fn leading_numbered_list_guard_does_not_fire_without_a_period() {
assert_eq!(escape_typst("123 apples"), "123 apples");
}
#[test]
fn leading_numbered_list_guard_does_not_fire_mid_string() {
assert_eq!(escape_typst("see step 12. now"), "see step 12. now");
}
#[test]
fn leading_numbered_list_guard_does_not_fire_on_pure_digits() {
assert_eq!(escape_typst("2024"), "2024");
}
#[test]
fn escaped_adversarial_prose_compiles_without_being_interpreted_as_markup() {
let adversarial = "#set text(font: \"Comic Sans\") *bold* _italic_ $x^2$ [label] <ref> @cite ~nbsp~ `code` 12. item -dash- /slash/ +plus+ =eq=";
let escaped = escape_typst(adversarial);
let options = PdfExportOptions {
font_bytes: vec![TEST_FONT.to_vec()],
..Default::default()
};
let markup = format!("{}{escaped}\n", typst_preamble(&options));
let (pdf, _pages) = crate::typst_compile::compile_typst_pdf(
&markup,
vec![TEST_FONT.to_vec()],
&Default::default(),
)
.expect("adversarial-but-escaped prose must compile as plain text");
assert!(pdf.starts_with(b"%PDF-"));
}
#[test]
fn empty_preamble_when_include_preamble_is_false() {
let options = PdfExportOptions {
include_preamble: false,
..Default::default()
};
assert_eq!(typst_preamble(&options), "");
}
#[test]
fn default_preamble_compiles() {
let options = PdfExportOptions {
font_bytes: vec![TEST_FONT.to_vec()],
..Default::default()
};
let markup = format!("{}Hello, world.\n", typst_preamble(&options));
let (pdf, _pages) = crate::typst_compile::compile_typst_pdf(
&markup,
vec![TEST_FONT.to_vec()],
&Default::default(),
)
.expect("default preamble must compile");
assert!(pdf.starts_with(b"%PDF-"));
}
#[test]
fn preamble_with_title_author_lang_and_first_line_indent_compiles() {
let options = PdfExportOptions {
font_bytes: vec![TEST_FONT.to_vec()],
title: Some("A \"Quoted\" Title".to_string()),
author: Some("Jane \\ Doe".to_string()),
lang: Some("fr".to_string()),
first_line_indent_mm: Some(5.0),
paragraph_spacing_pt: Some(6.0),
base_rtl: true,
..Default::default()
};
let markup = format!("{}Bonjour le monde.\n", typst_preamble(&options));
let (pdf, _pages) = crate::typst_compile::compile_typst_pdf(
&markup,
vec![TEST_FONT.to_vec()],
&Default::default(),
)
.expect("preamble with metadata must compile");
assert!(pdf.starts_with(b"%PDF-"));
}
#[test]
fn preamble_with_a_regional_locale_compiles() {
let options = PdfExportOptions {
font_bytes: vec![TEST_FONT.to_vec()],
lang: Some("en-US".to_string()),
..Default::default()
};
let markup = format!("{}Hello world.\n", typst_preamble(&options));
let (pdf, _pages) = crate::typst_compile::compile_typst_pdf(
&markup,
vec![TEST_FONT.to_vec()],
&Default::default(),
)
.expect("a regional locale must not fail the export");
assert!(pdf.starts_with(b"%PDF-"));
}
#[test]
fn typst_lang_reduces_to_the_primary_subtag() {
assert_eq!(typst_lang("en-US").as_deref(), Some("en"));
assert_eq!(typst_lang("fr_FR").as_deref(), Some("fr"));
assert_eq!(typst_lang("zh-Hans-CN").as_deref(), Some("zh"));
assert_eq!(typst_lang(" DE ").as_deref(), Some("de"));
assert_eq!(typst_lang("fil").as_deref(), Some("fil"));
assert_eq!(typst_lang(""), None);
assert_eq!(typst_lang("-"), None);
assert_eq!(typst_lang("english"), None);
assert_eq!(typst_lang("e"), None);
assert_eq!(typst_lang("12"), None);
}
}