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(chunk) = 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 None,
214 "",
215 ) {
216 rendered_items.push(fmt.citation(chunk.ids, chunk.content));
217 }
218 }
219 Ok(rendered_items)
220 }
221
222 fn render_integral_explicit_group<F>(
227 &self,
228 group: &[&crate::reference::CitationItem],
229 spec: &citum_schema::CitationSpec,
230 mode: &citum_schema::citation::CitationMode,
231 suppress_author: bool,
232 position: Option<&citum_schema::citation::Position>,
233 ) -> Result<Option<String>, ProcessorError>
234 where
235 F: crate::render::format::OutputFormat<Output = String>,
236 {
237 let fmt = F::default();
238 let component_delimiter = spec.delimiter.as_deref().unwrap_or(" ");
239 let item_join_delim = spec.multi_cite_delimiter.as_deref().unwrap_or(", ");
240 let mut group_items_str = Vec::new();
241 let mut all_ids = Vec::new();
242
243 for item in group {
244 let state = self.resolve_item_render_state(item, spec)?;
245 if let Some(item_str) = self.render_group_item_from_template_with_format::<F>(
246 state.reference,
247 GroupItemRenderRequest {
248 item: state.item,
249 template: &state.template,
250 mode,
251 suppress_author,
252 position,
253 note_start_text_case: spec.note_start_text_case,
254 delimiter: component_delimiter,
255 },
256 ) && !item_str.is_empty()
257 {
258 group_items_str.push(self.affix_content(
259 &fmt,
260 item_str,
261 item.prefix.as_deref(),
262 item.suffix.as_deref(),
263 Some(item.id.as_str()),
264 ));
265 all_ids.push(item.id.clone());
266 }
267 }
268
269 if group_items_str.is_empty() {
270 return Ok(None);
271 }
272
273 let combined_str = group_items_str.join(item_join_delim);
274 Ok(Some(fmt.citation(all_ids, combined_str)))
275 }
276
277 pub fn render_grouped_citation_with_format<F>(
286 &self,
287 items: &[crate::reference::CitationItem],
288 params: &GroupRenderParams<'_>,
289 ) -> Result<Vec<String>, ProcessorError>
290 where
291 F: crate::render::format::OutputFormat<Output = String>,
292 {
293 let groups = group_citation_items_by_author(self, items);
294 let mut rendered_groups = Vec::new();
295 for (_author_key, group) in groups {
296 rendered_groups
297 .extend(self.render_grouped_citation_group_with_format::<F>(&group, params)?);
298 }
299
300 Ok(rendered_groups)
301 }
302
303 fn render_grouped_citation_group_with_format<F>(
304 &self,
305 group: &[&crate::reference::CitationItem],
306 params: &GroupRenderParams<'_>,
307 ) -> Result<Vec<String>, ProcessorError>
308 where
309 F: crate::render::format::OutputFormat<Output = String>,
310 {
311 let state = self.resolve_group_render_state(group, params.spec)?;
312
313 if group.len() == 1
318 && let Some(citation) = self.try_render_integral_group_with_format::<F>(
319 group,
320 params.spec,
321 params.mode,
322 params.suppress_author,
323 params.position,
324 )?
325 {
326 return Ok(vec![citation]);
327 }
328
329 if self.requires_full_group_item_rendering(params.mode, state.first_ref) {
330 return self.render_special_type_items::<F>(group, params);
331 }
332
333 Ok(self
334 .render_fallback_grouped_citation_with_format::<F>(
335 group,
336 state.first_ref,
337 state.first_item,
338 &state.template,
339 params,
340 )?
341 .into_iter()
342 .collect())
343 }
344
345 fn render_fallback_grouped_citation_with_format<F>(
346 &self,
347 group: &[&crate::reference::CitationItem],
348 first_ref: &Reference,
349 first_item: &crate::reference::CitationItem,
350 template: &[TemplateComponent],
351 params: &GroupRenderParams<'_>,
352 ) -> Result<Option<String>, ProcessorError>
353 where
354 F: crate::render::format::OutputFormat<Output = String>,
355 {
356 let fmt = F::default();
357 let author_part = self.render_author_for_grouping_with_format::<F>(
358 first_ref,
359 first_item,
360 template,
361 params.mode,
362 params.suppress_author,
363 params.position,
364 );
365 let (item_parts, group_delimiter, captured_year_wrap) =
366 self.render_group_item_parts_with_format::<F>(&fmt, group, params)?;
367 let pre_wrapped_years =
373 if matches!(params.mode, citum_schema::citation::CitationMode::Integral)
374 && !item_parts.is_empty()
375 {
376 let delimiter = group_delimiter.as_deref().unwrap_or(params.intra_delimiter);
377 let joined = self.join_integral_group_item_parts(&item_parts, delimiter);
378 let wrap_punct = captured_year_wrap
379 .as_ref()
380 .map(|w| &w.punctuation)
381 .unwrap_or(&WrapPunctuation::Parentheses);
382 let inner_prefix = captured_year_wrap
383 .as_ref()
384 .and_then(|w| w.inner_prefix.as_deref())
385 .unwrap_or("");
386 let inner_suffix = captured_year_wrap
387 .as_ref()
388 .and_then(|w| w.inner_suffix.as_deref())
389 .unwrap_or("");
390 let inner = fmt.inner_affix(inner_prefix, joined, inner_suffix);
391 let marks = crate::render::format::QuoteMarks::from(&self.locale.grammar_options);
392 let (script, realization) = crate::values::punctuation_realization_context(
393 crate::values::effective_item_language(first_ref).as_deref(),
394 self.config.multilingual.as_ref(),
395 self.locale.punctuation_realization.as_ref(),
396 );
397 Some(fmt.wrap_punctuation(
398 wrap_punct,
399 inner,
400 &marks,
401 script,
402 realization.as_deref(),
403 ))
404 } else {
405 None
406 };
407 let Some(content) = self.build_grouped_citation_content::<F>(
408 &author_part,
409 &item_parts,
410 params,
411 group_delimiter.as_deref(),
412 pre_wrapped_years.as_deref(),
413 ) else {
414 return Ok(None);
415 };
416 let group_ids = group.iter().map(|item| item.id.clone()).collect();
417 let prefix = first_item.prefix.as_deref().unwrap_or("");
418 let suffix = if item_parts.is_empty() {
421 first_item.suffix.as_deref()
422 } else {
423 None
424 };
425
426 Ok(Some(fmt.citation(
427 group_ids,
428 self.affix_content(
429 &fmt,
430 content,
431 Some(prefix),
432 suffix,
433 Some(first_item.id.as_str()),
434 ),
435 )))
436 }
437
438 fn build_grouped_citation_content<F: crate::render::format::OutputFormat<Output = String>>(
439 &self,
440 author_part: &str,
441 item_parts: &[String],
442 params: &GroupRenderParams<'_>,
443 group_delimiter: Option<&str>,
444 pre_wrapped_years: Option<&str>,
445 ) -> Option<String> {
446 if !author_part.is_empty() && !item_parts.is_empty() {
447 let author_item_delimiter = group_delimiter.unwrap_or(params.intra_delimiter);
448 return Some(match params.mode {
449 citum_schema::citation::CitationMode::Integral => {
450 let wrapped = pre_wrapped_years.map(str::to_string).unwrap_or_else(|| {
454 self.join_integral_group_item_parts(item_parts, author_item_delimiter)
455 });
456 self.format_integral_grouped_items(
457 author_part,
458 &wrapped,
459 params.suppress_author,
460 )
461 }
462 citum_schema::citation::CitationMode::NonIntegral => {
463 let repeated_item_delimiter = if author_item_delimiter.trim().is_empty() {
464 ", "
465 } else {
466 author_item_delimiter
467 };
468 let joined_items = item_parts.join(repeated_item_delimiter);
469 self.format_non_integral_grouped_items::<F>(
470 author_part,
471 author_item_delimiter,
472 &joined_items,
473 params.suppress_author,
474 )
475 }
476 });
477 }
478
479 if !author_part.is_empty() {
480 return Some(author_part.to_string());
481 }
482
483 if !item_parts.is_empty() {
484 return Some(item_parts.join(params.intra_delimiter));
485 }
486
487 None
488 }
489
490 fn format_integral_grouped_items(
491 &self,
492 author_part: &str,
493 wrapped_content: &str,
494 suppress_author: bool,
495 ) -> String {
496 if suppress_author {
497 wrapped_content.to_string()
498 } else {
499 format!("{author_part} {wrapped_content}")
500 }
501 }
502
503 fn format_non_integral_grouped_items<
504 F: crate::render::format::OutputFormat<Output = String>,
505 >(
506 &self,
507 author_part: &str,
508 author_item_delimiter: &str,
509 joined_items: &str,
510 suppress_author: bool,
511 ) -> String {
512 if suppress_author {
513 return joined_items.to_string();
514 }
515
516 if let Some(adjusted) =
517 self.adjust_grouped_author_quote_punctuation::<F>(author_part, author_item_delimiter)
518 {
519 return format!("{adjusted}{joined_items}");
520 }
521
522 format!("{author_part}{author_item_delimiter}{joined_items}")
523 }
524
525 fn adjust_grouped_author_quote_punctuation<
526 F: crate::render::format::OutputFormat<Output = String>,
527 >(
528 &self,
529 author_part: &str,
530 author_item_delimiter: &str,
531 ) -> Option<String> {
532 if !self.config.punctuation_in_quote || !author_item_delimiter.starts_with(',') {
533 return None;
534 }
535
536 let close_quote = crate::render::format::QuoteMarks::from(self.locale).close;
537 let mut adjusted = author_part.to_string();
538 if !crate::render::punctuation::move_punctuation_into_quote::<F>(
539 &mut adjusted,
540 ',',
541 &close_quote,
542 ) {
543 return None;
544 }
545 #[allow(clippy::string_slice, reason = "delimiter checked to start with ','")]
546 Some(format!("{adjusted}{}", &author_item_delimiter[1..]))
547 }
548
549 fn render_group_item_parts_with_format<F>(
550 &self,
551 fmt: &F,
552 group: &[&crate::reference::CitationItem],
553 params: &GroupRenderParams<'_>,
554 ) -> Result<(Vec<String>, Option<String>, Option<WrapConfig>), ProcessorError>
555 where
556 F: crate::render::format::OutputFormat<Output = String>,
557 {
558 let mut item_parts = Vec::new();
559 let mut group_delimiter: Option<String> = None;
560 let mut captured_year_wrap: Option<WrapConfig> = None;
567 let collapse_group = group.len() > 1
568 && matches!(params.mode, citum_schema::citation::CitationMode::Integral);
569 for (index, item) in group.iter().enumerate() {
570 let state = self.resolve_item_render_state(item, params.spec)?;
571 let (script, realization) = crate::values::punctuation_realization_context(
572 crate::values::effective_item_language(state.reference).as_deref(),
573 self.config.multilingual.as_ref(),
574 self.locale.punctuation_realization.as_ref(),
575 );
576 let (mut filtered_template, leading_affix, strip_item_delimiter) =
577 filter_author_from_template::<F>(
578 &state.template,
579 script,
580 realization.as_deref(),
581 fmt,
582 );
583 if collapse_group {
584 if index == 0 {
585 captured_year_wrap = filtered_template
590 .first_mut()
591 .and_then(|c| c.rendering_mut().wrap.take());
592 } else {
593 if let Some(first) = filtered_template.first_mut() {
595 first.rendering_mut().wrap = None;
596 }
597 }
598 }
599 if group_delimiter.is_none() {
600 group_delimiter = leading_affix
601 .as_ref()
602 .filter(|value| !value.is_empty())
603 .cloned();
604 }
605 let item_delimiter = if strip_item_delimiter {
606 ""
607 } else {
608 params.intra_delimiter
609 };
610 if let Some(item_str) = self.render_group_item_from_template_with_format::<F>(
611 state.reference,
612 GroupItemRenderRequest {
613 item: state.item,
614 template: &filtered_template,
615 mode: params.mode,
616 suppress_author: params.suppress_author,
617 position: params.position,
618 note_start_text_case: params.note_start_text_case,
619 delimiter: item_delimiter,
620 },
621 ) && !item_str.is_empty()
622 {
623 let prefix = (index > 0).then_some(item.prefix.as_deref()).flatten();
624 item_parts.push(self.affix_content(
625 fmt,
626 item_str,
627 prefix,
628 item.suffix.as_deref(),
629 Some(item.id.as_str()),
630 ));
631 }
632 }
633 Ok((item_parts, group_delimiter, captured_year_wrap))
634 }
635
636 fn resolve_group_render_state<'b>(
637 &'b self,
638 group: &'b [&'b crate::reference::CitationItem],
639 spec: &'b citum_schema::CitationSpec,
640 ) -> Result<GroupRenderState<'b>, ProcessorError> {
641 #[allow(clippy::indexing_slicing, reason = "groups are non-empty")]
642 let first_item = group[0];
643 let first_ref = self
644 .bibliography
645 .get(&first_item.id)
646 .ok_or_else(|| ProcessorError::ReferenceNotFound(first_item.id.clone()))?;
647 let first_language = crate::values::effective_item_language(first_ref);
648 let ref_type = first_ref.ref_type();
649 let localized = spec.resolve_localized_template(first_language.as_deref());
650 let first_template = localized
651 .as_ref()
652 .filter(|resolved| resolved.type_variants.is_some())
653 .cloned()
654 .map(|resolved| {
655 Cow::Owned(resolve_localized_type_variant(
656 resolved,
657 spec.type_variants.as_ref(),
658 &ref_type,
659 ))
660 })
661 .or_else(|| {
662 resolve_type_variant(spec.type_variants.as_ref(), &ref_type).map(Cow::Borrowed)
663 })
664 .or_else(|| localized.map(|resolved| Cow::Owned(resolved.template)));
665
666 Ok(GroupRenderState {
667 first_item,
668 first_ref,
669 template: first_template.unwrap_or(Cow::Borrowed(&[])),
670 })
671 }
672
673 fn resolve_item_render_state<'b>(
674 &'b self,
675 item: &'b crate::reference::CitationItem,
676 spec: &'b citum_schema::CitationSpec,
677 ) -> Result<ItemRenderState<'b>, ProcessorError> {
678 let reference = self
679 .bibliography
680 .get(&item.id)
681 .ok_or_else(|| ProcessorError::ReferenceNotFound(item.id.clone()))?;
682 let item_language = crate::values::effective_item_language(reference);
683 let ref_type = reference.ref_type();
684 let localized = spec.resolve_localized_template(item_language.as_deref());
685 let item_template = localized
686 .as_ref()
687 .filter(|resolved| resolved.type_variants.is_some())
688 .cloned()
689 .map(|resolved| {
690 Cow::Owned(resolve_localized_type_variant(
691 resolved,
692 spec.type_variants.as_ref(),
693 &ref_type,
694 ))
695 })
696 .or_else(|| {
697 resolve_type_variant(spec.type_variants.as_ref(), &ref_type).map(Cow::Borrowed)
698 })
699 .or_else(|| localized.map(|resolved| Cow::Owned(resolved.template)));
700
701 Ok(ItemRenderState {
702 item,
703 reference,
704 template: item_template.unwrap_or(Cow::Borrowed(&[])),
705 })
706 }
707
708 fn try_render_integral_group_with_format<F>(
709 &self,
710 group: &[&crate::reference::CitationItem],
711 spec: &citum_schema::CitationSpec,
712 mode: &citum_schema::citation::CitationMode,
713 suppress_author: bool,
714 position: Option<&citum_schema::citation::Position>,
715 ) -> Result<Option<String>, ProcessorError>
716 where
717 F: crate::render::format::OutputFormat<Output = String>,
718 {
719 if !matches!(mode, citum_schema::citation::CitationMode::Integral)
720 || !self.has_explicit_integral_template()
721 {
722 return Ok(None);
723 }
724
725 self.render_integral_explicit_group::<F>(group, spec, mode, suppress_author, position)
726 }
727
728 fn requires_full_group_item_rendering(
739 &self,
740 mode: &citum_schema::citation::CitationMode,
741 reference: &Reference,
742 ) -> bool {
743 matches!(mode, citum_schema::citation::CitationMode::NonIntegral)
744 && matches!(
745 reference.ref_type().as_str(),
746 "legal-case" | "treaty" | "hearing" | "personal-communication"
747 )
748 }
749
750 pub(crate) fn render_author_for_grouping_with_format<F>(
752 &self,
753 reference: &Reference,
754 item: &crate::reference::CitationItem,
755 template: &[TemplateComponent],
756 mode: &citum_schema::citation::CitationMode,
757 suppress_author: bool,
758 position: Option<&citum_schema::citation::Position>,
759 ) -> String
760 where
761 F: crate::render::format::OutputFormat<Output = String>,
762 {
763 let is_note_processing = self.config.processing.as_ref().is_some_and(|processing| {
764 matches!(processing, citum_schema::options::Processing::Note)
765 });
766 if is_note_processing
767 && matches!(
768 position,
769 Some(
770 citum_schema::citation::Position::Ibid
771 | citum_schema::citation::Position::IbidWithLocator
772 )
773 )
774 && !template.iter().any(has_contributor_component)
775 {
776 return String::new();
777 }
778
779 let locale = self.locale_for_reference(reference, RenderContext::Citation);
780 let options = self.citation_render_options(
781 locale.as_ref(),
782 mode.clone(),
783 suppress_author,
784 None,
785 None,
786 );
787
788 if let Some(comp) = template.first().and_then(find_grouping_component) {
792 let base_hints = self
793 .hints
794 .get(reference.id().as_deref().unwrap_or_default())
795 .cloned()
796 .unwrap_or_default();
797 let hints = ProcHints {
799 position: position.cloned(),
800 integral_name_state: item.integral_name_state,
801 ..base_hints
802 };
803 if let Some(vals) = comp.values::<F>(reference, &hints, &options)
804 && !vals.value.is_empty()
805 {
806 return vals.value;
807 }
808 }
809
810 if let Some(authors) = reference.author() {
812 let names_vec = self.resolve_contributor_names(&authors);
813 F::default().text(&crate::values::format_contributors_short(
814 &names_vec, &options,
815 ))
816 } else {
817 String::new()
818 }
819 }
820
821 pub(crate) fn render_integral_anchor_with_format<F>(
823 &self,
824 items: &[crate::reference::CitationItem],
825 spec: &citum_schema::CitationSpec,
826 inter_delimiter: &str,
827 suppress_author: bool,
828 position: Option<&citum_schema::citation::Position>,
829 ) -> Result<String, ProcessorError>
830 where
831 F: crate::render::format::OutputFormat<Output = String>,
832 {
833 let groups = group_citation_items_by_author(self, items);
834
835 let mut rendered_groups = Vec::new();
836 let fmt = F::default();
837 for (_author_key, group) in groups {
838 #[allow(
839 clippy::indexing_slicing,
840 reason = "group is non-empty by construction"
841 )]
842 let first_item = group[0];
843 let reference = self
844 .bibliography
845 .get(&first_item.id)
846 .ok_or_else(|| ProcessorError::ReferenceNotFound(first_item.id.clone()))?;
847 let item_language = crate::values::effective_item_language(reference);
848 let template = spec.resolve_template_for_language(item_language.as_deref());
849 let effective_template = template.as_deref().unwrap_or(&[]);
850 let author_part = self.render_author_for_grouping_with_format::<F>(
851 reference,
852 first_item,
853 effective_template,
854 &citum_schema::citation::CitationMode::Integral,
855 suppress_author,
856 position,
857 );
858 if !author_part.is_empty() {
859 rendered_groups.push(author_part);
860 }
861 }
862
863 Ok(fmt.join(rendered_groups, inter_delimiter))
864 }
865
866 #[must_use]
868 pub fn get_or_assign_citation_number(&self, ref_id: &str) -> usize {
869 let mut numbers = self
870 .citation_numbers
871 .write()
872 .unwrap_or_else(std::sync::PoisonError::into_inner);
873 let next_num = numbers.len() + 1;
874 *numbers.entry(ref_id.to_string()).or_insert(next_num)
875 }
876
877 #[must_use]
879 pub fn process_bibliography_entry(
880 &self,
881 reference: &Reference,
882 entry_number: usize,
883 ) -> Option<ProcTemplate> {
884 self.process_bibliography_entry_with_format::<crate::render::plain::PlainText>(
885 reference,
886 entry_number,
887 )
888 }
889
890 #[must_use]
897 pub(crate) fn bibliography_marker_with_format<F>(
898 &self,
899 reference: &Reference,
900 entry_number: usize,
901 ) -> Option<String>
902 where
903 F: crate::render::format::OutputFormat<Output = String>,
904 {
905 let spec =
906 super::super::marker::resolve_bibliography_marker(self.bibliography_config.as_deref())?;
907 let ref_id = reference.id().unwrap_or_default().to_string();
908 let value = super::super::marker::marker_value(
909 spec.kind,
910 &self.config,
911 reference,
912 Some(entry_number),
913 None,
914 self.hints.get(&ref_id),
915 )?;
916 let fmt = F::default();
917 Some(self.present_bibliography_marker_with_format(&fmt, &spec, &value, Some(&ref_id)))
918 }
919
920 #[must_use]
922 pub fn process_bibliography_entry_with_format<F>(
923 &self,
924 reference: &Reference,
925 entry_number: usize,
926 ) -> Option<ProcTemplate>
927 where
928 F: crate::render::format::OutputFormat<Output = String>,
929 {
930 let bib_spec = self.style.bibliography.as_ref()?;
931
932 let item_language = crate::values::effective_item_language(reference);
933 let ref_type = reference.ref_type();
934 let localized = bib_spec.resolve_localized_template(item_language.as_deref());
935 let template = localized
936 .as_ref()
937 .filter(|resolved| resolved.type_variants.is_some())
938 .cloned()
939 .map(|resolved| {
940 Cow::Owned(resolve_localized_type_variant(
941 resolved,
942 bib_spec.type_variants.as_ref(),
943 &ref_type,
944 ))
945 })
946 .or_else(|| {
947 resolve_type_variant(bib_spec.type_variants.as_ref(), &ref_type).map(Cow::Borrowed)
948 })
949 .or_else(|| localized.map(|resolved| Cow::Owned(resolved.template)))?;
950
951 let template = self.apply_anonymous_entry_bibliography_policy(reference, template)?;
952 let template = self.apply_article_journal_bibliography_policy(reference, template);
953
954 self.process_template_request_with_format::<F>(
955 reference,
956 TemplateRenderRequest {
957 template: template.as_ref(),
958 context: RenderContext::Bibliography,
959 mode: citum_schema::citation::CitationMode::NonIntegral,
960 suppress_author: false,
961 locator_raw: None,
962 citation_number: entry_number,
963 position: None,
964 note_start_text_case: None,
965 integral_name_state: None,
966 org_abbreviation_state: None,
967 first_reference_note_number: None,
968 },
969 )
970 }
971
972 #[must_use]
977 pub fn process_template_with_number(
978 &self,
979 reference: &Reference,
980 params: TemplateRenderParams<'_>,
981 ) -> Option<ProcTemplate> {
982 self.process_template_with_number_with_format::<crate::render::plain::PlainText>(
983 reference, params,
984 )
985 }
986
987 pub fn process_template_with_number_with_format<F>(
992 &self,
993 reference: &Reference,
994 params: TemplateRenderParams<'_>,
995 ) -> Option<ProcTemplate>
996 where
997 F: crate::render::format::OutputFormat<Output = String>,
998 {
999 self.process_template_request_with_format::<F>(
1000 reference,
1001 TemplateRenderRequest {
1002 template: params.template,
1003 context: params.context,
1004 mode: params.mode,
1005 suppress_author: params.suppress_author,
1006 locator_raw: params.locator_raw,
1007 citation_number: params.citation_number,
1008 position: params.position.cloned(),
1009 note_start_text_case: params.note_start_text_case,
1010 integral_name_state: params.integral_name_state,
1011 org_abbreviation_state: params.org_abbreviation_state,
1012 first_reference_note_number: None,
1013 },
1014 )
1015 }
1016
1017 #[must_use]
1019 pub fn process_template_request_with_format<F>(
1020 &self,
1021 reference: &Reference,
1022 request: TemplateRenderRequest<'_>,
1023 ) -> Option<ProcTemplate>
1024 where
1025 F: crate::render::format::OutputFormat<Output = String>,
1026 {
1027 let TemplateRenderRequest {
1028 template,
1029 context,
1030 mode,
1031 suppress_author,
1032 locator_raw,
1033 citation_number,
1034 position,
1035 note_start_text_case,
1036 integral_name_state,
1037 org_abbreviation_state,
1038 first_reference_note_number,
1039 } = request;
1040 let ref_type = reference.ref_type();
1041 let template = crate::values::date::materialize_identity_date_substitute(
1042 template,
1043 &self.config,
1044 &ref_type,
1045 );
1046 let locale = self.locale_for_reference(reference, context);
1047 let options = RenderOptions {
1048 config: self.config.clone(),
1049 bibliography_config: self.bibliography_config.clone(),
1050 locale: locale.as_ref(),
1051 context,
1052 mode,
1053 suppress_author,
1054 locator_raw,
1055 ref_type: Some(ref_type.clone()),
1056 show_semantics: self.show_semantics,
1057 current_template_index: None,
1058 abbreviation_map: self.abbreviation_map,
1059 };
1060 let effective_first_ref_note = if template_uses_first_ref_note_number(&template) {
1065 first_reference_note_number
1066 } else {
1067 None
1068 };
1069 let hint = self.build_template_render_hint(HintInputs {
1070 reference,
1071 context: options.context,
1072 citation_number,
1073 position,
1074 integral_name_state,
1075 org_abbreviation_state,
1076 first_reference_note_number: effective_first_ref_note,
1077 });
1078 let mut components =
1079 self.render_template_components::<F>(reference, &ref_type, &options, &hint, &template);
1080
1081 self.apply_sentence_initial_context::<F>(&mut components, context, note_start_text_case);
1082
1083 (!components.is_empty()).then_some(components)
1084 }
1085
1086 fn render_template_components<F>(
1090 &self,
1091 reference: &Reference,
1092 ref_type: &str,
1093 options: &RenderOptions<'_>,
1094 hint: &ProcHints,
1095 template: &[TemplateComponent],
1096 ) -> Vec<ProcTemplateComponent>
1097 where
1098 F: crate::render::format::OutputFormat<Output = String>,
1099 {
1100 let mut tracker = TemplateComponentTracker::default();
1101 let mut components = Vec::with_capacity(template.len());
1102 let mut component_options = options.clone();
1103 for (template_index, component) in template.iter().enumerate() {
1104 component_options.current_template_index =
1105 self.inject_ast_indices.then_some(template_index);
1106 let ctx = TemplateRenderContext {
1107 reference,
1108 ref_type,
1109 options: &component_options,
1110 hint,
1111 template_index,
1112 };
1113 if let Some(component) =
1114 self.render_template_component_with_format::<F>(&ctx, component, &mut tracker)
1115 {
1116 components.push(component);
1117 }
1118 }
1119 components
1120 }
1121
1122 fn build_template_render_hint(&self, inputs: HintInputs<'_>) -> ProcHints {
1123 let HintInputs {
1124 reference,
1125 context,
1126 citation_number,
1127 position,
1128 integral_name_state,
1129 org_abbreviation_state,
1130 first_reference_note_number,
1131 } = inputs;
1132 let default_hint = ProcHints::default();
1133 let base_hint = self
1134 .hints
1135 .get(reference.id().as_deref().unwrap_or_default())
1136 .unwrap_or(&default_hint);
1137 let is_subsequent = matches!(position, Some(citum_schema::citation::Position::Subsequent));
1138 ProcHints {
1139 citation_number: (citation_number > 0).then_some(citation_number),
1140 citation_sub_label: if context == RenderContext::Citation {
1141 reference
1142 .id()
1143 .as_deref()
1144 .and_then(|id| self.citation_sub_label_for_ref(id))
1145 } else {
1146 None
1147 },
1148 position,
1149 integral_name_state,
1150 org_abbreviation_state,
1151 first_reference_note_number: if is_subsequent {
1152 first_reference_note_number
1153 } else {
1154 None
1155 },
1156 suppress_disambiguation_title: is_subsequent && first_reference_note_number.is_some(),
1157 ..base_hint.clone()
1158 }
1159 }
1160
1161 fn render_template_component_with_format<F>(
1162 &self,
1163 ctx: &TemplateRenderContext<'_>,
1164 component: &TemplateComponent,
1165 tracker: &mut TemplateComponentTracker,
1166 ) -> Option<ProcTemplateComponent>
1167 where
1168 F: crate::render::format::OutputFormat<Output = String>,
1169 {
1170 if let TemplateComponent::Group(group) = component {
1171 return self.render_group_component_with_format::<F>(ctx, group, tracker);
1172 }
1173
1174 let resolved_component = component;
1175 if resolved_component.rendering().suppress == Some(true) {
1176 return None;
1177 }
1178
1179 let var_key = get_variable_key(resolved_component);
1180 if tracker.should_skip(var_key.as_deref()) {
1181 return None;
1182 }
1183
1184 let mut values = resolved_component.values::<F>(ctx.reference, ctx.hint, ctx.options)?;
1185 if values.value.trim().is_empty() {
1189 return None;
1190 }
1191 self.apply_issued_no_date_fallback(
1192 ctx.reference,
1193 ctx.options,
1194 resolved_component,
1195 &mut values,
1196 );
1197 self.apply_entry_link_fallback(ctx.reference, ctx.options, &mut values);
1198
1199 let item_language =
1200 crate::values::effective_component_language(ctx.reference, resolved_component);
1201 tracker.mark_rendered(var_key, values.substituted_key.as_deref());
1202
1203 Some(ProcTemplateComponent {
1204 template_component: resolved_component.clone(),
1205 template_index: self.inject_ast_indices.then_some(ctx.template_index),
1206 value: values.value,
1207 prefix: values.prefix,
1208 suffix: values.suffix,
1209 url: values.url,
1210 ref_type: Some(ctx.ref_type.to_string()),
1211 config: Some(ctx.options.config.clone()),
1212 bibliography_config: ctx.options.bibliography_config.clone(),
1213 item_language,
1214 quote_marks: crate::render::format::QuoteMarks::from(ctx.options.locale),
1215 sentence_initial: false,
1216 pre_formatted: values.pre_formatted,
1217 })
1218 }
1219
1220 fn render_group_component_with_format<F>(
1221 &self,
1222 ctx: &TemplateRenderContext<'_>,
1223 group: &citum_schema::template::TemplateGroup,
1224 tracker: &mut TemplateComponentTracker,
1225 ) -> Option<ProcTemplateComponent>
1226 where
1227 F: crate::render::format::OutputFormat<Output = String>,
1228 {
1229 if group.rendering.suppress == Some(true) {
1230 return None;
1231 }
1232 if group
1233 .render_when
1234 .as_ref()
1235 .is_some_and(|condition| !group_condition_matches(ctx.reference, condition))
1236 {
1237 return None;
1238 }
1239
1240 let fmt = F::default();
1241 let mut group_tracker = tracker.clone();
1242 let values = self.render_group_child_values(&fmt, ctx, group, &mut group_tracker)?;
1243 let default_delimiter = citum_schema::template::DelimiterPunctuation::Comma;
1244 let punctuation = group.delimiter.as_ref().unwrap_or(&default_delimiter);
1245 let (script, realization) = crate::values::punctuation_realization_context(
1246 crate::values::effective_item_language(ctx.reference).as_deref(),
1247 ctx.options.config.multilingual.as_ref(),
1248 ctx.options.locale.punctuation_realization.as_ref(),
1249 );
1250 let delimiter = crate::render::format::realize_punctuation(
1251 punctuation,
1252 script,
1253 realization.as_deref(),
1254 crate::render::format::PunctuationPosition::Separator,
1255 );
1256 let delimiter = if punctuation.is_semantic() {
1257 fmt.text(&delimiter)
1258 } else {
1259 delimiter.into_owned()
1260 };
1261 let escaped_delimiter = fmt.join(vec![String::new(), String::new()], &delimiter);
1266 let escaped_delimiter =
1267 crate::render::format::RealizedPunctuation::new(escaped_delimiter.into());
1268 let close_quote = crate::render::format::QuoteMarks::from(ctx.options.locale).close;
1269 let joined_value = crate::render::punctuation::join_with_quote_movement::<F>(
1270 values,
1271 &escaped_delimiter,
1272 ctx.options.config.punctuation_in_quote,
1273 &close_quote,
1274 );
1275 tracker.merge_from(group_tracker);
1276 let group_component = TemplateComponent::Group(group.clone());
1277 Some(ProcTemplateComponent {
1278 template_component: group_component.clone(),
1279 template_index: self.inject_ast_indices.then_some(ctx.template_index),
1280 value: joined_value,
1281 prefix: None,
1282 suffix: None,
1283 url: None,
1284 ref_type: Some(ctx.ref_type.to_string()),
1285 config: Some(ctx.options.config.clone()),
1286 bibliography_config: ctx.options.bibliography_config.clone(),
1287 item_language: crate::values::effective_component_language(
1288 ctx.reference,
1289 &group_component,
1290 ),
1291 quote_marks: crate::render::format::QuoteMarks::from(ctx.options.locale),
1292 sentence_initial: false,
1293 pre_formatted: true,
1294 })
1295 }
1296
1297 fn render_group_child_values<F>(
1309 &self,
1310 fmt: &F,
1311 ctx: &TemplateRenderContext<'_>,
1312 group: &citum_schema::template::TemplateGroup,
1313 tracker: &mut TemplateComponentTracker,
1314 ) -> Option<Vec<crate::render::component::RenderedComponent>>
1315 where
1316 F: crate::render::format::OutputFormat<Output = String>,
1317 {
1318 let mut has_meaningful_content = false;
1319 let mut values = Vec::with_capacity(group.group.len());
1320
1321 for item in &group.group {
1322 let Some(rendered) =
1323 self.render_template_component_with_format::<F>(ctx, item, tracker)
1324 else {
1325 continue;
1326 };
1327 let rendered_detailed =
1328 crate::render::component::render_component_detailed_with_format_and_renderer::<F>(
1329 &rendered,
1330 fmt,
1331 ctx.options.show_semantics,
1332 );
1333 if rendered_detailed.text.trim().is_empty() {
1334 continue;
1335 }
1336 if !is_term_only_component(item) {
1337 has_meaningful_content = true;
1338 }
1339 values.push(rendered_detailed);
1340 }
1341
1342 if values.is_empty() || !has_meaningful_content {
1343 return None;
1344 }
1345 Some(values)
1346 }
1347
1348 fn apply_issued_no_date_fallback(
1349 &self,
1350 reference: &Reference,
1351 options: &RenderOptions<'_>,
1352 component: &TemplateComponent,
1353 values: &mut crate::values::ProcValues<String>,
1354 ) {
1355 if !matches!(
1356 component,
1357 TemplateComponent::Date(citum_schema::template::TemplateDate {
1358 date: citum_schema::template::DateVariable::Issued,
1359 fallback: None,
1360 ..
1361 })
1362 ) || reference.effective_issued_date().is_some()
1363 || self.preferred_no_date_term_form() != citum_schema::locale::TermForm::Long
1364 {
1365 return;
1366 }
1367
1368 if let Some(long) = options.locale.resolved_general_term(
1369 &citum_schema::locale::GeneralTerm::NoDate,
1370 &citum_schema::locale::TermForm::Long,
1371 None,
1372 ) {
1373 values.value = long;
1374 }
1375 }
1376
1377 fn apply_entry_link_fallback(
1378 &self,
1379 reference: &Reference,
1380 options: &RenderOptions<'_>,
1381 values: &mut crate::values::ProcValues<String>,
1382 ) {
1383 if values.url.is_some() {
1384 return;
1385 }
1386
1387 let Some(links) = &options.config.links else {
1388 return;
1389 };
1390 use citum_schema::options::LinkAnchor;
1391 if matches!(links.anchor, Some(LinkAnchor::Entry)) {
1392 values.url = crate::values::resolve_url(links, reference);
1393 }
1394 }
1395
1396 pub fn apply_author_substitution(&self, proc: &mut ProcTemplate, substitute: &str) {
1398 self.apply_author_substitution_with_format::<crate::render::plain::PlainText>(
1399 proc, substitute,
1400 );
1401 }
1402
1403 pub fn apply_author_substitution_with_format<F>(
1405 &self,
1406 proc: &mut ProcTemplate,
1407 substitute: &str,
1408 ) where
1409 F: crate::render::format::OutputFormat<Output = String>,
1410 {
1411 if let Some(component) = proc
1412 .iter_mut()
1413 .find(|c| matches!(c.template_component, TemplateComponent::Contributor(_)))
1414 {
1415 let fmt = F::default();
1416 component.value = fmt.text(substitute);
1417 }
1418 }
1419
1420 fn preferred_no_date_term_form(&self) -> citum_schema::locale::TermForm {
1423 match self
1424 .config
1425 .dates
1426 .as_ref()
1427 .and_then(|dates| dates.no_date_form)
1428 {
1429 Some(citum_schema::options::NoDateForm::Long) => citum_schema::locale::TermForm::Long,
1430 Some(citum_schema::options::NoDateForm::Short) | None => {
1431 citum_schema::locale::TermForm::Short
1432 }
1433 }
1434 }
1435
1436 fn render_group_item_from_template_with_format<F>(
1437 &self,
1438 reference: &Reference,
1439 item_request: GroupItemRenderRequest<'_>,
1440 ) -> Option<String>
1441 where
1442 F: crate::render::format::OutputFormat<Output = String>,
1443 {
1444 let request = self.citation_render_request(
1445 item_request.item,
1446 item_request.template,
1447 item_request.mode,
1448 item_request.suppress_author,
1449 item_request.position,
1450 item_request.note_start_text_case,
1451 );
1452 self.render_item_from_template_with_format::<F>(reference, request, item_request.delimiter)
1453 }
1454}
1455
1456pub(super) fn template_uses_first_ref_note_number(template: &[TemplateComponent]) -> bool {
1463 template.iter().any(|c| match c {
1464 TemplateComponent::Number(n) => {
1465 n.number == citum_schema::template::NumberVariable::FirstReferenceNoteNumber
1466 }
1467 TemplateComponent::Group(g) => template_uses_first_ref_note_number(&g.group),
1468 _ => false,
1469 })
1470}
1471
1472pub(super) fn filter_author_from_template<F>(
1473 template: &[TemplateComponent],
1474 script: crate::values::ScriptClass,
1475 realization: Option<&citum_schema::options::PunctuationRealization>,
1476 fmt: &F,
1477) -> (Vec<TemplateComponent>, Option<String>, bool)
1478where
1479 F: crate::render::format::OutputFormat<Output = String>,
1480{
1481 let grouping_role = template
1487 .first()
1488 .and_then(find_grouping_component)
1489 .and_then(|component| match component {
1490 TemplateComponent::Contributor(contributor)
1491 if contributor.contributor != citum_schema::template::ContributorRole::Author =>
1492 {
1493 Some(contributor.contributor.clone())
1494 }
1495 _ => None,
1496 });
1497 let mut filtered: Vec<TemplateComponent> =
1498 template.iter().filter_map(strip_author_component).collect();
1499 if let Some(role) = grouping_role
1500 && !filtered.is_empty()
1501 {
1502 let first = filtered.remove(0);
1503 if let (Some(remaining), _) = remove_first_contributor_with_role(first, &role) {
1504 filtered.insert(0, remaining);
1505 }
1506 }
1507 let stripped_leading_affix = filtered
1508 .first()
1509 .and_then(|first| leading_group_affix(first, script, realization, fmt));
1510 let leading_affix = stripped_leading_affix.clone().or_else(|| {
1511 filtered.first().and_then(|_| {
1512 template
1513 .first()
1514 .and_then(|first| author_group_delimiter_affix(first, script, realization, fmt))
1515 })
1516 });
1517 if let Some(first) = filtered.first_mut() {
1518 strip_leading_group_affixes(first);
1519 }
1520 (filtered, leading_affix, stripped_leading_affix.is_some())
1521}
1522
1523fn author_group_delimiter_affix<F>(
1524 component: &TemplateComponent,
1525 script: crate::values::ScriptClass,
1526 realization: Option<&citum_schema::options::PunctuationRealization>,
1527 fmt: &F,
1528) -> Option<String>
1529where
1530 F: crate::render::format::OutputFormat<Output = String>,
1531{
1532 let TemplateComponent::Group(group) = component else {
1533 return None;
1534 };
1535 group
1536 .group
1537 .first()
1538 .is_some_and(component_starts_with_author)
1539 .then_some(group.delimiter.as_ref())
1540 .flatten()
1541 .map(|punctuation| {
1542 let realized = crate::render::format::realize_punctuation(
1543 punctuation,
1544 script,
1545 realization,
1546 crate::render::format::PunctuationPosition::Separator,
1547 );
1548 if punctuation.is_semantic() {
1549 fmt.text(&realized)
1550 } else {
1551 realized.into_owned()
1552 }
1553 })
1554 .filter(|delimiter| !delimiter.is_empty())
1555}
1556
1557fn component_starts_with_author(component: &TemplateComponent) -> bool {
1558 match component {
1559 TemplateComponent::Contributor(contributor) => contributor
1560 .contributor
1561 .contains(&citum_schema::template::ContributorRole::Author),
1562 TemplateComponent::Group(group) => group
1563 .group
1564 .first()
1565 .is_some_and(component_starts_with_author),
1566 _ => false,
1567 }
1568}
1569
1570#[cfg(test)]
1571mod tests {
1572 use super::*;
1573 use citum_schema::template::{
1574 ContributorRole, DelimiterPunctuation, TemplateContributor, TemplateGroup,
1575 };
1576
1577 #[test]
1578 fn author_group_delimiter_affix_recognizes_merged_leading_author_component() {
1579 let group = TemplateComponent::Group(TemplateGroup {
1582 group: vec![TemplateComponent::Contributor(TemplateContributor {
1583 contributor: vec![ContributorRole::Author, ContributorRole::Editor].into(),
1584 ..Default::default()
1585 })],
1586 delimiter: Some(DelimiterPunctuation::Comma),
1587 ..Default::default()
1588 });
1589
1590 let affix = author_group_delimiter_affix(
1592 &group,
1593 crate::values::ScriptClass::Latin,
1594 None,
1595 &crate::render::plain::PlainText,
1596 );
1597
1598 assert_eq!(affix, Some(", ".to_string()));
1600 }
1601
1602 #[test]
1603 fn author_group_delimiter_affix_ignores_merged_component_without_author() {
1604 let group = TemplateComponent::Group(TemplateGroup {
1607 group: vec![TemplateComponent::Contributor(TemplateContributor {
1608 contributor: vec![ContributorRole::Editor, ContributorRole::Translator].into(),
1609 ..Default::default()
1610 })],
1611 delimiter: Some(DelimiterPunctuation::Comma),
1612 ..Default::default()
1613 });
1614
1615 let affix = author_group_delimiter_affix(
1617 &group,
1618 crate::values::ScriptClass::Latin,
1619 None,
1620 &crate::render::plain::PlainText,
1621 );
1622
1623 assert_eq!(affix, None);
1625 }
1626}