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