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 indexmap::IndexMap;
20use std::borrow::Cow;
21use std::cell::RefCell;
22use std::collections::{HashMap, HashSet};
23
24/// The renderer for citation and bibliography templates.
25///
26/// The `Renderer` is responsible for taking compiled templates and applying them
27/// to bibliographic data, handling localization, numbering, and formatting.
28pub struct Renderer<'a> {
29    /// The style definition containing templates and options.
30    pub style: &'a citum_schema::Style,
31    /// The bibliography containing the reference data.
32    pub bibliography: &'a Bibliography,
33    /// The locale used for terms and formatting.
34    pub locale: &'a Locale,
35    /// The active configuration options.
36    pub config: &'a Config,
37    /// The active bibliography-only configuration.
38    pub bibliography_config: Option<BibliographyConfig>,
39    /// Pre-calculated hints for optimization.
40    pub hints: &'a HashMap<String, ProcHints>,
41    /// Shared state for citation numbers (used in numeric styles).
42    pub citation_numbers: &'a RefCell<HashMap<String, usize>>,
43    /// Optional compound set membership indexed by reference id.
44    pub compound_set_by_ref: &'a HashMap<String, String>,
45    /// Optional 0-based member index within each compound set.
46    pub compound_member_index: &'a HashMap<String, usize>,
47    /// Compound sets keyed by set id.
48    pub compound_sets: &'a IndexMap<String, Vec<String>>,
49    /// Whether to output semantic markup (HTML spans, Djot attributes).
50    pub show_semantics: bool,
51    /// Whether to attach source template indices to rendered semantic wrappers.
52    pub inject_ast_indices: bool,
53    /// Mapping from filtered to original template indices (for grouped citations).
54    pub filtered_to_original_index: RefCell<Option<Vec<usize>>>,
55    /// Document-level abbreviation map for post-render substitution.
56    pub abbreviation_map: Option<&'a crate::api::AbbreviationMap>,
57}
58
59/// Borrowed compound-set context for rendering.
60pub struct CompoundRenderData<'a> {
61    /// Optional compound set membership indexed by reference id.
62    pub set_by_ref: &'a HashMap<String, String>,
63    /// Optional 0-based member index within each compound set.
64    pub member_index: &'a HashMap<String, usize>,
65    /// Compound sets keyed by set id.
66    pub sets: &'a IndexMap<String, Vec<String>>,
67}
68
69mod collapse;
70mod grouped;
71mod grouped_fallback;
72mod helpers;
73
74#[cfg(test)]
75#[allow(
76    clippy::unwrap_used,
77    clippy::expect_used,
78    clippy::panic,
79    clippy::indexing_slicing,
80    clippy::todo,
81    clippy::unimplemented,
82    clippy::unreachable,
83    clippy::get_unwrap,
84    reason = "Panicking is acceptable and often desired in tests."
85)]
86mod tests;
87
88pub use grouped_fallback::GroupRenderParams;
89pub use grouped_fallback::TemplateRenderParams;
90pub(super) use helpers::{
91    find_grouping_component, has_contributor_component, leading_group_affix,
92    strip_author_component, strip_leading_group_affixes,
93};
94
95/// Internal render request used to keep template-processing call sites compact.
96pub struct TemplateRenderRequest<'a> {
97    /// The template to render.
98    pub template: &'a [TemplateComponent],
99    /// The rendering context (Citation or Bibliography).
100    pub context: RenderContext,
101    /// The citation mode (Integral or `NonIntegral`).
102    pub mode: citum_schema::citation::CitationMode,
103    /// Whether to suppress the author in output.
104    pub suppress_author: bool,
105    /// The raw citation locator if present (for new rendering logic).
106    pub locator_raw: Option<&'a CitationLocator>,
107    /// The citation number for numeric styles.
108    pub citation_number: usize,
109    /// The citation position (e.g., Ibid).
110    pub position: Option<citum_schema::citation::Position>,
111    /// Optional note-start text-case policy for note-style repeated-note output.
112    pub note_start_text_case: Option<citum_schema::NoteStartTextCase>,
113    /// Integral name state for name formatting.
114    pub integral_name_state: Option<citum_schema::citation::IntegralNameState>,
115    /// Org abbreviation state for org-name formatting.
116    pub org_abbreviation_state: Option<citum_schema::citation::IntegralNameState>,
117}
118
119#[derive(Default)]
120struct TemplateComponentTracker {
121    rendered_vars: HashSet<String>,
122    substituted_bases: HashSet<String>,
123}
124
125impl TemplateComponentTracker {
126    fn should_skip(&self, var_key: Option<&str>) -> bool {
127        let Some(var_key) = var_key else {
128            return false;
129        };
130        let base = key_base(var_key);
131        self.rendered_vars.contains(var_key) || self.substituted_bases.contains(base.as_ref())
132    }
133
134    fn mark_rendered(&mut self, var_key: Option<String>, substituted_key: Option<&str>) {
135        if let Some(var_key) = var_key {
136            self.rendered_vars.insert(var_key);
137        }
138        if let Some(substituted_key) = substituted_key {
139            self.rendered_vars.insert(substituted_key.to_string());
140            self.substituted_bases
141                .insert(key_base(substituted_key).into_owned());
142        }
143    }
144}
145
146/// Core style resources borrowed by every [`Renderer`] instance.
147///
148/// Bundles the four immutable resolution inputs so that [`Renderer::new`] stays
149/// within clippy's argument-count limit.
150pub struct RendererResources<'a> {
151    /// The style definition containing templates and options.
152    pub style: &'a citum_schema::Style,
153    /// The bibliography containing the reference data.
154    pub bibliography: &'a Bibliography,
155    /// The locale used for terms and formatting.
156    pub locale: &'a Locale,
157    /// The active configuration options.
158    pub config: &'a Config,
159    /// The active bibliography-only configuration.
160    pub bibliography_config: Option<BibliographyConfig>,
161}
162
163impl<'a> Renderer<'a> {
164    /// Creates a new `Renderer` instance.
165    pub fn new(
166        resources: RendererResources<'a>,
167        hints: &'a HashMap<String, ProcHints>,
168        citation_numbers: &'a RefCell<HashMap<String, usize>>,
169        compound: CompoundRenderData<'a>,
170        show_semantics: bool,
171        inject_ast_indices: bool,
172        abbreviation_map: Option<&'a crate::api::AbbreviationMap>,
173    ) -> Self {
174        Self {
175            style: resources.style,
176            bibliography: resources.bibliography,
177            locale: resources.locale,
178            config: resources.config,
179            bibliography_config: resources.bibliography_config,
180            hints,
181            citation_numbers,
182            compound_set_by_ref: compound.set_by_ref,
183            compound_member_index: compound.member_index,
184            compound_sets: compound.sets,
185            show_semantics,
186            inject_ast_indices,
187            filtered_to_original_index: RefCell::new(None),
188            abbreviation_map,
189        }
190    }
191
192    /// Resolve multilingual contributor names using the style's config.
193    fn resolve_contributor_names(
194        &self,
195        contributor: &citum_schema::reference::contributor::Contributor,
196    ) -> Vec<crate::reference::FlatName> {
197        let ml = self.config.multilingual.as_ref();
198        crate::values::resolve_multilingual_name(
199            contributor,
200            ml.and_then(|m| m.name_mode.as_ref()),
201            ml.and_then(|m| m.preferred_transliteration.as_deref()),
202            ml.and_then(|m| m.preferred_script.as_ref()),
203            &self.locale.locale,
204        )
205    }
206
207    /// Generate an alphabetic or numeric sub-label (e.g., "a", "1") for a
208    /// reference member of a compound set.
209    fn citation_sub_label_for_ref(&self, ref_id: &str) -> Option<String> {
210        let compound = self
211            .bibliography_config
212            .as_ref()
213            .and_then(|b| b.compound_numeric.as_ref())?;
214        let set_id = self.compound_set_by_ref.get(ref_id)?;
215        let members = self.compound_sets.get(set_id)?;
216        if members.len() <= 1 {
217            return None;
218        }
219        if !compound.subentry {
220            return None;
221        }
222        let idx = *self.compound_member_index.get(ref_id)?;
223        match compound.sub_label {
224            citum_schema::options::bibliography::SubLabelStyle::Alphabetic => {
225                crate::values::int_to_letter((idx + 1) as u32)
226            }
227            citum_schema::options::bibliography::SubLabelStyle::Numeric => {
228                Some((idx + 1).to_string())
229            }
230        }
231    }
232
233    /// Determines if the processor should render author-plus-number text for a numeric style
234    /// when in "integral" (narrative) citation mode.
235    ///
236    /// This happens when the style is numeric and the user requests a narrative
237    /// citation (e.g., "Smith [1]"), but hasn't provided an explicit narrative template.
238    fn should_render_author_number_for_numeric_integral(
239        &self,
240        mode: &citum_schema::citation::CitationMode,
241    ) -> bool {
242        matches!(mode, citum_schema::citation::CitationMode::Integral)
243            && self.config.processing.as_ref().is_some_and(|processing| {
244                matches!(processing, citum_schema::options::Processing::Numeric)
245            })
246            && !self.has_explicit_integral_template()
247    }
248
249    /// Whether the style provides an explicit integral (narrative) template.
250    fn has_explicit_integral_template(&self) -> bool {
251        self.style.citation.as_ref().is_some_and(|c| {
252            c.integral.as_ref().is_some_and(|i| {
253                i.template.is_some() || i.template_ref.is_some() || i.locales.is_some()
254            })
255        })
256    }
257
258    /// Determine if compound subentries should be collapsed for this citation.
259    fn should_collapse_compound_subentries(
260        &self,
261        mode: &citum_schema::citation::CitationMode,
262    ) -> bool {
263        if !matches!(mode, citum_schema::citation::CitationMode::NonIntegral) {
264            return false;
265        }
266
267        self.bibliography_config
268            .as_ref()
269            .and_then(|b| b.compound_numeric.as_ref())
270            .is_some_and(|c| c.subentry && c.collapse_subentries)
271    }
272
273    /// Determine if citation numbers should be collapsed into ranges.
274    fn should_collapse_citation_numbers(
275        &self,
276        spec: &citum_schema::CitationSpec,
277        mode: &citum_schema::citation::CitationMode,
278    ) -> bool {
279        if !matches!(mode, citum_schema::citation::CitationMode::NonIntegral) {
280            return false;
281        }
282
283        let is_numeric = self
284            .config
285            .processing
286            .as_ref()
287            .is_some_and(|p| matches!(p, citum_schema::options::Processing::Numeric));
288
289        is_numeric
290            && matches!(
291                spec.collapse,
292                Some(citum_schema::CitationCollapse::CitationNumber)
293            )
294    }
295
296    /// Heuristic for ensuring proper spacing after a citation prefix.
297    fn normalize_prefix_spacing(prefix: &str) -> String {
298        if !prefix.is_empty() && !prefix.ends_with(char::is_whitespace) {
299            format!("{prefix} ")
300        } else {
301            prefix.to_string()
302        }
303    }
304
305    /// Ensure suffix has proper spacing (add space if suffix doesn't start with
306    /// punctuation and isn't empty).
307    fn ensure_suffix_spacing(suffix: &str) -> String {
308        if suffix.is_empty() {
309            String::new()
310        } else if suffix.starts_with(char::is_whitespace)
311            || suffix.starts_with(',')
312            || suffix.starts_with(';')
313            || suffix.starts_with('.')
314        {
315            // Already has leading space or punctuation
316            suffix.to_string()
317        } else {
318            // Add space before suffix to separate from content
319            format!(" {suffix}")
320        }
321    }
322
323    /// Apply prefix and suffix spacing heuristics to a rendered string.
324    fn affix_content<F>(
325        &self,
326        fmt: &F,
327        content: String,
328        prefix: Option<&str>,
329        suffix: Option<&str>,
330    ) -> String
331    where
332        F: crate::render::format::OutputFormat<Output = String>,
333    {
334        let prefix = prefix.unwrap_or("");
335        let suffix = suffix.unwrap_or("");
336        if prefix.is_empty() && suffix.is_empty() {
337            content
338        } else {
339            fmt.affix(
340                &Self::normalize_prefix_spacing(prefix),
341                content,
342                &Self::ensure_suffix_spacing(suffix),
343            )
344        }
345    }
346
347    /// Pair rendered content with associated reference IDs to form a semantic chunk.
348    fn build_citation_chunk<F>(
349        &self,
350        fmt: &F,
351        ids: Vec<String>,
352        content: String,
353        prefix: Option<&str>,
354        suffix: Option<&str>,
355    ) -> Option<(Vec<String>, String)>
356    where
357        F: crate::render::format::OutputFormat<Output = String>,
358    {
359        if content.is_empty() {
360            None
361        } else {
362            Some((ids, self.affix_content(fmt, content, prefix, suffix)))
363        }
364    }
365
366    /// Create a template render request for a single citation item.
367    fn citation_render_request<'b>(
368        &self,
369        item: &'b crate::reference::CitationItem,
370        template: &'b [TemplateComponent],
371        mode: &citum_schema::citation::CitationMode,
372        suppress_author: bool,
373        position: Option<&citum_schema::citation::Position>,
374        note_start_text_case: Option<citum_schema::NoteStartTextCase>,
375    ) -> TemplateRenderRequest<'b> {
376        TemplateRenderRequest {
377            template,
378            context: RenderContext::Citation,
379            mode: mode.clone(),
380            suppress_author,
381            locator_raw: item.locator.as_ref(),
382            citation_number: self.get_or_assign_citation_number(&item.id),
383            position: position.cloned(),
384            note_start_text_case,
385            integral_name_state: item.integral_name_state,
386            org_abbreviation_state: item.org_abbreviation_state,
387        }
388    }
389
390    /// Render a single item to a formatted string using a template.
391    fn render_item_from_template_with_format<F>(
392        &self,
393        reference: &Reference,
394        request: TemplateRenderRequest<'_>,
395        delimiter: &str,
396    ) -> Option<String>
397    where
398        F: crate::render::format::OutputFormat<Output = String>,
399    {
400        self.process_template_request_with_format::<F>(reference, request)
401            .map(|proc| {
402                crate::render::citation::citation_to_string_with_format::<F>(
403                    &proc,
404                    None,
405                    None,
406                    None,
407                    Some(delimiter),
408                )
409            })
410    }
411
412    /// Initialize render options for a citation.
413    fn citation_render_options<'b>(
414        &'b self,
415        mode: citum_schema::citation::CitationMode,
416        suppress_author: bool,
417        locator_raw: Option<&'b CitationLocator>,
418        ref_type: Option<String>,
419    ) -> RenderOptions<'b> {
420        RenderOptions {
421            config: self.config,
422            bibliography_config: self.bibliography_config.clone(),
423            locale: self.locale,
424            context: RenderContext::Citation,
425            mode,
426            suppress_author,
427            locator_raw,
428            ref_type,
429            show_semantics: self.show_semantics,
430            current_template_index: None,
431            abbreviation_map: self.abbreviation_map,
432        }
433    }
434
435    /// Render author + citation number for numeric integral citations.
436    ///
437    /// Default implementation for narrative citations in numeric styles (e.g., "Smith [1]").
438    fn render_author_number_for_numeric_integral_with_format<F>(
439        &self,
440        reference: &Reference,
441        item: &crate::reference::CitationItem,
442        citation_number: usize,
443    ) -> String
444    where
445        F: crate::render::format::OutputFormat<Output = String>,
446    {
447        let fmt = F::default();
448        let options = self.citation_render_options(
449            citum_schema::citation::CitationMode::Integral,
450            false,
451            item.locator.as_ref(),
452            Some(reference.ref_type()),
453        );
454
455        // Render author in short form
456        let author_part = if let Some(authors) = reference.author() {
457            let names_vec = self.resolve_contributor_names(&authors);
458            fmt.text(&crate::values::format_contributors_short(
459                &names_vec, &options,
460            ))
461        } else {
462            String::new()
463        };
464
465        // Include compound sub-label (e.g. "a", "b") when applicable.
466        let ref_id = reference.id().unwrap_or_default().to_string();
467        let sub_label = self.citation_sub_label_for_ref(&ref_id).unwrap_or_default();
468
469        // Format: "Author [Na]"
470        if author_part.is_empty() {
471            // Fallback: just citation number if no author
472            format!("[{citation_number}{sub_label}]")
473        } else {
474            format!("{author_part} [{citation_number}{sub_label}]")
475        }
476    }
477
478    /// Render citation items without grouping, using plain text format.
479    ///
480    /// # Errors
481    ///
482    /// Returns an error when a referenced item is missing or item rendering
483    /// fails.
484    pub fn render_ungrouped_citation(
485        &self,
486        items: &[crate::reference::CitationItem],
487        spec: &citum_schema::CitationSpec,
488        mode: &citum_schema::citation::CitationMode,
489        intra_delimiter: &str,
490        suppress_author: bool,
491        position: Option<&citum_schema::citation::Position>,
492    ) -> Result<Vec<String>, ProcessorError> {
493        self.render_ungrouped_citation_with_format::<crate::render::plain::PlainText>(
494            items,
495            spec,
496            mode,
497            intra_delimiter,
498            suppress_author,
499            position,
500            spec.note_start_text_case,
501        )
502    }
503
504    /// Render citation items without grouping, generic over the output format.
505    ///
506    /// This is the core logic for iterating over citation items, looking up references,
507    /// and applying the appropriate template or fallback logic.
508    ///
509    /// # Errors
510    ///
511    /// Returns an error when a referenced item is missing or item rendering
512    /// fails.
513    #[allow(
514        clippy::too_many_arguments,
515        reason = "Ungrouped citation rendering now needs explicit note-start context."
516    )]
517    pub fn render_ungrouped_citation_with_format<F>(
518        &self,
519        items: &[crate::reference::CitationItem],
520        spec: &citum_schema::CitationSpec,
521        mode: &citum_schema::citation::CitationMode,
522        intra_delimiter: &str,
523        suppress_author: bool,
524        position: Option<&citum_schema::citation::Position>,
525        note_start_text_case: Option<citum_schema::NoteStartTextCase>,
526    ) -> Result<Vec<String>, ProcessorError>
527    where
528        F: crate::render::format::OutputFormat<Output = String>,
529    {
530        let fmt = F::default();
531        let mut chunks: Vec<(Vec<String>, String)> = Vec::new();
532
533        // For numeric styles with integral mode, render author + citation number instead.
534        let use_author_number = self.should_render_author_number_for_numeric_integral(mode);
535
536        for item in items {
537            let reference = self
538                .bibliography
539                .get(&item.id)
540                .ok_or_else(|| ProcessorError::ReferenceNotFound(item.id.clone()))?;
541
542            if use_author_number {
543                // Numeric integral: render author + citation number
544                let citation_number = self.get_or_assign_citation_number(&item.id);
545                let item_str = self.render_author_number_for_numeric_integral_with_format::<F>(
546                    reference,
547                    item,
548                    citation_number,
549                );
550                if let Some(chunk) = self.build_citation_chunk(
551                    &fmt,
552                    vec![item.id.clone()],
553                    item_str,
554                    item.prefix.as_deref(),
555                    item.suffix.as_deref(),
556                ) {
557                    chunks.push(chunk);
558                }
559            } else {
560                // Standard rendering: use template with citation number
561                let item_language = crate::values::effective_item_language(reference);
562                let default_template = spec.resolve_template_for_language(item_language.as_deref());
563
564                let ref_type = reference.ref_type();
565                let matched_type_template = spec.type_variants.as_ref().and_then(|type_variants| {
566                    let mut matched_template = None;
567                    for (selector, template) in type_variants {
568                        if selector.matches(&ref_type) {
569                            matched_template = template.clone().into_template();
570                            break;
571                        }
572                    }
573                    matched_template
574                });
575
576                let template = matched_type_template.or(default_template);
577                let effective_template = template.as_deref().unwrap_or(&[]);
578                let effective_delim = spec.delimiter.as_deref().unwrap_or(intra_delimiter);
579                let request = self.citation_render_request(
580                    item,
581                    effective_template,
582                    mode,
583                    suppress_author,
584                    position,
585                    note_start_text_case,
586                );
587                if let Some(item_str) = self.render_item_from_template_with_format::<F>(
588                    reference,
589                    request,
590                    effective_delim,
591                ) && let Some(chunk) = self.build_citation_chunk(
592                    &fmt,
593                    vec![item.id.clone()],
594                    item_str,
595                    item.prefix.as_deref(),
596                    item.suffix.as_deref(),
597                ) {
598                    chunks.push(chunk);
599                }
600            }
601        }
602
603        if self.should_collapse_compound_subentries(mode) {
604            chunks = self.collapse_compound_citation_chunks(chunks);
605        }
606        if self.should_collapse_citation_numbers(spec, mode) {
607            chunks = self.collapse_numeric_citation_chunks(chunks);
608        }
609
610        Ok(chunks
611            .into_iter()
612            .map(|(ids, content)| fmt.citation(ids, content))
613            .collect())
614    }
615}
616
617fn key_base(key: &str) -> Cow<'_, str> {
618    let mut parts = key.splitn(3, ':');
619    match (parts.next(), parts.next()) {
620        (Some(kind), Some(var)) => Cow::Owned(format!("{kind}:{var}")),
621        _ => Cow::Borrowed(key),
622    }
623}
624
625/// Get a unique key for a template component's variable.
626///
627/// The key includes rendering context (prefix/suffix) to allow the same variable
628/// to render multiple times if it appears in semantically different contexts.
629/// This enables styles like Chicago that require year after author AND after publisher.
630#[must_use]
631pub fn get_variable_key(component: &TemplateComponent) -> Option<String> {
632    use citum_schema::template::Rendering;
633    use std::fmt::Write;
634
635    fn push_context_suffix(key: &mut String, rendering: &Rendering) {
636        match (&rendering.prefix, &rendering.suffix) {
637            (Some(prefix), Some(suffix)) => {
638                key.push(':');
639                key.push_str(prefix);
640                key.push('_');
641                key.push_str(suffix);
642            }
643            (Some(prefix), None) => {
644                key.push(':');
645                key.push_str(prefix);
646            }
647            (None, Some(suffix)) => {
648                key.push(':');
649                key.push_str(suffix);
650            }
651            (None, None) => {}
652        }
653    }
654
655    fn make_key(kind: &str, value: impl std::fmt::Debug, rendering: &Rendering) -> Option<String> {
656        let mut key = String::new();
657        write!(&mut key, "{kind}:{value:?}").ok()?;
658        push_context_suffix(&mut key, rendering);
659        Some(key)
660    }
661
662    match component {
663        TemplateComponent::Contributor(c) => make_key("contributor", &c.contributor, &c.rendering),
664        TemplateComponent::Date(d) => make_key("date", &d.date, &d.rendering),
665        TemplateComponent::Variable(v) => make_key("variable", &v.variable, &v.rendering),
666        TemplateComponent::Title(t) => {
667            let mut key = format!("title:{:?}", t.title);
668            if let Some(form) = &t.form {
669                write!(&mut key, ":{form:?}").ok()?;
670            }
671            push_context_suffix(&mut key, &t.rendering);
672            Some(key)
673        }
674        TemplateComponent::Number(n) => make_key("number", &n.number, &n.rendering),
675        TemplateComponent::Group(_) => None,
676        _ => None,
677    }
678}