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/// A realized separator decomposed into its leading character and the tail
120/// that follows it, so punctuation-in-quote join sites (`render/bibliography.rs`,
121/// `render/citation.rs`, `render/punctuation.rs`, `processor/rendering/grouped/core.rs`)
122/// no longer each call `.chars().next()` on a plain `&str` to rediscover what a
123/// mark's identity already determined at realization.
124///
125/// [`Self::core`] mirrors `text.chars().next()` exactly, so every existing
126/// `matches!(core(), Some('.' | ','))` or `core() == Some(',')` comparison at
127/// a join site is byte-identical to what it replaces. This intentionally does
128/// *not* expose a [`PunctuationClass`](crate::render::punctuation::PunctuationClass)
129/// of the realized glyph: classifying the
130/// rendered character (rather than the source mark) is wrong for non-ASCII
131/// realizations (a CJK `,` is comma-like by origin but not by any ASCII
132/// classification of its glyph), and nothing in this codebase needs it —
133/// quote-movement/collision resolution in `PUNCTUATION_NORMALIZATION.md` is
134/// deliberately scoped to the Latin `.`/`,` convention. Extending it to
135/// full-width marks is a real design question for a future spec increment,
136/// not a byproduct of typing separators.
137///
138/// Must be built from the same string a join site will actually splice into
139/// its output. For the `group:` join sites this is the *post-escape* string
140/// (after `fmt.text`/`fmt.join` round-tripping), matching what
141/// `.chars().next()` inspected before this type existed — see
142/// `docs/specs/PUNCTUATION_REALIZATION.md` §6 on realization strictly
143/// preceding output-format escaping.
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub(crate) struct RealizedPunctuation<'a> {
146    text: Cow<'a, str>,
147    core_len: usize,
148}
149
150impl<'a> RealizedPunctuation<'a> {
151    /// Decompose an already-realized separator string.
152    pub(crate) fn new(text: Cow<'a, str>) -> Self {
153        let core_len = text.chars().next().map(char::len_utf8).unwrap_or(0);
154        Self { text, core_len }
155    }
156
157    /// The full realized separator text.
158    pub(crate) fn text(&self) -> &str {
159        &self.text
160    }
161
162    /// The separator's leading character, or `None` when the separator is empty.
163    pub(crate) fn core(&self) -> Option<char> {
164        self.text.chars().next()
165    }
166
167    /// The separator with its leading character removed.
168    pub(crate) fn tail(&self) -> &str {
169        #[allow(
170            clippy::string_slice,
171            reason = "core_len is a char boundary derived from chars().next()"
172        )]
173        &self.text[self.core_len..]
174    }
175
176    /// Return whether the realized separator is the empty string.
177    pub(crate) fn is_empty(&self) -> bool {
178        self.text.is_empty()
179    }
180
181    /// Detach from the borrowed input, cloning if necessary.
182    pub(crate) fn into_owned(self) -> RealizedPunctuation<'static> {
183        RealizedPunctuation {
184            text: Cow::Owned(self.text.into_owned()),
185            core_len: self.core_len,
186        }
187    }
188}
189
190/// Realize `punctuation` and decompose the result — see [`RealizedPunctuation`].
191#[must_use]
192pub(crate) fn realize_punctuation_decomposed<'a>(
193    punctuation: &'a DelimiterPunctuation,
194    script: ScriptClass,
195    overrides: Option<&'a PunctuationRealization>,
196    position: PunctuationPosition,
197) -> RealizedPunctuation<'a> {
198    RealizedPunctuation::new(realize_punctuation(
199        punctuation,
200        script,
201        overrides,
202        position,
203    ))
204}
205
206/// Apply realized punctuation affixes while routing semantic glyphs through
207/// the active output format's text escaping.
208pub(crate) fn apply_punctuation_affixes<F>(
209    fmt: &F,
210    prefix: Option<(&DelimiterPunctuation, &str)>,
211    mut content: String,
212    suffix: Option<(&DelimiterPunctuation, &str)>,
213) -> String
214where
215    F: OutputFormat<Output = String>,
216{
217    if let Some((punctuation, text)) = prefix {
218        content = if punctuation.is_semantic() {
219            fmt.join(vec![fmt.text(text), content], "")
220        } else {
221            fmt.affix(text, content, "")
222        };
223    }
224    if let Some((punctuation, text)) = suffix {
225        content = if punctuation.is_semantic() {
226            fmt.join(vec![content, fmt.text(text)], "")
227        } else {
228            fmt.affix("", content, text)
229        };
230    }
231    content
232}
233
234/// Return Unicode quote marks for a nesting depth.
235///
236/// Even depths use outer double quotes; odd depths use inner single quotes.
237#[must_use]
238pub fn unicode_quote_marks(depth: usize) -> (&'static str, &'static str) {
239    if depth.is_multiple_of(2) {
240        ("\u{201C}", "\u{201D}")
241    } else {
242        ("\u{2018}", "\u{2019}")
243    }
244}
245
246/// Locale-resolved quote mark characters, threaded from
247/// [`GrammarOptions`] through to rendering so that
248/// styles using non-English quotation conventions (e.g. fr-FR guillemets) render correctly.
249#[derive(Debug, Clone, PartialEq, Eq)]
250pub struct QuoteMarks {
251    /// Opening outer quotation mark.
252    pub open: String,
253    /// Closing outer quotation mark.
254    pub close: String,
255    /// Opening inner (nested) quotation mark.
256    pub open_inner: String,
257    /// Closing inner (nested) quotation mark.
258    pub close_inner: String,
259    /// Semantic punctuation realization table from the active locale.
260    pub punctuation_realization: Option<citum_schema::options::PunctuationRealization>,
261}
262
263impl QuoteMarks {
264    /// Return the opening and closing quote delimiters for a nesting depth.
265    ///
266    /// Depth 0 (and other even depths) use the outer pair; odd depths use the inner pair.
267    #[must_use]
268    pub fn for_depth(&self, depth: usize) -> (&str, &str) {
269        if depth.is_multiple_of(2) {
270            (&self.open, &self.close)
271        } else {
272            (&self.open_inner, &self.close_inner)
273        }
274    }
275}
276
277impl Default for QuoteMarks {
278    /// The historical hardcoded English fallback, used when no resolved locale is available.
279    fn default() -> Self {
280        let (open, close) = unicode_quote_marks(0);
281        let (open_inner, close_inner) = unicode_quote_marks(1);
282        Self {
283            open: open.to_string(),
284            close: close.to_string(),
285            open_inner: open_inner.to_string(),
286            close_inner: close_inner.to_string(),
287            punctuation_realization: None,
288        }
289    }
290}
291
292impl From<&GrammarOptions> for QuoteMarks {
293    fn from(options: &GrammarOptions) -> Self {
294        Self {
295            open: options.open_quote.clone(),
296            close: options.close_quote.clone(),
297            open_inner: options.open_inner_quote.clone(),
298            close_inner: options.close_inner_quote.clone(),
299            punctuation_realization: None,
300        }
301    }
302}
303
304impl From<&citum_schema::locale::Locale> for QuoteMarks {
305    fn from(locale: &citum_schema::locale::Locale) -> Self {
306        Self {
307            open: locale.grammar_options.open_quote.clone(),
308            close: locale.grammar_options.close_quote.clone(),
309            open_inner: locale.grammar_options.open_inner_quote.clone(),
310            close_inner: locale.grammar_options.close_inner_quote.clone(),
311            punctuation_realization: locale.punctuation_realization.clone(),
312        }
313    }
314}
315
316/// Extra attributes applied to semantic wrappers when a renderer supports them.
317#[derive(Debug, Clone, PartialEq, Eq)]
318pub struct SemanticAttribute {
319    /// The attribute name.
320    pub name: &'static str,
321    /// The attribute value.
322    pub value: String,
323}
324
325/// Realize a semantic [`WrapPunctuation`] into the `(open, close)` glyph pair
326/// for a script class.
327///
328/// Returns `None` for [`WrapPunctuation::Quotes`], which realizes through
329/// locale-resolved quote marks (`QuoteMarks`) rather than a fixed pair — see
330/// `docs/specs/PUNCTUATION_REALIZATION.md` §2. The table is closed for v1;
331/// new marks or script classes require a spec revision.
332#[must_use]
333pub(crate) fn realize_wrap<'a>(
334    wrap: &WrapPunctuation,
335    script: ScriptClass,
336    overrides: Option<&'a PunctuationRealization>,
337) -> Option<(Cow<'a, str>, Cow<'a, str>)> {
338    if let Some(pair) = overrides.and_then(|table| match wrap {
339        WrapPunctuation::Parentheses => table.parentheses.as_ref(),
340        WrapPunctuation::Brackets => table.brackets.as_ref(),
341        WrapPunctuation::Quotes => None,
342    }) {
343        return Some((
344            Cow::Borrowed(pair[0].as_str()),
345            Cow::Borrowed(pair[1].as_str()),
346        ));
347    }
348
349    match (wrap, script) {
350        (WrapPunctuation::Parentheses, ScriptClass::Latin) => {
351            Some((Cow::Borrowed("("), Cow::Borrowed(")")))
352        }
353        (WrapPunctuation::Parentheses, ScriptClass::Cjk) => {
354            Some((Cow::Borrowed("("), Cow::Borrowed(")")))
355        }
356        (WrapPunctuation::Parentheses, ScriptClass::Mixed) => {
357            Some((Cow::Borrowed("("), Cow::Borrowed(")")))
358        }
359        (WrapPunctuation::Brackets, ScriptClass::Latin) => {
360            Some((Cow::Borrowed("["), Cow::Borrowed("]")))
361        }
362        (WrapPunctuation::Brackets, ScriptClass::Cjk) => {
363            Some((Cow::Borrowed("【"), Cow::Borrowed("】")))
364        }
365        (WrapPunctuation::Brackets, ScriptClass::Mixed) => {
366            Some((Cow::Borrowed("["), Cow::Borrowed("]")))
367        }
368        (WrapPunctuation::Quotes, _) => None,
369    }
370}
371
372/// Trait for defining how to render template components into a specific format.
373///
374/// Implementations of this trait define how various formatting instructions
375/// (emphasis, quotes, links, etc.) are translated into specific markup or text.
376pub trait OutputFormat: Default + Clone {
377    /// The type used for intermediate rendered content.
378    ///
379    /// For simple text formats, this is usually `String`. More complex formats
380    /// might use an AST or a specialized builder type.
381    type Output;
382
383    /// Convert a raw string into the format's output type.
384    ///
385    /// The implementation should handle any necessary character escaping
386    /// required by the target format.
387    fn text(&self, s: &str) -> Self::Output;
388
389    /// Join multiple outputs into a single output using a delimiter.
390    fn join(&self, items: Vec<Self::Output>, delimiter: &str) -> Self::Output;
391
392    /// Convert the intermediate output into the final result string.
393    ///
394    /// This is called exactly once at the end of the rendering process
395    /// for a top-level component (citation or bibliography entry).
396    fn finish(&self, output: Self::Output) -> String;
397
398    /// Render content with emphasis (typically italics).
399    fn emph(&self, content: Self::Output) -> Self::Output;
400
401    /// Render content with strong emphasis (typically bold).
402    fn strong(&self, content: Self::Output) -> Self::Output;
403
404    /// Render content in small capitals.
405    fn small_caps(&self, content: Self::Output) -> Self::Output;
406
407    /// Render content as superscript text.
408    fn superscript(&self, content: Self::Output) -> Self::Output;
409
410    /// Return the opening and closing quote delimiters for a nesting depth.
411    ///
412    /// Depth 0 is an outer quote pair, depth 1 is the first inner quote pair,
413    /// and deeper levels alternate between those two pairs. `marks` carries the
414    /// locale-resolved quote characters; callers with no resolved locale can pass
415    /// `&QuoteMarks::default()` to keep the historical English fallback.
416    fn quote_marks<'a>(&self, depth: usize, marks: &'a QuoteMarks) -> (&'a str, &'a str) {
417        marks.for_depth(depth)
418    }
419
420    /// Render content enclosed in quotation marks at a specific nesting depth.
421    fn quote_with_depth(
422        &self,
423        content: Self::Output,
424        depth: usize,
425        marks: &QuoteMarks,
426    ) -> Self::Output {
427        let (open, close) = self.quote_marks(depth, marks);
428        self.affix(open, content, close)
429    }
430
431    /// Render content enclosed in outer quotation marks.
432    fn quote(&self, content: Self::Output, marks: &QuoteMarks) -> Self::Output {
433        self.quote_with_depth(content, 0, marks)
434    }
435
436    /// Apply outer prefix and suffix strings to the content.
437    ///
438    /// These are typically the "prefix" and "suffix" fields from the Citum style.
439    fn affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output;
440
441    /// Apply inner prefix and suffix strings to the content.
442    ///
443    /// These are applied inside any wrapping punctuation.
444    fn inner_affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output;
445
446    /// Wrap the content in specific punctuation (parentheses, brackets, or quotes).
447    ///
448    /// `marks` supplies the locale-resolved quote characters for the `Quotes`
449    /// variant. `script` selects the half-width or full-width glyph form for
450    /// the `Parentheses`/`Brackets` variants — see `realize_wrap` and
451    /// `docs/specs/PUNCTUATION_REALIZATION.md`.
452    fn wrap_punctuation(
453        &self,
454        wrap: &WrapPunctuation,
455        content: Self::Output,
456        marks: &QuoteMarks,
457        script: ScriptClass,
458        realization: Option<&PunctuationRealization>,
459    ) -> Self::Output;
460
461    /// Apply a semantic identifier (class) to the content.
462    ///
463    /// This is used for machine readability or fine-grained CSS styling.
464    /// Examples include "citum-title", "citum-author", "citum-doi".
465    fn semantic(&self, class: &str, content: Self::Output) -> Self::Output;
466
467    /// Render an annotation block.
468    ///
469    /// This is typically called at the end of a bibliography entry to render
470    /// reader-supplied notes.
471    fn annotation(&self, content: Self::Output) -> Self::Output;
472
473    // ── Block-level methods (used by the body markup renderer) ─────────────
474    // Defaults produce plain passthrough so existing format impls need not change.
475
476    /// Render a paragraph block.
477    fn paragraph(&self, content: Self::Output) -> Self::Output {
478        content
479    }
480
481    /// Render a block quotation.
482    fn block_quote(&self, content: Self::Output) -> Self::Output {
483        content
484    }
485
486    /// Render an unordered (bullet) list from pre-rendered item strings.
487    fn bullet_list(&self, items: Vec<Self::Output>) -> Self::Output {
488        self.join(items, "\n")
489    }
490
491    /// Render an ordered (numbered) list from pre-rendered item strings.
492    fn ordered_list(&self, items: Vec<Self::Output>) -> Self::Output {
493        self.join(items, "\n")
494    }
495
496    /// Render a list item.
497    fn list_item(&self, content: Self::Output) -> Self::Output {
498        content
499    }
500
501    /// Render a heading at the given level (1 = top-level).
502    fn heading(&self, _level: u8, content: Self::Output) -> Self::Output {
503        content
504    }
505
506    /// Render an unnumbered heading at the given level.
507    ///
508    /// Used for generated section headings (e.g. bibliography group
509    /// headings) that must not participate in document section numbering.
510    /// Defaults to [`Self::heading`]; formats with numbered headings
511    /// (LaTeX) override this with their unnumbered variants.
512    fn unnumbered_heading(&self, level: u8, content: Self::Output) -> Self::Output {
513        self.heading(level, content)
514    }
515
516    /// Render a fenced or indented code block with an optional language hint.
517    ///
518    /// `content` is the raw (unescaped) code text.
519    fn code_block(&self, _lang: Option<&str>, content: Self::Output) -> Self::Output {
520        content
521    }
522
523    /// Render inline code.
524    fn inline_code(&self, content: Self::Output) -> Self::Output {
525        content
526    }
527
528    /// Render strikethrough text.
529    fn strikeout(&self, content: Self::Output) -> Self::Output {
530        content
531    }
532
533    /// Render a hard line break.
534    fn hard_break(&self) -> Self::Output {
535        self.text(" ")
536    }
537
538    /// Apply a semantic identifier plus optional attributes to the content.
539    ///
540    /// Formats that do not support extra attributes can ignore them and reuse
541    /// [`Self::semantic`].
542    fn semantic_with_attributes(
543        &self,
544        class: &str,
545        content: Self::Output,
546        _attributes: &[SemanticAttribute],
547    ) -> Self::Output {
548        self.semantic(class, content)
549    }
550
551    /// Render a full citation container with one or more reference IDs.
552    fn citation(&self, _ids: Vec<String>, content: Self::Output) -> Self::Output {
553        content
554    }
555
556    // ── Visible-text methods ────────────────────────────────────────────────
557    // Used by bibliography/citation punctuation-boundary logic so separator
558    // and dedup decisions look at logical text, not backend markup (the
559    // "backends differ only in markup" rule — see DESIGN_PRINCIPLES §7).
560
561    /// Byte ranges of `fragment` that are visible (non-markup) text, in order.
562    ///
563    /// The default treats the whole fragment as visible, which is correct
564    /// for [`PlainText`](crate::render::plain::PlainText) and safe for any
565    /// third-party format that hasn't implemented a lexer: boundary logic
566    /// simply falls back to looking at raw characters, as it always has.
567    /// Backends whose inline methods (`emph`, `link`, `wrap_punctuation`,
568    /// ...) emit markup should override this to exclude it.
569    fn visible_runs(&self, fragment: &str) -> Vec<Range<usize>> {
570        let mut runs = Vec::new();
571        if !fragment.is_empty() {
572            runs.push(0..fragment.len());
573        }
574        runs
575    }
576
577    /// The visible (markup-stripped) text of a rendered fragment.
578    ///
579    /// Borrows `fragment` unchanged when it is entirely visible (the common
580    /// case); otherwise stitches the visible runs into an owned `String`.
581    fn visible_text<'a>(&self, fragment: &'a str) -> Cow<'a, str> {
582        let runs = self.visible_runs(fragment);
583        if runs.len() == 1 && runs.first() == Some(&(0..fragment.len())) {
584            return Cow::Borrowed(fragment);
585        }
586        let mut owned = String::with_capacity(fragment.len());
587        for run in runs {
588            if let Some(slice) = fragment.get(run) {
589                owned.push_str(slice);
590            }
591        }
592        Cow::Owned(owned)
593    }
594
595    /// Hyperlink the content to a URL.
596    fn link(&self, url: &str, content: Self::Output) -> Self::Output;
597
598    /// Format a reference ID for use as a target or link (e.g. adding a prefix).
599    fn format_id(&self, id: &str) -> String {
600        id.to_string()
601    }
602
603    /// Render a full bibliography container.
604    ///
605    /// The default implementation joins the entries with double newlines.
606    fn bibliography(&self, entries: Vec<Self::Output>) -> Self::Output {
607        self.join(entries, "\n\n")
608    }
609
610    /// Render a single bibliography entry with its unique identifier and optional link.
611    ///
612    /// The default implementation just returns the content.
613    fn entry(
614        &self,
615        _id: &str,
616        content: Self::Output,
617        _url: Option<&str>,
618        _metadata: &ProcEntryMetadata,
619    ) -> Self::Output {
620        content
621    }
622}
623
624/// Metadata for a processed bibliography entry, used for interactivity.
625#[derive(Debug, Clone, Default, PartialEq)]
626pub struct ProcEntryMetadata {
627    /// Rendered primary author(s) string.
628    pub author: Option<String>,
629    /// Rendered year string.
630    pub year: Option<String>,
631    /// Rendered title string.
632    pub title: Option<String>,
633}
634
635#[cfg(test)]
636#[allow(
637    clippy::unwrap_used,
638    clippy::expect_used,
639    clippy::panic,
640    clippy::indexing_slicing,
641    clippy::todo,
642    clippy::unimplemented,
643    clippy::unreachable,
644    clippy::get_unwrap,
645    reason = "Panicking is acceptable and often desired in tests."
646)]
647mod tests {
648    use super::*;
649    use rstest::rstest;
650
651    #[derive(Default, Clone)]
652    struct DummyFormat;
653
654    impl OutputFormat for DummyFormat {
655        type Output = String;
656        fn text(&self, s: &str) -> Self::Output {
657            s.to_string()
658        }
659        fn join(&self, items: Vec<Self::Output>, delimiter: &str) -> Self::Output {
660            items.join(delimiter)
661        }
662        fn finish(&self, output: Self::Output) -> String {
663            output
664        }
665        fn emph(&self, content: Self::Output) -> Self::Output {
666            format!("emph({content})")
667        }
668        fn strong(&self, content: Self::Output) -> Self::Output {
669            format!("strong({content})")
670        }
671        fn small_caps(&self, content: Self::Output) -> Self::Output {
672            format!("sc({content})")
673        }
674        fn superscript(&self, content: Self::Output) -> Self::Output {
675            format!("sup({content})")
676        }
677        fn affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
678            format!("{prefix}{content}{suffix}")
679        }
680        fn inner_affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
681            format!("{prefix}{content}{suffix}")
682        }
683        fn wrap_punctuation(
684            &self,
685            _wrap: &WrapPunctuation,
686            content: Self::Output,
687            _marks: &QuoteMarks,
688            _script: ScriptClass,
689            _realization: Option<&PunctuationRealization>,
690        ) -> Self::Output {
691            content
692        }
693        fn semantic(&self, class: &str, content: Self::Output) -> Self::Output {
694            format!("sem[{class}]({content})")
695        }
696        fn annotation(&self, content: Self::Output) -> Self::Output {
697            format!("annot({content})")
698        }
699        fn link(&self, url: &str, content: Self::Output) -> Self::Output {
700            format!("link[{url}]({content})")
701        }
702    }
703
704    #[test]
705    fn test_realize_wrap() {
706        for (wrap, script, expected) in [
707            (
708                WrapPunctuation::Parentheses,
709                ScriptClass::Latin,
710                Some(("(", ")")),
711            ),
712            (
713                WrapPunctuation::Parentheses,
714                ScriptClass::Cjk,
715                Some(("(", ")")),
716            ),
717            (
718                WrapPunctuation::Brackets,
719                ScriptClass::Latin,
720                Some(("[", "]")),
721            ),
722            (
723                WrapPunctuation::Brackets,
724                ScriptClass::Cjk,
725                Some(("【", "】")),
726            ),
727            (WrapPunctuation::Quotes, ScriptClass::Latin, None),
728            (WrapPunctuation::Quotes, ScriptClass::Cjk, None),
729        ] {
730            assert_eq!(
731                realize_wrap(&wrap, script, None)
732                    .map(|(open, close)| (open.into_owned(), close.into_owned())),
733                expected.map(|(open, close)| (open.to_string(), close.to_string())),
734                "{wrap:?}/{script:?}"
735            );
736        }
737    }
738
739    #[test]
740    fn paired_punctuation_override_includes_both_marks_as_separator() {
741        let overrides = PunctuationRealization {
742            parentheses: Some(["〔".to_string(), "〕".to_string()]),
743            ..PunctuationRealization::default()
744        };
745
746        assert_eq!(
747            realize_punctuation(
748                &DelimiterPunctuation::Parentheses,
749                ScriptClass::Cjk,
750                Some(&overrides),
751                PunctuationPosition::Separator,
752            ),
753            "〔〕"
754        );
755    }
756
757    #[test]
758    fn test_default_methods() {
759        let fmt = DummyFormat;
760        assert_eq!(
761            fmt.semantic_with_attributes("test", "content".to_string(), &[]),
762            "sem[test](content)"
763        );
764        assert_eq!(
765            fmt.citation(vec!["id1".to_string()], "content".to_string()),
766            "content"
767        );
768        assert_eq!(fmt.format_id("id1"), "id1");
769        assert_eq!(
770            fmt.bibliography(vec!["entry1".to_string(), "entry2".to_string()]),
771            "entry1\n\nentry2"
772        );
773        assert_eq!(
774            fmt.entry(
775                "id1",
776                "content".to_string(),
777                None,
778                &ProcEntryMetadata::default()
779            ),
780            "content"
781        );
782    }
783
784    #[test]
785    fn semantic_affixes_use_each_output_formats_text_escaping() {
786        let punctuation = DelimiterPunctuation::Comma;
787
788        assert_eq!(
789            apply_punctuation_affixes(
790                &crate::render::plain::PlainText,
791                Some((&punctuation, "<&")),
792                "value".to_string(),
793                None,
794            ),
795            "<&value"
796        );
797        assert_eq!(
798            apply_punctuation_affixes(
799                &crate::render::html::Html,
800                Some((&punctuation, "<&")),
801                "value".to_string(),
802                None,
803            ),
804            "&lt;&amp;value"
805        );
806        assert_eq!(
807            apply_punctuation_affixes(
808                &crate::render::latex::Latex,
809                Some((&punctuation, "<&")),
810                "value".to_string(),
811                None,
812            ),
813            "<\\&value"
814        );
815        assert_eq!(
816            apply_punctuation_affixes(
817                &crate::render::typst::Typst,
818                Some((&punctuation, "<&")),
819                "value".to_string(),
820                None,
821            ),
822            "\\<&value"
823        );
824        assert_eq!(
825            apply_punctuation_affixes(
826                &crate::render::markdown::Markdown,
827                Some((&punctuation, "<&")),
828                "value".to_string(),
829                None,
830            ),
831            "\\<\\&value"
832        );
833        assert_eq!(
834            apply_punctuation_affixes(
835                &crate::render::djot::Djot,
836                Some((&punctuation, "<&")),
837                "value".to_string(),
838                None,
839            ),
840            "<&value"
841        );
842        assert_eq!(
843            apply_punctuation_affixes(
844                &crate::render::org::OrgOutputFormat,
845                Some((&punctuation, "<&")),
846                "value".to_string(),
847                None,
848            ),
849            "<&value"
850        );
851    }
852
853    #[rstest]
854    #[case::latin_comma(DelimiterPunctuation::Comma, ScriptClass::Latin, Some(','), " ")]
855    #[case::cjk_comma_has_no_tail(DelimiterPunctuation::Comma, ScriptClass::Cjk, Some(','), "")]
856    #[case::custom_period_matches_semantic_period_under_latin(
857        DelimiterPunctuation::Custom(". ".to_string()),
858        ScriptClass::Latin,
859        Some('.'),
860        " ",
861    )]
862    #[case::custom_empty_has_no_core(
863        DelimiterPunctuation::Custom(String::new()),
864        ScriptClass::Latin,
865        None,
866        ""
867    )]
868    #[case::custom_ampersand_space_led_core_is_not_terminal_punctuation(
869        DelimiterPunctuation::Custom(" & ".to_string()),
870        ScriptClass::Latin,
871        Some(' '),
872        "& ",
873    )]
874    fn realized_punctuation_decomposes_core_and_tail(
875        #[case] punctuation: DelimiterPunctuation,
876        #[case] script: ScriptClass,
877        #[case] expected_core: Option<char>,
878        #[case] expected_tail: &str,
879    ) {
880        let realized = realize_punctuation_decomposed(
881            &punctuation,
882            script,
883            None,
884            PunctuationPosition::Separator,
885        );
886
887        assert_eq!(realized.core(), expected_core);
888        assert_eq!(realized.tail(), expected_tail);
889    }
890
891    #[test]
892    fn realized_punctuation_french_colon_has_no_movable_core() {
893        // The parity case from `docs/specs/PUNCTUATION_REALIZATION.md` §2: a
894        // locale-supplied realization can lead with a non-breaking space
895        // rather than the mark's own glyph, so `core()` — which mirrors
896        // `chars().next()` exactly — returns the NBSP, not `:`. Downstream
897        // `matches!(core(), Some('.' | ','))` movement checks correctly treat
898        // this the same as no leading punctuation at all.
899        let realization = citum_schema::options::PunctuationRealization {
900            colon: Some("\u{00A0}: ".to_string()),
901            ..Default::default()
902        };
903        let punctuation = DelimiterPunctuation::Colon;
904
905        let realized = realize_punctuation_decomposed(
906            &punctuation,
907            ScriptClass::Latin,
908            Some(&realization),
909            PunctuationPosition::Separator,
910        );
911
912        assert_eq!(realized.text(), "\u{00A0}: ");
913        assert_eq!(realized.core(), Some('\u{00A0}'));
914        assert!(!matches!(realized.core(), Some('.' | ',')));
915    }
916
917    #[test]
918    fn realized_punctuation_is_empty_and_into_owned_detach_from_the_input() {
919        let borrowed = RealizedPunctuation::new(Cow::Borrowed(""));
920        assert!(borrowed.is_empty());
921
922        let source = String::from(", ");
923        let realized = RealizedPunctuation::new(Cow::Borrowed(source.as_str()));
924        let owned = realized.into_owned();
925        drop(source);
926
927        assert_eq!(owned.text(), ", ");
928        assert_eq!(owned.core(), Some(','));
929    }
930}