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(
227    case: TextCase,
228    language: Option<&str>,
229) -> impl FnMut(&str) -> String {
230    let mut seen_alpha = false;
231    let language = crate::values::text_case::language_identifier_for_tag(language);
232    move |text: &str| match case {
233        TextCase::Sentence | TextCase::SentenceApa | TextCase::SentenceNlm => {
234            let lowered = crate::values::text_case::apply_text_case_with_language_id(
235                text,
236                TextCase::Lowercase,
237                &language,
238            );
239            if seen_alpha {
240                lowered
241            } else {
242                let result = crate::values::text_case::apply_text_case_with_language_id(
243                    &lowered,
244                    TextCase::CapitalizeFirst,
245                    &language,
246                );
247                if result.chars().any(char::is_alphabetic) {
248                    seen_alpha = true;
249                }
250                result
251            }
252        }
253        _ => crate::values::text_case::apply_text_case_with_language_id(text, case, &language),
254    }
255}
256
257/// Resolve the raw value string for a simple variable from a reference.
258fn resolve_variable_value(
259    variable: &SimpleVariable,
260    reference: &Reference,
261    options: &RenderOptions<'_>,
262) -> Option<String> {
263    match variable {
264        SimpleVariable::Doi => reference.doi(),
265        SimpleVariable::Url => reference.url().map(|u| u.to_string()).or_else(|| {
266            crate::values::type_class::synthesizes_doi_url(&reference.ref_type())
267                .then(|| reference.doi().map(|doi| format!("https://doi.org/{doi}")))
268                .flatten()
269        }),
270        SimpleVariable::Isbn => reference.isbn(),
271        SimpleVariable::Issn => reference.issn(),
272        SimpleVariable::Publisher => reference.publisher_str(),
273        SimpleVariable::PublisherPlace => reference.publisher_place(),
274        SimpleVariable::OriginalPublisher => reference.original_publisher_str(),
275        SimpleVariable::OriginalPublisherPlace => reference.original_publisher_place(),
276        SimpleVariable::EventTitle => event_title(reference),
277        SimpleVariable::EventPlace => event_place(reference),
278        SimpleVariable::Dimensions => dimensions(reference),
279        SimpleVariable::References => references(reference),
280        SimpleVariable::Scale => reference.scale(),
281        // A genre that merely restates the reference's own type (e.g. an
282        // entry-encyclopedia carrying genre "entry-encyclopedia") is the data
283        // model's internal type-carrier, round-tripped through `ref_type()` for
284        // variant selection. citeproc never emits it, so rendering it only leaks
285        // literal type text into migrated bibliographies.
286        SimpleVariable::Genre => reference
287            .genre()
288            .filter(|genre| *genre != reference.ref_type())
289            .map(|k| options.locale.lookup_genre(&k)),
290        SimpleVariable::RawGenre => raw_genre(reference),
291        SimpleVariable::Medium => reference.medium().map(|k| options.locale.lookup_medium(&k)),
292        SimpleVariable::RawMedium => raw_medium(reference),
293        SimpleVariable::Status => reference.status(),
294        SimpleVariable::Abstract | SimpleVariable::Note => None,
295        SimpleVariable::Archive => reference.archive(),
296        SimpleVariable::ArchiveLocation => reference
297            .archive_location()
298            .or_else(|| assemble_archive_hierarchy(reference, options)),
299        SimpleVariable::ArchiveName => resolve_archive_name(reference, options),
300        SimpleVariable::ArchivePlace => reference.archive_place(),
301        SimpleVariable::ArchiveCollection => reference.archive_collection(),
302        SimpleVariable::ArchiveCollectionId => reference.archive_collection_id(),
303        SimpleVariable::ArchiveSeries => reference.archive_series(),
304        SimpleVariable::ArchiveBox => reference.archive_box(),
305        SimpleVariable::ArchiveFolder => reference.archive_folder(),
306        SimpleVariable::ArchiveItem => reference.archive_item(),
307        SimpleVariable::ArchiveUrl => reference.archive_url().map(|url| url.to_string()),
308        SimpleVariable::EprintId => reference.eprint_id(),
309        SimpleVariable::EprintServer => reference.eprint_server(),
310        SimpleVariable::EprintClass => reference.eprint_class(),
311        SimpleVariable::Authority => reference.authority(),
312        SimpleVariable::Code => reference.code(),
313        SimpleVariable::Reporter => reference.reporter(),
314        SimpleVariable::Page => reference.pages().map(|v| v.to_string()),
315        SimpleVariable::Section => reference.section(),
316        SimpleVariable::Volume => reference.volume().map(|v| v.to_string()),
317        SimpleVariable::Number => reference.number(),
318        SimpleVariable::DocketNumber => match reference.extension() {
319            ClassExtension::Brief(r) => r.docket_number.clone(),
320            _ => None,
321        },
322        SimpleVariable::PatentNumber => match reference.extension() {
323            ClassExtension::Patent(r) => Some(r.patent_number.clone()),
324            _ => None,
325        },
326        SimpleVariable::StandardNumber => match reference.extension() {
327            ClassExtension::Standard(r) => Some(r.standard_number.clone()),
328            _ => None,
329        },
330        SimpleVariable::AdsBibcode => reference.ads_bibcode(),
331        SimpleVariable::ReportNumber => reference.report_number(),
332        SimpleVariable::Version => reference.version(),
333        SimpleVariable::VolumeTitle => reference.volume_title(),
334        SimpleVariable::ContainerTitleShort => container_title_short(reference),
335        SimpleVariable::Locator => options.locator_raw.map(|loc| {
336            // When no explicit locators config is set, derive a default from the
337            // processing mode so note styles automatically suppress page labels.
338            let derived;
339            let cfg = if let Some(c) = options.config.locators.as_ref() {
340                c
341            } else {
342                derived = if matches!(
343                    options.config.processing,
344                    Some(citum_schema::options::Processing::Note)
345                ) {
346                    citum_schema::options::LocatorPreset::Note.config()
347                } else {
348                    citum_schema::options::LocatorConfig::default()
349                };
350                &derived
351            };
352            let ref_type = options.ref_type.as_deref().unwrap_or("");
353            crate::values::locator::render_locator(loc, ref_type, cfg, options.locale)
354        }),
355        _ => None,
356    }
357}
358
359impl ComponentValues for TemplateVariable {
360    fn values<F: crate::render::format::OutputFormat<Output = String>>(
361        &self,
362        reference: &Reference,
363        _hints: &ProcHints,
364        options: &RenderOptions<'_>,
365    ) -> Option<ProcValues<F::Output>> {
366        let language = reference.language();
367        // Rich-text variables carry format metadata — handle before the plain-string path.
368        let rich_text: Option<RichText> = match self.variable {
369            SimpleVariable::Note => reference.note(),
370            SimpleVariable::Abstract => reference.abstract_text(),
371            _ => None,
372        };
373
374        if let Some(rt) = rich_text {
375            if rt.is_empty() {
376                return None;
377            }
378            let fmt = F::default();
379            let (value, pre_formatted) = match (rt, self.rendering.text_case) {
380                (RichText::Plain(s), Some(tc)) => (
381                    crate::values::text_case::apply_text_case_with_language(
382                        &s,
383                        tc,
384                        language.as_deref(),
385                    ),
386                    false,
387                ),
388                (RichText::Plain(s), None) => (s, false),
389                (RichText::Djot { djot }, Some(tc)) => (
390                    crate::render::rich_text::render_djot_inline_with_transform(
391                        &djot,
392                        &fmt,
393                        make_rich_text_case_transform(tc, language.as_deref()),
394                    )
395                    .0,
396                    true,
397                ),
398                (RichText::Djot { djot }, None) => {
399                    (crate::render::render_djot_inline(&djot, &fmt), true)
400                }
401            };
402            return Some(ProcValues {
403                value,
404                prefix: None,
405                suffix: None,
406                url: None,
407                substituted_key: None,
408                pre_formatted,
409            });
410        }
411
412        // Plain-string path for all other variables.
413        let value = resolve_variable_value(&self.variable, reference, options);
414
415        value.filter(|s: &String| !s.is_empty()).map(|value| {
416            let value = if let Some(tc) = self.rendering.text_case {
417                crate::values::text_case::apply_text_case_with_language(
418                    &value,
419                    tc,
420                    language.as_deref(),
421                )
422            } else {
423                value
424            };
425            let value = crate::values::apply_abbreviation(value, options.abbreviation_map);
426            use citum_schema::options::{LinkAnchor, LinkTarget};
427            let component_anchor = match self.variable {
428                SimpleVariable::Url => LinkAnchor::Url,
429                SimpleVariable::Doi => LinkAnchor::Doi,
430                _ => LinkAnchor::Component,
431            };
432
433            let mut url = crate::values::resolve_effective_url(
434                self.links.as_ref(),
435                options.config.links.as_ref(),
436                reference,
437                component_anchor,
438            );
439
440            // Fallback for simple legacy config
441            if url.is_none()
442                && let Some(links) = &self.links
443            {
444                if self.variable == SimpleVariable::Url
445                    && (links.url == Some(true)
446                        || matches!(links.target, Some(LinkTarget::Url | LinkTarget::UrlOrDoi)))
447                {
448                    url = reference.url().map(|u| u.to_string());
449                } else if self.variable == SimpleVariable::Doi
450                    && (links.doi == Some(true)
451                        || matches!(links.target, Some(LinkTarget::Doi | LinkTarget::UrlOrDoi)))
452                {
453                    url = reference.doi().map(|d| format!("https://doi.org/{d}"));
454                }
455            }
456
457            ProcValues {
458                value,
459                prefix: None,
460                suffix: None,
461                url,
462                substituted_key: None,
463                pre_formatted: false,
464            }
465        })
466    }
467}