Skip to main content

citum_engine/render/
format.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Output format trait for pluggable renderers.
7
8use citum_schema::locale::GrammarOptions;
9use citum_schema::template::WrapPunctuation;
10
11/// Return Unicode quote marks for a nesting depth.
12///
13/// Even depths use outer double quotes; odd depths use inner single quotes.
14#[must_use]
15pub fn unicode_quote_marks(depth: usize) -> (&'static str, &'static str) {
16    if depth.is_multiple_of(2) {
17        ("\u{201C}", "\u{201D}")
18    } else {
19        ("\u{2018}", "\u{2019}")
20    }
21}
22
23/// Locale-resolved quote mark characters, threaded from
24/// [`GrammarOptions`](citum_schema::locale::GrammarOptions) through to rendering so that
25/// styles using non-English quotation conventions (e.g. fr-FR guillemets) render correctly.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct QuoteMarks {
28    /// Opening outer quotation mark.
29    pub open: String,
30    /// Closing outer quotation mark.
31    pub close: String,
32    /// Opening inner (nested) quotation mark.
33    pub open_inner: String,
34    /// Closing inner (nested) quotation mark.
35    pub close_inner: String,
36}
37
38impl QuoteMarks {
39    /// Return the opening and closing quote delimiters for a nesting depth.
40    ///
41    /// Depth 0 (and other even depths) use the outer pair; odd depths use the inner pair.
42    #[must_use]
43    pub fn for_depth(&self, depth: usize) -> (&str, &str) {
44        if depth.is_multiple_of(2) {
45            (&self.open, &self.close)
46        } else {
47            (&self.open_inner, &self.close_inner)
48        }
49    }
50}
51
52impl Default for QuoteMarks {
53    /// The historical hardcoded English fallback, used when no resolved locale is available.
54    fn default() -> Self {
55        let (open, close) = unicode_quote_marks(0);
56        let (open_inner, close_inner) = unicode_quote_marks(1);
57        Self {
58            open: open.to_string(),
59            close: close.to_string(),
60            open_inner: open_inner.to_string(),
61            close_inner: close_inner.to_string(),
62        }
63    }
64}
65
66impl From<&GrammarOptions> for QuoteMarks {
67    fn from(options: &GrammarOptions) -> Self {
68        Self {
69            open: options.open_quote.clone(),
70            close: options.close_quote.clone(),
71            open_inner: options.open_inner_quote.clone(),
72            close_inner: options.close_inner_quote.clone(),
73        }
74    }
75}
76
77/// Extra attributes applied to semantic wrappers when a renderer supports them.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct SemanticAttribute {
80    /// The attribute name.
81    pub name: &'static str,
82    /// The attribute value.
83    pub value: String,
84}
85
86/// Trait for defining how to render template components into a specific format.
87///
88/// Implementations of this trait define how various formatting instructions
89/// (emphasis, quotes, links, etc.) are translated into specific markup or text.
90pub trait OutputFormat: Default + Clone {
91    /// The type used for intermediate rendered content.
92    ///
93    /// For simple text formats, this is usually `String`. More complex formats
94    /// might use an AST or a specialized builder type.
95    type Output;
96
97    /// Convert a raw string into the format's output type.
98    ///
99    /// The implementation should handle any necessary character escaping
100    /// required by the target format.
101    fn text(&self, s: &str) -> Self::Output;
102
103    /// Join multiple outputs into a single output using a delimiter.
104    fn join(&self, items: Vec<Self::Output>, delimiter: &str) -> Self::Output;
105
106    /// Convert the intermediate output into the final result string.
107    ///
108    /// This is called exactly once at the end of the rendering process
109    /// for a top-level component (citation or bibliography entry).
110    fn finish(&self, output: Self::Output) -> String;
111
112    /// Render content with emphasis (typically italics).
113    fn emph(&self, content: Self::Output) -> Self::Output;
114
115    /// Render content with strong emphasis (typically bold).
116    fn strong(&self, content: Self::Output) -> Self::Output;
117
118    /// Render content in small capitals.
119    fn small_caps(&self, content: Self::Output) -> Self::Output;
120
121    /// Render content as superscript text.
122    fn superscript(&self, content: Self::Output) -> Self::Output;
123
124    /// Return the opening and closing quote delimiters for a nesting depth.
125    ///
126    /// Depth 0 is an outer quote pair, depth 1 is the first inner quote pair,
127    /// and deeper levels alternate between those two pairs. `marks` carries the
128    /// locale-resolved quote characters; callers with no resolved locale can pass
129    /// `&QuoteMarks::default()` to keep the historical English fallback.
130    fn quote_marks<'a>(&self, depth: usize, marks: &'a QuoteMarks) -> (&'a str, &'a str) {
131        marks.for_depth(depth)
132    }
133
134    /// Render content enclosed in quotation marks at a specific nesting depth.
135    fn quote_with_depth(
136        &self,
137        content: Self::Output,
138        depth: usize,
139        marks: &QuoteMarks,
140    ) -> Self::Output {
141        let (open, close) = self.quote_marks(depth, marks);
142        self.affix(open, content, close)
143    }
144
145    /// Render content enclosed in outer quotation marks.
146    fn quote(&self, content: Self::Output, marks: &QuoteMarks) -> Self::Output {
147        self.quote_with_depth(content, 0, marks)
148    }
149
150    /// Apply outer prefix and suffix strings to the content.
151    ///
152    /// These are typically the "prefix" and "suffix" fields from the Citum style.
153    fn affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output;
154
155    /// Apply inner prefix and suffix strings to the content.
156    ///
157    /// These are applied inside any wrapping punctuation.
158    fn inner_affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output;
159
160    /// Wrap the content in specific punctuation (parentheses, brackets, or quotes).
161    ///
162    /// `marks` supplies the locale-resolved quote characters for the `Quotes` variant.
163    fn wrap_punctuation(
164        &self,
165        wrap: &WrapPunctuation,
166        content: Self::Output,
167        marks: &QuoteMarks,
168    ) -> Self::Output;
169
170    /// Apply a semantic identifier (class) to the content.
171    ///
172    /// This is used for machine readability or fine-grained CSS styling.
173    /// Examples include "citum-title", "citum-author", "citum-doi".
174    fn semantic(&self, class: &str, content: Self::Output) -> Self::Output;
175
176    /// Render an annotation block.
177    ///
178    /// This is typically called at the end of a bibliography entry to render
179    /// reader-supplied notes.
180    fn annotation(&self, content: Self::Output) -> Self::Output;
181
182    // ── Block-level methods (used by the body markup renderer) ─────────────
183    // Defaults produce plain passthrough so existing format impls need not change.
184
185    /// Render a paragraph block.
186    fn paragraph(&self, content: Self::Output) -> Self::Output {
187        content
188    }
189
190    /// Render a block quotation.
191    fn block_quote(&self, content: Self::Output) -> Self::Output {
192        content
193    }
194
195    /// Render an unordered (bullet) list from pre-rendered item strings.
196    fn bullet_list(&self, items: Vec<Self::Output>) -> Self::Output {
197        self.join(items, "\n")
198    }
199
200    /// Render an ordered (numbered) list from pre-rendered item strings.
201    fn ordered_list(&self, items: Vec<Self::Output>) -> Self::Output {
202        self.join(items, "\n")
203    }
204
205    /// Render a list item.
206    fn list_item(&self, content: Self::Output) -> Self::Output {
207        content
208    }
209
210    /// Render a heading at the given level (1 = top-level).
211    fn heading(&self, _level: u8, content: Self::Output) -> Self::Output {
212        content
213    }
214
215    /// Render an unnumbered heading at the given level.
216    ///
217    /// Used for generated section headings (e.g. bibliography group
218    /// headings) that must not participate in document section numbering.
219    /// Defaults to [`Self::heading`]; formats with numbered headings
220    /// (LaTeX) override this with their unnumbered variants.
221    fn unnumbered_heading(&self, level: u8, content: Self::Output) -> Self::Output {
222        self.heading(level, content)
223    }
224
225    /// Render a fenced or indented code block with an optional language hint.
226    ///
227    /// `content` is the raw (unescaped) code text.
228    fn code_block(&self, _lang: Option<&str>, content: Self::Output) -> Self::Output {
229        content
230    }
231
232    /// Render inline code.
233    fn inline_code(&self, content: Self::Output) -> Self::Output {
234        content
235    }
236
237    /// Render strikethrough text.
238    fn strikeout(&self, content: Self::Output) -> Self::Output {
239        content
240    }
241
242    /// Render a hard line break.
243    fn hard_break(&self) -> Self::Output {
244        self.text(" ")
245    }
246
247    /// Apply a semantic identifier plus optional attributes to the content.
248    ///
249    /// Formats that do not support extra attributes can ignore them and reuse
250    /// [`Self::semantic`].
251    fn semantic_with_attributes(
252        &self,
253        class: &str,
254        content: Self::Output,
255        _attributes: &[SemanticAttribute],
256    ) -> Self::Output {
257        self.semantic(class, content)
258    }
259
260    /// Render a full citation container with one or more reference IDs.
261    fn citation(&self, _ids: Vec<String>, content: Self::Output) -> Self::Output {
262        content
263    }
264
265    /// Hyperlink the content to a URL.
266    fn link(&self, url: &str, content: Self::Output) -> Self::Output;
267
268    /// Format a reference ID for use as a target or link (e.g. adding a prefix).
269    fn format_id(&self, id: &str) -> String {
270        id.to_string()
271    }
272
273    /// Render a full bibliography container.
274    ///
275    /// The default implementation joins the entries with double newlines.
276    fn bibliography(&self, entries: Vec<Self::Output>) -> Self::Output {
277        self.join(entries, "\n\n")
278    }
279
280    /// Render a single bibliography entry with its unique identifier and optional link.
281    ///
282    /// The default implementation just returns the content.
283    fn entry(
284        &self,
285        _id: &str,
286        content: Self::Output,
287        _url: Option<&str>,
288        _metadata: &ProcEntryMetadata,
289    ) -> Self::Output {
290        content
291    }
292}
293
294/// Metadata for a processed bibliography entry, used for interactivity.
295#[derive(Debug, Clone, Default, PartialEq)]
296pub struct ProcEntryMetadata {
297    /// Rendered primary author(s) string.
298    pub author: Option<String>,
299    /// Rendered year string.
300    pub year: Option<String>,
301    /// Rendered title string.
302    pub title: Option<String>,
303}
304
305#[cfg(test)]
306#[allow(
307    clippy::unwrap_used,
308    clippy::expect_used,
309    clippy::panic,
310    clippy::indexing_slicing,
311    clippy::todo,
312    clippy::unimplemented,
313    clippy::unreachable,
314    clippy::get_unwrap,
315    reason = "Panicking is acceptable and often desired in tests."
316)]
317mod tests {
318    use super::*;
319
320    #[derive(Default, Clone)]
321    struct DummyFormat;
322
323    impl OutputFormat for DummyFormat {
324        type Output = String;
325        fn text(&self, s: &str) -> Self::Output {
326            s.to_string()
327        }
328        fn join(&self, items: Vec<Self::Output>, delimiter: &str) -> Self::Output {
329            items.join(delimiter)
330        }
331        fn finish(&self, output: Self::Output) -> String {
332            output
333        }
334        fn emph(&self, content: Self::Output) -> Self::Output {
335            format!("emph({content})")
336        }
337        fn strong(&self, content: Self::Output) -> Self::Output {
338            format!("strong({content})")
339        }
340        fn small_caps(&self, content: Self::Output) -> Self::Output {
341            format!("sc({content})")
342        }
343        fn superscript(&self, content: Self::Output) -> Self::Output {
344            format!("sup({content})")
345        }
346        fn affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
347            format!("{prefix}{content}{suffix}")
348        }
349        fn inner_affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
350            format!("{prefix}{content}{suffix}")
351        }
352        fn wrap_punctuation(
353            &self,
354            _wrap: &WrapPunctuation,
355            content: Self::Output,
356            _marks: &QuoteMarks,
357        ) -> Self::Output {
358            content
359        }
360        fn semantic(&self, class: &str, content: Self::Output) -> Self::Output {
361            format!("sem[{class}]({content})")
362        }
363        fn annotation(&self, content: Self::Output) -> Self::Output {
364            format!("annot({content})")
365        }
366        fn link(&self, url: &str, content: Self::Output) -> Self::Output {
367            format!("link[{url}]({content})")
368        }
369    }
370
371    #[test]
372    fn test_default_methods() {
373        let fmt = DummyFormat;
374        assert_eq!(
375            fmt.semantic_with_attributes("test", "content".to_string(), &[]),
376            "sem[test](content)"
377        );
378        assert_eq!(
379            fmt.citation(vec!["id1".to_string()], "content".to_string()),
380            "content"
381        );
382        assert_eq!(fmt.format_id("id1"), "id1");
383        assert_eq!(
384            fmt.bibliography(vec!["entry1".to_string(), "entry2".to_string()]),
385            "entry1\n\nentry2"
386        );
387        assert_eq!(
388            fmt.entry(
389                "id1",
390                "content".to_string(),
391                None,
392                &ProcEntryMetadata::default()
393            ),
394            "content"
395        );
396    }
397}