1use super::super::{
7 GroupRenderParams, Renderer, TemplateComponentTracker, TemplateRenderParams,
8 TemplateRenderRequest, find_grouping_component, get_variable_key, has_contributor_component,
9 leading_group_affix, remove_first_contributor_with_role, strip_author_component,
10 strip_leading_group_affixes,
11};
12use super::component_predicates::{
13 is_term_only_component, resolve_localized_type_variant, resolve_type_variant,
14};
15use super::group_citation_items_by_author;
16use crate::error::ProcessorError;
17use crate::reference::Reference;
18use crate::render::{ProcTemplate, ProcTemplateComponent};
19use crate::values::{ComponentValues, ProcHints, RenderContext, RenderOptions};
20use citum_schema::template::{
21 TemplateComponent, TemplateConditionField, TemplateGroupCondition, WrapConfig, WrapPunctuation,
22};
23use std::borrow::Cow;
24
25struct GroupRenderState<'a> {
26 first_item: &'a crate::reference::CitationItem,
27 first_ref: &'a Reference,
28 template: Cow<'a, [TemplateComponent]>,
29}
30
31struct ItemRenderState<'a> {
32 item: &'a crate::reference::CitationItem,
33 reference: &'a Reference,
34 template: Cow<'a, [TemplateComponent]>,
35}
36
37struct GroupItemRenderRequest<'a> {
38 item: &'a crate::reference::CitationItem,
39 template: &'a [TemplateComponent],
40 mode: &'a citum_schema::citation::CitationMode,
41 suppress_author: bool,
42 position: Option<&'a citum_schema::citation::Position>,
43 note_start_text_case: Option<citum_schema::NoteStartTextCase>,
44 delimiter: &'a str,
45}
46
47struct TemplateRenderContext<'a> {
53 reference: &'a Reference,
54 ref_type: &'a str,
55 options: &'a RenderOptions<'a>,
56 hint: &'a ProcHints,
57 template_index: usize,
58}
59
60struct HintInputs<'a> {
64 reference: &'a Reference,
65 context: RenderContext,
66 citation_number: usize,
67 position: Option<citum_schema::citation::Position>,
68 integral_name_state: Option<citum_schema::citation::IntegralNameState>,
69 org_abbreviation_state: Option<citum_schema::citation::IntegralNameState>,
70 first_reference_note_number: Option<u32>,
71}
72
73fn group_condition_matches(reference: &Reference, condition: &TemplateGroupCondition) -> bool {
74 condition
75 .field_present
76 .as_ref()
77 .is_none_or(|field| condition_field_present(reference, field))
78 && condition
79 .field_absent
80 .as_ref()
81 .is_none_or(|field| !condition_field_present(reference, field))
82}
83
84fn condition_field_present(reference: &Reference, field: &TemplateConditionField) -> bool {
85 match field {
86 TemplateConditionField::Author => reference.author().is_some(),
87 TemplateConditionField::Editor => reference.editor().is_some(),
88 TemplateConditionField::Recipient => reference
89 .contributor(citum_schema::reference::ContributorRole::Recipient)
90 .is_some(),
91 TemplateConditionField::Translator => reference.translator().is_some(),
92 TemplateConditionField::Title => reference.title().is_some(),
93 TemplateConditionField::CollectionTitle => reference.collection_title().is_some(),
94 TemplateConditionField::Issued => reference.effective_issued_date().is_some(),
95 TemplateConditionField::OriginalPublished => reference.original_date().is_some(),
96 TemplateConditionField::Publisher => reference.publisher_str().is_some(),
97 TemplateConditionField::OriginalPublisher => reference.original_publisher_str().is_some(),
98 TemplateConditionField::OriginalPublisherPlace => {
99 reference.original_publisher_place().is_some()
100 }
101 TemplateConditionField::OriginalTitle => reference.original_title().is_some(),
102 TemplateConditionField::Doi => reference.doi().is_some(),
103 TemplateConditionField::Genre => reference.genre().is_some(),
104 TemplateConditionField::Archive => reference.archive().is_some(),
105 TemplateConditionField::ArchiveLocation => reference.archive_location().is_some(),
106 TemplateConditionField::VolumeOrIssue => {
107 reference.volume().is_some() || reference.issue().is_some()
108 }
109 }
110}
111
112impl Renderer<'_> {
113 fn strip_redundant_leading_group_punctuation<'a>(
114 &self,
115 value: &'a str,
116 delimiter: &str,
117 ) -> &'a str {
118 let Some(delimiter_char) = delimiter.chars().find(|ch| !ch.is_whitespace()) else {
119 return value;
120 };
121
122 let trimmed = value.trim_start();
123 if !trimmed.starts_with(delimiter_char) {
124 return value;
125 }
126
127 #[allow(clippy::string_slice, reason = "delimiter found at start")]
128 trimmed[delimiter_char.len_utf8()..].trim_start()
129 }
130
131 fn join_integral_group_item_parts(&self, item_parts: &[String], delimiter: &str) -> String {
132 let repeated_item_delimiter = if delimiter.trim().is_empty() {
133 ", "
134 } else {
135 delimiter
136 };
137
138 let mut joined = String::new();
139 for (index, part) in item_parts.iter().enumerate() {
140 if index > 0 {
141 joined.push_str(repeated_item_delimiter);
142 }
143
144 let normalized = if index == 0 {
145 part.as_str()
146 } else {
147 self.strip_redundant_leading_group_punctuation(part, repeated_item_delimiter)
148 };
149 joined.push_str(normalized);
150 }
151
152 joined
153 }
154
155 pub fn render_grouped_citation(
161 &self,
162 items: &[crate::reference::CitationItem],
163 spec: &citum_schema::CitationSpec,
164 mode: &citum_schema::citation::CitationMode,
165 intra_delimiter: &str,
166 suppress_author: bool,
167 position: Option<&citum_schema::citation::Position>,
168 ) -> Result<Vec<String>, ProcessorError> {
169 self.render_grouped_citation_with_format::<crate::render::plain::PlainText>(
170 items,
171 &GroupRenderParams {
172 spec,
173 mode,
174 intra_delimiter,
175 suppress_author,
176 position,
177 note_start_text_case: spec.note_start_text_case,
178 },
179 )
180 }
181
182 fn render_special_type_items<F>(
185 &self,
186 group: &[&crate::reference::CitationItem],
187 params: &GroupRenderParams<'_>,
188 ) -> Result<Vec<String>, ProcessorError>
189 where
190 F: crate::render::format::OutputFormat<Output = String>,
191 {
192 let fmt = F::default();
193 let mut rendered_items = Vec::new();
194 for item in group {
195 let state = self.resolve_item_render_state(item, params.spec)?;
196 if let Some(item_str) = self.render_group_item_from_template_with_format::<F>(
197 state.reference,
198 GroupItemRenderRequest {
199 item: state.item,
200 template: &state.template,
201 mode: params.mode,
202 suppress_author: params.suppress_author,
203 position: params.position,
204 note_start_text_case: params.note_start_text_case,
205 delimiter: params.intra_delimiter,
206 },
207 ) && let Some((ids, content)) = self.build_citation_chunk(
208 &fmt,
209 vec![item.id.clone()],
210 item_str,
211 item.prefix.as_deref(),
212 item.suffix.as_deref(),
213 ) {
214 rendered_items.push(fmt.citation(ids, content));
215 }
216 }
217 Ok(rendered_items)
218 }
219
220 fn render_integral_explicit_group<F>(
225 &self,
226 group: &[&crate::reference::CitationItem],
227 spec: &citum_schema::CitationSpec,
228 mode: &citum_schema::citation::CitationMode,
229 suppress_author: bool,
230 position: Option<&citum_schema::citation::Position>,
231 ) -> Result<Option<String>, ProcessorError>
232 where
233 F: crate::render::format::OutputFormat<Output = String>,
234 {
235 let fmt = F::default();
236 let component_delimiter = spec.delimiter.as_deref().unwrap_or(" ");
237 let item_join_delim = spec.multi_cite_delimiter.as_deref().unwrap_or(", ");
238 let mut group_items_str = Vec::new();
239 let mut all_ids = Vec::new();
240
241 for item in group {
242 let state = self.resolve_item_render_state(item, spec)?;
243 if let Some(item_str) = self.render_group_item_from_template_with_format::<F>(
244 state.reference,
245 GroupItemRenderRequest {
246 item: state.item,
247 template: &state.template,
248 mode,
249 suppress_author,
250 position,
251 note_start_text_case: spec.note_start_text_case,
252 delimiter: component_delimiter,
253 },
254 ) && !item_str.is_empty()
255 {
256 group_items_str.push(self.affix_content(
257 &fmt,
258 item_str,
259 item.prefix.as_deref(),
260 item.suffix.as_deref(),
261 Some(item.id.as_str()),
262 ));
263 all_ids.push(item.id.clone());
264 }
265 }
266
267 if group_items_str.is_empty() {
268 return Ok(None);
269 }
270
271 let combined_str = group_items_str.join(item_join_delim);
272 Ok(Some(fmt.citation(all_ids, combined_str)))
273 }
274
275 pub fn render_grouped_citation_with_format<F>(
284 &self,
285 items: &[crate::reference::CitationItem],
286 params: &GroupRenderParams<'_>,
287 ) -> Result<Vec<String>, ProcessorError>
288 where
289 F: crate::render::format::OutputFormat<Output = String>,
290 {
291 let groups = group_citation_items_by_author(self, items);
292 let mut rendered_groups = Vec::new();
293 for (_author_key, group) in groups {
294 rendered_groups
295 .extend(self.render_grouped_citation_group_with_format::<F>(&group, params)?);
296 }
297
298 Ok(rendered_groups)
299 }
300
301 fn render_grouped_citation_group_with_format<F>(
302 &self,
303 group: &[&crate::reference::CitationItem],
304 params: &GroupRenderParams<'_>,
305 ) -> Result<Vec<String>, ProcessorError>
306 where
307 F: crate::render::format::OutputFormat<Output = String>,
308 {
309 let state = self.resolve_group_render_state(group, params.spec)?;
310
311 if group.len() == 1
316 && let Some(citation) = self.try_render_integral_group_with_format::<F>(
317 group,
318 params.spec,
319 params.mode,
320 params.suppress_author,
321 params.position,
322 )?
323 {
324 return Ok(vec![citation]);
325 }
326
327 if self.requires_full_group_item_rendering(params.mode, state.first_ref) {
328 return self.render_special_type_items::<F>(group, params);
329 }
330
331 Ok(self
332 .render_fallback_grouped_citation_with_format::<F>(
333 group,
334 state.first_ref,
335 state.first_item,
336 &state.template,
337 params,
338 )?
339 .into_iter()
340 .collect())
341 }
342
343 fn render_fallback_grouped_citation_with_format<F>(
344 &self,
345 group: &[&crate::reference::CitationItem],
346 first_ref: &Reference,
347 first_item: &crate::reference::CitationItem,
348 template: &[TemplateComponent],
349 params: &GroupRenderParams<'_>,
350 ) -> Result<Option<String>, ProcessorError>
351 where
352 F: crate::render::format::OutputFormat<Output = String>,
353 {
354 let fmt = F::default();
355 let author_part = self.render_author_for_grouping_with_format::<F>(
356 first_ref,
357 first_item,
358 template,
359 params.mode,
360 params.suppress_author,
361 params.position,
362 );
363 let (item_parts, group_delimiter, captured_year_wrap) =
364 self.render_group_item_parts_with_format::<F>(&fmt, group, params)?;
365 let pre_wrapped_years =
371 if matches!(params.mode, citum_schema::citation::CitationMode::Integral)
372 && !item_parts.is_empty()
373 {
374 let delimiter = group_delimiter.as_deref().unwrap_or(params.intra_delimiter);
375 let joined = self.join_integral_group_item_parts(&item_parts, delimiter);
376 let wrap_punct = captured_year_wrap
377 .as_ref()
378 .map(|w| &w.punctuation)
379 .unwrap_or(&WrapPunctuation::Parentheses);
380 let inner_prefix = captured_year_wrap
381 .as_ref()
382 .and_then(|w| w.inner_prefix.as_deref())
383 .unwrap_or("");
384 let inner_suffix = captured_year_wrap
385 .as_ref()
386 .and_then(|w| w.inner_suffix.as_deref())
387 .unwrap_or("");
388 let inner = fmt.inner_affix(inner_prefix, joined, inner_suffix);
389 let marks = crate::render::format::QuoteMarks::from(&self.locale.grammar_options);
390 let (script, realization) = crate::values::punctuation_realization_context(
391 crate::values::effective_item_language(first_ref).as_deref(),
392 self.config.multilingual.as_ref(),
393 );
394 Some(fmt.wrap_punctuation(wrap_punct, inner, &marks, script, realization))
395 } else {
396 None
397 };
398 let Some(content) = self.build_grouped_citation_content(
399 &author_part,
400 &item_parts,
401 params,
402 group_delimiter.as_deref(),
403 pre_wrapped_years.as_deref(),
404 ) else {
405 return Ok(None);
406 };
407 let group_ids = group.iter().map(|item| item.id.clone()).collect();
408 let prefix = first_item.prefix.as_deref().unwrap_or("");
409 let suffix = if item_parts.is_empty() {
412 first_item.suffix.as_deref()
413 } else {
414 None
415 };
416
417 Ok(Some(fmt.citation(
418 group_ids,
419 self.affix_content(
420 &fmt,
421 content,
422 Some(prefix),
423 suffix,
424 Some(first_item.id.as_str()),
425 ),
426 )))
427 }
428
429 fn build_grouped_citation_content(
430 &self,
431 author_part: &str,
432 item_parts: &[String],
433 params: &GroupRenderParams<'_>,
434 group_delimiter: Option<&str>,
435 pre_wrapped_years: Option<&str>,
436 ) -> Option<String> {
437 if !author_part.is_empty() && !item_parts.is_empty() {
438 let author_item_delimiter = group_delimiter.unwrap_or(params.intra_delimiter);
439 return Some(match params.mode {
440 citum_schema::citation::CitationMode::Integral => {
441 let wrapped = pre_wrapped_years.map(str::to_string).unwrap_or_else(|| {
445 self.join_integral_group_item_parts(item_parts, author_item_delimiter)
446 });
447 self.format_integral_grouped_items(
448 author_part,
449 &wrapped,
450 params.suppress_author,
451 )
452 }
453 citum_schema::citation::CitationMode::NonIntegral => {
454 let repeated_item_delimiter = if author_item_delimiter.trim().is_empty() {
455 ", "
456 } else {
457 author_item_delimiter
458 };
459 let joined_items = item_parts.join(repeated_item_delimiter);
460 self.format_non_integral_grouped_items(
461 author_part,
462 author_item_delimiter,
463 &joined_items,
464 params.suppress_author,
465 )
466 }
467 });
468 }
469
470 if !author_part.is_empty() {
471 return Some(author_part.to_string());
472 }
473
474 if !item_parts.is_empty() {
475 return Some(item_parts.join(params.intra_delimiter));
476 }
477
478 None
479 }
480
481 fn format_integral_grouped_items(
482 &self,
483 author_part: &str,
484 wrapped_content: &str,
485 suppress_author: bool,
486 ) -> String {
487 if suppress_author {
488 wrapped_content.to_string()
489 } else {
490 format!("{author_part} {wrapped_content}")
491 }
492 }
493
494 fn format_non_integral_grouped_items(
495 &self,
496 author_part: &str,
497 author_item_delimiter: &str,
498 joined_items: &str,
499 suppress_author: bool,
500 ) -> String {
501 if suppress_author {
502 return joined_items.to_string();
503 }
504
505 if let Some(adjusted) =
506 self.adjust_grouped_author_quote_punctuation(author_part, author_item_delimiter)
507 {
508 return format!("{adjusted}{joined_items}");
509 }
510
511 format!("{author_part}{author_item_delimiter}{joined_items}")
512 }
513
514 fn adjust_grouped_author_quote_punctuation(
515 &self,
516 author_part: &str,
517 author_item_delimiter: &str,
518 ) -> Option<String> {
519 if !self.config.punctuation_in_quote
520 || !author_item_delimiter.starts_with(',')
521 || !(author_part.ends_with('"') || author_part.ends_with('\u{201D}'))
522 {
523 return None;
524 }
525
526 let is_curly = author_part.ends_with('\u{201D}');
527 let quote_char = if is_curly { '\u{201D}' } else { '"' };
528 #[allow(clippy::string_slice, reason = "quote found at end")]
529 let trimmed = &author_part[..author_part.len() - quote_char.len_utf8()];
530 #[allow(clippy::string_slice, reason = "delimiter checked to start with ','")]
531 Some(format!(
532 "{trimmed},{quote_char}{}",
533 &author_item_delimiter[1..]
534 ))
535 }
536
537 fn render_group_item_parts_with_format<F>(
538 &self,
539 fmt: &F,
540 group: &[&crate::reference::CitationItem],
541 params: &GroupRenderParams<'_>,
542 ) -> Result<(Vec<String>, Option<String>, Option<WrapConfig>), ProcessorError>
543 where
544 F: crate::render::format::OutputFormat<Output = String>,
545 {
546 let mut item_parts = Vec::new();
547 let mut group_delimiter: Option<String> = None;
548 let mut captured_year_wrap: Option<WrapConfig> = None;
555 let collapse_group = group.len() > 1
556 && matches!(params.mode, citum_schema::citation::CitationMode::Integral);
557 for (index, item) in group.iter().enumerate() {
558 let state = self.resolve_item_render_state(item, params.spec)?;
559 let (script, realization) = crate::values::punctuation_realization_context(
560 crate::values::effective_item_language(state.reference).as_deref(),
561 self.config.multilingual.as_ref(),
562 );
563 let (mut filtered_template, leading_affix, strip_item_delimiter) =
564 filter_author_from_template::<F>(&state.template, script, realization, fmt);
565 if collapse_group {
566 if index == 0 {
567 captured_year_wrap = filtered_template
572 .first_mut()
573 .and_then(|c| c.rendering_mut().wrap.take());
574 } else {
575 if let Some(first) = filtered_template.first_mut() {
577 first.rendering_mut().wrap = None;
578 }
579 }
580 }
581 if group_delimiter.is_none() {
582 group_delimiter = leading_affix
583 .as_ref()
584 .filter(|value| !value.is_empty())
585 .cloned();
586 }
587 let item_delimiter = if strip_item_delimiter {
588 ""
589 } else {
590 params.intra_delimiter
591 };
592 if let Some(item_str) = self.render_group_item_from_template_with_format::<F>(
593 state.reference,
594 GroupItemRenderRequest {
595 item: state.item,
596 template: &filtered_template,
597 mode: params.mode,
598 suppress_author: params.suppress_author,
599 position: params.position,
600 note_start_text_case: params.note_start_text_case,
601 delimiter: item_delimiter,
602 },
603 ) && !item_str.is_empty()
604 {
605 let prefix = (index > 0).then_some(item.prefix.as_deref()).flatten();
606 item_parts.push(self.affix_content(
607 fmt,
608 item_str,
609 prefix,
610 item.suffix.as_deref(),
611 Some(item.id.as_str()),
612 ));
613 }
614 }
615 Ok((item_parts, group_delimiter, captured_year_wrap))
616 }
617
618 fn resolve_group_render_state<'b>(
619 &'b self,
620 group: &'b [&'b crate::reference::CitationItem],
621 spec: &'b citum_schema::CitationSpec,
622 ) -> Result<GroupRenderState<'b>, ProcessorError> {
623 #[allow(clippy::indexing_slicing, reason = "groups are non-empty")]
624 let first_item = group[0];
625 let first_ref = self
626 .bibliography
627 .get(&first_item.id)
628 .ok_or_else(|| ProcessorError::ReferenceNotFound(first_item.id.clone()))?;
629 let first_language = crate::values::effective_item_language(first_ref);
630 let ref_type = first_ref.ref_type();
631 let localized = spec.resolve_localized_template(first_language.as_deref());
632 let first_template = localized
633 .as_ref()
634 .filter(|resolved| resolved.type_variants.is_some())
635 .cloned()
636 .map(|resolved| Cow::Owned(resolve_localized_type_variant(resolved, None, &ref_type)))
637 .or_else(|| {
638 resolve_type_variant(spec.type_variants.as_ref(), &ref_type).map(Cow::Borrowed)
639 })
640 .or_else(|| localized.map(|resolved| Cow::Owned(resolved.template)));
641
642 Ok(GroupRenderState {
643 first_item,
644 first_ref,
645 template: first_template.unwrap_or(Cow::Borrowed(&[])),
646 })
647 }
648
649 fn resolve_item_render_state<'b>(
650 &'b self,
651 item: &'b crate::reference::CitationItem,
652 spec: &'b citum_schema::CitationSpec,
653 ) -> Result<ItemRenderState<'b>, ProcessorError> {
654 let reference = self
655 .bibliography
656 .get(&item.id)
657 .ok_or_else(|| ProcessorError::ReferenceNotFound(item.id.clone()))?;
658 let item_language = crate::values::effective_item_language(reference);
659 let ref_type = reference.ref_type();
660 let localized = spec.resolve_localized_template(item_language.as_deref());
661 let item_template = localized
662 .as_ref()
663 .filter(|resolved| resolved.type_variants.is_some())
664 .cloned()
665 .map(|resolved| Cow::Owned(resolve_localized_type_variant(resolved, None, &ref_type)))
666 .or_else(|| {
667 resolve_type_variant(spec.type_variants.as_ref(), &ref_type).map(Cow::Borrowed)
668 })
669 .or_else(|| localized.map(|resolved| Cow::Owned(resolved.template)));
670
671 Ok(ItemRenderState {
672 item,
673 reference,
674 template: item_template.unwrap_or(Cow::Borrowed(&[])),
675 })
676 }
677
678 fn try_render_integral_group_with_format<F>(
679 &self,
680 group: &[&crate::reference::CitationItem],
681 spec: &citum_schema::CitationSpec,
682 mode: &citum_schema::citation::CitationMode,
683 suppress_author: bool,
684 position: Option<&citum_schema::citation::Position>,
685 ) -> Result<Option<String>, ProcessorError>
686 where
687 F: crate::render::format::OutputFormat<Output = String>,
688 {
689 if !matches!(mode, citum_schema::citation::CitationMode::Integral)
690 || !self.has_explicit_integral_template()
691 {
692 return Ok(None);
693 }
694
695 self.render_integral_explicit_group::<F>(group, spec, mode, suppress_author, position)
696 }
697
698 fn requires_full_group_item_rendering(
709 &self,
710 mode: &citum_schema::citation::CitationMode,
711 reference: &Reference,
712 ) -> bool {
713 matches!(mode, citum_schema::citation::CitationMode::NonIntegral)
714 && matches!(
715 reference.ref_type().as_str(),
716 "legal-case" | "treaty" | "hearing" | "personal-communication"
717 )
718 }
719
720 pub(crate) fn render_author_for_grouping_with_format<F>(
722 &self,
723 reference: &Reference,
724 item: &crate::reference::CitationItem,
725 template: &[TemplateComponent],
726 mode: &citum_schema::citation::CitationMode,
727 suppress_author: bool,
728 position: Option<&citum_schema::citation::Position>,
729 ) -> String
730 where
731 F: crate::render::format::OutputFormat<Output = String>,
732 {
733 let is_note_processing = self.config.processing.as_ref().is_some_and(|processing| {
734 matches!(processing, citum_schema::options::Processing::Note)
735 });
736 if is_note_processing
737 && matches!(
738 position,
739 Some(
740 citum_schema::citation::Position::Ibid
741 | citum_schema::citation::Position::IbidWithLocator
742 )
743 )
744 && !template.iter().any(has_contributor_component)
745 {
746 return String::new();
747 }
748
749 let options =
750 self.citation_render_options(reference, mode.clone(), suppress_author, None, None);
751
752 if let Some(comp) = template.first().and_then(find_grouping_component) {
756 let base_hints = self
757 .hints
758 .get(reference.id().as_deref().unwrap_or_default())
759 .cloned()
760 .unwrap_or_default();
761 let hints = ProcHints {
763 position: position.cloned(),
764 integral_name_state: item.integral_name_state,
765 ..base_hints
766 };
767 if let Some(vals) = comp.values::<F>(reference, &hints, &options)
768 && !vals.value.is_empty()
769 {
770 return vals.value;
771 }
772 }
773
774 if let Some(authors) = reference.author() {
776 let names_vec = self.resolve_contributor_names(&authors);
777 F::default().text(&crate::values::format_contributors_short(
778 &names_vec, &options,
779 ))
780 } else {
781 String::new()
782 }
783 }
784
785 pub(crate) fn render_integral_anchor_with_format<F>(
787 &self,
788 items: &[crate::reference::CitationItem],
789 spec: &citum_schema::CitationSpec,
790 inter_delimiter: &str,
791 suppress_author: bool,
792 position: Option<&citum_schema::citation::Position>,
793 ) -> Result<String, ProcessorError>
794 where
795 F: crate::render::format::OutputFormat<Output = String>,
796 {
797 let groups = group_citation_items_by_author(self, items);
798
799 let mut rendered_groups = Vec::new();
800 let fmt = F::default();
801 for (_author_key, group) in groups {
802 #[allow(
803 clippy::indexing_slicing,
804 reason = "group is non-empty by construction"
805 )]
806 let first_item = group[0];
807 let reference = self
808 .bibliography
809 .get(&first_item.id)
810 .ok_or_else(|| ProcessorError::ReferenceNotFound(first_item.id.clone()))?;
811 let item_language = crate::values::effective_item_language(reference);
812 let template = spec.resolve_template_for_language(item_language.as_deref());
813 let effective_template = template.as_deref().unwrap_or(&[]);
814 let author_part = self.render_author_for_grouping_with_format::<F>(
815 reference,
816 first_item,
817 effective_template,
818 &citum_schema::citation::CitationMode::Integral,
819 suppress_author,
820 position,
821 );
822 if !author_part.is_empty() {
823 rendered_groups.push(author_part);
824 }
825 }
826
827 Ok(fmt.join(rendered_groups, inter_delimiter))
828 }
829
830 #[must_use]
832 pub fn get_or_assign_citation_number(&self, ref_id: &str) -> usize {
833 let mut numbers = self
834 .citation_numbers
835 .write()
836 .unwrap_or_else(std::sync::PoisonError::into_inner);
837 let next_num = numbers.len() + 1;
838 *numbers.entry(ref_id.to_string()).or_insert(next_num)
839 }
840
841 #[must_use]
843 pub fn process_bibliography_entry(
844 &self,
845 reference: &Reference,
846 entry_number: usize,
847 ) -> Option<ProcTemplate> {
848 self.process_bibliography_entry_with_format::<crate::render::plain::PlainText>(
849 reference,
850 entry_number,
851 )
852 }
853
854 #[must_use]
856 pub fn process_bibliography_entry_with_format<F>(
857 &self,
858 reference: &Reference,
859 entry_number: usize,
860 ) -> Option<ProcTemplate>
861 where
862 F: crate::render::format::OutputFormat<Output = String>,
863 {
864 let bib_spec = self.style.bibliography.as_ref()?;
865
866 let item_language = crate::values::effective_item_language(reference);
867 let ref_type = reference.ref_type();
868 let localized = bib_spec.resolve_localized_template(item_language.as_deref());
869 let template = localized
870 .as_ref()
871 .filter(|resolved| resolved.type_variants.is_some())
872 .cloned()
873 .map(|resolved| Cow::Owned(resolve_localized_type_variant(resolved, None, &ref_type)))
874 .or_else(|| {
875 resolve_type_variant(bib_spec.type_variants.as_ref(), &ref_type).map(Cow::Borrowed)
876 })
877 .or_else(|| localized.map(|resolved| Cow::Owned(resolved.template)))?;
878
879 let template = self.apply_anonymous_entry_bibliography_policy(reference, template)?;
880 let template = self.apply_article_journal_bibliography_policy(reference, template);
881
882 self.process_template_request_with_format::<F>(
883 reference,
884 TemplateRenderRequest {
885 template: template.as_ref(),
886 context: RenderContext::Bibliography,
887 mode: citum_schema::citation::CitationMode::NonIntegral,
888 suppress_author: false,
889 locator_raw: None,
890 citation_number: entry_number,
891 position: None,
892 note_start_text_case: None,
893 integral_name_state: None,
894 org_abbreviation_state: None,
895 first_reference_note_number: None,
896 },
897 )
898 }
899
900 #[must_use]
905 pub fn process_template_with_number(
906 &self,
907 reference: &Reference,
908 params: TemplateRenderParams<'_>,
909 ) -> Option<ProcTemplate> {
910 self.process_template_with_number_with_format::<crate::render::plain::PlainText>(
911 reference, params,
912 )
913 }
914
915 pub fn process_template_with_number_with_format<F>(
920 &self,
921 reference: &Reference,
922 params: TemplateRenderParams<'_>,
923 ) -> Option<ProcTemplate>
924 where
925 F: crate::render::format::OutputFormat<Output = String>,
926 {
927 self.process_template_request_with_format::<F>(
928 reference,
929 TemplateRenderRequest {
930 template: params.template,
931 context: params.context,
932 mode: params.mode,
933 suppress_author: params.suppress_author,
934 locator_raw: params.locator_raw,
935 citation_number: params.citation_number,
936 position: params.position.cloned(),
937 note_start_text_case: params.note_start_text_case,
938 integral_name_state: params.integral_name_state,
939 org_abbreviation_state: params.org_abbreviation_state,
940 first_reference_note_number: None,
941 },
942 )
943 }
944
945 #[must_use]
947 pub fn process_template_request_with_format<F>(
948 &self,
949 reference: &Reference,
950 request: TemplateRenderRequest<'_>,
951 ) -> Option<ProcTemplate>
952 where
953 F: crate::render::format::OutputFormat<Output = String>,
954 {
955 let TemplateRenderRequest {
956 template,
957 context,
958 mode,
959 suppress_author,
960 locator_raw,
961 citation_number,
962 position,
963 note_start_text_case,
964 integral_name_state,
965 org_abbreviation_state,
966 first_reference_note_number,
967 } = request;
968 let ref_type = reference.ref_type();
969 let locale = self.locale_for_reference(reference, context);
970 let options = RenderOptions {
971 config: self.config.clone(),
972 bibliography_config: self.bibliography_config.clone(),
973 locale,
974 context,
975 mode,
976 suppress_author,
977 locator_raw,
978 ref_type: Some(ref_type.clone()),
979 show_semantics: self.show_semantics,
980 current_template_index: None,
981 abbreviation_map: self.abbreviation_map,
982 };
983 let effective_first_ref_note = if template_uses_first_ref_note_number(template) {
988 first_reference_note_number
989 } else {
990 None
991 };
992 let hint = self.build_template_render_hint(HintInputs {
993 reference,
994 context: options.context,
995 citation_number,
996 position,
997 integral_name_state,
998 org_abbreviation_state,
999 first_reference_note_number: effective_first_ref_note,
1000 });
1001 let mut components =
1002 self.render_template_components::<F>(reference, &ref_type, &options, &hint, template);
1003
1004 self.apply_sentence_initial_context::<F>(&mut components, context, note_start_text_case);
1005
1006 (!components.is_empty()).then_some(components)
1007 }
1008
1009 fn render_template_components<F>(
1013 &self,
1014 reference: &Reference,
1015 ref_type: &str,
1016 options: &RenderOptions<'_>,
1017 hint: &ProcHints,
1018 template: &[TemplateComponent],
1019 ) -> Vec<ProcTemplateComponent>
1020 where
1021 F: crate::render::format::OutputFormat<Output = String>,
1022 {
1023 let mut tracker = TemplateComponentTracker::default();
1024 let mut components = Vec::with_capacity(template.len());
1025 let mut component_options = options.clone();
1026 for (template_index, component) in template.iter().enumerate() {
1027 component_options.current_template_index =
1028 self.inject_ast_indices.then_some(template_index);
1029 let ctx = TemplateRenderContext {
1030 reference,
1031 ref_type,
1032 options: &component_options,
1033 hint,
1034 template_index,
1035 };
1036 if let Some(component) =
1037 self.render_template_component_with_format::<F>(&ctx, component, &mut tracker)
1038 {
1039 components.push(component);
1040 }
1041 }
1042 components
1043 }
1044
1045 fn build_template_render_hint(&self, inputs: HintInputs<'_>) -> ProcHints {
1046 let HintInputs {
1047 reference,
1048 context,
1049 citation_number,
1050 position,
1051 integral_name_state,
1052 org_abbreviation_state,
1053 first_reference_note_number,
1054 } = inputs;
1055 let default_hint = ProcHints::default();
1056 let base_hint = self
1057 .hints
1058 .get(reference.id().as_deref().unwrap_or_default())
1059 .unwrap_or(&default_hint);
1060 let is_subsequent = matches!(position, Some(citum_schema::citation::Position::Subsequent));
1061 ProcHints {
1062 citation_number: (citation_number > 0).then_some(citation_number),
1063 citation_sub_label: if context == RenderContext::Citation {
1064 reference
1065 .id()
1066 .as_deref()
1067 .and_then(|id| self.citation_sub_label_for_ref(id))
1068 } else {
1069 None
1070 },
1071 position,
1072 integral_name_state,
1073 org_abbreviation_state,
1074 first_reference_note_number: if is_subsequent {
1075 first_reference_note_number
1076 } else {
1077 None
1078 },
1079 suppress_disambiguation_title: is_subsequent && first_reference_note_number.is_some(),
1080 ..base_hint.clone()
1081 }
1082 }
1083
1084 fn render_template_component_with_format<F>(
1085 &self,
1086 ctx: &TemplateRenderContext<'_>,
1087 component: &TemplateComponent,
1088 tracker: &mut TemplateComponentTracker,
1089 ) -> Option<ProcTemplateComponent>
1090 where
1091 F: crate::render::format::OutputFormat<Output = String>,
1092 {
1093 if let TemplateComponent::Group(group) = component {
1094 return self.render_group_component_with_format::<F>(ctx, group, tracker);
1095 }
1096
1097 let resolved_component = component;
1098 if resolved_component.rendering().suppress == Some(true) {
1099 return None;
1100 }
1101
1102 let var_key = get_variable_key(resolved_component);
1103 if tracker.should_skip(var_key.as_deref()) {
1104 return None;
1105 }
1106
1107 let mut values = resolved_component.values::<F>(ctx.reference, ctx.hint, ctx.options)?;
1108 if values.value.trim().is_empty() {
1112 return None;
1113 }
1114 self.apply_issued_no_date_fallback(
1115 ctx.reference,
1116 ctx.options,
1117 resolved_component,
1118 &mut values,
1119 );
1120 self.apply_entry_link_fallback(ctx.reference, ctx.options, &mut values);
1121
1122 let item_language =
1123 crate::values::effective_component_language(ctx.reference, resolved_component);
1124 tracker.mark_rendered(var_key, values.substituted_key.as_deref());
1125
1126 Some(ProcTemplateComponent {
1127 template_component: resolved_component.clone(),
1128 template_index: self.inject_ast_indices.then_some(ctx.template_index),
1129 value: values.value,
1130 prefix: values.prefix,
1131 suffix: values.suffix,
1132 url: values.url,
1133 ref_type: Some(ctx.ref_type.to_string()),
1134 config: Some(ctx.options.config.clone()),
1135 bibliography_config: ctx.options.bibliography_config.clone(),
1136 item_language,
1137 quote_marks: crate::render::format::QuoteMarks::from(
1138 &ctx.options.locale.grammar_options,
1139 ),
1140 sentence_initial: false,
1141 pre_formatted: values.pre_formatted,
1142 })
1143 }
1144
1145 fn render_group_component_with_format<F>(
1146 &self,
1147 ctx: &TemplateRenderContext<'_>,
1148 group: &citum_schema::template::TemplateGroup,
1149 tracker: &mut TemplateComponentTracker,
1150 ) -> Option<ProcTemplateComponent>
1151 where
1152 F: crate::render::format::OutputFormat<Output = String>,
1153 {
1154 if group.rendering.suppress == Some(true) {
1155 return None;
1156 }
1157 if group
1158 .render_when
1159 .as_ref()
1160 .is_some_and(|condition| !group_condition_matches(ctx.reference, condition))
1161 {
1162 return None;
1163 }
1164
1165 let fmt = F::default();
1166 let mut group_tracker = tracker.clone();
1167 let values = self.render_group_child_values(&fmt, ctx, group, &mut group_tracker)?;
1168 let default_delimiter = citum_schema::template::DelimiterPunctuation::Comma;
1169 let punctuation = group.delimiter.as_ref().unwrap_or(&default_delimiter);
1170 let (script, realization) = crate::values::punctuation_realization_context(
1171 crate::values::effective_item_language(ctx.reference).as_deref(),
1172 ctx.options.config.multilingual.as_ref(),
1173 );
1174 let delimiter = crate::render::format::realize_punctuation(
1175 punctuation,
1176 script,
1177 realization,
1178 crate::render::format::PunctuationPosition::Separator,
1179 );
1180 let delimiter = if punctuation.is_semantic() {
1181 fmt.text(&delimiter)
1182 } else {
1183 delimiter.into_owned()
1184 };
1185 tracker.merge_from(group_tracker);
1186 let group_component = TemplateComponent::Group(group.clone());
1187 Some(ProcTemplateComponent {
1188 template_component: group_component.clone(),
1189 template_index: self.inject_ast_indices.then_some(ctx.template_index),
1190 value: fmt.join(values, &delimiter),
1191 prefix: None,
1192 suffix: None,
1193 url: None,
1194 ref_type: Some(ctx.ref_type.to_string()),
1195 config: Some(ctx.options.config.clone()),
1196 bibliography_config: ctx.options.bibliography_config.clone(),
1197 item_language: crate::values::effective_component_language(
1198 ctx.reference,
1199 &group_component,
1200 ),
1201 quote_marks: crate::render::format::QuoteMarks::from(
1202 &ctx.options.locale.grammar_options,
1203 ),
1204 sentence_initial: false,
1205 pre_formatted: true,
1206 })
1207 }
1208
1209 fn render_group_child_values<F>(
1215 &self,
1216 fmt: &F,
1217 ctx: &TemplateRenderContext<'_>,
1218 group: &citum_schema::template::TemplateGroup,
1219 tracker: &mut TemplateComponentTracker,
1220 ) -> Option<Vec<String>>
1221 where
1222 F: crate::render::format::OutputFormat<Output = String>,
1223 {
1224 let mut has_meaningful_content = false;
1225 let mut values = Vec::with_capacity(group.group.len());
1226
1227 for item in &group.group {
1228 let Some(rendered) =
1229 self.render_template_component_with_format::<F>(ctx, item, tracker)
1230 else {
1231 continue;
1232 };
1233 let rendered_str = crate::render::render_component_with_format_and_renderer::<F>(
1234 &rendered,
1235 fmt,
1236 ctx.options.show_semantics,
1237 );
1238 if rendered_str.trim().is_empty() {
1239 continue;
1240 }
1241 if !is_term_only_component(item) {
1242 has_meaningful_content = true;
1243 }
1244 values.push(rendered_str);
1245 }
1246
1247 (has_meaningful_content && !values.is_empty()).then_some(values)
1248 }
1249
1250 fn apply_issued_no_date_fallback(
1251 &self,
1252 reference: &Reference,
1253 options: &RenderOptions<'_>,
1254 component: &TemplateComponent,
1255 values: &mut crate::values::ProcValues<String>,
1256 ) {
1257 if !matches!(
1258 component,
1259 TemplateComponent::Date(citum_schema::template::TemplateDate {
1260 date: citum_schema::template::DateVariable::Issued,
1261 ..
1262 })
1263 ) || reference.effective_issued_date().is_some()
1264 || self.preferred_no_date_term_form() != citum_schema::locale::TermForm::Long
1265 {
1266 return;
1267 }
1268
1269 if let Some(long) = options.locale.resolved_general_term(
1270 &citum_schema::locale::GeneralTerm::NoDate,
1271 &citum_schema::locale::TermForm::Long,
1272 None,
1273 ) {
1274 values.value = long;
1275 }
1276 }
1277
1278 fn apply_entry_link_fallback(
1279 &self,
1280 reference: &Reference,
1281 options: &RenderOptions<'_>,
1282 values: &mut crate::values::ProcValues<String>,
1283 ) {
1284 if values.url.is_some() {
1285 return;
1286 }
1287
1288 let Some(links) = &options.config.links else {
1289 return;
1290 };
1291 use citum_schema::options::LinkAnchor;
1292 if matches!(links.anchor, Some(LinkAnchor::Entry)) {
1293 values.url = crate::values::resolve_url(links, reference);
1294 }
1295 }
1296
1297 pub fn apply_author_substitution(&self, proc: &mut ProcTemplate, substitute: &str) {
1299 self.apply_author_substitution_with_format::<crate::render::plain::PlainText>(
1300 proc, substitute,
1301 );
1302 }
1303
1304 pub fn apply_author_substitution_with_format<F>(
1306 &self,
1307 proc: &mut ProcTemplate,
1308 substitute: &str,
1309 ) where
1310 F: crate::render::format::OutputFormat<Output = String>,
1311 {
1312 if let Some(component) = proc
1313 .iter_mut()
1314 .find(|c| matches!(c.template_component, TemplateComponent::Contributor(_)))
1315 {
1316 let fmt = F::default();
1317 component.value = fmt.text(substitute);
1318 }
1319 }
1320
1321 fn preferred_no_date_term_form(&self) -> citum_schema::locale::TermForm {
1324 match self
1325 .config
1326 .dates
1327 .as_ref()
1328 .and_then(|dates| dates.no_date_form)
1329 {
1330 Some(citum_schema::options::NoDateForm::Long) => citum_schema::locale::TermForm::Long,
1331 Some(citum_schema::options::NoDateForm::Short) | None => {
1332 citum_schema::locale::TermForm::Short
1333 }
1334 }
1335 }
1336
1337 fn render_group_item_from_template_with_format<F>(
1338 &self,
1339 reference: &Reference,
1340 item_request: GroupItemRenderRequest<'_>,
1341 ) -> Option<String>
1342 where
1343 F: crate::render::format::OutputFormat<Output = String>,
1344 {
1345 let request = self.citation_render_request(
1346 item_request.item,
1347 item_request.template,
1348 item_request.mode,
1349 item_request.suppress_author,
1350 item_request.position,
1351 item_request.note_start_text_case,
1352 );
1353 self.render_item_from_template_with_format::<F>(reference, request, item_request.delimiter)
1354 }
1355}
1356
1357pub(super) fn template_uses_first_ref_note_number(template: &[TemplateComponent]) -> bool {
1364 template.iter().any(|c| match c {
1365 TemplateComponent::Number(n) => {
1366 n.number == citum_schema::template::NumberVariable::FirstReferenceNoteNumber
1367 }
1368 TemplateComponent::Group(g) => template_uses_first_ref_note_number(&g.group),
1369 _ => false,
1370 })
1371}
1372
1373pub(super) fn filter_author_from_template<F>(
1374 template: &[TemplateComponent],
1375 script: crate::values::ScriptClass,
1376 realization: Option<&citum_schema::options::PunctuationRealization>,
1377 fmt: &F,
1378) -> (Vec<TemplateComponent>, Option<String>, bool)
1379where
1380 F: crate::render::format::OutputFormat<Output = String>,
1381{
1382 let grouping_role = template
1388 .first()
1389 .and_then(find_grouping_component)
1390 .and_then(|component| match component {
1391 TemplateComponent::Contributor(contributor)
1392 if contributor.contributor != citum_schema::template::ContributorRole::Author =>
1393 {
1394 Some(contributor.contributor.clone())
1395 }
1396 _ => None,
1397 });
1398 let mut filtered: Vec<TemplateComponent> =
1399 template.iter().filter_map(strip_author_component).collect();
1400 if let Some(role) = grouping_role
1401 && !filtered.is_empty()
1402 {
1403 let first = filtered.remove(0);
1404 if let (Some(remaining), _) = remove_first_contributor_with_role(first, &role) {
1405 filtered.insert(0, remaining);
1406 }
1407 }
1408 let stripped_leading_affix = filtered
1409 .first()
1410 .and_then(|first| leading_group_affix(first, script, realization, fmt));
1411 let leading_affix = stripped_leading_affix.clone().or_else(|| {
1412 filtered.first().and_then(|_| {
1413 template
1414 .first()
1415 .and_then(|first| author_group_delimiter_affix(first, script, realization, fmt))
1416 })
1417 });
1418 if let Some(first) = filtered.first_mut() {
1419 strip_leading_group_affixes(first);
1420 }
1421 (filtered, leading_affix, stripped_leading_affix.is_some())
1422}
1423
1424fn author_group_delimiter_affix<F>(
1425 component: &TemplateComponent,
1426 script: crate::values::ScriptClass,
1427 realization: Option<&citum_schema::options::PunctuationRealization>,
1428 fmt: &F,
1429) -> Option<String>
1430where
1431 F: crate::render::format::OutputFormat<Output = String>,
1432{
1433 let TemplateComponent::Group(group) = component else {
1434 return None;
1435 };
1436 group
1437 .group
1438 .first()
1439 .is_some_and(component_starts_with_author)
1440 .then_some(group.delimiter.as_ref())
1441 .flatten()
1442 .map(|punctuation| {
1443 let realized = crate::render::format::realize_punctuation(
1444 punctuation,
1445 script,
1446 realization,
1447 crate::render::format::PunctuationPosition::Separator,
1448 );
1449 if punctuation.is_semantic() {
1450 fmt.text(&realized)
1451 } else {
1452 realized.into_owned()
1453 }
1454 })
1455 .filter(|delimiter| !delimiter.is_empty())
1456}
1457
1458fn component_starts_with_author(component: &TemplateComponent) -> bool {
1459 match component {
1460 TemplateComponent::Contributor(contributor) => contributor
1461 .contributor
1462 .contains(&citum_schema::template::ContributorRole::Author),
1463 TemplateComponent::Group(group) => group
1464 .group
1465 .first()
1466 .is_some_and(component_starts_with_author),
1467 _ => false,
1468 }
1469}
1470
1471#[cfg(test)]
1472mod tests {
1473 use super::*;
1474 use citum_schema::template::{
1475 ContributorRole, DelimiterPunctuation, TemplateContributor, TemplateGroup,
1476 };
1477
1478 #[test]
1479 fn author_group_delimiter_affix_recognizes_merged_leading_author_component() {
1480 let group = TemplateComponent::Group(TemplateGroup {
1483 group: vec![TemplateComponent::Contributor(TemplateContributor {
1484 contributor: vec![ContributorRole::Author, ContributorRole::Editor].into(),
1485 ..Default::default()
1486 })],
1487 delimiter: Some(DelimiterPunctuation::Comma),
1488 ..Default::default()
1489 });
1490
1491 let affix = author_group_delimiter_affix(
1493 &group,
1494 crate::values::ScriptClass::Latin,
1495 None,
1496 &crate::render::plain::PlainText,
1497 );
1498
1499 assert_eq!(affix, Some(", ".to_string()));
1501 }
1502
1503 #[test]
1504 fn author_group_delimiter_affix_ignores_merged_component_without_author() {
1505 let group = TemplateComponent::Group(TemplateGroup {
1508 group: vec![TemplateComponent::Contributor(TemplateContributor {
1509 contributor: vec![ContributorRole::Editor, ContributorRole::Translator].into(),
1510 ..Default::default()
1511 })],
1512 delimiter: Some(DelimiterPunctuation::Comma),
1513 ..Default::default()
1514 });
1515
1516 let affix = author_group_delimiter_affix(
1518 &group,
1519 crate::values::ScriptClass::Latin,
1520 None,
1521 &crate::render::plain::PlainText,
1522 );
1523
1524 assert_eq!(affix, None);
1526 }
1527}