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