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, bool), 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 punctuation_in_quote = citation_config.punctuation_in_quote;
445        let citation_config = Arc::new(citation_config.into_owned());
446        let renderer = Renderer::new(
447            RendererResources {
448                style: &self.style,
449                bibliography: &self.bibliography,
450                locale: &self.locale,
451                config: citation_config.clone(),
452                bibliography_config: Some(Arc::new(self.get_bibliography_options().into_owned())),
453                first_note_by_id: Some(&run.first_note_by_id),
454            },
455            renderer_hints,
456            &run.citation_numbers,
457            CompoundRenderData {
458                set_by_ref: effective_set_by_ref,
459                member_index: effective_member_index,
460                sets: effective_compound_sets,
461            },
462            self.show_semantics,
463            self.inject_ast_indices,
464            self.abbreviation_map.as_ref(),
465        );
466        let processing = citation_config.processing.clone().unwrap_or_default();
467        let has_explicit_integral_multi_cite_delimiter = matches!(
468            citation.mode,
469            citum_schema::citation::CitationMode::Integral
470        ) && self
471            .resolve_positioned_citation_spec(citation)
472            .integral
473            .as_ref()
474            .and_then(|spec| spec.multi_cite_delimiter.as_ref())
475            .is_some();
476        let rendered_groups = if matches!(
477            processing,
478            citum_schema::options::Processing::Numeric
479                | citum_schema::options::Processing::Label(_)
480        ) {
481            renderer.render_ungrouped_citation_with_format::<F>(
482                &sorted_items,
483                effective_spec,
484                &citation.mode,
485                renderer_delimiter,
486                citation.suppress_author,
487                citation.position.as_ref(),
488                note_start_text_case,
489            )?
490        } else {
491            renderer.render_grouped_citation_with_format::<F>(
492                &sorted_items,
493                &GroupRenderParams {
494                    spec: effective_spec,
495                    mode: &citation.mode,
496                    intra_delimiter: renderer_delimiter,
497                    suppress_author: citation.suppress_author,
498                    position: citation.position.as_ref(),
499                    note_start_text_case,
500                },
501            )?
502        };
503
504        let rendered = 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        Ok((rendered, punctuation_in_quote))
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        punctuation_in_quote: bool,
563        fmt: &F,
564    ) -> String
565    where
566        F: crate::render::format::OutputFormat<Output = String>,
567    {
568        let (script, realization) = self.citation_punctuation_context(citation);
569        let spec_prefix = effective_spec
570            .prefix
571            .as_ref()
572            .map(|punctuation| {
573                crate::render::format::realize_punctuation(
574                    punctuation,
575                    script,
576                    realization.as_deref(),
577                    crate::render::format::PunctuationPosition::Prefix,
578                )
579            })
580            .unwrap_or(Cow::Borrowed(""));
581        let spec_suffix = effective_spec
582            .suffix
583            .as_ref()
584            .map(|punctuation| {
585                crate::render::format::realize_punctuation(
586                    punctuation,
587                    script,
588                    realization.as_deref(),
589                    crate::render::format::PunctuationPosition::Suffix,
590                )
591            })
592            .unwrap_or(Cow::Borrowed(""));
593        let spec_suffix = crate::render::format::RealizedPunctuation::new(spec_suffix);
594        let is_integral = matches!(
595            citation.mode,
596            citum_schema::citation::CitationMode::Integral
597        );
598        let suffix_applies = is_integral || effective_spec.wrap.is_none();
599        let mut output = output;
600        let suffix_core = spec_suffix.core();
601        let suffix_moved_inside_quote = if suffix_applies
602            && punctuation_in_quote
603            && let Some(mark @ ('.' | ',')) = suffix_core
604        {
605            let quote_marks = crate::render::format::QuoteMarks::from(&self.locale.grammar_options);
606            crate::render::punctuation::move_punctuation_into_quote::<F>(
607                &mut output,
608                mark,
609                &quote_marks.close,
610            )
611        } else {
612            false
613        };
614        let spec_suffix_text = if suffix_moved_inside_quote {
615            spec_suffix.tail()
616        } else {
617            spec_suffix.text()
618        };
619
620        if is_integral {
621            if !spec_prefix.is_empty() || !spec_suffix.is_empty() {
622                crate::render::format::apply_punctuation_affixes(
623                    fmt,
624                    effective_spec
625                        .prefix
626                        .as_ref()
627                        .map(|punctuation| (punctuation, spec_prefix.as_ref())),
628                    output,
629                    effective_spec
630                        .suffix
631                        .as_ref()
632                        .map(|punctuation| (punctuation, spec_suffix_text)),
633                )
634            } else {
635                output
636            }
637        } else if let Some(wrap) = effective_spec.wrap.as_ref() {
638            let inner_prefix = wrap.inner_prefix.as_deref().unwrap_or("");
639            let inner_suffix = wrap.inner_suffix.as_deref().unwrap_or("");
640            let inner_wrapped = if !inner_prefix.is_empty() || !inner_suffix.is_empty() {
641                fmt.inner_affix(inner_prefix, output, inner_suffix)
642            } else {
643                output
644            };
645            let marks = crate::render::format::QuoteMarks::from(&self.locale.grammar_options);
646            fmt.wrap_punctuation(
647                &wrap.punctuation,
648                inner_wrapped,
649                &marks,
650                script,
651                realization.as_deref(),
652            )
653        } else if !spec_prefix.is_empty() || !spec_suffix.is_empty() {
654            crate::render::format::apply_punctuation_affixes(
655                fmt,
656                effective_spec
657                    .prefix
658                    .as_ref()
659                    .map(|punctuation| (punctuation, spec_prefix.as_ref())),
660                output,
661                effective_spec
662                    .suffix
663                    .as_ref()
664                    .map(|punctuation| (punctuation, spec_suffix_text)),
665            )
666        } else {
667            output
668        }
669    }
670
671    /// Whether `options.multilingual.scripts.latin.punctuation: latin` applies to a
672    /// citation, based on its first item's effective language.
673    ///
674    /// The citation-spec-level `prefix`/`suffix`/`wrap` applied by
675    /// [`Self::apply_spec_wrap_and_affixes`] sits outside all per-component and
676    /// per-item rendering (which already remap their own full-width delimiters —
677    /// see `render::component` and [`super::rendering::Renderer::affix_content`]),
678    /// so a literal full-width wrap like GB/T author-date's `prefix: ( suffix: )`
679    /// needs the same remap applied to the fully-assembled citation. All items in
680    /// one citation typically share a language; the first item's stands in for
681    /// mixed compound citations as a reasonable approximation.
682    fn wants_latin_punctuation_for_citation(&self, citation: &Citation) -> bool {
683        let configured = self.get_config().multilingual.as_ref().is_some_and(|ml| {
684            ml.scripts.get("latin").is_some_and(|script| {
685                script.punctuation == Some(citum_schema::options::PunctuationStyle::Latin)
686            })
687        });
688
689        configured
690            && citation.items.first().is_some_and(|item| {
691                self.bibliography.get(&item.id).is_some_and(|reference| {
692                    crate::values::is_latin_script_language(
693                        crate::values::effective_item_language(reference).as_deref(),
694                    )
695                })
696            })
697    }
698
699    /// Resolve script selection and per-script realization overrides for a
700    /// citation using its first item as the mixed-cluster approximation.
701    fn citation_punctuation_context(
702        &self,
703        citation: &Citation,
704    ) -> (
705        crate::values::ScriptClass,
706        Option<Cow<'_, citum_schema::options::PunctuationRealization>>,
707    ) {
708        let lang = citation.items.first().and_then(|item| {
709            self.bibliography
710                .get(&item.id)
711                .and_then(crate::values::effective_item_language)
712        });
713        crate::values::punctuation_realization_context(
714            lang.as_deref(),
715            self.get_config().multilingual.as_ref(),
716            self.locale.punctuation_realization.as_ref(),
717        )
718    }
719
720    /// Render a single citation to plain text.
721    ///
722    /// This is a one-shot convenience wrapper: it begins a throwaway
723    /// [`RunState`] internally, so it has no continuity with any other call.
724    /// Use [`Processor::process_citations`] (or the run-threaded
725    /// `_with_format` variants with an explicit, shared `RunState`) to render
726    /// multiple citations from one document with correct cumulative
727    /// numbering, cite-order tracking, and dynamic compound grouping.
728    ///
729    /// # Errors
730    ///
731    /// Returns an error when referenced items are missing or rendering fails.
732    pub fn process_citation(&self, citation: &Citation) -> Result<String, ProcessorError> {
733        let mut run = self.begin_run();
734        self.process_citation_with_format::<crate::render::plain::PlainText>(citation, &mut run)
735    }
736
737    /// Render a citation to a string using a specific output format.
738    ///
739    /// This resolves the effective citation spec for the citation's mode and
740    /// position, renders the citation body, and applies input and style
741    /// affixes. `run` accumulates cite-order state (citation numbers, cited
742    /// IDs, dynamic compound groups) across calls; pass the same `RunState`
743    /// for every citation in one document to get correct cumulative
744    /// behavior, or a fresh one (via [`Processor::begin_run`]) for an
745    /// isolated, one-off render.
746    ///
747    /// # Errors
748    ///
749    /// Returns an error when referenced items are missing or rendering fails.
750    pub fn process_citation_with_format<F>(
751        &self,
752        citation: &Citation,
753        run: &mut RunState,
754    ) -> Result<String, ProcessorError>
755    where
756        F: crate::render::format::OutputFormat<Output = String>,
757    {
758        let fmt = F::default();
759
760        // For grouped citations, resolve the dynamic compound group BEFORE updating
761        // cited_ids with the current citation's items. This ensures the first-occurrence
762        // check in resolve_dynamic_group sees only references from prior citations.
763        if citation.grouped {
764            self.initialize_numeric_citation_numbers(run);
765            self.resolve_dynamic_group(citation, run);
766        }
767
768        self.track_cited_ids_and_init_numbers(citation, run);
769
770        let effective_spec = self.resolve_effective_citation_spec(citation);
771        let note_start_text_case =
772            self.sentence_initial_note_start_text_case(citation, &effective_spec);
773        let (renderer_delimiter, renderer_inter_delimiter) =
774            self.resolve_citation_delimiters(citation, &effective_spec);
775        let renderer_delimiter = if effective_spec
776            .delimiter
777            .as_ref()
778            .is_some_and(citum_schema::template::DelimiterPunctuation::is_semantic)
779        {
780            fmt.text(&renderer_delimiter)
781        } else {
782            renderer_delimiter.into_owned()
783        };
784        let renderer_inter_delimiter = if effective_spec
785            .multi_cite_delimiter
786            .as_ref()
787            .is_some_and(citum_schema::template::DelimiterPunctuation::is_semantic)
788        {
789            fmt.text(&renderer_inter_delimiter)
790        } else {
791            renderer_inter_delimiter.into_owned()
792        };
793        let (content, punctuation_in_quote) = self.render_citation_content::<F>(
794            citation,
795            &effective_spec,
796            &renderer_delimiter,
797            &renderer_inter_delimiter,
798            note_start_text_case,
799            run,
800        )?;
801        let output = self.apply_citation_input_affixes(citation, content, &fmt);
802        let wrapped = self.apply_spec_wrap_and_affixes(
803            citation,
804            &effective_spec,
805            output,
806            punctuation_in_quote,
807            &fmt,
808        );
809        let wrapped = if self.wants_latin_punctuation_for_citation(citation) {
810            crate::render::component::remap_to_latin_punctuation(wrapped)
811        } else {
812            wrapped
813        };
814
815        // If the host signals that this cluster opens a sentence, capitalize
816        // the leading character of the composed output.  The markup-aware
817        // variant skips leading punctuation (e.g. an opening parenthesis) so
818        // only the first alphabetic character is affected.
819        let finalized = if citation.sentence_start {
820            let case = crate::values::text_case::resolve_text_case(
821                citum_schema::options::titles::TextCase::CapitalizeFirst,
822                Some(self.locale.locale.as_str()),
823            );
824            crate::values::text_case::apply_text_case_markup_aware_with_language(
825                &wrapped,
826                case,
827                Some(self.locale.locale.as_str()),
828            )
829        } else {
830            wrapped
831        };
832
833        Ok(fmt.finish(finalized))
834    }
835
836    /// Render multiple citations in document order.
837    ///
838    /// For note-based styles, normalizes context and assigns citation
839    /// positions. This is a one-shot convenience wrapper: it begins a
840    /// throwaway [`RunState`] internally, shared across all citations in
841    /// `citations` (so cumulative numbering/grouping within this call is
842    /// correct) but not shared with any other call.
843    ///
844    /// # Errors
845    ///
846    /// Returns an error when any citation in the sequence fails to render.
847    pub fn process_citations(&self, citations: &[Citation]) -> Result<Vec<String>, ProcessorError> {
848        let mut run = self.begin_run();
849        self.process_citations_with_format::<crate::render::plain::PlainText>(citations, &mut run)
850    }
851
852    /// Render multiple citations with a custom output format.
853    ///
854    /// `run` is threaded through every citation in `citations`, in order, so
855    /// numbering/cite-order/dynamic-grouping state accumulates correctly
856    /// across the whole batch. Pass the same `run` on to bibliography
857    /// rendering (after [`RunState::finalize`]) to render a consistent
858    /// document.
859    ///
860    /// # Errors
861    ///
862    /// Returns an error when any citation in the sequence fails to render.
863    pub fn process_citations_with_format<F>(
864        &self,
865        citations: &[Citation],
866        run: &mut RunState,
867    ) -> Result<Vec<String>, ProcessorError>
868    where
869        F: crate::render::format::OutputFormat<Output = String>,
870    {
871        let mut normalized = self.normalize_note_context(citations, run);
872        self.annotate_positions(&mut normalized);
873        normalized
874            .iter()
875            .map(|citation| self.process_citation_with_format::<F>(citation, run))
876            .collect()
877    }
878}
879
880#[cfg(test)]
881mod tests {
882    use super::*;
883    use crate::render::plain::PlainText;
884    use citum_schema::Style;
885    use citum_schema::template::DelimiterPunctuation;
886
887    fn apply_period_suffix(punctuation_in_quote: bool) -> String {
888        let style = Style {
889            options: Some(Config {
890                punctuation_in_quote,
891                ..Default::default()
892            }),
893            ..Default::default()
894        };
895        let processor = Processor::new(style, Default::default());
896        let spec = citum_schema::CitationSpec {
897            suffix: Some(DelimiterPunctuation::Custom(".".to_string())),
898            ..Default::default()
899        };
900
901        processor.apply_spec_wrap_and_affixes(
902            &Citation::default(),
903            &spec,
904            "“Title”".to_string(),
905            punctuation_in_quote,
906            &PlainText,
907        )
908    }
909
910    #[test]
911    fn citation_spec_suffix_respects_punctuation_in_quote() {
912        assert_eq!(apply_period_suffix(true), "“Title.”");
913        assert_eq!(apply_period_suffix(false), "“Title”.");
914    }
915}