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