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/// Supplementary standardized identifier extraction.
16pub mod identifier;
17/// List-component value extraction helpers.
18pub mod list;
19/// Locator rendering logic.
20pub mod locator;
21/// Locale message component rendering.
22pub mod message;
23/// Numeric variable extraction and page-range helpers.
24pub mod number;
25/// Shared helpers for collapsing consecutive numeric or ordinal numbering.
26pub mod range;
27/// Locale term resolution helpers.
28pub mod term;
29/// Title text-case transform functions.
30pub mod text_case;
31/// Title extraction and title-formatting helpers.
32pub mod title;
33/// Single source of truth for reference-type classification (title
34/// category, `TypeClass` membership, serial-parent-ness, selector aliases,
35/// DOI-URL synthesis).
36pub(crate) mod type_class;
37/// `type-label` component rendering (localized reference-type description).
38pub mod type_label;
39/// Generic variable extraction helpers.
40pub mod variable;
41
42#[cfg(test)]
43#[allow(
44    clippy::unwrap_used,
45    clippy::expect_used,
46    clippy::panic,
47    clippy::indexing_slicing,
48    clippy::todo,
49    clippy::unimplemented,
50    clippy::unreachable,
51    clippy::get_unwrap,
52    reason = "Panicking is acceptable and often desired in tests."
53)]
54mod tests;
55
56use crate::reference::Reference;
57use citum_schema::locale::Locale;
58use citum_schema::options::{Config, bibliography::BibliographyConfig};
59use citum_schema::reference::types::Title;
60use citum_schema::template::{TemplateComponent, TitleType};
61use std::sync::Arc;
62
63thread_local! {
64    static EXTENDED_LOCALE_EXPANDER: icu_locale::LocaleExpander =
65        const { icu_locale::LocaleExpander::new_extended() };
66}
67
68pub use contributor::format_contributors_short;
69pub use date::int_to_letter;
70
71/// Resolve the preferred variant from a map keyed by BCP 47 tag or script code.
72///
73/// Applies priority-based matching:
74/// 1. Preferred transliteration list: exact match
75/// 2. Preferred transliteration list: substring match
76/// 3. Preferred script: exact match
77/// 4. Preferred script: substring match
78///
79/// Substring passes select the lexicographically smallest matching key:
80/// HashMap iteration order is randomized per process, and both rendering and
81/// bibliography sorting need a reproducible choice when several keys match.
82pub(crate) fn resolve_preferred_variant<'a, T>(
83    variants: &'a std::collections::HashMap<String, T>,
84    preferred_transliteration: Option<&[String]>,
85    preferred_script: Option<&String>,
86) -> Option<&'a T> {
87    let substring_match = |needle: &str| {
88        variants
89            .iter()
90            .filter(|(key, _)| key.contains(needle))
91            .min_by(|(a, _), (b, _)| a.cmp(b))
92            .map(|(_, value)| value)
93    };
94
95    if let Some(tags) = preferred_transliteration {
96        for tag in tags {
97            if let Some(value) = variants.get(tag) {
98                return Some(value);
99            }
100        }
101        for tag in tags {
102            if let Some(value) = substring_match(tag) {
103                return Some(value);
104            }
105        }
106    }
107
108    if let Some(script) = preferred_script {
109        if let Some(value) = variants.get(script) {
110            return Some(value);
111        }
112        if let Some(value) = substring_match(script) {
113            return Some(value);
114        }
115    }
116
117    None
118}
119
120/// Resolve preferred transliteration from a map of transliterations.
121fn resolve_transliteration<'a>(
122    transliterations: &'a std::collections::HashMap<String, String>,
123    preferred_transliteration: Option<&[String]>,
124    preferred_script: Option<&String>,
125) -> Option<&'a str> {
126    resolve_preferred_variant(
127        transliterations,
128        preferred_transliteration,
129        preferred_script,
130    )
131    .map(String::as_str)
132}
133
134fn resolve_translation<'a>(
135    translations: &'a std::collections::HashMap<citum_schema::reference::LangID, String>,
136    style_locale: &str,
137) -> Option<&'a str> {
138    translations
139        .get(style_locale)
140        .or_else(|| {
141            style_locale
142                .split(['-', '_'])
143                .next()
144                .and_then(|base| translations.get(base))
145        })
146        .map(String::as_str)
147}
148
149/// Resolve a multilingual string based on style configuration.
150///
151/// Applies BCP 47 fallback logic:
152/// 1. Exact tag match (e.g., "ja-Latn-hepburn")
153/// 2. Script prefix match (e.g., "ja-Latn")
154/// 3. Fallback to original field
155///
156/// # Arguments
157/// * `string` - The multilingual string to resolve
158/// * `mode` - The rendering mode from style config
159/// * `preferred_transliteration` - Optional ordered list of BCP 47 transliteration tags
160/// * `preferred_script` - Optional preferred script (e.g., "Latn")
161/// * `style_locale` - The style's locale for translation matching
162#[must_use]
163pub fn resolve_multilingual_string(
164    string: &citum_schema::reference::types::MultilingualString,
165    mode: Option<&citum_schema::options::MultilingualMode>,
166    preferred_transliteration: Option<&[String]>,
167    preferred_script: Option<&String>,
168    style_locale: &str,
169) -> String {
170    use citum_schema::options::MultilingualMode;
171    use citum_schema::reference::types::MultilingualString;
172
173    match string {
174        MultilingualString::Simple(s) => s.clone(),
175        MultilingualString::Complex(complex) => {
176            let mode = mode.unwrap_or(&MultilingualMode::Primary);
177
178            match mode {
179                MultilingualMode::Primary => complex.original.clone(),
180
181                MultilingualMode::Transliterated => {
182                    if let Some(trans) = resolve_transliteration(
183                        &complex.transliterations,
184                        preferred_transliteration,
185                        preferred_script,
186                    ) {
187                        return trans.to_string();
188                    }
189
190                    // Fallback: use any available transliteration, or original
191                    complex
192                        .transliterations
193                        .values()
194                        .next()
195                        .cloned()
196                        .unwrap_or_else(|| complex.original.clone())
197                }
198
199                MultilingualMode::Translated => {
200                    // Try to match style locale
201                    resolve_translation(&complex.translations, style_locale)
202                        .map(ToString::to_string)
203                        .unwrap_or_else(|| complex.original.clone())
204                }
205
206                MultilingualMode::Combined => {
207                    // Format: "transliterated [translated]" or fallback variants
208                    let trans = resolve_transliteration(
209                        &complex.transliterations,
210                        preferred_transliteration,
211                        preferred_script,
212                    );
213
214                    let translation = resolve_translation(&complex.translations, style_locale);
215
216                    match (trans, translation) {
217                        (Some(t), Some(tr)) => format!("{t} [{tr}]"),
218                        (Some(t), None) => t.to_string(),
219                        (None, Some(tr)) => format!("{} [{}]", complex.original, tr),
220                        (None, None) => complex.original.clone(),
221                    }
222                }
223
224                MultilingualMode::Pattern(segments) => resolve_multilingual_pattern(
225                    segments,
226                    &complex.original,
227                    &complex.transliterations,
228                    &complex.translations,
229                    preferred_transliteration,
230                    preferred_script,
231                    style_locale,
232                ),
233            }
234        }
235    }
236}
237
238/// Render a [`MultilingualMode::Pattern`] against a complex multilingual string.
239///
240/// Each segment is resolved to its text; segments that are empty or identical to
241/// the previous non-empty segment are skipped (dedup).  The surviving segments are
242/// joined by a single space.
243fn resolve_multilingual_pattern(
244    segments: &[citum_schema::options::MultilingualSegment],
245    original: &str,
246    transliterations: &std::collections::HashMap<String, String>,
247    translations: &std::collections::HashMap<citum_schema::reference::types::LangID, String>,
248    preferred_transliteration: Option<&[String]>,
249    preferred_script: Option<&String>,
250    style_locale: &str,
251) -> String {
252    use citum_schema::options::{MultilingualView, SegmentWrap};
253    let mut parts: Vec<String> = Vec::with_capacity(segments.len());
254    let mut last_text: Option<String> = None;
255
256    for seg in segments {
257        let text: Option<String> = match &seg.view {
258            MultilingualView::OriginalScript => Some(original.to_string()),
259            MultilingualView::Transliterated => resolve_transliteration(
260                transliterations,
261                preferred_transliteration,
262                preferred_script,
263            )
264            .map(ToString::to_string),
265            MultilingualView::Translated => {
266                resolve_translation(translations, style_locale).map(ToString::to_string)
267            }
268        };
269
270        let Some(text) = text else { continue };
271        if text.is_empty() {
272            continue;
273        }
274        // Skip duplicate: if this text is identical to the previous segment (e.g. when
275        // transliteration falls back to the same value as original).
276        if last_text.as_deref() == Some(text.as_str()) {
277            continue;
278        }
279
280        let wrapped = match &seg.wrap {
281            SegmentWrap::None => text.clone(),
282            other => other.apply(&text),
283        };
284        last_text = Some(text);
285        parts.push(wrapped);
286    }
287
288    parts.join(" ")
289}
290
291/// Resolve the effective language for one logical field scope on a reference.
292///
293/// This prefers an explicit `field_languages` entry, then a multilingual title
294/// language tag for the provided title value, and finally the reference-level
295/// language.
296#[must_use]
297pub fn effective_field_language(
298    reference: &Reference,
299    scope: &str,
300    title: Option<&Title>,
301) -> Option<String> {
302    reference
303        .field_languages()
304        .get(scope)
305        .map(ToString::to_string)
306        .or_else(|| match title {
307            Some(Title::Multilingual(multilingual)) => {
308                multilingual.lang.as_ref().map(ToString::to_string)
309            }
310            _ => None,
311        })
312        .or_else(|| reference.language().map(|lang| lang.to_string()))
313}
314
315/// Resolve the effective language for the primary title of a reference.
316#[must_use]
317pub fn effective_item_language(reference: &Reference) -> Option<String> {
318    effective_field_language(reference, "title", reference.title().as_ref())
319}
320
321/// Resolve the effective language for the specific template component being rendered.
322#[must_use]
323pub fn effective_component_language(
324    reference: &Reference,
325    component: &TemplateComponent,
326) -> Option<String> {
327    match component {
328        TemplateComponent::Title(title_component) => {
329            let title = match title_component.title {
330                TitleType::Primary => reference.title(),
331                TitleType::ContainerTitle => reference.container_title(),
332                TitleType::ParentMonograph => reference.container_title(),
333                TitleType::ParentSerial => reference.container_title(),
334                TitleType::CollectionTitle => reference.collection_title(),
335                _ => reference.title(),
336            };
337
338            let scope = match title_component.title {
339                TitleType::Primary => "title",
340                TitleType::ContainerTitle => "container-title",
341                TitleType::ParentMonograph => "parent-monograph.title",
342                TitleType::ParentSerial => "parent-serial.title",
343                TitleType::CollectionTitle => "collection-title",
344                _ => "title",
345            };
346
347            effective_field_language(reference, scope, title.as_ref())
348        }
349        _ => effective_item_language(reference),
350    }
351}
352
353/// Parse a language tag after applying Citum's accepted normalization and fallback rules.
354///
355/// Underscores are normalized to BCP 47 separators, and unrecognized rightmost
356/// subtags are progressively removed so Citum-specific locale suffixes do not
357/// hide an otherwise valid language identifier.
358pub(crate) fn parse_language_identifier(
359    lang: Option<&str>,
360) -> Option<icu_locale::LanguageIdentifier> {
361    use std::borrow::Cow;
362
363    let lang = lang?.trim();
364    if lang.is_empty() {
365        return None;
366    }
367
368    let normalized = if lang.contains('_') {
369        Cow::Owned(lang.replace('_', "-"))
370    } else {
371        Cow::Borrowed(lang)
372    };
373    let mut candidate = normalized.as_ref();
374    while !candidate.is_empty() {
375        if let Ok(locale) = candidate.parse::<icu_locale::Locale>() {
376            return Some(locale.id);
377        }
378        candidate = candidate.rsplit_once('-')?.0;
379    }
380
381    None
382}
383
384/// Resolve a BCP 47 language tag to its effective ISO 15924 script code.
385///
386/// An explicit script subtag takes precedence. Otherwise, the tag's language
387/// and region are expanded with CLDR likely-subtags data. Missing, malformed,
388/// private-use-only, and unrecognized language evidence resolves to `None`;
389/// this function never supplies a default script.
390#[must_use]
391pub fn resolve_language_script(lang: Option<&str>) -> Option<String> {
392    let mut langid = parse_language_identifier(lang)?;
393
394    if let Some(script) = langid.script {
395        return Some(script.to_string());
396    }
397    if langid.language.is_unknown() || matches!(langid.language.as_str(), "mul" | "zxx") {
398        return None;
399    }
400
401    EXTENDED_LOCALE_EXPANDER.with(|expander| expander.maximize(&mut langid));
402    langid.script.map(|script| script.to_string())
403}
404
405/// Whether a BCP 47 language tag resolves to the Latin script.
406///
407/// This compatibility adapter preserves the positive-evidence behavior used
408/// by the Latin punctuation remapping call sites.
409#[must_use]
410pub fn is_latin_script_language(lang: Option<&str>) -> bool {
411    resolve_language_script(lang).as_deref() == Some("Latn")
412}
413
414/// Script partition used to select semantic wrap-punctuation glyphs
415/// (`docs/specs/PUNCTUATION_REALIZATION.md`, increment 1). Extends by adding
416/// variants (`Cyrillic`, `Arabic`, …) as later increments broaden coverage;
417/// v1 covers the two classes the embedded bilingual styles need.
418#[derive(Debug, Clone, Copy, PartialEq, Eq)]
419pub enum ScriptClass {
420    /// Latin-script realization: half-width delimiters (`(`, `[`, …).
421    Latin,
422    /// CJK-script realization: full-width delimiters (`(`, `【`, …).
423    Cjk,
424    /// Mixed table: full-width except periods and square brackets.
425    Mixed,
426}
427
428/// Resolve the effective script class from positive script evidence only.
429///
430/// Returns `None` when the language tag carries no usable evidence for either
431/// supported class. Latin and Cyrillic both select the narrow/Latin class;
432/// unrecognized scripts have no positive evidence. Callers realizing wrap
433/// punctuation should fall back to the style-declared default via
434/// [`wrap_script_class`] rather than treating `None` as a script itself.
435#[must_use]
436pub fn script_class(lang: Option<&str>) -> Option<ScriptClass> {
437    match resolve_language_script(lang).as_deref() {
438        Some("Hani" | "Hans" | "Hant" | "Jpan" | "Kore" | "Hang" | "Bopo") => {
439            Some(ScriptClass::Cjk)
440        }
441        Some("Latn" | "Cyrl") => Some(ScriptClass::Latin),
442        Some(_) | None => None,
443    }
444}
445
446/// Resolve the script class to realize semantic wrap punctuation as.
447///
448/// Per-item script evidence only overrides the style-declared `default` when
449/// that default is [`ScriptClass::Cjk`] — i.e. the style has opted in via
450/// `options.multilingual.realization-default: cjk`. A style that has not
451/// opted in (`default` is [`ScriptClass::Latin`], which includes every
452/// existing style today) realizes Latin wrap punctuation unconditionally,
453/// regardless of the item's language.
454///
455/// This gate exists because raw item language is not the same as the
456/// item's *rendered* script: a style can romanize a non-Latin source (e.g.
457/// Chicago's "romanized + original-script \[translation\]" mode for East Asian
458/// references) so the citation displays as Latin-script prose even though
459/// the reference's `language` is `zh`/`ja`/`ko`. Without the gate, such an
460/// item's CJK language evidence would incorrectly force full-width wrap
461/// punctuation onto an otherwise entirely Latin-script citation. See
462/// `docs/specs/PUNCTUATION_REALIZATION.md` §5.
463#[must_use]
464pub fn wrap_script_class(lang: Option<&str>, default: ScriptClass) -> ScriptClass {
465    match default {
466        ScriptClass::Latin => ScriptClass::Latin,
467        ScriptClass::Cjk => script_class(lang).unwrap_or(ScriptClass::Cjk),
468        ScriptClass::Mixed => ScriptClass::Mixed,
469    }
470}
471
472/// Resolve the style-declared realization-default script class from
473/// `options.multilingual.realization-default`, defaulting to
474/// [`ScriptClass::Latin`] when unset — today's `wrap: parentheses`/`brackets`
475/// behavior, byte-for-byte.
476#[must_use]
477pub fn realization_default_script_class(
478    multilingual: Option<&citum_schema::options::MultilingualConfig>,
479) -> ScriptClass {
480    match multilingual.map(|ml| ml.realization_default) {
481        Some(citum_schema::options::RealizationDefault::Cjk) => ScriptClass::Cjk,
482        _ => ScriptClass::Latin,
483    }
484}
485
486/// Resolve the effective script class and punctuation realization overrides for
487/// one item.
488#[must_use]
489pub fn punctuation_realization_context<'a>(
490    lang: Option<&str>,
491    multilingual: Option<&'a citum_schema::options::MultilingualConfig>,
492    locale_realization: Option<&'a citum_schema::options::PunctuationRealization>,
493) -> (
494    ScriptClass,
495    Option<std::borrow::Cow<'a, citum_schema::options::PunctuationRealization>>,
496) {
497    let legacy_script = wrap_script_class(lang, realization_default_script_class(multilingual));
498    let script = match multilingual.and_then(|config| config.punctuation_width) {
499        Some(citum_schema::options::PunctuationWidth::Half) => ScriptClass::Latin,
500        Some(citum_schema::options::PunctuationWidth::Full) => ScriptClass::Cjk,
501        Some(citum_schema::options::PunctuationWidth::Mixed) => ScriptClass::Mixed,
502        Some(citum_schema::options::PunctuationWidth::Bylan) => {
503            wrap_script_class(lang, ScriptClass::Cjk)
504        }
505        None => legacy_script,
506    };
507    let override_script = script_class(lang).unwrap_or(match script {
508        // `mixed` has no corresponding script override table; CJK owns the
509        // full-width side of its default realization table.
510        ScriptClass::Mixed => ScriptClass::Cjk,
511        script => script,
512    });
513    let key = match override_script {
514        ScriptClass::Latin => "latin",
515        ScriptClass::Cjk => "cjk",
516        ScriptClass::Mixed => "cjk",
517    };
518    let style_realization = multilingual
519        .and_then(|config| config.scripts.get(key))
520        .and_then(|script| script.realization.as_ref());
521    (
522        script,
523        merge_punctuation_realizations(style_realization, locale_realization),
524    )
525}
526
527/// Merge a style-owned table over a locale-owned table, preserving missing
528/// marks for the engine's selected default realization.
529fn merge_punctuation_realizations<'a>(
530    style: Option<&'a citum_schema::options::PunctuationRealization>,
531    locale: Option<&'a citum_schema::options::PunctuationRealization>,
532) -> Option<std::borrow::Cow<'a, citum_schema::options::PunctuationRealization>> {
533    match (style, locale) {
534        (None, None) => None,
535        (Some(style), None) => Some(std::borrow::Cow::Borrowed(style)),
536        (None, Some(locale)) => Some(std::borrow::Cow::Borrowed(locale)),
537        (Some(style), Some(locale)) => Some(std::borrow::Cow::Owned(
538            citum_schema::options::PunctuationRealization {
539                comma: style.comma.clone().or_else(|| locale.comma.clone()),
540                colon: style.colon.clone().or_else(|| locale.colon.clone()),
541                semicolon: style.semicolon.clone().or_else(|| locale.semicolon.clone()),
542                period: style.period.clone().or_else(|| locale.period.clone()),
543                parentheses: style
544                    .parentheses
545                    .clone()
546                    .or_else(|| locale.parentheses.clone()),
547                brackets: style.brackets.clone().or_else(|| locale.brackets.clone()),
548            },
549        )),
550    }
551}
552
553/// Select a structured name from transliteration maps using priority-list then script-match rules.
554fn select_by_transliteration<'a>(
555    m: &'a citum_schema::reference::contributor::MultilingualName,
556    preferred_transliteration: Option<&[String]>,
557    preferred_script: Option<&String>,
558) -> &'a citum_schema::reference::contributor::StructuredName {
559    // 1. Priority list: exact match
560    if let Some(tags) = preferred_transliteration {
561        for tag in tags {
562            if let Some(name) = m.transliterations.get(tag) {
563                return name;
564            }
565        }
566        // 2. Priority list: substring match
567        for tag in tags {
568            if let Some((_, name)) = m
569                .transliterations
570                .iter()
571                .find(|(k, _)| k.contains(tag.as_str()))
572            {
573                return name;
574            }
575        }
576    }
577    // 3. Preferred script: exact match
578    if let Some(script) = preferred_script {
579        if let Some(name) = m.transliterations.get(script) {
580            return name;
581        }
582        // 4. Preferred script: substring match
583        if let Some((_, name)) = m
584            .transliterations
585            .iter()
586            .find(|(tag, _)| tag.contains(script))
587        {
588            return name;
589        }
590    }
591    // Fallback: any available transliteration before falling back to original
592    m.transliterations.values().next().unwrap_or(&m.original)
593}
594
595/// Render the original-script display form of a structured name.
596///
597/// CJK names display family-first with no separator (`华林甫`); other scripts
598/// display given-first with a space.
599fn original_script_display(name: &citum_schema::reference::contributor::StructuredName) -> String {
600    use unicode_script::{Script, UnicodeScript};
601
602    let family = name.family.to_string();
603    let given = name.given.to_string();
604    let is_cjk = family.chars().chain(given.chars()).any(|ch| {
605        matches!(
606            ch.script(),
607            Script::Han | Script::Hiragana | Script::Katakana | Script::Hangul
608        )
609    });
610    if is_cjk || family.is_empty() || given.is_empty() {
611        format!("{family}{given}")
612    } else {
613        format!("{given} {family}")
614    }
615}
616
617/// Resolve a multilingual contributor name based on style configuration.
618///
619/// Uses holistic name matching - selects the entire name variant (original/transliterated/translated)
620/// as a unit rather than mixing fields from different variants.
621///
622/// # Arguments
623/// * `contributor` - The contributor to resolve
624/// * `mode` - The rendering mode from style config
625/// * `preferred_transliteration` - Optional ordered list of BCP 47 transliteration tags
626/// * `preferred_script` - Optional preferred script (e.g., "Latn")
627/// * `style_locale` - The style's locale for translation matching
628#[must_use]
629pub fn resolve_multilingual_name(
630    contributor: &citum_schema::reference::contributor::Contributor,
631    mode: Option<&citum_schema::options::MultilingualMode>,
632    preferred_transliteration: Option<&[String]>,
633    preferred_script: Option<&String>,
634    style_locale: &str,
635) -> Vec<crate::reference::FlatName> {
636    use citum_schema::options::MultilingualMode;
637    use citum_schema::reference::contributor::Contributor;
638
639    match contributor {
640        // Simple and structured names have no multilingual data
641        Contributor::SimpleName(_) | Contributor::StructuredName(_) => contributor.to_names_vec(),
642
643        // Multilingual names: select variant holistically
644        Contributor::Multilingual(m) => {
645            let mode = mode.unwrap_or(&MultilingualMode::Primary);
646
647            let selected_name = match mode {
648                MultilingualMode::Primary => &m.original,
649                MultilingualMode::Transliterated => {
650                    select_by_transliteration(m, preferred_transliteration, preferred_script)
651                }
652                MultilingualMode::Translated => {
653                    m.translations.get(style_locale).unwrap_or(&m.original)
654                }
655                // Combined mode for names defaults to transliterated (parenthetical combo not common for names)
656                MultilingualMode::Combined => {
657                    select_by_transliteration(m, preferred_transliteration, preferred_script)
658                }
659                // Pattern mode for names: render the romanized view, carrying the
660                // original-script form along when the pattern requests it
661                // (e.g. "Hua Linfu 华林甫").
662                MultilingualMode::Pattern(_) => {
663                    select_by_transliteration(m, preferred_transliteration, preferred_script)
664                }
665            };
666
667            // When a name pattern includes an `original-script` view alongside
668            // the selected transliteration, carry the original-script display
669            // form (with the segment's wrap applied) so formatting can append
670            // it after the romanized name.
671            let original_script = match mode {
672                MultilingualMode::Pattern(segments) if selected_name != &m.original => segments
673                    .iter()
674                    .find(|segment| {
675                        segment.view == citum_schema::options::MultilingualView::OriginalScript
676                    })
677                    .map(|segment| segment.wrap.apply(&original_script_display(&m.original))),
678                _ => None,
679            };
680
681            // Convert selected name to FlatName
682            vec![crate::reference::FlatName {
683                given: Some(selected_name.given.to_string()),
684                family: Some(selected_name.family.to_string()),
685                suffix: selected_name.suffix.clone(),
686                dropping_particle: selected_name.dropping_particle.clone(),
687                non_dropping_particle: selected_name.non_dropping_particle.clone(),
688                literal: None,
689                short_name: None,
690                original_script,
691            }]
692        }
693
694        Contributor::ContributorList(l) => {
695            l.0.iter()
696                .flat_map(|c| {
697                    resolve_multilingual_name(
698                        c,
699                        mode,
700                        preferred_transliteration,
701                        preferred_script,
702                        style_locale,
703                    )
704                })
705                .collect()
706        }
707    }
708}
709
710/// Resolve the URL for a component based on its links configuration and the reference data.
711#[must_use]
712pub fn resolve_url(
713    links: &citum_schema::options::LinksConfig,
714    reference: &Reference,
715) -> Option<String> {
716    use citum_schema::options::LinkTarget;
717
718    let target = links.target.as_ref().unwrap_or(&LinkTarget::UrlOrDoi);
719
720    let url = match target {
721        LinkTarget::Url => reference.url().map(|u| u.to_string()),
722        LinkTarget::Doi => reference.doi().map(|d| format!("https://doi.org/{d}")),
723        LinkTarget::UrlOrDoi => reference
724            .url()
725            .map(|u| u.to_string())
726            .or_else(|| reference.doi().map(|d| format!("https://doi.org/{d}"))),
727        LinkTarget::Pubmed => reference
728            .id()
729            .filter(|id| id.starts_with("pmid:"))
730            .map(|id| {
731                #[allow(clippy::string_slice, reason = "known ASCII prefix")]
732                let result = format!("https://pubmed.ncbi.nlm.nih.gov/{}/", &id[5..]);
733                result
734            }),
735        LinkTarget::Pmcid => reference
736            .id()
737            .filter(|id| id.starts_with("pmc:"))
738            .map(|id| {
739                #[allow(clippy::string_slice, reason = "known ASCII prefix")]
740                let result = format!("https://www.ncbi.nlm.nih.gov/pmc/articles/{}/", &id[4..]);
741                result
742            }),
743    };
744
745    if links.strip_protocol == Some(true) {
746        url.map(|u| {
747            u.strip_prefix("https://")
748                .or_else(|| u.strip_prefix("http://"))
749                .map_or_else(|| u.clone(), ToString::to_string)
750        })
751    } else {
752        url
753    }
754}
755
756/// Resolve the effective URL for a component, checking local links then falling back to global config.
757#[must_use]
758pub fn resolve_effective_url(
759    local_links: Option<&citum_schema::options::LinksConfig>,
760    global_links: Option<&citum_schema::options::LinksConfig>,
761    reference: &Reference,
762    component_anchor: citum_schema::options::LinkAnchor,
763) -> Option<String> {
764    use citum_schema::options::LinkAnchor;
765
766    // 1. Check local links first
767    if let Some(links) = local_links {
768        let anchor = links.anchor.as_ref().unwrap_or(&LinkAnchor::Component);
769        if matches!(anchor, LinkAnchor::Component) || *anchor == component_anchor {
770            return resolve_url(links, reference);
771        }
772    }
773
774    // 2. Fall back to global links if anchor matches this component type
775    if let Some(links) = global_links
776        && let Some(anchor) = &links.anchor
777        && *anchor == component_anchor
778    {
779        return resolve_url(links, reference);
780    }
781
782    None
783}
784
785/// Processed values ready for rendering.
786#[derive(Debug, Clone, Default)]
787pub struct ProcValues<T = String> {
788    /// The primary formatted value.
789    pub value: T,
790    /// Optional prefix to prepend.
791    pub prefix: Option<String>,
792    /// Optional suffix to append.
793    pub suffix: Option<String>,
794    /// Optional URL for hyperlinking.
795    pub url: Option<String>,
796    /// Variable key that was substituted (e.g., "title:Primary" when title replaces author).
797    /// Used to prevent duplicate rendering per CSL variable-once rule.
798    pub substituted_key: Option<String>,
799    /// Whether the value is already pre-formatted.
800    pub pre_formatted: bool,
801}
802
803/// Processing hints computed before rendering a reference or citation item.
804#[derive(Debug, Clone, Default)]
805pub struct ProcHints {
806    /// Whether disambiguation is active (triggers year-suffix).
807    pub disamb_condition: bool,
808    /// Index in the disambiguation group (1-based).
809    pub group_index: usize,
810    /// Total size of the disambiguation group.
811    pub group_length: usize,
812    /// The grouping key used.
813    pub group_key: String,
814    /// Whether to expand given names for disambiguation.
815    pub expand_given_names: bool,
816    /// Whether to expand given names for primary author only.
817    pub expand_given_names_primary_only: bool,
818    /// Minimum number of names to show to resolve ambiguity (overrides et-al-use-first).
819    pub min_names_to_show: Option<usize>,
820    /// Citation number for numeric citation styles (1-based).
821    pub citation_number: Option<usize>,
822    /// Optional sub-label for compound numeric citation addressing (e.g., "a" in "1a").
823    pub citation_sub_label: Option<String>,
824    /// Citation position (first, subsequent, ibid, etc.).
825    pub position: Option<citum_schema::citation::Position>,
826    /// Explicit integral citation name-memory state for this rendered item.
827    pub integral_name_state: Option<citum_schema::citation::IntegralNameState>,
828    /// Explicit org-abbreviation state for this rendered item.
829    pub org_abbreviation_state: Option<citum_schema::citation::IntegralNameState>,
830    /// First note number in which this reference was cited (note styles only).
831    /// Set for subsequent-position citations; `None` otherwise.
832    pub first_reference_note_number: Option<u32>,
833    /// When true, suppress a `disambiguate_only` title component.
834    /// Set when `first_reference_note_number` is present — the note number
835    /// already identifies the work; the disambiguating short title is redundant.
836    pub suppress_disambiguation_title: bool,
837}
838
839/// Context for rendering (citation vs bibliography).
840#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
841pub enum RenderContext {
842    #[default]
843    /// Render values for citation output.
844    Citation,
845    /// Render values for bibliography output.
846    Bibliography,
847}
848
849/// Options for rendering.
850#[derive(Clone)]
851pub struct RenderOptions<'a> {
852    /// Effective configuration after style and default resolution.
853    pub config: Arc<Config>,
854    /// Effective bibliography-only configuration when rendering bibliography behavior.
855    pub bibliography_config: Option<Arc<BibliographyConfig>>,
856    /// Locale used for term lookup and locale-sensitive formatting.
857    pub locale: &'a Locale,
858    /// Whether the current render target is a citation or bibliography.
859    pub context: RenderContext,
860    /// Citation mode for the current render operation.
861    pub mode: citum_schema::citation::CitationMode,
862    /// Whether to suppress the author name for this citation.
863    /// Set from the citation-level `suppress_author` flag.
864    pub suppress_author: bool,
865    /// Optional raw citation locator for rendering via locator config.
866    pub locator_raw: Option<&'a citum_schema::citation::CitationLocator>,
867    /// Reference type for optional type-class gating in locator patterns.
868    pub ref_type: Option<String>,
869    /// Whether to output semantic markup (HTML spans, Djot attributes).
870    pub show_semantics: bool,
871    /// The current top-level template index, when propagating preview annotations.
872    pub current_template_index: Option<usize>,
873    /// Document-level abbreviation map for post-render substitution.
874    pub abbreviation_map: Option<&'a crate::api::AbbreviationMap>,
875}
876
877/// Trait for extracting values from template components.
878pub trait ComponentValues {
879    /// Resolve the component into processed render values for one reference.
880    fn values<F: crate::render::format::OutputFormat<Output = String>>(
881        &self,
882        reference: &Reference,
883        hints: &ProcHints,
884        options: &RenderOptions<'_>,
885    ) -> Option<ProcValues<F::Output>>;
886}
887
888impl ComponentValues for TemplateComponent {
889    fn values<F: crate::render::format::OutputFormat<Output = String>>(
890        &self,
891        reference: &Reference,
892        hints: &ProcHints,
893        options: &RenderOptions<'_>,
894    ) -> Option<ProcValues<F::Output>> {
895        match self {
896            TemplateComponent::Contributor(c) => c.values::<F>(reference, hints, options),
897            TemplateComponent::Date(d) => d.values::<F>(reference, hints, options),
898            TemplateComponent::Title(t) => t.values::<F>(reference, hints, options),
899            TemplateComponent::Number(n) => n.values::<F>(reference, hints, options),
900            TemplateComponent::Identifier(i) => i.values::<F>(reference, hints, options),
901            TemplateComponent::Variable(v) => v.values::<F>(reference, hints, options),
902            TemplateComponent::Message(m) => m.values::<F>(reference, hints, options),
903            TemplateComponent::Group(l) => l.values::<F>(reference, hints, options),
904            TemplateComponent::Term(t) => t.values::<F>(reference, hints, options),
905            TemplateComponent::TypeLabel(t) => t.values::<F>(reference, hints, options),
906            _ => None,
907        }
908    }
909}
910
911/// Check if periods should be stripped based on three-tier precedence.
912///
913/// Resolution order:
914/// 1. Component-level `strip_periods`
915/// 2. Global config `strip_periods`
916/// 3. Defaults to false
917#[must_use]
918pub fn should_strip_periods(
919    rendering: &citum_schema::template::Rendering,
920    options: &RenderOptions<'_>,
921) -> bool {
922    rendering
923        .strip_periods
924        .or(options.config.strip_periods)
925        .unwrap_or(false)
926}
927
928/// Strip trailing periods from a string.
929///
930/// Only removes periods at the end of the string, preserves internal periods
931/// (e.g., "Ph.D." remains unchanged if there's no trailing period).
932#[must_use]
933pub fn strip_trailing_periods(s: &str) -> String {
934    s.trim_end_matches('.').to_string()
935}
936
937/// Strip every period from a string.
938///
939/// Matches the CSL `strip-periods` attribute's actual semantics (remove all
940/// periods, not just a trailing one) — used for abbreviated journal titles
941/// like "Br. Med. J." → "Br Med J", where periods can appear after every
942/// abbreviated word, not only at the end.
943#[must_use]
944pub fn strip_all_periods(s: &str) -> String {
945    s.chars().filter(|c| *c != '.').collect()
946}
947
948/// Apply abbreviation substitution if the map contains an entry for `value`.
949///
950/// Returns the abbreviation if found, otherwise returns the original value unchanged.
951#[must_use]
952pub fn apply_abbreviation(value: String, map: Option<&crate::api::AbbreviationMap>) -> String {
953    if let Some(abbr) = map.and_then(|m| m.0.get(&value)) {
954        return abbr.clone();
955    }
956    value
957}