Skip to main content

citum_engine/render/
plain.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Plain text output format.
7
8use super::format::{OutputFormat, QuoteMarks};
9use citum_schema::template::WrapPunctuation;
10
11#[derive(Default, Clone)]
12/// Renders processed citations and bibliography entries as plain text.
13pub struct PlainText;
14
15impl OutputFormat for PlainText {
16    type Output = String;
17
18    fn text(&self, s: &str) -> Self::Output {
19        s.to_string()
20    }
21
22    fn join(&self, items: Vec<Self::Output>, delimiter: &str) -> Self::Output {
23        items.join(delimiter)
24    }
25
26    fn finish(&self, output: Self::Output) -> String {
27        output
28    }
29
30    fn heading(&self, level: u8, content: Self::Output) -> Self::Output {
31        let marks = "#".repeat(level.max(1) as usize);
32        format!("{marks} {content}\n\n")
33    }
34
35    fn emph(&self, content: Self::Output) -> Self::Output {
36        if content.is_empty() {
37            return content;
38        }
39        format!("_{content}_")
40    }
41
42    fn strong(&self, content: Self::Output) -> Self::Output {
43        if content.is_empty() {
44            return content;
45        }
46        format!("**{content}**")
47    }
48
49    fn small_caps(&self, content: Self::Output) -> Self::Output {
50        content.to_uppercase()
51    }
52
53    fn superscript(&self, content: Self::Output) -> Self::Output {
54        if content.is_empty() {
55            return content;
56        }
57        format!("^{content}^")
58    }
59
60    fn quote(&self, content: Self::Output, marks: &QuoteMarks) -> Self::Output {
61        if content.is_empty() {
62            return content;
63        }
64        let (open, close) = marks.for_depth(0);
65        format!("{open}{content}{close}")
66    }
67
68    fn affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
69        format!("{prefix}{content}{suffix}")
70    }
71
72    fn inner_affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
73        format!("{prefix}{content}{suffix}")
74    }
75
76    fn wrap_punctuation(
77        &self,
78        wrap: &WrapPunctuation,
79        content: Self::Output,
80        marks: &QuoteMarks,
81    ) -> Self::Output {
82        match wrap {
83            WrapPunctuation::Parentheses => format!("({content})"),
84            WrapPunctuation::Brackets => format!("[{content}]"),
85            WrapPunctuation::Quotes => self.quote(content, marks),
86        }
87    }
88
89    fn semantic(&self, _class: &str, content: Self::Output) -> Self::Output {
90        // Plain text ignores semantic classes
91        content
92    }
93
94    fn annotation(&self, content: Self::Output) -> Self::Output {
95        if content.is_empty() {
96            return content;
97        }
98
99        format!("\n\n{content}")
100    }
101
102    fn link(&self, _url: &str, content: Self::Output) -> Self::Output {
103        // Plain text just renders the text content of the link
104        content
105    }
106
107    fn entry(
108        &self,
109        _id: &str,
110        content: Self::Output,
111        _url: Option<&str>,
112        _metadata: &super::format::ProcEntryMetadata,
113    ) -> Self::Output {
114        content
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn small_caps_preserves_empty_text() {
124        let fmt = PlainText;
125
126        assert_eq!(fmt.small_caps(String::new()), "");
127    }
128
129    #[test]
130    fn small_caps_uppercases_plain_text() {
131        let fmt = PlainText;
132
133        assert_eq!(
134            fmt.small_caps("Smith and Lumière".to_string()),
135            "SMITH AND LUMIÈRE"
136        );
137    }
138}