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