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_citation_number_component, is_term_only_component, resolve_localized_type_variant,
14 resolve_type_variant,
15};
16use super::group_citation_items_by_author;
17use crate::error::ProcessorError;
18use crate::reference::Reference;
19use crate::render::{ProcTemplate, ProcTemplateComponent};
20use crate::values::{ComponentValues, ProcHints, RenderContext, RenderOptions};
21use citum_schema::template::{
22 TemplateComponent, TemplateConditionField, TemplateGroupCondition, WrapConfig, WrapPunctuation,
23};
24use std::borrow::Cow;
25
26struct GroupRenderState<'a> {
27 first_item: &'a crate::reference::CitationItem,
28 first_ref: &'a Reference,
29 template: Cow<'a, [TemplateComponent]>,
30}
31
32struct ItemRenderState<'a> {
33 item: &'a crate::reference::CitationItem,
34 reference: &'a Reference,
35 template: Cow<'a, [TemplateComponent]>,
36}
37
38struct GroupItemRenderRequest<'a> {
39 item: &'a crate::reference::CitationItem,
40 template: &'a [TemplateComponent],
41 mode: &'a citum_schema::citation::CitationMode,
42 suppress_author: bool,
43 position: Option<&'a citum_schema::citation::Position>,
44 note_start_text_case: Option<citum_schema::NoteStartTextCase>,
45 delimiter: &'a str,
46}
47
48struct TemplateRenderContext<'a> {
54 reference: &'a Reference,
55 ref_type: &'a str,
56 options: &'a RenderOptions<'a>,
57 hint: &'a ProcHints,
58 template_index: usize,
59}
60
61struct HintInputs<'a> {
65 reference: &'a Reference,
66 context: RenderContext,
67 citation_number: usize,
68 position: Option<citum_schema::citation::Position>,
69 integral_name_state: Option<citum_schema::citation::IntegralNameState>,
70 org_abbreviation_state: Option<citum_schema::citation::IntegralNameState>,
71 first_reference_note_number: Option<u32>,
72}
73
74fn group_condition_matches(reference: &Reference, condition: &TemplateGroupCondition) -> bool {
75 condition
76 .field_present
77 .as_ref()
78 .is_none_or(|field| condition_field_present(reference, field))
79 && condition
80 .field_absent
81 .as_ref()
82 .is_none_or(|field| !condition_field_present(reference, field))
83}
84
85fn condition_field_present(reference: &Reference, field: &TemplateConditionField) -> bool {
86 match field {
87 TemplateConditionField::Author => reference.author().is_some(),
88 TemplateConditionField::Editor => reference.editor().is_some(),
89 TemplateConditionField::Recipient => reference
90 .contributor(citum_schema::reference::ContributorRole::Recipient)
91 .is_some(),
92 TemplateConditionField::Translator => reference.translator().is_some(),
93 TemplateConditionField::Title => reference.title().is_some(),
94 TemplateConditionField::CollectionTitle => reference.collection_title().is_some(),
95 TemplateConditionField::Issued => reference.effective_issued_date().is_some(),
96 TemplateConditionField::OriginalPublished => reference.original_date().is_some(),
97 TemplateConditionField::Publisher => reference.publisher_str().is_some(),
98 TemplateConditionField::OriginalPublisher => reference.original_publisher_str().is_some(),
99 TemplateConditionField::OriginalPublisherPlace => {
100 reference.original_publisher_place().is_some()
101 }
102 TemplateConditionField::OriginalTitle => reference.original_title().is_some(),
103 TemplateConditionField::Doi => reference.doi().is_some(),
104 TemplateConditionField::Genre => reference.genre().is_some(),
105 TemplateConditionField::Archive => reference.archive().is_some(),
106 TemplateConditionField::ArchiveLocation => reference.archive_location().is_some(),
107 TemplateConditionField::VolumeOrIssue => {
108 reference.volume().is_some() || reference.issue().is_some()
109 }
110 }
111}
112
113impl Renderer<'_> {
114 fn strip_redundant_leading_group_punctuation<'a>(
115 &self,
116 value: &'a str,
117 delimiter: &str,
118 ) -> &'a str {
119 let Some(delimiter_char) = delimiter.chars().find(|ch| !ch.is_whitespace()) else {
120 return value;
121 };
122
123 let trimmed = value.trim_start();
124 if !trimmed.starts_with(delimiter_char) {
125 return value;
126 }
127
128 #[allow(clippy::string_slice, reason = "delimiter found at start")]
129 trimmed[delimiter_char.len_utf8()..].trim_start()
130 }
131
132 fn join_integral_group_item_parts(&self, item_parts: &[String], delimiter: &str) -> String {
133 let repeated_item_delimiter = if delimiter.trim().is_empty() {
134 ", "
135 } else {
136 delimiter
137 };
138
139 let mut joined = String::new();
140 for (index, part) in item_parts.iter().enumerate() {
141 if index > 0 {
142 joined.push_str(repeated_item_delimiter);
143 }
144
145 let normalized = if index == 0 {
146 part.as_str()
147 } else {
148 self.strip_redundant_leading_group_punctuation(part, repeated_item_delimiter)
149 };
150 joined.push_str(normalized);
151 }
152
153 joined
154 }
155
156 pub fn render_grouped_citation(
162 &self,
163 items: &[crate::reference::CitationItem],
164 spec: &citum_schema::CitationSpec,
165 mode: &citum_schema::citation::CitationMode,
166 intra_delimiter: &str,
167 suppress_author: bool,
168 position: Option<&citum_schema::citation::Position>,
169 ) -> Result<Vec<String>, ProcessorError> {
170 self.render_grouped_citation_with_format::<crate::render::plain::PlainText>(
171 items,
172 &GroupRenderParams {
173 spec,
174 mode,
175 intra_delimiter,
176 suppress_author,
177 position,
178 note_start_text_case: spec.note_start_text_case,
179 },
180 )
181 }
182
183 fn render_special_type_items<F>(
186 &self,
187 group: &[&crate::reference::CitationItem],
188 params: &GroupRenderParams<'_>,
189 ) -> Result<Vec<String>, ProcessorError>
190 where
191 F: crate::render::format::OutputFormat<Output = String>,
192 {
193 let fmt = F::default();
194 let mut rendered_items = Vec::new();
195 for item in group {
196 let state = self.resolve_item_render_state(item, params.spec)?;
197 if let Some(item_str) = self.render_group_item_from_template_with_format::<F>(
198 state.reference,
199 GroupItemRenderRequest {
200 item: state.item,
201 template: &state.template,
202 mode: params.mode,
203 suppress_author: params.suppress_author,
204 position: params.position,
205 note_start_text_case: params.note_start_text_case,
206 delimiter: params.intra_delimiter,
207 },
208 ) && let Some((ids, content)) = self.build_citation_chunk(
209 &fmt,
210 vec![item.id.clone()],
211 item_str,
212 item.prefix.as_deref(),
213 item.suffix.as_deref(),
214 ) {
215 rendered_items.push(fmt.citation(ids, content));
216 }
217 }
218 Ok(rendered_items)
219 }
220
221 fn render_integral_explicit_group<F>(
226 &self,
227 group: &[&crate::reference::CitationItem],
228 spec: &citum_schema::CitationSpec,
229 mode: &citum_schema::citation::CitationMode,
230 suppress_author: bool,
231 position: Option<&citum_schema::citation::Position>,
232 ) -> Result<Option<String>, ProcessorError>
233 where
234 F: crate::render::format::OutputFormat<Output = String>,
235 {
236 let fmt = F::default();
237 let component_delimiter = spec.delimiter.as_deref().unwrap_or(" ");
238 let item_join_delim = spec.multi_cite_delimiter.as_deref().unwrap_or(", ");
239 let mut group_items_str = Vec::new();
240 let mut all_ids = Vec::new();
241
242 for item in group {
243 let state = self.resolve_item_render_state(item, spec)?;
244 if let Some(item_str) = self.render_group_item_from_template_with_format::<F>(
245 state.reference,
246 GroupItemRenderRequest {
247 item: state.item,
248 template: &state.template,
249 mode,
250 suppress_author,
251 position,
252 note_start_text_case: spec.note_start_text_case,
253 delimiter: component_delimiter,
254 },
255 ) && !item_str.is_empty()
256 {
257 group_items_str.push(self.affix_content(
258 &fmt,
259 item_str,
260 item.prefix.as_deref(),
261 item.suffix.as_deref(),
262 Some(item.id.as_str()),
263 ));
264 all_ids.push(item.id.clone());
265 }
266 }
267
268 if group_items_str.is_empty() {
269 return Ok(None);
270 }
271
272 let combined_str = group_items_str.join(item_join_delim);
273 Ok(Some(fmt.citation(all_ids, combined_str)))
274 }
275
276 pub fn render_grouped_citation_with_format<F>(
285 &self,
286 items: &[crate::reference::CitationItem],
287 params: &GroupRenderParams<'_>,
288 ) -> Result<Vec<String>, ProcessorError>
289 where
290 F: crate::render::format::OutputFormat<Output = String>,
291 {
292 let groups = group_citation_items_by_author(self, items);
293 let mut rendered_groups = Vec::new();
294 for (_author_key, group) in groups {
295 rendered_groups
296 .extend(self.render_grouped_citation_group_with_format::<F>(&group, params)?);
297 }
298
299 Ok(rendered_groups)
300 }
301
302 fn render_grouped_citation_group_with_format<F>(
303 &self,
304 group: &[&crate::reference::CitationItem],
305 params: &GroupRenderParams<'_>,
306 ) -> Result<Vec<String>, ProcessorError>
307 where
308 F: crate::render::format::OutputFormat<Output = String>,
309 {
310 let state = self.resolve_group_render_state(group, params.spec)?;
311
312 if group.len() == 1
317 && let Some(citation) = self.try_render_integral_group_with_format::<F>(
318 group,
319 params.spec,
320 params.mode,
321 params.suppress_author,
322 params.position,
323 )?
324 {
325 return Ok(vec![citation]);
326 }
327
328 if self.requires_full_group_item_rendering(params.mode, state.first_ref) {
329 return self.render_special_type_items::<F>(group, params);
330 }
331
332 Ok(self
333 .render_fallback_grouped_citation_with_format::<F>(
334 group,
335 state.first_ref,
336 state.first_item,
337 &state.template,
338 params,
339 )?
340 .into_iter()
341 .collect())
342 }
343
344 fn render_fallback_grouped_citation_with_format<F>(
345 &self,
346 group: &[&crate::reference::CitationItem],
347 first_ref: &Reference,
348 first_item: &crate::reference::CitationItem,
349 template: &[TemplateComponent],
350 params: &GroupRenderParams<'_>,
351 ) -> Result<Option<String>, ProcessorError>
352 where
353 F: crate::render::format::OutputFormat<Output = String>,
354 {
355 let fmt = F::default();
356 let author_part = self.render_author_for_grouping_with_format::<F>(
357 first_ref,
358 first_item,
359 template,
360 params.mode,
361 params.suppress_author,
362 params.position,
363 );
364 let (item_parts, group_delimiter, captured_year_wrap) =
365 self.render_group_item_parts_with_format::<F>(&fmt, group, params)?;
366 let pre_wrapped_years =
372 if matches!(params.mode, citum_schema::citation::CitationMode::Integral)
373 && !item_parts.is_empty()
374 {
375 let delimiter = group_delimiter.as_deref().unwrap_or(params.intra_delimiter);
376 let joined = self.join_integral_group_item_parts(&item_parts, delimiter);
377 let wrap_punct = captured_year_wrap
378 .as_ref()
379 .map(|w| &w.punctuation)
380 .unwrap_or(&WrapPunctuation::Parentheses);
381 let inner_prefix = captured_year_wrap
382 .as_ref()
383 .and_then(|w| w.inner_prefix.as_deref())
384 .unwrap_or("");
385 let inner_suffix = captured_year_wrap
386 .as_ref()
387 .and_then(|w| w.inner_suffix.as_deref())
388 .unwrap_or("");
389 let inner = fmt.inner_affix(inner_prefix, joined, inner_suffix);
390 let marks = crate::render::format::QuoteMarks::from(&self.locale.grammar_options);
391 let (script, realization) = crate::values::punctuation_realization_context(
392 crate::values::effective_item_language(first_ref).as_deref(),
393 self.config.multilingual.as_ref(),
394 self.locale.punctuation_realization.as_ref(),
395 );
396 Some(fmt.wrap_punctuation(
397 wrap_punct,
398 inner,
399 &marks,
400 script,
401 realization.as_deref(),
402 ))
403 } else {
404 None
405 };
406 let Some(content) = self.build_grouped_citation_content(
407 &author_part,
408 &item_parts,
409 params,
410 group_delimiter.as_deref(),
411 pre_wrapped_years.as_deref(),
412 ) else {
413 return Ok(None);
414 };
415 let group_ids = group.iter().map(|item| item.id.clone()).collect();
416 let prefix = first_item.prefix.as_deref().unwrap_or("");
417 let suffix = if item_parts.is_empty() {
420 first_item.suffix.as_deref()
421 } else {
422 None
423 };
424
425 Ok(Some(fmt.citation(
426 group_ids,
427 self.affix_content(
428 &fmt,
429 content,
430 Some(prefix),
431 suffix,
432 Some(first_item.id.as_str()),
433 ),
434 )))
435 }
436
437 fn build_grouped_citation_content(
438 &self,
439 author_part: &str,
440 item_parts: &[String],
441 params: &GroupRenderParams<'_>,
442 group_delimiter: Option<&str>,
443 pre_wrapped_years: Option<&str>,
444 ) -> Option<String> {
445 if !author_part.is_empty() && !item_parts.is_empty() {
446 let author_item_delimiter = group_delimiter.unwrap_or(params.intra_delimiter);
447 return Some(match params.mode {
448 citum_schema::citation::CitationMode::Integral => {
449 let wrapped = pre_wrapped_years.map(str::to_string).unwrap_or_else(|| {
453 self.join_integral_group_item_parts(item_parts, author_item_delimiter)
454 });
455 self.format_integral_grouped_items(
456 author_part,
457 &wrapped,
458 params.suppress_author,
459 )
460 }
461 citum_schema::citation::CitationMode::NonIntegral => {
462 let repeated_item_delimiter = if author_item_delimiter.trim().is_empty() {
463 ", "
464 } else {
465 author_item_delimiter
466 };
467 let joined_items = item_parts.join(repeated_item_delimiter);
468 self.format_non_integral_grouped_items(
469 author_part,
470 author_item_delimiter,
471 &joined_items,
472 params.suppress_author,
473 )
474 }
475 });
476 }
477
478 if !author_part.is_empty() {
479 return Some(author_part.to_string());
480 }
481
482 if !item_parts.is_empty() {
483 return Some(item_parts.join(params.intra_delimiter));
484 }
485
486 None
487 }
488
489 fn format_integral_grouped_items(
490 &self,
491 author_part: &str,
492 wrapped_content: &str,
493 suppress_author: bool,
494 ) -> String {
495 if suppress_author {
496 wrapped_content.to_string()
497 } else {
498 format!("{author_part} {wrapped_content}")
499 }
500 }
501
502 fn format_non_integral_grouped_items(
503 &self,
504 author_part: &str,
505 author_item_delimiter: &str,
506 joined_items: &str,
507 suppress_author: bool,
508 ) -> String {
509 if suppress_author {
510 return joined_items.to_string();
511 }
512
513 if let Some(adjusted) =
514 self.adjust_grouped_author_quote_punctuation(author_part, author_item_delimiter)
515 {
516 return format!("{adjusted}{joined_items}");
517 }
518
519 format!("{author_part}{author_item_delimiter}{joined_items}")
520 }
521
522 fn adjust_grouped_author_quote_punctuation(
523 &self,
524 author_part: &str,
525 author_item_delimiter: &str,
526 ) -> Option<String> {
527 if !self.config.punctuation_in_quote || !author_item_delimiter.starts_with(',') {
528 return None;
529 }
530
531 let close_quote = crate::render::format::QuoteMarks::from(self.locale).close;
532 let mut adjusted = author_part.to_string();
533 if !crate::render::punctuation::move_punctuation_into_quote(
534 &mut adjusted,
535 ',',
536 &close_quote,
537 ) {
538 return None;
539 }
540 #[allow(clippy::string_slice, reason = "delimiter checked to start with ','")]
541 Some(format!("{adjusted}{}", &author_item_delimiter[1..]))
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 label_only: false,
1178 })
1179 }
1180
1181 fn render_group_component_with_format<F>(
1182 &self,
1183 ctx: &TemplateRenderContext<'_>,
1184 group: &citum_schema::template::TemplateGroup,
1185 tracker: &mut TemplateComponentTracker,
1186 ) -> Option<ProcTemplateComponent>
1187 where
1188 F: crate::render::format::OutputFormat<Output = String>,
1189 {
1190 if group.rendering.suppress == Some(true) {
1191 return None;
1192 }
1193 if group
1194 .render_when
1195 .as_ref()
1196 .is_some_and(|condition| !group_condition_matches(ctx.reference, condition))
1197 {
1198 return None;
1199 }
1200
1201 let fmt = F::default();
1202 let mut group_tracker = tracker.clone();
1203 let (values, label_only) =
1204 self.render_group_child_values(&fmt, ctx, group, &mut group_tracker)?;
1205 let default_delimiter = citum_schema::template::DelimiterPunctuation::Comma;
1206 let punctuation = group.delimiter.as_ref().unwrap_or(&default_delimiter);
1207 let (script, realization) = crate::values::punctuation_realization_context(
1208 crate::values::effective_item_language(ctx.reference).as_deref(),
1209 ctx.options.config.multilingual.as_ref(),
1210 ctx.options.locale.punctuation_realization.as_ref(),
1211 );
1212 let delimiter = crate::render::format::realize_punctuation(
1213 punctuation,
1214 script,
1215 realization.as_deref(),
1216 crate::render::format::PunctuationPosition::Separator,
1217 );
1218 let delimiter = if punctuation.is_semantic() {
1219 fmt.text(&delimiter)
1220 } else {
1221 delimiter.into_owned()
1222 };
1223 let escaped_delimiter = fmt.join(vec![String::new(), String::new()], &delimiter);
1228 let escaped_delimiter =
1229 crate::render::format::RealizedPunctuation::new(escaped_delimiter.into());
1230 let close_quote = crate::render::format::QuoteMarks::from(ctx.options.locale).close;
1231 let joined_value = crate::render::punctuation::join_with_quote_movement(
1232 values,
1233 &escaped_delimiter,
1234 ctx.options.config.punctuation_in_quote,
1235 &close_quote,
1236 );
1237 tracker.merge_from(group_tracker);
1238 let group_component = TemplateComponent::Group(group.clone());
1239 Some(ProcTemplateComponent {
1240 template_component: group_component.clone(),
1241 template_index: self.inject_ast_indices.then_some(ctx.template_index),
1242 value: joined_value,
1243 prefix: None,
1244 suffix: None,
1245 url: None,
1246 ref_type: Some(ctx.ref_type.to_string()),
1247 config: Some(ctx.options.config.clone()),
1248 bibliography_config: ctx.options.bibliography_config.clone(),
1249 item_language: crate::values::effective_component_language(
1250 ctx.reference,
1251 &group_component,
1252 ),
1253 quote_marks: crate::render::format::QuoteMarks::from(ctx.options.locale),
1254 sentence_initial: false,
1255 pre_formatted: true,
1256 label_only,
1257 })
1258 }
1259
1260 fn render_group_child_values<F>(
1272 &self,
1273 fmt: &F,
1274 ctx: &TemplateRenderContext<'_>,
1275 group: &citum_schema::template::TemplateGroup,
1276 tracker: &mut TemplateComponentTracker,
1277 ) -> Option<(Vec<String>, bool)>
1278 where
1279 F: crate::render::format::OutputFormat<Output = String>,
1280 {
1281 let mut has_meaningful_content = false;
1282 let mut has_citation_number_label = false;
1283 let mut values = Vec::with_capacity(group.group.len());
1284
1285 for item in &group.group {
1286 let Some(rendered) =
1287 self.render_template_component_with_format::<F>(ctx, item, tracker)
1288 else {
1289 continue;
1290 };
1291 let rendered_str = crate::render::render_component_with_format_and_renderer::<F>(
1292 &rendered,
1293 fmt,
1294 ctx.options.show_semantics,
1295 );
1296 if rendered_str.trim().is_empty() {
1297 continue;
1298 }
1299 if is_citation_number_component(item) {
1300 has_citation_number_label = true;
1301 } else if !is_term_only_component(item) {
1302 has_meaningful_content = true;
1303 }
1304 values.push(rendered_str);
1305 }
1306
1307 if values.is_empty() || !(has_meaningful_content || has_citation_number_label) {
1308 return None;
1309 }
1310 Some((values, !has_meaningful_content && has_citation_number_label))
1311 }
1312
1313 fn apply_issued_no_date_fallback(
1314 &self,
1315 reference: &Reference,
1316 options: &RenderOptions<'_>,
1317 component: &TemplateComponent,
1318 values: &mut crate::values::ProcValues<String>,
1319 ) {
1320 if !matches!(
1321 component,
1322 TemplateComponent::Date(citum_schema::template::TemplateDate {
1323 date: citum_schema::template::DateVariable::Issued,
1324 ..
1325 })
1326 ) || reference.effective_issued_date().is_some()
1327 || self.preferred_no_date_term_form() != citum_schema::locale::TermForm::Long
1328 {
1329 return;
1330 }
1331
1332 if let Some(long) = options.locale.resolved_general_term(
1333 &citum_schema::locale::GeneralTerm::NoDate,
1334 &citum_schema::locale::TermForm::Long,
1335 None,
1336 ) {
1337 values.value = long;
1338 }
1339 }
1340
1341 fn apply_entry_link_fallback(
1342 &self,
1343 reference: &Reference,
1344 options: &RenderOptions<'_>,
1345 values: &mut crate::values::ProcValues<String>,
1346 ) {
1347 if values.url.is_some() {
1348 return;
1349 }
1350
1351 let Some(links) = &options.config.links else {
1352 return;
1353 };
1354 use citum_schema::options::LinkAnchor;
1355 if matches!(links.anchor, Some(LinkAnchor::Entry)) {
1356 values.url = crate::values::resolve_url(links, reference);
1357 }
1358 }
1359
1360 pub fn apply_author_substitution(&self, proc: &mut ProcTemplate, substitute: &str) {
1362 self.apply_author_substitution_with_format::<crate::render::plain::PlainText>(
1363 proc, substitute,
1364 );
1365 }
1366
1367 pub fn apply_author_substitution_with_format<F>(
1369 &self,
1370 proc: &mut ProcTemplate,
1371 substitute: &str,
1372 ) where
1373 F: crate::render::format::OutputFormat<Output = String>,
1374 {
1375 if let Some(component) = proc
1376 .iter_mut()
1377 .find(|c| matches!(c.template_component, TemplateComponent::Contributor(_)))
1378 {
1379 let fmt = F::default();
1380 component.value = fmt.text(substitute);
1381 }
1382 }
1383
1384 fn preferred_no_date_term_form(&self) -> citum_schema::locale::TermForm {
1387 match self
1388 .config
1389 .dates
1390 .as_ref()
1391 .and_then(|dates| dates.no_date_form)
1392 {
1393 Some(citum_schema::options::NoDateForm::Long) => citum_schema::locale::TermForm::Long,
1394 Some(citum_schema::options::NoDateForm::Short) | None => {
1395 citum_schema::locale::TermForm::Short
1396 }
1397 }
1398 }
1399
1400 fn render_group_item_from_template_with_format<F>(
1401 &self,
1402 reference: &Reference,
1403 item_request: GroupItemRenderRequest<'_>,
1404 ) -> Option<String>
1405 where
1406 F: crate::render::format::OutputFormat<Output = String>,
1407 {
1408 let request = self.citation_render_request(
1409 item_request.item,
1410 item_request.template,
1411 item_request.mode,
1412 item_request.suppress_author,
1413 item_request.position,
1414 item_request.note_start_text_case,
1415 );
1416 self.render_item_from_template_with_format::<F>(reference, request, item_request.delimiter)
1417 }
1418}
1419
1420pub(super) fn template_uses_first_ref_note_number(template: &[TemplateComponent]) -> bool {
1427 template.iter().any(|c| match c {
1428 TemplateComponent::Number(n) => {
1429 n.number == citum_schema::template::NumberVariable::FirstReferenceNoteNumber
1430 }
1431 TemplateComponent::Group(g) => template_uses_first_ref_note_number(&g.group),
1432 _ => false,
1433 })
1434}
1435
1436pub(super) fn filter_author_from_template<F>(
1437 template: &[TemplateComponent],
1438 script: crate::values::ScriptClass,
1439 realization: Option<&citum_schema::options::PunctuationRealization>,
1440 fmt: &F,
1441) -> (Vec<TemplateComponent>, Option<String>, bool)
1442where
1443 F: crate::render::format::OutputFormat<Output = String>,
1444{
1445 let grouping_role = template
1451 .first()
1452 .and_then(find_grouping_component)
1453 .and_then(|component| match component {
1454 TemplateComponent::Contributor(contributor)
1455 if contributor.contributor != citum_schema::template::ContributorRole::Author =>
1456 {
1457 Some(contributor.contributor.clone())
1458 }
1459 _ => None,
1460 });
1461 let mut filtered: Vec<TemplateComponent> =
1462 template.iter().filter_map(strip_author_component).collect();
1463 if let Some(role) = grouping_role
1464 && !filtered.is_empty()
1465 {
1466 let first = filtered.remove(0);
1467 if let (Some(remaining), _) = remove_first_contributor_with_role(first, &role) {
1468 filtered.insert(0, remaining);
1469 }
1470 }
1471 let stripped_leading_affix = filtered
1472 .first()
1473 .and_then(|first| leading_group_affix(first, script, realization, fmt));
1474 let leading_affix = stripped_leading_affix.clone().or_else(|| {
1475 filtered.first().and_then(|_| {
1476 template
1477 .first()
1478 .and_then(|first| author_group_delimiter_affix(first, script, realization, fmt))
1479 })
1480 });
1481 if let Some(first) = filtered.first_mut() {
1482 strip_leading_group_affixes(first);
1483 }
1484 (filtered, leading_affix, stripped_leading_affix.is_some())
1485}
1486
1487fn author_group_delimiter_affix<F>(
1488 component: &TemplateComponent,
1489 script: crate::values::ScriptClass,
1490 realization: Option<&citum_schema::options::PunctuationRealization>,
1491 fmt: &F,
1492) -> Option<String>
1493where
1494 F: crate::render::format::OutputFormat<Output = String>,
1495{
1496 let TemplateComponent::Group(group) = component else {
1497 return None;
1498 };
1499 group
1500 .group
1501 .first()
1502 .is_some_and(component_starts_with_author)
1503 .then_some(group.delimiter.as_ref())
1504 .flatten()
1505 .map(|punctuation| {
1506 let realized = crate::render::format::realize_punctuation(
1507 punctuation,
1508 script,
1509 realization,
1510 crate::render::format::PunctuationPosition::Separator,
1511 );
1512 if punctuation.is_semantic() {
1513 fmt.text(&realized)
1514 } else {
1515 realized.into_owned()
1516 }
1517 })
1518 .filter(|delimiter| !delimiter.is_empty())
1519}
1520
1521fn component_starts_with_author(component: &TemplateComponent) -> bool {
1522 match component {
1523 TemplateComponent::Contributor(contributor) => contributor
1524 .contributor
1525 .contains(&citum_schema::template::ContributorRole::Author),
1526 TemplateComponent::Group(group) => group
1527 .group
1528 .first()
1529 .is_some_and(component_starts_with_author),
1530 _ => false,
1531 }
1532}
1533
1534#[cfg(test)]
1535mod tests {
1536 use super::*;
1537 use citum_schema::template::{
1538 ContributorRole, DelimiterPunctuation, TemplateContributor, TemplateGroup,
1539 };
1540
1541 #[test]
1542 fn author_group_delimiter_affix_recognizes_merged_leading_author_component() {
1543 let group = TemplateComponent::Group(TemplateGroup {
1546 group: vec![TemplateComponent::Contributor(TemplateContributor {
1547 contributor: vec![ContributorRole::Author, ContributorRole::Editor].into(),
1548 ..Default::default()
1549 })],
1550 delimiter: Some(DelimiterPunctuation::Comma),
1551 ..Default::default()
1552 });
1553
1554 let affix = author_group_delimiter_affix(
1556 &group,
1557 crate::values::ScriptClass::Latin,
1558 None,
1559 &crate::render::plain::PlainText,
1560 );
1561
1562 assert_eq!(affix, Some(", ".to_string()));
1564 }
1565
1566 #[test]
1567 fn author_group_delimiter_affix_ignores_merged_component_without_author() {
1568 let group = TemplateComponent::Group(TemplateGroup {
1571 group: vec![TemplateComponent::Contributor(TemplateContributor {
1572 contributor: vec![ContributorRole::Editor, ContributorRole::Translator].into(),
1573 ..Default::default()
1574 })],
1575 delimiter: Some(DelimiterPunctuation::Comma),
1576 ..Default::default()
1577 });
1578
1579 let affix = author_group_delimiter_affix(
1581 &group,
1582 crate::values::ScriptClass::Latin,
1583 None,
1584 &crate::render::plain::PlainText,
1585 );
1586
1587 assert_eq!(affix, None);
1589 }
1590}