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 self.locale.punctuation_realization.as_ref(),
394 );
395 Some(fmt.wrap_punctuation(
396 wrap_punct,
397 inner,
398 &marks,
399 script,
400 realization.as_deref(),
401 ))
402 } else {
403 None
404 };
405 let Some(content) = self.build_grouped_citation_content(
406 &author_part,
407 &item_parts,
408 params,
409 group_delimiter.as_deref(),
410 pre_wrapped_years.as_deref(),
411 ) else {
412 return Ok(None);
413 };
414 let group_ids = group.iter().map(|item| item.id.clone()).collect();
415 let prefix = first_item.prefix.as_deref().unwrap_or("");
416 let suffix = if item_parts.is_empty() {
419 first_item.suffix.as_deref()
420 } else {
421 None
422 };
423
424 Ok(Some(fmt.citation(
425 group_ids,
426 self.affix_content(
427 &fmt,
428 content,
429 Some(prefix),
430 suffix,
431 Some(first_item.id.as_str()),
432 ),
433 )))
434 }
435
436 fn build_grouped_citation_content(
437 &self,
438 author_part: &str,
439 item_parts: &[String],
440 params: &GroupRenderParams<'_>,
441 group_delimiter: Option<&str>,
442 pre_wrapped_years: Option<&str>,
443 ) -> Option<String> {
444 if !author_part.is_empty() && !item_parts.is_empty() {
445 let author_item_delimiter = group_delimiter.unwrap_or(params.intra_delimiter);
446 return Some(match params.mode {
447 citum_schema::citation::CitationMode::Integral => {
448 let wrapped = pre_wrapped_years.map(str::to_string).unwrap_or_else(|| {
452 self.join_integral_group_item_parts(item_parts, author_item_delimiter)
453 });
454 self.format_integral_grouped_items(
455 author_part,
456 &wrapped,
457 params.suppress_author,
458 )
459 }
460 citum_schema::citation::CitationMode::NonIntegral => {
461 let repeated_item_delimiter = if author_item_delimiter.trim().is_empty() {
462 ", "
463 } else {
464 author_item_delimiter
465 };
466 let joined_items = item_parts.join(repeated_item_delimiter);
467 self.format_non_integral_grouped_items(
468 author_part,
469 author_item_delimiter,
470 &joined_items,
471 params.suppress_author,
472 )
473 }
474 });
475 }
476
477 if !author_part.is_empty() {
478 return Some(author_part.to_string());
479 }
480
481 if !item_parts.is_empty() {
482 return Some(item_parts.join(params.intra_delimiter));
483 }
484
485 None
486 }
487
488 fn format_integral_grouped_items(
489 &self,
490 author_part: &str,
491 wrapped_content: &str,
492 suppress_author: bool,
493 ) -> String {
494 if suppress_author {
495 wrapped_content.to_string()
496 } else {
497 format!("{author_part} {wrapped_content}")
498 }
499 }
500
501 fn format_non_integral_grouped_items(
502 &self,
503 author_part: &str,
504 author_item_delimiter: &str,
505 joined_items: &str,
506 suppress_author: bool,
507 ) -> String {
508 if suppress_author {
509 return joined_items.to_string();
510 }
511
512 if let Some(adjusted) =
513 self.adjust_grouped_author_quote_punctuation(author_part, author_item_delimiter)
514 {
515 return format!("{adjusted}{joined_items}");
516 }
517
518 format!("{author_part}{author_item_delimiter}{joined_items}")
519 }
520
521 fn adjust_grouped_author_quote_punctuation(
522 &self,
523 author_part: &str,
524 author_item_delimiter: &str,
525 ) -> Option<String> {
526 if !self.config.punctuation_in_quote
527 || !author_item_delimiter.starts_with(',')
528 || !(author_part.ends_with('"') || author_part.ends_with('\u{201D}'))
529 {
530 return None;
531 }
532
533 let is_curly = author_part.ends_with('\u{201D}');
534 let quote_char = if is_curly { '\u{201D}' } else { '"' };
535 #[allow(clippy::string_slice, reason = "quote found at end")]
536 let trimmed = &author_part[..author_part.len() - quote_char.len_utf8()];
537 #[allow(clippy::string_slice, reason = "delimiter checked to start with ','")]
538 Some(format!(
539 "{trimmed},{quote_char}{}",
540 &author_item_delimiter[1..]
541 ))
542 }
543
544 fn render_group_item_parts_with_format<F>(
545 &self,
546 fmt: &F,
547 group: &[&crate::reference::CitationItem],
548 params: &GroupRenderParams<'_>,
549 ) -> Result<(Vec<String>, Option<String>, Option<WrapConfig>), ProcessorError>
550 where
551 F: crate::render::format::OutputFormat<Output = String>,
552 {
553 let mut item_parts = Vec::new();
554 let mut group_delimiter: Option<String> = None;
555 let mut captured_year_wrap: Option<WrapConfig> = None;
562 let collapse_group = group.len() > 1
563 && matches!(params.mode, citum_schema::citation::CitationMode::Integral);
564 for (index, item) in group.iter().enumerate() {
565 let state = self.resolve_item_render_state(item, params.spec)?;
566 let (script, realization) = crate::values::punctuation_realization_context(
567 crate::values::effective_item_language(state.reference).as_deref(),
568 self.config.multilingual.as_ref(),
569 self.locale.punctuation_realization.as_ref(),
570 );
571 let (mut filtered_template, leading_affix, strip_item_delimiter) =
572 filter_author_from_template::<F>(
573 &state.template,
574 script,
575 realization.as_deref(),
576 fmt,
577 );
578 if collapse_group {
579 if index == 0 {
580 captured_year_wrap = filtered_template
585 .first_mut()
586 .and_then(|c| c.rendering_mut().wrap.take());
587 } else {
588 if let Some(first) = filtered_template.first_mut() {
590 first.rendering_mut().wrap = None;
591 }
592 }
593 }
594 if group_delimiter.is_none() {
595 group_delimiter = leading_affix
596 .as_ref()
597 .filter(|value| !value.is_empty())
598 .cloned();
599 }
600 let item_delimiter = if strip_item_delimiter {
601 ""
602 } else {
603 params.intra_delimiter
604 };
605 if let Some(item_str) = self.render_group_item_from_template_with_format::<F>(
606 state.reference,
607 GroupItemRenderRequest {
608 item: state.item,
609 template: &filtered_template,
610 mode: params.mode,
611 suppress_author: params.suppress_author,
612 position: params.position,
613 note_start_text_case: params.note_start_text_case,
614 delimiter: item_delimiter,
615 },
616 ) && !item_str.is_empty()
617 {
618 let prefix = (index > 0).then_some(item.prefix.as_deref()).flatten();
619 item_parts.push(self.affix_content(
620 fmt,
621 item_str,
622 prefix,
623 item.suffix.as_deref(),
624 Some(item.id.as_str()),
625 ));
626 }
627 }
628 Ok((item_parts, group_delimiter, captured_year_wrap))
629 }
630
631 fn resolve_group_render_state<'b>(
632 &'b self,
633 group: &'b [&'b crate::reference::CitationItem],
634 spec: &'b citum_schema::CitationSpec,
635 ) -> Result<GroupRenderState<'b>, ProcessorError> {
636 #[allow(clippy::indexing_slicing, reason = "groups are non-empty")]
637 let first_item = group[0];
638 let first_ref = self
639 .bibliography
640 .get(&first_item.id)
641 .ok_or_else(|| ProcessorError::ReferenceNotFound(first_item.id.clone()))?;
642 let first_language = crate::values::effective_item_language(first_ref);
643 let ref_type = first_ref.ref_type();
644 let localized = spec.resolve_localized_template(first_language.as_deref());
645 let first_template = localized
646 .as_ref()
647 .filter(|resolved| resolved.type_variants.is_some())
648 .cloned()
649 .map(|resolved| {
650 Cow::Owned(resolve_localized_type_variant(
651 resolved,
652 spec.type_variants.as_ref(),
653 &ref_type,
654 ))
655 })
656 .or_else(|| {
657 resolve_type_variant(spec.type_variants.as_ref(), &ref_type).map(Cow::Borrowed)
658 })
659 .or_else(|| localized.map(|resolved| Cow::Owned(resolved.template)));
660
661 Ok(GroupRenderState {
662 first_item,
663 first_ref,
664 template: first_template.unwrap_or(Cow::Borrowed(&[])),
665 })
666 }
667
668 fn resolve_item_render_state<'b>(
669 &'b self,
670 item: &'b crate::reference::CitationItem,
671 spec: &'b citum_schema::CitationSpec,
672 ) -> Result<ItemRenderState<'b>, ProcessorError> {
673 let reference = self
674 .bibliography
675 .get(&item.id)
676 .ok_or_else(|| ProcessorError::ReferenceNotFound(item.id.clone()))?;
677 let item_language = crate::values::effective_item_language(reference);
678 let ref_type = reference.ref_type();
679 let localized = spec.resolve_localized_template(item_language.as_deref());
680 let item_template = localized
681 .as_ref()
682 .filter(|resolved| resolved.type_variants.is_some())
683 .cloned()
684 .map(|resolved| {
685 Cow::Owned(resolve_localized_type_variant(
686 resolved,
687 spec.type_variants.as_ref(),
688 &ref_type,
689 ))
690 })
691 .or_else(|| {
692 resolve_type_variant(spec.type_variants.as_ref(), &ref_type).map(Cow::Borrowed)
693 })
694 .or_else(|| localized.map(|resolved| Cow::Owned(resolved.template)));
695
696 Ok(ItemRenderState {
697 item,
698 reference,
699 template: item_template.unwrap_or(Cow::Borrowed(&[])),
700 })
701 }
702
703 fn try_render_integral_group_with_format<F>(
704 &self,
705 group: &[&crate::reference::CitationItem],
706 spec: &citum_schema::CitationSpec,
707 mode: &citum_schema::citation::CitationMode,
708 suppress_author: bool,
709 position: Option<&citum_schema::citation::Position>,
710 ) -> Result<Option<String>, ProcessorError>
711 where
712 F: crate::render::format::OutputFormat<Output = String>,
713 {
714 if !matches!(mode, citum_schema::citation::CitationMode::Integral)
715 || !self.has_explicit_integral_template()
716 {
717 return Ok(None);
718 }
719
720 self.render_integral_explicit_group::<F>(group, spec, mode, suppress_author, position)
721 }
722
723 fn requires_full_group_item_rendering(
734 &self,
735 mode: &citum_schema::citation::CitationMode,
736 reference: &Reference,
737 ) -> bool {
738 matches!(mode, citum_schema::citation::CitationMode::NonIntegral)
739 && matches!(
740 reference.ref_type().as_str(),
741 "legal-case" | "treaty" | "hearing" | "personal-communication"
742 )
743 }
744
745 pub(crate) fn render_author_for_grouping_with_format<F>(
747 &self,
748 reference: &Reference,
749 item: &crate::reference::CitationItem,
750 template: &[TemplateComponent],
751 mode: &citum_schema::citation::CitationMode,
752 suppress_author: bool,
753 position: Option<&citum_schema::citation::Position>,
754 ) -> String
755 where
756 F: crate::render::format::OutputFormat<Output = String>,
757 {
758 let is_note_processing = self.config.processing.as_ref().is_some_and(|processing| {
759 matches!(processing, citum_schema::options::Processing::Note)
760 });
761 if is_note_processing
762 && matches!(
763 position,
764 Some(
765 citum_schema::citation::Position::Ibid
766 | citum_schema::citation::Position::IbidWithLocator
767 )
768 )
769 && !template.iter().any(has_contributor_component)
770 {
771 return String::new();
772 }
773
774 let locale = self.locale_for_reference(reference, RenderContext::Citation);
775 let options = self.citation_render_options(
776 locale.as_ref(),
777 mode.clone(),
778 suppress_author,
779 None,
780 None,
781 );
782
783 if let Some(comp) = template.first().and_then(find_grouping_component) {
787 let base_hints = self
788 .hints
789 .get(reference.id().as_deref().unwrap_or_default())
790 .cloned()
791 .unwrap_or_default();
792 let hints = ProcHints {
794 position: position.cloned(),
795 integral_name_state: item.integral_name_state,
796 ..base_hints
797 };
798 if let Some(vals) = comp.values::<F>(reference, &hints, &options)
799 && !vals.value.is_empty()
800 {
801 return vals.value;
802 }
803 }
804
805 if let Some(authors) = reference.author() {
807 let names_vec = self.resolve_contributor_names(&authors);
808 F::default().text(&crate::values::format_contributors_short(
809 &names_vec, &options,
810 ))
811 } else {
812 String::new()
813 }
814 }
815
816 pub(crate) fn render_integral_anchor_with_format<F>(
818 &self,
819 items: &[crate::reference::CitationItem],
820 spec: &citum_schema::CitationSpec,
821 inter_delimiter: &str,
822 suppress_author: bool,
823 position: Option<&citum_schema::citation::Position>,
824 ) -> Result<String, ProcessorError>
825 where
826 F: crate::render::format::OutputFormat<Output = String>,
827 {
828 let groups = group_citation_items_by_author(self, items);
829
830 let mut rendered_groups = Vec::new();
831 let fmt = F::default();
832 for (_author_key, group) in groups {
833 #[allow(
834 clippy::indexing_slicing,
835 reason = "group is non-empty by construction"
836 )]
837 let first_item = group[0];
838 let reference = self
839 .bibliography
840 .get(&first_item.id)
841 .ok_or_else(|| ProcessorError::ReferenceNotFound(first_item.id.clone()))?;
842 let item_language = crate::values::effective_item_language(reference);
843 let template = spec.resolve_template_for_language(item_language.as_deref());
844 let effective_template = template.as_deref().unwrap_or(&[]);
845 let author_part = self.render_author_for_grouping_with_format::<F>(
846 reference,
847 first_item,
848 effective_template,
849 &citum_schema::citation::CitationMode::Integral,
850 suppress_author,
851 position,
852 );
853 if !author_part.is_empty() {
854 rendered_groups.push(author_part);
855 }
856 }
857
858 Ok(fmt.join(rendered_groups, inter_delimiter))
859 }
860
861 #[must_use]
863 pub fn get_or_assign_citation_number(&self, ref_id: &str) -> usize {
864 let mut numbers = self
865 .citation_numbers
866 .write()
867 .unwrap_or_else(std::sync::PoisonError::into_inner);
868 let next_num = numbers.len() + 1;
869 *numbers.entry(ref_id.to_string()).or_insert(next_num)
870 }
871
872 #[must_use]
874 pub fn process_bibliography_entry(
875 &self,
876 reference: &Reference,
877 entry_number: usize,
878 ) -> Option<ProcTemplate> {
879 self.process_bibliography_entry_with_format::<crate::render::plain::PlainText>(
880 reference,
881 entry_number,
882 )
883 }
884
885 #[must_use]
887 pub fn process_bibliography_entry_with_format<F>(
888 &self,
889 reference: &Reference,
890 entry_number: usize,
891 ) -> Option<ProcTemplate>
892 where
893 F: crate::render::format::OutputFormat<Output = String>,
894 {
895 let bib_spec = self.style.bibliography.as_ref()?;
896
897 let item_language = crate::values::effective_item_language(reference);
898 let ref_type = reference.ref_type();
899 let localized = bib_spec.resolve_localized_template(item_language.as_deref());
900 let template = localized
901 .as_ref()
902 .filter(|resolved| resolved.type_variants.is_some())
903 .cloned()
904 .map(|resolved| {
905 Cow::Owned(resolve_localized_type_variant(
906 resolved,
907 bib_spec.type_variants.as_ref(),
908 &ref_type,
909 ))
910 })
911 .or_else(|| {
912 resolve_type_variant(bib_spec.type_variants.as_ref(), &ref_type).map(Cow::Borrowed)
913 })
914 .or_else(|| localized.map(|resolved| Cow::Owned(resolved.template)))?;
915
916 let template = self.apply_anonymous_entry_bibliography_policy(reference, template)?;
917 let template = self.apply_article_journal_bibliography_policy(reference, template);
918
919 self.process_template_request_with_format::<F>(
920 reference,
921 TemplateRenderRequest {
922 template: template.as_ref(),
923 context: RenderContext::Bibliography,
924 mode: citum_schema::citation::CitationMode::NonIntegral,
925 suppress_author: false,
926 locator_raw: None,
927 citation_number: entry_number,
928 position: None,
929 note_start_text_case: None,
930 integral_name_state: None,
931 org_abbreviation_state: None,
932 first_reference_note_number: None,
933 },
934 )
935 }
936
937 #[must_use]
942 pub fn process_template_with_number(
943 &self,
944 reference: &Reference,
945 params: TemplateRenderParams<'_>,
946 ) -> Option<ProcTemplate> {
947 self.process_template_with_number_with_format::<crate::render::plain::PlainText>(
948 reference, params,
949 )
950 }
951
952 pub fn process_template_with_number_with_format<F>(
957 &self,
958 reference: &Reference,
959 params: TemplateRenderParams<'_>,
960 ) -> Option<ProcTemplate>
961 where
962 F: crate::render::format::OutputFormat<Output = String>,
963 {
964 self.process_template_request_with_format::<F>(
965 reference,
966 TemplateRenderRequest {
967 template: params.template,
968 context: params.context,
969 mode: params.mode,
970 suppress_author: params.suppress_author,
971 locator_raw: params.locator_raw,
972 citation_number: params.citation_number,
973 position: params.position.cloned(),
974 note_start_text_case: params.note_start_text_case,
975 integral_name_state: params.integral_name_state,
976 org_abbreviation_state: params.org_abbreviation_state,
977 first_reference_note_number: None,
978 },
979 )
980 }
981
982 #[must_use]
984 pub fn process_template_request_with_format<F>(
985 &self,
986 reference: &Reference,
987 request: TemplateRenderRequest<'_>,
988 ) -> Option<ProcTemplate>
989 where
990 F: crate::render::format::OutputFormat<Output = String>,
991 {
992 let TemplateRenderRequest {
993 template,
994 context,
995 mode,
996 suppress_author,
997 locator_raw,
998 citation_number,
999 position,
1000 note_start_text_case,
1001 integral_name_state,
1002 org_abbreviation_state,
1003 first_reference_note_number,
1004 } = request;
1005 let ref_type = reference.ref_type();
1006 let locale = self.locale_for_reference(reference, context);
1007 let options = RenderOptions {
1008 config: self.config.clone(),
1009 bibliography_config: self.bibliography_config.clone(),
1010 locale: locale.as_ref(),
1011 context,
1012 mode,
1013 suppress_author,
1014 locator_raw,
1015 ref_type: Some(ref_type.clone()),
1016 show_semantics: self.show_semantics,
1017 current_template_index: None,
1018 abbreviation_map: self.abbreviation_map,
1019 };
1020 let effective_first_ref_note = if template_uses_first_ref_note_number(template) {
1025 first_reference_note_number
1026 } else {
1027 None
1028 };
1029 let hint = self.build_template_render_hint(HintInputs {
1030 reference,
1031 context: options.context,
1032 citation_number,
1033 position,
1034 integral_name_state,
1035 org_abbreviation_state,
1036 first_reference_note_number: effective_first_ref_note,
1037 });
1038 let mut components =
1039 self.render_template_components::<F>(reference, &ref_type, &options, &hint, template);
1040
1041 self.apply_sentence_initial_context::<F>(&mut components, context, note_start_text_case);
1042
1043 (!components.is_empty()).then_some(components)
1044 }
1045
1046 fn render_template_components<F>(
1050 &self,
1051 reference: &Reference,
1052 ref_type: &str,
1053 options: &RenderOptions<'_>,
1054 hint: &ProcHints,
1055 template: &[TemplateComponent],
1056 ) -> Vec<ProcTemplateComponent>
1057 where
1058 F: crate::render::format::OutputFormat<Output = String>,
1059 {
1060 let mut tracker = TemplateComponentTracker::default();
1061 let mut components = Vec::with_capacity(template.len());
1062 let mut component_options = options.clone();
1063 for (template_index, component) in template.iter().enumerate() {
1064 component_options.current_template_index =
1065 self.inject_ast_indices.then_some(template_index);
1066 let ctx = TemplateRenderContext {
1067 reference,
1068 ref_type,
1069 options: &component_options,
1070 hint,
1071 template_index,
1072 };
1073 if let Some(component) =
1074 self.render_template_component_with_format::<F>(&ctx, component, &mut tracker)
1075 {
1076 components.push(component);
1077 }
1078 }
1079 components
1080 }
1081
1082 fn build_template_render_hint(&self, inputs: HintInputs<'_>) -> ProcHints {
1083 let HintInputs {
1084 reference,
1085 context,
1086 citation_number,
1087 position,
1088 integral_name_state,
1089 org_abbreviation_state,
1090 first_reference_note_number,
1091 } = inputs;
1092 let default_hint = ProcHints::default();
1093 let base_hint = self
1094 .hints
1095 .get(reference.id().as_deref().unwrap_or_default())
1096 .unwrap_or(&default_hint);
1097 let is_subsequent = matches!(position, Some(citum_schema::citation::Position::Subsequent));
1098 ProcHints {
1099 citation_number: (citation_number > 0).then_some(citation_number),
1100 citation_sub_label: if context == RenderContext::Citation {
1101 reference
1102 .id()
1103 .as_deref()
1104 .and_then(|id| self.citation_sub_label_for_ref(id))
1105 } else {
1106 None
1107 },
1108 position,
1109 integral_name_state,
1110 org_abbreviation_state,
1111 first_reference_note_number: if is_subsequent {
1112 first_reference_note_number
1113 } else {
1114 None
1115 },
1116 suppress_disambiguation_title: is_subsequent && first_reference_note_number.is_some(),
1117 ..base_hint.clone()
1118 }
1119 }
1120
1121 fn render_template_component_with_format<F>(
1122 &self,
1123 ctx: &TemplateRenderContext<'_>,
1124 component: &TemplateComponent,
1125 tracker: &mut TemplateComponentTracker,
1126 ) -> Option<ProcTemplateComponent>
1127 where
1128 F: crate::render::format::OutputFormat<Output = String>,
1129 {
1130 if let TemplateComponent::Group(group) = component {
1131 return self.render_group_component_with_format::<F>(ctx, group, tracker);
1132 }
1133
1134 let resolved_component = component;
1135 if resolved_component.rendering().suppress == Some(true) {
1136 return None;
1137 }
1138
1139 let var_key = get_variable_key(resolved_component);
1140 if tracker.should_skip(var_key.as_deref()) {
1141 return None;
1142 }
1143
1144 let mut values = resolved_component.values::<F>(ctx.reference, ctx.hint, ctx.options)?;
1145 if values.value.trim().is_empty() {
1149 return None;
1150 }
1151 self.apply_issued_no_date_fallback(
1152 ctx.reference,
1153 ctx.options,
1154 resolved_component,
1155 &mut values,
1156 );
1157 self.apply_entry_link_fallback(ctx.reference, ctx.options, &mut values);
1158
1159 let item_language =
1160 crate::values::effective_component_language(ctx.reference, resolved_component);
1161 tracker.mark_rendered(var_key, values.substituted_key.as_deref());
1162
1163 Some(ProcTemplateComponent {
1164 template_component: resolved_component.clone(),
1165 template_index: self.inject_ast_indices.then_some(ctx.template_index),
1166 value: values.value,
1167 prefix: values.prefix,
1168 suffix: values.suffix,
1169 url: values.url,
1170 ref_type: Some(ctx.ref_type.to_string()),
1171 config: Some(ctx.options.config.clone()),
1172 bibliography_config: ctx.options.bibliography_config.clone(),
1173 item_language,
1174 quote_marks: crate::render::format::QuoteMarks::from(ctx.options.locale),
1175 sentence_initial: false,
1176 pre_formatted: values.pre_formatted,
1177 })
1178 }
1179
1180 fn render_group_component_with_format<F>(
1181 &self,
1182 ctx: &TemplateRenderContext<'_>,
1183 group: &citum_schema::template::TemplateGroup,
1184 tracker: &mut TemplateComponentTracker,
1185 ) -> Option<ProcTemplateComponent>
1186 where
1187 F: crate::render::format::OutputFormat<Output = String>,
1188 {
1189 if group.rendering.suppress == Some(true) {
1190 return None;
1191 }
1192 if group
1193 .render_when
1194 .as_ref()
1195 .is_some_and(|condition| !group_condition_matches(ctx.reference, condition))
1196 {
1197 return None;
1198 }
1199
1200 let fmt = F::default();
1201 let mut group_tracker = tracker.clone();
1202 let values = self.render_group_child_values(&fmt, ctx, group, &mut group_tracker)?;
1203 let default_delimiter = citum_schema::template::DelimiterPunctuation::Comma;
1204 let punctuation = group.delimiter.as_ref().unwrap_or(&default_delimiter);
1205 let (script, realization) = crate::values::punctuation_realization_context(
1206 crate::values::effective_item_language(ctx.reference).as_deref(),
1207 ctx.options.config.multilingual.as_ref(),
1208 ctx.options.locale.punctuation_realization.as_ref(),
1209 );
1210 let delimiter = crate::render::format::realize_punctuation(
1211 punctuation,
1212 script,
1213 realization.as_deref(),
1214 crate::render::format::PunctuationPosition::Separator,
1215 );
1216 let delimiter = if punctuation.is_semantic() {
1217 fmt.text(&delimiter)
1218 } else {
1219 delimiter.into_owned()
1220 };
1221 tracker.merge_from(group_tracker);
1222 let group_component = TemplateComponent::Group(group.clone());
1223 Some(ProcTemplateComponent {
1224 template_component: group_component.clone(),
1225 template_index: self.inject_ast_indices.then_some(ctx.template_index),
1226 value: fmt.join(values, &delimiter),
1227 prefix: None,
1228 suffix: None,
1229 url: None,
1230 ref_type: Some(ctx.ref_type.to_string()),
1231 config: Some(ctx.options.config.clone()),
1232 bibliography_config: ctx.options.bibliography_config.clone(),
1233 item_language: crate::values::effective_component_language(
1234 ctx.reference,
1235 &group_component,
1236 ),
1237 quote_marks: crate::render::format::QuoteMarks::from(ctx.options.locale),
1238 sentence_initial: false,
1239 pre_formatted: true,
1240 })
1241 }
1242
1243 fn render_group_child_values<F>(
1249 &self,
1250 fmt: &F,
1251 ctx: &TemplateRenderContext<'_>,
1252 group: &citum_schema::template::TemplateGroup,
1253 tracker: &mut TemplateComponentTracker,
1254 ) -> Option<Vec<String>>
1255 where
1256 F: crate::render::format::OutputFormat<Output = String>,
1257 {
1258 let mut has_meaningful_content = false;
1259 let mut values = Vec::with_capacity(group.group.len());
1260
1261 for item in &group.group {
1262 let Some(rendered) =
1263 self.render_template_component_with_format::<F>(ctx, item, tracker)
1264 else {
1265 continue;
1266 };
1267 let rendered_str = crate::render::render_component_with_format_and_renderer::<F>(
1268 &rendered,
1269 fmt,
1270 ctx.options.show_semantics,
1271 );
1272 if rendered_str.trim().is_empty() {
1273 continue;
1274 }
1275 if !is_term_only_component(item) {
1276 has_meaningful_content = true;
1277 }
1278 values.push(rendered_str);
1279 }
1280
1281 (has_meaningful_content && !values.is_empty()).then_some(values)
1282 }
1283
1284 fn apply_issued_no_date_fallback(
1285 &self,
1286 reference: &Reference,
1287 options: &RenderOptions<'_>,
1288 component: &TemplateComponent,
1289 values: &mut crate::values::ProcValues<String>,
1290 ) {
1291 if !matches!(
1292 component,
1293 TemplateComponent::Date(citum_schema::template::TemplateDate {
1294 date: citum_schema::template::DateVariable::Issued,
1295 ..
1296 })
1297 ) || reference.effective_issued_date().is_some()
1298 || self.preferred_no_date_term_form() != citum_schema::locale::TermForm::Long
1299 {
1300 return;
1301 }
1302
1303 if let Some(long) = options.locale.resolved_general_term(
1304 &citum_schema::locale::GeneralTerm::NoDate,
1305 &citum_schema::locale::TermForm::Long,
1306 None,
1307 ) {
1308 values.value = long;
1309 }
1310 }
1311
1312 fn apply_entry_link_fallback(
1313 &self,
1314 reference: &Reference,
1315 options: &RenderOptions<'_>,
1316 values: &mut crate::values::ProcValues<String>,
1317 ) {
1318 if values.url.is_some() {
1319 return;
1320 }
1321
1322 let Some(links) = &options.config.links else {
1323 return;
1324 };
1325 use citum_schema::options::LinkAnchor;
1326 if matches!(links.anchor, Some(LinkAnchor::Entry)) {
1327 values.url = crate::values::resolve_url(links, reference);
1328 }
1329 }
1330
1331 pub fn apply_author_substitution(&self, proc: &mut ProcTemplate, substitute: &str) {
1333 self.apply_author_substitution_with_format::<crate::render::plain::PlainText>(
1334 proc, substitute,
1335 );
1336 }
1337
1338 pub fn apply_author_substitution_with_format<F>(
1340 &self,
1341 proc: &mut ProcTemplate,
1342 substitute: &str,
1343 ) where
1344 F: crate::render::format::OutputFormat<Output = String>,
1345 {
1346 if let Some(component) = proc
1347 .iter_mut()
1348 .find(|c| matches!(c.template_component, TemplateComponent::Contributor(_)))
1349 {
1350 let fmt = F::default();
1351 component.value = fmt.text(substitute);
1352 }
1353 }
1354
1355 fn preferred_no_date_term_form(&self) -> citum_schema::locale::TermForm {
1358 match self
1359 .config
1360 .dates
1361 .as_ref()
1362 .and_then(|dates| dates.no_date_form)
1363 {
1364 Some(citum_schema::options::NoDateForm::Long) => citum_schema::locale::TermForm::Long,
1365 Some(citum_schema::options::NoDateForm::Short) | None => {
1366 citum_schema::locale::TermForm::Short
1367 }
1368 }
1369 }
1370
1371 fn render_group_item_from_template_with_format<F>(
1372 &self,
1373 reference: &Reference,
1374 item_request: GroupItemRenderRequest<'_>,
1375 ) -> Option<String>
1376 where
1377 F: crate::render::format::OutputFormat<Output = String>,
1378 {
1379 let request = self.citation_render_request(
1380 item_request.item,
1381 item_request.template,
1382 item_request.mode,
1383 item_request.suppress_author,
1384 item_request.position,
1385 item_request.note_start_text_case,
1386 );
1387 self.render_item_from_template_with_format::<F>(reference, request, item_request.delimiter)
1388 }
1389}
1390
1391pub(super) fn template_uses_first_ref_note_number(template: &[TemplateComponent]) -> bool {
1398 template.iter().any(|c| match c {
1399 TemplateComponent::Number(n) => {
1400 n.number == citum_schema::template::NumberVariable::FirstReferenceNoteNumber
1401 }
1402 TemplateComponent::Group(g) => template_uses_first_ref_note_number(&g.group),
1403 _ => false,
1404 })
1405}
1406
1407pub(super) fn filter_author_from_template<F>(
1408 template: &[TemplateComponent],
1409 script: crate::values::ScriptClass,
1410 realization: Option<&citum_schema::options::PunctuationRealization>,
1411 fmt: &F,
1412) -> (Vec<TemplateComponent>, Option<String>, bool)
1413where
1414 F: crate::render::format::OutputFormat<Output = String>,
1415{
1416 let grouping_role = template
1422 .first()
1423 .and_then(find_grouping_component)
1424 .and_then(|component| match component {
1425 TemplateComponent::Contributor(contributor)
1426 if contributor.contributor != citum_schema::template::ContributorRole::Author =>
1427 {
1428 Some(contributor.contributor.clone())
1429 }
1430 _ => None,
1431 });
1432 let mut filtered: Vec<TemplateComponent> =
1433 template.iter().filter_map(strip_author_component).collect();
1434 if let Some(role) = grouping_role
1435 && !filtered.is_empty()
1436 {
1437 let first = filtered.remove(0);
1438 if let (Some(remaining), _) = remove_first_contributor_with_role(first, &role) {
1439 filtered.insert(0, remaining);
1440 }
1441 }
1442 let stripped_leading_affix = filtered
1443 .first()
1444 .and_then(|first| leading_group_affix(first, script, realization, fmt));
1445 let leading_affix = stripped_leading_affix.clone().or_else(|| {
1446 filtered.first().and_then(|_| {
1447 template
1448 .first()
1449 .and_then(|first| author_group_delimiter_affix(first, script, realization, fmt))
1450 })
1451 });
1452 if let Some(first) = filtered.first_mut() {
1453 strip_leading_group_affixes(first);
1454 }
1455 (filtered, leading_affix, stripped_leading_affix.is_some())
1456}
1457
1458fn author_group_delimiter_affix<F>(
1459 component: &TemplateComponent,
1460 script: crate::values::ScriptClass,
1461 realization: Option<&citum_schema::options::PunctuationRealization>,
1462 fmt: &F,
1463) -> Option<String>
1464where
1465 F: crate::render::format::OutputFormat<Output = String>,
1466{
1467 let TemplateComponent::Group(group) = component else {
1468 return None;
1469 };
1470 group
1471 .group
1472 .first()
1473 .is_some_and(component_starts_with_author)
1474 .then_some(group.delimiter.as_ref())
1475 .flatten()
1476 .map(|punctuation| {
1477 let realized = crate::render::format::realize_punctuation(
1478 punctuation,
1479 script,
1480 realization,
1481 crate::render::format::PunctuationPosition::Separator,
1482 );
1483 if punctuation.is_semantic() {
1484 fmt.text(&realized)
1485 } else {
1486 realized.into_owned()
1487 }
1488 })
1489 .filter(|delimiter| !delimiter.is_empty())
1490}
1491
1492fn component_starts_with_author(component: &TemplateComponent) -> bool {
1493 match component {
1494 TemplateComponent::Contributor(contributor) => contributor
1495 .contributor
1496 .contains(&citum_schema::template::ContributorRole::Author),
1497 TemplateComponent::Group(group) => group
1498 .group
1499 .first()
1500 .is_some_and(component_starts_with_author),
1501 _ => false,
1502 }
1503}
1504
1505#[cfg(test)]
1506mod tests {
1507 use super::*;
1508 use citum_schema::template::{
1509 ContributorRole, DelimiterPunctuation, TemplateContributor, TemplateGroup,
1510 };
1511
1512 #[test]
1513 fn author_group_delimiter_affix_recognizes_merged_leading_author_component() {
1514 let group = TemplateComponent::Group(TemplateGroup {
1517 group: vec![TemplateComponent::Contributor(TemplateContributor {
1518 contributor: vec![ContributorRole::Author, ContributorRole::Editor].into(),
1519 ..Default::default()
1520 })],
1521 delimiter: Some(DelimiterPunctuation::Comma),
1522 ..Default::default()
1523 });
1524
1525 let affix = author_group_delimiter_affix(
1527 &group,
1528 crate::values::ScriptClass::Latin,
1529 None,
1530 &crate::render::plain::PlainText,
1531 );
1532
1533 assert_eq!(affix, Some(", ".to_string()));
1535 }
1536
1537 #[test]
1538 fn author_group_delimiter_affix_ignores_merged_component_without_author() {
1539 let group = TemplateComponent::Group(TemplateGroup {
1542 group: vec![TemplateComponent::Contributor(TemplateContributor {
1543 contributor: vec![ContributorRole::Editor, ContributorRole::Translator].into(),
1544 ..Default::default()
1545 })],
1546 delimiter: Some(DelimiterPunctuation::Comma),
1547 ..Default::default()
1548 });
1549
1550 let affix = author_group_delimiter_affix(
1552 &group,
1553 crate::values::ScriptClass::Latin,
1554 None,
1555 &crate::render::plain::PlainText,
1556 );
1557
1558 assert_eq!(affix, None);
1560 }
1561}