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    );
214    let (prefix, suffix) = realized_component_affixes(&rendering, script, realization);
215    let inner_prefix = rendering
216        .wrap
217        .as_ref()
218        .and_then(|w| w.inner_prefix.as_deref())
219        .unwrap_or_default();
220    let inner_suffix = rendering
221        .wrap
222        .as_ref()
223        .and_then(|w| w.inner_suffix.as_deref())
224        .unwrap_or_default();
225
226    let mut output = if component.pre_formatted {
227        // If already pre-formatted (e.g. from a List), don't escape again.
228        // We just need to convert the String back to Output (which is String here).
229        fmt.join(vec![component.value.clone()], "")
230    } else {
231        fmt.text(&component.value)
232    };
233
234    // Apply styles, links, inner affixes, wrap, outer affixes, then semantics.
235    if rendering.emph == Some(true) {
236        output = fmt.emph(output);
237    }
238    if rendering.strong == Some(true) {
239        output = fmt.strong(output);
240    }
241    if rendering.small_caps == Some(true) {
242        output = fmt.small_caps(output);
243    }
244    if rendering.vertical_align == Some(citum_schema::VerticalAlign::Superscript) {
245        output = fmt.superscript(output);
246    }
247    // A `wrap: quotes` (applied below) already surrounds the value in quotation
248    // marks; honoring the `quote` flag as well would double them (`““Title””`).
249    // Only apply the flag when the wrap is not itself a quote wrap.
250    let wrapped_in_quotes = rendering
251        .wrap
252        .as_ref()
253        .is_some_and(|w| w.punctuation == citum_schema::template::WrapPunctuation::Quotes);
254    if rendering.quote == Some(true) && !wrapped_in_quotes {
255        output = fmt.quote(output, &component.quote_marks);
256    }
257
258    if let Some(url) = &component.url {
259        output = fmt.link(url, output);
260    }
261
262    let total_inner_prefix = format!(
263        "{}{}",
264        inner_prefix,
265        component.prefix.as_deref().unwrap_or_default()
266    );
267    let total_inner_suffix = format!(
268        "{}{}",
269        component.suffix.as_deref().unwrap_or_default(),
270        inner_suffix
271    );
272
273    if !total_inner_prefix.is_empty() || !total_inner_suffix.is_empty() {
274        output = fmt.inner_affix(&total_inner_prefix, output, &total_inner_suffix);
275    }
276
277    if let Some(wrap_config) = rendering.wrap.as_ref() {
278        output = fmt.wrap_punctuation(
279            &wrap_config.punctuation,
280            output,
281            &component.quote_marks,
282            script,
283            realization,
284        );
285    }
286
287    if !prefix.is_empty() || !suffix.is_empty() {
288        output = super::format::apply_punctuation_affixes(
289            fmt,
290            rendering
291                .prefix
292                .as_ref()
293                .map(|punctuation| (punctuation, prefix.as_ref())),
294            output,
295            rendering
296                .suffix
297                .as_ref()
298                .map(|punctuation| (punctuation, suffix.as_ref())),
299        );
300    }
301
302    output = apply_component_semantics(component, fmt, show_semantics, output);
303
304    // 7. Legacy literal-punctuation compatibility shim. Semantic punctuation
305    // realizes before this point; this late remap remains only for external
306    // bilingual styles authored with the original `punctuation: latin` option.
307    if wants_latin_punctuation(component) {
308        output = remap_to_latin_punctuation(output);
309    }
310
311    output
312}
313
314/// Whether this component opts into the legacy literal-punctuation remap.
315///
316/// New styles express punctuation with semantic marks. This fixed compatibility
317/// shim remains crate-wide because external literal-authored styles may place
318/// punctuation at component, citation-section, or citation-spec boundaries.
319pub(crate) fn wants_latin_punctuation(component: &ProcTemplateComponent) -> bool {
320    let configured = component
321        .config
322        .as_ref()
323        .and_then(|cfg| cfg.multilingual.as_ref())
324        .and_then(|ml| ml.scripts.get("latin"))
325        .is_some_and(|script| {
326            script.punctuation == Some(citum_schema::options::PunctuationStyle::Latin)
327        });
328
329    configured && crate::values::is_latin_script_language(component.item_language.as_deref())
330}
331
332/// Apply the legacy fixed CJK-to-Latin literal-punctuation remap.
333///
334/// `:`(U+FF1A) → `: `, `,`(U+FF0C) → `, `, `(`(U+FF08) → `(`, `)`(U+FF09) → `)`,
335/// then any resulting doubled space is collapsed. Do not extend this table;
336/// new punctuation behavior belongs in semantic realization.
337pub(crate) fn remap_to_latin_punctuation(text: String) -> String {
338    if !text.contains([':', ',', '(', ')']) {
339        return text;
340    }
341
342    let mut mapped = String::with_capacity(text.len());
343    for ch in text.chars() {
344        match ch {
345            ':' => mapped.push_str(": "),
346            ',' => mapped.push_str(", "),
347            '(' => mapped.push('('),
348            ')' => mapped.push(')'),
349            _ => mapped.push(ch),
350        }
351    }
352
353    while mapped.contains("  ") {
354        mapped = mapped.replace("  ", " ");
355    }
356    mapped
357}
358
359/// Get effective rendering, applying global config, then local template settings, then type-specific overrides.
360#[must_use]
361pub fn get_effective_rendering(component: &ProcTemplateComponent) -> Rendering {
362    let mut effective = Rendering::default();
363
364    // 1. Layer global config
365    if let Some(config) = &component.config {
366        match &component.template_component {
367            TemplateComponent::Title(t) => {
368                if let Some(global_title) = get_title_category_rendering(
369                    &t.title,
370                    component.ref_type.as_deref(),
371                    component.item_language.as_deref(),
372                    config,
373                ) {
374                    effective.merge(&global_title);
375                }
376            }
377            TemplateComponent::Contributor(c) => {
378                if let Some(contributors_config) = &config.contributors
379                    && let Some(role_config) = &contributors_config.role
380                    && let Some(primary_role) = c.contributor.as_slice().first()
381                    && let Some(role_rendering) = role_config.role_rendering(primary_role)
382                {
383                    effective.merge(&role_rendering.to_rendering());
384                }
385            }
386            // Add other component types here as we expand Config
387            _ => {}
388        }
389    }
390
391    // 2. Layer local template rendering
392    effective.merge(component.template_component.rendering());
393
394    effective
395}
396
397/// Resolve title-category-specific rendering overrides for a title component.
398///
399/// The returned rendering reflects title type, mapped reference category, and
400/// optional language-specific overrides from the style configuration.
401#[must_use]
402pub fn get_title_category_rendering(
403    title_type: &TitleType,
404    ref_type: Option<&str>,
405    language: Option<&str>,
406    config: &Config,
407) -> Option<Rendering> {
408    get_title_category_title_rendering(title_type, ref_type, language, config)
409        .map(|rendering| rendering.to_rendering())
410}
411
412/// Resolve title-category-specific title rendering options for a title component.
413///
414/// The returned rendering reflects title type, mapped reference category, and
415/// optional language-specific overrides from the style configuration.
416#[must_use]
417pub fn get_title_category_title_rendering(
418    title_type: &TitleType,
419    ref_type: Option<&str>,
420    language: Option<&str>,
421    config: &Config,
422) -> Option<TitleRendering> {
423    let titles_config = config.titles.as_ref()?;
424
425    // Use type_mapping if available to resolve category
426    let mapped_category = ref_type.and_then(|rt| titles_config.type_mapping.get(rt));
427
428    use crate::values::type_class::TitleCategory;
429
430    let rendering = match title_type {
431        TitleType::ContainerTitle => {
432            if let Some(cat) = mapped_category {
433                match cat.as_str() {
434                    "periodical" => titles_config.periodical.as_ref(),
435                    "serial" => titles_config.serial.as_ref(),
436                    "monograph" | "collection" => titles_config
437                        .container_monograph
438                        .as_ref()
439                        .or(titles_config.monograph.as_ref()),
440                    _ => titles_config.default.as_ref(),
441                }
442            } else if let Some(rt) = ref_type {
443                match crate::values::type_class::container_title_category(rt) {
444                    TitleCategory::Periodical => titles_config.periodical.as_ref(),
445                    TitleCategory::ContainerMonograph => titles_config
446                        .container_monograph
447                        .as_ref()
448                        .or(titles_config.monograph.as_ref()),
449                    _ => titles_config.default.as_ref(),
450                }
451            } else {
452                titles_config.default.as_ref()
453            }
454        }
455        TitleType::ParentSerial => {
456            if let Some(cat) = mapped_category {
457                match cat.as_str() {
458                    "periodical" => titles_config.periodical.as_ref(),
459                    "serial" => titles_config.serial.as_ref(),
460                    _ => titles_config.periodical.as_ref(),
461                }
462            } else if let Some(rt) = ref_type {
463                match crate::values::type_class::parent_serial_title_category(rt) {
464                    TitleCategory::Periodical => titles_config.periodical.as_ref(),
465                    _ => titles_config.serial.as_ref(),
466                }
467            } else {
468                titles_config.periodical.as_ref()
469            }
470        }
471        TitleType::ParentMonograph => titles_config
472            .container_monograph
473            .as_ref()
474            .or(titles_config.monograph.as_ref()),
475        TitleType::CollectionTitle => titles_config
476            .container_monograph
477            .as_ref()
478            .or(titles_config.monograph.as_ref())
479            .or(titles_config.default.as_ref()),
480        TitleType::Primary => {
481            if let Some(cat) = mapped_category {
482                match cat.as_str() {
483                    "component" => titles_config.component.as_ref(),
484                    "monograph" => titles_config.monograph.as_ref(),
485                    _ => titles_config.default.as_ref(),
486                }
487            } else if let Some(rt) = ref_type {
488                match crate::values::type_class::title_category(rt) {
489                    TitleCategory::Component => titles_config.component.as_ref(),
490                    TitleCategory::Monograph => titles_config.monograph.as_ref(),
491                    _ => titles_config.default.as_ref(),
492                }
493            } else {
494                titles_config.default.as_ref()
495            }
496        }
497        _ => None,
498    };
499
500    let selected = rendering.or(titles_config.default.as_ref())?;
501    let mut effective = selected.clone();
502    if let Some(override_rendering) = selected.locale_override(language) {
503        effective.merge(override_rendering);
504    }
505    Some(effective)
506}
507
508#[cfg(test)]
509#[allow(
510    clippy::unwrap_used,
511    clippy::expect_used,
512    clippy::panic,
513    clippy::indexing_slicing,
514    clippy::todo,
515    clippy::unimplemented,
516    clippy::unreachable,
517    clippy::get_unwrap,
518    reason = "Panicking is acceptable and often desired in tests."
519)]
520mod tests {
521    use super::*;
522    use citum_schema::template::{Rendering, TemplateComponent, TemplateTitle, TitleType};
523
524    #[test]
525    fn test_render_with_emphasis() {
526        let component = ProcTemplateComponent {
527            template_component: TemplateComponent::Title(TemplateTitle {
528                title: TitleType::Primary,
529                rendering: Rendering {
530                    emph: Some(true),
531                    ..Default::default()
532                },
533                ..Default::default()
534            }),
535            value: "The Structure of Scientific Revolutions".to_string(),
536            ..Default::default()
537        };
538
539        let result = render_component(&component);
540        assert_eq!(result, "_The Structure of Scientific Revolutions_");
541    }
542
543    #[test]
544    fn given_quote_flag_and_quote_wrap_when_render_then_single_pair_of_quotes() {
545        use citum_schema::template::{WrapConfig, WrapPunctuation};
546
547        // Migrated styles can carry both a global `titles.*.quote` flag and a
548        // template `wrap: quotes`; applying both would double the quotes.
549        let component = ProcTemplateComponent {
550            template_component: TemplateComponent::Title(TemplateTitle {
551                title: TitleType::Primary,
552                rendering: Rendering {
553                    quote: Some(true),
554                    wrap: Some(WrapConfig {
555                        punctuation: WrapPunctuation::Quotes,
556                        inner_prefix: None,
557                        inner_suffix: None,
558                    }),
559                    ..Default::default()
560                },
561                ..Default::default()
562            }),
563            value: "The Structure of Scientific Revolutions".to_string(),
564            ..Default::default()
565        };
566
567        let result = render_component(&component);
568        assert_eq!(
569            result,
570            "\u{201C}The Structure of Scientific Revolutions\u{201D}"
571        );
572    }
573
574    #[test]
575    fn given_quote_flag_and_non_quote_wrap_when_render_then_both_applied() {
576        use citum_schema::template::{WrapConfig, WrapPunctuation};
577
578        // A non-quote wrap (parentheses) does not subsume the quote flag, so
579        // both must still apply.
580        let component = ProcTemplateComponent {
581            template_component: TemplateComponent::Title(TemplateTitle {
582                title: TitleType::Primary,
583                rendering: Rendering {
584                    quote: Some(true),
585                    wrap: Some(WrapConfig {
586                        punctuation: WrapPunctuation::Parentheses,
587                        inner_prefix: None,
588                        inner_suffix: None,
589                    }),
590                    ..Default::default()
591                },
592                ..Default::default()
593            }),
594            value: "Title".to_string(),
595            ..Default::default()
596        };
597
598        let result = render_component(&component);
599        assert_eq!(result, "(\u{201C}Title\u{201D})");
600    }
601}