Skip to main content

citum_engine/render/
rich_text.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Djot and org-mode inline markup rendering for free-text fields.
7
8use super::format::{OutputFormat, QuoteMarks};
9use jotdown::{Attributes, Container, Event, Parser};
10
11/// Ambient context used while rendering inline rich text.
12#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
13pub struct InlineRenderContext {
14    /// Quote nesting depth inherited from an outer template wrapper.
15    pub quote_depth: usize,
16}
17
18#[derive(Default)]
19struct DjotFrame {
20    children: Vec<String>,
21    classes: Vec<String>,
22    link_url: Option<String>,
23    has_explicit_link: bool,
24    last_char: Option<char>,
25    /// True when this frame (or an ancestor) carries `.nocase` protection.
26    case_protected: bool,
27}
28
29impl DjotFrame {
30    fn push_rendered(&mut self, rendered: String, logical_last_char: Option<char>) {
31        self.children.push(rendered);
32        if let Some(ch) = logical_last_char {
33            self.last_char = Some(ch);
34        }
35    }
36
37    fn prev_opens_quote(&self) -> bool {
38        self.last_char
39            .is_none_or(|c| c.is_whitespace() || "([{\u{2018}\u{201C}'\"".contains(c))
40    }
41}
42
43fn opening_quote_depth(
44    context: InlineRenderContext,
45    current_depth: usize,
46    source_inner: bool,
47) -> usize {
48    if source_inner && current_depth <= context.quote_depth {
49        context.quote_depth + 1
50    } else {
51        current_depth
52    }
53}
54
55fn push_open_quote<F: OutputFormat<Output = String>>(
56    frame: &mut DjotFrame,
57    fmt: &F,
58    depth: usize,
59    marks: &QuoteMarks,
60) {
61    let (open, _) = fmt.quote_marks(depth, marks);
62    let logical_char = open.chars().next();
63    frame.push_rendered(fmt.text(open), logical_char);
64}
65
66fn push_close_quote<F: OutputFormat<Output = String>>(
67    frame: &mut DjotFrame,
68    fmt: &F,
69    depth: usize,
70    marks: &QuoteMarks,
71) {
72    let (_, close) = fmt.quote_marks(depth, marks);
73    let logical_char = close.chars().last();
74    frame.push_rendered(fmt.text(close), logical_char);
75}
76
77struct QuoteRenderState {
78    depth: usize,
79    stack: Vec<usize>,
80    // Inline djot/org markup has no locale in scope here, so this falls back to
81    // the Unicode default; the primary `quote`/`wrap: quotes` template path (see
82    // `render::component`) uses the resolved locale marks. Cached once per render
83    // rather than per quote token to avoid repeated `String` allocations.
84    marks: QuoteMarks,
85}
86
87impl QuoteRenderState {
88    fn new(context: InlineRenderContext) -> Self {
89        Self {
90            depth: context.quote_depth,
91            stack: Vec::new(),
92            marks: QuoteMarks::default(),
93        }
94    }
95
96    fn render_event<F: OutputFormat<Output = String>>(
97        &mut self,
98        frame: &mut DjotFrame,
99        fmt: &F,
100        context: InlineRenderContext,
101        source_inner: bool,
102        opens_quote: bool,
103    ) {
104        if opens_quote {
105            let depth = opening_quote_depth(context, self.depth, source_inner);
106            push_open_quote(frame, fmt, depth, &self.marks);
107            self.stack.push(depth);
108            self.depth = depth + 1;
109        } else {
110            let fallback_depth = context.quote_depth + usize::from(source_inner);
111            let depth = self.stack.pop().unwrap_or(fallback_depth);
112            push_close_quote(frame, fmt, depth, &self.marks);
113            self.depth = self.stack.last().map_or(context.quote_depth, |d| d + 1);
114        }
115    }
116}
117
118fn span_classes(attrs: Option<&Attributes>) -> Vec<String> {
119    attrs
120        .into_iter()
121        .flat_map(|attrs| attrs.iter())
122        .filter_map(|(kind, val)| {
123            use jotdown::AttributeKind;
124            if matches!(kind, AttributeKind::Class) {
125                Some(val.to_string())
126            } else {
127                None
128            }
129        })
130        .flat_map(|classes| {
131            classes
132                .split_whitespace()
133                .map(std::string::ToString::to_string)
134                .collect::<Vec<_>>()
135        })
136        .collect()
137}
138
139/// Process a djot End container event, applying formatting and merging into parent frame.
140fn handle_end_event<F: OutputFormat<Output = String>>(
141    container: Container,
142    frame: DjotFrame,
143    parent: &mut DjotFrame,
144    fmt: &F,
145) {
146    let inner_text = frame.children.join("");
147    let formatted = match container {
148        Container::Emphasis => fmt.emph(inner_text),
149        Container::Strong => fmt.strong(inner_text),
150        Container::Link(_, _) => {
151            if let Some(url) = frame.link_url.as_deref() {
152                fmt.link(url, inner_text)
153            } else {
154                inner_text
155            }
156        }
157        Container::Span => {
158            if frame
159                .classes
160                .iter()
161                .any(|class| class == "smallcaps" || class == "small-caps")
162            {
163                fmt.small_caps(inner_text)
164            } else {
165                inner_text
166            }
167        }
168        _ => inner_text,
169    };
170    parent.push_rendered(formatted, frame.last_char);
171    parent.has_explicit_link |= frame.has_explicit_link;
172}
173
174fn render_djot_inline_internal<F, G>(
175    src: &str,
176    fmt: &F,
177    context: InlineRenderContext,
178    mut transform_text: G,
179) -> (String, bool)
180where
181    F: OutputFormat<Output = String>,
182    G: FnMut(&str) -> String,
183{
184    let parser = Parser::new(src);
185    let mut stack = vec![DjotFrame::default()];
186    let mut quote_state = QuoteRenderState::new(context);
187
188    for event in parser {
189        match event {
190            Event::Start(container, attrs) => {
191                let link_url = if let Container::Link(url, _) = &container {
192                    Some(url.to_string())
193                } else {
194                    None
195                };
196                let classes = span_classes(Some(&attrs));
197                let parent_protected = stack.last().is_some_and(|f| f.case_protected);
198                let is_nocase = classes.iter().any(|c| c == "nocase");
199                stack.push(DjotFrame {
200                    case_protected: parent_protected || is_nocase,
201                    has_explicit_link: link_url.is_some(),
202                    link_url,
203                    classes,
204                    ..Default::default()
205                });
206            }
207            Event::End(container) => {
208                if let (Some(frame), Some(parent)) = (stack.pop(), stack.last_mut()) {
209                    handle_end_event(container, frame, parent, fmt);
210                }
211            }
212            Event::Str(s) => {
213                if let Some(frame) = stack.last_mut() {
214                    // Always call transform_text so stateful transforms (e.g., sentence-case)
215                    // can update their internal state, even for .nocase-protected spans.
216                    let transformed = transform_text(s.as_ref());
217                    let render_text = if frame.case_protected {
218                        s.to_string()
219                    } else {
220                        transformed
221                    };
222                    frame.push_rendered(fmt.text(&render_text), render_text.chars().last());
223                }
224            }
225            Event::Symbol(sym) => {
226                if let Some(frame) = stack.last_mut() {
227                    frame.push_rendered(fmt.text(sym.as_ref()), sym.chars().last());
228                }
229            }
230            Event::LeftSingleQuote => {
231                if let Some(frame) = stack.last_mut() {
232                    quote_state.render_event(frame, fmt, context, true, true);
233                }
234            }
235            Event::RightSingleQuote => {
236                if let Some(frame) = stack.last_mut() {
237                    quote_state.render_event(frame, fmt, context, true, frame.prev_opens_quote());
238                }
239            }
240            Event::LeftDoubleQuote => {
241                if let Some(frame) = stack.last_mut() {
242                    quote_state.render_event(frame, fmt, context, false, true);
243                }
244            }
245            Event::RightDoubleQuote => {
246                if let Some(frame) = stack.last_mut() {
247                    quote_state.render_event(frame, fmt, context, false, frame.prev_opens_quote());
248                }
249            }
250            Event::Softbreak | Event::Hardbreak => {
251                if let Some(frame) = stack.last_mut() {
252                    frame.push_rendered(fmt.text(" "), Some(' '));
253                }
254            }
255            _ => {}
256        }
257    }
258
259    stack
260        .into_iter()
261        .next()
262        .map(|frame| (frame.children.join(""), frame.has_explicit_link))
263        .unwrap_or_default()
264}
265
266/// Render djot inline markup and map events to `OutputFormat` methods.
267///
268/// Parses the input as djot inline markup and transforms container and text
269/// events into formatted output. Block-level containers are collapsed to their
270/// text content. Inline containers (emphasis, strong, links, etc.) are rendered
271/// using the format's methods.
272///
273/// # Arguments
274/// * `src` - Input string with djot inline markup
275/// * `fmt` - `OutputFormat` implementation for rendering
276///
277/// # Returns
278/// Formatted string with markup applied according to the `OutputFormat`'s methods
279pub fn render_djot_inline<F: OutputFormat<Output = String>>(src: &str, fmt: &F) -> String {
280    render_djot_inline_internal(src, fmt, InlineRenderContext::default(), str::to_string).0
281}
282
283/// Render djot inline markup with an ambient inline rendering context.
284pub fn render_djot_inline_with_context<F: OutputFormat<Output = String>>(
285    src: &str,
286    fmt: &F,
287    context: InlineRenderContext,
288) -> String {
289    render_djot_inline_internal(src, fmt, context, str::to_string).0
290}
291
292/// Render djot inline markup while transforming text leaves and returning link metadata.
293pub(crate) fn render_djot_inline_with_transform<F, G>(
294    src: &str,
295    fmt: &F,
296    transform_text: G,
297) -> (String, bool)
298where
299    F: OutputFormat<Output = String>,
300    G: FnMut(&str) -> String,
301{
302    render_djot_inline_internal(src, fmt, InlineRenderContext::default(), transform_text)
303}
304
305/// Render djot inline markup with text transforms and ambient context.
306pub(crate) fn render_djot_inline_with_transform_and_context<F, G>(
307    src: &str,
308    fmt: &F,
309    context: InlineRenderContext,
310    transform_text: G,
311) -> (String, bool)
312where
313    F: OutputFormat<Output = String>,
314    G: FnMut(&str) -> String,
315{
316    render_djot_inline_internal(src, fmt, context, transform_text)
317}
318
319/// Render org-mode inline markup by walking the orgize event stream.
320///
321/// Parses `src` as org-mode and maps inline elements to `OutputFormat` methods:
322/// bold (`*text*`) → `strong`, italic (`/text/`) → `emph`, verbatim/code →
323/// `text` (stripped), links (`[[url][desc]]`) → `link`, plain text → `text`.
324/// Container elements (Bold, Italic) are collected via a stack so nested
325/// markup is handled correctly.
326pub fn render_org_inline<F: OutputFormat<Output = String>>(src: &str, fmt: &F) -> String {
327    use orgize::Event;
328    use orgize::Org;
329    use orgize::elements::Element;
330
331    let org = Org::parse(src);
332    // Stack of (tag, accumulated_children) for open containers.
333    // Tags: 0 = Bold, 1 = Italic, 2 = root paragraph accumulator.
334    let mut stack: Vec<(u8, String)> = vec![(2, String::new())];
335
336    for event in org.iter() {
337        match event {
338            Event::Start(Element::Bold) => stack.push((0, String::new())),
339            Event::Start(Element::Italic) => stack.push((1, String::new())),
340            Event::End(Element::Bold) => {
341                if let Some((0, inner)) = stack.pop() {
342                    let rendered = fmt.strong(inner);
343                    if let Some(top) = stack.last_mut() {
344                        top.1.push_str(&rendered);
345                    }
346                }
347            }
348            Event::End(Element::Italic) => {
349                if let Some((1, inner)) = stack.pop() {
350                    let rendered = fmt.emph(inner);
351                    if let Some(top) = stack.last_mut() {
352                        top.1.push_str(&rendered);
353                    }
354                }
355            }
356            Event::Start(Element::Link(link)) => {
357                let desc = link.desc.as_deref().unwrap_or(&link.path);
358                let rendered = fmt.link(&link.path, fmt.text(desc));
359                if let Some(top) = stack.last_mut() {
360                    top.1.push_str(&rendered);
361                }
362            }
363            Event::Start(Element::Text { value }) => {
364                if let Some(top) = stack.last_mut() {
365                    top.1.push_str(&fmt.text(value));
366                }
367            }
368            Event::Start(Element::Verbatim { value } | Element::Code { value }) => {
369                if let Some(top) = stack.last_mut() {
370                    top.1.push_str(&fmt.text(value));
371                }
372            }
373            _ => {}
374        }
375    }
376
377    stack.into_iter().next().map(|(_, s)| s).unwrap_or_default()
378}
379
380#[cfg(test)]
381#[allow(
382    clippy::unwrap_used,
383    clippy::expect_used,
384    clippy::panic,
385    clippy::indexing_slicing,
386    clippy::todo,
387    clippy::unimplemented,
388    clippy::unreachable,
389    clippy::get_unwrap,
390    reason = "Panicking is acceptable and often desired in tests."
391)]
392mod tests {
393    use super::*;
394    use crate::render::html::Html;
395    use crate::render::plain::PlainText;
396    use crate::render::typst::Typst;
397
398    #[test]
399    fn test_djot_emphasis_plain() {
400        let fmt = PlainText;
401        let result = render_djot_inline("_foo_", &fmt);
402        // PlainText.emph() wraps content in _..._
403        assert_eq!(result, "_foo_");
404    }
405
406    #[test]
407    fn test_djot_strong_single_asterisk() {
408        let fmt = PlainText;
409        // jotdown uses * for strong (bold), not **
410        let result = render_djot_inline("*bar*", &fmt);
411        // PlainText.strong() wraps content in **...**
412        assert_eq!(result, "**bar**");
413    }
414
415    #[test]
416    fn test_djot_unicode_math() {
417        let fmt = PlainText;
418        let result = render_djot_inline("H₂O", &fmt);
419        assert_eq!(result, "H₂O");
420    }
421
422    #[test]
423    fn test_djot_plain_no_markup() {
424        let fmt = PlainText;
425        let result = render_djot_inline("plain text with no markup", &fmt);
426        assert_eq!(result, "plain text with no markup");
427    }
428
429    #[test]
430    fn test_djot_combined_formatting() {
431        let fmt = PlainText;
432        // In djot, _text_ is emphasis and *text* is strong
433        let result = render_djot_inline("_emphasized *bold* text_", &fmt);
434        // Emphasis wraps in _..._. Inside that, strong wraps in **...**
435        assert_eq!(result, "_emphasized **bold** text_");
436    }
437
438    #[test]
439    fn test_djot_link() {
440        let fmt = PlainText;
441        // In djot, [text](url) is a link
442        let result = render_djot_inline("[click here](https://example.com)", &fmt);
443        // PlainText.link() just renders the link text (ignores URL)
444        assert_eq!(result, "click here");
445    }
446
447    #[test]
448    fn test_djot_nested_formatting_preserves_typst_markup() {
449        let fmt = Typst;
450        let result = render_djot_inline("_emphasized *bold* text_", &fmt);
451        assert_eq!(result, "#emph[emphasized #strong[bold] text]");
452    }
453
454    #[test]
455    fn test_djot_nested_link_preserves_inner_markup_html() {
456        let fmt = Html;
457        let result = render_djot_inline("[_linked emphasis_](https://example.com)", &fmt);
458        assert_eq!(
459            result,
460            r#"<a href="https://example.com"><em>linked emphasis</em></a>"#
461        );
462    }
463
464    #[test]
465    fn test_djot_quotes_inside_emphasis_open_correctly() {
466        let fmt = PlainText;
467        let result = render_djot_inline("_\"Parmenides\" dialogue_", &fmt);
468        assert_eq!(result, "_“Parmenides” dialogue_");
469    }
470
471    #[test]
472    fn test_djot_quotes_with_ambient_quote_depth_use_inner_marks() {
473        let fmt = PlainText;
474        let result = render_djot_inline_with_context(
475            "\"Parmenides\" dialogue",
476            &fmt,
477            InlineRenderContext { quote_depth: 1 },
478        );
479        assert_eq!(result, "‘Parmenides’ dialogue");
480    }
481
482    #[test]
483    fn test_djot_nested_quotes_alternate_marks() {
484        let fmt = PlainText;
485        let result = render_djot_inline("\"outer \"inner\" claim\"", &fmt);
486        assert_eq!(result, "“outer ‘inner’ claim”");
487    }
488
489    #[test]
490    fn test_djot_quotes_inside_emphasis_use_ambient_quote_depth() {
491        let fmt = PlainText;
492        let result = render_djot_inline_with_context(
493            "_\"Parmenides\" dialogue_",
494            &fmt,
495            InlineRenderContext { quote_depth: 1 },
496        );
497        assert_eq!(result, "_‘Parmenides’ dialogue_");
498    }
499
500    #[test]
501    fn test_org_plain_text() {
502        let fmt = PlainText;
503        let result = render_org_inline("plain text with no markup", &fmt);
504        assert_eq!(result, "plain text with no markup");
505    }
506
507    #[test]
508    fn test_org_bold() {
509        let fmt = PlainText;
510        // PlainText.strong() wraps in **...**
511        let result = render_org_inline("*bold*", &fmt);
512        assert_eq!(result, "**bold**");
513    }
514
515    #[test]
516    fn test_org_italic() {
517        let fmt = PlainText;
518        // PlainText.emph() wraps in _..._
519        let result = render_org_inline("/italic/", &fmt);
520        assert_eq!(result, "_italic_");
521    }
522}