Skip to main content

citum_engine/values/
variable.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Rendering logic for simple variables (DOI, URL, ISBN, etc.).
7//!
8//! This module handles template variable rendering, including proper localization
9//! of locator labels and special handling for reference-type-specific variables.
10
11use crate::reference::Reference;
12use crate::values::{ComponentValues, ProcHints, ProcValues, RenderOptions};
13use citum_schema::locale::ArchiveHierarchyField;
14use citum_schema::options::titles::TextCase;
15use citum_schema::reference::{ClassExtension, RichText, WorkRelation};
16use citum_schema::template::{SimpleVariable, TemplateVariable};
17
18/// Extracts the short title from a parent reference if available.
19///
20/// Returns the `short_title` from the embedded parent of collection or serial
21/// components, or None if the parent is an ID reference or the component
22/// type doesn't support short titles.
23fn container_title_short(reference: &Reference) -> Option<String> {
24    reference.container_title().and_then(|t| match t {
25        citum_schema::reference::types::Title::Shorthand(short, _) => Some(short),
26        citum_schema::reference::types::Title::Single(s) => Some(s),
27        _ => None,
28    })
29}
30
31fn event_place(reference: &Reference) -> Option<String> {
32    match reference.extension() {
33        ClassExtension::Event(event) => event.location.clone(),
34        ClassExtension::Monograph(monograph) => embedded_event_place(monograph.event.as_ref()?),
35        ClassExtension::SerialComponent(component) => {
36            embedded_event_place(component.event.as_ref()?)
37        }
38        ClassExtension::AudioVisual(audio_visual) => {
39            embedded_event_place(audio_visual.event.as_ref()?)
40        }
41        _ => None,
42    }
43}
44
45fn event_title(reference: &Reference) -> Option<String> {
46    match reference.extension() {
47        ClassExtension::Event(event) => event.title.as_ref().map(ToString::to_string),
48        ClassExtension::Monograph(monograph) => embedded_event_title(monograph.event.as_ref()?),
49        ClassExtension::SerialComponent(component) => {
50            embedded_event_title(component.event.as_ref()?)
51        }
52        ClassExtension::AudioVisual(audio_visual) => {
53            embedded_event_title(audio_visual.event.as_ref()?)
54        }
55        _ => None,
56    }
57}
58
59fn embedded_event_title(relation: &WorkRelation) -> Option<String> {
60    let WorkRelation::Embedded(reference) = relation else {
61        return None;
62    };
63    let ClassExtension::Event(event) = reference.extension() else {
64        return None;
65    };
66    event.title.as_ref().map(ToString::to_string)
67}
68
69fn embedded_event_place(relation: &WorkRelation) -> Option<String> {
70    let WorkRelation::Embedded(reference) = relation else {
71        return None;
72    };
73    let ClassExtension::Event(event) = reference.extension() else {
74        return None;
75    };
76    event.location.clone()
77}
78
79fn dimensions(reference: &Reference) -> Option<String> {
80    match reference.extension() {
81        ClassExtension::Monograph(monograph) => {
82            monograph.duration.clone().or(monograph.size.clone())
83        }
84        ClassExtension::SerialComponent(component) => component.duration.clone(),
85        ClassExtension::AudioVisual(audio_visual) => audio_visual.dimensions.clone(),
86        _ => None,
87    }
88}
89
90fn raw_medium(reference: &Reference) -> Option<String> {
91    match reference.extension() {
92        ClassExtension::Monograph(monograph) => monograph.medium.clone(),
93        ClassExtension::CollectionComponent(component) => component.medium.clone(),
94        ClassExtension::SerialComponent(component) => component.medium.clone(),
95        ClassExtension::AudioVisual(audio_visual) => audio_visual.medium.clone(),
96        ClassExtension::Software(software) => software.platform.clone(),
97        _ => None,
98    }
99}
100
101fn raw_genre(reference: &Reference) -> Option<String> {
102    match reference.extension() {
103        ClassExtension::Monograph(monograph) => monograph.genre.clone(),
104        ClassExtension::CollectionComponent(component) => component.genre.clone(),
105        ClassExtension::SerialComponent(component) => component.genre.clone(),
106        ClassExtension::Event(event) => event.genre.clone(),
107        ClassExtension::AudioVisual(audio_visual) => audio_visual.core.genre.clone(),
108        _ => None,
109    }
110}
111
112fn references(reference: &Reference) -> Option<String> {
113    match reference.extension() {
114        ClassExtension::Monograph(monograph) => monograph.references.clone(),
115        _ => None,
116    }
117}
118
119fn resolve_archive_name(reference: &Reference, options: &RenderOptions<'_>) -> Option<String> {
120    let archive_name = reference.archive_name()?;
121    let multilingual = options.config.multilingual.as_ref();
122
123    Some(crate::values::resolve_multilingual_string(
124        &archive_name,
125        multilingual.and_then(|ml| ml.name_mode.as_ref()),
126        multilingual.and_then(|ml| ml.preferred_transliteration.as_deref()),
127        multilingual.and_then(|ml| ml.preferred_script.as_ref()),
128        options.locale.locale.as_str(),
129    ))
130}
131
132fn assemble_archive_hierarchy(
133    reference: &Reference,
134    options: &RenderOptions<'_>,
135) -> Option<String> {
136    let locale = options.locale;
137    let mut parts: Vec<String> = Vec::new();
138
139    // collection (with optional collection_id in parens)
140    if let Some(collection) = reference.archive_collection() {
141        let label = locale
142            .resolved_archive_term(ArchiveHierarchyField::Collection)
143            .map(|l| format!("{l} "))
144            .unwrap_or_default();
145        if let Some(cid) = reference.archive_collection_id() {
146            parts.push(format!("{label}{collection} ({cid})"));
147        } else {
148            parts.push(format!("{label}{collection}"));
149        }
150    }
151
152    // series
153    if let Some(series) = reference.archive_series() {
154        let label = locale
155            .resolved_archive_term(ArchiveHierarchyField::Series)
156            .map(|l| format!("{l} "))
157            .unwrap_or_default();
158        parts.push(format!("{label}{series}"));
159    }
160
161    // box
162    if let Some(b) = reference.archive_box() {
163        let label = locale
164            .resolved_archive_term(ArchiveHierarchyField::Box)
165            .map(|l| format!("{l} "))
166            .unwrap_or_default();
167        parts.push(format!("{label}{b}"));
168    }
169
170    // folder
171    if let Some(folder) = reference.archive_folder() {
172        let label = locale
173            .resolved_archive_term(ArchiveHierarchyField::Folder)
174            .map(|l| format!("{l} "))
175            .unwrap_or_default();
176        parts.push(format!("{label}{folder}"));
177    }
178
179    // item
180    if let Some(item) = reference.archive_item() {
181        let label = locale
182            .resolved_archive_term(ArchiveHierarchyField::Item)
183            .map(|l| format!("{l} "))
184            .unwrap_or_default();
185        parts.push(format!("{label}{item}"));
186    }
187
188    if parts.is_empty() {
189        None
190    } else {
191        Some(parts.join(", "))
192    }
193}
194
195fn make_rich_text_case_transform(case: TextCase) -> impl FnMut(&str) -> String {
196    let mut seen_alpha = false;
197    move |text: &str| match case {
198        TextCase::Sentence | TextCase::SentenceApa | TextCase::SentenceNlm => {
199            let lowered = text.to_lowercase();
200            if seen_alpha {
201                lowered
202            } else {
203                let result = crate::values::text_case::capitalize_first_word(&lowered);
204                if result.chars().any(char::is_alphabetic) {
205                    seen_alpha = true;
206                }
207                result
208            }
209        }
210        _ => crate::values::text_case::apply_text_case(text, case),
211    }
212}
213
214/// Resolve the raw value string for a simple variable from a reference.
215fn resolve_variable_value(
216    variable: &SimpleVariable,
217    reference: &Reference,
218    options: &RenderOptions<'_>,
219) -> Option<String> {
220    match variable {
221        SimpleVariable::Doi => reference.doi(),
222        SimpleVariable::Url => reference.url().map(|u| u.to_string()).or_else(|| {
223            crate::values::type_class::synthesizes_doi_url(&reference.ref_type())
224                .then(|| reference.doi().map(|doi| format!("https://doi.org/{doi}")))
225                .flatten()
226        }),
227        SimpleVariable::Isbn => reference.isbn(),
228        SimpleVariable::Issn => reference.issn(),
229        SimpleVariable::Publisher => reference.publisher_str(),
230        SimpleVariable::PublisherPlace => reference.publisher_place(),
231        SimpleVariable::OriginalPublisher => reference.original_publisher_str(),
232        SimpleVariable::OriginalPublisherPlace => reference.original_publisher_place(),
233        SimpleVariable::EventTitle => event_title(reference),
234        SimpleVariable::EventPlace => event_place(reference),
235        SimpleVariable::Dimensions => dimensions(reference),
236        SimpleVariable::References => references(reference),
237        // A genre that merely restates the reference's own type (e.g. an
238        // entry-encyclopedia carrying genre "entry-encyclopedia") is the data
239        // model's internal type-carrier, round-tripped through `ref_type()` for
240        // variant selection. citeproc never emits it, so rendering it only leaks
241        // literal type text into migrated bibliographies.
242        SimpleVariable::Genre => reference
243            .genre()
244            .filter(|genre| *genre != reference.ref_type())
245            .map(|k| options.locale.lookup_genre(&k)),
246        SimpleVariable::RawGenre => raw_genre(reference),
247        SimpleVariable::Medium => reference.medium().map(|k| options.locale.lookup_medium(&k)),
248        SimpleVariable::RawMedium => raw_medium(reference),
249        SimpleVariable::Status => reference.status(),
250        SimpleVariable::Abstract | SimpleVariable::Note => None,
251        SimpleVariable::Archive => reference.archive(),
252        SimpleVariable::ArchiveLocation => reference
253            .archive_location()
254            .or_else(|| assemble_archive_hierarchy(reference, options)),
255        SimpleVariable::ArchiveName => resolve_archive_name(reference, options),
256        SimpleVariable::ArchivePlace => reference.archive_place(),
257        SimpleVariable::ArchiveCollection => reference.archive_collection(),
258        SimpleVariable::ArchiveCollectionId => reference.archive_collection_id(),
259        SimpleVariable::ArchiveSeries => reference.archive_series(),
260        SimpleVariable::ArchiveBox => reference.archive_box(),
261        SimpleVariable::ArchiveFolder => reference.archive_folder(),
262        SimpleVariable::ArchiveItem => reference.archive_item(),
263        SimpleVariable::ArchiveUrl => reference.archive_url().map(|url| url.to_string()),
264        SimpleVariable::EprintId => reference.eprint_id(),
265        SimpleVariable::EprintServer => reference.eprint_server(),
266        SimpleVariable::EprintClass => reference.eprint_class(),
267        SimpleVariable::Authority => reference.authority(),
268        SimpleVariable::Code => reference.code(),
269        SimpleVariable::Reporter => reference.reporter(),
270        SimpleVariable::Page => reference.pages().map(|v| v.to_string()),
271        SimpleVariable::Section => reference.section(),
272        SimpleVariable::Volume => reference.volume().map(|v| v.to_string()),
273        SimpleVariable::Number => reference.number(),
274        SimpleVariable::DocketNumber => match reference.extension() {
275            ClassExtension::Brief(r) => r.docket_number.clone(),
276            _ => None,
277        },
278        SimpleVariable::PatentNumber => match reference.extension() {
279            ClassExtension::Patent(r) => Some(r.patent_number.clone()),
280            _ => None,
281        },
282        SimpleVariable::StandardNumber => match reference.extension() {
283            ClassExtension::Standard(r) => Some(r.standard_number.clone()),
284            _ => None,
285        },
286        SimpleVariable::AdsBibcode => reference.ads_bibcode(),
287        SimpleVariable::ReportNumber => reference.report_number(),
288        SimpleVariable::Version => reference.version(),
289        SimpleVariable::ContainerTitleShort => container_title_short(reference),
290        SimpleVariable::Locator => options.locator_raw.map(|loc| {
291            // When no explicit locators config is set, derive a default from the
292            // processing mode so note styles automatically suppress page labels.
293            let derived;
294            let cfg = if let Some(c) = options.config.locators.as_ref() {
295                c
296            } else {
297                derived = if matches!(
298                    options.config.processing,
299                    Some(citum_schema::options::Processing::Note)
300                ) {
301                    citum_schema::options::LocatorPreset::Note.config()
302                } else {
303                    citum_schema::options::LocatorConfig::default()
304                };
305                &derived
306            };
307            let ref_type = options.ref_type.as_deref().unwrap_or("");
308            crate::values::locator::render_locator(loc, ref_type, cfg, options.locale)
309        }),
310        _ => None,
311    }
312}
313
314impl ComponentValues for TemplateVariable {
315    fn values<F: crate::render::format::OutputFormat<Output = String>>(
316        &self,
317        reference: &Reference,
318        _hints: &ProcHints,
319        options: &RenderOptions<'_>,
320    ) -> Option<ProcValues<F::Output>> {
321        // Rich-text variables carry format metadata — handle before the plain-string path.
322        let rich_text: Option<RichText> = match self.variable {
323            SimpleVariable::Note => reference.note(),
324            SimpleVariable::Abstract => reference.abstract_text(),
325            _ => None,
326        };
327
328        if let Some(rt) = rich_text {
329            if rt.is_empty() {
330                return None;
331            }
332            let fmt = F::default();
333            let (value, pre_formatted) = match (rt, self.rendering.text_case) {
334                (RichText::Plain(s), Some(tc)) => {
335                    (crate::values::text_case::apply_text_case(&s, tc), false)
336                }
337                (RichText::Plain(s), None) => (s, false),
338                (RichText::Djot { djot }, Some(tc)) => (
339                    crate::render::rich_text::render_djot_inline_with_transform(
340                        &djot,
341                        &fmt,
342                        make_rich_text_case_transform(tc),
343                    )
344                    .0,
345                    true,
346                ),
347                (RichText::Djot { djot }, None) => {
348                    (crate::render::render_djot_inline(&djot, &fmt), true)
349                }
350            };
351            return Some(ProcValues {
352                value,
353                prefix: None,
354                suffix: None,
355                url: None,
356                substituted_key: None,
357                pre_formatted,
358            });
359        }
360
361        // Plain-string path for all other variables.
362        let value = resolve_variable_value(&self.variable, reference, options);
363
364        value.filter(|s: &String| !s.is_empty()).map(|value| {
365            let value = if let Some(tc) = self.rendering.text_case {
366                crate::values::text_case::apply_text_case(&value, tc)
367            } else {
368                value
369            };
370            let value = crate::values::apply_abbreviation(value, options.abbreviation_map);
371            use citum_schema::options::{LinkAnchor, LinkTarget};
372            let component_anchor = match self.variable {
373                SimpleVariable::Url => LinkAnchor::Url,
374                SimpleVariable::Doi => LinkAnchor::Doi,
375                _ => LinkAnchor::Component,
376            };
377
378            let mut url = crate::values::resolve_effective_url(
379                self.links.as_ref(),
380                options.config.links.as_ref(),
381                reference,
382                component_anchor,
383            );
384
385            // Fallback for simple legacy config
386            if url.is_none()
387                && let Some(links) = &self.links
388            {
389                if self.variable == SimpleVariable::Url
390                    && (links.url == Some(true)
391                        || matches!(links.target, Some(LinkTarget::Url | LinkTarget::UrlOrDoi)))
392                {
393                    url = reference.url().map(|u| u.to_string());
394                } else if self.variable == SimpleVariable::Doi
395                    && (links.doi == Some(true)
396                        || matches!(links.target, Some(LinkTarget::Doi | LinkTarget::UrlOrDoi)))
397                {
398                    url = reference.doi().map(|d| format!("https://doi.org/{d}"));
399                }
400            }
401
402            ProcValues {
403                value,
404                prefix: None,
405                suffix: None,
406                url,
407                substituted_key: None,
408                pre_formatted: false,
409            }
410        })
411    }
412}