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