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