Skip to main content

citum_engine/processor/bibliography/
grouping.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Grouped bibliography rendering with configurable selectors and sorting.
7
8use super::RenderedBibliographyGroup;
9use crate::api::AnnotationStyle;
10use crate::grouping::SelectorEvaluator;
11use crate::processor::FinalizedRun;
12use crate::processor::Processor;
13use crate::processor::disambiguation::Disambiguator;
14use crate::reference::{Bibliography, Reference};
15use crate::render::ProcEntry;
16use crate::render::format::{OutputFormat, ProcEntryMetadata};
17use crate::sorting::ReferenceSorter;
18use crate::values::{
19    ProcHints, RenderContext, RenderOptions, format_contributors_short, resolve_multilingual_name,
20    resolve_multilingual_string,
21};
22use citum_schema::grouping::{BibliographyGroup, DisambiguationScope, GroupHeading};
23use citum_schema::options::{BibliographyPartitionHeading, BibliographySortPartitioning};
24use std::borrow::Cow;
25use std::collections::{HashMap, HashSet};
26use std::sync::Arc;
27
28use super::EntryRenderContext;
29
30/// Products requested from the shared flat bibliography render pass.
31#[derive(Clone, Copy, PartialEq, Eq)]
32enum FlatBibliographyOutput {
33    /// Produce only the formatted bibliography string.
34    ContentOnly,
35    /// Produce the formatted string and flat per-reference entry data.
36    ContentAndEntries,
37}
38
39impl Processor {
40    /// Resolve a localized or literal group heading.
41    pub(super) fn resolve_group_heading(&self, heading: &GroupHeading) -> Option<String> {
42        match heading {
43            GroupHeading::Literal { literal } => Some(literal.clone()),
44            GroupHeading::Term { term, form } => self.locale.resolved_general_term(
45                term,
46                &form.clone().unwrap_or(citum_schema::locale::TermForm::Long),
47                None,
48            ),
49            GroupHeading::Localized { localized } => self.resolve_localized_heading(localized),
50        }
51    }
52
53    /// Resolve a localized heading map based on the processor locale.
54    ///
55    /// Matches in order:
56    /// 1. Exact locale (e.g., "en-GB")
57    /// 2. Primary language (e.g., "en")
58    /// 3. Style default locale
59    /// 4. en-US fallback
60    /// 5. First alphabetically defined key
61    fn resolve_localized_heading(&self, localized: &HashMap<String, String>) -> Option<String> {
62        fn language_tag(locale: &str) -> &str {
63            locale.split('-').next().unwrap_or(locale)
64        }
65
66        let mut candidates = Vec::new();
67        let mut push_candidate = |locale: &str| {
68            let candidate = locale.to_string();
69            if !candidates.contains(&candidate) {
70                candidates.push(candidate);
71            }
72        };
73
74        push_candidate(&self.locale.locale);
75        push_candidate(language_tag(&self.locale.locale));
76
77        if let Some(default_locale) = self.style.info.default_locale.as_deref() {
78            push_candidate(default_locale);
79            push_candidate(language_tag(default_locale));
80        }
81
82        push_candidate("en-US");
83        push_candidate("en");
84
85        for locale in candidates {
86            if let Some(value) = localized.get(&locale) {
87                return Some(value.clone());
88            }
89        }
90
91        localized
92            .iter()
93            .min_by(|left, right| left.0.cmp(right.0))
94            .map(|(_locale, value)| value.clone())
95    }
96
97    /// Resolve a bibliography partition heading.
98    fn resolve_partition_heading(&self, heading: &BibliographyPartitionHeading) -> Option<String> {
99        match heading {
100            BibliographyPartitionHeading::Literal { literal } => Some(literal.clone()),
101            BibliographyPartitionHeading::Term { term, form } => self.locale.resolved_general_term(
102                term,
103                &form.clone().unwrap_or(citum_schema::locale::TermForm::Long),
104                None,
105            ),
106            BibliographyPartitionHeading::Localized { localized } => {
107                self.resolve_localized_heading(localized)
108            }
109        }
110    }
111
112    /// Find unassigned bibliography entries that match a group's selector.
113    fn collect_matching_group_refs<'a>(
114        &'a self,
115        bibliography: &'a [ProcEntry],
116        assigned: &HashSet<String>,
117        evaluator: &SelectorEvaluator<'_>,
118        group: &BibliographyGroup,
119    ) -> Vec<&'a Reference> {
120        bibliography
121            .iter()
122            .filter(|entry| !assigned.contains(&entry.id))
123            .filter_map(|entry| {
124                self.bibliography
125                    .get(&entry.id)
126                    .filter(|reference| evaluator.matches(reference, &group.selector))
127            })
128            .collect()
129    }
130
131    /// Returns `ProcEntry` stubs with only `id` populated, in sort order.
132    ///
133    /// Used for grouping paths that only need IDs for selector matching — avoids
134    /// the full PlainText render pass that `process_references` performs.
135    /// `pub(crate)` so callers that render multiple bibliography blocks in one
136    /// pass (or exercise that path in tests) can compute this once and share
137    /// it, instead of each block independently re-sorting the bibliography.
138    pub(crate) fn sorted_id_stubs(&self) -> Vec<ProcEntry> {
139        // Numeric citation numbers are already populated by `Processor::begin_run`,
140        // which every `FinalizedRun` this module consumes was produced from.
141        self.sort_references(self.bibliography.values().collect())
142            .into_iter()
143            .filter_map(|r| {
144                r.id().map(|id| ProcEntry {
145                    id: id.to_string(),
146                    template: vec![],
147                    metadata: ProcEntryMetadata::default(),
148                })
149            })
150            .collect()
151    }
152
153    /// Mark references as assigned to a bibliography group.
154    fn mark_group_members_assigned(assigned: &mut HashSet<String>, references: &[&Reference]) {
155        for reference in references {
156            if let Some(id) = reference.id() {
157                assigned.insert(id.to_string());
158            }
159        }
160    }
161
162    /// Calculate disambiguation hints locally within a bibliography group.
163    ///
164    /// Only calculates hints if the group specifies local disambiguation scope.
165    fn build_group_local_hints(
166        &self,
167        sorted_refs: &[&Reference],
168        group: &BibliographyGroup,
169    ) -> Option<HashMap<String, ProcHints>> {
170        if !matches!(group.disambiguate, Some(DisambiguationScope::Locally)) {
171            return None;
172        }
173
174        let mut group_bibliography = Bibliography::new();
175        for reference in sorted_refs {
176            group_bibliography.insert(
177                reference.id().unwrap_or_default().to_string(),
178                (*reference).clone(),
179            );
180        }
181
182        let resolved_sort = group
183            .sort
184            .as_ref()
185            .map(citum_schema::GroupSortEntry::resolve);
186        let bibliography_config = self.get_bibliography_config();
187        let disambiguator = if let Some(sort) = resolved_sort.as_ref() {
188            Disambiguator::with_group_sort(
189                &group_bibliography,
190                &bibliography_config,
191                &self.locale,
192                sort,
193            )
194        } else {
195            Disambiguator::new(&group_bibliography, &bibliography_config, &self.locale)
196        };
197
198        Some(disambiguator.calculate_hints())
199    }
200
201    /// Resolve the effective style to use for a bibliography group.
202    fn effective_group_style<'a>(
203        &'a self,
204        group: &'a BibliographyGroup,
205    ) -> Cow<'a, citum_schema::Style> {
206        if let Some(group_template) = &group.template {
207            let mut local_style = self.style.clone();
208            if let Some(bibliography) = local_style.bibliography.as_mut() {
209                bibliography.template = Some(group_template.clone());
210            }
211            Cow::Owned(local_style)
212        } else {
213            Cow::Borrowed(&self.style)
214        }
215    }
216
217    /// Render bibliography entries for a specific group.
218    ///
219    /// Entry numbers are resolved sequentially first, then entries render
220    /// via [`render_numbered_refs`](Self::render_numbered_refs) —
221    /// sequentially through one shared `Renderer`, or across the rayon
222    /// thread pool (one fresh `Renderer` per task; see
223    /// [`EntryRenderContext`]) once the group is large enough — and finally
224    /// [`apply_substitution_post_pass`](super::Processor::apply_substitution_post_pass)
225    /// applies subsequent-author substitution sequentially over the
226    /// (order-preserved) results.
227    fn render_group_entries<F>(
228        &self,
229        _bibliography: &[ProcEntry],
230        sorted_refs: Vec<&Reference>,
231        group: &BibliographyGroup,
232        local_hints: Option<&HashMap<String, ProcHints>>,
233        run: &FinalizedRun,
234    ) -> Vec<ProcEntry>
235    where
236        F: OutputFormat<Output = String>,
237    {
238        // Always process entries with format F so that group components (pre_formatted=true)
239        // contain markup in the target format rather than PlainText (_..._).
240        let effective_style = self.effective_group_style(group);
241        let ctx = EntryRenderContext {
242            style: &effective_style,
243            hints: local_hints.unwrap_or(&self.hints),
244            config: Arc::new(self.get_bibliography_config().into_owned()),
245            bibliography_config: Arc::new(self.get_bibliography_options().into_owned()),
246            run,
247        };
248
249        let numbered_refs = super::number_sorted_refs(sorted_refs.into_iter(), run);
250        let rendered = self.render_numbered_refs::<F>(&numbered_refs, &ctx);
251
252        let substitute = ctx
253            .bibliography_config
254            .subsequent_author_substitute
255            .as_ref();
256        self.apply_substitution_post_pass::<F>(rendered, substitute, &ctx)
257    }
258
259    /// Append a rendered bibliography group to the output string.
260    fn append_rendered_group<F>(
261        &self,
262        result: &mut String,
263        group: &BibliographyGroup,
264        entries: Vec<ProcEntry>,
265        annotations: Option<&HashMap<String, String>>,
266        annotation_style: Option<&AnnotationStyle>,
267        suppress_heading: bool,
268    ) where
269        F: OutputFormat<Output = String>,
270    {
271        if !result.is_empty() {
272            result.push_str("\n\n");
273        }
274
275        if !suppress_heading
276            && let Some(heading) = group
277                .heading
278                .as_ref()
279                .and_then(|group_heading| self.resolve_group_heading(group_heading))
280        {
281            result.push_str(&self.render_group_heading::<F>(&heading));
282        }
283
284        result.push_str(&crate::render::refs_to_string_with_format::<F>(
285            entries,
286            annotations,
287            annotation_style,
288        ));
289    }
290
291    /// Append a rendered bibliography partition to the output string.
292    fn append_rendered_partition<F>(
293        &self,
294        result: &mut String,
295        heading: Option<&BibliographyPartitionHeading>,
296        entries: Vec<ProcEntry>,
297        annotations: Option<&HashMap<String, String>>,
298        annotation_style: Option<&AnnotationStyle>,
299    ) where
300        F: OutputFormat<Output = String>,
301    {
302        if !result.is_empty() {
303            result.push_str("\n\n");
304        }
305
306        if let Some(heading) =
307            heading.and_then(|group_heading| self.resolve_partition_heading(group_heading))
308        {
309            result.push_str(&self.render_group_heading::<F>(&heading));
310        }
311
312        result.push_str(&crate::render::refs_to_string_with_format::<F>(
313            entries,
314            annotations,
315            annotation_style,
316        ));
317    }
318
319    /// Orchestrate the rendering of automatic bibliography partitions with headings.
320    pub(super) fn render_with_partition_sections<F>(
321        &self,
322        sorted_refs: Vec<&Reference>,
323        partitioning: &BibliographySortPartitioning,
324        annotations: Option<&HashMap<String, String>>,
325        annotation_style: Option<&AnnotationStyle>,
326        run: &FinalizedRun,
327    ) -> String
328    where
329        F: OutputFormat<Output = String>,
330    {
331        let fmt = F::default();
332        let mut result = String::new();
333
334        for (partition_key, references) in
335            crate::sort_partitioning::partition_references(sorted_refs, &self.locale, partitioning)
336        {
337            let heading = partition_key
338                .as_ref()
339                .and_then(|key| partitioning.headings.get(key));
340            let entries = self.merge_compound_entries::<F>(
341                self.process_sorted_refs::<_, F>(references.into_iter(), run),
342                run,
343            );
344            self.append_rendered_partition::<F>(
345                &mut result,
346                heading,
347                entries,
348                annotations,
349                annotation_style,
350            );
351        }
352
353        fmt.finish(result)
354    }
355
356    /// Render a filtered subset of entries using custom bibliography grouping.
357    ///
358    /// This uses a two-pass grouping strategy:
359    /// 1. Collect and render all populated groups.
360    /// 2. Determine if heading suppression applies (only one group populated).
361    /// 3. Append groups and any remaining unassigned entries.
362    pub(super) fn render_with_custom_groups_filtered<F>(
363        &self,
364        all_entries: &[ProcEntry],
365        groups: &[BibliographyGroup],
366        selected: &HashSet<String>,
367        annotations: Option<&HashMap<String, String>>,
368        annotation_style: Option<&AnnotationStyle>,
369        run: &FinalizedRun,
370    ) -> String
371    where
372        F: OutputFormat<Output = String>,
373    {
374        let fmt = F::default();
375        let cited_ids = &run.state().cited_ids;
376        let evaluator = SelectorEvaluator::new(cited_ids);
377        let bibliography_config = self.get_bibliography_config();
378        let sorter = ReferenceSorter::with_bibliography_config(&self.locale, &bibliography_config);
379
380        let mut assigned = HashSet::new();
381        let mut result = String::new();
382
383        // First pass: collect all populated groups with their rendered entries
384        let mut populated_groups: Vec<(&BibliographyGroup, Vec<ProcEntry>)> = Vec::new();
385
386        for group in groups {
387            let matching_refs =
388                self.collect_matching_group_refs(all_entries, &assigned, &evaluator, group);
389
390            let matching_refs: Vec<&Reference> = matching_refs
391                .into_iter()
392                .filter(|r| r.id().as_deref().is_some_and(|id| selected.contains(id)))
393                .collect();
394
395            if matching_refs.is_empty() {
396                continue;
397            }
398
399            Self::mark_group_members_assigned(&mut assigned, &matching_refs);
400
401            let sorted_refs = if let Some(sort_spec) = &group.sort {
402                sorter.sort_references(matching_refs, &sort_spec.resolve())
403            } else {
404                matching_refs
405            };
406            let local_hints = self.build_group_local_hints(&sorted_refs, group);
407            let entries = self.merge_compound_entries::<F>(
408                self.render_group_entries::<F>(
409                    all_entries,
410                    sorted_refs,
411                    group,
412                    local_hints.as_ref(),
413                    run,
414                ),
415                run,
416            );
417
418            populated_groups.push((group, entries));
419        }
420
421        // Compute unassigned entries to determine if heading suppression applies
422        let unassigned_refs: Vec<&Reference> = all_entries
423            .iter()
424            .filter(|entry| !assigned.contains(&entry.id) && selected.contains(&entry.id))
425            .filter_map(|entry| self.bibliography.get(&entry.id))
426            .collect();
427
428        let suppress_heading = populated_groups.len() == 1 && unassigned_refs.is_empty();
429
430        // Second pass: render populated groups with optional heading suppression
431        for (group, entries) in populated_groups {
432            self.append_rendered_group::<F>(
433                &mut result,
434                group,
435                entries,
436                annotations,
437                annotation_style,
438                suppress_heading,
439            );
440        }
441
442        self.append_unassigned_entries_filtered::<F>(
443            &mut result,
444            all_entries,
445            &assigned,
446            selected,
447            annotations,
448            annotation_style,
449            run,
450        );
451        fmt.finish(result)
452    }
453
454    /// Append unassigned bibliography entries to the output string.
455    #[allow(
456        clippy::too_many_arguments,
457        reason = "internal helper, all params load-bearing"
458    )]
459    fn append_unassigned_entries_filtered<F>(
460        &self,
461        result: &mut String,
462        bibliography: &[ProcEntry],
463        assigned: &HashSet<String>,
464        selected: &HashSet<String>,
465        annotations: Option<&HashMap<String, String>>,
466        annotation_style: Option<&AnnotationStyle>,
467        run: &FinalizedRun,
468    ) where
469        F: OutputFormat<Output = String>,
470    {
471        let unassigned_refs: Vec<&Reference> = bibliography
472            .iter()
473            .filter(|entry| !assigned.contains(&entry.id) && selected.contains(&entry.id))
474            .filter_map(|entry| self.bibliography.get(&entry.id))
475            .collect();
476
477        if unassigned_refs.is_empty() {
478            return;
479        }
480
481        // Re-process references to ensure correct author substitution and disambiguation
482        // within the unassigned subset.
483        let unassigned = self.merge_compound_entries::<F>(
484            self.process_sorted_refs::<_, F>(unassigned_refs.into_iter(), run),
485            run,
486        );
487
488        if !result.is_empty() {
489            result.push_str("\n\n");
490        }
491
492        result.push_str(&crate::render::refs_to_string_with_format::<F>(
493            unassigned,
494            annotations,
495            annotation_style,
496        ));
497    }
498
499    /// Render already-merged compound entries as a flat bibliography.
500    ///
501    /// The compound path renders every configured member before merging. A
502    /// compound set is a single bibliographic unit: a cited-only document
503    /// retains a merged row if *any* configured member of that set is cited,
504    /// showing the full row (leader plus every tail) rather than hiding
505    /// non-cited members. Rows outside any compound set still filter by
506    /// their own cited status. All-references callers render every merged
507    /// row.
508    fn render_flat_compound_entries<F>(
509        &self,
510        bibliography: &[ProcEntry],
511        restrict_to_cited: bool,
512        annotations: Option<&HashMap<String, String>>,
513        annotation_style: Option<&AnnotationStyle>,
514        run: &FinalizedRun,
515    ) -> String
516    where
517        F: OutputFormat<Output = String>,
518    {
519        let fmt = F::default();
520        let selected = if restrict_to_cited {
521            let cited_ids = &run.state().cited_ids;
522            let compound_groups = &run.state().compound_groups;
523            let ref_to_group = Self::build_compound_group_lookup(compound_groups);
524            let row_is_cited = |id: &str| match ref_to_group.get(id) {
525                Some(group_number) => compound_groups
526                    .get(group_number)
527                    .is_some_and(|members| members.iter().any(|member| cited_ids.contains(member))),
528                None => cited_ids.contains(id),
529            };
530            Cow::Owned(
531                bibliography
532                    .iter()
533                    .filter(|entry| row_is_cited(&entry.id))
534                    .cloned()
535                    .collect(),
536            )
537        } else {
538            Cow::Borrowed(bibliography)
539        };
540
541        let result = if selected.is_empty() {
542            String::new()
543        } else {
544            crate::render::refs_to_string_slice_with_format::<F>(
545                selected.as_ref(),
546                annotations,
547                annotation_style,
548            )
549        };
550
551        fmt.finish(result)
552    }
553
554    /// Render all loaded references with the style's effective grouping policy.
555    ///
556    /// Enabled manual groups take precedence over automatic partition sections,
557    /// which in turn take precedence over flat rendering. Group selectors apply
558    /// to individual references before compound numeric rows are merged, so each
559    /// rendered group only includes the members that matched its selector.
560    pub fn render_grouped_bibliography_with_format<F>(&self, run: &FinalizedRun) -> String
561    where
562        F: OutputFormat<Output = String>,
563    {
564        self.render_grouped_bibliography_with_format_and_annotations::<F>(None, None, run)
565    }
566
567    /// Render the bibliography with grouping and annotations.
568    pub fn render_grouped_bibliography_with_format_and_annotations<F>(
569        &self,
570        annotations: Option<&HashMap<String, String>>,
571        annotation_style: Option<&AnnotationStyle>,
572        run: &FinalizedRun,
573    ) -> String
574    where
575        F: OutputFormat<Output = String>,
576    {
577        self.render_grouped_bibliography_inner::<F>(false, annotations, annotation_style, run)
578    }
579
580    /// One-shot convenience for [`Processor::render_grouped_bibliography_with_format`]:
581    /// begins a throwaway run internally.
582    pub fn render_grouped_bibliography_with_format_standalone<F>(&self) -> String
583    where
584        F: OutputFormat<Output = String>,
585    {
586        let run = self.begin_run().finalize();
587        self.render_grouped_bibliography_with_format::<F>(&run)
588    }
589
590    /// One-shot convenience for
591    /// [`Processor::render_grouped_bibliography_with_format_and_annotations`]:
592    /// begins a throwaway run internally.
593    pub fn render_grouped_bibliography_with_format_and_annotations_standalone<F>(
594        &self,
595        annotations: Option<&HashMap<String, String>>,
596        annotation_style: Option<&AnnotationStyle>,
597    ) -> String
598    where
599        F: OutputFormat<Output = String>,
600    {
601        let run = self.begin_run().finalize();
602        self.render_grouped_bibliography_with_format_and_annotations::<F>(
603            annotations,
604            annotation_style,
605            &run,
606        )
607    }
608
609    /// Unified document bibliography facade — returns content and per-entry data together.
610    ///
611    /// This is the single entry point for all document-context bibliography rendering:
612    /// batch (`format_document`), interactive session (`DocumentSession`), and the
613    /// document-string (`process_document`) path all funnel through here.
614    ///
615    /// When `restrict_to_cited` is `true` (the document case), only references present
616    /// in `run`'s `cited_ids` — cited in-text or registered via `nocite` — are included.
617    /// When `false`, all loaded references are eligible; this hook is reserved for the
618    /// `allrefs` escape hatch (csl26-f9ri) and is not yet exposed publicly.
619    ///
620    /// Both `content` and `entries` are computed from the same eligible subset so
621    /// subsequent-author substitution stays consistent across both outputs.
622    ///
623    /// The flat and sort-partitioned-sections cases render each eligible
624    /// reference's template exactly once — see
625    /// [`render_flat_bibliography`](Self::render_flat_bibliography) — instead
626    /// of once for `content` and again for `entries`. Custom
627    /// bibliography groups (`style.bibliography.groups`) need group-local
628    /// disambiguation and per-group templates that a flat entry list can't
629    /// carry. Compound-numeric merging needs to see every configured group
630    /// member whether cited or not (see
631    /// [`merge_compound_entries`](Self::merge_compound_entries)), so it keeps
632    /// its historical full-member content pass.
633    pub(crate) fn render_document_bibliography<F>(
634        &self,
635        restrict_to_cited: bool,
636        annotations: Option<&HashMap<String, String>>,
637        annotation_style: Option<&AnnotationStyle>,
638        run: &FinalizedRun,
639    ) -> super::DocumentBibliography
640    where
641        F: OutputFormat<Output = String>,
642    {
643        let has_custom_groups = self.effective_custom_groups().is_some();
644
645        if has_custom_groups || !run.state().compound_groups.is_empty() {
646            let content = self.render_grouped_bibliography_inner::<F>(
647                restrict_to_cited,
648                annotations,
649                annotation_style,
650                run,
651            );
652            let cited_ids: Vec<String> = run.state().cited_ids.iter().cloned().collect();
653            let entries = if restrict_to_cited {
654                self.process_selected_references_with_format::<F, _>(cited_ids, run)
655                    .bibliography
656            } else {
657                self.process_references_with_format::<F>(run).bibliography
658            };
659            return super::DocumentBibliography { content, entries };
660        }
661
662        let bibliography_options = self.get_bibliography_options();
663        let partitioning = bibliography_options
664            .sort_partitioning
665            .as_ref()
666            .filter(|partitioning| crate::sort_partitioning::should_render_sections(partitioning));
667
668        self.render_flat_bibliography::<F>(
669            restrict_to_cited,
670            partitioning,
671            FlatBibliographyOutput::ContentAndEntries,
672            annotations,
673            annotation_style,
674            run,
675        )
676    }
677
678    /// Collect the eligible references — all loaded, or only the cited/`nocite`
679    /// subset when `restrict_to_cited` is `true` — in bibliography sort order.
680    fn sorted_eligible_refs(&self, restrict_to_cited: bool, run: &FinalizedRun) -> Vec<&Reference> {
681        let mut refs: Vec<&Reference> = self.bibliography.values().collect();
682        if restrict_to_cited {
683            let cited = &run.state().cited_ids;
684            refs.retain(|reference| {
685                reference
686                    .id()
687                    .as_deref()
688                    .is_some_and(|id| cited.contains(id))
689            });
690        }
691        self.sort_references(refs)
692    }
693
694    /// Render a flat or automatically partitioned bibliography in one pass.
695    ///
696    /// Shared by document and grouped bibliography surfaces once the caller
697    /// has established there are no custom or compound-numeric groups active.
698    /// When `restrict_to_cited` is `true`, only cited and `nocite` references
699    /// are eligible; otherwise every loaded reference is eligible. Renders each
700    /// eligible reference's template exactly once (`render_numbered_refs` — the
701    /// expensive step, resolving names/dates/titles through the full template)
702    /// and reuses that render for both outputs:
703    ///
704    /// - `entries`: one continuous subsequent-author-substitution pass over the
705    ///   flat, globally sorted eligible set — matches the historical
706    ///   `process_selected_references_with_format` contract, including its
707    ///   ordering.
708    /// - `content`: without partitioning, the same flat pass rendered to a
709    ///   string. With `partitioning` requesting visible sections, an
710    ///   independent substitution pass runs *per section* — substitution
711    ///   state must reset at each section boundary to match historical
712    ///   [`render_with_partition_sections`](Self::render_with_partition_sections)
713    ///   output — reusing the one template render already produced above.
714    ///   Only this lightweight linear post-pass reruns per section; the
715    ///   expensive template render does not.
716    ///
717    /// Entry numbering (`number_sorted_refs`) is resolved once, in flat sorted
718    /// order, for both outputs. Numeric bibliography styles pre-assign
719    /// citation numbers document-wide during `begin_run` and look them up from
720    /// that shared map regardless of section membership, so this is a no-op
721    /// difference for them. Only the position-based fallback used by
722    /// non-numeric styles could differ from a per-section index — but
723    /// non-numeric styles do not render a citation-number variable, so that
724    /// fallback value is never observable in output.
725    fn render_flat_bibliography<F>(
726        &self,
727        restrict_to_cited: bool,
728        partitioning: Option<&BibliographySortPartitioning>,
729        output: FlatBibliographyOutput,
730        annotations: Option<&HashMap<String, String>>,
731        annotation_style: Option<&AnnotationStyle>,
732        run: &FinalizedRun,
733    ) -> super::DocumentBibliography
734    where
735        F: OutputFormat<Output = String>,
736    {
737        let sorted_refs = self.sorted_eligible_refs(restrict_to_cited, run);
738
739        if output == FlatBibliographyOutput::ContentOnly
740            && let Some(partitioning) = partitioning
741        {
742            let content = self.render_with_partition_sections::<F>(
743                sorted_refs,
744                partitioning,
745                annotations,
746                annotation_style,
747                run,
748            );
749            return super::DocumentBibliography {
750                content,
751                entries: Vec::new(),
752            };
753        }
754
755        let ctx = self.flat_render_context(run);
756        let numbered_refs = super::number_sorted_refs(sorted_refs.iter().copied(), run);
757        let rendered = self.render_numbered_refs::<F>(&numbered_refs, &ctx);
758
759        let substitute = ctx
760            .bibliography_config
761            .subsequent_author_substitute
762            .as_ref();
763
764        if let Some(partitioning) = partitioning {
765            // Only ContentAndEntries reaches this branch: ContentOnly with
766            // partitioning returned through `render_with_partition_sections`
767            // above, so `entries` is always wanted here.
768            let entries =
769                self.apply_substitution_post_pass::<F>(rendered.clone(), substitute, &ctx);
770            let mut result = String::new();
771            for (partition_key, refs_in_section) in crate::sort_partitioning::partition_references(
772                sorted_refs,
773                &self.locale,
774                partitioning,
775            ) {
776                let heading = partition_key
777                    .as_ref()
778                    .and_then(|key| partitioning.headings.get(key));
779                let section_ids: HashSet<String> = refs_in_section
780                    .iter()
781                    .filter_map(|reference| reference.id().map(|id| id.to_string()))
782                    .collect();
783                let section_rendered = rendered
784                    .iter()
785                    .filter(|(reference, _)| {
786                        reference
787                            .id()
788                            .as_deref()
789                            .is_some_and(|id| section_ids.contains(id))
790                    })
791                    .cloned()
792                    .collect();
793                let section_entries =
794                    self.apply_substitution_post_pass::<F>(section_rendered, substitute, &ctx);
795                self.append_rendered_partition::<F>(
796                    &mut result,
797                    heading,
798                    section_entries,
799                    annotations,
800                    annotation_style,
801                );
802            }
803            return super::DocumentBibliography {
804                content: F::default().finish(result),
805                entries,
806            };
807        }
808
809        let rendered_entries = self.apply_substitution_post_pass::<F>(rendered, substitute, &ctx);
810        let content = if rendered_entries.is_empty() {
811            String::new()
812        } else {
813            crate::render::refs_to_string_slice_with_format::<F>(
814                &rendered_entries,
815                annotations,
816                annotation_style,
817            )
818        };
819        let entries = match output {
820            FlatBibliographyOutput::ContentAndEntries => rendered_entries,
821            FlatBibliographyOutput::ContentOnly => Vec::new(),
822        };
823
824        super::DocumentBibliography { content, entries }
825    }
826
827    /// Shared implementation for grouped bibliography rendering.
828    ///
829    /// When `restrict_to_cited` is `true`, each branch limits its candidate
830    /// set to references present in `run`'s `cited_ids`. When `false`, all
831    /// loaded references are eligible (the all-references behaviour used by
832    /// the standalone grouped API, FFI, and tests).
833    fn render_grouped_bibliography_inner<F>(
834        &self,
835        restrict_to_cited: bool,
836        annotations: Option<&HashMap<String, String>>,
837        annotation_style: Option<&AnnotationStyle>,
838        run: &FinalizedRun,
839    ) -> String
840    where
841        F: OutputFormat<Output = String>,
842    {
843        if let Some(groups) = self.effective_custom_groups() {
844            let id_stubs = self.sorted_id_stubs();
845            let selected = if restrict_to_cited {
846                let cited = &run.state().cited_ids;
847                id_stubs
848                    .iter()
849                    .filter(|e| cited.contains(&e.id))
850                    .map(|e| e.id.clone())
851                    .collect::<HashSet<_>>()
852            } else {
853                id_stubs
854                    .iter()
855                    .map(|e| e.id.clone())
856                    .collect::<HashSet<_>>()
857            };
858            return self.render_with_custom_groups_filtered::<F>(
859                &id_stubs,
860                groups,
861                &selected,
862                annotations,
863                annotation_style,
864                run,
865            );
866        }
867
868        let bibliography_options = self.get_bibliography_options();
869        let partitioning = bibliography_options
870            .sort_partitioning
871            .as_ref()
872            .filter(|partitioning| crate::sort_partitioning::should_render_sections(partitioning));
873
874        if !run.state().compound_groups.is_empty() {
875            if let Some(partitioning) = partitioning {
876                let sorted_refs = self.sorted_eligible_refs(restrict_to_cited, run);
877                return self.render_with_partition_sections::<F>(
878                    sorted_refs,
879                    partitioning,
880                    annotations,
881                    annotation_style,
882                    run,
883                );
884            }
885
886            let all_entries = self.process_references_with_format::<F>(run).bibliography;
887            let merged = self.merge_compound_entries::<F>(all_entries, run);
888            return self.render_flat_compound_entries::<F>(
889                &merged,
890                restrict_to_cited,
891                annotations,
892                annotation_style,
893                run,
894            );
895        }
896
897        self.render_flat_bibliography::<F>(
898            restrict_to_cited,
899            partitioning,
900            FlatBibliographyOutput::ContentOnly,
901            annotations,
902            annotation_style,
903            run,
904        )
905        .content
906    }
907
908    /// Extract and render entries for a bibliography group.
909    ///
910    /// Returns the individual processed entries for the group, threading
911    /// the `assigned` dedup set to ensure each reference appears in only one
912    /// group. `spine` is the document-wide sorted ID stub list (see
913    /// [`sorted_id_stubs`](Self::sorted_id_stubs)); callers that render
914    /// multiple groups in one pass (e.g.
915    /// [`render_document_bibliography_blocks`](Self::render_document_bibliography_blocks))
916    /// compute it once and pass the same slice to every group instead of
917    /// re-sorting the whole bibliography per group.
918    fn entries_for_bibliography_group<F>(
919        &self,
920        spine: &[ProcEntry],
921        group: &BibliographyGroup,
922        assigned: &mut HashSet<String>,
923        run: &FinalizedRun,
924    ) -> Vec<crate::render::ProcEntry>
925    where
926        F: OutputFormat<Output = String>,
927    {
928        let cited_ids = &run.state().cited_ids;
929        let evaluator = SelectorEvaluator::new(cited_ids);
930        let bibliography_config = self.get_bibliography_config();
931        let sorter = ReferenceSorter::with_bibliography_config(&self.locale, &bibliography_config);
932
933        let matching_refs = self.collect_matching_group_refs(spine, assigned, &evaluator, group);
934        Self::mark_group_members_assigned(assigned, &matching_refs);
935
936        if matching_refs.is_empty() {
937            return Vec::new();
938        }
939
940        let sorted_refs = if let Some(sort_spec) = &group.sort {
941            sorter.sort_references(matching_refs, &sort_spec.resolve())
942        } else {
943            matching_refs
944        };
945
946        let local_hints = self.build_group_local_hints(&sorted_refs, group);
947        self.merge_compound_entries::<F>(
948            self.render_group_entries::<F>(spine, sorted_refs, group, local_hints.as_ref(), run),
949            run,
950        )
951    }
952
953    /// Render one bibliography block for document output.
954    ///
955    /// Returns heading and body separately so callers can insert headings
956    /// in their own output format.
957    ///
958    /// `spine` is the document-wide sorted ID spine (see
959    /// [`sorted_id_stubs`](Self::sorted_id_stubs)). Standalone callers pass
960    /// their own `self.sorted_id_stubs()`;
961    /// [`render_document_bibliography_blocks`](Self::render_document_bibliography_blocks)
962    /// computes it once and shares the same slice across every block,
963    /// avoiding a full bibliography re-sort per block.
964    #[allow(
965        clippy::too_many_arguments,
966        reason = "internal helper, all params load-bearing"
967    )]
968    pub(crate) fn render_document_bibliography_block<F>(
969        &self,
970        spine: &[ProcEntry],
971        group: &BibliographyGroup,
972        assigned: &mut HashSet<String>,
973        annotations: Option<&HashMap<String, String>>,
974        annotation_style: Option<&AnnotationStyle>,
975        run: &FinalizedRun,
976    ) -> RenderedBibliographyGroup
977    where
978        F: OutputFormat<Output = String>,
979    {
980        let mut headingless = group.clone();
981        let heading = headingless
982            .heading
983            .take()
984            .and_then(|group_heading| self.resolve_group_heading(&group_heading));
985
986        let entries = self.entries_for_bibliography_group::<F>(spine, &headingless, assigned, run);
987        let body = crate::render::refs_to_string_slice_with_format::<F>(
988            &entries,
989            annotations,
990            annotation_style,
991        );
992
993        RenderedBibliographyGroup {
994            heading,
995            body,
996            entries,
997        }
998    }
999
1000    /// Render an ordered sequence of sectional bibliography blocks.
1001    ///
1002    /// Threads a single `assigned` dedup set so each reference appears in
1003    /// only one block. Returns rendered groups with heading, body, and
1004    /// entries. Computes the sorted ID spine once for the whole call (see
1005    /// [`sorted_id_stubs`](Self::sorted_id_stubs)) and shares it across every
1006    /// block, instead of each block independently re-sorting the full
1007    /// bibliography.
1008    pub(crate) fn render_document_bibliography_blocks<F>(
1009        &self,
1010        groups: &[BibliographyGroup],
1011        annotations: Option<&HashMap<String, String>>,
1012        annotation_style: Option<&AnnotationStyle>,
1013        run: &FinalizedRun,
1014    ) -> Vec<RenderedBibliographyGroup>
1015    where
1016        F: OutputFormat<Output = String>,
1017    {
1018        let spine = self.sorted_id_stubs();
1019        let mut assigned = std::collections::HashSet::new();
1020        groups
1021            .iter()
1022            .map(|group| {
1023                self.render_document_bibliography_block::<F>(
1024                    &spine,
1025                    group,
1026                    &mut assigned,
1027                    annotations,
1028                    annotation_style,
1029                    run,
1030                )
1031            })
1032            .collect()
1033    }
1034
1035    pub(super) fn extract_metadata(
1036        &self,
1037        reference: &Reference,
1038        ctx: &EntryRenderContext<'_>,
1039    ) -> ProcEntryMetadata {
1040        let bibliography_config = &ctx.config;
1041        let options = RenderOptions {
1042            config: bibliography_config.clone(),
1043            bibliography_config: Some(ctx.bibliography_config.clone()),
1044            locale: &self.locale,
1045            context: RenderContext::Bibliography,
1046            mode: citum_schema::citation::CitationMode::NonIntegral,
1047            suppress_author: false,
1048            locator_raw: None,
1049            ref_type: None,
1050            show_semantics: self.show_semantics,
1051            current_template_index: None,
1052            abbreviation_map: self.abbreviation_map.as_ref(),
1053        };
1054
1055        let ml = bibliography_config.multilingual.as_ref();
1056        let preferred_transliteration = ml.and_then(|m| m.preferred_transliteration.as_deref());
1057        let preferred_script = ml.and_then(|m| m.preferred_script.as_ref());
1058
1059        ProcEntryMetadata {
1060            author: reference.author().map(|author| {
1061                let names = resolve_multilingual_name(
1062                    &author,
1063                    ml.and_then(|m| m.name_mode.as_ref()),
1064                    preferred_transliteration,
1065                    preferred_script,
1066                    &self.locale.locale,
1067                );
1068                format_contributors_short(&names, &options)
1069            }),
1070            year: reference
1071                .effective_issued_date()
1072                .map(|issued| issued.year().clone()),
1073            title: reference.title().map(|title| {
1074                use citum_schema::reference::types::{MultilingualString, Title};
1075                match &title {
1076                    Title::Multilingual(m) => resolve_multilingual_string(
1077                        &MultilingualString::Complex(m.clone()),
1078                        ml.and_then(|ml| ml.title_mode.as_ref()),
1079                        preferred_transliteration,
1080                        preferred_script,
1081                        &self.locale.locale,
1082                    ),
1083                    _ => title.to_string(),
1084                }
1085            }),
1086        }
1087    }
1088
1089    fn render_group_heading<F>(&self, heading: &str) -> String
1090    where
1091        F: OutputFormat<Output = String>,
1092    {
1093        let fmt = F::default();
1094        fmt.finish(fmt.unnumbered_heading(2, fmt.text(heading)))
1095    }
1096}