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
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::ParentMonograph | TitleType::ParentSerial => {
62                Some("citum-container-title".to_string())
63            }
64            _ => Some("citum-title".to_string()),
65        },
66        TemplateComponent::Contributor(c) => Some(format!("citum-{}", c.contributor.as_str())),
67        TemplateComponent::Date(d) => Some(format!(
68            "citum-{}",
69            match d.date {
70                DateVariable::Issued => "issued",
71                DateVariable::Accessed => "accessed",
72                DateVariable::OriginalPublished => "original-published",
73                DateVariable::Submitted => "submitted",
74                DateVariable::EventDate => "event-date",
75            }
76        )),
77        TemplateComponent::Number(n) => Some(format!("citum-{}", n.number.as_key())),
78        TemplateComponent::Variable(v) => Some(format!(
79            "citum-{}",
80            match v.variable {
81                SimpleVariable::Doi => "doi",
82                SimpleVariable::Url => "url",
83                SimpleVariable::Isbn => "isbn",
84                SimpleVariable::Issn => "issn",
85                SimpleVariable::Pmid => "pmid",
86                SimpleVariable::Note => "note",
87                SimpleVariable::Publisher => "publisher",
88                SimpleVariable::PublisherPlace => "publisher-place",
89                SimpleVariable::ContainerTitleShort => "container-title-short",
90                SimpleVariable::Archive => "archive",
91                _ => "variable",
92            }
93        )),
94        _ => None,
95    }
96}
97
98/// Render a single component to string using the default `PlainText` format.
99#[must_use]
100pub fn render_component(component: &ProcTemplateComponent) -> String {
101    PlainText.finish(render_component_with_format::<PlainText>(component))
102}
103
104/// Render a single component using a specific output format.
105#[must_use]
106pub fn render_component_with_format<F: OutputFormat<Output = String>>(
107    component: &ProcTemplateComponent,
108) -> F::Output {
109    render_component_with_format_and_renderer::<F>(component, &F::default(), true)
110}
111
112/// Render a single component using a specific output format and an existing renderer instance.
113pub fn render_component_with_format_and_renderer<F: OutputFormat<Output = String>>(
114    component: &ProcTemplateComponent,
115    fmt: &F,
116    show_semantics: bool,
117) -> F::Output {
118    // Get merged rendering (global config + local settings + overrides)
119    let rendering = get_effective_rendering(component);
120
121    // Check if suppressed
122    if rendering.suppress == Some(true) {
123        return fmt.text("");
124    }
125
126    let prefix = rendering.prefix.as_deref().unwrap_or_default();
127    let suffix = rendering.suffix.as_deref().unwrap_or_default();
128    let inner_prefix = rendering
129        .wrap
130        .as_ref()
131        .and_then(|w| w.inner_prefix.as_deref())
132        .unwrap_or_default();
133    let inner_suffix = rendering
134        .wrap
135        .as_ref()
136        .and_then(|w| w.inner_suffix.as_deref())
137        .unwrap_or_default();
138
139    let mut output = if component.pre_formatted {
140        // If already pre-formatted (e.g. from a List), don't escape again.
141        // We just need to convert the String back to Output (which is String here).
142        fmt.join(vec![component.value.clone()], "")
143    } else {
144        fmt.text(&component.value)
145    };
146
147    // Order of application:
148    // 1. Text styles (emph, strong, etc.)
149    // 2. Links
150    // 3. Inner affixes
151    // 4. Wrap
152    // 5. Outer affixes
153    // 6. Semantic classes (last, to wrap everything)
154
155    // 1. Apply text styles
156    if rendering.emph == Some(true) {
157        output = fmt.emph(output);
158    }
159    if rendering.strong == Some(true) {
160        output = fmt.strong(output);
161    }
162    if rendering.small_caps == Some(true) {
163        output = fmt.small_caps(output);
164    }
165    if rendering.vertical_align == Some(citum_schema::VerticalAlign::Superscript) {
166        output = fmt.superscript(output);
167    }
168    if rendering.quote == Some(true) {
169        output = fmt.quote(output);
170    }
171
172    // 2. Apply links if URL is present
173    if let Some(url) = &component.url {
174        output = fmt.link(url, output);
175    }
176
177    // 3. Inner affixes + extracted val prefix/suffix
178    let total_inner_prefix = format!(
179        "{}{}",
180        inner_prefix,
181        component.prefix.as_deref().unwrap_or_default()
182    );
183    let total_inner_suffix = format!(
184        "{}{}",
185        component.suffix.as_deref().unwrap_or_default(),
186        inner_suffix
187    );
188
189    if !total_inner_prefix.is_empty() || !total_inner_suffix.is_empty() {
190        output = fmt.inner_affix(&total_inner_prefix, output, &total_inner_suffix);
191    }
192
193    // 4. Wrap
194    if let Some(wrap_config) = rendering.wrap.as_ref() {
195        output = fmt.wrap_punctuation(&wrap_config.punctuation, output);
196    }
197
198    // 5. Outer affixes
199    if !prefix.is_empty() || !suffix.is_empty() {
200        output = fmt.affix(prefix, output, suffix);
201    }
202
203    // 6. Apply semantic class based on component type
204    if show_semantics && let Some(class) = resolve_semantic_class(component) {
205        let semantic_attributes = component
206            .template_index
207            .map(|index| {
208                vec![SemanticAttribute {
209                    name: "data-index",
210                    value: index.to_string(),
211                }]
212            })
213            .unwrap_or_default();
214        output = fmt.semantic_with_attributes(&class, output, &semantic_attributes);
215    }
216
217    output
218}
219
220/// Get effective rendering, applying global config, then local template settings, then type-specific overrides.
221#[must_use]
222pub fn get_effective_rendering(component: &ProcTemplateComponent) -> Rendering {
223    let mut effective = Rendering::default();
224
225    // 1. Layer global config
226    if let Some(config) = &component.config {
227        match &component.template_component {
228            TemplateComponent::Title(t) => {
229                if let Some(global_title) = get_title_category_rendering(
230                    &t.title,
231                    component.ref_type.as_deref(),
232                    component.item_language.as_deref(),
233                    config,
234                ) {
235                    effective.merge(&global_title);
236                }
237            }
238            TemplateComponent::Contributor(c) => {
239                if let Some(contributors_config) = &config.contributors
240                    && let Some(role_config) = &contributors_config.role
241                    && let Some(role_rendering) = role_config.role_rendering(&c.contributor)
242                {
243                    effective.merge(&role_rendering.to_rendering());
244                }
245            }
246            // Add other component types here as we expand Config
247            _ => {}
248        }
249    }
250
251    // 2. Layer local template rendering
252    effective.merge(component.template_component.rendering());
253
254    if component.ref_type.as_deref() == Some("dataset")
255        && component.value.starts_with('[')
256        && matches!(
257            component.template_component,
258            TemplateComponent::Title(TemplateTitle {
259                title: TitleType::Primary,
260                ..
261            })
262        )
263        && effective.suffix.as_deref() == Some(" [Dataset].")
264    {
265        effective.suffix = Some(".".to_string());
266    }
267
268    effective
269}
270
271/// Resolve title-category-specific rendering overrides for a title component.
272///
273/// The returned rendering reflects title type, mapped reference category, and
274/// optional language-specific overrides from the style configuration.
275#[must_use]
276pub fn get_title_category_rendering(
277    title_type: &TitleType,
278    ref_type: Option<&str>,
279    language: Option<&str>,
280    config: &Config,
281) -> Option<Rendering> {
282    let titles_config = config.titles.as_ref()?;
283
284    // Use type_mapping if available to resolve category
285    let mapped_category = ref_type.and_then(|rt| titles_config.type_mapping.get(rt));
286
287    let rendering = match title_type {
288        TitleType::ParentSerial => {
289            if let Some(cat) = mapped_category {
290                match cat.as_str() {
291                    "periodical" => titles_config.periodical.as_ref(),
292                    "serial" => titles_config.serial.as_ref(),
293                    _ => titles_config.periodical.as_ref(),
294                }
295            } else if let Some(rt) = ref_type {
296                if matches!(
297                    rt,
298                    "article-journal" | "article-magazine" | "article-newspaper"
299                ) {
300                    titles_config.periodical.as_ref()
301                } else {
302                    titles_config.serial.as_ref()
303                }
304            } else {
305                titles_config.periodical.as_ref()
306            }
307        }
308        TitleType::ParentMonograph => titles_config
309            .container_monograph
310            .as_ref()
311            .or(titles_config.monograph.as_ref()),
312        TitleType::Primary => {
313            if let Some(cat) = mapped_category {
314                match cat.as_str() {
315                    "component" => titles_config.component.as_ref(),
316                    "monograph" => titles_config.monograph.as_ref(),
317                    _ => titles_config.default.as_ref(),
318                }
319            } else if let Some(rt) = ref_type {
320                // Legacy hardcoded logic
321                // "Component" titles: articles, chapters, entries - typically quoted
322                if matches!(
323                    rt,
324                    "article-journal"
325                        | "article-magazine"
326                        | "article-newspaper"
327                        | "chapter"
328                        | "entry"
329                        | "entry-dictionary"
330                        | "entry-encyclopedia"
331                        | "paper-conference"
332                        | "post"
333                        | "post-weblog"
334                ) {
335                    titles_config.component.as_ref()
336                } else if matches!(rt, "book" | "thesis" | "report") {
337                    titles_config.monograph.as_ref()
338                } else {
339                    titles_config.default.as_ref()
340                }
341            } else {
342                titles_config.default.as_ref()
343            }
344        }
345        _ => None,
346    };
347
348    let selected = rendering.or(titles_config.default.as_ref())?;
349    let mut effective = selected.to_rendering();
350    if let Some(override_rendering) = selected.locale_override(language) {
351        effective.merge(&override_rendering.to_rendering());
352    }
353    Some(effective)
354}
355
356#[cfg(test)]
357#[allow(
358    clippy::unwrap_used,
359    clippy::expect_used,
360    clippy::panic,
361    clippy::indexing_slicing,
362    clippy::todo,
363    clippy::unimplemented,
364    clippy::unreachable,
365    clippy::get_unwrap,
366    reason = "Panicking is acceptable and often desired in tests."
367)]
368mod tests {
369    use super::*;
370    use citum_schema::template::{Rendering, TemplateComponent, TemplateTitle, TitleType};
371
372    #[test]
373    fn test_render_with_emphasis() {
374        let component = ProcTemplateComponent {
375            template_component: TemplateComponent::Title(TemplateTitle {
376                title: TitleType::Primary,
377                rendering: Rendering {
378                    emph: Some(true),
379                    ..Default::default()
380                },
381                ..Default::default()
382            }),
383            value: "The Structure of Scientific Revolutions".to_string(),
384            ..Default::default()
385        };
386
387        let result = render_component(&component);
388        assert_eq!(result, "_The Structure of Scientific Revolutions_");
389    }
390}