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};
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 resolve_archive_name(reference: &Reference, options: &RenderOptions<'_>) -> Option<String> {
32    let archive_name = reference.archive_name()?;
33    let multilingual = options.config.multilingual.as_ref();
34
35    Some(crate::values::resolve_multilingual_string(
36        &archive_name,
37        multilingual.and_then(|ml| ml.name_mode.as_ref()),
38        multilingual.and_then(|ml| ml.preferred_transliteration.as_deref()),
39        multilingual.and_then(|ml| ml.preferred_script.as_ref()),
40        options.locale.locale.as_str(),
41    ))
42}
43
44fn assemble_archive_hierarchy(
45    reference: &Reference,
46    options: &RenderOptions<'_>,
47) -> Option<String> {
48    let locale = options.locale;
49    let mut parts: Vec<String> = Vec::new();
50
51    // collection (with optional collection_id in parens)
52    if let Some(collection) = reference.archive_collection() {
53        let label = locale
54            .resolved_archive_term(ArchiveHierarchyField::Collection)
55            .map(|l| format!("{l} "))
56            .unwrap_or_default();
57        if let Some(cid) = reference.archive_collection_id() {
58            parts.push(format!("{label}{collection} ({cid})"));
59        } else {
60            parts.push(format!("{label}{collection}"));
61        }
62    }
63
64    // series
65    if let Some(series) = reference.archive_series() {
66        let label = locale
67            .resolved_archive_term(ArchiveHierarchyField::Series)
68            .map(|l| format!("{l} "))
69            .unwrap_or_default();
70        parts.push(format!("{label}{series}"));
71    }
72
73    // box
74    if let Some(b) = reference.archive_box() {
75        let label = locale
76            .resolved_archive_term(ArchiveHierarchyField::Box)
77            .map(|l| format!("{l} "))
78            .unwrap_or_default();
79        parts.push(format!("{label}{b}"));
80    }
81
82    // folder
83    if let Some(folder) = reference.archive_folder() {
84        let label = locale
85            .resolved_archive_term(ArchiveHierarchyField::Folder)
86            .map(|l| format!("{l} "))
87            .unwrap_or_default();
88        parts.push(format!("{label}{folder}"));
89    }
90
91    // item
92    if let Some(item) = reference.archive_item() {
93        let label = locale
94            .resolved_archive_term(ArchiveHierarchyField::Item)
95            .map(|l| format!("{l} "))
96            .unwrap_or_default();
97        parts.push(format!("{label}{item}"));
98    }
99
100    if parts.is_empty() {
101        None
102    } else {
103        Some(parts.join(", "))
104    }
105}
106
107fn make_rich_text_case_transform(case: TextCase) -> impl FnMut(&str) -> String {
108    let mut seen_alpha = false;
109    move |text: &str| match case {
110        TextCase::Sentence | TextCase::SentenceApa | TextCase::SentenceNlm => {
111            let lowered = text.to_lowercase();
112            if seen_alpha {
113                lowered
114            } else {
115                let result = crate::values::text_case::capitalize_first_word(&lowered);
116                if result.chars().any(char::is_alphabetic) {
117                    seen_alpha = true;
118                }
119                result
120            }
121        }
122        _ => crate::values::text_case::apply_text_case(text, case),
123    }
124}
125
126/// Resolve the raw value string for a simple variable from a reference.
127fn resolve_variable_value(
128    variable: &SimpleVariable,
129    reference: &Reference,
130    options: &RenderOptions<'_>,
131) -> Option<String> {
132    match variable {
133        SimpleVariable::Doi => reference.doi(),
134        SimpleVariable::Url => reference.url().map(|u| u.to_string()).or_else(|| {
135            (reference.ref_type() == "dataset")
136                .then(|| reference.doi().map(|doi| format!("https://doi.org/{doi}")))
137                .flatten()
138        }),
139        SimpleVariable::Isbn => reference.isbn(),
140        SimpleVariable::Issn => reference.issn(),
141        SimpleVariable::Publisher => reference.publisher_str(),
142        SimpleVariable::PublisherPlace => reference.publisher_place(),
143        SimpleVariable::OriginalPublisher => reference.original_publisher_str(),
144        SimpleVariable::OriginalPublisherPlace => reference.original_publisher_place(),
145        // A genre that merely restates the reference's own type (e.g. an
146        // entry-encyclopedia carrying genre "entry-encyclopedia") is the data
147        // model's internal type-carrier, round-tripped through `ref_type()` for
148        // variant selection. citeproc never emits it, so rendering it only leaks
149        // literal type text into migrated bibliographies.
150        SimpleVariable::Genre => reference
151            .genre()
152            .filter(|genre| *genre != reference.ref_type())
153            .map(|k| options.locale.lookup_genre(&k)),
154        SimpleVariable::Medium => reference.medium().map(|k| options.locale.lookup_medium(&k)),
155        SimpleVariable::Status => reference.status(),
156        SimpleVariable::Abstract | SimpleVariable::Note => None,
157        SimpleVariable::Archive => reference.archive(),
158        SimpleVariable::ArchiveLocation => reference
159            .archive_location()
160            .or_else(|| assemble_archive_hierarchy(reference, options)),
161        SimpleVariable::ArchiveName => resolve_archive_name(reference, options),
162        SimpleVariable::ArchivePlace => reference.archive_place(),
163        SimpleVariable::ArchiveCollection => reference.archive_collection(),
164        SimpleVariable::ArchiveCollectionId => reference.archive_collection_id(),
165        SimpleVariable::ArchiveSeries => reference.archive_series(),
166        SimpleVariable::ArchiveBox => reference.archive_box(),
167        SimpleVariable::ArchiveFolder => reference.archive_folder(),
168        SimpleVariable::ArchiveItem => reference.archive_item(),
169        SimpleVariable::ArchiveUrl => reference.archive_url().map(|url| url.to_string()),
170        SimpleVariable::EprintId => reference.eprint_id(),
171        SimpleVariable::EprintServer => reference.eprint_server(),
172        SimpleVariable::EprintClass => reference.eprint_class(),
173        SimpleVariable::Authority => reference.authority(),
174        SimpleVariable::Code => reference.code(),
175        SimpleVariable::Reporter => reference.reporter(),
176        SimpleVariable::Page => reference.pages().map(|v| v.to_string()),
177        SimpleVariable::Section => reference.section(),
178        SimpleVariable::Volume => reference.volume().map(|v| v.to_string()),
179        SimpleVariable::Number => reference.number(),
180        SimpleVariable::DocketNumber => match reference.extension() {
181            ClassExtension::Brief(r) => r.docket_number.clone(),
182            _ => None,
183        },
184        SimpleVariable::PatentNumber => match reference.extension() {
185            ClassExtension::Patent(r) => Some(r.patent_number.clone()),
186            _ => None,
187        },
188        SimpleVariable::StandardNumber => match reference.extension() {
189            ClassExtension::Standard(r) => Some(r.standard_number.clone()),
190            _ => None,
191        },
192        SimpleVariable::AdsBibcode => reference.ads_bibcode(),
193        SimpleVariable::ReportNumber => reference.report_number(),
194        SimpleVariable::Version => reference.version(),
195        SimpleVariable::ContainerTitleShort => container_title_short(reference),
196        SimpleVariable::Locator => options.locator_raw.map(|loc| {
197            // When no explicit locators config is set, derive a default from the
198            // processing mode so note styles automatically suppress page labels.
199            let derived;
200            let cfg = if let Some(c) = options.config.locators.as_ref() {
201                c
202            } else {
203                derived = if matches!(
204                    options.config.processing,
205                    Some(citum_schema::options::Processing::Note)
206                ) {
207                    citum_schema::options::LocatorPreset::Note.config()
208                } else {
209                    citum_schema::options::LocatorConfig::default()
210                };
211                &derived
212            };
213            let ref_type = options.ref_type.as_deref().unwrap_or("");
214            crate::values::locator::render_locator(loc, ref_type, cfg, options.locale)
215        }),
216        _ => None,
217    }
218}
219
220impl ComponentValues for TemplateVariable {
221    fn values<F: crate::render::format::OutputFormat<Output = String>>(
222        &self,
223        reference: &Reference,
224        _hints: &ProcHints,
225        options: &RenderOptions<'_>,
226    ) -> Option<ProcValues<F::Output>> {
227        // Rich-text variables carry format metadata — handle before the plain-string path.
228        let rich_text: Option<RichText> = match self.variable {
229            SimpleVariable::Note => reference.note(),
230            SimpleVariable::Abstract => reference.abstract_text(),
231            _ => None,
232        };
233
234        if let Some(rt) = rich_text {
235            if rt.is_empty() {
236                return None;
237            }
238            let fmt = F::default();
239            let (value, pre_formatted) = match (rt, self.rendering.text_case) {
240                (RichText::Plain(s), Some(tc)) => {
241                    (crate::values::text_case::apply_text_case(&s, tc), false)
242                }
243                (RichText::Plain(s), None) => (s, false),
244                (RichText::Djot { djot }, Some(tc)) => (
245                    crate::render::rich_text::render_djot_inline_with_transform(
246                        &djot,
247                        &fmt,
248                        make_rich_text_case_transform(tc),
249                    )
250                    .0,
251                    true,
252                ),
253                (RichText::Djot { djot }, None) => {
254                    (crate::render::render_djot_inline(&djot, &fmt), true)
255                }
256            };
257            return Some(ProcValues {
258                value,
259                prefix: None,
260                suffix: None,
261                url: None,
262                substituted_key: None,
263                pre_formatted,
264            });
265        }
266
267        // Plain-string path for all other variables.
268        let value = resolve_variable_value(&self.variable, reference, options);
269
270        value.filter(|s: &String| !s.is_empty()).map(|value| {
271            let value = if let Some(tc) = self.rendering.text_case {
272                crate::values::text_case::apply_text_case(&value, tc)
273            } else {
274                value
275            };
276            let value = crate::values::apply_abbreviation(value, options.abbreviation_map);
277            use citum_schema::options::{LinkAnchor, LinkTarget};
278            let component_anchor = match self.variable {
279                SimpleVariable::Url => LinkAnchor::Url,
280                SimpleVariable::Doi => LinkAnchor::Doi,
281                _ => LinkAnchor::Component,
282            };
283
284            let mut url = crate::values::resolve_effective_url(
285                self.links.as_ref(),
286                options.config.links.as_ref(),
287                reference,
288                component_anchor,
289            );
290
291            // Fallback for simple legacy config
292            if url.is_none()
293                && let Some(links) = &self.links
294            {
295                if self.variable == SimpleVariable::Url
296                    && (links.url == Some(true)
297                        || matches!(links.target, Some(LinkTarget::Url | LinkTarget::UrlOrDoi)))
298                {
299                    url = reference.url().map(|u| u.to_string());
300                } else if self.variable == SimpleVariable::Doi
301                    && (links.doi == Some(true)
302                        || matches!(links.target, Some(LinkTarget::Doi | LinkTarget::UrlOrDoi)))
303                {
304                    url = reference.doi().map(|d| format!("https://doi.org/{d}"));
305                }
306            }
307
308            ProcValues {
309                value,
310                prefix: None,
311                suffix: None,
312                url,
313                substituted_key: None,
314                pre_formatted: false,
315            }
316        })
317    }
318}