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