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