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