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/// Resolve a BCP 47 language tag to its effective ISO 15924 script code.
354///
355/// An explicit script subtag takes precedence. Otherwise, the tag's language
356/// and region are expanded with CLDR likely-subtags data. Missing, malformed,
357/// private-use-only, and unrecognized language evidence resolves to `None`;
358/// this function never supplies a default script.
359#[must_use]
360pub fn resolve_language_script(lang: Option<&str>) -> Option<String> {
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 langid = normalized.parse::<icu_locale::Locale>().ok()?.id;
374
375    if let Some(script) = langid.script {
376        return Some(script.to_string());
377    }
378    if langid.language.is_unknown() || matches!(langid.language.as_str(), "mul" | "zxx") {
379        return None;
380    }
381
382    EXTENDED_LOCALE_EXPANDER.with(|expander| expander.maximize(&mut langid));
383    langid.script.map(|script| script.to_string())
384}
385
386/// Whether a BCP 47 language tag resolves to the Latin script.
387///
388/// This compatibility adapter preserves the positive-evidence behavior used
389/// by the Latin punctuation remapping call sites.
390#[must_use]
391pub fn is_latin_script_language(lang: Option<&str>) -> bool {
392    resolve_language_script(lang).as_deref() == Some("Latn")
393}
394
395/// Script partition used to select semantic wrap-punctuation glyphs
396/// (`docs/specs/PUNCTUATION_REALIZATION.md`, increment 1). Extends by adding
397/// variants (`Cyrillic`, `Arabic`, …) as later increments broaden coverage;
398/// v1 covers the two classes the embedded bilingual styles need.
399#[derive(Debug, Clone, Copy, PartialEq, Eq)]
400pub enum ScriptClass {
401    /// Latin-script realization: half-width delimiters (`(`, `[`, …).
402    Latin,
403    /// CJK-script realization: full-width delimiters (`(`, `【`, …).
404    Cjk,
405}
406
407/// Resolve the effective script class from positive script evidence only.
408///
409/// Returns `None` when the language tag carries no usable evidence for either
410/// supported class, including known but unsupported scripts such as Cyrillic —
411/// the positive-evidence rule (`MULTILINGUAL.md` §3.2a). Callers realizing wrap
412/// punctuation should fall back to the style-declared default via
413/// [`wrap_script_class`] rather than treating `None` as a script itself.
414#[must_use]
415pub fn script_class(lang: Option<&str>) -> Option<ScriptClass> {
416    match resolve_language_script(lang).as_deref() {
417        Some("Hani" | "Hans" | "Hant" | "Jpan" | "Kore" | "Hang" | "Bopo") => {
418            Some(ScriptClass::Cjk)
419        }
420        Some("Latn") => Some(ScriptClass::Latin),
421        Some(_) | None => None,
422    }
423}
424
425/// Resolve the script class to realize semantic wrap punctuation as.
426///
427/// Per-item script evidence only overrides the style-declared `default` when
428/// that default is [`ScriptClass::Cjk`] — i.e. the style has opted in via
429/// `options.multilingual.realization-default: cjk`. A style that has not
430/// opted in (`default` is [`ScriptClass::Latin`], which includes every
431/// existing style today) realizes Latin wrap punctuation unconditionally,
432/// regardless of the item's language.
433///
434/// This gate exists because raw item language is not the same as the
435/// item's *rendered* script: a style can romanize a non-Latin source (e.g.
436/// Chicago's "romanized + original-script \[translation\]" mode for East Asian
437/// references) so the citation displays as Latin-script prose even though
438/// the reference's `language` is `zh`/`ja`/`ko`. Without the gate, such an
439/// item's CJK language evidence would incorrectly force full-width wrap
440/// punctuation onto an otherwise entirely Latin-script citation. See
441/// `docs/specs/PUNCTUATION_REALIZATION.md` §5.
442#[must_use]
443pub fn wrap_script_class(lang: Option<&str>, default: ScriptClass) -> ScriptClass {
444    match default {
445        ScriptClass::Latin => ScriptClass::Latin,
446        ScriptClass::Cjk => script_class(lang).unwrap_or(ScriptClass::Cjk),
447    }
448}
449
450/// Resolve the style-declared realization-default script class from
451/// `options.multilingual.realization-default`, defaulting to
452/// [`ScriptClass::Latin`] when unset — today's `wrap: parentheses`/`brackets`
453/// behavior, byte-for-byte.
454#[must_use]
455pub fn realization_default_script_class(
456    multilingual: Option<&citum_schema::options::MultilingualConfig>,
457) -> ScriptClass {
458    match multilingual.map(|ml| ml.realization_default) {
459        Some(citum_schema::options::RealizationDefault::Cjk) => ScriptClass::Cjk,
460        _ => ScriptClass::Latin,
461    }
462}
463
464/// Resolve the effective script class and style-owned realization overrides for
465/// one item.
466#[must_use]
467pub fn punctuation_realization_context<'a>(
468    lang: Option<&str>,
469    multilingual: Option<&'a citum_schema::options::MultilingualConfig>,
470) -> (
471    ScriptClass,
472    Option<&'a citum_schema::options::PunctuationRealization>,
473) {
474    let script = wrap_script_class(lang, realization_default_script_class(multilingual));
475    let key = match script {
476        ScriptClass::Latin => "latin",
477        ScriptClass::Cjk => "cjk",
478    };
479    let realization = multilingual
480        .and_then(|config| config.scripts.get(key))
481        .and_then(|script| script.realization.as_ref());
482    (script, realization)
483}
484
485/// Select a structured name from transliteration maps using priority-list then script-match rules.
486fn select_by_transliteration<'a>(
487    m: &'a citum_schema::reference::contributor::MultilingualName,
488    preferred_transliteration: Option<&[String]>,
489    preferred_script: Option<&String>,
490) -> &'a citum_schema::reference::contributor::StructuredName {
491    // 1. Priority list: exact match
492    if let Some(tags) = preferred_transliteration {
493        for tag in tags {
494            if let Some(name) = m.transliterations.get(tag) {
495                return name;
496            }
497        }
498        // 2. Priority list: substring match
499        for tag in tags {
500            if let Some((_, name)) = m
501                .transliterations
502                .iter()
503                .find(|(k, _)| k.contains(tag.as_str()))
504            {
505                return name;
506            }
507        }
508    }
509    // 3. Preferred script: exact match
510    if let Some(script) = preferred_script {
511        if let Some(name) = m.transliterations.get(script) {
512            return name;
513        }
514        // 4. Preferred script: substring match
515        if let Some((_, name)) = m
516            .transliterations
517            .iter()
518            .find(|(tag, _)| tag.contains(script))
519        {
520            return name;
521        }
522    }
523    // Fallback: any available transliteration before falling back to original
524    m.transliterations.values().next().unwrap_or(&m.original)
525}
526
527/// Render the original-script display form of a structured name.
528///
529/// CJK names display family-first with no separator (`华林甫`); other scripts
530/// display given-first with a space.
531fn original_script_display(name: &citum_schema::reference::contributor::StructuredName) -> String {
532    use unicode_script::{Script, UnicodeScript};
533
534    let family = name.family.to_string();
535    let given = name.given.to_string();
536    let is_cjk = family.chars().chain(given.chars()).any(|ch| {
537        matches!(
538            ch.script(),
539            Script::Han | Script::Hiragana | Script::Katakana | Script::Hangul
540        )
541    });
542    if is_cjk || family.is_empty() || given.is_empty() {
543        format!("{family}{given}")
544    } else {
545        format!("{given} {family}")
546    }
547}
548
549/// Resolve a multilingual contributor name based on style configuration.
550///
551/// Uses holistic name matching - selects the entire name variant (original/transliterated/translated)
552/// as a unit rather than mixing fields from different variants.
553///
554/// # Arguments
555/// * `contributor` - The contributor to resolve
556/// * `mode` - The rendering mode from style config
557/// * `preferred_transliteration` - Optional ordered list of BCP 47 transliteration tags
558/// * `preferred_script` - Optional preferred script (e.g., "Latn")
559/// * `style_locale` - The style's locale for translation matching
560#[must_use]
561pub fn resolve_multilingual_name(
562    contributor: &citum_schema::reference::contributor::Contributor,
563    mode: Option<&citum_schema::options::MultilingualMode>,
564    preferred_transliteration: Option<&[String]>,
565    preferred_script: Option<&String>,
566    style_locale: &str,
567) -> Vec<crate::reference::FlatName> {
568    use citum_schema::options::MultilingualMode;
569    use citum_schema::reference::contributor::Contributor;
570
571    match contributor {
572        // Simple and structured names have no multilingual data
573        Contributor::SimpleName(_) | Contributor::StructuredName(_) => contributor.to_names_vec(),
574
575        // Multilingual names: select variant holistically
576        Contributor::Multilingual(m) => {
577            let mode = mode.unwrap_or(&MultilingualMode::Primary);
578
579            let selected_name = match mode {
580                MultilingualMode::Primary => &m.original,
581                MultilingualMode::Transliterated => {
582                    select_by_transliteration(m, preferred_transliteration, preferred_script)
583                }
584                MultilingualMode::Translated => {
585                    m.translations.get(style_locale).unwrap_or(&m.original)
586                }
587                // Combined mode for names defaults to transliterated (parenthetical combo not common for names)
588                MultilingualMode::Combined => {
589                    select_by_transliteration(m, preferred_transliteration, preferred_script)
590                }
591                // Pattern mode for names: render the romanized view, carrying the
592                // original-script form along when the pattern requests it
593                // (e.g. "Hua Linfu 华林甫").
594                MultilingualMode::Pattern(_) => {
595                    select_by_transliteration(m, preferred_transliteration, preferred_script)
596                }
597            };
598
599            // When a name pattern includes an `original-script` view alongside
600            // the selected transliteration, carry the original-script display
601            // form (with the segment's wrap applied) so formatting can append
602            // it after the romanized name.
603            let original_script = match mode {
604                MultilingualMode::Pattern(segments) if selected_name != &m.original => segments
605                    .iter()
606                    .find(|segment| {
607                        segment.view == citum_schema::options::MultilingualView::OriginalScript
608                    })
609                    .map(|segment| segment.wrap.apply(&original_script_display(&m.original))),
610                _ => None,
611            };
612
613            // Convert selected name to FlatName
614            vec![crate::reference::FlatName {
615                given: Some(selected_name.given.to_string()),
616                family: Some(selected_name.family.to_string()),
617                suffix: selected_name.suffix.clone(),
618                dropping_particle: selected_name.dropping_particle.clone(),
619                non_dropping_particle: selected_name.non_dropping_particle.clone(),
620                literal: None,
621                short_name: None,
622                original_script,
623            }]
624        }
625
626        Contributor::ContributorList(l) => {
627            l.0.iter()
628                .flat_map(|c| {
629                    resolve_multilingual_name(
630                        c,
631                        mode,
632                        preferred_transliteration,
633                        preferred_script,
634                        style_locale,
635                    )
636                })
637                .collect()
638        }
639    }
640}
641
642/// Resolve the URL for a component based on its links configuration and the reference data.
643#[must_use]
644pub fn resolve_url(
645    links: &citum_schema::options::LinksConfig,
646    reference: &Reference,
647) -> Option<String> {
648    use citum_schema::options::LinkTarget;
649
650    let target = links.target.as_ref().unwrap_or(&LinkTarget::UrlOrDoi);
651
652    let url = match target {
653        LinkTarget::Url => reference.url().map(|u| u.to_string()),
654        LinkTarget::Doi => reference.doi().map(|d| format!("https://doi.org/{d}")),
655        LinkTarget::UrlOrDoi => reference
656            .url()
657            .map(|u| u.to_string())
658            .or_else(|| reference.doi().map(|d| format!("https://doi.org/{d}"))),
659        LinkTarget::Pubmed => reference
660            .id()
661            .filter(|id| id.starts_with("pmid:"))
662            .map(|id| {
663                #[allow(clippy::string_slice, reason = "known ASCII prefix")]
664                let result = format!("https://pubmed.ncbi.nlm.nih.gov/{}/", &id[5..]);
665                result
666            }),
667        LinkTarget::Pmcid => reference
668            .id()
669            .filter(|id| id.starts_with("pmc:"))
670            .map(|id| {
671                #[allow(clippy::string_slice, reason = "known ASCII prefix")]
672                let result = format!("https://www.ncbi.nlm.nih.gov/pmc/articles/{}/", &id[4..]);
673                result
674            }),
675    };
676
677    if links.strip_protocol == Some(true) {
678        url.map(|u| {
679            u.strip_prefix("https://")
680                .or_else(|| u.strip_prefix("http://"))
681                .map_or_else(|| u.clone(), ToString::to_string)
682        })
683    } else {
684        url
685    }
686}
687
688/// Resolve the effective URL for a component, checking local links then falling back to global config.
689#[must_use]
690pub fn resolve_effective_url(
691    local_links: Option<&citum_schema::options::LinksConfig>,
692    global_links: Option<&citum_schema::options::LinksConfig>,
693    reference: &Reference,
694    component_anchor: citum_schema::options::LinkAnchor,
695) -> Option<String> {
696    use citum_schema::options::LinkAnchor;
697
698    // 1. Check local links first
699    if let Some(links) = local_links {
700        let anchor = links.anchor.as_ref().unwrap_or(&LinkAnchor::Component);
701        if matches!(anchor, LinkAnchor::Component) || *anchor == component_anchor {
702            return resolve_url(links, reference);
703        }
704    }
705
706    // 2. Fall back to global links if anchor matches this component type
707    if let Some(links) = global_links
708        && let Some(anchor) = &links.anchor
709        && *anchor == component_anchor
710    {
711        return resolve_url(links, reference);
712    }
713
714    None
715}
716
717/// Processed values ready for rendering.
718#[derive(Debug, Clone, Default)]
719pub struct ProcValues<T = String> {
720    /// The primary formatted value.
721    pub value: T,
722    /// Optional prefix to prepend.
723    pub prefix: Option<String>,
724    /// Optional suffix to append.
725    pub suffix: Option<String>,
726    /// Optional URL for hyperlinking.
727    pub url: Option<String>,
728    /// Variable key that was substituted (e.g., "title:Primary" when title replaces author).
729    /// Used to prevent duplicate rendering per CSL variable-once rule.
730    pub substituted_key: Option<String>,
731    /// Whether the value is already pre-formatted.
732    pub pre_formatted: bool,
733}
734
735/// Processing hints computed before rendering a reference or citation item.
736#[derive(Debug, Clone, Default)]
737pub struct ProcHints {
738    /// Whether disambiguation is active (triggers year-suffix).
739    pub disamb_condition: bool,
740    /// Index in the disambiguation group (1-based).
741    pub group_index: usize,
742    /// Total size of the disambiguation group.
743    pub group_length: usize,
744    /// The grouping key used.
745    pub group_key: String,
746    /// Whether to expand given names for disambiguation.
747    pub expand_given_names: bool,
748    /// Whether to expand given names for primary author only.
749    pub expand_given_names_primary_only: bool,
750    /// Minimum number of names to show to resolve ambiguity (overrides et-al-use-first).
751    pub min_names_to_show: Option<usize>,
752    /// Citation number for numeric citation styles (1-based).
753    pub citation_number: Option<usize>,
754    /// Optional sub-label for compound numeric citation addressing (e.g., "a" in "1a").
755    pub citation_sub_label: Option<String>,
756    /// Citation position (first, subsequent, ibid, etc.).
757    pub position: Option<citum_schema::citation::Position>,
758    /// Explicit integral citation name-memory state for this rendered item.
759    pub integral_name_state: Option<citum_schema::citation::IntegralNameState>,
760    /// Explicit org-abbreviation state for this rendered item.
761    pub org_abbreviation_state: Option<citum_schema::citation::IntegralNameState>,
762    /// First note number in which this reference was cited (note styles only).
763    /// Set for subsequent-position citations; `None` otherwise.
764    pub first_reference_note_number: Option<u32>,
765    /// When true, suppress a `disambiguate_only` title component.
766    /// Set when `first_reference_note_number` is present — the note number
767    /// already identifies the work; the disambiguating short title is redundant.
768    pub suppress_disambiguation_title: bool,
769}
770
771/// Context for rendering (citation vs bibliography).
772#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
773pub enum RenderContext {
774    #[default]
775    /// Render values for citation output.
776    Citation,
777    /// Render values for bibliography output.
778    Bibliography,
779}
780
781/// Options for rendering.
782#[derive(Clone)]
783pub struct RenderOptions<'a> {
784    /// Effective configuration after style and default resolution.
785    pub config: Arc<Config>,
786    /// Effective bibliography-only configuration when rendering bibliography behavior.
787    pub bibliography_config: Option<Arc<BibliographyConfig>>,
788    /// Locale used for term lookup and locale-sensitive formatting.
789    pub locale: &'a Locale,
790    /// Whether the current render target is a citation or bibliography.
791    pub context: RenderContext,
792    /// Citation mode for the current render operation.
793    pub mode: citum_schema::citation::CitationMode,
794    /// Whether to suppress the author name for this citation.
795    /// Set from the citation-level `suppress_author` flag.
796    pub suppress_author: bool,
797    /// Optional raw citation locator for rendering via locator config.
798    pub locator_raw: Option<&'a citum_schema::citation::CitationLocator>,
799    /// Reference type for optional type-class gating in locator patterns.
800    pub ref_type: Option<String>,
801    /// Whether to output semantic markup (HTML spans, Djot attributes).
802    pub show_semantics: bool,
803    /// The current top-level template index, when propagating preview annotations.
804    pub current_template_index: Option<usize>,
805    /// Document-level abbreviation map for post-render substitution.
806    pub abbreviation_map: Option<&'a crate::api::AbbreviationMap>,
807}
808
809/// Trait for extracting values from template components.
810pub trait ComponentValues {
811    /// Resolve the component into processed render values for one reference.
812    fn values<F: crate::render::format::OutputFormat<Output = String>>(
813        &self,
814        reference: &Reference,
815        hints: &ProcHints,
816        options: &RenderOptions<'_>,
817    ) -> Option<ProcValues<F::Output>>;
818}
819
820impl ComponentValues for TemplateComponent {
821    fn values<F: crate::render::format::OutputFormat<Output = String>>(
822        &self,
823        reference: &Reference,
824        hints: &ProcHints,
825        options: &RenderOptions<'_>,
826    ) -> Option<ProcValues<F::Output>> {
827        match self {
828            TemplateComponent::Contributor(c) => c.values::<F>(reference, hints, options),
829            TemplateComponent::Date(d) => d.values::<F>(reference, hints, options),
830            TemplateComponent::Title(t) => t.values::<F>(reference, hints, options),
831            TemplateComponent::Number(n) => n.values::<F>(reference, hints, options),
832            TemplateComponent::Identifier(i) => i.values::<F>(reference, hints, options),
833            TemplateComponent::Variable(v) => v.values::<F>(reference, hints, options),
834            TemplateComponent::Message(m) => m.values::<F>(reference, hints, options),
835            TemplateComponent::Group(l) => l.values::<F>(reference, hints, options),
836            TemplateComponent::Term(t) => t.values::<F>(reference, hints, options),
837            TemplateComponent::TypeLabel(t) => t.values::<F>(reference, hints, options),
838            _ => None,
839        }
840    }
841}
842
843/// Check if periods should be stripped based on three-tier precedence.
844///
845/// Resolution order:
846/// 1. Component-level `strip_periods`
847/// 2. Global config `strip_periods`
848/// 3. Defaults to false
849#[must_use]
850pub fn should_strip_periods(
851    rendering: &citum_schema::template::Rendering,
852    options: &RenderOptions<'_>,
853) -> bool {
854    rendering
855        .strip_periods
856        .or(options.config.strip_periods)
857        .unwrap_or(false)
858}
859
860/// Strip trailing periods from a string.
861///
862/// Only removes periods at the end of the string, preserves internal periods
863/// (e.g., "Ph.D." remains unchanged if there's no trailing period).
864#[must_use]
865pub fn strip_trailing_periods(s: &str) -> String {
866    s.trim_end_matches('.').to_string()
867}
868
869/// Strip every period from a string.
870///
871/// Matches the CSL `strip-periods` attribute's actual semantics (remove all
872/// periods, not just a trailing one) — used for abbreviated journal titles
873/// like "Br. Med. J." → "Br Med J", where periods can appear after every
874/// abbreviated word, not only at the end.
875#[must_use]
876pub fn strip_all_periods(s: &str) -> String {
877    s.chars().filter(|c| *c != '.').collect()
878}
879
880/// Apply abbreviation substitution if the map contains an entry for `value`.
881///
882/// Returns the abbreviation if found, otherwise returns the original value unchanged.
883#[must_use]
884pub fn apply_abbreviation(value: String, map: Option<&crate::api::AbbreviationMap>) -> String {
885    if let Some(abbr) = map.and_then(|m| m.0.get(&value)) {
886        return abbr.clone();
887    }
888    value
889}