Skip to main content

citum_engine/render/
component.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6use super::format::QuoteMarks;
7use citum_schema::options::{Config, bibliography::BibliographyConfig, titles::TitleRendering};
8use citum_schema::template::{Rendering, TemplateComponent, TitleType};
9use std::sync::Arc;
10
11/// A processed template component with its rendered value.
12#[derive(Debug, Clone, Default, PartialEq)]
13pub struct ProcTemplateComponent {
14    /// The original template component (for rendering instructions).
15    pub template_component: TemplateComponent,
16    /// The 0-based source index in the active layout template, when requested.
17    pub template_index: Option<usize>,
18    /// The processed values.
19    pub value: String,
20    /// Optional prefix from value extraction.
21    pub prefix: Option<String>,
22    /// Optional suffix from value extraction.
23    pub suffix: Option<String>,
24    /// Optional URL for hyperlinking.
25    pub url: Option<String>,
26    /// Reference type for type-specific overrides.
27    pub ref_type: Option<String>,
28    /// Optional global configuration.
29    pub config: Option<Arc<Config>>,
30    /// Optional bibliography-only configuration.
31    pub bibliography_config: Option<Arc<BibliographyConfig>>,
32    /// Effective language for this rendered component.
33    pub item_language: Option<String>,
34    /// Locale-resolved quote mark characters, threaded from the active [`Locale`]'s
35    /// [`GrammarOptions`](citum_schema::locale::GrammarOptions) so `quote`/`wrap: quotes`
36    /// render the style's actual quotation convention instead of a hardcoded default.
37    ///
38    /// [`Locale`]: citum_schema::locale::Locale
39    pub quote_marks: QuoteMarks,
40    /// Whether this component begins a sentence according to processor-owned render context.
41    pub sentence_initial: bool,
42    /// Whether the value is already pre-formatted (e.g. from a List or substitution).
43    pub pre_formatted: bool,
44}
45
46/// A processed template (list of rendered components).
47pub type ProcTemplate = Vec<ProcTemplateComponent>;
48
49/// A processed bibliography entry.
50#[derive(Debug, Clone, Default, PartialEq)]
51pub struct ProcEntry {
52    /// The reference ID.
53    pub id: String,
54    /// The rendered reference marker this entry leads with, wrap and
55    /// `label-separator` already applied. `None` when the style declares no
56    /// marker. See `docs/specs/REFERENCE_MARKERS.md`.
57    pub marker: Option<String>,
58    /// The processed template components.
59    pub template: ProcTemplate,
60    /// Metadata for interactivity (tooltips, etc.)
61    pub metadata: super::format::ProcEntryMetadata,
62}
63
64use super::format::{OutputFormat, SemanticAttribute};
65use super::plain::PlainText;
66use std::borrow::Cow;
67
68/// Resolve the semantic CSS class for a rendered component based on its template type.
69fn resolve_semantic_class(component: &ProcTemplateComponent) -> Option<String> {
70    use citum_schema::template::{DateVariable, SimpleVariable};
71    match &component.template_component {
72        TemplateComponent::Title(t) => match t.title {
73            TitleType::Primary => Some("citum-title".to_string()),
74            TitleType::ContainerTitle
75            | TitleType::ParentMonograph
76            | TitleType::ParentSerial
77            | TitleType::CollectionTitle => Some("citum-container-title".to_string()),
78            _ => Some("citum-title".to_string()),
79        },
80        TemplateComponent::Contributor(c) => Some(format!(
81            "citum-{}",
82            c.contributor
83                .as_slice()
84                .iter()
85                .map(citum_schema::template::ContributorRole::as_str)
86                .collect::<Vec<_>>()
87                .join("-")
88        )),
89        TemplateComponent::Date(d) => Some(format!(
90            "citum-{}",
91            match d.date {
92                DateVariable::Issued => "issued",
93                DateVariable::Accessed => "accessed",
94                DateVariable::OriginalPublished => "original-published",
95                DateVariable::Submitted => "submitted",
96                DateVariable::EventDate => "event-date",
97                DateVariable::Copyright => "copyright",
98                DateVariable::Printing => "printing",
99            }
100        )),
101        TemplateComponent::Number(n) => Some(format!("citum-{}", n.number.as_key())),
102        TemplateComponent::Identifier(identifier) => Some(format!(
103            "citum-identifier-{}",
104            identifier.identifier.as_str()
105        )),
106        TemplateComponent::Variable(v) => Some(format!(
107            "citum-{}",
108            match v.variable {
109                SimpleVariable::Doi => "doi",
110                SimpleVariable::Url => "url",
111                SimpleVariable::Isbn => "isbn",
112                SimpleVariable::Issn => "issn",
113                SimpleVariable::Pmid => "pmid",
114                SimpleVariable::Note => "note",
115                SimpleVariable::Publisher => "publisher",
116                SimpleVariable::PublisherPlace => "publisher-place",
117                SimpleVariable::ContainerTitleShort => "container-title-short",
118                SimpleVariable::Archive => "archive",
119                _ => "variable",
120            }
121        )),
122        TemplateComponent::Message(m) => Some(format!(
123            "citum-message-{}",
124            m.message
125                .chars()
126                .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' })
127                .collect::<String>()
128                .trim_matches('-')
129        )),
130        _ => None,
131    }
132}
133
134/// Render a single component to string using the default `PlainText` format.
135#[must_use]
136pub fn render_component(component: &ProcTemplateComponent) -> String {
137    PlainText.finish(render_component_with_format::<PlainText>(component))
138}
139
140/// Render a single component using a specific output format.
141#[must_use]
142pub fn render_component_with_format<F: OutputFormat<Output = String>>(
143    component: &ProcTemplateComponent,
144) -> F::Output {
145    render_component_with_format_and_renderer::<F>(component, &F::default(), true)
146}
147
148/// A rendered component's text.
149///
150/// Exists so punctuation-in-quote join sites (`render/bibliography.rs`,
151/// `render/citation.rs`, `render/punctuation.rs`) have a named type to
152/// extend if a future increment needs to carry more than the text — today
153/// they detect a movable leading mark from `text` itself, via
154/// `render::punctuation::leading_movable_mark`, which compares the rendered
155/// string's *visible* first character (not its raw one) against the
156/// *visible* closing quote it might relocate into. An earlier version of
157/// this fix instead typed the mark from the component's realized outer
158/// `prefix`, gating the move on that alone; a CJK regression test caught
159/// that this misses leading marks supplied by other means (value-extraction
160/// prefixes, nested-group joins), so the visible-character check — a strict
161/// superset of what the old raw sniff caught, just no longer defeated by a
162/// semantic wrapper's markup — is the actual fix.
163#[derive(Debug, Clone, PartialEq, Eq, Default)]
164pub(crate) struct RenderedComponent {
165    /// The fully rendered component text (affixes, wraps, semantics applied).
166    pub(crate) text: String,
167}
168
169/// As [`render_component_with_format`], but returns a [`RenderedComponent`]
170/// rather than a bare string.
171pub(crate) fn render_component_detailed<F: OutputFormat<Output = String>>(
172    component: &ProcTemplateComponent,
173) -> RenderedComponent {
174    render_component_detailed_with_format_and_renderer::<F>(component, &F::default(), true)
175}
176
177fn realized_component_affixes<'a>(
178    rendering: &'a Rendering,
179    script: crate::values::ScriptClass,
180    realization: Option<&'a citum_schema::options::PunctuationRealization>,
181) -> (Cow<'a, str>, Cow<'a, str>) {
182    let realize = |punctuation: &'a citum_schema::template::DelimiterPunctuation, position| {
183        super::format::realize_punctuation(punctuation, script, realization, position)
184    };
185    let prefix = rendering
186        .prefix
187        .as_ref()
188        .map(|punctuation| realize(punctuation, super::format::PunctuationPosition::Prefix))
189        .unwrap_or(Cow::Borrowed(""));
190    let suffix = rendering
191        .suffix
192        .as_ref()
193        .map(|punctuation| realize(punctuation, super::format::PunctuationPosition::Suffix))
194        .unwrap_or(Cow::Borrowed(""));
195    (prefix, suffix)
196}
197
198fn apply_component_semantics<F>(
199    component: &ProcTemplateComponent,
200    fmt: &F,
201    show_semantics: bool,
202    output: F::Output,
203) -> F::Output
204where
205    F: OutputFormat<Output = String>,
206{
207    if !show_semantics {
208        return output;
209    }
210    let Some(class) = resolve_semantic_class(component) else {
211        return output;
212    };
213    let semantic_attributes = component
214        .template_index
215        .map(|index| {
216            vec![SemanticAttribute {
217                name: "data-index",
218                value: index.to_string(),
219            }]
220        })
221        .unwrap_or_default();
222    fmt.semantic_with_attributes(&class, output, &semantic_attributes)
223}
224
225/// Render a single component using a specific output format and an existing renderer instance.
226pub fn render_component_with_format_and_renderer<F: OutputFormat<Output = String>>(
227    component: &ProcTemplateComponent,
228    fmt: &F,
229    show_semantics: bool,
230) -> F::Output {
231    render_component_detailed_with_format_and_renderer::<F>(component, fmt, show_semantics).text
232}
233
234/// As [`render_component_with_format_and_renderer`], but returns a
235/// [`RenderedComponent`] rather than a bare string.
236pub(crate) fn render_component_detailed_with_format_and_renderer<
237    F: OutputFormat<Output = String>,
238>(
239    component: &ProcTemplateComponent,
240    fmt: &F,
241    show_semantics: bool,
242) -> RenderedComponent {
243    // Get merged rendering (global config + local settings + overrides)
244    let rendering = get_effective_rendering(component);
245
246    // Check if suppressed
247    if rendering.suppress == Some(true) {
248        return RenderedComponent::default();
249    }
250
251    let multilingual = component
252        .config
253        .as_ref()
254        .and_then(|config| config.multilingual.as_ref());
255    let (script, realization) = crate::values::punctuation_realization_context(
256        component.item_language.as_deref(),
257        multilingual,
258        component.quote_marks.punctuation_realization.as_ref(),
259    );
260    let (prefix, suffix) = realized_component_affixes(&rendering, script, realization.as_deref());
261    let inner_prefix = rendering
262        .wrap
263        .as_ref()
264        .and_then(|w| w.inner_prefix.as_deref())
265        .unwrap_or_default();
266    let inner_suffix = rendering
267        .wrap
268        .as_ref()
269        .and_then(|w| w.inner_suffix.as_deref())
270        .unwrap_or_default();
271
272    let mut output = if component.pre_formatted {
273        // If already pre-formatted (e.g. from a List), don't escape again.
274        // We just need to convert the String back to Output (which is String here).
275        fmt.join(vec![component.value.clone()], "")
276    } else {
277        fmt.text(&component.value)
278    };
279
280    // Apply styles, links, inner affixes, wrap, outer affixes, then semantics.
281    if rendering.emph == Some(true) {
282        output = fmt.emph(output);
283    }
284    if rendering.strong == Some(true) {
285        output = fmt.strong(output);
286    }
287    if rendering.small_caps == Some(true) {
288        output = fmt.small_caps(output);
289    }
290    if rendering.vertical_align == Some(citum_schema::VerticalAlign::Superscript) {
291        output = fmt.superscript(output);
292    }
293    // A `wrap: quotes` (applied below) already surrounds the value in quotation
294    // marks; honoring the `quote` flag as well would double them (`““Title””`).
295    // Only apply the flag when the wrap is not itself a quote wrap.
296    let wrapped_in_quotes = rendering
297        .wrap
298        .as_ref()
299        .is_some_and(|w| w.punctuation == citum_schema::template::WrapPunctuation::Quotes);
300    if rendering.quote == Some(true) && !wrapped_in_quotes {
301        output = fmt.quote(output, &component.quote_marks);
302    }
303
304    if let Some(url) = &component.url {
305        output = fmt.link(url, output);
306    }
307
308    let total_inner_prefix = format!(
309        "{}{}",
310        inner_prefix,
311        component.prefix.as_deref().unwrap_or_default()
312    );
313    let total_inner_suffix = format!(
314        "{}{}",
315        component.suffix.as_deref().unwrap_or_default(),
316        inner_suffix
317    );
318
319    if !total_inner_prefix.is_empty() || !total_inner_suffix.is_empty() {
320        output = fmt.inner_affix(&total_inner_prefix, output, &total_inner_suffix);
321    }
322
323    if let Some(wrap_config) = rendering.wrap.as_ref() {
324        output = fmt.wrap_punctuation(
325            &wrap_config.punctuation,
326            output,
327            &component.quote_marks,
328            script,
329            realization.as_deref(),
330        );
331    }
332
333    if !prefix.is_empty() || !suffix.is_empty() {
334        output = super::format::apply_punctuation_affixes(
335            fmt,
336            rendering
337                .prefix
338                .as_ref()
339                .map(|punctuation| (punctuation, prefix.as_ref())),
340            output,
341            rendering
342                .suffix
343                .as_ref()
344                .map(|punctuation| (punctuation, suffix.as_ref())),
345        );
346    }
347
348    output = apply_component_semantics(component, fmt, show_semantics, output);
349
350    // 7. Legacy literal-punctuation compatibility shim. Semantic punctuation
351    // realizes before this point; this late remap remains only for external
352    // bilingual styles authored with the original `punctuation: latin` option.
353    if wants_latin_punctuation(component) {
354        output = remap_to_latin_punctuation(output);
355    }
356
357    RenderedComponent { text: output }
358}
359
360/// Whether this component opts into the legacy literal-punctuation remap.
361///
362/// New styles express punctuation with semantic marks. This fixed compatibility
363/// shim remains crate-wide because external literal-authored styles may place
364/// punctuation at component, citation-section, or citation-spec boundaries.
365pub(crate) fn wants_latin_punctuation(component: &ProcTemplateComponent) -> bool {
366    let configured = component
367        .config
368        .as_ref()
369        .and_then(|cfg| cfg.multilingual.as_ref())
370        .and_then(|ml| ml.scripts.get("latin"))
371        .is_some_and(|script| {
372            script.punctuation == Some(citum_schema::options::PunctuationStyle::Latin)
373        });
374
375    configured && crate::values::is_latin_script_language(component.item_language.as_deref())
376}
377
378/// Apply the legacy fixed CJK-to-Latin literal-punctuation remap.
379///
380/// `:`(U+FF1A) → `: `, `,`(U+FF0C) → `, `, `(`(U+FF08) → `(`, `)`(U+FF09) → `)`,
381/// then any resulting doubled space is collapsed. Do not extend this table;
382/// new punctuation behavior belongs in semantic realization.
383pub(crate) fn remap_to_latin_punctuation(text: String) -> String {
384    if !text.contains([':', ',', '(', ')']) {
385        return text;
386    }
387
388    let mut mapped = String::with_capacity(text.len());
389    for ch in text.chars() {
390        match ch {
391            ':' => mapped.push_str(": "),
392            ',' => mapped.push_str(", "),
393            '(' => mapped.push('('),
394            ')' => mapped.push(')'),
395            _ => mapped.push(ch),
396        }
397    }
398
399    while mapped.contains("  ") {
400        mapped = mapped.replace("  ", " ");
401    }
402    mapped
403}
404
405/// Get effective rendering, applying global config, then local template settings, then type-specific overrides.
406#[must_use]
407pub fn get_effective_rendering(component: &ProcTemplateComponent) -> Rendering {
408    let mut effective = Rendering::default();
409
410    // 1. Layer global config
411    if let Some(config) = &component.config {
412        match &component.template_component {
413            TemplateComponent::Title(t) => {
414                if let Some(global_title) = get_title_category_rendering(
415                    &t.title,
416                    component.ref_type.as_deref(),
417                    component.item_language.as_deref(),
418                    config,
419                ) {
420                    effective.merge(&global_title);
421                }
422            }
423            TemplateComponent::Contributor(c) => {
424                if let Some(contributors_config) = &config.contributors
425                    && let Some(role_config) = &contributors_config.role
426                    && let Some(primary_role) = c.contributor.as_slice().first()
427                    && let Some(role_rendering) = role_config.role_rendering(primary_role)
428                {
429                    effective.merge(&role_rendering.to_rendering());
430                }
431            }
432            // Add other component types here as we expand Config
433            _ => {}
434        }
435    }
436
437    // 2. Layer local template rendering
438    effective.merge(component.template_component.rendering());
439
440    effective
441}
442
443/// Resolve title-category-specific rendering overrides for a title component.
444///
445/// The returned rendering reflects title type, mapped reference category, and
446/// optional language-specific overrides from the style configuration.
447#[must_use]
448pub fn get_title_category_rendering(
449    title_type: &TitleType,
450    ref_type: Option<&str>,
451    language: Option<&str>,
452    config: &Config,
453) -> Option<Rendering> {
454    get_title_category_title_rendering(title_type, ref_type, language, config)
455        .map(|rendering| rendering.to_rendering())
456}
457
458/// Resolve title-category-specific title rendering options for a title component.
459///
460/// The returned rendering reflects title type, mapped reference category, and
461/// optional language-specific overrides from the style configuration.
462#[must_use]
463pub fn get_title_category_title_rendering(
464    title_type: &TitleType,
465    ref_type: Option<&str>,
466    language: Option<&str>,
467    config: &Config,
468) -> Option<TitleRendering> {
469    let titles_config = config.titles.as_ref()?;
470
471    let mapped_category = ref_type.and_then(|rt| titles_config.mapped_category(rt));
472
473    use crate::values::type_class::TitleCategory;
474
475    let rendering = match title_type {
476        TitleType::ContainerTitle => {
477            if let Some(cat) = mapped_category {
478                match cat {
479                    TitleCategory::Periodical => titles_config.periodical.as_ref(),
480                    TitleCategory::Serial => titles_config.serial.as_ref(),
481                    TitleCategory::Monograph | TitleCategory::ContainerMonograph => titles_config
482                        .container_monograph
483                        .as_ref()
484                        .or(titles_config.monograph.as_ref()),
485                    TitleCategory::Component | TitleCategory::Default => {
486                        titles_config.default.as_ref()
487                    }
488                }
489            } else if let Some(rt) = ref_type {
490                match crate::values::type_class::container_title_category(rt) {
491                    TitleCategory::Periodical => titles_config.periodical.as_ref(),
492                    TitleCategory::ContainerMonograph => titles_config
493                        .container_monograph
494                        .as_ref()
495                        .or(titles_config.monograph.as_ref()),
496                    _ => titles_config.default.as_ref(),
497                }
498            } else {
499                titles_config.default.as_ref()
500            }
501        }
502        TitleType::ParentSerial => {
503            if let Some(cat) = mapped_category {
504                match cat {
505                    TitleCategory::Periodical => titles_config.periodical.as_ref(),
506                    TitleCategory::Serial => titles_config.serial.as_ref(),
507                    TitleCategory::Component
508                    | TitleCategory::Monograph
509                    | TitleCategory::ContainerMonograph
510                    | TitleCategory::Default => titles_config.periodical.as_ref(),
511                }
512            } else if let Some(rt) = ref_type {
513                match crate::values::type_class::parent_serial_title_category(rt) {
514                    TitleCategory::Periodical => titles_config.periodical.as_ref(),
515                    _ => titles_config.serial.as_ref(),
516                }
517            } else {
518                titles_config.periodical.as_ref()
519            }
520        }
521        TitleType::ParentMonograph => titles_config
522            .container_monograph
523            .as_ref()
524            .or(titles_config.monograph.as_ref()),
525        TitleType::CollectionTitle => titles_config
526            .container_monograph
527            .as_ref()
528            .or(titles_config.monograph.as_ref())
529            .or(titles_config.default.as_ref()),
530        TitleType::Primary => {
531            if let Some(cat) = mapped_category {
532                match cat {
533                    TitleCategory::Component => titles_config.component.as_ref(),
534                    TitleCategory::Monograph => titles_config.monograph.as_ref(),
535                    TitleCategory::Periodical
536                    | TitleCategory::Serial
537                    | TitleCategory::ContainerMonograph
538                    | TitleCategory::Default => titles_config.default.as_ref(),
539                }
540            } else if let Some(rt) = ref_type {
541                match crate::values::type_class::title_category(rt) {
542                    TitleCategory::Component => titles_config.component.as_ref(),
543                    TitleCategory::Monograph => titles_config.monograph.as_ref(),
544                    _ => titles_config.default.as_ref(),
545                }
546            } else {
547                titles_config.default.as_ref()
548            }
549        }
550        _ => None,
551    };
552
553    let selected = rendering.or(titles_config.default.as_ref())?;
554    let mut effective = selected.clone();
555    if let Some(override_rendering) = selected.locale_override(language) {
556        effective.merge(override_rendering);
557    }
558    Some(effective)
559}
560
561#[cfg(test)]
562#[allow(
563    clippy::unwrap_used,
564    clippy::expect_used,
565    clippy::panic,
566    clippy::indexing_slicing,
567    clippy::todo,
568    clippy::unimplemented,
569    clippy::unreachable,
570    clippy::get_unwrap,
571    reason = "Panicking is acceptable and often desired in tests."
572)]
573mod tests {
574    use super::*;
575    use citum_schema::template::{Rendering, TemplateComponent, TemplateTitle, TitleType};
576
577    #[test]
578    fn test_render_with_emphasis() {
579        let component = ProcTemplateComponent {
580            template_component: TemplateComponent::Title(TemplateTitle {
581                title: TitleType::Primary,
582                rendering: Rendering {
583                    emph: Some(true),
584                    ..Default::default()
585                },
586                ..Default::default()
587            }),
588            value: "The Structure of Scientific Revolutions".to_string(),
589            ..Default::default()
590        };
591
592        let result = render_component(&component);
593        assert_eq!(result, "_The Structure of Scientific Revolutions_");
594    }
595
596    #[test]
597    fn given_quote_flag_and_quote_wrap_when_render_then_single_pair_of_quotes() {
598        use citum_schema::template::{WrapConfig, WrapPunctuation};
599
600        // Migrated styles can carry both a global `titles.*.quote` flag and a
601        // template `wrap: quotes`; applying both would double the quotes.
602        let component = ProcTemplateComponent {
603            template_component: TemplateComponent::Title(TemplateTitle {
604                title: TitleType::Primary,
605                rendering: Rendering {
606                    quote: Some(true),
607                    wrap: Some(WrapConfig {
608                        punctuation: WrapPunctuation::Quotes,
609                        inner_prefix: None,
610                        inner_suffix: None,
611                    }),
612                    ..Default::default()
613                },
614                ..Default::default()
615            }),
616            value: "The Structure of Scientific Revolutions".to_string(),
617            ..Default::default()
618        };
619
620        let result = render_component(&component);
621        assert_eq!(
622            result,
623            "\u{201C}The Structure of Scientific Revolutions\u{201D}"
624        );
625    }
626
627    #[test]
628    fn given_quote_flag_and_non_quote_wrap_when_render_then_both_applied() {
629        use citum_schema::template::{WrapConfig, WrapPunctuation};
630
631        // A non-quote wrap (parentheses) does not subsume the quote flag, so
632        // both must still apply.
633        let component = ProcTemplateComponent {
634            template_component: TemplateComponent::Title(TemplateTitle {
635                title: TitleType::Primary,
636                rendering: Rendering {
637                    quote: Some(true),
638                    wrap: Some(WrapConfig {
639                        punctuation: WrapPunctuation::Parentheses,
640                        inner_prefix: None,
641                        inner_suffix: None,
642                    }),
643                    ..Default::default()
644                },
645                ..Default::default()
646            }),
647            value: "Title".to_string(),
648            ..Default::default()
649        };
650
651        let result = render_component(&component);
652        assert_eq!(result, "(\u{201C}Title\u{201D})");
653    }
654
655    #[test]
656    fn underscore_title_mapping_key_matches_canonical_reference_type() {
657        let config: Config = serde_yaml::from_str(
658            "titles:\n  type-mapping:\n    personal_communication: component\n  component:\n    quote: true\n",
659        )
660        .expect("title configuration should parse");
661
662        let rendering = get_title_category_title_rendering(
663            &TitleType::Primary,
664            Some("personal-communication"),
665            None,
666            &config,
667        )
668        .expect("the normalized mapping should select component rendering");
669
670        assert_eq!(rendering.quote, Some(true));
671    }
672}