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