Skip to main content

citum_engine/values/
mod.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Value extraction for template components.
7//!
8//! This module provides the logic to extract formatted values from references
9//! based on template component specifications.
10
11/// Contributor extraction and name-formatting helpers.
12pub mod contributor;
13/// Date extraction and date-formatting helpers.
14pub mod date;
15/// List-component value extraction helpers.
16pub mod list;
17/// Locator rendering logic.
18pub mod locator;
19/// Locale message component rendering.
20pub mod message;
21/// Numeric variable extraction and page-range helpers.
22pub mod number;
23/// Shared helpers for collapsing consecutive numeric or ordinal numbering.
24pub mod range;
25/// Locale term resolution helpers.
26pub mod term;
27/// Title text-case transform functions.
28pub mod text_case;
29/// Title extraction and title-formatting helpers.
30pub mod title;
31/// Generic variable extraction helpers.
32pub mod variable;
33
34#[cfg(test)]
35#[allow(
36    clippy::unwrap_used,
37    clippy::expect_used,
38    clippy::panic,
39    clippy::indexing_slicing,
40    clippy::todo,
41    clippy::unimplemented,
42    clippy::unreachable,
43    clippy::get_unwrap,
44    reason = "Panicking is acceptable and often desired in tests."
45)]
46mod tests;
47
48use crate::reference::Reference;
49use citum_schema::locale::Locale;
50use citum_schema::options::{Config, bibliography::BibliographyConfig};
51use citum_schema::reference::types::Title;
52use citum_schema::template::{TemplateComponent, TitleType};
53
54pub use contributor::format_contributors_short;
55pub use date::int_to_letter;
56
57/// Resolve preferred transliteration from a map of transliterations.
58///
59/// Applies priority-based matching:
60/// 1. Preferred transliteration list: exact match
61/// 2. Preferred transliteration list: substring match
62/// 3. Preferred script: exact match
63/// 4. Preferred script: substring match
64fn resolve_transliteration<'a>(
65    transliterations: &'a std::collections::HashMap<String, String>,
66    preferred_transliteration: Option<&[String]>,
67    preferred_script: Option<&String>,
68) -> Option<&'a str> {
69    // 1. Priority list: exact match
70    if let Some(tags) = preferred_transliteration {
71        for tag in tags {
72            if let Some(v) = transliterations.get(tag) {
73                return Some(v.as_str());
74            }
75        }
76        // 2. Priority list: substring match
77        for tag in tags {
78            for (k, v) in transliterations {
79                if k.contains(tag.as_str()) {
80                    return Some(v.as_str());
81                }
82            }
83        }
84    }
85    // 3. preferred_script exact match
86    if let Some(script) = preferred_script {
87        if let Some(v) = transliterations.get(script) {
88            return Some(v.as_str());
89        }
90        // 4. preferred_script substring match
91        for (k, v) in transliterations {
92            if k.contains(script.as_str()) {
93                return Some(v.as_str());
94            }
95        }
96    }
97    None
98}
99
100fn resolve_translation<'a>(
101    translations: &'a std::collections::HashMap<citum_schema::reference::LangID, String>,
102    style_locale: &str,
103) -> Option<&'a str> {
104    translations
105        .get(style_locale)
106        .or_else(|| {
107            style_locale
108                .split(['-', '_'])
109                .next()
110                .and_then(|base| translations.get(base))
111        })
112        .map(String::as_str)
113}
114
115/// Resolve a multilingual string based on style configuration.
116///
117/// Applies BCP 47 fallback logic:
118/// 1. Exact tag match (e.g., "ja-Latn-hepburn")
119/// 2. Script prefix match (e.g., "ja-Latn")
120/// 3. Fallback to original field
121///
122/// # Arguments
123/// * `string` - The multilingual string to resolve
124/// * `mode` - The rendering mode from style config
125/// * `preferred_transliteration` - Optional ordered list of BCP 47 transliteration tags
126/// * `preferred_script` - Optional preferred script (e.g., "Latn")
127/// * `style_locale` - The style's locale for translation matching
128#[must_use]
129pub fn resolve_multilingual_string(
130    string: &citum_schema::reference::types::MultilingualString,
131    mode: Option<&citum_schema::options::MultilingualMode>,
132    preferred_transliteration: Option<&[String]>,
133    preferred_script: Option<&String>,
134    style_locale: &str,
135) -> String {
136    use citum_schema::options::MultilingualMode;
137    use citum_schema::reference::types::MultilingualString;
138
139    match string {
140        MultilingualString::Simple(s) => s.clone(),
141        MultilingualString::Complex(complex) => {
142            let mode = mode.unwrap_or(&MultilingualMode::Primary);
143
144            match mode {
145                MultilingualMode::Primary => complex.original.clone(),
146
147                MultilingualMode::Transliterated => {
148                    if let Some(trans) = resolve_transliteration(
149                        &complex.transliterations,
150                        preferred_transliteration,
151                        preferred_script,
152                    ) {
153                        return trans.to_string();
154                    }
155
156                    // Fallback: use any available transliteration, or original
157                    complex
158                        .transliterations
159                        .values()
160                        .next()
161                        .cloned()
162                        .unwrap_or_else(|| complex.original.clone())
163                }
164
165                MultilingualMode::Translated => {
166                    // Try to match style locale
167                    resolve_translation(&complex.translations, style_locale)
168                        .map(ToString::to_string)
169                        .unwrap_or_else(|| complex.original.clone())
170                }
171
172                MultilingualMode::Combined => {
173                    // Format: "transliterated [translated]" or fallback variants
174                    let trans = resolve_transliteration(
175                        &complex.transliterations,
176                        preferred_transliteration,
177                        preferred_script,
178                    );
179
180                    let translation = resolve_translation(&complex.translations, style_locale);
181
182                    match (trans, translation) {
183                        (Some(t), Some(tr)) => format!("{t} [{tr}]"),
184                        (Some(t), None) => t.to_string(),
185                        (None, Some(tr)) => format!("{} [{}]", complex.original, tr),
186                        (None, None) => complex.original.clone(),
187                    }
188                }
189
190                MultilingualMode::Pattern(segments) => resolve_multilingual_pattern(
191                    segments,
192                    &complex.original,
193                    &complex.transliterations,
194                    &complex.translations,
195                    preferred_transliteration,
196                    preferred_script,
197                    style_locale,
198                ),
199            }
200        }
201    }
202}
203
204/// Render a [`MultilingualMode::Pattern`] against a complex multilingual string.
205///
206/// Each segment is resolved to its text; segments that are empty or identical to
207/// the previous non-empty segment are skipped (dedup).  The surviving segments are
208/// joined by a single space.
209fn resolve_multilingual_pattern(
210    segments: &[citum_schema::options::MultilingualSegment],
211    original: &str,
212    transliterations: &std::collections::HashMap<String, String>,
213    translations: &std::collections::HashMap<citum_schema::reference::types::LangID, String>,
214    preferred_transliteration: Option<&[String]>,
215    preferred_script: Option<&String>,
216    style_locale: &str,
217) -> String {
218    use citum_schema::options::{MultilingualView, SegmentWrap};
219    let mut parts: Vec<String> = Vec::with_capacity(segments.len());
220    let mut last_text: Option<String> = None;
221
222    for seg in segments {
223        let text: Option<String> = match &seg.view {
224            MultilingualView::OriginalScript => Some(original.to_string()),
225            MultilingualView::Transliterated => resolve_transliteration(
226                transliterations,
227                preferred_transliteration,
228                preferred_script,
229            )
230            .map(ToString::to_string),
231            MultilingualView::Translated => {
232                resolve_translation(translations, style_locale).map(ToString::to_string)
233            }
234        };
235
236        let Some(text) = text else { continue };
237        if text.is_empty() {
238            continue;
239        }
240        // Skip duplicate: if this text is identical to the previous segment (e.g. when
241        // transliteration falls back to the same value as original).
242        if last_text.as_deref() == Some(text.as_str()) {
243            continue;
244        }
245
246        let wrapped = match &seg.wrap {
247            SegmentWrap::None => text.clone(),
248            other => other.apply(&text),
249        };
250        last_text = Some(text);
251        parts.push(wrapped);
252    }
253
254    parts.join(" ")
255}
256
257/// Resolve the effective language for one logical field scope on a reference.
258///
259/// This prefers an explicit `field_languages` entry, then a multilingual title
260/// language tag for the provided title value, and finally the reference-level
261/// language.
262#[must_use]
263pub fn effective_field_language(
264    reference: &Reference,
265    scope: &str,
266    title: Option<&Title>,
267) -> Option<String> {
268    reference
269        .field_languages()
270        .get(scope)
271        .map(ToString::to_string)
272        .or_else(|| match title {
273            Some(Title::Multilingual(multilingual)) => {
274                multilingual.lang.as_ref().map(ToString::to_string)
275            }
276            _ => None,
277        })
278        .or_else(|| reference.language().map(|lang| lang.to_string()))
279}
280
281/// Resolve the effective language for the primary title of a reference.
282#[must_use]
283pub fn effective_item_language(reference: &Reference) -> Option<String> {
284    effective_field_language(reference, "title", reference.title().as_ref())
285}
286
287/// Resolve the effective language for the specific template component being rendered.
288#[must_use]
289pub fn effective_component_language(
290    reference: &Reference,
291    component: &TemplateComponent,
292) -> Option<String> {
293    match component {
294        TemplateComponent::Title(title_component) => {
295            let title = match title_component.title {
296                TitleType::Primary => reference.title(),
297                TitleType::ContainerTitle => reference.container_title(),
298                TitleType::ParentMonograph => reference.container_title(),
299                TitleType::ParentSerial => reference.container_title(),
300                TitleType::CollectionTitle => reference.collection_title(),
301                _ => reference.title(),
302            };
303
304            let scope = match title_component.title {
305                TitleType::Primary => "title",
306                TitleType::ContainerTitle => "container-title",
307                TitleType::ParentMonograph => "parent-monograph.title",
308                TitleType::ParentSerial => "parent-serial.title",
309                TitleType::CollectionTitle => "collection-title",
310                _ => "title",
311            };
312
313            effective_field_language(reference, scope, title.as_ref())
314        }
315        _ => effective_item_language(reference),
316    }
317}
318
319/// Select a structured name from transliteration maps using priority-list then script-match rules.
320fn select_by_transliteration<'a>(
321    m: &'a citum_schema::reference::contributor::MultilingualName,
322    preferred_transliteration: Option<&[String]>,
323    preferred_script: Option<&String>,
324) -> &'a citum_schema::reference::contributor::StructuredName {
325    // 1. Priority list: exact match
326    if let Some(tags) = preferred_transliteration {
327        for tag in tags {
328            if let Some(name) = m.transliterations.get(tag) {
329                return name;
330            }
331        }
332        // 2. Priority list: substring match
333        for tag in tags {
334            if let Some((_, name)) = m
335                .transliterations
336                .iter()
337                .find(|(k, _)| k.contains(tag.as_str()))
338            {
339                return name;
340            }
341        }
342    }
343    // 3. Preferred script: exact match
344    if let Some(script) = preferred_script {
345        if let Some(name) = m.transliterations.get(script) {
346            return name;
347        }
348        // 4. Preferred script: substring match
349        if let Some((_, name)) = m
350            .transliterations
351            .iter()
352            .find(|(tag, _)| tag.contains(script))
353        {
354            return name;
355        }
356    }
357    // Fallback: any available transliteration before falling back to original
358    m.transliterations.values().next().unwrap_or(&m.original)
359}
360
361/// Render the original-script display form of a structured name.
362///
363/// CJK names display family-first with no separator (`华林甫`); other scripts
364/// display given-first with a space.
365fn original_script_display(name: &citum_schema::reference::contributor::StructuredName) -> String {
366    use unicode_script::{Script, UnicodeScript};
367
368    let family = name.family.to_string();
369    let given = name.given.to_string();
370    let is_cjk = family.chars().chain(given.chars()).any(|ch| {
371        matches!(
372            ch.script(),
373            Script::Han | Script::Hiragana | Script::Katakana | Script::Hangul
374        )
375    });
376    if is_cjk || family.is_empty() || given.is_empty() {
377        format!("{family}{given}")
378    } else {
379        format!("{given} {family}")
380    }
381}
382
383/// Resolve a multilingual contributor name based on style configuration.
384///
385/// Uses holistic name matching - selects the entire name variant (original/transliterated/translated)
386/// as a unit rather than mixing fields from different variants.
387///
388/// # Arguments
389/// * `contributor` - The contributor to resolve
390/// * `mode` - The rendering mode from style config
391/// * `preferred_transliteration` - Optional ordered list of BCP 47 transliteration tags
392/// * `preferred_script` - Optional preferred script (e.g., "Latn")
393/// * `style_locale` - The style's locale for translation matching
394#[must_use]
395pub fn resolve_multilingual_name(
396    contributor: &citum_schema::reference::contributor::Contributor,
397    mode: Option<&citum_schema::options::MultilingualMode>,
398    preferred_transliteration: Option<&[String]>,
399    preferred_script: Option<&String>,
400    style_locale: &str,
401) -> Vec<crate::reference::FlatName> {
402    use citum_schema::options::MultilingualMode;
403    use citum_schema::reference::contributor::Contributor;
404
405    match contributor {
406        // Simple and structured names have no multilingual data
407        Contributor::SimpleName(_) | Contributor::StructuredName(_) => contributor.to_names_vec(),
408
409        // Multilingual names: select variant holistically
410        Contributor::Multilingual(m) => {
411            let mode = mode.unwrap_or(&MultilingualMode::Primary);
412
413            let selected_name = match mode {
414                MultilingualMode::Primary => &m.original,
415                MultilingualMode::Transliterated => {
416                    select_by_transliteration(m, preferred_transliteration, preferred_script)
417                }
418                MultilingualMode::Translated => {
419                    m.translations.get(style_locale).unwrap_or(&m.original)
420                }
421                // Combined mode for names defaults to transliterated (parenthetical combo not common for names)
422                MultilingualMode::Combined => {
423                    select_by_transliteration(m, preferred_transliteration, preferred_script)
424                }
425                // Pattern mode for names: render the romanized view, carrying the
426                // original-script form along when the pattern requests it
427                // (e.g. "Hua Linfu 华林甫").
428                MultilingualMode::Pattern(_) => {
429                    select_by_transliteration(m, preferred_transliteration, preferred_script)
430                }
431            };
432
433            // When a name pattern includes an `original-script` view alongside
434            // the selected transliteration, carry the original-script display
435            // form (with the segment's wrap applied) so formatting can append
436            // it after the romanized name.
437            let original_script = match mode {
438                MultilingualMode::Pattern(segments) if selected_name != &m.original => segments
439                    .iter()
440                    .find(|segment| {
441                        segment.view == citum_schema::options::MultilingualView::OriginalScript
442                    })
443                    .map(|segment| segment.wrap.apply(&original_script_display(&m.original))),
444                _ => None,
445            };
446
447            // Convert selected name to FlatName
448            vec![crate::reference::FlatName {
449                given: Some(selected_name.given.to_string()),
450                family: Some(selected_name.family.to_string()),
451                suffix: selected_name.suffix.clone(),
452                dropping_particle: selected_name.dropping_particle.clone(),
453                non_dropping_particle: selected_name.non_dropping_particle.clone(),
454                literal: None,
455                short_name: None,
456                original_script,
457            }]
458        }
459
460        Contributor::ContributorList(l) => {
461            l.0.iter()
462                .flat_map(|c| {
463                    resolve_multilingual_name(
464                        c,
465                        mode,
466                        preferred_transliteration,
467                        preferred_script,
468                        style_locale,
469                    )
470                })
471                .collect()
472        }
473    }
474}
475
476/// Resolve the URL for a component based on its links configuration and the reference data.
477#[must_use]
478pub fn resolve_url(
479    links: &citum_schema::options::LinksConfig,
480    reference: &Reference,
481) -> Option<String> {
482    use citum_schema::options::LinkTarget;
483
484    let target = links.target.as_ref().unwrap_or(&LinkTarget::UrlOrDoi);
485
486    match target {
487        LinkTarget::Url => reference.url().map(|u| u.to_string()),
488        LinkTarget::Doi => reference.doi().map(|d| format!("https://doi.org/{d}")),
489        LinkTarget::UrlOrDoi => reference
490            .url()
491            .map(|u| u.to_string())
492            .or_else(|| reference.doi().map(|d| format!("https://doi.org/{d}"))),
493        LinkTarget::Pubmed => reference
494            .id()
495            .filter(|id| id.starts_with("pmid:"))
496            .map(|id| {
497                #[allow(clippy::string_slice, reason = "known ASCII prefix")]
498                let result = format!("https://pubmed.ncbi.nlm.nih.gov/{}/", &id[5..]);
499                result
500            }),
501        LinkTarget::Pmcid => reference
502            .id()
503            .filter(|id| id.starts_with("pmc:"))
504            .map(|id| {
505                #[allow(clippy::string_slice, reason = "known ASCII prefix")]
506                let result = format!("https://www.ncbi.nlm.nih.gov/pmc/articles/{}/", &id[4..]);
507                result
508            }),
509    }
510}
511
512/// Resolve the effective URL for a component, checking local links then falling back to global config.
513#[must_use]
514pub fn resolve_effective_url(
515    local_links: Option<&citum_schema::options::LinksConfig>,
516    global_links: Option<&citum_schema::options::LinksConfig>,
517    reference: &Reference,
518    component_anchor: citum_schema::options::LinkAnchor,
519) -> Option<String> {
520    use citum_schema::options::LinkAnchor;
521
522    // 1. Check local links first
523    if let Some(links) = local_links {
524        let anchor = links.anchor.as_ref().unwrap_or(&LinkAnchor::Component);
525        if matches!(anchor, LinkAnchor::Component) || *anchor == component_anchor {
526            return resolve_url(links, reference);
527        }
528    }
529
530    // 2. Fall back to global links if anchor matches this component type
531    if let Some(links) = global_links
532        && let Some(anchor) = &links.anchor
533        && *anchor == component_anchor
534    {
535        return resolve_url(links, reference);
536    }
537
538    None
539}
540
541/// Processed values ready for rendering.
542#[derive(Debug, Clone, Default)]
543pub struct ProcValues<T = String> {
544    /// The primary formatted value.
545    pub value: T,
546    /// Optional prefix to prepend.
547    pub prefix: Option<String>,
548    /// Optional suffix to append.
549    pub suffix: Option<String>,
550    /// Optional URL for hyperlinking.
551    pub url: Option<String>,
552    /// Variable key that was substituted (e.g., "title:Primary" when title replaces author).
553    /// Used to prevent duplicate rendering per CSL variable-once rule.
554    pub substituted_key: Option<String>,
555    /// Whether the value is already pre-formatted.
556    pub pre_formatted: bool,
557}
558
559/// Processing hints computed before rendering a reference or citation item.
560#[derive(Debug, Clone, Default)]
561pub struct ProcHints {
562    /// Whether disambiguation is active (triggers year-suffix).
563    pub disamb_condition: bool,
564    /// Index in the disambiguation group (1-based).
565    pub group_index: usize,
566    /// Total size of the disambiguation group.
567    pub group_length: usize,
568    /// The grouping key used.
569    pub group_key: String,
570    /// Whether to expand given names for disambiguation.
571    pub expand_given_names: bool,
572    /// Whether to expand given names for primary author only.
573    pub expand_given_names_primary_only: bool,
574    /// Minimum number of names to show to resolve ambiguity (overrides et-al-use-first).
575    pub min_names_to_show: Option<usize>,
576    /// Citation number for numeric citation styles (1-based).
577    pub citation_number: Option<usize>,
578    /// Optional sub-label for compound numeric citation addressing (e.g., "a" in "1a").
579    pub citation_sub_label: Option<String>,
580    /// Citation position (first, subsequent, ibid, etc.).
581    pub position: Option<citum_schema::citation::Position>,
582    /// Explicit integral citation name-memory state for this rendered item.
583    pub integral_name_state: Option<citum_schema::citation::IntegralNameState>,
584    /// Explicit org-abbreviation state for this rendered item.
585    pub org_abbreviation_state: Option<citum_schema::citation::IntegralNameState>,
586    /// First note number in which this reference was cited (note styles only).
587    /// Set for subsequent-position citations; `None` otherwise.
588    pub first_reference_note_number: Option<u32>,
589    /// When true, suppress a `disambiguate_only` title component.
590    /// Set when `first_reference_note_number` is present — the note number
591    /// already identifies the work; the disambiguating short title is redundant.
592    pub suppress_disambiguation_title: bool,
593}
594
595/// Context for rendering (citation vs bibliography).
596#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
597pub enum RenderContext {
598    #[default]
599    /// Render values for citation output.
600    Citation,
601    /// Render values for bibliography output.
602    Bibliography,
603}
604
605/// Options for rendering.
606#[derive(Clone)]
607pub struct RenderOptions<'a> {
608    /// Effective configuration after style and default resolution.
609    pub config: &'a Config,
610    /// Effective bibliography-only configuration when rendering bibliography behavior.
611    pub bibliography_config: Option<BibliographyConfig>,
612    /// Locale used for term lookup and locale-sensitive formatting.
613    pub locale: &'a Locale,
614    /// Whether the current render target is a citation or bibliography.
615    pub context: RenderContext,
616    /// Citation mode for the current render operation.
617    pub mode: citum_schema::citation::CitationMode,
618    /// Whether to suppress the author name for this citation.
619    /// Set from the citation-level `suppress_author` flag.
620    pub suppress_author: bool,
621    /// Optional raw citation locator for rendering via locator config.
622    pub locator_raw: Option<&'a citum_schema::citation::CitationLocator>,
623    /// Reference type for optional type-class gating in locator patterns.
624    pub ref_type: Option<String>,
625    /// Whether to output semantic markup (HTML spans, Djot attributes).
626    pub show_semantics: bool,
627    /// The current top-level template index, when propagating preview annotations.
628    pub current_template_index: Option<usize>,
629    /// Document-level abbreviation map for post-render substitution.
630    pub abbreviation_map: Option<&'a crate::api::AbbreviationMap>,
631}
632
633/// Trait for extracting values from template components.
634pub trait ComponentValues {
635    /// Resolve the component into processed render values for one reference.
636    fn values<F: crate::render::format::OutputFormat<Output = String>>(
637        &self,
638        reference: &Reference,
639        hints: &ProcHints,
640        options: &RenderOptions<'_>,
641    ) -> Option<ProcValues<F::Output>>;
642}
643
644impl ComponentValues for TemplateComponent {
645    fn values<F: crate::render::format::OutputFormat<Output = String>>(
646        &self,
647        reference: &Reference,
648        hints: &ProcHints,
649        options: &RenderOptions<'_>,
650    ) -> Option<ProcValues<F::Output>> {
651        match self {
652            TemplateComponent::Contributor(c) => c.values::<F>(reference, hints, options),
653            TemplateComponent::Date(d) => d.values::<F>(reference, hints, options),
654            TemplateComponent::Title(t) => t.values::<F>(reference, hints, options),
655            TemplateComponent::Number(n) => n.values::<F>(reference, hints, options),
656            TemplateComponent::Variable(v) => v.values::<F>(reference, hints, options),
657            TemplateComponent::Message(m) => m.values::<F>(reference, hints, options),
658            TemplateComponent::Group(l) => l.values::<F>(reference, hints, options),
659            TemplateComponent::Term(t) => t.values::<F>(reference, hints, options),
660            _ => None,
661        }
662    }
663}
664
665/// Check if periods should be stripped based on three-tier precedence.
666///
667/// Resolution order:
668/// 1. Component-level `strip_periods`
669/// 2. Global config `strip_periods`
670/// 3. Defaults to false
671#[must_use]
672pub fn should_strip_periods(
673    rendering: &citum_schema::template::Rendering,
674    options: &RenderOptions<'_>,
675) -> bool {
676    rendering
677        .strip_periods
678        .or(options.config.strip_periods)
679        .unwrap_or(false)
680}
681
682/// Strip trailing periods from a string.
683///
684/// Only removes periods at the end of the string, preserves internal periods
685/// (e.g., "Ph.D." remains unchanged if there's no trailing period).
686#[must_use]
687pub fn strip_trailing_periods(s: &str) -> String {
688    s.trim_end_matches('.').to_string()
689}
690
691/// Apply abbreviation substitution if the map contains an entry for `value`.
692///
693/// Returns the abbreviation if found, otherwise returns the original value unchanged.
694#[must_use]
695pub fn apply_abbreviation(value: String, map: Option<&crate::api::AbbreviationMap>) -> String {
696    if let Some(abbr) = map.and_then(|m| m.0.get(&value)) {
697        return abbr.clone();
698    }
699    value
700}