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;
62
63/// Resolve the semantic CSS class for a rendered component based on its template type.
64fn resolve_semantic_class(component: &ProcTemplateComponent) -> Option<String> {
65    use citum_schema::template::{DateVariable, SimpleVariable};
66    match &component.template_component {
67        TemplateComponent::Title(t) => match t.title {
68            TitleType::Primary => Some("citum-title".to_string()),
69            TitleType::ContainerTitle
70            | TitleType::ParentMonograph
71            | TitleType::ParentSerial
72            | TitleType::CollectionTitle => Some("citum-container-title".to_string()),
73            _ => Some("citum-title".to_string()),
74        },
75        TemplateComponent::Contributor(c) => Some(format!(
76            "citum-{}",
77            c.contributor
78                .as_slice()
79                .iter()
80                .map(citum_schema::template::ContributorRole::as_str)
81                .collect::<Vec<_>>()
82                .join("-")
83        )),
84        TemplateComponent::Date(d) => Some(format!(
85            "citum-{}",
86            match d.date {
87                DateVariable::Issued => "issued",
88                DateVariable::Accessed => "accessed",
89                DateVariable::OriginalPublished => "original-published",
90                DateVariable::Submitted => "submitted",
91                DateVariable::EventDate => "event-date",
92            }
93        )),
94        TemplateComponent::Number(n) => Some(format!("citum-{}", n.number.as_key())),
95        TemplateComponent::Variable(v) => Some(format!(
96            "citum-{}",
97            match v.variable {
98                SimpleVariable::Doi => "doi",
99                SimpleVariable::Url => "url",
100                SimpleVariable::Isbn => "isbn",
101                SimpleVariable::Issn => "issn",
102                SimpleVariable::Pmid => "pmid",
103                SimpleVariable::Note => "note",
104                SimpleVariable::Publisher => "publisher",
105                SimpleVariable::PublisherPlace => "publisher-place",
106                SimpleVariable::ContainerTitleShort => "container-title-short",
107                SimpleVariable::Archive => "archive",
108                _ => "variable",
109            }
110        )),
111        TemplateComponent::Message(m) => Some(format!(
112            "citum-message-{}",
113            m.message
114                .chars()
115                .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' })
116                .collect::<String>()
117                .trim_matches('-')
118        )),
119        _ => None,
120    }
121}
122
123/// Render a single component to string using the default `PlainText` format.
124#[must_use]
125pub fn render_component(component: &ProcTemplateComponent) -> String {
126    PlainText.finish(render_component_with_format::<PlainText>(component))
127}
128
129/// Render a single component using a specific output format.
130#[must_use]
131pub fn render_component_with_format<F: OutputFormat<Output = String>>(
132    component: &ProcTemplateComponent,
133) -> F::Output {
134    render_component_with_format_and_renderer::<F>(component, &F::default(), true)
135}
136
137/// Render a single component using a specific output format and an existing renderer instance.
138pub fn render_component_with_format_and_renderer<F: OutputFormat<Output = String>>(
139    component: &ProcTemplateComponent,
140    fmt: &F,
141    show_semantics: bool,
142) -> F::Output {
143    // Get merged rendering (global config + local settings + overrides)
144    let rendering = get_effective_rendering(component);
145
146    // Check if suppressed
147    if rendering.suppress == Some(true) {
148        return fmt.text("");
149    }
150
151    let prefix = rendering.prefix.as_deref().unwrap_or_default();
152    let suffix = rendering.suffix.as_deref().unwrap_or_default();
153    let inner_prefix = rendering
154        .wrap
155        .as_ref()
156        .and_then(|w| w.inner_prefix.as_deref())
157        .unwrap_or_default();
158    let inner_suffix = rendering
159        .wrap
160        .as_ref()
161        .and_then(|w| w.inner_suffix.as_deref())
162        .unwrap_or_default();
163
164    let mut output = if component.pre_formatted {
165        // If already pre-formatted (e.g. from a List), don't escape again.
166        // We just need to convert the String back to Output (which is String here).
167        fmt.join(vec![component.value.clone()], "")
168    } else {
169        fmt.text(&component.value)
170    };
171
172    // Order of application:
173    // 1. Text styles (emph, strong, etc.)
174    // 2. Links
175    // 3. Inner affixes
176    // 4. Wrap
177    // 5. Outer affixes
178    // 6. Semantic classes (last, to wrap everything)
179
180    // 1. Apply text styles
181    if rendering.emph == Some(true) {
182        output = fmt.emph(output);
183    }
184    if rendering.strong == Some(true) {
185        output = fmt.strong(output);
186    }
187    if rendering.small_caps == Some(true) {
188        output = fmt.small_caps(output);
189    }
190    if rendering.vertical_align == Some(citum_schema::VerticalAlign::Superscript) {
191        output = fmt.superscript(output);
192    }
193    // A `wrap: quotes` (applied below) already surrounds the value in quotation
194    // marks; honoring the `quote` flag as well would double them (`““Title””`).
195    // Only apply the flag when the wrap is not itself a quote wrap.
196    let wrapped_in_quotes = rendering
197        .wrap
198        .as_ref()
199        .is_some_and(|w| w.punctuation == citum_schema::template::WrapPunctuation::Quotes);
200    if rendering.quote == Some(true) && !wrapped_in_quotes {
201        output = fmt.quote(output, &component.quote_marks);
202    }
203
204    // 2. Apply links if URL is present
205    if let Some(url) = &component.url {
206        output = fmt.link(url, output);
207    }
208
209    // 3. Inner affixes + extracted val prefix/suffix
210    let total_inner_prefix = format!(
211        "{}{}",
212        inner_prefix,
213        component.prefix.as_deref().unwrap_or_default()
214    );
215    let total_inner_suffix = format!(
216        "{}{}",
217        component.suffix.as_deref().unwrap_or_default(),
218        inner_suffix
219    );
220
221    if !total_inner_prefix.is_empty() || !total_inner_suffix.is_empty() {
222        output = fmt.inner_affix(&total_inner_prefix, output, &total_inner_suffix);
223    }
224
225    // 4. Wrap
226    if let Some(wrap_config) = rendering.wrap.as_ref() {
227        output = fmt.wrap_punctuation(&wrap_config.punctuation, output, &component.quote_marks);
228    }
229
230    // 5. Outer affixes
231    if !prefix.is_empty() || !suffix.is_empty() {
232        output = fmt.affix(prefix, output, suffix);
233    }
234
235    // 6. Apply semantic class based on component type
236    if show_semantics && let Some(class) = resolve_semantic_class(component) {
237        let semantic_attributes = component
238            .template_index
239            .map(|index| {
240                vec![SemanticAttribute {
241                    name: "data-index",
242                    value: index.to_string(),
243                }]
244            })
245            .unwrap_or_default();
246        output = fmt.semantic_with_attributes(&class, output, &semantic_attributes);
247    }
248
249    output
250}
251
252/// Get effective rendering, applying global config, then local template settings, then type-specific overrides.
253#[must_use]
254pub fn get_effective_rendering(component: &ProcTemplateComponent) -> Rendering {
255    let mut effective = Rendering::default();
256
257    // 1. Layer global config
258    if let Some(config) = &component.config {
259        match &component.template_component {
260            TemplateComponent::Title(t) => {
261                if let Some(global_title) = get_title_category_rendering(
262                    &t.title,
263                    component.ref_type.as_deref(),
264                    component.item_language.as_deref(),
265                    config,
266                ) {
267                    effective.merge(&global_title);
268                }
269            }
270            TemplateComponent::Contributor(c) => {
271                if let Some(contributors_config) = &config.contributors
272                    && let Some(role_config) = &contributors_config.role
273                    && let Some(primary_role) = c.contributor.as_slice().first()
274                    && let Some(role_rendering) = role_config.role_rendering(primary_role)
275                {
276                    effective.merge(&role_rendering.to_rendering());
277                }
278            }
279            // Add other component types here as we expand Config
280            _ => {}
281        }
282    }
283
284    // 2. Layer local template rendering
285    effective.merge(component.template_component.rendering());
286
287    effective
288}
289
290/// Resolve title-category-specific rendering overrides for a title component.
291///
292/// The returned rendering reflects title type, mapped reference category, and
293/// optional language-specific overrides from the style configuration.
294#[must_use]
295pub fn get_title_category_rendering(
296    title_type: &TitleType,
297    ref_type: Option<&str>,
298    language: Option<&str>,
299    config: &Config,
300) -> Option<Rendering> {
301    get_title_category_title_rendering(title_type, ref_type, language, config)
302        .map(|rendering| rendering.to_rendering())
303}
304
305/// Resolve title-category-specific title rendering options for a title component.
306///
307/// The returned rendering reflects title type, mapped reference category, and
308/// optional language-specific overrides from the style configuration.
309#[must_use]
310pub fn get_title_category_title_rendering(
311    title_type: &TitleType,
312    ref_type: Option<&str>,
313    language: Option<&str>,
314    config: &Config,
315) -> Option<TitleRendering> {
316    let titles_config = config.titles.as_ref()?;
317
318    // Use type_mapping if available to resolve category
319    let mapped_category = ref_type.and_then(|rt| titles_config.type_mapping.get(rt));
320
321    use crate::values::type_class::TitleCategory;
322
323    let rendering = match title_type {
324        TitleType::ContainerTitle => {
325            if let Some(cat) = mapped_category {
326                match cat.as_str() {
327                    "periodical" => titles_config.periodical.as_ref(),
328                    "serial" => titles_config.serial.as_ref(),
329                    "monograph" | "collection" => titles_config
330                        .container_monograph
331                        .as_ref()
332                        .or(titles_config.monograph.as_ref()),
333                    _ => titles_config.default.as_ref(),
334                }
335            } else if let Some(rt) = ref_type {
336                match crate::values::type_class::container_title_category(rt) {
337                    TitleCategory::Periodical => titles_config.periodical.as_ref(),
338                    TitleCategory::ContainerMonograph => titles_config
339                        .container_monograph
340                        .as_ref()
341                        .or(titles_config.monograph.as_ref()),
342                    _ => titles_config.default.as_ref(),
343                }
344            } else {
345                titles_config.default.as_ref()
346            }
347        }
348        TitleType::ParentSerial => {
349            if let Some(cat) = mapped_category {
350                match cat.as_str() {
351                    "periodical" => titles_config.periodical.as_ref(),
352                    "serial" => titles_config.serial.as_ref(),
353                    _ => titles_config.periodical.as_ref(),
354                }
355            } else if let Some(rt) = ref_type {
356                match crate::values::type_class::parent_serial_title_category(rt) {
357                    TitleCategory::Periodical => titles_config.periodical.as_ref(),
358                    _ => titles_config.serial.as_ref(),
359                }
360            } else {
361                titles_config.periodical.as_ref()
362            }
363        }
364        TitleType::ParentMonograph => titles_config
365            .container_monograph
366            .as_ref()
367            .or(titles_config.monograph.as_ref()),
368        TitleType::CollectionTitle => titles_config
369            .container_monograph
370            .as_ref()
371            .or(titles_config.monograph.as_ref())
372            .or(titles_config.default.as_ref()),
373        TitleType::Primary => {
374            if let Some(cat) = mapped_category {
375                match cat.as_str() {
376                    "component" => titles_config.component.as_ref(),
377                    "monograph" => titles_config.monograph.as_ref(),
378                    _ => titles_config.default.as_ref(),
379                }
380            } else if let Some(rt) = ref_type {
381                match crate::values::type_class::title_category(rt) {
382                    TitleCategory::Component => titles_config.component.as_ref(),
383                    TitleCategory::Monograph => titles_config.monograph.as_ref(),
384                    _ => titles_config.default.as_ref(),
385                }
386            } else {
387                titles_config.default.as_ref()
388            }
389        }
390        _ => None,
391    };
392
393    let selected = rendering.or(titles_config.default.as_ref())?;
394    let mut effective = selected.clone();
395    if let Some(override_rendering) = selected.locale_override(language) {
396        effective.merge(override_rendering);
397    }
398    Some(effective)
399}
400
401#[cfg(test)]
402#[allow(
403    clippy::unwrap_used,
404    clippy::expect_used,
405    clippy::panic,
406    clippy::indexing_slicing,
407    clippy::todo,
408    clippy::unimplemented,
409    clippy::unreachable,
410    clippy::get_unwrap,
411    reason = "Panicking is acceptable and often desired in tests."
412)]
413mod tests {
414    use super::*;
415    use citum_schema::template::{Rendering, TemplateComponent, TemplateTitle, TitleType};
416
417    #[test]
418    fn test_render_with_emphasis() {
419        let component = ProcTemplateComponent {
420            template_component: TemplateComponent::Title(TemplateTitle {
421                title: TitleType::Primary,
422                rendering: Rendering {
423                    emph: Some(true),
424                    ..Default::default()
425                },
426                ..Default::default()
427            }),
428            value: "The Structure of Scientific Revolutions".to_string(),
429            ..Default::default()
430        };
431
432        let result = render_component(&component);
433        assert_eq!(result, "_The Structure of Scientific Revolutions_");
434    }
435
436    #[test]
437    fn given_quote_flag_and_quote_wrap_when_render_then_single_pair_of_quotes() {
438        use citum_schema::template::{WrapConfig, WrapPunctuation};
439
440        // Migrated styles can carry both a global `titles.*.quote` flag and a
441        // template `wrap: quotes`; applying both would double the quotes.
442        let component = ProcTemplateComponent {
443            template_component: TemplateComponent::Title(TemplateTitle {
444                title: TitleType::Primary,
445                rendering: Rendering {
446                    quote: Some(true),
447                    wrap: Some(WrapConfig {
448                        punctuation: WrapPunctuation::Quotes,
449                        inner_prefix: None,
450                        inner_suffix: None,
451                    }),
452                    ..Default::default()
453                },
454                ..Default::default()
455            }),
456            value: "The Structure of Scientific Revolutions".to_string(),
457            ..Default::default()
458        };
459
460        let result = render_component(&component);
461        assert_eq!(
462            result,
463            "\u{201C}The Structure of Scientific Revolutions\u{201D}"
464        );
465    }
466
467    #[test]
468    fn given_quote_flag_and_non_quote_wrap_when_render_then_both_applied() {
469        use citum_schema::template::{WrapConfig, WrapPunctuation};
470
471        // A non-quote wrap (parentheses) does not subsume the quote flag, so
472        // both must still apply.
473        let component = ProcTemplateComponent {
474            template_component: TemplateComponent::Title(TemplateTitle {
475                title: TitleType::Primary,
476                rendering: Rendering {
477                    quote: Some(true),
478                    wrap: Some(WrapConfig {
479                        punctuation: WrapPunctuation::Parentheses,
480                        inner_prefix: None,
481                        inner_suffix: None,
482                    }),
483                    ..Default::default()
484                },
485                ..Default::default()
486            }),
487            value: "Title".to_string(),
488            ..Default::default()
489        };
490
491        let result = render_component(&component);
492        assert_eq!(result, "(\u{201C}Title\u{201D})");
493    }
494}