Skip to main content

citum_engine/render/
typst.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus
4*/
5
6//! Typst output format.
7
8use super::format::OutputFormat;
9use citum_schema::template::WrapPunctuation;
10
11/// Typst renderer.
12#[derive(Debug, Clone, Default)]
13pub struct Typst;
14
15impl Typst {
16    fn escape_text(input: &str) -> String {
17        let mut escaped = String::with_capacity(input.len());
18        for ch in input.chars() {
19            match ch {
20                '\\' => escaped.push_str("\\\\"),
21                '#' | '[' | ']' | '<' | '>' | '*' | '_' | '@' | '$' => {
22                    escaped.push('\\');
23                    escaped.push(ch);
24                }
25                _ => escaped.push(ch),
26            }
27        }
28        escaped
29    }
30
31    fn escape_string(input: &str) -> String {
32        let mut escaped = String::with_capacity(input.len());
33        for ch in input.chars() {
34            match ch {
35                '\\' => escaped.push_str("\\\\"),
36                '"' => escaped.push_str("\\\""),
37                _ => escaped.push(ch),
38            }
39        }
40        escaped
41    }
42}
43
44impl OutputFormat for Typst {
45    type Output = String;
46
47    fn text(&self, s: &str) -> Self::Output {
48        Self::escape_text(s)
49    }
50
51    fn join(&self, items: Vec<Self::Output>, delimiter: &str) -> Self::Output {
52        items.join(delimiter)
53    }
54
55    fn finish(&self, output: Self::Output) -> String {
56        output
57    }
58
59    fn emph(&self, content: Self::Output) -> Self::Output {
60        if content.is_empty() {
61            return content;
62        }
63        format!("#emph[{content}]")
64    }
65
66    fn strong(&self, content: Self::Output) -> Self::Output {
67        if content.is_empty() {
68            return content;
69        }
70        format!("*{content}*")
71    }
72
73    fn small_caps(&self, content: Self::Output) -> Self::Output {
74        if content.is_empty() {
75            return content;
76        }
77        format!("#smallcaps[{content}]")
78    }
79
80    fn superscript(&self, content: Self::Output) -> Self::Output {
81        if content.is_empty() {
82            return content;
83        }
84        format!("#super[{content}]")
85    }
86
87    fn quote(&self, content: Self::Output) -> Self::Output {
88        if content.is_empty() {
89            return content;
90        }
91        format!("\u{201C}{content}\u{201D}")
92    }
93
94    fn affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
95        format!("{}{}{}", self.text(prefix), content, self.text(suffix))
96    }
97
98    fn inner_affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
99        format!("{}{}{}", self.text(prefix), content, self.text(suffix))
100    }
101
102    fn wrap_punctuation(&self, wrap: &WrapPunctuation, content: Self::Output) -> Self::Output {
103        match wrap {
104            WrapPunctuation::Parentheses => format!("({content})"),
105            WrapPunctuation::Brackets => format!("[{content}]"),
106            WrapPunctuation::Quotes => format!("\u{201C}{content}\u{201D}"),
107        }
108    }
109
110    fn semantic(&self, _class: &str, content: Self::Output) -> Self::Output {
111        content
112    }
113
114    fn annotation(&self, content: Self::Output) -> Self::Output {
115        if content.is_empty() {
116            return content;
117        }
118        format!("\n#block(class: \"citum-annotation\")[{}]", content)
119    }
120
121    fn citation(&self, ids: Vec<String>, content: Self::Output) -> Self::Output {
122        if content.is_empty() || ids.len() != 1 {
123            return content;
124        }
125
126        #[allow(clippy::unwrap_used, reason = "length checked")]
127        let id = ids.first().unwrap();
128        format!("#link(<{}>)[{}]", self.format_id(id), content)
129    }
130
131    fn link(&self, url: &str, content: Self::Output) -> Self::Output {
132        if content.is_empty() {
133            return content;
134        }
135
136        if let Some(label) = url.strip_prefix('#') {
137            format!("#link(<{}>)[{}]", self.format_id(label), content)
138        } else {
139            format!(r#"#link("{}")[{}]"#, Self::escape_string(url), content)
140        }
141    }
142
143    fn format_id(&self, id: &str) -> String {
144        let mut normalized = String::with_capacity(id.len() + 4);
145        normalized.push_str("ref-");
146        for ch in id.chars() {
147            if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | ':' | '.') {
148                normalized.push(ch);
149            } else {
150                normalized.push('-');
151            }
152        }
153        normalized
154    }
155
156    fn bibliography(&self, entries: Vec<Self::Output>) -> Self::Output {
157        self.join(entries, "\n\n")
158    }
159
160    fn entry(
161        &self,
162        id: &str,
163        content: Self::Output,
164        url: Option<&str>,
165        _metadata: &super::format::ProcEntryMetadata,
166    ) -> Self::Output {
167        let content = if let Some(u) = url {
168            self.link(u, content)
169        } else {
170            content
171        };
172
173        format!("{} <{}>", content, self.format_id(id))
174    }
175}