Skip to main content

citum_engine/processor/rendering/
mod.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Rendering logic for citation and bibliography output.
7//!
8//! This module handles template-based rendering of citations and bibliographies,
9//! including handling of localization, numbering, formatting, and special modes
10//! like integral (narrative) citations for numeric and label styles.
11
12use crate::error::ProcessorError;
13use crate::reference::{Bibliography, Reference};
14use crate::values::{ProcHints, RenderContext, RenderOptions};
15use citum_schema::citation::CitationLocator;
16use citum_schema::locale::Locale;
17use citum_schema::options::{Config, bibliography::BibliographyConfig};
18use citum_schema::template::TemplateComponent;
19use grouped::component_predicates::{resolve_localized_type_variant, resolve_type_variant};
20use indexmap::IndexMap;
21use std::borrow::Cow;
22use std::cell::RefCell;
23use std::collections::{HashMap, HashSet};
24use std::sync::{Arc, OnceLock, RwLock};
25
26fn embedded_render_locales() -> &'static HashMap<String, Locale> {
27    static LOCALES: OnceLock<HashMap<String, Locale>> = OnceLock::new();
28    LOCALES.get_or_init(|| {
29        let mut locales = HashMap::new();
30        for id in citum_schema::embedded::EMBEDDED_LOCALE_IDS {
31            let Some(bytes) = citum_schema::embedded::get_locale_bytes(id) else {
32                continue;
33            };
34            let Ok(yaml) = std::str::from_utf8(bytes) else {
35                continue;
36            };
37            let Ok(locale) = Locale::from_yaml_str(yaml) else {
38                continue;
39            };
40            locales.insert(id.to_ascii_lowercase(), locale);
41        }
42        locales
43    })
44}
45
46/// The renderer for citation and bibliography templates.
47///
48/// The `Renderer` is responsible for taking compiled templates and applying them
49/// to bibliographic data, handling localization, numbering, and formatting.
50pub struct Renderer<'a> {
51    /// The style definition containing templates and options.
52    pub style: &'a citum_schema::Style,
53    /// The bibliography containing the reference data.
54    pub bibliography: &'a Bibliography,
55    /// The locale used for terms and formatting.
56    pub locale: &'a Locale,
57    /// The active configuration options.
58    pub config: Arc<Config>,
59    /// The active bibliography-only configuration.
60    pub bibliography_config: Option<Arc<BibliographyConfig>>,
61    /// Pre-calculated hints for optimization.
62    pub hints: &'a HashMap<String, ProcHints>,
63    /// Shared state for citation numbers (used in numeric styles).
64    ///
65    /// `RwLock`, not `RefCell`: bibliography entries render in parallel
66    /// (behind the `parallel` feature) once above `PARALLEL_MIN_ENTRIES`,
67    /// and each per-entry `Renderer` borrows this same run-scoped map.
68    pub citation_numbers: &'a RwLock<HashMap<String, usize>>,
69    /// Optional compound set membership indexed by reference id.
70    pub compound_set_by_ref: &'a HashMap<String, String>,
71    /// Optional 0-based member index within each compound set.
72    pub compound_member_index: &'a HashMap<String, usize>,
73    /// Compound sets keyed by set id.
74    pub compound_sets: &'a IndexMap<String, Vec<String>>,
75    /// Whether to output semantic markup (HTML spans, Djot attributes).
76    pub show_semantics: bool,
77    /// Whether to attach source template indices to rendered semantic wrappers.
78    pub inject_ast_indices: bool,
79    /// Mapping from filtered to original template indices (for grouped citations).
80    pub filtered_to_original_index: RefCell<Option<Vec<usize>>>,
81    /// Document-level abbreviation map for post-render substitution.
82    pub abbreviation_map: Option<&'a crate::api::AbbreviationMap>,
83    /// First note number per reference id (populated by normalize_note_context).
84    pub first_note_by_id: Option<&'a RwLock<HashMap<String, u32>>>,
85}
86
87/// Borrowed compound-set context for rendering.
88pub struct CompoundRenderData<'a> {
89    /// Optional compound set membership indexed by reference id.
90    pub set_by_ref: &'a HashMap<String, String>,
91    /// Optional 0-based member index within each compound set.
92    pub member_index: &'a HashMap<String, usize>,
93    /// Compound sets keyed by set id.
94    pub sets: &'a IndexMap<String, Vec<String>>,
95}
96
97mod collapse;
98mod grouped;
99mod grouped_fallback;
100mod helpers;
101
102#[cfg(test)]
103#[allow(
104    clippy::unwrap_used,
105    clippy::expect_used,
106    clippy::panic,
107    clippy::indexing_slicing,
108    clippy::todo,
109    clippy::unimplemented,
110    clippy::unreachable,
111    clippy::get_unwrap,
112    reason = "Panicking is acceptable and often desired in tests."
113)]
114mod tests;
115
116pub use grouped_fallback::GroupRenderParams;
117pub use grouped_fallback::TemplateRenderParams;
118pub(super) use helpers::{
119    find_grouping_component, has_contributor_component, leading_group_affix,
120    remove_first_contributor_with_role, strip_author_component, strip_leading_group_affixes,
121};
122
123/// Internal render request used to keep template-processing call sites compact.
124pub struct TemplateRenderRequest<'a> {
125    /// The template to render.
126    pub template: &'a [TemplateComponent],
127    /// The rendering context (Citation or Bibliography).
128    pub context: RenderContext,
129    /// The citation mode (Integral or `NonIntegral`).
130    pub mode: citum_schema::citation::CitationMode,
131    /// Whether to suppress the author in output.
132    pub suppress_author: bool,
133    /// The raw citation locator if present (for new rendering logic).
134    pub locator_raw: Option<&'a CitationLocator>,
135    /// The citation number for numeric styles.
136    pub citation_number: usize,
137    /// The citation position (e.g., Ibid).
138    pub position: Option<citum_schema::citation::Position>,
139    /// Optional note-start text-case policy for note-style repeated-note output.
140    pub note_start_text_case: Option<citum_schema::NoteStartTextCase>,
141    /// Integral name state for name formatting.
142    pub integral_name_state: Option<citum_schema::citation::IntegralNameState>,
143    /// Org abbreviation state for org-name formatting.
144    pub org_abbreviation_state: Option<citum_schema::citation::IntegralNameState>,
145    /// First note number for this reference (note styles, subsequent position).
146    pub first_reference_note_number: Option<u32>,
147}
148
149/// Per-item state resolved for rendering one ungrouped citation item.
150struct UngroupedItemRenderState<'a> {
151    reference: &'a Reference,
152    template: Cow<'a, [TemplateComponent]>,
153    delimiter: &'a str,
154}
155
156/// Shared, citation-wide parameters threaded into each ungrouped item render.
157#[derive(Clone, Copy)]
158struct UngroupedItemRenderParams<'a> {
159    mode: &'a citum_schema::citation::CitationMode,
160    suppress_author: bool,
161    position: Option<&'a citum_schema::citation::Position>,
162    note_start_text_case: Option<citum_schema::NoteStartTextCase>,
163}
164
165#[derive(Clone, Default)]
166struct TemplateComponentTracker {
167    rendered_vars: HashSet<String>,
168    substituted_bases: HashSet<String>,
169}
170
171impl TemplateComponentTracker {
172    fn should_skip(&self, var_key: Option<&str>) -> bool {
173        let Some(var_key) = var_key else {
174            return false;
175        };
176        let base = key_base(var_key);
177        self.rendered_vars.contains(var_key) || self.substituted_bases.contains(base.as_ref())
178    }
179
180    fn mark_rendered(&mut self, var_key: Option<String>, substituted_key: Option<&str>) {
181        if let Some(var_key) = var_key {
182            self.rendered_vars.insert(var_key);
183        }
184        if let Some(substituted_key) = substituted_key {
185            self.rendered_vars.insert(substituted_key.to_string());
186            self.substituted_bases
187                .insert(key_base(substituted_key).into_owned());
188        }
189    }
190
191    fn merge_from(&mut self, other: Self) {
192        self.rendered_vars.extend(other.rendered_vars);
193        self.substituted_bases.extend(other.substituted_bases);
194    }
195}
196
197/// Core style resources borrowed by every [`Renderer`] instance.
198///
199/// Bundles the four immutable resolution inputs so that [`Renderer::new`] stays
200/// within clippy's argument-count limit.
201pub struct RendererResources<'a> {
202    /// The style definition containing templates and options.
203    pub style: &'a citum_schema::Style,
204    /// The bibliography containing the reference data.
205    pub bibliography: &'a Bibliography,
206    /// The locale used for terms and formatting.
207    pub locale: &'a Locale,
208    /// The active configuration options.
209    pub config: Arc<Config>,
210    /// The active bibliography-only configuration.
211    pub bibliography_config: Option<Arc<BibliographyConfig>>,
212    /// First note number per reference id (note styles; `None` for bibliography rendering).
213    pub first_note_by_id: Option<&'a RwLock<HashMap<String, u32>>>,
214}
215
216impl<'a> Renderer<'a> {
217    /// Creates a new `Renderer` instance.
218    pub fn new(
219        resources: RendererResources<'a>,
220        hints: &'a HashMap<String, ProcHints>,
221        citation_numbers: &'a RwLock<HashMap<String, usize>>,
222        compound: CompoundRenderData<'a>,
223        show_semantics: bool,
224        inject_ast_indices: bool,
225        abbreviation_map: Option<&'a crate::api::AbbreviationMap>,
226    ) -> Self {
227        Self {
228            style: resources.style,
229            bibliography: resources.bibliography,
230            locale: resources.locale,
231            config: resources.config,
232            bibliography_config: resources.bibliography_config,
233            hints,
234            citation_numbers,
235            compound_set_by_ref: compound.set_by_ref,
236            compound_member_index: compound.member_index,
237            compound_sets: compound.sets,
238            show_semantics,
239            inject_ast_indices,
240            filtered_to_original_index: RefCell::new(None),
241            abbreviation_map,
242            first_note_by_id: resources.first_note_by_id,
243        }
244    }
245
246    /// Select the embedded rendering locale for an explicitly matched localized layout.
247    fn locale_for_reference(&self, reference: &Reference, context: RenderContext) -> &Locale {
248        let language = crate::values::effective_item_language(reference);
249        let selected = match context {
250            RenderContext::Citation => self
251                .style
252                .citation
253                .as_ref()
254                .and_then(|spec| spec.resolve_localized_template(language.as_deref())),
255            RenderContext::Bibliography => self
256                .style
257                .bibliography
258                .as_ref()
259                .and_then(|spec| spec.resolve_localized_template(language.as_deref())),
260        };
261        let Some(locale_id) = selected.and_then(|resolved| resolved.locale) else {
262            return self.locale;
263        };
264
265        let locales = embedded_render_locales();
266        let key = locale_id.to_ascii_lowercase();
267        locales
268            .get(&key)
269            .or_else(|| {
270                let primary = key.split(['-', '_']).next()?;
271                locales.iter().find_map(|(candidate, locale)| {
272                    candidate
273                        .split(['-', '_'])
274                        .next()
275                        .is_some_and(|candidate_primary| candidate_primary == primary)
276                        .then_some(locale)
277                })
278            })
279            .unwrap_or(self.locale)
280    }
281
282    /// Resolve multilingual contributor names using the style's config.
283    fn resolve_contributor_names(
284        &self,
285        contributor: &citum_schema::reference::contributor::Contributor,
286    ) -> Vec<crate::reference::FlatName> {
287        let ml = self.config.multilingual.as_ref();
288        crate::values::resolve_multilingual_name(
289            contributor,
290            ml.and_then(|m| m.name_mode.as_ref()),
291            ml.and_then(|m| m.preferred_transliteration.as_deref()),
292            ml.and_then(|m| m.preferred_script.as_ref()),
293            &self.locale.locale,
294        )
295    }
296
297    /// Generate an alphabetic or numeric sub-label (e.g., "a", "1") for a
298    /// reference member of a compound set.
299    fn citation_sub_label_for_ref(&self, ref_id: &str) -> Option<String> {
300        let compound = self
301            .bibliography_config
302            .as_ref()
303            .and_then(|b| b.compound_numeric.as_ref())?;
304        let set_id = self.compound_set_by_ref.get(ref_id)?;
305        let members = self.compound_sets.get(set_id)?;
306        if members.len() <= 1 {
307            return None;
308        }
309        if !compound.subentry {
310            return None;
311        }
312        let idx = *self.compound_member_index.get(ref_id)?;
313        match compound.sub_label {
314            citum_schema::options::bibliography::SubLabelStyle::Alphabetic => {
315                crate::values::int_to_letter((idx + 1) as u32)
316            }
317            citum_schema::options::bibliography::SubLabelStyle::Numeric => {
318                Some((idx + 1).to_string())
319            }
320        }
321    }
322
323    /// Determines if the processor should render author-plus-number text for a numeric style
324    /// when in "integral" (narrative) citation mode.
325    ///
326    /// This happens when the style is numeric and the user requests a narrative
327    /// citation (e.g., "Smith [1]"), but hasn't provided an explicit narrative template.
328    fn should_render_author_number_for_numeric_integral(
329        &self,
330        mode: &citum_schema::citation::CitationMode,
331    ) -> bool {
332        matches!(mode, citum_schema::citation::CitationMode::Integral)
333            && self.config.processing.as_ref().is_some_and(|processing| {
334                matches!(processing, citum_schema::options::Processing::Numeric)
335            })
336            && !self.has_explicit_integral_template()
337    }
338
339    /// Whether the style provides an explicit integral (narrative) template.
340    fn has_explicit_integral_template(&self) -> bool {
341        self.style.citation.as_ref().is_some_and(|c| {
342            c.integral.as_ref().is_some_and(|i| {
343                i.template.is_some() || i.template_ref.is_some() || i.locales.is_some()
344            })
345        })
346    }
347
348    /// Determine if compound subentries should be collapsed for this citation.
349    fn should_collapse_compound_subentries(
350        &self,
351        mode: &citum_schema::citation::CitationMode,
352    ) -> bool {
353        if !matches!(mode, citum_schema::citation::CitationMode::NonIntegral) {
354            return false;
355        }
356
357        self.bibliography_config
358            .as_ref()
359            .and_then(|b| b.compound_numeric.as_ref())
360            .is_some_and(|c| c.subentry && c.collapse_subentries)
361    }
362
363    /// Determine if citation numbers should be collapsed into ranges.
364    fn should_collapse_citation_numbers(
365        &self,
366        spec: &citum_schema::CitationSpec,
367        mode: &citum_schema::citation::CitationMode,
368    ) -> bool {
369        if !matches!(mode, citum_schema::citation::CitationMode::NonIntegral) {
370            return false;
371        }
372
373        let is_numeric = self
374            .config
375            .processing
376            .as_ref()
377            .is_some_and(|p| matches!(p, citum_schema::options::Processing::Numeric));
378
379        is_numeric
380            && matches!(
381                spec.collapse,
382                Some(citum_schema::CitationCollapse::CitationNumber)
383            )
384    }
385
386    /// Heuristic for ensuring proper spacing after a citation prefix.
387    fn normalize_prefix_spacing(prefix: &str) -> String {
388        if !prefix.is_empty() && !prefix.ends_with(char::is_whitespace) {
389            format!("{prefix} ")
390        } else {
391            prefix.to_string()
392        }
393    }
394
395    /// Ensure suffix has proper spacing (add space if suffix doesn't start with
396    /// punctuation and isn't empty).
397    fn ensure_suffix_spacing(suffix: &str) -> String {
398        if suffix.is_empty() {
399            String::new()
400        } else if suffix.starts_with(char::is_whitespace)
401            || suffix.starts_with(',')
402            || suffix.starts_with(';')
403            || suffix.starts_with('.')
404        {
405            // Already has leading space or punctuation
406            suffix.to_string()
407        } else {
408            // Add space before suffix to separate from content
409            format!(" {suffix}")
410        }
411    }
412
413    /// Whether `options.multilingual.scripts.latin.punctuation: latin` applies to the
414    /// reference behind `ref_id`.
415    ///
416    /// Citation-cluster-level `prefix`/`suffix`/`delimiter` (e.g. GB/T author-date's
417    /// full-width `( )` wrap) are applied in [`Self::affix_content`], outside each
418    /// component's own rendering — component-internal punctuation is already remapped
419    /// by `render::component::wants_latin_punctuation`. This mirrors that check using
420    /// the citation item's resolved reference.
421    fn wants_latin_punctuation_for_id(&self, ref_id: &str) -> bool {
422        let configured = self.config.multilingual.as_ref().is_some_and(|ml| {
423            ml.scripts.get("latin").is_some_and(|script| {
424                script.punctuation == Some(citum_schema::options::PunctuationStyle::Latin)
425            })
426        });
427
428        configured
429            && self.bibliography.get(ref_id).is_some_and(|reference| {
430                crate::values::is_latin_script_language(
431                    crate::values::effective_item_language(reference).as_deref(),
432                )
433            })
434    }
435
436    /// Apply prefix and suffix spacing heuristics to a rendered string.
437    ///
438    /// `ref_id` identifies the reference this content belongs to, so a
439    /// script-aware punctuation remap (see [`Self::wants_latin_punctuation_for_id`])
440    /// can be applied to affixes assembled outside component rendering. Pass
441    /// `None` when no single reference applies (e.g. author-only content).
442    fn affix_content<F>(
443        &self,
444        fmt: &F,
445        content: String,
446        prefix: Option<&str>,
447        suffix: Option<&str>,
448        ref_id: Option<&str>,
449    ) -> String
450    where
451        F: crate::render::format::OutputFormat<Output = String>,
452    {
453        let prefix = prefix.unwrap_or("");
454        let suffix = suffix.unwrap_or("");
455        let affixed = if prefix.is_empty() && suffix.is_empty() {
456            content
457        } else {
458            fmt.affix(
459                &Self::normalize_prefix_spacing(prefix),
460                content,
461                &Self::ensure_suffix_spacing(suffix),
462            )
463        };
464
465        if ref_id.is_some_and(|id| self.wants_latin_punctuation_for_id(id)) {
466            crate::render::component::remap_to_latin_punctuation(affixed)
467        } else {
468            affixed
469        }
470    }
471
472    /// Pair rendered content with associated reference IDs to form a semantic chunk.
473    fn build_citation_chunk<F>(
474        &self,
475        fmt: &F,
476        ids: Vec<String>,
477        content: String,
478        prefix: Option<&str>,
479        suffix: Option<&str>,
480    ) -> Option<(Vec<String>, String)>
481    where
482        F: crate::render::format::OutputFormat<Output = String>,
483    {
484        if content.is_empty() {
485            None
486        } else {
487            let affixed = self.affix_content(
488                fmt,
489                content,
490                prefix,
491                suffix,
492                ids.first().map(String::as_str),
493            );
494            Some((ids, affixed))
495        }
496    }
497
498    /// Build a citation chunk for a single item from its rendered content.
499    fn build_item_chunk<F>(
500        &self,
501        fmt: &F,
502        item: &crate::reference::CitationItem,
503        content: String,
504    ) -> Option<(Vec<String>, String)>
505    where
506        F: crate::render::format::OutputFormat<Output = String>,
507    {
508        self.build_citation_chunk(
509            fmt,
510            vec![item.id.clone()],
511            content,
512            item.prefix.as_deref(),
513            item.suffix.as_deref(),
514        )
515    }
516
517    /// Create a template render request for a single citation item.
518    fn citation_render_request<'b>(
519        &self,
520        item: &'b crate::reference::CitationItem,
521        template: &'b [TemplateComponent],
522        mode: &citum_schema::citation::CitationMode,
523        suppress_author: bool,
524        position: Option<&citum_schema::citation::Position>,
525        note_start_text_case: Option<citum_schema::NoteStartTextCase>,
526    ) -> TemplateRenderRequest<'b> {
527        TemplateRenderRequest {
528            template,
529            context: RenderContext::Citation,
530            mode: mode.clone(),
531            suppress_author,
532            locator_raw: item.locator.as_ref(),
533            citation_number: self.get_or_assign_citation_number(&item.id),
534            position: position.cloned(),
535            note_start_text_case,
536            integral_name_state: item.integral_name_state,
537            org_abbreviation_state: item.org_abbreviation_state,
538            first_reference_note_number: self.first_note_by_id.as_ref().and_then(|m| {
539                m.read()
540                    .unwrap_or_else(std::sync::PoisonError::into_inner)
541                    .get(&item.id)
542                    .copied()
543            }),
544        }
545    }
546
547    /// Render a single item to a formatted string using a template.
548    fn render_item_from_template_with_format<F>(
549        &self,
550        reference: &Reference,
551        request: TemplateRenderRequest<'_>,
552        delimiter: &str,
553    ) -> Option<String>
554    where
555        F: crate::render::format::OutputFormat<Output = String>,
556    {
557        self.process_template_request_with_format::<F>(reference, request)
558            .map(|proc| {
559                crate::render::citation::citation_to_string_with_format::<F>(
560                    &proc,
561                    None,
562                    None,
563                    None,
564                    Some(delimiter),
565                )
566            })
567    }
568
569    /// Resolve the reference, template, and delimiter needed to render one
570    /// ungrouped citation item, applying type-variant and language fallbacks.
571    fn resolve_ungrouped_item_render_state<'b>(
572        &'b self,
573        item: &'b crate::reference::CitationItem,
574        spec: &'b citum_schema::CitationSpec,
575        intra_delimiter: &'b str,
576    ) -> Result<UngroupedItemRenderState<'b>, ProcessorError> {
577        let reference = self
578            .bibliography
579            .get(&item.id)
580            .ok_or_else(|| ProcessorError::ReferenceNotFound(item.id.clone()))?;
581        let ref_type = reference.ref_type();
582        let item_language = crate::values::effective_item_language(reference);
583        let localized = spec.resolve_localized_template(item_language.as_deref());
584        let template = localized
585            .as_ref()
586            .filter(|resolved| resolved.type_variants.is_some())
587            .cloned()
588            .map(|resolved| Cow::Owned(resolve_localized_type_variant(resolved, None, &ref_type)))
589            .or_else(|| {
590                resolve_type_variant(spec.type_variants.as_ref(), &ref_type).map(Cow::Borrowed)
591            })
592            .or_else(|| localized.map(|resolved| Cow::Owned(resolved.template)))
593            .unwrap_or(Cow::Borrowed(&[] as &[TemplateComponent]));
594
595        Ok(UngroupedItemRenderState {
596            reference,
597            template,
598            delimiter: intra_delimiter,
599        })
600    }
601
602    /// Initialize render options for a citation.
603    fn citation_render_options<'b>(
604        &'b self,
605        reference: &Reference,
606        mode: citum_schema::citation::CitationMode,
607        suppress_author: bool,
608        locator_raw: Option<&'b CitationLocator>,
609        ref_type: Option<String>,
610    ) -> RenderOptions<'b> {
611        RenderOptions {
612            config: self.config.clone(),
613            bibliography_config: self.bibliography_config.clone(),
614            locale: self.locale_for_reference(reference, RenderContext::Citation),
615            context: RenderContext::Citation,
616            mode,
617            suppress_author,
618            locator_raw,
619            ref_type,
620            show_semantics: self.show_semantics,
621            current_template_index: None,
622            abbreviation_map: self.abbreviation_map,
623        }
624    }
625
626    /// Render author + citation number for numeric integral citations.
627    ///
628    /// Default implementation for narrative citations in numeric styles (e.g., "Smith [1]").
629    fn render_author_number_for_numeric_integral_with_format<F>(
630        &self,
631        reference: &Reference,
632        item: &crate::reference::CitationItem,
633        citation_number: usize,
634    ) -> String
635    where
636        F: crate::render::format::OutputFormat<Output = String>,
637    {
638        let fmt = F::default();
639        let options = self.citation_render_options(
640            reference,
641            citum_schema::citation::CitationMode::Integral,
642            false,
643            item.locator.as_ref(),
644            Some(reference.ref_type()),
645        );
646
647        // Render author in short form
648        let author_part = if let Some(authors) = reference.author() {
649            let names_vec = self.resolve_contributor_names(&authors);
650            fmt.text(&crate::values::format_contributors_short(
651                &names_vec, &options,
652            ))
653        } else {
654            String::new()
655        };
656
657        // Include compound sub-label (e.g. "a", "b") when applicable.
658        let ref_id = reference.id().unwrap_or_default().to_string();
659        let sub_label = self.citation_sub_label_for_ref(&ref_id).unwrap_or_default();
660
661        // Format: "Author [Na]"
662        if author_part.is_empty() {
663            // Fallback: just citation number if no author
664            format!("[{citation_number}{sub_label}]")
665        } else {
666            format!("{author_part} [{citation_number}{sub_label}]")
667        }
668    }
669
670    /// Render one item as author + citation number for numeric integral cites.
671    fn render_numeric_integral_item_chunk_with_format<F>(
672        &self,
673        fmt: &F,
674        item: &crate::reference::CitationItem,
675    ) -> Result<Option<(Vec<String>, String)>, ProcessorError>
676    where
677        F: crate::render::format::OutputFormat<Output = String>,
678    {
679        let reference = self
680            .bibliography
681            .get(&item.id)
682            .ok_or_else(|| ProcessorError::ReferenceNotFound(item.id.clone()))?;
683        let citation_number = self.get_or_assign_citation_number(&item.id);
684        let item_str = self.render_author_number_for_numeric_integral_with_format::<F>(
685            reference,
686            item,
687            citation_number,
688        );
689        Ok(self.build_item_chunk(fmt, item, item_str))
690    }
691
692    /// Render one ungrouped item from its resolved template state.
693    fn render_template_item_chunk_with_format<F>(
694        &self,
695        fmt: &F,
696        item: &crate::reference::CitationItem,
697        state: UngroupedItemRenderState<'_>,
698        params: UngroupedItemRenderParams<'_>,
699    ) -> Option<(Vec<String>, String)>
700    where
701        F: crate::render::format::OutputFormat<Output = String>,
702    {
703        let request = self.citation_render_request(
704            item,
705            &state.template,
706            params.mode,
707            params.suppress_author,
708            params.position,
709            params.note_start_text_case,
710        );
711        self.render_item_from_template_with_format::<F>(state.reference, request, state.delimiter)
712            .and_then(|item_str| self.build_item_chunk(fmt, item, item_str))
713    }
714
715    /// Render citation items without grouping, using plain text format.
716    ///
717    /// # Errors
718    ///
719    /// Returns an error when a referenced item is missing or item rendering
720    /// fails.
721    pub fn render_ungrouped_citation(
722        &self,
723        items: &[crate::reference::CitationItem],
724        spec: &citum_schema::CitationSpec,
725        mode: &citum_schema::citation::CitationMode,
726        intra_delimiter: &str,
727        suppress_author: bool,
728        position: Option<&citum_schema::citation::Position>,
729    ) -> Result<Vec<String>, ProcessorError> {
730        self.render_ungrouped_citation_with_format::<crate::render::plain::PlainText>(
731            items,
732            spec,
733            mode,
734            intra_delimiter,
735            suppress_author,
736            position,
737            spec.note_start_text_case,
738        )
739    }
740
741    /// Render citation items without grouping, generic over the output format.
742    ///
743    /// This is the core logic for iterating over citation items, looking up references,
744    /// and applying the appropriate template or fallback logic.
745    ///
746    /// # Errors
747    ///
748    /// Returns an error when a referenced item is missing or item rendering
749    /// fails.
750    #[allow(
751        clippy::too_many_arguments,
752        reason = "Ungrouped citation rendering now needs explicit note-start context."
753    )]
754    pub fn render_ungrouped_citation_with_format<F>(
755        &self,
756        items: &[crate::reference::CitationItem],
757        spec: &citum_schema::CitationSpec,
758        mode: &citum_schema::citation::CitationMode,
759        intra_delimiter: &str,
760        suppress_author: bool,
761        position: Option<&citum_schema::citation::Position>,
762        note_start_text_case: Option<citum_schema::NoteStartTextCase>,
763    ) -> Result<Vec<String>, ProcessorError>
764    where
765        F: crate::render::format::OutputFormat<Output = String>,
766    {
767        let fmt = F::default();
768        let mut chunks: Vec<(Vec<String>, String)> = Vec::new();
769
770        // For numeric styles with integral mode, render author + citation number instead.
771        let use_author_number = self.should_render_author_number_for_numeric_integral(mode);
772        let params = UngroupedItemRenderParams {
773            mode,
774            suppress_author,
775            position,
776            note_start_text_case,
777        };
778
779        for item in items {
780            let chunk = if use_author_number {
781                self.render_numeric_integral_item_chunk_with_format::<F>(&fmt, item)?
782            } else {
783                let state =
784                    self.resolve_ungrouped_item_render_state(item, spec, intra_delimiter)?;
785                self.render_template_item_chunk_with_format::<F>(&fmt, item, state, params)
786            };
787
788            if let Some(chunk) = chunk {
789                chunks.push(chunk);
790            }
791        }
792
793        if self.should_collapse_compound_subentries(mode) {
794            chunks = self.collapse_compound_citation_chunks(chunks);
795        }
796        if self.should_collapse_citation_numbers(spec, mode) {
797            chunks = self.collapse_numeric_citation_chunks(chunks);
798        }
799
800        Ok(chunks
801            .into_iter()
802            .map(|(ids, content)| fmt.citation(ids, content))
803            .collect())
804    }
805}
806
807fn key_base(key: &str) -> Cow<'_, str> {
808    let mut parts = key.splitn(3, ':');
809    match (parts.next(), parts.next()) {
810        (Some(kind), Some(var)) => Cow::Owned(format!("{kind}:{var}")),
811        _ => Cow::Borrowed(key),
812    }
813}
814
815/// Get a unique key for a template component's variable.
816///
817/// The key includes rendering context (prefix/suffix) to allow the same variable
818/// to render multiple times if it appears in semantically different contexts.
819/// This enables styles like Chicago that require year after author AND after publisher.
820#[must_use]
821pub fn get_variable_key(component: &TemplateComponent) -> Option<String> {
822    use citum_schema::template::Rendering;
823    use std::fmt::Write;
824
825    fn push_context_suffix(key: &mut String, rendering: &Rendering) {
826        match (&rendering.prefix, &rendering.suffix) {
827            (Some(prefix), Some(suffix)) => {
828                key.push(':');
829                key.push_str(prefix);
830                key.push('_');
831                key.push_str(suffix);
832            }
833            (Some(prefix), None) => {
834                key.push(':');
835                key.push_str(prefix);
836            }
837            (None, Some(suffix)) => {
838                key.push(':');
839                key.push_str(suffix);
840            }
841            (None, None) => {}
842        }
843    }
844
845    fn make_key(kind: &str, value: impl std::fmt::Debug, rendering: &Rendering) -> Option<String> {
846        let mut key = String::new();
847        write!(&mut key, "{kind}:{value:?}").ok()?;
848        push_context_suffix(&mut key, rendering);
849        Some(key)
850    }
851
852    match component {
853        TemplateComponent::Contributor(c) => c.contributor.as_single().map_or_else(
854            || make_key("contributor", &c.contributor, &c.rendering),
855            |role| make_key("contributor", role, &c.rendering),
856        ),
857        TemplateComponent::Date(d) => make_key("date", &d.date, &d.rendering),
858        TemplateComponent::Variable(v) => make_key("variable", &v.variable, &v.rendering),
859        TemplateComponent::Title(t) => {
860            let mut key = format!("title:{:?}", t.title);
861            if let Some(form) = &t.form {
862                write!(&mut key, ":{form:?}").ok()?;
863            }
864            push_context_suffix(&mut key, &t.rendering);
865            Some(key)
866        }
867        TemplateComponent::Number(n) => make_key("number", &n.number, &n.rendering),
868        TemplateComponent::Identifier(i) => make_key("identifier", &i.identifier, &i.rendering),
869        TemplateComponent::Group(_) => None,
870        _ => None,
871    }
872}