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 crate::values::ScriptClass;
12use citum_schema::locale::GrammarOptions;
13use citum_schema::options::PunctuationRealization;
14use citum_schema::template::{DelimiterPunctuation, WrapPunctuation};
15
16/// Position in which a semantic punctuation mark is realized.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub(crate) enum PunctuationPosition {
19    /// A separator between rendered values.
20    Separator,
21    /// An affix before rendered content.
22    Prefix,
23    /// An affix after rendered content.
24    Suffix,
25}
26
27/// Realize literal text or a semantic punctuation mark for a script class.
28///
29/// Literal strings are returned unchanged. Style overrides take precedence
30/// over the engine default table.
31#[must_use]
32pub(crate) fn realize_punctuation<'a>(
33    punctuation: &'a DelimiterPunctuation,
34    script: ScriptClass,
35    overrides: Option<&'a PunctuationRealization>,
36    position: PunctuationPosition,
37) -> Cow<'a, str> {
38    use DelimiterPunctuation as Punctuation;
39
40    let override_value = overrides.and_then(|table| match punctuation {
41        Punctuation::Comma => table.comma.as_deref().map(Cow::Borrowed),
42        Punctuation::Colon => table.colon.as_deref().map(Cow::Borrowed),
43        Punctuation::Semicolon => table.semicolon.as_deref().map(Cow::Borrowed),
44        Punctuation::Period => table.period.as_deref().map(Cow::Borrowed),
45        Punctuation::Parentheses => table
46            .parentheses
47            .as_ref()
48            .map(|pair| pair_mark(pair, position)),
49        Punctuation::Brackets => table
50            .brackets
51            .as_ref()
52            .map(|pair| pair_mark(pair, position)),
53        Punctuation::Ampersand
54        | Punctuation::VerticalLine
55        | Punctuation::Slash
56        | Punctuation::Hyphen
57        | Punctuation::Space
58        | Punctuation::None
59        | Punctuation::Custom(_) => None,
60    });
61    if let Some(value) = override_value {
62        return value;
63    }
64
65    let default = match (punctuation, script, position) {
66        (Punctuation::Comma, ScriptClass::Latin, _) => ", ",
67        (Punctuation::Comma, ScriptClass::Cjk, _) => ",",
68        (Punctuation::Colon, ScriptClass::Latin, _) => ": ",
69        (Punctuation::Colon, ScriptClass::Cjk, _) => ":",
70        (Punctuation::Semicolon, ScriptClass::Latin, _) => "; ",
71        (Punctuation::Semicolon, ScriptClass::Cjk, _) => ";",
72        (Punctuation::Period, ScriptClass::Latin, _) => ". ",
73        (Punctuation::Period, ScriptClass::Cjk, _) => "。",
74        (Punctuation::Parentheses, ScriptClass::Latin, PunctuationPosition::Prefix) => "(",
75        (Punctuation::Parentheses, ScriptClass::Latin, PunctuationPosition::Suffix) => ")",
76        (Punctuation::Parentheses, ScriptClass::Cjk, PunctuationPosition::Prefix) => "(",
77        (Punctuation::Parentheses, ScriptClass::Cjk, PunctuationPosition::Suffix) => ")",
78        (Punctuation::Brackets, ScriptClass::Latin, PunctuationPosition::Prefix) => "[",
79        (Punctuation::Brackets, ScriptClass::Latin, PunctuationPosition::Suffix) => "]",
80        (Punctuation::Brackets, ScriptClass::Cjk, PunctuationPosition::Prefix) => "【",
81        (Punctuation::Brackets, ScriptClass::Cjk, PunctuationPosition::Suffix) => "】",
82        (Punctuation::Parentheses, ScriptClass::Latin, PunctuationPosition::Separator) => "()",
83        (Punctuation::Parentheses, ScriptClass::Cjk, PunctuationPosition::Separator) => "()",
84        (Punctuation::Brackets, ScriptClass::Latin, PunctuationPosition::Separator) => "[]",
85        (Punctuation::Brackets, ScriptClass::Cjk, PunctuationPosition::Separator) => "【】",
86        (
87            Punctuation::Ampersand
88            | Punctuation::VerticalLine
89            | Punctuation::Slash
90            | Punctuation::Hyphen
91            | Punctuation::Space
92            | Punctuation::None
93            | Punctuation::Custom(_),
94            _,
95            _,
96        ) => return Cow::Borrowed(punctuation.as_default_str()),
97    };
98    Cow::Borrowed(default)
99}
100
101fn pair_mark(pair: &[String; 2], position: PunctuationPosition) -> Cow<'_, str> {
102    match position {
103        PunctuationPosition::Prefix => Cow::Borrowed(pair[0].as_str()),
104        PunctuationPosition::Suffix => Cow::Borrowed(pair[1].as_str()),
105        PunctuationPosition::Separator => Cow::Owned(format!("{}{}", pair[0], pair[1])),
106    }
107}
108
109/// Apply realized punctuation affixes while routing semantic glyphs through
110/// the active output format's text escaping.
111pub(crate) fn apply_punctuation_affixes<F>(
112    fmt: &F,
113    prefix: Option<(&DelimiterPunctuation, &str)>,
114    mut content: String,
115    suffix: Option<(&DelimiterPunctuation, &str)>,
116) -> String
117where
118    F: OutputFormat<Output = String>,
119{
120    if let Some((punctuation, text)) = prefix {
121        content = if punctuation.is_semantic() {
122            fmt.join(vec![fmt.text(text), content], "")
123        } else {
124            fmt.affix(text, content, "")
125        };
126    }
127    if let Some((punctuation, text)) = suffix {
128        content = if punctuation.is_semantic() {
129            fmt.join(vec![content, fmt.text(text)], "")
130        } else {
131            fmt.affix("", content, text)
132        };
133    }
134    content
135}
136
137/// Return Unicode quote marks for a nesting depth.
138///
139/// Even depths use outer double quotes; odd depths use inner single quotes.
140#[must_use]
141pub fn unicode_quote_marks(depth: usize) -> (&'static str, &'static str) {
142    if depth.is_multiple_of(2) {
143        ("\u{201C}", "\u{201D}")
144    } else {
145        ("\u{2018}", "\u{2019}")
146    }
147}
148
149/// Locale-resolved quote mark characters, threaded from
150/// [`GrammarOptions`] through to rendering so that
151/// styles using non-English quotation conventions (e.g. fr-FR guillemets) render correctly.
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct QuoteMarks {
154    /// Opening outer quotation mark.
155    pub open: String,
156    /// Closing outer quotation mark.
157    pub close: String,
158    /// Opening inner (nested) quotation mark.
159    pub open_inner: String,
160    /// Closing inner (nested) quotation mark.
161    pub close_inner: String,
162}
163
164impl QuoteMarks {
165    /// Return the opening and closing quote delimiters for a nesting depth.
166    ///
167    /// Depth 0 (and other even depths) use the outer pair; odd depths use the inner pair.
168    #[must_use]
169    pub fn for_depth(&self, depth: usize) -> (&str, &str) {
170        if depth.is_multiple_of(2) {
171            (&self.open, &self.close)
172        } else {
173            (&self.open_inner, &self.close_inner)
174        }
175    }
176}
177
178impl Default for QuoteMarks {
179    /// The historical hardcoded English fallback, used when no resolved locale is available.
180    fn default() -> Self {
181        let (open, close) = unicode_quote_marks(0);
182        let (open_inner, close_inner) = unicode_quote_marks(1);
183        Self {
184            open: open.to_string(),
185            close: close.to_string(),
186            open_inner: open_inner.to_string(),
187            close_inner: close_inner.to_string(),
188        }
189    }
190}
191
192impl From<&GrammarOptions> for QuoteMarks {
193    fn from(options: &GrammarOptions) -> Self {
194        Self {
195            open: options.open_quote.clone(),
196            close: options.close_quote.clone(),
197            open_inner: options.open_inner_quote.clone(),
198            close_inner: options.close_inner_quote.clone(),
199        }
200    }
201}
202
203/// Extra attributes applied to semantic wrappers when a renderer supports them.
204#[derive(Debug, Clone, PartialEq, Eq)]
205pub struct SemanticAttribute {
206    /// The attribute name.
207    pub name: &'static str,
208    /// The attribute value.
209    pub value: String,
210}
211
212/// Realize a semantic [`WrapPunctuation`] into the `(open, close)` glyph pair
213/// for a script class.
214///
215/// Returns `None` for [`WrapPunctuation::Quotes`], which realizes through
216/// locale-resolved quote marks (`QuoteMarks`) rather than a fixed pair — see
217/// `docs/specs/PUNCTUATION_REALIZATION.md` §2. The table is closed for v1;
218/// new marks or script classes require a spec revision.
219#[must_use]
220pub(crate) fn realize_wrap<'a>(
221    wrap: &WrapPunctuation,
222    script: ScriptClass,
223    overrides: Option<&'a PunctuationRealization>,
224) -> Option<(Cow<'a, str>, Cow<'a, str>)> {
225    if let Some(pair) = overrides.and_then(|table| match wrap {
226        WrapPunctuation::Parentheses => table.parentheses.as_ref(),
227        WrapPunctuation::Brackets => table.brackets.as_ref(),
228        WrapPunctuation::Quotes => None,
229    }) {
230        return Some((
231            Cow::Borrowed(pair[0].as_str()),
232            Cow::Borrowed(pair[1].as_str()),
233        ));
234    }
235
236    match (wrap, script) {
237        (WrapPunctuation::Parentheses, ScriptClass::Latin) => {
238            Some((Cow::Borrowed("("), Cow::Borrowed(")")))
239        }
240        (WrapPunctuation::Parentheses, ScriptClass::Cjk) => {
241            Some((Cow::Borrowed("("), Cow::Borrowed(")")))
242        }
243        (WrapPunctuation::Brackets, ScriptClass::Latin) => {
244            Some((Cow::Borrowed("["), Cow::Borrowed("]")))
245        }
246        (WrapPunctuation::Brackets, ScriptClass::Cjk) => {
247            Some((Cow::Borrowed("【"), Cow::Borrowed("】")))
248        }
249        (WrapPunctuation::Quotes, _) => None,
250    }
251}
252
253/// Trait for defining how to render template components into a specific format.
254///
255/// Implementations of this trait define how various formatting instructions
256/// (emphasis, quotes, links, etc.) are translated into specific markup or text.
257pub trait OutputFormat: Default + Clone {
258    /// The type used for intermediate rendered content.
259    ///
260    /// For simple text formats, this is usually `String`. More complex formats
261    /// might use an AST or a specialized builder type.
262    type Output;
263
264    /// Convert a raw string into the format's output type.
265    ///
266    /// The implementation should handle any necessary character escaping
267    /// required by the target format.
268    fn text(&self, s: &str) -> Self::Output;
269
270    /// Join multiple outputs into a single output using a delimiter.
271    fn join(&self, items: Vec<Self::Output>, delimiter: &str) -> Self::Output;
272
273    /// Convert the intermediate output into the final result string.
274    ///
275    /// This is called exactly once at the end of the rendering process
276    /// for a top-level component (citation or bibliography entry).
277    fn finish(&self, output: Self::Output) -> String;
278
279    /// Render content with emphasis (typically italics).
280    fn emph(&self, content: Self::Output) -> Self::Output;
281
282    /// Render content with strong emphasis (typically bold).
283    fn strong(&self, content: Self::Output) -> Self::Output;
284
285    /// Render content in small capitals.
286    fn small_caps(&self, content: Self::Output) -> Self::Output;
287
288    /// Render content as superscript text.
289    fn superscript(&self, content: Self::Output) -> Self::Output;
290
291    /// Return the opening and closing quote delimiters for a nesting depth.
292    ///
293    /// Depth 0 is an outer quote pair, depth 1 is the first inner quote pair,
294    /// and deeper levels alternate between those two pairs. `marks` carries the
295    /// locale-resolved quote characters; callers with no resolved locale can pass
296    /// `&QuoteMarks::default()` to keep the historical English fallback.
297    fn quote_marks<'a>(&self, depth: usize, marks: &'a QuoteMarks) -> (&'a str, &'a str) {
298        marks.for_depth(depth)
299    }
300
301    /// Render content enclosed in quotation marks at a specific nesting depth.
302    fn quote_with_depth(
303        &self,
304        content: Self::Output,
305        depth: usize,
306        marks: &QuoteMarks,
307    ) -> Self::Output {
308        let (open, close) = self.quote_marks(depth, marks);
309        self.affix(open, content, close)
310    }
311
312    /// Render content enclosed in outer quotation marks.
313    fn quote(&self, content: Self::Output, marks: &QuoteMarks) -> Self::Output {
314        self.quote_with_depth(content, 0, marks)
315    }
316
317    /// Apply outer prefix and suffix strings to the content.
318    ///
319    /// These are typically the "prefix" and "suffix" fields from the Citum style.
320    fn affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output;
321
322    /// Apply inner prefix and suffix strings to the content.
323    ///
324    /// These are applied inside any wrapping punctuation.
325    fn inner_affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output;
326
327    /// Wrap the content in specific punctuation (parentheses, brackets, or quotes).
328    ///
329    /// `marks` supplies the locale-resolved quote characters for the `Quotes`
330    /// variant. `script` selects the half-width or full-width glyph form for
331    /// the `Parentheses`/`Brackets` variants — see `realize_wrap` and
332    /// `docs/specs/PUNCTUATION_REALIZATION.md`.
333    fn wrap_punctuation(
334        &self,
335        wrap: &WrapPunctuation,
336        content: Self::Output,
337        marks: &QuoteMarks,
338        script: ScriptClass,
339        realization: Option<&PunctuationRealization>,
340    ) -> Self::Output;
341
342    /// Apply a semantic identifier (class) to the content.
343    ///
344    /// This is used for machine readability or fine-grained CSS styling.
345    /// Examples include "citum-title", "citum-author", "citum-doi".
346    fn semantic(&self, class: &str, content: Self::Output) -> Self::Output;
347
348    /// Render an annotation block.
349    ///
350    /// This is typically called at the end of a bibliography entry to render
351    /// reader-supplied notes.
352    fn annotation(&self, content: Self::Output) -> Self::Output;
353
354    // ── Block-level methods (used by the body markup renderer) ─────────────
355    // Defaults produce plain passthrough so existing format impls need not change.
356
357    /// Render a paragraph block.
358    fn paragraph(&self, content: Self::Output) -> Self::Output {
359        content
360    }
361
362    /// Render a block quotation.
363    fn block_quote(&self, content: Self::Output) -> Self::Output {
364        content
365    }
366
367    /// Render an unordered (bullet) list from pre-rendered item strings.
368    fn bullet_list(&self, items: Vec<Self::Output>) -> Self::Output {
369        self.join(items, "\n")
370    }
371
372    /// Render an ordered (numbered) list from pre-rendered item strings.
373    fn ordered_list(&self, items: Vec<Self::Output>) -> Self::Output {
374        self.join(items, "\n")
375    }
376
377    /// Render a list item.
378    fn list_item(&self, content: Self::Output) -> Self::Output {
379        content
380    }
381
382    /// Render a heading at the given level (1 = top-level).
383    fn heading(&self, _level: u8, content: Self::Output) -> Self::Output {
384        content
385    }
386
387    /// Render an unnumbered heading at the given level.
388    ///
389    /// Used for generated section headings (e.g. bibliography group
390    /// headings) that must not participate in document section numbering.
391    /// Defaults to [`Self::heading`]; formats with numbered headings
392    /// (LaTeX) override this with their unnumbered variants.
393    fn unnumbered_heading(&self, level: u8, content: Self::Output) -> Self::Output {
394        self.heading(level, content)
395    }
396
397    /// Render a fenced or indented code block with an optional language hint.
398    ///
399    /// `content` is the raw (unescaped) code text.
400    fn code_block(&self, _lang: Option<&str>, content: Self::Output) -> Self::Output {
401        content
402    }
403
404    /// Render inline code.
405    fn inline_code(&self, content: Self::Output) -> Self::Output {
406        content
407    }
408
409    /// Render strikethrough text.
410    fn strikeout(&self, content: Self::Output) -> Self::Output {
411        content
412    }
413
414    /// Render a hard line break.
415    fn hard_break(&self) -> Self::Output {
416        self.text(" ")
417    }
418
419    /// Apply a semantic identifier plus optional attributes to the content.
420    ///
421    /// Formats that do not support extra attributes can ignore them and reuse
422    /// [`Self::semantic`].
423    fn semantic_with_attributes(
424        &self,
425        class: &str,
426        content: Self::Output,
427        _attributes: &[SemanticAttribute],
428    ) -> Self::Output {
429        self.semantic(class, content)
430    }
431
432    /// Render a full citation container with one or more reference IDs.
433    fn citation(&self, _ids: Vec<String>, content: Self::Output) -> Self::Output {
434        content
435    }
436
437    // ── Visible-text methods ────────────────────────────────────────────────
438    // Used by bibliography/citation punctuation-boundary logic so separator
439    // and dedup decisions look at logical text, not backend markup (the
440    // "backends differ only in markup" rule — see DESIGN_PRINCIPLES §7).
441
442    /// Byte ranges of `fragment` that are visible (non-markup) text, in order.
443    ///
444    /// The default treats the whole fragment as visible, which is correct
445    /// for [`PlainText`](crate::render::plain::PlainText) and safe for any
446    /// third-party format that hasn't implemented a lexer: boundary logic
447    /// simply falls back to looking at raw characters, as it always has.
448    /// Backends whose inline methods (`emph`, `link`, `wrap_punctuation`,
449    /// ...) emit markup should override this to exclude it.
450    fn visible_runs(&self, fragment: &str) -> Vec<Range<usize>> {
451        let mut runs = Vec::new();
452        if !fragment.is_empty() {
453            runs.push(0..fragment.len());
454        }
455        runs
456    }
457
458    /// The visible (markup-stripped) text of a rendered fragment.
459    ///
460    /// Borrows `fragment` unchanged when it is entirely visible (the common
461    /// case); otherwise stitches the visible runs into an owned `String`.
462    fn visible_text<'a>(&self, fragment: &'a str) -> Cow<'a, str> {
463        let runs = self.visible_runs(fragment);
464        if runs.len() == 1 && runs.first() == Some(&(0..fragment.len())) {
465            return Cow::Borrowed(fragment);
466        }
467        let mut owned = String::with_capacity(fragment.len());
468        for run in runs {
469            if let Some(slice) = fragment.get(run) {
470                owned.push_str(slice);
471            }
472        }
473        Cow::Owned(owned)
474    }
475
476    /// Hyperlink the content to a URL.
477    fn link(&self, url: &str, content: Self::Output) -> Self::Output;
478
479    /// Format a reference ID for use as a target or link (e.g. adding a prefix).
480    fn format_id(&self, id: &str) -> String {
481        id.to_string()
482    }
483
484    /// Render a full bibliography container.
485    ///
486    /// The default implementation joins the entries with double newlines.
487    fn bibliography(&self, entries: Vec<Self::Output>) -> Self::Output {
488        self.join(entries, "\n\n")
489    }
490
491    /// Render a single bibliography entry with its unique identifier and optional link.
492    ///
493    /// The default implementation just returns the content.
494    fn entry(
495        &self,
496        _id: &str,
497        content: Self::Output,
498        _url: Option<&str>,
499        _metadata: &ProcEntryMetadata,
500    ) -> Self::Output {
501        content
502    }
503}
504
505/// Metadata for a processed bibliography entry, used for interactivity.
506#[derive(Debug, Clone, Default, PartialEq)]
507pub struct ProcEntryMetadata {
508    /// Rendered primary author(s) string.
509    pub author: Option<String>,
510    /// Rendered year string.
511    pub year: Option<String>,
512    /// Rendered title string.
513    pub title: Option<String>,
514}
515
516#[cfg(test)]
517#[allow(
518    clippy::unwrap_used,
519    clippy::expect_used,
520    clippy::panic,
521    clippy::indexing_slicing,
522    clippy::todo,
523    clippy::unimplemented,
524    clippy::unreachable,
525    clippy::get_unwrap,
526    reason = "Panicking is acceptable and often desired in tests."
527)]
528mod tests {
529    use super::*;
530
531    #[derive(Default, Clone)]
532    struct DummyFormat;
533
534    impl OutputFormat for DummyFormat {
535        type Output = String;
536        fn text(&self, s: &str) -> Self::Output {
537            s.to_string()
538        }
539        fn join(&self, items: Vec<Self::Output>, delimiter: &str) -> Self::Output {
540            items.join(delimiter)
541        }
542        fn finish(&self, output: Self::Output) -> String {
543            output
544        }
545        fn emph(&self, content: Self::Output) -> Self::Output {
546            format!("emph({content})")
547        }
548        fn strong(&self, content: Self::Output) -> Self::Output {
549            format!("strong({content})")
550        }
551        fn small_caps(&self, content: Self::Output) -> Self::Output {
552            format!("sc({content})")
553        }
554        fn superscript(&self, content: Self::Output) -> Self::Output {
555            format!("sup({content})")
556        }
557        fn affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
558            format!("{prefix}{content}{suffix}")
559        }
560        fn inner_affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
561            format!("{prefix}{content}{suffix}")
562        }
563        fn wrap_punctuation(
564            &self,
565            _wrap: &WrapPunctuation,
566            content: Self::Output,
567            _marks: &QuoteMarks,
568            _script: ScriptClass,
569            _realization: Option<&PunctuationRealization>,
570        ) -> Self::Output {
571            content
572        }
573        fn semantic(&self, class: &str, content: Self::Output) -> Self::Output {
574            format!("sem[{class}]({content})")
575        }
576        fn annotation(&self, content: Self::Output) -> Self::Output {
577            format!("annot({content})")
578        }
579        fn link(&self, url: &str, content: Self::Output) -> Self::Output {
580            format!("link[{url}]({content})")
581        }
582    }
583
584    #[test]
585    fn test_realize_wrap() {
586        for (wrap, script, expected) in [
587            (
588                WrapPunctuation::Parentheses,
589                ScriptClass::Latin,
590                Some(("(", ")")),
591            ),
592            (
593                WrapPunctuation::Parentheses,
594                ScriptClass::Cjk,
595                Some(("(", ")")),
596            ),
597            (
598                WrapPunctuation::Brackets,
599                ScriptClass::Latin,
600                Some(("[", "]")),
601            ),
602            (
603                WrapPunctuation::Brackets,
604                ScriptClass::Cjk,
605                Some(("【", "】")),
606            ),
607            (WrapPunctuation::Quotes, ScriptClass::Latin, None),
608            (WrapPunctuation::Quotes, ScriptClass::Cjk, None),
609        ] {
610            assert_eq!(
611                realize_wrap(&wrap, script, None)
612                    .map(|(open, close)| (open.into_owned(), close.into_owned())),
613                expected.map(|(open, close)| (open.to_string(), close.to_string())),
614                "{wrap:?}/{script:?}"
615            );
616        }
617    }
618
619    #[test]
620    fn paired_punctuation_override_includes_both_marks_as_separator() {
621        let overrides = PunctuationRealization {
622            parentheses: Some(["〔".to_string(), "〕".to_string()]),
623            ..PunctuationRealization::default()
624        };
625
626        assert_eq!(
627            realize_punctuation(
628                &DelimiterPunctuation::Parentheses,
629                ScriptClass::Cjk,
630                Some(&overrides),
631                PunctuationPosition::Separator,
632            ),
633            "〔〕"
634        );
635    }
636
637    #[test]
638    fn test_default_methods() {
639        let fmt = DummyFormat;
640        assert_eq!(
641            fmt.semantic_with_attributes("test", "content".to_string(), &[]),
642            "sem[test](content)"
643        );
644        assert_eq!(
645            fmt.citation(vec!["id1".to_string()], "content".to_string()),
646            "content"
647        );
648        assert_eq!(fmt.format_id("id1"), "id1");
649        assert_eq!(
650            fmt.bibliography(vec!["entry1".to_string(), "entry2".to_string()]),
651            "entry1\n\nentry2"
652        );
653        assert_eq!(
654            fmt.entry(
655                "id1",
656                "content".to_string(),
657                None,
658                &ProcEntryMetadata::default()
659            ),
660            "content"
661        );
662    }
663
664    #[test]
665    fn semantic_affixes_use_each_output_formats_text_escaping() {
666        let punctuation = DelimiterPunctuation::Comma;
667
668        assert_eq!(
669            apply_punctuation_affixes(
670                &crate::render::plain::PlainText,
671                Some((&punctuation, "<&")),
672                "value".to_string(),
673                None,
674            ),
675            "<&value"
676        );
677        assert_eq!(
678            apply_punctuation_affixes(
679                &crate::render::html::Html,
680                Some((&punctuation, "<&")),
681                "value".to_string(),
682                None,
683            ),
684            "&lt;&amp;value"
685        );
686        assert_eq!(
687            apply_punctuation_affixes(
688                &crate::render::latex::Latex,
689                Some((&punctuation, "<&")),
690                "value".to_string(),
691                None,
692            ),
693            "<\\&value"
694        );
695        assert_eq!(
696            apply_punctuation_affixes(
697                &crate::render::typst::Typst,
698                Some((&punctuation, "<&")),
699                "value".to_string(),
700                None,
701            ),
702            "\\<&value"
703        );
704        assert_eq!(
705            apply_punctuation_affixes(
706                &crate::render::markdown::Markdown,
707                Some((&punctuation, "<&")),
708                "value".to_string(),
709                None,
710            ),
711            "\\<\\&value"
712        );
713        assert_eq!(
714            apply_punctuation_affixes(
715                &crate::render::djot::Djot,
716                Some((&punctuation, "<&")),
717                "value".to_string(),
718                None,
719            ),
720            "<&value"
721        );
722        assert_eq!(
723            apply_punctuation_affixes(
724                &crate::render::org::OrgOutputFormat,
725                Some((&punctuation, "<&")),
726                "value".to_string(),
727                None,
728            ),
729            "<&value"
730        );
731    }
732}