1pub mod contributor;
13pub mod date;
15pub mod identifier;
17pub mod list;
19pub mod locator;
21pub mod message;
23pub mod number;
25pub mod range;
27pub mod term;
29pub mod text_case;
31pub mod title;
33pub(crate) mod type_class;
37pub mod type_label;
39pub 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
71pub(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
120fn 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#[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 complex
192 .transliterations
193 .values()
194 .next()
195 .cloned()
196 .unwrap_or_else(|| complex.original.clone())
197 }
198
199 MultilingualMode::Translated => {
200 resolve_translation(&complex.translations, style_locale)
202 .map(ToString::to_string)
203 .unwrap_or_else(|| complex.original.clone())
204 }
205
206 MultilingualMode::Combined => {
207 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
238fn 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 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#[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#[must_use]
317pub fn effective_item_language(reference: &Reference) -> Option<String> {
318 effective_field_language(reference, "title", reference.title().as_ref())
319}
320
321#[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#[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#[must_use]
391pub fn is_latin_script_language(lang: Option<&str>) -> bool {
392 resolve_language_script(lang).as_deref() == Some("Latn")
393}
394
395#[derive(Debug, Clone, Copy, PartialEq, Eq)]
400pub enum ScriptClass {
401 Latin,
403 Cjk,
405}
406
407#[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#[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#[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#[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
485fn 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 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 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 if let Some(script) = preferred_script {
511 if let Some(name) = m.transliterations.get(script) {
512 return name;
513 }
514 if let Some((_, name)) = m
516 .transliterations
517 .iter()
518 .find(|(tag, _)| tag.contains(script))
519 {
520 return name;
521 }
522 }
523 m.transliterations.values().next().unwrap_or(&m.original)
525}
526
527fn 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#[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 Contributor::SimpleName(_) | Contributor::StructuredName(_) => contributor.to_names_vec(),
574
575 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 MultilingualMode::Combined => {
589 select_by_transliteration(m, preferred_transliteration, preferred_script)
590 }
591 MultilingualMode::Pattern(_) => {
595 select_by_transliteration(m, preferred_transliteration, preferred_script)
596 }
597 };
598
599 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 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#[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#[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 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 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#[derive(Debug, Clone, Default)]
719pub struct ProcValues<T = String> {
720 pub value: T,
722 pub prefix: Option<String>,
724 pub suffix: Option<String>,
726 pub url: Option<String>,
728 pub substituted_key: Option<String>,
731 pub pre_formatted: bool,
733}
734
735#[derive(Debug, Clone, Default)]
737pub struct ProcHints {
738 pub disamb_condition: bool,
740 pub group_index: usize,
742 pub group_length: usize,
744 pub group_key: String,
746 pub expand_given_names: bool,
748 pub expand_given_names_primary_only: bool,
750 pub min_names_to_show: Option<usize>,
752 pub citation_number: Option<usize>,
754 pub citation_sub_label: Option<String>,
756 pub position: Option<citum_schema::citation::Position>,
758 pub integral_name_state: Option<citum_schema::citation::IntegralNameState>,
760 pub org_abbreviation_state: Option<citum_schema::citation::IntegralNameState>,
762 pub first_reference_note_number: Option<u32>,
765 pub suppress_disambiguation_title: bool,
769}
770
771#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
773pub enum RenderContext {
774 #[default]
775 Citation,
777 Bibliography,
779}
780
781#[derive(Clone)]
783pub struct RenderOptions<'a> {
784 pub config: Arc<Config>,
786 pub bibliography_config: Option<Arc<BibliographyConfig>>,
788 pub locale: &'a Locale,
790 pub context: RenderContext,
792 pub mode: citum_schema::citation::CitationMode,
794 pub suppress_author: bool,
797 pub locator_raw: Option<&'a citum_schema::citation::CitationLocator>,
799 pub ref_type: Option<String>,
801 pub show_semantics: bool,
803 pub current_template_index: Option<usize>,
805 pub abbreviation_map: Option<&'a crate::api::AbbreviationMap>,
807}
808
809pub trait ComponentValues {
811 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#[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#[must_use]
865pub fn strip_trailing_periods(s: &str) -> String {
866 s.trim_end_matches('.').to_string()
867}
868
869#[must_use]
876pub fn strip_all_periods(s: &str) -> String {
877 s.chars().filter(|c| *c != '.').collect()
878}
879
880#[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}