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