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::{Config, bibliography::BibliographyConfig};
18use citum_schema::template::TemplateComponent;
19use grouped::component_predicates::resolve_type_variant;
20use indexmap::IndexMap;
21use std::borrow::Cow;
22use std::cell::RefCell;
23use std::collections::{HashMap, HashSet};
24use std::sync::{Arc, RwLock};
25
26pub struct Renderer<'a> {
31 pub style: &'a citum_schema::Style,
33 pub bibliography: &'a Bibliography,
35 pub locale: &'a Locale,
37 pub config: Arc<Config>,
39 pub bibliography_config: Option<Arc<BibliographyConfig>>,
41 pub hints: &'a HashMap<String, ProcHints>,
43 pub citation_numbers: &'a RwLock<HashMap<String, usize>>,
49 pub compound_set_by_ref: &'a HashMap<String, String>,
51 pub compound_member_index: &'a HashMap<String, usize>,
53 pub compound_sets: &'a IndexMap<String, Vec<String>>,
55 pub show_semantics: bool,
57 pub inject_ast_indices: bool,
59 pub filtered_to_original_index: RefCell<Option<Vec<usize>>>,
61 pub abbreviation_map: Option<&'a crate::api::AbbreviationMap>,
63 pub first_note_by_id: Option<&'a RwLock<HashMap<String, u32>>>,
65}
66
67pub struct CompoundRenderData<'a> {
69 pub set_by_ref: &'a HashMap<String, String>,
71 pub member_index: &'a HashMap<String, usize>,
73 pub sets: &'a IndexMap<String, Vec<String>>,
75}
76
77mod collapse;
78mod grouped;
79mod grouped_fallback;
80mod helpers;
81
82#[cfg(test)]
83#[allow(
84 clippy::unwrap_used,
85 clippy::expect_used,
86 clippy::panic,
87 clippy::indexing_slicing,
88 clippy::todo,
89 clippy::unimplemented,
90 clippy::unreachable,
91 clippy::get_unwrap,
92 reason = "Panicking is acceptable and often desired in tests."
93)]
94mod tests;
95
96pub use grouped_fallback::GroupRenderParams;
97pub use grouped_fallback::TemplateRenderParams;
98pub(super) use helpers::{
99 find_grouping_component, has_contributor_component, leading_group_affix,
100 remove_first_contributor_with_role, strip_author_component, strip_leading_group_affixes,
101};
102
103pub struct TemplateRenderRequest<'a> {
105 pub template: &'a [TemplateComponent],
107 pub context: RenderContext,
109 pub mode: citum_schema::citation::CitationMode,
111 pub suppress_author: bool,
113 pub locator_raw: Option<&'a CitationLocator>,
115 pub citation_number: usize,
117 pub position: Option<citum_schema::citation::Position>,
119 pub note_start_text_case: Option<citum_schema::NoteStartTextCase>,
121 pub integral_name_state: Option<citum_schema::citation::IntegralNameState>,
123 pub org_abbreviation_state: Option<citum_schema::citation::IntegralNameState>,
125 pub first_reference_note_number: Option<u32>,
127}
128
129struct UngroupedItemRenderState<'a> {
131 reference: &'a Reference,
132 template: Cow<'a, [TemplateComponent]>,
133 delimiter: &'a str,
134}
135
136#[derive(Clone, Copy)]
138struct UngroupedItemRenderParams<'a> {
139 mode: &'a citum_schema::citation::CitationMode,
140 suppress_author: bool,
141 position: Option<&'a citum_schema::citation::Position>,
142 note_start_text_case: Option<citum_schema::NoteStartTextCase>,
143}
144
145#[derive(Clone, Default)]
146struct TemplateComponentTracker {
147 rendered_vars: HashSet<String>,
148 substituted_bases: HashSet<String>,
149}
150
151impl TemplateComponentTracker {
152 fn should_skip(&self, var_key: Option<&str>) -> bool {
153 let Some(var_key) = var_key else {
154 return false;
155 };
156 let base = key_base(var_key);
157 self.rendered_vars.contains(var_key) || self.substituted_bases.contains(base.as_ref())
158 }
159
160 fn mark_rendered(&mut self, var_key: Option<String>, substituted_key: Option<&str>) {
161 if let Some(var_key) = var_key {
162 self.rendered_vars.insert(var_key);
163 }
164 if let Some(substituted_key) = substituted_key {
165 self.rendered_vars.insert(substituted_key.to_string());
166 self.substituted_bases
167 .insert(key_base(substituted_key).into_owned());
168 }
169 }
170
171 fn merge_from(&mut self, other: Self) {
172 self.rendered_vars.extend(other.rendered_vars);
173 self.substituted_bases.extend(other.substituted_bases);
174 }
175}
176
177pub struct RendererResources<'a> {
182 pub style: &'a citum_schema::Style,
184 pub bibliography: &'a Bibliography,
186 pub locale: &'a Locale,
188 pub config: Arc<Config>,
190 pub bibliography_config: Option<Arc<BibliographyConfig>>,
192 pub first_note_by_id: Option<&'a RwLock<HashMap<String, u32>>>,
194}
195
196impl<'a> Renderer<'a> {
197 pub fn new(
199 resources: RendererResources<'a>,
200 hints: &'a HashMap<String, ProcHints>,
201 citation_numbers: &'a RwLock<HashMap<String, usize>>,
202 compound: CompoundRenderData<'a>,
203 show_semantics: bool,
204 inject_ast_indices: bool,
205 abbreviation_map: Option<&'a crate::api::AbbreviationMap>,
206 ) -> Self {
207 Self {
208 style: resources.style,
209 bibliography: resources.bibliography,
210 locale: resources.locale,
211 config: resources.config,
212 bibliography_config: resources.bibliography_config,
213 hints,
214 citation_numbers,
215 compound_set_by_ref: compound.set_by_ref,
216 compound_member_index: compound.member_index,
217 compound_sets: compound.sets,
218 show_semantics,
219 inject_ast_indices,
220 filtered_to_original_index: RefCell::new(None),
221 abbreviation_map,
222 first_note_by_id: resources.first_note_by_id,
223 }
224 }
225
226 fn resolve_contributor_names(
228 &self,
229 contributor: &citum_schema::reference::contributor::Contributor,
230 ) -> Vec<crate::reference::FlatName> {
231 let ml = self.config.multilingual.as_ref();
232 crate::values::resolve_multilingual_name(
233 contributor,
234 ml.and_then(|m| m.name_mode.as_ref()),
235 ml.and_then(|m| m.preferred_transliteration.as_deref()),
236 ml.and_then(|m| m.preferred_script.as_ref()),
237 &self.locale.locale,
238 )
239 }
240
241 fn citation_sub_label_for_ref(&self, ref_id: &str) -> Option<String> {
244 let compound = self
245 .bibliography_config
246 .as_ref()
247 .and_then(|b| b.compound_numeric.as_ref())?;
248 let set_id = self.compound_set_by_ref.get(ref_id)?;
249 let members = self.compound_sets.get(set_id)?;
250 if members.len() <= 1 {
251 return None;
252 }
253 if !compound.subentry {
254 return None;
255 }
256 let idx = *self.compound_member_index.get(ref_id)?;
257 match compound.sub_label {
258 citum_schema::options::bibliography::SubLabelStyle::Alphabetic => {
259 crate::values::int_to_letter((idx + 1) as u32)
260 }
261 citum_schema::options::bibliography::SubLabelStyle::Numeric => {
262 Some((idx + 1).to_string())
263 }
264 }
265 }
266
267 fn should_render_author_number_for_numeric_integral(
273 &self,
274 mode: &citum_schema::citation::CitationMode,
275 ) -> bool {
276 matches!(mode, citum_schema::citation::CitationMode::Integral)
277 && self.config.processing.as_ref().is_some_and(|processing| {
278 matches!(processing, citum_schema::options::Processing::Numeric)
279 })
280 && !self.has_explicit_integral_template()
281 }
282
283 fn has_explicit_integral_template(&self) -> bool {
285 self.style.citation.as_ref().is_some_and(|c| {
286 c.integral.as_ref().is_some_and(|i| {
287 i.template.is_some() || i.template_ref.is_some() || i.locales.is_some()
288 })
289 })
290 }
291
292 fn should_collapse_compound_subentries(
294 &self,
295 mode: &citum_schema::citation::CitationMode,
296 ) -> bool {
297 if !matches!(mode, citum_schema::citation::CitationMode::NonIntegral) {
298 return false;
299 }
300
301 self.bibliography_config
302 .as_ref()
303 .and_then(|b| b.compound_numeric.as_ref())
304 .is_some_and(|c| c.subentry && c.collapse_subentries)
305 }
306
307 fn should_collapse_citation_numbers(
309 &self,
310 spec: &citum_schema::CitationSpec,
311 mode: &citum_schema::citation::CitationMode,
312 ) -> bool {
313 if !matches!(mode, citum_schema::citation::CitationMode::NonIntegral) {
314 return false;
315 }
316
317 let is_numeric = self
318 .config
319 .processing
320 .as_ref()
321 .is_some_and(|p| matches!(p, citum_schema::options::Processing::Numeric));
322
323 is_numeric
324 && matches!(
325 spec.collapse,
326 Some(citum_schema::CitationCollapse::CitationNumber)
327 )
328 }
329
330 fn normalize_prefix_spacing(prefix: &str) -> String {
332 if !prefix.is_empty() && !prefix.ends_with(char::is_whitespace) {
333 format!("{prefix} ")
334 } else {
335 prefix.to_string()
336 }
337 }
338
339 fn ensure_suffix_spacing(suffix: &str) -> String {
342 if suffix.is_empty() {
343 String::new()
344 } else if suffix.starts_with(char::is_whitespace)
345 || suffix.starts_with(',')
346 || suffix.starts_with(';')
347 || suffix.starts_with('.')
348 {
349 suffix.to_string()
351 } else {
352 format!(" {suffix}")
354 }
355 }
356
357 fn affix_content<F>(
359 &self,
360 fmt: &F,
361 content: String,
362 prefix: Option<&str>,
363 suffix: Option<&str>,
364 ) -> String
365 where
366 F: crate::render::format::OutputFormat<Output = String>,
367 {
368 let prefix = prefix.unwrap_or("");
369 let suffix = suffix.unwrap_or("");
370 if prefix.is_empty() && suffix.is_empty() {
371 content
372 } else {
373 fmt.affix(
374 &Self::normalize_prefix_spacing(prefix),
375 content,
376 &Self::ensure_suffix_spacing(suffix),
377 )
378 }
379 }
380
381 fn build_citation_chunk<F>(
383 &self,
384 fmt: &F,
385 ids: Vec<String>,
386 content: String,
387 prefix: Option<&str>,
388 suffix: Option<&str>,
389 ) -> Option<(Vec<String>, String)>
390 where
391 F: crate::render::format::OutputFormat<Output = String>,
392 {
393 if content.is_empty() {
394 None
395 } else {
396 Some((ids, self.affix_content(fmt, content, prefix, suffix)))
397 }
398 }
399
400 fn build_item_chunk<F>(
402 &self,
403 fmt: &F,
404 item: &crate::reference::CitationItem,
405 content: String,
406 ) -> Option<(Vec<String>, String)>
407 where
408 F: crate::render::format::OutputFormat<Output = String>,
409 {
410 self.build_citation_chunk(
411 fmt,
412 vec![item.id.clone()],
413 content,
414 item.prefix.as_deref(),
415 item.suffix.as_deref(),
416 )
417 }
418
419 fn citation_render_request<'b>(
421 &self,
422 item: &'b crate::reference::CitationItem,
423 template: &'b [TemplateComponent],
424 mode: &citum_schema::citation::CitationMode,
425 suppress_author: bool,
426 position: Option<&citum_schema::citation::Position>,
427 note_start_text_case: Option<citum_schema::NoteStartTextCase>,
428 ) -> TemplateRenderRequest<'b> {
429 TemplateRenderRequest {
430 template,
431 context: RenderContext::Citation,
432 mode: mode.clone(),
433 suppress_author,
434 locator_raw: item.locator.as_ref(),
435 citation_number: self.get_or_assign_citation_number(&item.id),
436 position: position.cloned(),
437 note_start_text_case,
438 integral_name_state: item.integral_name_state,
439 org_abbreviation_state: item.org_abbreviation_state,
440 first_reference_note_number: self.first_note_by_id.as_ref().and_then(|m| {
441 m.read()
442 .unwrap_or_else(std::sync::PoisonError::into_inner)
443 .get(&item.id)
444 .copied()
445 }),
446 }
447 }
448
449 fn render_item_from_template_with_format<F>(
451 &self,
452 reference: &Reference,
453 request: TemplateRenderRequest<'_>,
454 delimiter: &str,
455 ) -> Option<String>
456 where
457 F: crate::render::format::OutputFormat<Output = String>,
458 {
459 self.process_template_request_with_format::<F>(reference, request)
460 .map(|proc| {
461 crate::render::citation::citation_to_string_with_format::<F>(
462 &proc,
463 None,
464 None,
465 None,
466 Some(delimiter),
467 )
468 })
469 }
470
471 fn resolve_ungrouped_item_render_state<'b>(
474 &'b self,
475 item: &'b crate::reference::CitationItem,
476 spec: &'b citum_schema::CitationSpec,
477 intra_delimiter: &'b str,
478 ) -> Result<UngroupedItemRenderState<'b>, ProcessorError> {
479 let reference = self
480 .bibliography
481 .get(&item.id)
482 .ok_or_else(|| ProcessorError::ReferenceNotFound(item.id.clone()))?;
483 let ref_type = reference.ref_type();
484 let item_language = crate::values::effective_item_language(reference);
485 let template = resolve_type_variant(spec.type_variants.as_ref(), &ref_type)
486 .map(Cow::Borrowed)
487 .or_else(|| {
488 spec.resolve_template_for_language(item_language.as_deref())
489 .map(Cow::Owned)
490 })
491 .unwrap_or(Cow::Borrowed(&[]));
492
493 Ok(UngroupedItemRenderState {
494 reference,
495 template,
496 delimiter: spec.delimiter.as_deref().unwrap_or(intra_delimiter),
497 })
498 }
499
500 fn citation_render_options<'b>(
502 &'b self,
503 mode: citum_schema::citation::CitationMode,
504 suppress_author: bool,
505 locator_raw: Option<&'b CitationLocator>,
506 ref_type: Option<String>,
507 ) -> RenderOptions<'b> {
508 RenderOptions {
509 config: self.config.clone(),
510 bibliography_config: self.bibliography_config.clone(),
511 locale: self.locale,
512 context: RenderContext::Citation,
513 mode,
514 suppress_author,
515 locator_raw,
516 ref_type,
517 show_semantics: self.show_semantics,
518 current_template_index: None,
519 abbreviation_map: self.abbreviation_map,
520 }
521 }
522
523 fn render_author_number_for_numeric_integral_with_format<F>(
527 &self,
528 reference: &Reference,
529 item: &crate::reference::CitationItem,
530 citation_number: usize,
531 ) -> String
532 where
533 F: crate::render::format::OutputFormat<Output = String>,
534 {
535 let fmt = F::default();
536 let options = self.citation_render_options(
537 citum_schema::citation::CitationMode::Integral,
538 false,
539 item.locator.as_ref(),
540 Some(reference.ref_type()),
541 );
542
543 let author_part = if let Some(authors) = reference.author() {
545 let names_vec = self.resolve_contributor_names(&authors);
546 fmt.text(&crate::values::format_contributors_short(
547 &names_vec, &options,
548 ))
549 } else {
550 String::new()
551 };
552
553 let ref_id = reference.id().unwrap_or_default().to_string();
555 let sub_label = self.citation_sub_label_for_ref(&ref_id).unwrap_or_default();
556
557 if author_part.is_empty() {
559 format!("[{citation_number}{sub_label}]")
561 } else {
562 format!("{author_part} [{citation_number}{sub_label}]")
563 }
564 }
565
566 fn render_numeric_integral_item_chunk_with_format<F>(
568 &self,
569 fmt: &F,
570 item: &crate::reference::CitationItem,
571 ) -> Result<Option<(Vec<String>, String)>, ProcessorError>
572 where
573 F: crate::render::format::OutputFormat<Output = String>,
574 {
575 let reference = self
576 .bibliography
577 .get(&item.id)
578 .ok_or_else(|| ProcessorError::ReferenceNotFound(item.id.clone()))?;
579 let citation_number = self.get_or_assign_citation_number(&item.id);
580 let item_str = self.render_author_number_for_numeric_integral_with_format::<F>(
581 reference,
582 item,
583 citation_number,
584 );
585 Ok(self.build_item_chunk(fmt, item, item_str))
586 }
587
588 fn render_template_item_chunk_with_format<F>(
590 &self,
591 fmt: &F,
592 item: &crate::reference::CitationItem,
593 state: UngroupedItemRenderState<'_>,
594 params: UngroupedItemRenderParams<'_>,
595 ) -> Option<(Vec<String>, String)>
596 where
597 F: crate::render::format::OutputFormat<Output = String>,
598 {
599 let request = self.citation_render_request(
600 item,
601 &state.template,
602 params.mode,
603 params.suppress_author,
604 params.position,
605 params.note_start_text_case,
606 );
607 self.render_item_from_template_with_format::<F>(state.reference, request, state.delimiter)
608 .and_then(|item_str| self.build_item_chunk(fmt, item, item_str))
609 }
610
611 pub fn render_ungrouped_citation(
618 &self,
619 items: &[crate::reference::CitationItem],
620 spec: &citum_schema::CitationSpec,
621 mode: &citum_schema::citation::CitationMode,
622 intra_delimiter: &str,
623 suppress_author: bool,
624 position: Option<&citum_schema::citation::Position>,
625 ) -> Result<Vec<String>, ProcessorError> {
626 self.render_ungrouped_citation_with_format::<crate::render::plain::PlainText>(
627 items,
628 spec,
629 mode,
630 intra_delimiter,
631 suppress_author,
632 position,
633 spec.note_start_text_case,
634 )
635 }
636
637 #[allow(
647 clippy::too_many_arguments,
648 reason = "Ungrouped citation rendering now needs explicit note-start context."
649 )]
650 pub fn render_ungrouped_citation_with_format<F>(
651 &self,
652 items: &[crate::reference::CitationItem],
653 spec: &citum_schema::CitationSpec,
654 mode: &citum_schema::citation::CitationMode,
655 intra_delimiter: &str,
656 suppress_author: bool,
657 position: Option<&citum_schema::citation::Position>,
658 note_start_text_case: Option<citum_schema::NoteStartTextCase>,
659 ) -> Result<Vec<String>, ProcessorError>
660 where
661 F: crate::render::format::OutputFormat<Output = String>,
662 {
663 let fmt = F::default();
664 let mut chunks: Vec<(Vec<String>, String)> = Vec::new();
665
666 let use_author_number = self.should_render_author_number_for_numeric_integral(mode);
668 let params = UngroupedItemRenderParams {
669 mode,
670 suppress_author,
671 position,
672 note_start_text_case,
673 };
674
675 for item in items {
676 let chunk = if use_author_number {
677 self.render_numeric_integral_item_chunk_with_format::<F>(&fmt, item)?
678 } else {
679 let state =
680 self.resolve_ungrouped_item_render_state(item, spec, intra_delimiter)?;
681 self.render_template_item_chunk_with_format::<F>(&fmt, item, state, params)
682 };
683
684 if let Some(chunk) = chunk {
685 chunks.push(chunk);
686 }
687 }
688
689 if self.should_collapse_compound_subentries(mode) {
690 chunks = self.collapse_compound_citation_chunks(chunks);
691 }
692 if self.should_collapse_citation_numbers(spec, mode) {
693 chunks = self.collapse_numeric_citation_chunks(chunks);
694 }
695
696 Ok(chunks
697 .into_iter()
698 .map(|(ids, content)| fmt.citation(ids, content))
699 .collect())
700 }
701}
702
703fn key_base(key: &str) -> Cow<'_, str> {
704 let mut parts = key.splitn(3, ':');
705 match (parts.next(), parts.next()) {
706 (Some(kind), Some(var)) => Cow::Owned(format!("{kind}:{var}")),
707 _ => Cow::Borrowed(key),
708 }
709}
710
711#[must_use]
717pub fn get_variable_key(component: &TemplateComponent) -> Option<String> {
718 use citum_schema::template::Rendering;
719 use std::fmt::Write;
720
721 fn push_context_suffix(key: &mut String, rendering: &Rendering) {
722 match (&rendering.prefix, &rendering.suffix) {
723 (Some(prefix), Some(suffix)) => {
724 key.push(':');
725 key.push_str(prefix);
726 key.push('_');
727 key.push_str(suffix);
728 }
729 (Some(prefix), None) => {
730 key.push(':');
731 key.push_str(prefix);
732 }
733 (None, Some(suffix)) => {
734 key.push(':');
735 key.push_str(suffix);
736 }
737 (None, None) => {}
738 }
739 }
740
741 fn make_key(kind: &str, value: impl std::fmt::Debug, rendering: &Rendering) -> Option<String> {
742 let mut key = String::new();
743 write!(&mut key, "{kind}:{value:?}").ok()?;
744 push_context_suffix(&mut key, rendering);
745 Some(key)
746 }
747
748 match component {
749 TemplateComponent::Contributor(c) => c.contributor.as_single().map_or_else(
750 || make_key("contributor", &c.contributor, &c.rendering),
751 |role| make_key("contributor", role, &c.rendering),
752 ),
753 TemplateComponent::Date(d) => make_key("date", &d.date, &d.rendering),
754 TemplateComponent::Variable(v) => make_key("variable", &v.variable, &v.rendering),
755 TemplateComponent::Title(t) => {
756 let mut key = format!("title:{:?}", t.title);
757 if let Some(form) = &t.form {
758 write!(&mut key, ":{form:?}").ok()?;
759 }
760 push_context_suffix(&mut key, &t.rendering);
761 Some(key)
762 }
763 TemplateComponent::Number(n) => make_key("number", &n.number, &n.rendering),
764 TemplateComponent::Group(_) => None,
765 _ => None,
766 }
767}