Skip to main content

citum_engine/render/
org.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Org-mode output format.
7
8use super::format::{OutputFormat, QuoteMarks, realize_wrap};
9use crate::values::ScriptClass;
10use citum_schema::template::WrapPunctuation;
11
12/// Renders processed citations and bibliography entries as org-mode markup.
13///
14/// Does not override [`OutputFormat::visible_runs`]: org-mode's `/.../`,
15/// `*...*`, `~...~`, `^...^` markers are unescaped single characters (see
16/// [`OutputFormat::text`] below), so a data field containing a literal
17/// instance of one is already indistinguishable from markup in the rendered
18/// output itself — a lexer here could not resolve that ambiguity any better
19/// than the renderer did. Boundary/dedup logic for this backend falls back
20/// to the raw text default, a known, documented gap (see bean `csl26-ztxq`).
21#[derive(Default, Clone)]
22pub struct OrgOutputFormat;
23
24impl OutputFormat for OrgOutputFormat {
25    type Output = String;
26
27    fn text(&self, s: &str) -> Self::Output {
28        s.to_string()
29    }
30
31    fn join(&self, items: Vec<Self::Output>, delimiter: &str) -> Self::Output {
32        items.join(delimiter)
33    }
34
35    fn finish(&self, output: Self::Output) -> String {
36        output
37    }
38
39    /// Render a heading using org-mode stars (`*`, `**`, ...).
40    fn heading(&self, level: u8, content: Self::Output) -> Self::Output {
41        let marks = "*".repeat(level.max(1) as usize);
42        format!("{marks} {content}\n\n")
43    }
44
45    /// Render content with emphasis (italics in org-mode: /text/).
46    fn emph(&self, content: Self::Output) -> Self::Output {
47        if content.is_empty() {
48            return content;
49        }
50        format!("/{content}/")
51    }
52
53    /// Render content with strong emphasis (bold in org-mode: *text*).
54    fn strong(&self, content: Self::Output) -> Self::Output {
55        if content.is_empty() {
56            return content;
57        }
58        format!("*{content}*")
59    }
60
61    /// Render content in small capitals (org-mode uses ~text~).
62    fn small_caps(&self, content: Self::Output) -> Self::Output {
63        if content.is_empty() {
64            return content;
65        }
66        format!("~{content}~")
67    }
68
69    fn superscript(&self, content: Self::Output) -> Self::Output {
70        if content.is_empty() {
71            return content;
72        }
73        format!("^{content}^")
74    }
75
76    fn quote(&self, content: Self::Output, marks: &QuoteMarks) -> Self::Output {
77        if content.is_empty() {
78            return content;
79        }
80        // Org-mode doesn't have native quotation marks; use the locale marks as-is.
81        let (open, close) = marks.for_depth(0);
82        format!("{open}{content}{close}")
83    }
84
85    fn affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
86        format!("{prefix}{content}{suffix}")
87    }
88
89    fn inner_affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
90        format!("{prefix}{content}{suffix}")
91    }
92
93    fn wrap_punctuation(
94        &self,
95        wrap: &WrapPunctuation,
96        content: Self::Output,
97        marks: &QuoteMarks,
98        script: ScriptClass,
99        realization: Option<&citum_schema::options::PunctuationRealization>,
100    ) -> Self::Output {
101        match realize_wrap(wrap, script, realization) {
102            Some((open, close)) => {
103                format!("{}{}{}", self.text(&open), content, self.text(&close))
104            }
105            None => self.quote(content, marks),
106        }
107    }
108
109    fn semantic(&self, _class: &str, content: Self::Output) -> Self::Output {
110        // Org-mode doesn't support semantic classes; just return the content
111        content
112    }
113
114    fn annotation(&self, content: Self::Output) -> Self::Output {
115        if content.is_empty() {
116            return content;
117        }
118        format!(
119            "\n\n#+begin_citum_annotation\n{}\n#+end_citum_annotation",
120            content
121        )
122    }
123
124    /// Render a hyperlink in org-mode format: `[[url][text]]`
125    fn link(&self, url: &str, content: Self::Output) -> Self::Output {
126        format!("[[{url}][{content}]]")
127    }
128
129    fn entry(
130        &self,
131        _id: &str,
132        content: Self::Output,
133        _url: Option<&str>,
134        _metadata: &super::format::ProcEntryMetadata,
135    ) -> Self::Output {
136        content
137    }
138}
139
140#[cfg(test)]
141#[allow(
142    clippy::unwrap_used,
143    clippy::expect_used,
144    clippy::panic,
145    clippy::indexing_slicing,
146    clippy::todo,
147    clippy::unimplemented,
148    clippy::unreachable,
149    clippy::get_unwrap,
150    reason = "Panicking is acceptable and often desired in tests."
151)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn test_org_emph() {
157        let fmt = OrgOutputFormat;
158        let result = fmt.emph(fmt.text("italic text"));
159        assert_eq!(result, "/italic text/");
160    }
161
162    #[test]
163    fn test_org_strong() {
164        let fmt = OrgOutputFormat;
165        let result = fmt.strong(fmt.text("bold text"));
166        assert_eq!(result, "*bold text*");
167    }
168
169    #[test]
170    fn test_org_small_caps() {
171        let fmt = OrgOutputFormat;
172        let result = fmt.small_caps(fmt.text("small caps"));
173        assert_eq!(result, "~small caps~");
174    }
175
176    #[test]
177    fn test_org_link() {
178        let fmt = OrgOutputFormat;
179        let result = fmt.link("https://example.com", fmt.text("Example"));
180        assert_eq!(result, "[[https://example.com][Example]]");
181    }
182
183    #[test]
184    fn test_org_empty_content() {
185        let fmt = OrgOutputFormat;
186        let result = fmt.emph(fmt.text(""));
187        assert_eq!(result, "");
188    }
189}