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::PdfExportOptions;
use common::types::EntityId;
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 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().filter(|l| !l.is_empty()) {
text_args.push(format!("lang: \"{}\"", escape_typst_string(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 fn render_blocks_typst(store: &Store, blocks: &[Block], options: &PdfExportOptions) -> String {
let mut parts: Vec<String> = Vec::new();
let mut i = 0;
while i < blocks.len() {
let block = &blocks[i];
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 b_is_listed {
let inline = render_inline_typst(store, b);
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);
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(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) -> 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::Text(t) => {
if is_monospace {
format!("#raw(\"{}\")", escape_typst_string(t))
} else {
escape_typst(t)
}
}
InlineContent::Image { name, .. } => {
escape_typst(name)
}
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) -> 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);
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()])
.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()])
.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()])
.expect("preamble with metadata must compile");
assert!(pdf.starts_with(b"%PDF-"));
}
}