1use crate::error::ProcessorError;
13use crate::reference::{Bibliography, Reference};
14use crate::values::{ProcHints, RenderContext, RenderOptions};
15use citum_schema::citation::CitationLocator;
16use citum_schema::locale::Locale;
17use citum_schema::options::{
18 CitationLabelMode, Config, LabelWrap, bibliography::BibliographyConfig,
19};
20use citum_schema::template::TemplateComponent;
21use grouped::component_predicates::{resolve_localized_type_variant, resolve_type_variant};
22use indexmap::IndexMap;
23use std::borrow::Cow;
24use std::cell::RefCell;
25use std::collections::{HashMap, HashSet};
26use std::sync::{Arc, OnceLock, RwLock};
27
28fn embedded_render_locales() -> &'static HashMap<String, Locale> {
29 static LOCALES: OnceLock<HashMap<String, Locale>> = OnceLock::new();
30 LOCALES.get_or_init(|| {
31 let mut locales = HashMap::new();
32 for id in citum_schema::embedded::EMBEDDED_LOCALE_IDS {
33 let Some(locale) = citum_schema::embedded::get_locale(id) else {
34 continue;
35 };
36 locales.insert(id.to_ascii_lowercase(), locale);
37 }
38 locales
39 })
40}
41
42pub(crate) fn lookup_embedded_locale(locale_id: &str) -> Option<&'static Locale> {
49 let locales = embedded_render_locales();
50 let key = locale_id.to_ascii_lowercase();
51 locales.get(&key).or_else(|| {
52 let primary = key.split(['-', '_']).next()?;
53 locales.iter().find_map(|(candidate, locale)| {
54 candidate
55 .split(['-', '_'])
56 .next()
57 .is_some_and(|candidate_primary| candidate_primary == primary)
58 .then_some(locale)
59 })
60 })
61}
62
63pub struct Renderer<'a> {
68 pub style: &'a citum_schema::Style,
70 pub bibliography: &'a Bibliography,
72 pub locale: &'a Locale,
74 pub config: Arc<Config>,
76 pub bibliography_config: Option<Arc<BibliographyConfig>>,
78 pub hints: &'a HashMap<String, ProcHints>,
80 pub citation_numbers: &'a RwLock<HashMap<String, usize>>,
86 pub compound_set_by_ref: &'a HashMap<String, String>,
88 pub compound_member_index: &'a HashMap<String, usize>,
90 pub compound_sets: &'a IndexMap<String, Vec<String>>,
92 pub show_semantics: bool,
94 pub inject_ast_indices: bool,
96 pub filtered_to_original_index: RefCell<Option<Vec<usize>>>,
98 pub abbreviation_map: Option<&'a crate::api::AbbreviationMap>,
100 pub first_note_by_id: Option<&'a RwLock<HashMap<String, u32>>>,
102}
103
104pub struct CompoundRenderData<'a> {
106 pub set_by_ref: &'a HashMap<String, String>,
108 pub member_index: &'a HashMap<String, usize>,
110 pub sets: &'a IndexMap<String, Vec<String>>,
112}
113
114mod collapse;
115mod grouped;
116mod grouped_fallback;
117mod helpers;
118mod marker;
119
120#[cfg(test)]
121#[allow(
122 clippy::unwrap_used,
123 clippy::expect_used,
124 clippy::panic,
125 clippy::indexing_slicing,
126 clippy::todo,
127 clippy::unimplemented,
128 clippy::unreachable,
129 clippy::get_unwrap,
130 reason = "Panicking is acceptable and often desired in tests."
131)]
132mod tests;
133
134pub use grouped_fallback::GroupRenderParams;
135pub use grouped_fallback::TemplateRenderParams;
136pub(super) use helpers::{
137 find_grouping_component, has_contributor_component, leading_group_affix,
138 remove_first_contributor_with_role, strip_author_component, strip_leading_group_affixes,
139};
140
141pub struct TemplateRenderRequest<'a> {
143 pub template: &'a [TemplateComponent],
145 pub context: RenderContext,
147 pub mode: citum_schema::citation::CitationMode,
149 pub suppress_author: bool,
151 pub locator_raw: Option<&'a CitationLocator>,
153 pub citation_number: usize,
155 pub position: Option<citum_schema::citation::Position>,
157 pub note_start_text_case: Option<citum_schema::NoteStartTextCase>,
159 pub integral_name_state: Option<citum_schema::citation::IntegralNameState>,
161 pub org_abbreviation_state: Option<citum_schema::citation::IntegralNameState>,
163 pub first_reference_note_number: Option<u32>,
165}
166
167struct UngroupedItemRenderState<'a> {
169 reference: &'a Reference,
170 template: Cow<'a, [TemplateComponent]>,
171 delimiter: &'a str,
172 marker: Option<marker::CitationMarkerSpec>,
174}
175
176#[derive(Debug, Clone, PartialEq, Eq)]
178pub(super) struct ResolvedMarker {
179 pub value: marker::MarkerValue,
181 pub spec: marker::CitationMarkerSpec,
183}
184
185#[derive(Debug, Clone, PartialEq, Eq)]
187pub(super) struct CitationChunk {
188 pub ids: Vec<String>,
190 pub content: String,
192 pub marker: Option<ResolvedMarker>,
196}
197
198#[derive(Clone, Copy)]
200struct UngroupedItemRenderParams<'a> {
201 mode: &'a citum_schema::citation::CitationMode,
202 suppress_author: bool,
203 position: Option<&'a citum_schema::citation::Position>,
204 note_start_text_case: Option<citum_schema::NoteStartTextCase>,
205}
206
207#[derive(Clone, Default)]
208struct TemplateComponentTracker {
209 rendered_vars: HashSet<String>,
210 substituted_bases: HashSet<String>,
211}
212
213impl TemplateComponentTracker {
214 fn should_skip(&self, var_key: Option<&str>) -> bool {
215 let Some(var_key) = var_key else {
216 return false;
217 };
218 let base = key_base(var_key);
219 self.rendered_vars.contains(var_key) || self.substituted_bases.contains(base.as_ref())
220 }
221
222 fn mark_rendered(&mut self, var_key: Option<String>, substituted_key: Option<&str>) {
223 if let Some(var_key) = var_key {
224 self.rendered_vars.insert(var_key);
225 }
226 if let Some(substituted_key) = substituted_key {
227 self.rendered_vars.insert(substituted_key.to_string());
228 self.substituted_bases
229 .insert(key_base(substituted_key).into_owned());
230 }
231 }
232
233 fn merge_from(&mut self, other: Self) {
234 self.rendered_vars.extend(other.rendered_vars);
235 self.substituted_bases.extend(other.substituted_bases);
236 }
237}
238
239pub struct RendererResources<'a> {
244 pub style: &'a citum_schema::Style,
246 pub bibliography: &'a Bibliography,
248 pub locale: &'a Locale,
250 pub config: Arc<Config>,
252 pub bibliography_config: Option<Arc<BibliographyConfig>>,
254 pub first_note_by_id: Option<&'a RwLock<HashMap<String, u32>>>,
256}
257
258impl<'a> Renderer<'a> {
259 pub fn new(
261 resources: RendererResources<'a>,
262 hints: &'a HashMap<String, ProcHints>,
263 citation_numbers: &'a RwLock<HashMap<String, usize>>,
264 compound: CompoundRenderData<'a>,
265 show_semantics: bool,
266 inject_ast_indices: bool,
267 abbreviation_map: Option<&'a crate::api::AbbreviationMap>,
268 ) -> Self {
269 Self {
270 style: resources.style,
271 bibliography: resources.bibliography,
272 locale: resources.locale,
273 config: resources.config,
274 bibliography_config: resources.bibliography_config,
275 hints,
276 citation_numbers,
277 compound_set_by_ref: compound.set_by_ref,
278 compound_member_index: compound.member_index,
279 compound_sets: compound.sets,
280 show_semantics,
281 inject_ast_indices,
282 filtered_to_original_index: RefCell::new(None),
283 abbreviation_map,
284 first_note_by_id: resources.first_note_by_id,
285 }
286 }
287
288 fn locale_for_reference(
301 &self,
302 reference: &Reference,
303 context: RenderContext,
304 ) -> Cow<'a, Locale> {
305 let language = crate::values::effective_item_language(reference);
306 let selected = match context {
307 RenderContext::Citation => self
308 .style
309 .citation
310 .as_ref()
311 .and_then(|spec| spec.resolve_localized_template(language.as_deref())),
312 RenderContext::Bibliography => self
313 .style
314 .bibliography
315 .as_ref()
316 .and_then(|spec| spec.resolve_localized_template(language.as_deref())),
317 };
318
319 if let Some(locale_id) = selected.and_then(|resolved| resolved.locale) {
320 return Cow::Borrowed(lookup_embedded_locale(&locale_id).unwrap_or(self.locale));
321 }
322
323 let term_locale_is_item = self
324 .config
325 .multilingual
326 .as_ref()
327 .is_some_and(|ml| ml.term_locale == citum_schema::options::TermLocale::Item);
328
329 if term_locale_is_item
330 && let Some(item_locale) = language.as_deref().and_then(lookup_embedded_locale)
331 {
332 return Cow::Owned(self.locale.with_term_surfaces_from(item_locale));
333 }
334
335 Cow::Borrowed(self.locale)
336 }
337
338 fn resolve_contributor_names(
340 &self,
341 contributor: &citum_schema::reference::contributor::Contributor,
342 ) -> Vec<crate::reference::FlatName> {
343 let ml = self.config.multilingual.as_ref();
344 crate::values::resolve_multilingual_name(
345 contributor,
346 ml.and_then(|m| m.name_mode.as_ref()),
347 ml.and_then(|m| m.preferred_transliteration.as_deref()),
348 ml.and_then(|m| m.preferred_script.as_ref()),
349 &self.locale.locale,
350 )
351 }
352
353 fn citation_sub_label_for_ref(&self, ref_id: &str) -> Option<String> {
356 let compound = self
357 .bibliography_config
358 .as_ref()
359 .and_then(|b| b.compound_numeric.as_ref())?;
360 let set_id = self.compound_set_by_ref.get(ref_id)?;
361 let members = self.compound_sets.get(set_id)?;
362 if members.len() <= 1 {
363 return None;
364 }
365 if !compound.subentry {
366 return None;
367 }
368 let idx = *self.compound_member_index.get(ref_id)?;
369 match compound.sub_label {
370 citum_schema::options::bibliography::SubLabelStyle::Alphabetic => {
371 crate::values::int_to_letter((idx + 1) as u32)
372 }
373 citum_schema::options::bibliography::SubLabelStyle::Numeric => {
374 Some((idx + 1).to_string())
375 }
376 }
377 }
378
379 fn citation_label_mode(&self, spec: &citum_schema::CitationSpec) -> Option<CitationLabelMode> {
381 marker::citation_label_mode(&self.config, spec)
382 }
383
384 fn wrap_citation_label_with_format<F>(
386 &self,
387 fmt: &F,
388 content: String,
389 wrap: Option<LabelWrap>,
390 ref_id: Option<&str>,
391 ) -> String
392 where
393 F: crate::render::format::OutputFormat<Output = String>,
394 {
395 let Some(wrap) = wrap else {
396 return content;
397 };
398 if wrap == LabelWrap::None {
399 return content;
400 }
401 if wrap == LabelWrap::Superscript {
402 return fmt.superscript(content);
403 }
404 let language = ref_id
405 .and_then(|id| self.bibliography.get(id))
406 .and_then(crate::values::effective_item_language);
407 let (script, realization) = crate::values::punctuation_realization_context(
408 language.as_deref(),
409 self.config.multilingual.as_ref(),
410 self.locale.punctuation_realization.as_ref(),
411 );
412 let marks = crate::render::format::QuoteMarks::from(&self.locale.grammar_options);
413 let Some(config) = wrap.as_wrap_config() else {
414 return content;
415 };
416 fmt.wrap_punctuation(
417 &config.punctuation,
418 content,
419 &marks,
420 script,
421 realization.as_deref(),
422 )
423 }
424
425 fn should_render_author_number_for_numeric_integral(
431 &self,
432 mode: &citum_schema::citation::CitationMode,
433 ) -> bool {
434 matches!(mode, citum_schema::citation::CitationMode::Integral)
435 && self.config.processing.as_ref().is_some_and(|processing| {
436 matches!(processing, citum_schema::options::Processing::Numeric)
437 })
438 && !self.has_explicit_integral_template()
439 }
440
441 fn has_explicit_integral_template(&self) -> bool {
443 self.style.citation.as_ref().is_some_and(|c| {
444 c.integral.as_ref().is_some_and(|i| {
445 i.template.is_some() || i.template_ref.is_some() || i.locales.is_some()
446 })
447 })
448 }
449
450 fn should_collapse_compound_subentries(
452 &self,
453 mode: &citum_schema::citation::CitationMode,
454 ) -> bool {
455 if !matches!(mode, citum_schema::citation::CitationMode::NonIntegral) {
456 return false;
457 }
458
459 self.bibliography_config
460 .as_ref()
461 .and_then(|b| b.compound_numeric.as_ref())
462 .is_some_and(|c| c.subentry && c.collapse_subentries)
463 }
464
465 fn should_collapse_citation_numbers(
467 &self,
468 spec: &citum_schema::CitationSpec,
469 mode: &citum_schema::citation::CitationMode,
470 ) -> bool {
471 if !matches!(mode, citum_schema::citation::CitationMode::NonIntegral) {
472 return false;
473 }
474
475 let is_numeric = self
476 .config
477 .processing
478 .as_ref()
479 .is_some_and(|p| matches!(p, citum_schema::options::Processing::Numeric));
480
481 is_numeric
482 && matches!(
483 spec.collapse,
484 Some(citum_schema::CitationCollapse::CitationNumber)
485 )
486 }
487
488 fn normalize_prefix_spacing(prefix: &str) -> String {
490 if !prefix.is_empty() && !prefix.ends_with(char::is_whitespace) {
491 format!("{prefix} ")
492 } else {
493 prefix.to_string()
494 }
495 }
496
497 fn ensure_suffix_spacing(suffix: &str) -> String {
500 if suffix.is_empty() {
501 String::new()
502 } else if suffix.starts_with(char::is_whitespace)
503 || suffix.starts_with(',')
504 || suffix.starts_with(';')
505 || suffix.starts_with('.')
506 {
507 suffix.to_string()
509 } else {
510 format!(" {suffix}")
512 }
513 }
514
515 fn wants_latin_punctuation_for_id(&self, ref_id: &str) -> bool {
524 let configured = self.config.multilingual.as_ref().is_some_and(|ml| {
525 ml.scripts.get("latin").is_some_and(|script| {
526 script.punctuation == Some(citum_schema::options::PunctuationStyle::Latin)
527 })
528 });
529
530 configured
531 && self.bibliography.get(ref_id).is_some_and(|reference| {
532 crate::values::is_latin_script_language(
533 crate::values::effective_item_language(reference).as_deref(),
534 )
535 })
536 }
537
538 fn affix_content<F>(
545 &self,
546 fmt: &F,
547 content: String,
548 prefix: Option<&str>,
549 suffix: Option<&str>,
550 ref_id: Option<&str>,
551 ) -> String
552 where
553 F: crate::render::format::OutputFormat<Output = String>,
554 {
555 let prefix = prefix.unwrap_or("");
556 let suffix = suffix.unwrap_or("");
557 let affixed = if prefix.is_empty() && suffix.is_empty() {
558 content
559 } else {
560 fmt.affix(
561 &Self::normalize_prefix_spacing(prefix),
562 content,
563 &Self::ensure_suffix_spacing(suffix),
564 )
565 };
566
567 if ref_id.is_some_and(|id| self.wants_latin_punctuation_for_id(id)) {
568 crate::render::component::remap_to_latin_punctuation(affixed)
569 } else {
570 affixed
571 }
572 }
573
574 fn present_bibliography_marker_with_format<F>(
578 &self,
579 fmt: &F,
580 spec: &marker::BibliographyMarkerSpec,
581 value: &marker::MarkerValue,
582 ref_id: Option<&str>,
583 ) -> String
584 where
585 F: crate::render::format::OutputFormat<Output = String>,
586 {
587 let text = fmt.text(&value.as_localized_text(&self.locale.number_formats.digit_system));
588 let wrapped = match spec.wrap {
589 Some(wrap) => self.wrap_bibliography_label_with_format(fmt, text, wrap, ref_id),
590 None => text,
591 };
592 let suffix = spec
593 .wrap
594 .and_then(citum_schema::options::BibliographyLabelWrap::as_suffix)
595 .unwrap_or_default();
596 format!("{wrapped}{suffix}{}", spec.separator)
597 }
598
599 fn wrap_bibliography_label_with_format<F>(
602 &self,
603 fmt: &F,
604 content: String,
605 wrap: citum_schema::options::BibliographyLabelWrap,
606 ref_id: Option<&str>,
607 ) -> String
608 where
609 F: crate::render::format::OutputFormat<Output = String>,
610 {
611 let Some(config) = wrap.as_wrap_config() else {
612 return content;
613 };
614 let language = ref_id
615 .and_then(|id| self.bibliography.get(id))
616 .and_then(crate::values::effective_item_language);
617 let (script, realization) = crate::values::punctuation_realization_context(
618 language.as_deref(),
619 self.config.multilingual.as_ref(),
620 self.locale.punctuation_realization.as_ref(),
621 );
622 let marks = crate::render::format::QuoteMarks::from(&self.locale.grammar_options);
623 fmt.wrap_punctuation(
624 &config.punctuation,
625 content,
626 &marks,
627 script,
628 realization.as_deref(),
629 )
630 }
631
632 fn present_marker_with_format<F>(
634 &self,
635 fmt: &F,
636 resolved: &ResolvedMarker,
637 ref_id: Option<&str>,
638 ) -> String
639 where
640 F: crate::render::format::OutputFormat<Output = String>,
641 {
642 let text = fmt.text(
643 &resolved
644 .value
645 .as_localized_text(&self.locale.number_formats.digit_system),
646 );
647 self.wrap_citation_label_with_format(fmt, text, resolved.spec.label_wrap, ref_id)
648 }
649
650 fn compose_marker_with_body(
652 marker: String,
653 body: String,
654 placement: marker::MarkerPlacement,
655 delimiter: &str,
656 ) -> String {
657 if body.is_empty() {
658 return marker;
659 }
660 match placement {
661 marker::MarkerPlacement::Leading => format!("{marker}{delimiter}{body}"),
662 marker::MarkerPlacement::Trailing => format!("{body}{delimiter}{marker}"),
663 }
664 }
665
666 #[allow(
673 clippy::too_many_arguments,
674 reason = "chunk assembly keeps rendering affixes and marker metadata together"
675 )]
676 fn build_citation_chunk<F>(
677 &self,
678 fmt: &F,
679 ids: Vec<String>,
680 body: String,
681 prefix: Option<&str>,
682 suffix: Option<&str>,
683 resolved: Option<ResolvedMarker>,
684 delimiter: &str,
685 ) -> Option<CitationChunk>
686 where
687 F: crate::render::format::OutputFormat<Output = String>,
688 {
689 let ref_id = ids.first().cloned();
690 let ref_id = ref_id.as_deref();
691 let collapsible = body.is_empty()
692 && prefix.is_none()
693 && suffix.is_none()
694 && resolved
695 .as_ref()
696 .is_some_and(|resolved| resolved.value.is_numeric());
697
698 if collapsible {
699 return Some(CitationChunk {
700 ids,
701 content: String::new(),
702 marker: resolved,
703 });
704 }
705
706 let content = match &resolved {
707 Some(resolved) => {
708 let marker_text = self.present_marker_with_format(fmt, resolved, ref_id);
709 let composed = Self::compose_marker_with_body(
710 marker_text,
711 body,
712 resolved.spec.placement,
713 delimiter,
714 );
715 self.wrap_citation_label_with_format(fmt, composed, resolved.spec.item_wrap, ref_id)
716 }
717 None => body,
718 };
719 if content.is_empty() {
720 return None;
721 }
722 Some(CitationChunk {
723 ids,
724 content: self.affix_content(fmt, content, prefix, suffix, ref_id),
725 marker: None,
726 })
727 }
728
729 fn build_item_chunk<F>(
731 &self,
732 fmt: &F,
733 item: &crate::reference::CitationItem,
734 reference: &Reference,
735 body: String,
736 spec: Option<marker::CitationMarkerSpec>,
737 delimiter: &str,
738 ) -> Option<CitationChunk>
739 where
740 F: crate::render::format::OutputFormat<Output = String>,
741 {
742 let resolved = spec.and_then(|spec| {
743 marker::marker_value(
744 spec.kind,
745 &self.config,
746 reference,
747 Some(self.get_or_assign_citation_number(&item.id)),
748 self.citation_sub_label_for_ref(&item.id),
749 self.hints.get(&item.id),
750 )
751 .map(|value| ResolvedMarker { value, spec })
752 });
753 self.build_citation_chunk(
754 fmt,
755 vec![item.id.clone()],
756 body,
757 item.prefix.as_deref(),
758 item.suffix.as_deref(),
759 resolved,
760 delimiter,
761 )
762 }
763
764 fn citation_render_request<'b>(
766 &self,
767 item: &'b crate::reference::CitationItem,
768 template: &'b [TemplateComponent],
769 mode: &citum_schema::citation::CitationMode,
770 suppress_author: bool,
771 position: Option<&citum_schema::citation::Position>,
772 note_start_text_case: Option<citum_schema::NoteStartTextCase>,
773 ) -> TemplateRenderRequest<'b> {
774 TemplateRenderRequest {
775 template,
776 context: RenderContext::Citation,
777 mode: mode.clone(),
778 suppress_author,
779 locator_raw: item.locator.as_ref(),
780 citation_number: self.get_or_assign_citation_number(&item.id),
781 position: position.cloned(),
782 note_start_text_case,
783 integral_name_state: item.integral_name_state,
784 org_abbreviation_state: item.org_abbreviation_state,
785 first_reference_note_number: self.first_note_by_id.as_ref().and_then(|m| {
786 m.read()
787 .unwrap_or_else(std::sync::PoisonError::into_inner)
788 .get(&item.id)
789 .copied()
790 }),
791 }
792 }
793
794 fn render_item_from_template_with_format<F>(
796 &self,
797 reference: &Reference,
798 request: TemplateRenderRequest<'_>,
799 delimiter: &str,
800 ) -> Option<String>
801 where
802 F: crate::render::format::OutputFormat<Output = String>,
803 {
804 self.process_template_request_with_format::<F>(reference, request)
805 .map(|proc| {
806 crate::render::citation::citation_to_string_with_format::<F>(
807 &proc,
808 None,
809 None,
810 None,
811 Some(delimiter),
812 )
813 })
814 }
815
816 fn resolve_ungrouped_item_render_state<'b>(
819 &'b self,
820 item: &'b crate::reference::CitationItem,
821 spec: &'b citum_schema::CitationSpec,
822 mode: &'b citum_schema::citation::CitationMode,
823 intra_delimiter: &'b str,
824 ) -> Result<UngroupedItemRenderState<'b>, ProcessorError> {
825 let reference = self
826 .bibliography
827 .get(&item.id)
828 .ok_or_else(|| ProcessorError::ReferenceNotFound(item.id.clone()))?;
829 let ref_type = reference.ref_type();
830 let item_language = crate::values::effective_item_language(reference);
831 let localized = spec.resolve_localized_template(item_language.as_deref());
832 let template = localized
833 .as_ref()
834 .filter(|resolved| resolved.type_variants.is_some())
835 .cloned()
836 .map(|resolved| {
837 Cow::Owned(resolve_localized_type_variant(
838 resolved,
839 spec.type_variants.as_ref(),
840 &ref_type,
841 ))
842 })
843 .or_else(|| {
844 resolve_type_variant(spec.type_variants.as_ref(), &ref_type).map(Cow::Borrowed)
845 })
846 .or_else(|| localized.map(|resolved| Cow::Owned(resolved.template)))
847 .unwrap_or(Cow::Borrowed(&[] as &[TemplateComponent]));
848
849 Ok(UngroupedItemRenderState {
850 reference,
851 template,
852 delimiter: intra_delimiter,
853 marker: marker::resolve_citation_marker(&self.config, spec, mode),
854 })
855 }
856
857 fn citation_render_options<'b>(
862 &'b self,
863 locale: &'b Locale,
864 mode: citum_schema::citation::CitationMode,
865 suppress_author: bool,
866 locator_raw: Option<&'b CitationLocator>,
867 ref_type: Option<String>,
868 ) -> RenderOptions<'b> {
869 RenderOptions {
870 config: self.config.clone(),
871 bibliography_config: self.bibliography_config.clone(),
872 locale,
873 context: RenderContext::Citation,
874 mode,
875 suppress_author,
876 locator_raw,
877 ref_type,
878 show_semantics: self.show_semantics,
879 current_template_index: None,
880 abbreviation_map: self.abbreviation_map,
881 }
882 }
883
884 fn render_author_number_for_numeric_integral_with_format<F>(
888 &self,
889 fmt: &F,
890 reference: &Reference,
891 item: &crate::reference::CitationItem,
892 citation_number: usize,
893 label_wrap: Option<LabelWrap>,
894 ) -> String
895 where
896 F: crate::render::format::OutputFormat<Output = String>,
897 {
898 let locale = self.locale_for_reference(reference, RenderContext::Citation);
899 let options = self.citation_render_options(
900 locale.as_ref(),
901 citum_schema::citation::CitationMode::Integral,
902 false,
903 item.locator.as_ref(),
904 Some(reference.ref_type()),
905 );
906
907 let author_part = if let Some(authors) = reference.author() {
909 let names_vec = self.resolve_contributor_names(&authors);
910 fmt.text(&crate::values::format_contributors_short(
911 &names_vec, &options,
912 ))
913 } else {
914 String::new()
915 };
916
917 let ref_id = reference.id().unwrap_or_default().to_string();
919 let sub_label = self.citation_sub_label_for_ref(&ref_id).unwrap_or_default();
920
921 let raw_label = format!("{citation_number}{sub_label}");
922 let label = match label_wrap {
923 Some(wrap) => self.wrap_citation_label_with_format::<F>(
924 fmt,
925 raw_label,
926 Some(wrap),
927 Some(&item.id),
928 ),
929 None => format!("[{raw_label}]"),
930 };
931
932 if author_part.is_empty() {
934 label
936 } else {
937 format!("{author_part} {label}")
938 }
939 }
940
941 fn render_numeric_integral_item_chunk_with_format<F>(
943 &self,
944 fmt: &F,
945 item: &crate::reference::CitationItem,
946 label_wrap: Option<LabelWrap>,
947 ) -> Result<Option<CitationChunk>, ProcessorError>
948 where
949 F: crate::render::format::OutputFormat<Output = String>,
950 {
951 let reference = self
952 .bibliography
953 .get(&item.id)
954 .ok_or_else(|| ProcessorError::ReferenceNotFound(item.id.clone()))?;
955 let citation_number = self.get_or_assign_citation_number(&item.id);
956 let item_str = self.render_author_number_for_numeric_integral_with_format::<F>(
957 fmt,
958 reference,
959 item,
960 citation_number,
961 label_wrap,
962 );
963 Ok(self.build_item_chunk(fmt, item, reference, item_str, None, ""))
964 }
965
966 fn render_template_item_chunk_with_format<F>(
968 &self,
969 fmt: &F,
970 item: &crate::reference::CitationItem,
971 state: UngroupedItemRenderState<'_>,
972 params: UngroupedItemRenderParams<'_>,
973 ) -> Option<CitationChunk>
974 where
975 F: crate::render::format::OutputFormat<Output = String>,
976 {
977 let request = self.citation_render_request(
978 item,
979 &state.template,
980 params.mode,
981 params.suppress_author,
982 params.position,
983 params.note_start_text_case,
984 );
985 let body = self
988 .render_item_from_template_with_format::<F>(state.reference, request, state.delimiter)
989 .unwrap_or_default();
990 if body.is_empty() && state.marker.is_none() {
991 return None;
992 }
993 self.build_item_chunk(
994 fmt,
995 item,
996 state.reference,
997 body,
998 state.marker,
999 state.delimiter,
1000 )
1001 }
1002
1003 pub fn render_ungrouped_citation(
1010 &self,
1011 items: &[crate::reference::CitationItem],
1012 spec: &citum_schema::CitationSpec,
1013 mode: &citum_schema::citation::CitationMode,
1014 intra_delimiter: &str,
1015 suppress_author: bool,
1016 position: Option<&citum_schema::citation::Position>,
1017 ) -> Result<Vec<String>, ProcessorError> {
1018 self.render_ungrouped_citation_with_format::<crate::render::plain::PlainText>(
1019 items,
1020 spec,
1021 mode,
1022 intra_delimiter,
1023 suppress_author,
1024 position,
1025 spec.note_start_text_case,
1026 )
1027 }
1028
1029 #[allow(
1039 clippy::too_many_arguments,
1040 reason = "Ungrouped citation rendering now needs explicit note-start context."
1041 )]
1042 pub fn render_ungrouped_citation_with_format<F>(
1043 &self,
1044 items: &[crate::reference::CitationItem],
1045 spec: &citum_schema::CitationSpec,
1046 mode: &citum_schema::citation::CitationMode,
1047 intra_delimiter: &str,
1048 suppress_author: bool,
1049 position: Option<&citum_schema::citation::Position>,
1050 note_start_text_case: Option<citum_schema::NoteStartTextCase>,
1051 ) -> Result<Vec<String>, ProcessorError>
1052 where
1053 F: crate::render::format::OutputFormat<Output = String>,
1054 {
1055 let fmt = F::default();
1056 let mut chunks: Vec<CitationChunk> = Vec::new();
1057
1058 let use_author_number = self.should_render_author_number_for_numeric_integral(mode)
1060 && self.citation_label_mode(spec) != Some(CitationLabelMode::None);
1061 let params = UngroupedItemRenderParams {
1062 mode,
1063 suppress_author,
1064 position,
1065 note_start_text_case,
1066 };
1067
1068 for item in items {
1069 let chunk = if use_author_number {
1070 self.render_numeric_integral_item_chunk_with_format::<F>(
1071 &fmt,
1072 item,
1073 spec.options.as_ref().and_then(|options| options.label_wrap),
1074 )?
1075 } else {
1076 let state =
1077 self.resolve_ungrouped_item_render_state(item, spec, mode, intra_delimiter)?;
1078 self.render_template_item_chunk_with_format::<F>(&fmt, item, state, params)
1079 };
1080
1081 if let Some(chunk) = chunk {
1082 chunks.push(chunk);
1083 }
1084 }
1085
1086 if self.should_collapse_compound_subentries(mode) {
1087 chunks = self.collapse_compound_citation_chunks(chunks);
1088 }
1089 if self.should_collapse_citation_numbers(spec, mode) {
1090 chunks = self.collapse_numeric_citation_chunks(chunks);
1091 }
1092
1093 Ok(chunks
1094 .into_iter()
1095 .map(|chunk| {
1096 let ref_id = chunk.ids.first().map(String::as_str);
1097 let content = match &chunk.marker {
1102 Some(resolved) => {
1103 let marker_text = self.present_marker_with_format(&fmt, resolved, ref_id);
1104 self.wrap_citation_label_with_format(
1105 &fmt,
1106 marker_text,
1107 resolved.spec.item_wrap,
1108 ref_id,
1109 )
1110 }
1111 None => chunk.content,
1112 };
1113 fmt.citation(chunk.ids, content)
1114 })
1115 .collect())
1116 }
1117}
1118
1119fn key_base(key: &str) -> Cow<'_, str> {
1120 let mut parts = key.splitn(3, ':');
1121 match (parts.next(), parts.next()) {
1122 (Some(kind), Some(var)) => Cow::Owned(format!("{kind}:{var}")),
1123 _ => Cow::Borrowed(key),
1124 }
1125}
1126
1127#[must_use]
1134pub fn get_variable_key(component: &TemplateComponent) -> Option<String> {
1135 use citum_schema::template::Rendering;
1136 use std::fmt::Write;
1137
1138 fn push_context_suffix(key: &mut String, rendering: &Rendering) {
1139 match (&rendering.prefix, &rendering.suffix) {
1140 (Some(prefix), Some(suffix)) => {
1141 key.push(':');
1142 key.push_str(prefix);
1143 key.push('_');
1144 key.push_str(suffix);
1145 }
1146 (Some(prefix), None) => {
1147 key.push(':');
1148 key.push_str(prefix);
1149 }
1150 (None, Some(suffix)) => {
1151 key.push(':');
1152 key.push_str(suffix);
1153 }
1154 (None, None) => {}
1155 }
1156 }
1157
1158 fn make_key(kind: &str, value: impl std::fmt::Debug, rendering: &Rendering) -> Option<String> {
1159 let mut key = String::new();
1160 write!(&mut key, "{kind}:{value:?}").ok()?;
1161 push_context_suffix(&mut key, rendering);
1162 Some(key)
1163 }
1164
1165 match component {
1166 TemplateComponent::Contributor(c) => c.contributor.as_single().map_or_else(
1167 || make_key("contributor", &c.contributor, &c.rendering),
1168 |role| make_key("contributor", role, &c.rendering),
1169 ),
1170 TemplateComponent::Date(_) => None,
1176 TemplateComponent::Variable(v) => make_key("variable", &v.variable, &v.rendering),
1177 TemplateComponent::Title(t) => {
1178 let mut key = format!("title:{:?}", t.title);
1179 if let Some(form) = &t.form {
1180 write!(&mut key, ":{form:?}").ok()?;
1181 }
1182 push_context_suffix(&mut key, &t.rendering);
1183 Some(key)
1184 }
1185 TemplateComponent::Number(n) => make_key("number", &n.number, &n.rendering),
1186 TemplateComponent::Identifier(i) => make_key("identifier", &i.identifier, &i.rendering),
1187 TemplateComponent::Group(_) => None,
1188 _ => None,
1189 }
1190}