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