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