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 indexmap::IndexMap;
33use std::borrow::Cow;
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        &'a self,
152        citation: &Citation,
153        effective_spec: &'a citum_schema::CitationSpec,
154    ) -> (Cow<'a, str>, Cow<'a, str>) {
155        let (script, realization) = self.citation_punctuation_context(citation);
156        let intra_delimiter = effective_spec
157            .delimiter
158            .as_ref()
159            .map(|punctuation| {
160                crate::render::format::realize_punctuation(
161                    punctuation,
162                    script,
163                    realization,
164                    crate::render::format::PunctuationPosition::Separator,
165                )
166            })
167            .unwrap_or(Cow::Borrowed(", "));
168        let inter_delimiter = effective_spec
169            .multi_cite_delimiter
170            .as_ref()
171            .map(|punctuation| {
172                crate::render::format::realize_punctuation(
173                    punctuation,
174                    script,
175                    realization,
176                    crate::render::format::PunctuationPosition::Separator,
177                )
178            })
179            .unwrap_or(Cow::Borrowed("; "));
180
181        (intra_delimiter, inter_delimiter)
182    }
183
184    /// Register a dynamic compound group for a `grouped` citation.
185    ///
186    /// The first item in `citation.items` is the head; subsequent items are tails.
187    /// Skips silently when:
188    /// - The style has no `compound-numeric` bibliography configuration (non-numeric style).
189    /// - A static compound set already covers the head or any tail (static sets take precedence).
190    /// - The head or any tail was previously cited in any context (first occurrence wins).
191    ///
192    /// This method must be called before `track_cited_ids_and_init_numbers` so that
193    /// `cited_ids` reflects only references from prior citations, not the current one.
194    fn resolve_dynamic_group(&self, citation: &Citation, run: &mut RunState) {
195        if self.get_bibliography_options().compound_numeric.is_none() {
196            return;
197        }
198
199        if citation.items.len() < 2 {
200            return;
201        }
202
203        #[allow(clippy::indexing_slicing, reason = "citation.items.len() >= 2")]
204        let head_id = &citation.items[0].id;
205        #[allow(clippy::indexing_slicing, reason = "citation.items.len() >= 2")]
206        let tail_ids: Vec<String> = citation.items[1..].iter().map(|i| i.id.clone()).collect();
207
208        // Static sets take precedence — skip if head or any tail is in a static set.
209        if self.compound_set_by_ref.contains_key(head_id) {
210            return;
211        }
212        for tail in &tail_ids {
213            if self.compound_set_by_ref.contains_key(tail.as_str()) {
214                return;
215            }
216        }
217
218        // First-occurrence wins: reject if the head or any tail was already cited in any
219        // context — whether via a prior dynamic group or a previous ungrouped citation.
220        // Because this method is called before cited_ids is updated for the current
221        // citation, `cited_ids` contains only references from earlier citations.
222        if run
223            .dynamic_compound_set_by_ref
224            .contains_key(head_id.as_str())
225            || run.cited_ids.contains(head_id.as_str())
226        {
227            return;
228        }
229        for tail in &tail_ids {
230            if run.dynamic_compound_set_by_ref.contains_key(tail.as_str())
231                || run.cited_ids.contains(tail.as_str())
232            {
233                return;
234            }
235        }
236
237        let head_number = {
238            let numbers = run
239                .citation_numbers
240                .read()
241                .unwrap_or_else(std::sync::PoisonError::into_inner);
242            let Some(&n) = numbers.get(head_id.as_str()) else {
243                return;
244            };
245            n
246        };
247
248        // Assign all tails the same citation number as the head.
249        {
250            let mut numbers = run
251                .citation_numbers
252                .write()
253                .unwrap_or_else(std::sync::PoisonError::into_inner);
254            for tail in &tail_ids {
255                numbers.insert(tail.clone(), head_number);
256            }
257        }
258
259        // Build the ordered member list for this group.
260        let all_members: Vec<String> = std::iter::once(head_id.clone())
261            .chain(tail_ids.iter().cloned())
262            .collect();
263
264        // Populate dynamic index maps so the renderer can assign sub-labels.
265        for (idx, member) in all_members.iter().enumerate() {
266            run.dynamic_compound_set_by_ref
267                .insert(member.clone(), head_id.clone());
268            run.dynamic_compound_member_index
269                .insert(member.clone(), idx);
270        }
271
272        // Inject into compound_groups for bibliography rendering.
273        {
274            let members = run
275                .compound_groups
276                .entry(head_number)
277                .or_insert_with(|| vec![head_id.clone()]);
278            for tail in &tail_ids {
279                if !members.contains(tail) {
280                    members.push(tail.clone());
281                }
282            }
283        }
284
285        // Register dynamic set so citation_sub_label_for_ref can find members.
286        run.dynamic_compound_sets
287            .insert(head_id.clone(), all_members);
288    }
289
290    /// Build a citation-local hint overlay for CSL `givenname-disambiguation-rule: by-cite`.
291    ///
292    /// Global hints remain authoritative for bibliography rendering, year-suffix ordering,
293    /// numeric state, and note-position state. This overlay only recalculates name expansion
294    /// fields for the references rendered by the current citation.
295    fn citation_scoped_by_cite_hints(
296        &self,
297        items: &[crate::reference::CitationItem],
298        config: &Config,
299    ) -> Option<HashMap<String, ProcHints>> {
300        if !Self::uses_by_cite_givenname(config) {
301            return None;
302        }
303
304        let mut scoped_hints = HashMap::new();
305        let mut scoped_bibliography = IndexMap::new();
306
307        for item in items {
308            let mut hint = self.hints.get(&item.id).cloned().unwrap_or_default();
309            hint.expand_given_names = false;
310            hint.expand_given_names_primary_only = false;
311            hint.min_names_to_show = None;
312            scoped_hints.insert(item.id.clone(), hint);
313
314            if let Some(reference) = self.bibliography.get(&item.id) {
315                scoped_bibliography.insert(item.id.clone(), reference.clone());
316            }
317        }
318
319        if scoped_bibliography.len() < 2 {
320            return Some(scoped_hints);
321        }
322
323        let bibliography_config = self.get_bibliography_config();
324        let mut disambiguator = Disambiguator::new(
325            &scoped_bibliography,
326            config,
327            &bibliography_config,
328            &self.locale,
329        );
330        if let Some(spec) = self.style.citation.as_ref() {
331            disambiguator = disambiguator.with_citation_spec(spec);
332        }
333        let local_hints = disambiguator.calculate_hints();
334
335        for item in items {
336            let Some(local) = local_hints.get(&item.id) else {
337                continue;
338            };
339            let target = scoped_hints.entry(item.id.clone()).or_default();
340            target.expand_given_names = local.expand_given_names;
341            target.expand_given_names_primary_only = local.expand_given_names_primary_only;
342            target.min_names_to_show = local.min_names_to_show;
343        }
344
345        Some(scoped_hints)
346    }
347
348    /// Return true when the active citation config requests CSL by-cite given-name expansion.
349    fn uses_by_cite_givenname(config: &Config) -> bool {
350        let disambiguate = config.effective_processing().config().disambiguate;
351
352        disambiguate
353            .as_ref()
354            .is_some_and(|d| d.add_givenname && matches!(d.givenname_rule, GivennameRule::ByCite))
355    }
356
357    /// Build the merged static + dynamic compound lookup maps for the renderer.
358    ///
359    /// When no dynamic groups exist (the common case) the static maps are returned
360    /// via references with no allocation. Owned merged maps are only constructed when
361    /// at least one dynamic group is registered.
362    fn merged_compound_data(
363        &self,
364        run: &RunState,
365    ) -> (
366        Option<HashMap<String, String>>,
367        Option<HashMap<String, usize>>,
368        Option<IndexMap<String, Vec<String>>>,
369    ) {
370        if run.dynamic_compound_set_by_ref.is_empty() {
371            return (None, None, None);
372        }
373        let merged_set: HashMap<String, String> = self
374            .compound_set_by_ref
375            .iter()
376            .chain(run.dynamic_compound_set_by_ref.iter())
377            .map(|(k, v)| (k.clone(), v.clone()))
378            .collect();
379        let merged_idx: HashMap<String, usize> = self
380            .compound_member_index
381            .iter()
382            .chain(run.dynamic_compound_member_index.iter())
383            .map(|(k, v)| (k.clone(), *v))
384            .collect();
385        let merged_sets: IndexMap<String, Vec<String>> = self
386            .compound_sets
387            .iter()
388            .chain(run.dynamic_compound_sets.iter())
389            .map(|(k, v)| (k.clone(), v.clone()))
390            .collect();
391        (Some(merged_set), Some(merged_idx), Some(merged_sets))
392    }
393
394    /// Render the core content of a citation, handling sorting and grouping.
395    ///
396    /// This is the main orchestration point for template rendering, compound data
397    /// resolution, and mode-specific (integral vs non-integral) formatting.
398    fn render_citation_content<F>(
399        &self,
400        citation: &Citation,
401        effective_spec: &citum_schema::CitationSpec,
402        renderer_delimiter: &str,
403        renderer_inter_delimiter: &str,
404        note_start_text_case: Option<NoteStartTextCase>,
405        run: &RunState,
406    ) -> Result<String, ProcessorError>
407    where
408        F: crate::render::format::OutputFormat<Output = String>,
409    {
410        // Grouped citations preserve item order (dynamic grouping was already resolved
411        // in process_citation_with_format before cited_ids was updated).
412        let sorted_items = if citation.grouped {
413            citation.items.clone()
414        } else {
415            self.sort_citation_items(citation.items.clone(), effective_spec)
416        };
417
418        // Build merged compound lookup maps (static + dynamic).
419        // Return owned maps only when dynamic groups exist; otherwise use static maps directly.
420        let (dyn_set_owned, dyn_idx_owned, dyn_sets_owned) = self.merged_compound_data(run);
421        let effective_set_by_ref = dyn_set_owned.as_ref().unwrap_or(&self.compound_set_by_ref);
422        let effective_member_index = dyn_idx_owned
423            .as_ref()
424            .unwrap_or(&self.compound_member_index);
425        let effective_compound_sets = dyn_sets_owned.as_ref().unwrap_or(&self.compound_sets);
426
427        let citation_config = self.get_citation_config();
428        let citation_config = match effective_spec.options.as_ref() {
429            Some(mode_options) => {
430                let mut config = citation_config.into_owned();
431                config.merge(&mode_options.to_config());
432                std::borrow::Cow::Owned(config)
433            }
434            None => citation_config,
435        };
436        let scoped_hints = self.citation_scoped_by_cite_hints(&sorted_items, &citation_config);
437        let renderer_hints = scoped_hints.as_ref().unwrap_or(&self.hints);
438        let citation_config = Arc::new(citation_config.into_owned());
439        let renderer = Renderer::new(
440            RendererResources {
441                style: &self.style,
442                bibliography: &self.bibliography,
443                locale: &self.locale,
444                config: citation_config.clone(),
445                bibliography_config: Some(Arc::new(self.get_bibliography_options().into_owned())),
446                first_note_by_id: Some(&run.first_note_by_id),
447            },
448            renderer_hints,
449            &run.citation_numbers,
450            CompoundRenderData {
451                set_by_ref: effective_set_by_ref,
452                member_index: effective_member_index,
453                sets: effective_compound_sets,
454            },
455            self.show_semantics,
456            self.inject_ast_indices,
457            self.abbreviation_map.as_ref(),
458        );
459        let processing = citation_config.processing.clone().unwrap_or_default();
460        let has_explicit_integral_multi_cite_delimiter = matches!(
461            citation.mode,
462            citum_schema::citation::CitationMode::Integral
463        ) && self
464            .resolve_positioned_citation_spec(citation)
465            .integral
466            .as_ref()
467            .and_then(|spec| spec.multi_cite_delimiter.as_ref())
468            .is_some();
469        let rendered_groups = if matches!(
470            processing,
471            citum_schema::options::Processing::Numeric
472                | citum_schema::options::Processing::Label(_)
473        ) {
474            renderer.render_ungrouped_citation_with_format::<F>(
475                &sorted_items,
476                effective_spec,
477                &citation.mode,
478                renderer_delimiter,
479                citation.suppress_author,
480                citation.position.as_ref(),
481                note_start_text_case,
482            )?
483        } else {
484            renderer.render_grouped_citation_with_format::<F>(
485                &sorted_items,
486                &GroupRenderParams {
487                    spec: effective_spec,
488                    mode: &citation.mode,
489                    intra_delimiter: renderer_delimiter,
490                    suppress_author: citation.suppress_author,
491                    position: citation.position.as_ref(),
492                    note_start_text_case,
493                },
494            )?
495        };
496
497        Ok(
498            if matches!(
499                citation.mode,
500                citum_schema::citation::CitationMode::Integral
501            ) && !has_explicit_integral_multi_cite_delimiter
502            {
503                join_integral_groups(rendered_groups, &self.locale)
504            } else {
505                F::default().join(rendered_groups, renderer_inter_delimiter)
506            },
507        )
508    }
509
510    /// Apply user-supplied prefix and suffix from the citation input.
511    ///
512    /// Automatically adds a trailing space to the prefix and a leading space to
513    /// the suffix if they are not already present and not empty.
514    fn apply_citation_input_affixes<F>(
515        &self,
516        citation: &Citation,
517        content: String,
518        fmt: &F,
519    ) -> String
520    where
521        F: crate::render::format::OutputFormat<Output = String>,
522    {
523        let citation_prefix = citation.prefix.as_deref().unwrap_or("");
524        let citation_suffix = citation.suffix.as_deref().unwrap_or("");
525
526        if citation_prefix.is_empty() && citation_suffix.is_empty() {
527            return content;
528        }
529
530        let formatted_prefix =
531            if !citation_prefix.is_empty() && !citation_prefix.ends_with(char::is_whitespace) {
532                format!("{citation_prefix} ")
533            } else {
534                citation_prefix.to_string()
535            };
536
537        let formatted_suffix =
538            if !citation_suffix.is_empty() && !citation_suffix.starts_with(char::is_whitespace) {
539                format!(" {citation_suffix}")
540            } else {
541                citation_suffix.to_string()
542            };
543
544        fmt.affix(&formatted_prefix, content, &formatted_suffix)
545    }
546
547    /// Apply style-defined wrapping and affixes to the rendered citation output.
548    ///
549    /// Handles `wrap` logic (inner prefixes/suffixes and punctuation) based on
550    /// the citation mode and position.
551    fn apply_spec_wrap_and_affixes<F>(
552        &self,
553        citation: &Citation,
554        effective_spec: &citum_schema::CitationSpec,
555        output: String,
556        fmt: &F,
557    ) -> String
558    where
559        F: crate::render::format::OutputFormat<Output = String>,
560    {
561        let (script, realization) = self.citation_punctuation_context(citation);
562        let spec_prefix = effective_spec
563            .prefix
564            .as_ref()
565            .map(|punctuation| {
566                crate::render::format::realize_punctuation(
567                    punctuation,
568                    script,
569                    realization,
570                    crate::render::format::PunctuationPosition::Prefix,
571                )
572            })
573            .unwrap_or(Cow::Borrowed(""));
574        let spec_suffix = effective_spec
575            .suffix
576            .as_ref()
577            .map(|punctuation| {
578                crate::render::format::realize_punctuation(
579                    punctuation,
580                    script,
581                    realization,
582                    crate::render::format::PunctuationPosition::Suffix,
583                )
584            })
585            .unwrap_or(Cow::Borrowed(""));
586
587        if matches!(
588            citation.mode,
589            citum_schema::citation::CitationMode::Integral
590        ) {
591            if !spec_prefix.is_empty() || !spec_suffix.is_empty() {
592                crate::render::format::apply_punctuation_affixes(
593                    fmt,
594                    effective_spec
595                        .prefix
596                        .as_ref()
597                        .map(|punctuation| (punctuation, spec_prefix.as_ref())),
598                    output,
599                    effective_spec
600                        .suffix
601                        .as_ref()
602                        .map(|punctuation| (punctuation, spec_suffix.as_ref())),
603                )
604            } else {
605                output
606            }
607        } else if let Some(wrap) = effective_spec.wrap.as_ref() {
608            let inner_prefix = wrap.inner_prefix.as_deref().unwrap_or("");
609            let inner_suffix = wrap.inner_suffix.as_deref().unwrap_or("");
610            let inner_wrapped = if !inner_prefix.is_empty() || !inner_suffix.is_empty() {
611                fmt.inner_affix(inner_prefix, output, inner_suffix)
612            } else {
613                output
614            };
615            let marks = crate::render::format::QuoteMarks::from(&self.locale.grammar_options);
616            fmt.wrap_punctuation(
617                &wrap.punctuation,
618                inner_wrapped,
619                &marks,
620                script,
621                realization,
622            )
623        } else if !spec_prefix.is_empty() || !spec_suffix.is_empty() {
624            crate::render::format::apply_punctuation_affixes(
625                fmt,
626                effective_spec
627                    .prefix
628                    .as_ref()
629                    .map(|punctuation| (punctuation, spec_prefix.as_ref())),
630                output,
631                effective_spec
632                    .suffix
633                    .as_ref()
634                    .map(|punctuation| (punctuation, spec_suffix.as_ref())),
635            )
636        } else {
637            output
638        }
639    }
640
641    /// Whether `options.multilingual.scripts.latin.punctuation: latin` applies to a
642    /// citation, based on its first item's effective language.
643    ///
644    /// The citation-spec-level `prefix`/`suffix`/`wrap` applied by
645    /// [`Self::apply_spec_wrap_and_affixes`] sits outside all per-component and
646    /// per-item rendering (which already remap their own full-width delimiters —
647    /// see `render::component` and [`super::rendering::Renderer::affix_content`]),
648    /// so a literal full-width wrap like GB/T author-date's `prefix: ( suffix: )`
649    /// needs the same remap applied to the fully-assembled citation. All items in
650    /// one citation typically share a language; the first item's stands in for
651    /// mixed compound citations as a reasonable approximation.
652    fn wants_latin_punctuation_for_citation(&self, citation: &Citation) -> bool {
653        let configured = self.get_config().multilingual.as_ref().is_some_and(|ml| {
654            ml.scripts.get("latin").is_some_and(|script| {
655                script.punctuation == Some(citum_schema::options::PunctuationStyle::Latin)
656            })
657        });
658
659        configured
660            && citation.items.first().is_some_and(|item| {
661                self.bibliography.get(&item.id).is_some_and(|reference| {
662                    crate::values::is_latin_script_language(
663                        crate::values::effective_item_language(reference).as_deref(),
664                    )
665                })
666            })
667    }
668
669    /// Resolve script selection and per-script realization overrides for a
670    /// citation using its first item as the mixed-cluster approximation.
671    fn citation_punctuation_context(
672        &self,
673        citation: &Citation,
674    ) -> (
675        crate::values::ScriptClass,
676        Option<&citum_schema::options::PunctuationRealization>,
677    ) {
678        let lang = citation.items.first().and_then(|item| {
679            self.bibliography
680                .get(&item.id)
681                .and_then(crate::values::effective_item_language)
682        });
683        crate::values::punctuation_realization_context(
684            lang.as_deref(),
685            self.get_config().multilingual.as_ref(),
686        )
687    }
688
689    /// Render a single citation to plain text.
690    ///
691    /// This is a one-shot convenience wrapper: it begins a throwaway
692    /// [`RunState`] internally, so it has no continuity with any other call.
693    /// Use [`Processor::process_citations`] (or the run-threaded
694    /// `_with_format` variants with an explicit, shared `RunState`) to render
695    /// multiple citations from one document with correct cumulative
696    /// numbering, cite-order tracking, and dynamic compound grouping.
697    ///
698    /// # Errors
699    ///
700    /// Returns an error when referenced items are missing or rendering fails.
701    pub fn process_citation(&self, citation: &Citation) -> Result<String, ProcessorError> {
702        let mut run = self.begin_run();
703        self.process_citation_with_format::<crate::render::plain::PlainText>(citation, &mut run)
704    }
705
706    /// Render a citation to a string using a specific output format.
707    ///
708    /// This resolves the effective citation spec for the citation's mode and
709    /// position, renders the citation body, and applies input and style
710    /// affixes. `run` accumulates cite-order state (citation numbers, cited
711    /// IDs, dynamic compound groups) across calls; pass the same `RunState`
712    /// for every citation in one document to get correct cumulative
713    /// behavior, or a fresh one (via [`Processor::begin_run`]) for an
714    /// isolated, one-off render.
715    ///
716    /// # Errors
717    ///
718    /// Returns an error when referenced items are missing or rendering fails.
719    pub fn process_citation_with_format<F>(
720        &self,
721        citation: &Citation,
722        run: &mut RunState,
723    ) -> Result<String, ProcessorError>
724    where
725        F: crate::render::format::OutputFormat<Output = String>,
726    {
727        let fmt = F::default();
728
729        // For grouped citations, resolve the dynamic compound group BEFORE updating
730        // cited_ids with the current citation's items. This ensures the first-occurrence
731        // check in resolve_dynamic_group sees only references from prior citations.
732        if citation.grouped {
733            self.initialize_numeric_citation_numbers(run);
734            self.resolve_dynamic_group(citation, run);
735        }
736
737        self.track_cited_ids_and_init_numbers(citation, run);
738
739        let effective_spec = self.resolve_effective_citation_spec(citation);
740        let note_start_text_case =
741            self.sentence_initial_note_start_text_case(citation, &effective_spec);
742        let (renderer_delimiter, renderer_inter_delimiter) =
743            self.resolve_citation_delimiters(citation, &effective_spec);
744        let renderer_delimiter = if effective_spec
745            .delimiter
746            .as_ref()
747            .is_some_and(citum_schema::template::DelimiterPunctuation::is_semantic)
748        {
749            fmt.text(&renderer_delimiter)
750        } else {
751            renderer_delimiter.into_owned()
752        };
753        let renderer_inter_delimiter = if effective_spec
754            .multi_cite_delimiter
755            .as_ref()
756            .is_some_and(citum_schema::template::DelimiterPunctuation::is_semantic)
757        {
758            fmt.text(&renderer_inter_delimiter)
759        } else {
760            renderer_inter_delimiter.into_owned()
761        };
762        let content = self.render_citation_content::<F>(
763            citation,
764            &effective_spec,
765            &renderer_delimiter,
766            &renderer_inter_delimiter,
767            note_start_text_case,
768            run,
769        )?;
770        let output = self.apply_citation_input_affixes(citation, content, &fmt);
771        let wrapped = self.apply_spec_wrap_and_affixes(citation, &effective_spec, output, &fmt);
772        let wrapped = if self.wants_latin_punctuation_for_citation(citation) {
773            crate::render::component::remap_to_latin_punctuation(wrapped)
774        } else {
775            wrapped
776        };
777
778        // If the host signals that this cluster opens a sentence, capitalize
779        // the leading character of the composed output.  The markup-aware
780        // variant skips leading punctuation (e.g. an opening parenthesis) so
781        // only the first alphabetic character is affected.
782        let finalized = if citation.sentence_start {
783            let case = crate::values::text_case::resolve_text_case(
784                citum_schema::options::titles::TextCase::CapitalizeFirst,
785                Some(self.locale.locale.as_str()),
786            );
787            crate::values::text_case::apply_text_case_markup_aware(&wrapped, case)
788        } else {
789            wrapped
790        };
791
792        Ok(fmt.finish(finalized))
793    }
794
795    /// Render multiple citations in document order.
796    ///
797    /// For note-based styles, normalizes context and assigns citation
798    /// positions. This is a one-shot convenience wrapper: it begins a
799    /// throwaway [`RunState`] internally, shared across all citations in
800    /// `citations` (so cumulative numbering/grouping within this call is
801    /// correct) but not shared with any other call.
802    ///
803    /// # Errors
804    ///
805    /// Returns an error when any citation in the sequence fails to render.
806    pub fn process_citations(&self, citations: &[Citation]) -> Result<Vec<String>, ProcessorError> {
807        let mut run = self.begin_run();
808        self.process_citations_with_format::<crate::render::plain::PlainText>(citations, &mut run)
809    }
810
811    /// Render multiple citations with a custom output format.
812    ///
813    /// `run` is threaded through every citation in `citations`, in order, so
814    /// numbering/cite-order/dynamic-grouping state accumulates correctly
815    /// across the whole batch. Pass the same `run` on to bibliography
816    /// rendering (after [`RunState::finalize`]) to render a consistent
817    /// document.
818    ///
819    /// # Errors
820    ///
821    /// Returns an error when any citation in the sequence fails to render.
822    pub fn process_citations_with_format<F>(
823        &self,
824        citations: &[Citation],
825        run: &mut RunState,
826    ) -> Result<Vec<String>, ProcessorError>
827    where
828        F: crate::render::format::OutputFormat<Output = String>,
829    {
830        let mut normalized = self.normalize_note_context(citations, run);
831        self.annotate_positions(&mut normalized);
832        normalized
833            .iter()
834            .map(|citation| self.process_citation_with_format::<F>(citation, run))
835            .collect()
836    }
837}