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 indexmap::IndexMap;
20use std::borrow::Cow;
21use std::cell::RefCell;
22use std::collections::{HashMap, HashSet};
23
24pub struct Renderer<'a> {
29 pub style: &'a citum_schema::Style,
31 pub bibliography: &'a Bibliography,
33 pub locale: &'a Locale,
35 pub config: &'a Config,
37 pub bibliography_config: Option<BibliographyConfig>,
39 pub hints: &'a HashMap<String, ProcHints>,
41 pub citation_numbers: &'a RefCell<HashMap<String, usize>>,
43 pub compound_set_by_ref: &'a HashMap<String, String>,
45 pub compound_member_index: &'a HashMap<String, usize>,
47 pub compound_sets: &'a IndexMap<String, Vec<String>>,
49 pub show_semantics: bool,
51 pub inject_ast_indices: bool,
53 pub filtered_to_original_index: RefCell<Option<Vec<usize>>>,
55 pub abbreviation_map: Option<&'a crate::api::AbbreviationMap>,
57 pub first_note_by_id: Option<&'a RefCell<HashMap<String, u32>>>,
59}
60
61pub struct CompoundRenderData<'a> {
63 pub set_by_ref: &'a HashMap<String, String>,
65 pub member_index: &'a HashMap<String, usize>,
67 pub sets: &'a IndexMap<String, Vec<String>>,
69}
70
71mod collapse;
72mod grouped;
73mod grouped_fallback;
74mod helpers;
75
76#[cfg(test)]
77#[allow(
78 clippy::unwrap_used,
79 clippy::expect_used,
80 clippy::panic,
81 clippy::indexing_slicing,
82 clippy::todo,
83 clippy::unimplemented,
84 clippy::unreachable,
85 clippy::get_unwrap,
86 reason = "Panicking is acceptable and often desired in tests."
87)]
88mod tests;
89
90pub use grouped_fallback::GroupRenderParams;
91pub use grouped_fallback::TemplateRenderParams;
92pub(super) use helpers::{
93 find_grouping_component, has_contributor_component, leading_group_affix,
94 strip_author_component, strip_leading_group_affixes,
95};
96
97pub struct TemplateRenderRequest<'a> {
99 pub template: &'a [TemplateComponent],
101 pub context: RenderContext,
103 pub mode: citum_schema::citation::CitationMode,
105 pub suppress_author: bool,
107 pub locator_raw: Option<&'a CitationLocator>,
109 pub citation_number: usize,
111 pub position: Option<citum_schema::citation::Position>,
113 pub note_start_text_case: Option<citum_schema::NoteStartTextCase>,
115 pub integral_name_state: Option<citum_schema::citation::IntegralNameState>,
117 pub org_abbreviation_state: Option<citum_schema::citation::IntegralNameState>,
119 pub first_reference_note_number: Option<u32>,
121}
122
123#[derive(Clone, Default)]
124struct TemplateComponentTracker {
125 rendered_vars: HashSet<String>,
126 substituted_bases: HashSet<String>,
127}
128
129impl TemplateComponentTracker {
130 fn should_skip(&self, var_key: Option<&str>) -> bool {
131 let Some(var_key) = var_key else {
132 return false;
133 };
134 let base = key_base(var_key);
135 self.rendered_vars.contains(var_key) || self.substituted_bases.contains(base.as_ref())
136 }
137
138 fn mark_rendered(&mut self, var_key: Option<String>, substituted_key: Option<&str>) {
139 if let Some(var_key) = var_key {
140 self.rendered_vars.insert(var_key);
141 }
142 if let Some(substituted_key) = substituted_key {
143 self.rendered_vars.insert(substituted_key.to_string());
144 self.substituted_bases
145 .insert(key_base(substituted_key).into_owned());
146 }
147 }
148
149 fn merge_from(&mut self, other: Self) {
150 self.rendered_vars.extend(other.rendered_vars);
151 self.substituted_bases.extend(other.substituted_bases);
152 }
153}
154
155pub struct RendererResources<'a> {
160 pub style: &'a citum_schema::Style,
162 pub bibliography: &'a Bibliography,
164 pub locale: &'a Locale,
166 pub config: &'a Config,
168 pub bibliography_config: Option<BibliographyConfig>,
170 pub first_note_by_id: Option<&'a RefCell<HashMap<String, u32>>>,
172}
173
174impl<'a> Renderer<'a> {
175 pub fn new(
177 resources: RendererResources<'a>,
178 hints: &'a HashMap<String, ProcHints>,
179 citation_numbers: &'a RefCell<HashMap<String, usize>>,
180 compound: CompoundRenderData<'a>,
181 show_semantics: bool,
182 inject_ast_indices: bool,
183 abbreviation_map: Option<&'a crate::api::AbbreviationMap>,
184 ) -> Self {
185 Self {
186 style: resources.style,
187 bibliography: resources.bibliography,
188 locale: resources.locale,
189 config: resources.config,
190 bibliography_config: resources.bibliography_config,
191 hints,
192 citation_numbers,
193 compound_set_by_ref: compound.set_by_ref,
194 compound_member_index: compound.member_index,
195 compound_sets: compound.sets,
196 show_semantics,
197 inject_ast_indices,
198 filtered_to_original_index: RefCell::new(None),
199 abbreviation_map,
200 first_note_by_id: resources.first_note_by_id,
201 }
202 }
203
204 fn resolve_contributor_names(
206 &self,
207 contributor: &citum_schema::reference::contributor::Contributor,
208 ) -> Vec<crate::reference::FlatName> {
209 let ml = self.config.multilingual.as_ref();
210 crate::values::resolve_multilingual_name(
211 contributor,
212 ml.and_then(|m| m.name_mode.as_ref()),
213 ml.and_then(|m| m.preferred_transliteration.as_deref()),
214 ml.and_then(|m| m.preferred_script.as_ref()),
215 &self.locale.locale,
216 )
217 }
218
219 fn citation_sub_label_for_ref(&self, ref_id: &str) -> Option<String> {
222 let compound = self
223 .bibliography_config
224 .as_ref()
225 .and_then(|b| b.compound_numeric.as_ref())?;
226 let set_id = self.compound_set_by_ref.get(ref_id)?;
227 let members = self.compound_sets.get(set_id)?;
228 if members.len() <= 1 {
229 return None;
230 }
231 if !compound.subentry {
232 return None;
233 }
234 let idx = *self.compound_member_index.get(ref_id)?;
235 match compound.sub_label {
236 citum_schema::options::bibliography::SubLabelStyle::Alphabetic => {
237 crate::values::int_to_letter((idx + 1) as u32)
238 }
239 citum_schema::options::bibliography::SubLabelStyle::Numeric => {
240 Some((idx + 1).to_string())
241 }
242 }
243 }
244
245 fn should_render_author_number_for_numeric_integral(
251 &self,
252 mode: &citum_schema::citation::CitationMode,
253 ) -> bool {
254 matches!(mode, citum_schema::citation::CitationMode::Integral)
255 && self.config.processing.as_ref().is_some_and(|processing| {
256 matches!(processing, citum_schema::options::Processing::Numeric)
257 })
258 && !self.has_explicit_integral_template()
259 }
260
261 fn has_explicit_integral_template(&self) -> bool {
263 self.style.citation.as_ref().is_some_and(|c| {
264 c.integral.as_ref().is_some_and(|i| {
265 i.template.is_some() || i.template_ref.is_some() || i.locales.is_some()
266 })
267 })
268 }
269
270 fn should_collapse_compound_subentries(
272 &self,
273 mode: &citum_schema::citation::CitationMode,
274 ) -> bool {
275 if !matches!(mode, citum_schema::citation::CitationMode::NonIntegral) {
276 return false;
277 }
278
279 self.bibliography_config
280 .as_ref()
281 .and_then(|b| b.compound_numeric.as_ref())
282 .is_some_and(|c| c.subentry && c.collapse_subentries)
283 }
284
285 fn should_collapse_citation_numbers(
287 &self,
288 spec: &citum_schema::CitationSpec,
289 mode: &citum_schema::citation::CitationMode,
290 ) -> bool {
291 if !matches!(mode, citum_schema::citation::CitationMode::NonIntegral) {
292 return false;
293 }
294
295 let is_numeric = self
296 .config
297 .processing
298 .as_ref()
299 .is_some_and(|p| matches!(p, citum_schema::options::Processing::Numeric));
300
301 is_numeric
302 && matches!(
303 spec.collapse,
304 Some(citum_schema::CitationCollapse::CitationNumber)
305 )
306 }
307
308 fn normalize_prefix_spacing(prefix: &str) -> String {
310 if !prefix.is_empty() && !prefix.ends_with(char::is_whitespace) {
311 format!("{prefix} ")
312 } else {
313 prefix.to_string()
314 }
315 }
316
317 fn ensure_suffix_spacing(suffix: &str) -> String {
320 if suffix.is_empty() {
321 String::new()
322 } else if suffix.starts_with(char::is_whitespace)
323 || suffix.starts_with(',')
324 || suffix.starts_with(';')
325 || suffix.starts_with('.')
326 {
327 suffix.to_string()
329 } else {
330 format!(" {suffix}")
332 }
333 }
334
335 fn affix_content<F>(
337 &self,
338 fmt: &F,
339 content: String,
340 prefix: Option<&str>,
341 suffix: Option<&str>,
342 ) -> String
343 where
344 F: crate::render::format::OutputFormat<Output = String>,
345 {
346 let prefix = prefix.unwrap_or("");
347 let suffix = suffix.unwrap_or("");
348 if prefix.is_empty() && suffix.is_empty() {
349 content
350 } else {
351 fmt.affix(
352 &Self::normalize_prefix_spacing(prefix),
353 content,
354 &Self::ensure_suffix_spacing(suffix),
355 )
356 }
357 }
358
359 fn build_citation_chunk<F>(
361 &self,
362 fmt: &F,
363 ids: Vec<String>,
364 content: String,
365 prefix: Option<&str>,
366 suffix: Option<&str>,
367 ) -> Option<(Vec<String>, String)>
368 where
369 F: crate::render::format::OutputFormat<Output = String>,
370 {
371 if content.is_empty() {
372 None
373 } else {
374 Some((ids, self.affix_content(fmt, content, prefix, suffix)))
375 }
376 }
377
378 fn citation_render_request<'b>(
380 &self,
381 item: &'b crate::reference::CitationItem,
382 template: &'b [TemplateComponent],
383 mode: &citum_schema::citation::CitationMode,
384 suppress_author: bool,
385 position: Option<&citum_schema::citation::Position>,
386 note_start_text_case: Option<citum_schema::NoteStartTextCase>,
387 ) -> TemplateRenderRequest<'b> {
388 TemplateRenderRequest {
389 template,
390 context: RenderContext::Citation,
391 mode: mode.clone(),
392 suppress_author,
393 locator_raw: item.locator.as_ref(),
394 citation_number: self.get_or_assign_citation_number(&item.id),
395 position: position.cloned(),
396 note_start_text_case,
397 integral_name_state: item.integral_name_state,
398 org_abbreviation_state: item.org_abbreviation_state,
399 first_reference_note_number: self
400 .first_note_by_id
401 .as_ref()
402 .and_then(|m| m.borrow().get(&item.id).copied()),
403 }
404 }
405
406 fn render_item_from_template_with_format<F>(
408 &self,
409 reference: &Reference,
410 request: TemplateRenderRequest<'_>,
411 delimiter: &str,
412 ) -> Option<String>
413 where
414 F: crate::render::format::OutputFormat<Output = String>,
415 {
416 self.process_template_request_with_format::<F>(reference, request)
417 .map(|proc| {
418 crate::render::citation::citation_to_string_with_format::<F>(
419 &proc,
420 None,
421 None,
422 None,
423 Some(delimiter),
424 )
425 })
426 }
427
428 fn citation_render_options<'b>(
430 &'b self,
431 mode: citum_schema::citation::CitationMode,
432 suppress_author: bool,
433 locator_raw: Option<&'b CitationLocator>,
434 ref_type: Option<String>,
435 ) -> RenderOptions<'b> {
436 RenderOptions {
437 config: self.config,
438 bibliography_config: self.bibliography_config.clone(),
439 locale: self.locale,
440 context: RenderContext::Citation,
441 mode,
442 suppress_author,
443 locator_raw,
444 ref_type,
445 show_semantics: self.show_semantics,
446 current_template_index: None,
447 abbreviation_map: self.abbreviation_map,
448 }
449 }
450
451 fn render_author_number_for_numeric_integral_with_format<F>(
455 &self,
456 reference: &Reference,
457 item: &crate::reference::CitationItem,
458 citation_number: usize,
459 ) -> String
460 where
461 F: crate::render::format::OutputFormat<Output = String>,
462 {
463 let fmt = F::default();
464 let options = self.citation_render_options(
465 citum_schema::citation::CitationMode::Integral,
466 false,
467 item.locator.as_ref(),
468 Some(reference.ref_type()),
469 );
470
471 let author_part = if let Some(authors) = reference.author() {
473 let names_vec = self.resolve_contributor_names(&authors);
474 fmt.text(&crate::values::format_contributors_short(
475 &names_vec, &options,
476 ))
477 } else {
478 String::new()
479 };
480
481 let ref_id = reference.id().unwrap_or_default().to_string();
483 let sub_label = self.citation_sub_label_for_ref(&ref_id).unwrap_or_default();
484
485 if author_part.is_empty() {
487 format!("[{citation_number}{sub_label}]")
489 } else {
490 format!("{author_part} [{citation_number}{sub_label}]")
491 }
492 }
493
494 pub fn render_ungrouped_citation(
501 &self,
502 items: &[crate::reference::CitationItem],
503 spec: &citum_schema::CitationSpec,
504 mode: &citum_schema::citation::CitationMode,
505 intra_delimiter: &str,
506 suppress_author: bool,
507 position: Option<&citum_schema::citation::Position>,
508 ) -> Result<Vec<String>, ProcessorError> {
509 self.render_ungrouped_citation_with_format::<crate::render::plain::PlainText>(
510 items,
511 spec,
512 mode,
513 intra_delimiter,
514 suppress_author,
515 position,
516 spec.note_start_text_case,
517 )
518 }
519
520 #[allow(
530 clippy::too_many_arguments,
531 reason = "Ungrouped citation rendering now needs explicit note-start context."
532 )]
533 pub fn render_ungrouped_citation_with_format<F>(
534 &self,
535 items: &[crate::reference::CitationItem],
536 spec: &citum_schema::CitationSpec,
537 mode: &citum_schema::citation::CitationMode,
538 intra_delimiter: &str,
539 suppress_author: bool,
540 position: Option<&citum_schema::citation::Position>,
541 note_start_text_case: Option<citum_schema::NoteStartTextCase>,
542 ) -> Result<Vec<String>, ProcessorError>
543 where
544 F: crate::render::format::OutputFormat<Output = String>,
545 {
546 let fmt = F::default();
547 let mut chunks: Vec<(Vec<String>, String)> = Vec::new();
548
549 let use_author_number = self.should_render_author_number_for_numeric_integral(mode);
551
552 for item in items {
553 let reference = self
554 .bibliography
555 .get(&item.id)
556 .ok_or_else(|| ProcessorError::ReferenceNotFound(item.id.clone()))?;
557
558 if use_author_number {
559 let citation_number = self.get_or_assign_citation_number(&item.id);
561 let item_str = self.render_author_number_for_numeric_integral_with_format::<F>(
562 reference,
563 item,
564 citation_number,
565 );
566 if let Some(chunk) = self.build_citation_chunk(
567 &fmt,
568 vec![item.id.clone()],
569 item_str,
570 item.prefix.as_deref(),
571 item.suffix.as_deref(),
572 ) {
573 chunks.push(chunk);
574 }
575 } else {
576 let item_language = crate::values::effective_item_language(reference);
578 let default_template = spec.resolve_template_for_language(item_language.as_deref());
579
580 let ref_type = reference.ref_type();
581 let matched_type_template = spec.type_variants.as_ref().and_then(|type_variants| {
582 let mut matched_template = None;
583 for (selector, template) in type_variants {
584 if selector.matches(&ref_type) {
585 matched_template = template.clone().into_template();
586 break;
587 }
588 }
589 matched_template
590 });
591
592 let template = matched_type_template.or(default_template);
593 let effective_template = template.as_deref().unwrap_or(&[]);
594 let effective_delim = spec.delimiter.as_deref().unwrap_or(intra_delimiter);
595 let request = self.citation_render_request(
596 item,
597 effective_template,
598 mode,
599 suppress_author,
600 position,
601 note_start_text_case,
602 );
603 if let Some(item_str) = self.render_item_from_template_with_format::<F>(
604 reference,
605 request,
606 effective_delim,
607 ) && let Some(chunk) = self.build_citation_chunk(
608 &fmt,
609 vec![item.id.clone()],
610 item_str,
611 item.prefix.as_deref(),
612 item.suffix.as_deref(),
613 ) {
614 chunks.push(chunk);
615 }
616 }
617 }
618
619 if self.should_collapse_compound_subentries(mode) {
620 chunks = self.collapse_compound_citation_chunks(chunks);
621 }
622 if self.should_collapse_citation_numbers(spec, mode) {
623 chunks = self.collapse_numeric_citation_chunks(chunks);
624 }
625
626 Ok(chunks
627 .into_iter()
628 .map(|(ids, content)| fmt.citation(ids, content))
629 .collect())
630 }
631}
632
633fn key_base(key: &str) -> Cow<'_, str> {
634 let mut parts = key.splitn(3, ':');
635 match (parts.next(), parts.next()) {
636 (Some(kind), Some(var)) => Cow::Owned(format!("{kind}:{var}")),
637 _ => Cow::Borrowed(key),
638 }
639}
640
641#[must_use]
647pub fn get_variable_key(component: &TemplateComponent) -> Option<String> {
648 use citum_schema::template::Rendering;
649 use std::fmt::Write;
650
651 fn push_context_suffix(key: &mut String, rendering: &Rendering) {
652 match (&rendering.prefix, &rendering.suffix) {
653 (Some(prefix), Some(suffix)) => {
654 key.push(':');
655 key.push_str(prefix);
656 key.push('_');
657 key.push_str(suffix);
658 }
659 (Some(prefix), None) => {
660 key.push(':');
661 key.push_str(prefix);
662 }
663 (None, Some(suffix)) => {
664 key.push(':');
665 key.push_str(suffix);
666 }
667 (None, None) => {}
668 }
669 }
670
671 fn make_key(kind: &str, value: impl std::fmt::Debug, rendering: &Rendering) -> Option<String> {
672 let mut key = String::new();
673 write!(&mut key, "{kind}:{value:?}").ok()?;
674 push_context_suffix(&mut key, rendering);
675 Some(key)
676 }
677
678 match component {
679 TemplateComponent::Contributor(c) => make_key("contributor", &c.contributor, &c.rendering),
680 TemplateComponent::Date(d) => make_key("date", &d.date, &d.rendering),
681 TemplateComponent::Variable(v) => make_key("variable", &v.variable, &v.rendering),
682 TemplateComponent::Title(t) => {
683 let mut key = format!("title:{:?}", t.title);
684 if let Some(form) = &t.form {
685 write!(&mut key, ":{form:?}").ok()?;
686 }
687 push_context_suffix(&mut key, &t.rendering);
688 Some(key)
689 }
690 TemplateComponent::Number(n) => make_key("number", &n.number, &n.rendering),
691 TemplateComponent::Group(_) => None,
692 _ => None,
693 }
694}