1pub mod contributor;
13pub mod date;
15pub mod list;
17pub mod locator;
19pub mod message;
21pub mod number;
23pub mod range;
25pub mod term;
27pub mod text_case;
29pub mod title;
31pub mod variable;
33
34#[cfg(test)]
35#[allow(
36 clippy::unwrap_used,
37 clippy::expect_used,
38 clippy::panic,
39 clippy::indexing_slicing,
40 clippy::todo,
41 clippy::unimplemented,
42 clippy::unreachable,
43 clippy::get_unwrap,
44 reason = "Panicking is acceptable and often desired in tests."
45)]
46mod tests;
47
48use crate::reference::Reference;
49use citum_schema::locale::Locale;
50use citum_schema::options::{Config, bibliography::BibliographyConfig};
51use citum_schema::reference::types::Title;
52use citum_schema::template::{TemplateComponent, TitleType};
53
54pub use contributor::format_contributors_short;
55pub use date::int_to_letter;
56
57fn resolve_transliteration<'a>(
65 transliterations: &'a std::collections::HashMap<String, String>,
66 preferred_transliteration: Option<&[String]>,
67 preferred_script: Option<&String>,
68) -> Option<&'a str> {
69 if let Some(tags) = preferred_transliteration {
71 for tag in tags {
72 if let Some(v) = transliterations.get(tag) {
73 return Some(v.as_str());
74 }
75 }
76 for tag in tags {
78 for (k, v) in transliterations {
79 if k.contains(tag.as_str()) {
80 return Some(v.as_str());
81 }
82 }
83 }
84 }
85 if let Some(script) = preferred_script {
87 if let Some(v) = transliterations.get(script) {
88 return Some(v.as_str());
89 }
90 for (k, v) in transliterations {
92 if k.contains(script.as_str()) {
93 return Some(v.as_str());
94 }
95 }
96 }
97 None
98}
99
100fn resolve_translation<'a>(
101 translations: &'a std::collections::HashMap<citum_schema::reference::LangID, String>,
102 style_locale: &str,
103) -> Option<&'a str> {
104 translations
105 .get(style_locale)
106 .or_else(|| {
107 style_locale
108 .split(['-', '_'])
109 .next()
110 .and_then(|base| translations.get(base))
111 })
112 .map(String::as_str)
113}
114
115#[must_use]
129pub fn resolve_multilingual_string(
130 string: &citum_schema::reference::types::MultilingualString,
131 mode: Option<&citum_schema::options::MultilingualMode>,
132 preferred_transliteration: Option<&[String]>,
133 preferred_script: Option<&String>,
134 style_locale: &str,
135) -> String {
136 use citum_schema::options::MultilingualMode;
137 use citum_schema::reference::types::MultilingualString;
138
139 match string {
140 MultilingualString::Simple(s) => s.clone(),
141 MultilingualString::Complex(complex) => {
142 let mode = mode.unwrap_or(&MultilingualMode::Primary);
143
144 match mode {
145 MultilingualMode::Primary => complex.original.clone(),
146
147 MultilingualMode::Transliterated => {
148 if let Some(trans) = resolve_transliteration(
149 &complex.transliterations,
150 preferred_transliteration,
151 preferred_script,
152 ) {
153 return trans.to_string();
154 }
155
156 complex
158 .transliterations
159 .values()
160 .next()
161 .cloned()
162 .unwrap_or_else(|| complex.original.clone())
163 }
164
165 MultilingualMode::Translated => {
166 resolve_translation(&complex.translations, style_locale)
168 .map(ToString::to_string)
169 .unwrap_or_else(|| complex.original.clone())
170 }
171
172 MultilingualMode::Combined => {
173 let trans = resolve_transliteration(
175 &complex.transliterations,
176 preferred_transliteration,
177 preferred_script,
178 );
179
180 let translation = resolve_translation(&complex.translations, style_locale);
181
182 match (trans, translation) {
183 (Some(t), Some(tr)) => format!("{t} [{tr}]"),
184 (Some(t), None) => t.to_string(),
185 (None, Some(tr)) => format!("{} [{}]", complex.original, tr),
186 (None, None) => complex.original.clone(),
187 }
188 }
189
190 MultilingualMode::Pattern(segments) => resolve_multilingual_pattern(
191 segments,
192 &complex.original,
193 &complex.transliterations,
194 &complex.translations,
195 preferred_transliteration,
196 preferred_script,
197 style_locale,
198 ),
199 }
200 }
201 }
202}
203
204fn resolve_multilingual_pattern(
210 segments: &[citum_schema::options::MultilingualSegment],
211 original: &str,
212 transliterations: &std::collections::HashMap<String, String>,
213 translations: &std::collections::HashMap<citum_schema::reference::types::LangID, String>,
214 preferred_transliteration: Option<&[String]>,
215 preferred_script: Option<&String>,
216 style_locale: &str,
217) -> String {
218 use citum_schema::options::{MultilingualView, SegmentWrap};
219 let mut parts: Vec<String> = Vec::with_capacity(segments.len());
220 let mut last_text: Option<String> = None;
221
222 for seg in segments {
223 let text: Option<String> = match &seg.view {
224 MultilingualView::OriginalScript => Some(original.to_string()),
225 MultilingualView::Transliterated => resolve_transliteration(
226 transliterations,
227 preferred_transliteration,
228 preferred_script,
229 )
230 .map(ToString::to_string),
231 MultilingualView::Translated => {
232 resolve_translation(translations, style_locale).map(ToString::to_string)
233 }
234 };
235
236 let Some(text) = text else { continue };
237 if text.is_empty() {
238 continue;
239 }
240 if last_text.as_deref() == Some(text.as_str()) {
243 continue;
244 }
245
246 let wrapped = match &seg.wrap {
247 SegmentWrap::None => text.clone(),
248 other => other.apply(&text),
249 };
250 last_text = Some(text);
251 parts.push(wrapped);
252 }
253
254 parts.join(" ")
255}
256
257#[must_use]
263pub fn effective_field_language(
264 reference: &Reference,
265 scope: &str,
266 title: Option<&Title>,
267) -> Option<String> {
268 reference
269 .field_languages()
270 .get(scope)
271 .map(ToString::to_string)
272 .or_else(|| match title {
273 Some(Title::Multilingual(multilingual)) => {
274 multilingual.lang.as_ref().map(ToString::to_string)
275 }
276 _ => None,
277 })
278 .or_else(|| reference.language().map(|lang| lang.to_string()))
279}
280
281#[must_use]
283pub fn effective_item_language(reference: &Reference) -> Option<String> {
284 effective_field_language(reference, "title", reference.title().as_ref())
285}
286
287#[must_use]
289pub fn effective_component_language(
290 reference: &Reference,
291 component: &TemplateComponent,
292) -> Option<String> {
293 match component {
294 TemplateComponent::Title(title_component) => {
295 let title = match title_component.title {
296 TitleType::Primary => reference.title(),
297 TitleType::ContainerTitle => reference.container_title(),
298 TitleType::ParentMonograph => reference.container_title(),
299 TitleType::ParentSerial => reference.container_title(),
300 TitleType::CollectionTitle => reference.collection_title(),
301 _ => reference.title(),
302 };
303
304 let scope = match title_component.title {
305 TitleType::Primary => "title",
306 TitleType::ContainerTitle => "container-title",
307 TitleType::ParentMonograph => "parent-monograph.title",
308 TitleType::ParentSerial => "parent-serial.title",
309 TitleType::CollectionTitle => "collection-title",
310 _ => "title",
311 };
312
313 effective_field_language(reference, scope, title.as_ref())
314 }
315 _ => effective_item_language(reference),
316 }
317}
318
319fn select_by_transliteration<'a>(
321 m: &'a citum_schema::reference::contributor::MultilingualName,
322 preferred_transliteration: Option<&[String]>,
323 preferred_script: Option<&String>,
324) -> &'a citum_schema::reference::contributor::StructuredName {
325 if let Some(tags) = preferred_transliteration {
327 for tag in tags {
328 if let Some(name) = m.transliterations.get(tag) {
329 return name;
330 }
331 }
332 for tag in tags {
334 if let Some((_, name)) = m
335 .transliterations
336 .iter()
337 .find(|(k, _)| k.contains(tag.as_str()))
338 {
339 return name;
340 }
341 }
342 }
343 if let Some(script) = preferred_script {
345 if let Some(name) = m.transliterations.get(script) {
346 return name;
347 }
348 if let Some((_, name)) = m
350 .transliterations
351 .iter()
352 .find(|(tag, _)| tag.contains(script))
353 {
354 return name;
355 }
356 }
357 m.transliterations.values().next().unwrap_or(&m.original)
359}
360
361fn original_script_display(name: &citum_schema::reference::contributor::StructuredName) -> String {
366 use unicode_script::{Script, UnicodeScript};
367
368 let family = name.family.to_string();
369 let given = name.given.to_string();
370 let is_cjk = family.chars().chain(given.chars()).any(|ch| {
371 matches!(
372 ch.script(),
373 Script::Han | Script::Hiragana | Script::Katakana | Script::Hangul
374 )
375 });
376 if is_cjk || family.is_empty() || given.is_empty() {
377 format!("{family}{given}")
378 } else {
379 format!("{given} {family}")
380 }
381}
382
383#[must_use]
395pub fn resolve_multilingual_name(
396 contributor: &citum_schema::reference::contributor::Contributor,
397 mode: Option<&citum_schema::options::MultilingualMode>,
398 preferred_transliteration: Option<&[String]>,
399 preferred_script: Option<&String>,
400 style_locale: &str,
401) -> Vec<crate::reference::FlatName> {
402 use citum_schema::options::MultilingualMode;
403 use citum_schema::reference::contributor::Contributor;
404
405 match contributor {
406 Contributor::SimpleName(_) | Contributor::StructuredName(_) => contributor.to_names_vec(),
408
409 Contributor::Multilingual(m) => {
411 let mode = mode.unwrap_or(&MultilingualMode::Primary);
412
413 let selected_name = match mode {
414 MultilingualMode::Primary => &m.original,
415 MultilingualMode::Transliterated => {
416 select_by_transliteration(m, preferred_transliteration, preferred_script)
417 }
418 MultilingualMode::Translated => {
419 m.translations.get(style_locale).unwrap_or(&m.original)
420 }
421 MultilingualMode::Combined => {
423 select_by_transliteration(m, preferred_transliteration, preferred_script)
424 }
425 MultilingualMode::Pattern(_) => {
429 select_by_transliteration(m, preferred_transliteration, preferred_script)
430 }
431 };
432
433 let original_script = match mode {
438 MultilingualMode::Pattern(segments) if selected_name != &m.original => segments
439 .iter()
440 .find(|segment| {
441 segment.view == citum_schema::options::MultilingualView::OriginalScript
442 })
443 .map(|segment| segment.wrap.apply(&original_script_display(&m.original))),
444 _ => None,
445 };
446
447 vec![crate::reference::FlatName {
449 given: Some(selected_name.given.to_string()),
450 family: Some(selected_name.family.to_string()),
451 suffix: selected_name.suffix.clone(),
452 dropping_particle: selected_name.dropping_particle.clone(),
453 non_dropping_particle: selected_name.non_dropping_particle.clone(),
454 literal: None,
455 short_name: None,
456 original_script,
457 }]
458 }
459
460 Contributor::ContributorList(l) => {
461 l.0.iter()
462 .flat_map(|c| {
463 resolve_multilingual_name(
464 c,
465 mode,
466 preferred_transliteration,
467 preferred_script,
468 style_locale,
469 )
470 })
471 .collect()
472 }
473 }
474}
475
476#[must_use]
478pub fn resolve_url(
479 links: &citum_schema::options::LinksConfig,
480 reference: &Reference,
481) -> Option<String> {
482 use citum_schema::options::LinkTarget;
483
484 let target = links.target.as_ref().unwrap_or(&LinkTarget::UrlOrDoi);
485
486 match target {
487 LinkTarget::Url => reference.url().map(|u| u.to_string()),
488 LinkTarget::Doi => reference.doi().map(|d| format!("https://doi.org/{d}")),
489 LinkTarget::UrlOrDoi => reference
490 .url()
491 .map(|u| u.to_string())
492 .or_else(|| reference.doi().map(|d| format!("https://doi.org/{d}"))),
493 LinkTarget::Pubmed => reference
494 .id()
495 .filter(|id| id.starts_with("pmid:"))
496 .map(|id| {
497 #[allow(clippy::string_slice, reason = "known ASCII prefix")]
498 let result = format!("https://pubmed.ncbi.nlm.nih.gov/{}/", &id[5..]);
499 result
500 }),
501 LinkTarget::Pmcid => reference
502 .id()
503 .filter(|id| id.starts_with("pmc:"))
504 .map(|id| {
505 #[allow(clippy::string_slice, reason = "known ASCII prefix")]
506 let result = format!("https://www.ncbi.nlm.nih.gov/pmc/articles/{}/", &id[4..]);
507 result
508 }),
509 }
510}
511
512#[must_use]
514pub fn resolve_effective_url(
515 local_links: Option<&citum_schema::options::LinksConfig>,
516 global_links: Option<&citum_schema::options::LinksConfig>,
517 reference: &Reference,
518 component_anchor: citum_schema::options::LinkAnchor,
519) -> Option<String> {
520 use citum_schema::options::LinkAnchor;
521
522 if let Some(links) = local_links {
524 let anchor = links.anchor.as_ref().unwrap_or(&LinkAnchor::Component);
525 if matches!(anchor, LinkAnchor::Component) || *anchor == component_anchor {
526 return resolve_url(links, reference);
527 }
528 }
529
530 if let Some(links) = global_links
532 && let Some(anchor) = &links.anchor
533 && *anchor == component_anchor
534 {
535 return resolve_url(links, reference);
536 }
537
538 None
539}
540
541#[derive(Debug, Clone, Default)]
543pub struct ProcValues<T = String> {
544 pub value: T,
546 pub prefix: Option<String>,
548 pub suffix: Option<String>,
550 pub url: Option<String>,
552 pub substituted_key: Option<String>,
555 pub pre_formatted: bool,
557}
558
559#[derive(Debug, Clone, Default)]
561pub struct ProcHints {
562 pub disamb_condition: bool,
564 pub group_index: usize,
566 pub group_length: usize,
568 pub group_key: String,
570 pub expand_given_names: bool,
572 pub expand_given_names_primary_only: bool,
574 pub min_names_to_show: Option<usize>,
576 pub citation_number: Option<usize>,
578 pub citation_sub_label: Option<String>,
580 pub position: Option<citum_schema::citation::Position>,
582 pub integral_name_state: Option<citum_schema::citation::IntegralNameState>,
584 pub org_abbreviation_state: Option<citum_schema::citation::IntegralNameState>,
586 pub first_reference_note_number: Option<u32>,
589 pub suppress_disambiguation_title: bool,
593}
594
595#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
597pub enum RenderContext {
598 #[default]
599 Citation,
601 Bibliography,
603}
604
605#[derive(Clone)]
607pub struct RenderOptions<'a> {
608 pub config: &'a Config,
610 pub bibliography_config: Option<BibliographyConfig>,
612 pub locale: &'a Locale,
614 pub context: RenderContext,
616 pub mode: citum_schema::citation::CitationMode,
618 pub suppress_author: bool,
621 pub locator_raw: Option<&'a citum_schema::citation::CitationLocator>,
623 pub ref_type: Option<String>,
625 pub show_semantics: bool,
627 pub current_template_index: Option<usize>,
629 pub abbreviation_map: Option<&'a crate::api::AbbreviationMap>,
631}
632
633pub trait ComponentValues {
635 fn values<F: crate::render::format::OutputFormat<Output = String>>(
637 &self,
638 reference: &Reference,
639 hints: &ProcHints,
640 options: &RenderOptions<'_>,
641 ) -> Option<ProcValues<F::Output>>;
642}
643
644impl ComponentValues for TemplateComponent {
645 fn values<F: crate::render::format::OutputFormat<Output = String>>(
646 &self,
647 reference: &Reference,
648 hints: &ProcHints,
649 options: &RenderOptions<'_>,
650 ) -> Option<ProcValues<F::Output>> {
651 match self {
652 TemplateComponent::Contributor(c) => c.values::<F>(reference, hints, options),
653 TemplateComponent::Date(d) => d.values::<F>(reference, hints, options),
654 TemplateComponent::Title(t) => t.values::<F>(reference, hints, options),
655 TemplateComponent::Number(n) => n.values::<F>(reference, hints, options),
656 TemplateComponent::Variable(v) => v.values::<F>(reference, hints, options),
657 TemplateComponent::Message(m) => m.values::<F>(reference, hints, options),
658 TemplateComponent::Group(l) => l.values::<F>(reference, hints, options),
659 TemplateComponent::Term(t) => t.values::<F>(reference, hints, options),
660 _ => None,
661 }
662 }
663}
664
665#[must_use]
672pub fn should_strip_periods(
673 rendering: &citum_schema::template::Rendering,
674 options: &RenderOptions<'_>,
675) -> bool {
676 rendering
677 .strip_periods
678 .or(options.config.strip_periods)
679 .unwrap_or(false)
680}
681
682#[must_use]
687pub fn strip_trailing_periods(s: &str) -> String {
688 s.trim_end_matches('.').to_string()
689}
690
691#[must_use]
695pub fn apply_abbreviation(value: String, map: Option<&crate::api::AbbreviationMap>) -> String {
696 if let Some(abbr) = map.and_then(|m| m.0.get(&value)) {
697 return abbr.clone();
698 }
699 value
700}