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::template::WrapPunctuation;
9
10/// Return Unicode quote marks for a nesting depth.
11///
12/// Even depths use outer double quotes; odd depths use inner single quotes.
13#[must_use]
14pub fn unicode_quote_marks(depth: usize) -> (&'static str, &'static str) {
15    if depth.is_multiple_of(2) {
16        ("\u{201C}", "\u{201D}")
17    } else {
18        ("\u{2018}", "\u{2019}")
19    }
20}
21
22/// Extra attributes applied to semantic wrappers when a renderer supports them.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct SemanticAttribute {
25    /// The attribute name.
26    pub name: &'static str,
27    /// The attribute value.
28    pub value: String,
29}
30
31/// Trait for defining how to render template components into a specific format.
32///
33/// Implementations of this trait define how various formatting instructions
34/// (emphasis, quotes, links, etc.) are translated into specific markup or text.
35pub trait OutputFormat: Default + Clone {
36    /// The type used for intermediate rendered content.
37    ///
38    /// For simple text formats, this is usually `String`. More complex formats
39    /// might use an AST or a specialized builder type.
40    type Output;
41
42    /// Convert a raw string into the format's output type.
43    ///
44    /// The implementation should handle any necessary character escaping
45    /// required by the target format.
46    fn text(&self, s: &str) -> Self::Output;
47
48    /// Join multiple outputs into a single output using a delimiter.
49    fn join(&self, items: Vec<Self::Output>, delimiter: &str) -> Self::Output;
50
51    /// Convert the intermediate output into the final result string.
52    ///
53    /// This is called exactly once at the end of the rendering process
54    /// for a top-level component (citation or bibliography entry).
55    fn finish(&self, output: Self::Output) -> String;
56
57    /// Render content with emphasis (typically italics).
58    fn emph(&self, content: Self::Output) -> Self::Output;
59
60    /// Render content with strong emphasis (typically bold).
61    fn strong(&self, content: Self::Output) -> Self::Output;
62
63    /// Render content in small capitals.
64    fn small_caps(&self, content: Self::Output) -> Self::Output;
65
66    /// Render content as superscript text.
67    fn superscript(&self, content: Self::Output) -> Self::Output;
68
69    /// Return the opening and closing quote delimiters for a nesting depth.
70    ///
71    /// Depth 0 is an outer quote pair, depth 1 is the first inner quote pair,
72    /// and deeper levels alternate between those two pairs.
73    fn quote_marks(&self, depth: usize) -> (&'static str, &'static str) {
74        unicode_quote_marks(depth)
75    }
76
77    /// Render content enclosed in quotation marks at a specific nesting depth.
78    fn quote_with_depth(&self, content: Self::Output, depth: usize) -> Self::Output {
79        let (open, close) = self.quote_marks(depth);
80        self.affix(open, content, close)
81    }
82
83    /// Render content enclosed in outer quotation marks.
84    fn quote(&self, content: Self::Output) -> Self::Output {
85        self.quote_with_depth(content, 0)
86    }
87
88    /// Apply outer prefix and suffix strings to the content.
89    ///
90    /// These are typically the "prefix" and "suffix" fields from the Citum style.
91    fn affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output;
92
93    /// Apply inner prefix and suffix strings to the content.
94    ///
95    /// These are applied inside any wrapping punctuation.
96    fn inner_affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output;
97
98    /// Wrap the content in specific punctuation (parentheses, brackets, or quotes).
99    fn wrap_punctuation(&self, wrap: &WrapPunctuation, content: Self::Output) -> Self::Output;
100
101    /// Apply a semantic identifier (class) to the content.
102    ///
103    /// This is used for machine readability or fine-grained CSS styling.
104    /// Examples include "citum-title", "citum-author", "citum-doi".
105    fn semantic(&self, class: &str, content: Self::Output) -> Self::Output;
106
107    /// Render an annotation block.
108    ///
109    /// This is typically called at the end of a bibliography entry to render
110    /// reader-supplied notes.
111    fn annotation(&self, content: Self::Output) -> Self::Output;
112
113    /// Apply a semantic identifier plus optional attributes to the content.
114    ///
115    /// Formats that do not support extra attributes can ignore them and reuse
116    /// [`Self::semantic`].
117    fn semantic_with_attributes(
118        &self,
119        class: &str,
120        content: Self::Output,
121        _attributes: &[SemanticAttribute],
122    ) -> Self::Output {
123        self.semantic(class, content)
124    }
125
126    /// Render a full citation container with one or more reference IDs.
127    fn citation(&self, _ids: Vec<String>, content: Self::Output) -> Self::Output {
128        content
129    }
130
131    /// Hyperlink the content to a URL.
132    fn link(&self, url: &str, content: Self::Output) -> Self::Output;
133
134    /// Format a reference ID for use as a target or link (e.g. adding a prefix).
135    fn format_id(&self, id: &str) -> String {
136        id.to_string()
137    }
138
139    /// Render a full bibliography container.
140    ///
141    /// The default implementation joins the entries with double newlines.
142    fn bibliography(&self, entries: Vec<Self::Output>) -> Self::Output {
143        self.join(entries, "\n\n")
144    }
145
146    /// Render a single bibliography entry with its unique identifier and optional link.
147    ///
148    /// The default implementation just returns the content.
149    fn entry(
150        &self,
151        _id: &str,
152        content: Self::Output,
153        _url: Option<&str>,
154        _metadata: &ProcEntryMetadata,
155    ) -> Self::Output {
156        content
157    }
158}
159
160/// Metadata for a processed bibliography entry, used for interactivity.
161#[derive(Debug, Clone, Default, PartialEq)]
162pub struct ProcEntryMetadata {
163    /// Rendered primary author(s) string.
164    pub author: Option<String>,
165    /// Rendered year string.
166    pub year: Option<String>,
167    /// Rendered title string.
168    pub title: Option<String>,
169}
170
171#[cfg(test)]
172#[allow(
173    clippy::unwrap_used,
174    clippy::expect_used,
175    clippy::panic,
176    clippy::indexing_slicing,
177    clippy::todo,
178    clippy::unimplemented,
179    clippy::unreachable,
180    clippy::get_unwrap,
181    reason = "Panicking is acceptable and often desired in tests."
182)]
183mod tests {
184    use super::*;
185
186    #[derive(Default, Clone)]
187    struct DummyFormat;
188
189    impl OutputFormat for DummyFormat {
190        type Output = String;
191        fn text(&self, s: &str) -> Self::Output {
192            s.to_string()
193        }
194        fn join(&self, items: Vec<Self::Output>, delimiter: &str) -> Self::Output {
195            items.join(delimiter)
196        }
197        fn finish(&self, output: Self::Output) -> String {
198            output
199        }
200        fn emph(&self, content: Self::Output) -> Self::Output {
201            format!("emph({content})")
202        }
203        fn strong(&self, content: Self::Output) -> Self::Output {
204            format!("strong({content})")
205        }
206        fn small_caps(&self, content: Self::Output) -> Self::Output {
207            format!("sc({content})")
208        }
209        fn superscript(&self, content: Self::Output) -> Self::Output {
210            format!("sup({content})")
211        }
212        fn affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
213            format!("{prefix}{content}{suffix}")
214        }
215        fn inner_affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
216            format!("{prefix}{content}{suffix}")
217        }
218        fn wrap_punctuation(&self, _wrap: &WrapPunctuation, content: Self::Output) -> Self::Output {
219            content
220        }
221        fn semantic(&self, class: &str, content: Self::Output) -> Self::Output {
222            format!("sem[{class}]({content})")
223        }
224        fn annotation(&self, content: Self::Output) -> Self::Output {
225            format!("annot({content})")
226        }
227        fn link(&self, url: &str, content: Self::Output) -> Self::Output {
228            format!("link[{url}]({content})")
229        }
230    }
231
232    #[test]
233    fn test_default_methods() {
234        let fmt = DummyFormat;
235        assert_eq!(
236            fmt.semantic_with_attributes("test", "content".to_string(), &[]),
237            "sem[test](content)"
238        );
239        assert_eq!(
240            fmt.citation(vec!["id1".to_string()], "content".to_string()),
241            "content"
242        );
243        assert_eq!(fmt.format_id("id1"), "id1");
244        assert_eq!(
245            fmt.bibliography(vec!["entry1".to_string(), "entry2".to_string()]),
246            "entry1\n\nentry2"
247        );
248        assert_eq!(
249            fmt.entry(
250                "id1",
251                "content".to_string(),
252                None,
253                &ProcEntryMetadata::default()
254            ),
255            "content"
256        );
257    }
258}