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