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