Skip to main content

citum_engine/processor/
citation.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Citation rendering orchestration.
7//!
8//! This module resolves the effective citation spec for each citation, prepares
9//! renderer delimiters and affixes. Template-level rendering, including
10//! sentence-initial note-start handling, lives in `rendering`.
11
12use super::Processor;
13use super::disambiguation::Disambiguator;
14use super::rendering::{CompoundRenderData, GroupRenderParams, Renderer, RendererResources};
15use crate::error::ProcessorError;
16use crate::reference::Citation;
17use crate::values::ProcHints;
18use citum_schema::NoteStartTextCase;
19use citum_schema::locale::{GeneralTerm, Locale, TermForm};
20use citum_schema::options::{Config, GivennameRule};
21use citum_schema::template::DelimiterPunctuation;
22use indexmap::IndexMap;
23use std::collections::HashMap;
24
25/// Join rendered integral (narrative) groups with localized conjunctions.
26///
27/// Uses the locale's "and" term to join groups according to document grammar
28/// rules (e.g., "A and B" or "A, B, and C" with optional serial comma).
29fn join_integral_groups(rendered_groups: Vec<String>, locale: &Locale) -> String {
30    match rendered_groups.len() {
31        0 => String::new(),
32        1 => rendered_groups.into_iter().next().unwrap_or_default(),
33        2 => {
34            let conjunction = locale
35                .resolved_general_term(&GeneralTerm::And, &TermForm::Long, None)
36                .unwrap_or_else(|| locale.and_term(false).to_string());
37            rendered_groups.join(&format!(" {} ", conjunction.trim()))
38        }
39        _ => {
40            let conjunction = locale
41                .resolved_general_term(&GeneralTerm::And, &TermForm::Long, None)
42                .unwrap_or_else(|| locale.and_term(false).to_string());
43            let final_delimiter = if locale.grammar_options.serial_comma {
44                format!(", {} ", conjunction.trim())
45            } else {
46                format!(" {} ", conjunction.trim())
47            };
48
49            let mut rendered_groups = rendered_groups;
50            let last = rendered_groups.pop().unwrap_or_default();
51            format!("{}{}{}", rendered_groups.join(", "), final_delimiter, last)
52        }
53    }
54}
55
56impl Processor {
57    /// Determine the text-case policy for a citation at the start of a note.
58    ///
59    /// Only applies for note-based styles when a repeated-citation position (Ibid)
60    /// is at the start of the note and has no user-supplied or spec-defined prefix.
61    fn sentence_initial_note_start_text_case(
62        &self,
63        citation: &Citation,
64        effective_spec: &citum_schema::CitationSpec,
65    ) -> Option<NoteStartTextCase> {
66        let spec_prefix = effective_spec.prefix.as_deref().unwrap_or("");
67        if self.is_note_style()
68            && matches!(
69                citation.position,
70                Some(
71                    citum_schema::citation::Position::Ibid
72                        | citum_schema::citation::Position::IbidWithLocator
73                )
74            )
75            && matches!(
76                citation.mode,
77                citum_schema::citation::CitationMode::NonIntegral
78            )
79            && citation.prefix.as_deref().unwrap_or("").is_empty()
80            && spec_prefix.is_empty()
81        {
82            effective_spec.note_start_text_case
83        } else {
84            None
85        }
86    }
87
88    /// Resolve the citation specification based on the citation's document position.
89    ///
90    /// Delegates to the style's citation spec to handle ibid, subsequent, or first
91    /// position overrides.
92    fn resolve_positioned_citation_spec(
93        &self,
94        citation: &Citation,
95    ) -> std::borrow::Cow<'_, citum_schema::CitationSpec> {
96        self.style.citation.as_ref().map_or_else(
97            || std::borrow::Cow::Owned(citum_schema::CitationSpec::default()),
98            |spec| spec.resolve_for_position(citation.position.as_ref()),
99        )
100    }
101
102    /// Register nocite reference IDs into the cited set.
103    ///
104    /// Nocite IDs are treated as cited for bibliography-selection purposes (they
105    /// appear in `bibliography.entries` alongside normally cited refs and are
106    /// matched by `CitedStatus::Visible` selectors), but no `formatted_citations`
107    /// entry is produced for them. This matches standard citeproc / Pandoc `nocite`
108    /// semantics.
109    ///
110    /// IDs that are absent from `self.bibliography` are silently ignored here;
111    /// callers are responsible for emitting `nocite_missing_ref` warnings first.
112    pub fn register_nocite_ids(&self, ids: impl IntoIterator<Item = String>) {
113        let mut cited_ids = self.cited_ids.borrow_mut();
114        for id in ids {
115            cited_ids.insert(id);
116        }
117    }
118
119    /// Register cited reference IDs and ensure numeric labels are initialized.
120    ///
121    /// This maintains the set of all references cited in the document and ensures
122    /// that numeric styles have a stable numbering map.
123    fn track_cited_ids_and_init_numbers(&self, citation: &Citation) {
124        self.initialize_numeric_citation_numbers();
125        let mut cited_ids = self.cited_ids.borrow_mut();
126        for item in &citation.items {
127            cited_ids.insert(item.id.clone());
128        }
129    }
130
131    /// Resolve the final effective citation spec for a given mode and position.
132    fn resolve_effective_citation_spec(&self, citation: &Citation) -> citum_schema::CitationSpec {
133        self.resolve_positioned_citation_spec(citation)
134            .into_owned()
135            .resolve_for_mode(&citation.mode)
136            .into_owned()
137    }
138
139    /// Resolve intra-item and inter-citation delimiters for a citation spec.
140    fn resolve_citation_delimiters<'a>(
141        &self,
142        effective_spec: &'a citum_schema::CitationSpec,
143    ) -> (&'a str, &'a str) {
144        let intra_delimiter = effective_spec.delimiter.as_deref().unwrap_or(", ");
145        let inter_delimiter = effective_spec
146            .multi_cite_delimiter
147            .as_deref()
148            .unwrap_or("; ");
149
150        (
151            if matches!(
152                DelimiterPunctuation::from_csl_string(intra_delimiter),
153                DelimiterPunctuation::None
154            ) {
155                ""
156            } else {
157                intra_delimiter
158            },
159            if matches!(
160                DelimiterPunctuation::from_csl_string(inter_delimiter),
161                DelimiterPunctuation::None
162            ) {
163                ""
164            } else {
165                inter_delimiter
166            },
167        )
168    }
169
170    /// Register a dynamic compound group for a `grouped` citation.
171    ///
172    /// The first item in `citation.items` is the head; subsequent items are tails.
173    /// Skips silently when:
174    /// - The style has no `compound-numeric` bibliography configuration (non-numeric style).
175    /// - A static compound set already covers the head or any tail (static sets take precedence).
176    /// - The head or any tail was previously cited in any context (first occurrence wins).
177    ///
178    /// This method must be called before `track_cited_ids_and_init_numbers` so that
179    /// `cited_ids` reflects only references from prior citations, not the current one.
180    fn resolve_dynamic_group(&self, citation: &Citation) {
181        if self.get_bibliography_options().compound_numeric.is_none() {
182            return;
183        }
184
185        if citation.items.len() < 2 {
186            return;
187        }
188
189        #[allow(clippy::indexing_slicing, reason = "citation.items.len() >= 2")]
190        let head_id = &citation.items[0].id;
191        #[allow(clippy::indexing_slicing, reason = "citation.items.len() >= 2")]
192        let tail_ids: Vec<String> = citation.items[1..].iter().map(|i| i.id.clone()).collect();
193
194        // Static sets take precedence — skip if head or any tail is in a static set.
195        if self.compound_set_by_ref.contains_key(head_id) {
196            return;
197        }
198        for tail in &tail_ids {
199            if self.compound_set_by_ref.contains_key(tail.as_str()) {
200                return;
201            }
202        }
203
204        // First-occurrence wins: reject if the head or any tail was already cited in any
205        // context — whether via a prior dynamic group or a previous ungrouped citation.
206        // Because this method is called before cited_ids is updated for the current
207        // citation, `cited_ids` contains only references from earlier citations.
208        {
209            let dyn_set = self.dynamic_compound_set_by_ref.borrow();
210            let cited = self.cited_ids.borrow();
211
212            if dyn_set.contains_key(head_id.as_str()) || cited.contains(head_id.as_str()) {
213                return;
214            }
215            for tail in &tail_ids {
216                if dyn_set.contains_key(tail.as_str()) || cited.contains(tail.as_str()) {
217                    return;
218                }
219            }
220        }
221
222        let head_number = {
223            let numbers = self.citation_numbers.borrow();
224            let Some(&n) = numbers.get(head_id.as_str()) else {
225                return;
226            };
227            n
228        };
229
230        // Assign all tails the same citation number as the head.
231        {
232            let mut numbers = self.citation_numbers.borrow_mut();
233            for tail in &tail_ids {
234                numbers.insert(tail.clone(), head_number);
235            }
236        }
237
238        // Build the ordered member list for this group.
239        let all_members: Vec<String> = std::iter::once(head_id.clone())
240            .chain(tail_ids.iter().cloned())
241            .collect();
242
243        // Populate dynamic index maps so the renderer can assign sub-labels.
244        {
245            let mut dyn_set = self.dynamic_compound_set_by_ref.borrow_mut();
246            let mut dyn_idx = self.dynamic_compound_member_index.borrow_mut();
247            for (idx, member) in all_members.iter().enumerate() {
248                dyn_set.insert(member.clone(), head_id.clone());
249                dyn_idx.insert(member.clone(), idx);
250            }
251        }
252
253        // Inject into compound_groups for bibliography rendering.
254        {
255            let mut groups = self.compound_groups.borrow_mut();
256            let members = groups
257                .entry(head_number)
258                .or_insert_with(|| vec![head_id.clone()]);
259            for tail in &tail_ids {
260                if !members.contains(tail) {
261                    members.push(tail.clone());
262                }
263            }
264        }
265
266        // Register dynamic set so citation_sub_label_for_ref can find members.
267        self.dynamic_compound_sets
268            .borrow_mut()
269            .insert(head_id.clone(), all_members);
270    }
271
272    /// Build a citation-local hint overlay for CSL `givenname-disambiguation-rule: by-cite`.
273    ///
274    /// Global hints remain authoritative for bibliography rendering, year-suffix ordering,
275    /// numeric state, and note-position state. This overlay only recalculates name expansion
276    /// fields for the references rendered by the current citation.
277    fn citation_scoped_by_cite_hints(
278        &self,
279        items: &[crate::reference::CitationItem],
280        config: &Config,
281    ) -> Option<HashMap<String, ProcHints>> {
282        if !Self::uses_by_cite_givenname(config) {
283            return None;
284        }
285
286        let mut scoped_hints = HashMap::new();
287        let mut scoped_bibliography = IndexMap::new();
288
289        for item in items {
290            let mut hint = self.hints.get(&item.id).cloned().unwrap_or_default();
291            hint.expand_given_names = false;
292            hint.expand_given_names_primary_only = false;
293            hint.min_names_to_show = None;
294            scoped_hints.insert(item.id.clone(), hint);
295
296            if let Some(reference) = self.bibliography.get(&item.id) {
297                scoped_bibliography.insert(item.id.clone(), reference.clone());
298            }
299        }
300
301        if scoped_bibliography.len() < 2 {
302            return Some(scoped_hints);
303        }
304
305        let local_hints =
306            Disambiguator::new(&scoped_bibliography, config, &self.locale).calculate_hints();
307
308        for item in items {
309            let Some(local) = local_hints.get(&item.id) else {
310                continue;
311            };
312            let target = scoped_hints.entry(item.id.clone()).or_default();
313            target.expand_given_names = local.expand_given_names;
314            target.expand_given_names_primary_only = local.expand_given_names_primary_only;
315            target.min_names_to_show = local.min_names_to_show;
316        }
317
318        Some(scoped_hints)
319    }
320
321    /// Return true when the active citation config requests CSL by-cite given-name expansion.
322    fn uses_by_cite_givenname(config: &Config) -> bool {
323        let disambiguate = match config.processing.as_ref() {
324            Some(processing) => processing.config().disambiguate,
325            None => {
326                citum_schema::options::Processing::AuthorDate
327                    .config()
328                    .disambiguate
329            }
330        };
331
332        disambiguate
333            .as_ref()
334            .is_some_and(|d| d.add_givenname && matches!(d.givenname_rule, GivennameRule::ByCite))
335    }
336
337    /// Build the merged static + dynamic compound lookup maps for the renderer.
338    ///
339    /// When no dynamic groups exist (the common case) the static maps are returned
340    /// via references with no allocation. Owned merged maps are only constructed when
341    /// at least one dynamic group is registered.
342    fn merged_compound_data(
343        &self,
344    ) -> (
345        Option<HashMap<String, String>>,
346        Option<HashMap<String, usize>>,
347        Option<IndexMap<String, Vec<String>>>,
348    ) {
349        if self.dynamic_compound_set_by_ref.borrow().is_empty() {
350            return (None, None, None);
351        }
352        let merged_set: HashMap<String, String> = self
353            .compound_set_by_ref
354            .iter()
355            .chain(self.dynamic_compound_set_by_ref.borrow().iter())
356            .map(|(k, v)| (k.clone(), v.clone()))
357            .collect();
358        let merged_idx: HashMap<String, usize> = self
359            .compound_member_index
360            .iter()
361            .chain(self.dynamic_compound_member_index.borrow().iter())
362            .map(|(k, v)| (k.clone(), *v))
363            .collect();
364        let merged_sets: IndexMap<String, Vec<String>> = self
365            .compound_sets
366            .iter()
367            .chain(self.dynamic_compound_sets.borrow().iter())
368            .map(|(k, v)| (k.clone(), v.clone()))
369            .collect();
370        (Some(merged_set), Some(merged_idx), Some(merged_sets))
371    }
372
373    /// Render the core content of a citation, handling sorting and grouping.
374    ///
375    /// This is the main orchestration point for template rendering, compound data
376    /// resolution, and mode-specific (integral vs non-integral) formatting.
377    fn render_citation_content<F>(
378        &self,
379        citation: &Citation,
380        effective_spec: &citum_schema::CitationSpec,
381        renderer_delimiter: &str,
382        renderer_inter_delimiter: &str,
383        note_start_text_case: Option<NoteStartTextCase>,
384    ) -> Result<String, ProcessorError>
385    where
386        F: crate::render::format::OutputFormat<Output = String>,
387    {
388        // Grouped citations preserve item order (dynamic grouping was already resolved
389        // in process_citation_with_format before cited_ids was updated).
390        let sorted_items = if citation.grouped {
391            citation.items.clone()
392        } else {
393            self.sort_citation_items(citation.items.clone(), effective_spec)
394        };
395
396        // Build merged compound lookup maps (static + dynamic).
397        // Return owned maps only when dynamic groups exist; otherwise use static maps directly.
398        let (dyn_set_owned, dyn_idx_owned, dyn_sets_owned) = self.merged_compound_data();
399        let effective_set_by_ref = dyn_set_owned.as_ref().unwrap_or(&self.compound_set_by_ref);
400        let effective_member_index = dyn_idx_owned
401            .as_ref()
402            .unwrap_or(&self.compound_member_index);
403        let effective_compound_sets = dyn_sets_owned.as_ref().unwrap_or(&self.compound_sets);
404
405        let citation_config = self.get_citation_config();
406        let citation_config = match effective_spec.options.as_ref() {
407            Some(mode_options) => {
408                let mut config = citation_config.into_owned();
409                config.merge(&mode_options.to_config());
410                std::borrow::Cow::Owned(config)
411            }
412            None => citation_config,
413        };
414        let scoped_hints = self.citation_scoped_by_cite_hints(&sorted_items, &citation_config);
415        let renderer_hints = scoped_hints.as_ref().unwrap_or(&self.hints);
416        let renderer = Renderer::new(
417            RendererResources {
418                style: &self.style,
419                bibliography: &self.bibliography,
420                locale: &self.locale,
421                config: &citation_config,
422                bibliography_config: Some(self.get_bibliography_options().into_owned()),
423                first_note_by_id: Some(&self.first_note_by_id),
424            },
425            renderer_hints,
426            &self.citation_numbers,
427            CompoundRenderData {
428                set_by_ref: effective_set_by_ref,
429                member_index: effective_member_index,
430                sets: effective_compound_sets,
431            },
432            self.show_semantics,
433            self.inject_ast_indices,
434            self.abbreviation_map.as_ref(),
435        );
436        let processing = citation_config.processing.clone().unwrap_or_default();
437        let has_explicit_integral_multi_cite_delimiter = matches!(
438            citation.mode,
439            citum_schema::citation::CitationMode::Integral
440        ) && self
441            .resolve_positioned_citation_spec(citation)
442            .integral
443            .as_ref()
444            .and_then(|spec| spec.multi_cite_delimiter.as_ref())
445            .is_some();
446        let rendered_groups = if matches!(
447            processing,
448            citum_schema::options::Processing::Numeric
449                | citum_schema::options::Processing::Label(_)
450        ) {
451            renderer.render_ungrouped_citation_with_format::<F>(
452                &sorted_items,
453                effective_spec,
454                &citation.mode,
455                renderer_delimiter,
456                citation.suppress_author,
457                citation.position.as_ref(),
458                note_start_text_case,
459            )?
460        } else {
461            renderer.render_grouped_citation_with_format::<F>(
462                &sorted_items,
463                &GroupRenderParams {
464                    spec: effective_spec,
465                    mode: &citation.mode,
466                    intra_delimiter: renderer_delimiter,
467                    suppress_author: citation.suppress_author,
468                    position: citation.position.as_ref(),
469                    note_start_text_case,
470                },
471            )?
472        };
473
474        Ok(
475            if matches!(
476                citation.mode,
477                citum_schema::citation::CitationMode::Integral
478            ) && !has_explicit_integral_multi_cite_delimiter
479            {
480                join_integral_groups(rendered_groups, &self.locale)
481            } else {
482                F::default().join(rendered_groups, renderer_inter_delimiter)
483            },
484        )
485    }
486
487    /// Apply user-supplied prefix and suffix from the citation input.
488    ///
489    /// Automatically adds a trailing space to the prefix and a leading space to
490    /// the suffix if they are not already present and not empty.
491    fn apply_citation_input_affixes<F>(
492        &self,
493        citation: &Citation,
494        content: String,
495        fmt: &F,
496    ) -> String
497    where
498        F: crate::render::format::OutputFormat<Output = String>,
499    {
500        let citation_prefix = citation.prefix.as_deref().unwrap_or("");
501        let citation_suffix = citation.suffix.as_deref().unwrap_or("");
502
503        if citation_prefix.is_empty() && citation_suffix.is_empty() {
504            return content;
505        }
506
507        let formatted_prefix =
508            if !citation_prefix.is_empty() && !citation_prefix.ends_with(char::is_whitespace) {
509                format!("{citation_prefix} ")
510            } else {
511                citation_prefix.to_string()
512            };
513
514        let formatted_suffix =
515            if !citation_suffix.is_empty() && !citation_suffix.starts_with(char::is_whitespace) {
516                format!(" {citation_suffix}")
517            } else {
518                citation_suffix.to_string()
519            };
520
521        fmt.affix(&formatted_prefix, content, &formatted_suffix)
522    }
523
524    /// Apply style-defined wrapping and affixes to the rendered citation output.
525    ///
526    /// Handles `wrap` logic (inner prefixes/suffixes and punctuation) based on
527    /// the citation mode and position.
528    fn apply_spec_wrap_and_affixes<F>(
529        &self,
530        citation: &Citation,
531        effective_spec: &citum_schema::CitationSpec,
532        output: String,
533        fmt: &F,
534    ) -> String
535    where
536        F: crate::render::format::OutputFormat<Output = String>,
537    {
538        let spec_prefix = effective_spec.prefix.as_deref().unwrap_or("");
539        let spec_suffix = effective_spec.suffix.as_deref().unwrap_or("");
540
541        if matches!(
542            citation.mode,
543            citum_schema::citation::CitationMode::Integral
544        ) {
545            if !spec_prefix.is_empty() || !spec_suffix.is_empty() {
546                fmt.affix(spec_prefix, output, spec_suffix)
547            } else {
548                output
549            }
550        } else if let Some(wrap) = effective_spec.wrap.as_ref() {
551            let inner_prefix = wrap.inner_prefix.as_deref().unwrap_or("");
552            let inner_suffix = wrap.inner_suffix.as_deref().unwrap_or("");
553            let inner_wrapped = if !inner_prefix.is_empty() || !inner_suffix.is_empty() {
554                fmt.inner_affix(inner_prefix, output, inner_suffix)
555            } else {
556                output
557            };
558            fmt.wrap_punctuation(&wrap.punctuation, inner_wrapped)
559        } else if !spec_prefix.is_empty() || !spec_suffix.is_empty() {
560            fmt.affix(spec_prefix, output, spec_suffix)
561        } else {
562            output
563        }
564    }
565
566    /// Render a single citation to plain text.
567    ///
568    /// This is the primary entry point for citation processing. It handles:
569    /// 1. Looking up references in the bibliography.
570    /// 2. Annotating positions (ibid, subsequent, etc.).
571    /// 3. Resolving disambiguation (name expansion, year suffixes).
572    /// 4. Applying the style's citation template.
573    ///
574    /// Returns the formatted citation string or an error if processing fails.
575    ///
576    /// # Errors
577    ///
578    /// Returns an error when referenced items are missing or rendering fails.
579    pub fn process_citation(&self, citation: &Citation) -> Result<String, ProcessorError> {
580        self.process_citation_with_format::<crate::render::plain::PlainText>(citation)
581    }
582
583    /// Render a citation to a string using a specific output format.
584    ///
585    /// This resolves the effective citation spec for the citation's mode and
586    /// position, renders the citation body, and applies input and style affixes.
587    ///
588    /// # Errors
589    ///
590    /// Returns an error when referenced items are missing or rendering fails.
591    pub fn process_citation_with_format<F>(
592        &self,
593        citation: &Citation,
594    ) -> Result<String, ProcessorError>
595    where
596        F: crate::render::format::OutputFormat<Output = String>,
597    {
598        let fmt = F::default();
599
600        // For grouped citations, resolve the dynamic compound group BEFORE updating
601        // cited_ids with the current citation's items. This ensures the first-occurrence
602        // check in resolve_dynamic_group sees only references from prior citations.
603        if citation.grouped {
604            self.initialize_numeric_citation_numbers();
605            self.resolve_dynamic_group(citation);
606        }
607
608        self.track_cited_ids_and_init_numbers(citation);
609
610        let effective_spec = self.resolve_effective_citation_spec(citation);
611        let note_start_text_case =
612            self.sentence_initial_note_start_text_case(citation, &effective_spec);
613        let (renderer_delimiter, renderer_inter_delimiter) =
614            self.resolve_citation_delimiters(&effective_spec);
615        let content = self.render_citation_content::<F>(
616            citation,
617            &effective_spec,
618            renderer_delimiter,
619            renderer_inter_delimiter,
620            note_start_text_case,
621        )?;
622        let output = self.apply_citation_input_affixes(citation, content, &fmt);
623        let wrapped = self.apply_spec_wrap_and_affixes(citation, &effective_spec, output, &fmt);
624
625        // If the host signals that this cluster opens a sentence, capitalize
626        // the leading character of the composed output.  The markup-aware
627        // variant skips leading punctuation (e.g. an opening parenthesis) so
628        // only the first alphabetic character is affected.
629        let finalized = if citation.sentence_start {
630            let case = crate::values::text_case::resolve_text_case(
631                citum_schema::options::titles::TextCase::CapitalizeFirst,
632                Some(self.locale.locale.as_str()),
633            );
634            crate::values::text_case::apply_text_case_markup_aware(&wrapped, case)
635        } else {
636            wrapped
637        };
638
639        Ok(fmt.finish(finalized))
640    }
641
642    /// Render multiple citations in document order.
643    ///
644    /// For note-based styles, normalizes context and assigns citation positions.
645    ///
646    /// # Errors
647    ///
648    /// Returns an error when any citation in the sequence fails to render.
649    pub fn process_citations(&self, citations: &[Citation]) -> Result<Vec<String>, ProcessorError> {
650        self.process_citations_with_format::<crate::render::plain::PlainText>(citations)
651    }
652
653    /// Render multiple citations with a custom output format.
654    ///
655    /// # Errors
656    ///
657    /// Returns an error when any citation in the sequence fails to render.
658    pub fn process_citations_with_format<F>(
659        &self,
660        citations: &[Citation],
661    ) -> Result<Vec<String>, ProcessorError>
662    where
663        F: crate::render::format::OutputFormat<Output = String>,
664    {
665        let mut normalized = self.normalize_note_context(citations);
666        self.annotate_positions(&mut normalized);
667        normalized
668            .iter()
669            .map(|citation| self.process_citation_with_format::<F>(citation))
670            .collect()
671    }
672}