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