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