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