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
353pub(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#[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#[must_use]
410pub fn is_latin_script_language(lang: Option<&str>) -> bool {
411 resolve_language_script(lang).as_deref() == Some("Latn")
412}
413
414#[derive(Debug, Clone, Copy, PartialEq, Eq)]
419pub enum ScriptClass {
420 Latin,
422 Cjk,
424 Mixed,
426}
427
428#[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#[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#[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#[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 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
527fn 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
553fn 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 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 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 if let Some(script) = preferred_script {
579 if let Some(name) = m.transliterations.get(script) {
580 return name;
581 }
582 if let Some((_, name)) = m
584 .transliterations
585 .iter()
586 .find(|(tag, _)| tag.contains(script))
587 {
588 return name;
589 }
590 }
591 m.transliterations.values().next().unwrap_or(&m.original)
593}
594
595fn 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#[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 Contributor::SimpleName(_) | Contributor::StructuredName(_) => contributor.to_names_vec(),
642
643 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 MultilingualMode::Combined => {
657 select_by_transliteration(m, preferred_transliteration, preferred_script)
658 }
659 MultilingualMode::Pattern(_) => {
663 select_by_transliteration(m, preferred_transliteration, preferred_script)
664 }
665 };
666
667 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 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#[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#[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 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 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#[derive(Debug, Clone, Default)]
787pub struct ProcValues<T = String> {
788 pub value: T,
790 pub prefix: Option<String>,
792 pub suffix: Option<String>,
794 pub url: Option<String>,
796 pub substituted_key: Option<String>,
799 pub pre_formatted: bool,
801}
802
803#[derive(Debug, Clone, Default)]
805pub struct ProcHints {
806 pub disamb_condition: bool,
808 pub group_index: usize,
810 pub group_length: usize,
812 pub group_key: String,
814 pub expand_given_names: bool,
816 pub expand_given_names_primary_only: bool,
818 pub min_names_to_show: Option<usize>,
820 pub citation_number: Option<usize>,
822 pub citation_sub_label: Option<String>,
824 pub position: Option<citum_schema::citation::Position>,
826 pub integral_name_state: Option<citum_schema::citation::IntegralNameState>,
828 pub org_abbreviation_state: Option<citum_schema::citation::IntegralNameState>,
830 pub first_reference_note_number: Option<u32>,
833 pub suppress_disambiguation_title: bool,
837}
838
839#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
841pub enum RenderContext {
842 #[default]
843 Citation,
845 Bibliography,
847}
848
849#[derive(Clone)]
851pub struct RenderOptions<'a> {
852 pub config: Arc<Config>,
854 pub bibliography_config: Option<Arc<BibliographyConfig>>,
856 pub locale: &'a Locale,
858 pub context: RenderContext,
860 pub mode: citum_schema::citation::CitationMode,
862 pub suppress_author: bool,
865 pub locator_raw: Option<&'a citum_schema::citation::CitationLocator>,
867 pub ref_type: Option<String>,
869 pub show_semantics: bool,
871 pub current_template_index: Option<usize>,
873 pub abbreviation_map: Option<&'a crate::api::AbbreviationMap>,
875}
876
877pub trait ComponentValues {
879 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#[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#[must_use]
933pub fn strip_trailing_periods(s: &str) -> String {
934 s.trim_end_matches('.').to_string()
935}
936
937#[must_use]
944pub fn strip_all_periods(s: &str) -> String {
945 s.chars().filter(|c| *c != '.').collect()
946}
947
948#[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}