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