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