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::Processor;
12use crate::processor::disambiguation::Disambiguator;
13use crate::processor::rendering::{CompoundRenderData, Renderer, RendererResources};
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::rc::Rc;
27
28impl Processor {
29    /// Resolve a localized or literal group heading.
30    pub(super) fn resolve_group_heading(&self, heading: &GroupHeading) -> Option<String> {
31        match heading {
32            GroupHeading::Literal { literal } => Some(literal.clone()),
33            GroupHeading::Term { term, form } => self.locale.resolved_general_term(
34                term,
35                &form.clone().unwrap_or(citum_schema::locale::TermForm::Long),
36                None,
37            ),
38            GroupHeading::Localized { localized } => self.resolve_localized_heading(localized),
39        }
40    }
41
42    /// Resolve a localized heading map based on the processor locale.
43    ///
44    /// Matches in order:
45    /// 1. Exact locale (e.g., "en-GB")
46    /// 2. Primary language (e.g., "en")
47    /// 3. Style default locale
48    /// 4. en-US fallback
49    /// 5. First alphabetically defined key
50    fn resolve_localized_heading(&self, localized: &HashMap<String, String>) -> Option<String> {
51        fn language_tag(locale: &str) -> &str {
52            locale.split('-').next().unwrap_or(locale)
53        }
54
55        let mut candidates = Vec::new();
56        let mut push_candidate = |locale: &str| {
57            let candidate = locale.to_string();
58            if !candidates.contains(&candidate) {
59                candidates.push(candidate);
60            }
61        };
62
63        push_candidate(&self.locale.locale);
64        push_candidate(language_tag(&self.locale.locale));
65
66        if let Some(default_locale) = self.style.info.default_locale.as_deref() {
67            push_candidate(default_locale);
68            push_candidate(language_tag(default_locale));
69        }
70
71        push_candidate("en-US");
72        push_candidate("en");
73
74        for locale in candidates {
75            if let Some(value) = localized.get(&locale) {
76                return Some(value.clone());
77            }
78        }
79
80        localized
81            .iter()
82            .min_by(|left, right| left.0.cmp(right.0))
83            .map(|(_locale, value)| value.clone())
84    }
85
86    /// Resolve a bibliography partition heading.
87    fn resolve_partition_heading(&self, heading: &BibliographyPartitionHeading) -> Option<String> {
88        match heading {
89            BibliographyPartitionHeading::Literal { literal } => Some(literal.clone()),
90            BibliographyPartitionHeading::Term { term, form } => self.locale.resolved_general_term(
91                term,
92                &form.clone().unwrap_or(citum_schema::locale::TermForm::Long),
93                None,
94            ),
95            BibliographyPartitionHeading::Localized { localized } => {
96                self.resolve_localized_heading(localized)
97            }
98        }
99    }
100
101    /// Find unassigned bibliography entries that match a group's selector.
102    fn collect_matching_group_refs<'a>(
103        &'a self,
104        bibliography: &'a [ProcEntry],
105        assigned: &HashSet<String>,
106        evaluator: &SelectorEvaluator<'_>,
107        group: &BibliographyGroup,
108    ) -> Vec<&'a Reference> {
109        bibliography
110            .iter()
111            .filter(|entry| !assigned.contains(&entry.id))
112            .filter_map(|entry| {
113                self.bibliography
114                    .get(&entry.id)
115                    .filter(|reference| evaluator.matches(reference, &group.selector))
116            })
117            .collect()
118    }
119
120    /// Returns `ProcEntry` stubs with only `id` populated, in sort order.
121    ///
122    /// Used for grouping paths that only need IDs for selector matching — avoids
123    /// the full PlainText render pass that `process_references` performs.
124    pub(super) fn sorted_id_stubs(&self) -> Vec<ProcEntry> {
125        self.initialize_numeric_bibliography_numbers();
126        self.sort_references(self.bibliography.values().collect())
127            .into_iter()
128            .filter_map(|r| {
129                r.id().map(|id| ProcEntry {
130                    id: id.to_string(),
131                    template: vec![],
132                    metadata: ProcEntryMetadata::default(),
133                })
134            })
135            .collect()
136    }
137
138    /// Mark references as assigned to a bibliography group.
139    fn mark_group_members_assigned(assigned: &mut HashSet<String>, references: &[&Reference]) {
140        for reference in references {
141            if let Some(id) = reference.id() {
142                assigned.insert(id.to_string());
143            }
144        }
145    }
146
147    /// Calculate disambiguation hints locally within a bibliography group.
148    ///
149    /// Only calculates hints if the group specifies local disambiguation scope.
150    fn build_group_local_hints(
151        &self,
152        sorted_refs: &[&Reference],
153        group: &BibliographyGroup,
154    ) -> Option<HashMap<String, ProcHints>> {
155        if !matches!(group.disambiguate, Some(DisambiguationScope::Locally)) {
156            return None;
157        }
158
159        let mut group_bibliography = Bibliography::new();
160        for reference in sorted_refs {
161            group_bibliography.insert(
162                reference.id().unwrap_or_default().to_string(),
163                (*reference).clone(),
164            );
165        }
166
167        let resolved_sort = group
168            .sort
169            .as_ref()
170            .map(citum_schema::GroupSortEntry::resolve);
171        let bibliography_config = self.get_bibliography_config();
172        let disambiguator = if let Some(sort) = resolved_sort.as_ref() {
173            Disambiguator::with_group_sort(
174                &group_bibliography,
175                &bibliography_config,
176                &self.locale,
177                sort,
178            )
179        } else {
180            Disambiguator::new(&group_bibliography, &bibliography_config, &self.locale)
181        };
182
183        Some(disambiguator.calculate_hints())
184    }
185
186    /// Resolve the effective style to use for a bibliography group.
187    fn effective_group_style<'a>(
188        &'a self,
189        group: &'a BibliographyGroup,
190    ) -> Cow<'a, citum_schema::Style> {
191        if let Some(group_template) = &group.template {
192            let mut local_style = self.style.clone();
193            if let Some(bibliography) = local_style.bibliography.as_mut() {
194                bibliography.template = Some(group_template.clone());
195            }
196            Cow::Owned(local_style)
197        } else {
198            Cow::Borrowed(&self.style)
199        }
200    }
201
202    /// Render bibliography entries for a specific group.
203    fn render_group_entries<F>(
204        &self,
205        _bibliography: &[ProcEntry],
206        sorted_refs: Vec<&Reference>,
207        group: &BibliographyGroup,
208        local_hints: Option<&HashMap<String, ProcHints>>,
209    ) -> Vec<ProcEntry>
210    where
211        F: OutputFormat<Output = String>,
212    {
213        // Always process entries with format F so that group components (pre_formatted=true)
214        // contain markup in the target format rather than PlainText (_..._).
215        let hints = local_hints.unwrap_or(&self.hints);
216        let effective_style = self.effective_group_style(group);
217        let bibliography_config = self.get_bibliography_config();
218        let bibliography_options = self.get_bibliography_options().into_owned();
219        let substitute = bibliography_options.subsequent_author_substitute.clone();
220        let renderer = Renderer::new(
221            RendererResources {
222                style: &effective_style,
223                bibliography: &self.bibliography,
224                locale: &self.locale,
225                config: Rc::new(bibliography_config.into_owned()),
226                bibliography_config: Some(Rc::new(bibliography_options)),
227                first_note_by_id: None,
228            },
229            hints,
230            &self.citation_numbers,
231            CompoundRenderData {
232                set_by_ref: &self.compound_set_by_ref,
233                member_index: &self.compound_member_index,
234                sets: &self.compound_sets,
235            },
236            self.show_semantics,
237            self.inject_ast_indices,
238            self.abbreviation_map.as_ref(),
239        );
240
241        let mut entries = Vec::new();
242        let mut previous_reference: Option<&Reference> = None;
243
244        for (index, reference) in sorted_refs.into_iter().enumerate() {
245            let ref_id = reference.id().unwrap_or_default().to_string();
246            let entry_number = self
247                .citation_numbers
248                .borrow()
249                .get(&ref_id)
250                .copied()
251                .unwrap_or(index + 1);
252
253            if let Some(mut processed) =
254                renderer.process_bibliography_entry_with_format::<F>(reference, entry_number)
255            {
256                if let Some(substitute_string) = substitute.as_deref()
257                    && let Some(previous) = previous_reference
258                    && self.contributors_match(previous, reference)
259                {
260                    renderer.apply_author_substitution_with_format::<F>(
261                        &mut processed,
262                        substitute_string,
263                    );
264                }
265
266                entries.push(ProcEntry {
267                    id: ref_id,
268                    template: processed,
269                    metadata: self.extract_metadata(reference),
270                });
271                previous_reference = Some(reference);
272            }
273        }
274
275        entries
276    }
277
278    /// Append a rendered bibliography group to the output string.
279    fn append_rendered_group<F>(
280        &self,
281        result: &mut String,
282        group: &BibliographyGroup,
283        entries: Vec<ProcEntry>,
284        annotations: Option<&HashMap<String, String>>,
285        annotation_style: Option<&AnnotationStyle>,
286        suppress_heading: bool,
287    ) where
288        F: OutputFormat<Output = String>,
289    {
290        if !result.is_empty() {
291            result.push_str("\n\n");
292        }
293
294        if !suppress_heading
295            && let Some(heading) = group
296                .heading
297                .as_ref()
298                .and_then(|group_heading| self.resolve_group_heading(group_heading))
299        {
300            result.push_str(&self.render_group_heading::<F>(&heading));
301        }
302
303        result.push_str(&crate::render::refs_to_string_with_format::<F>(
304            entries,
305            annotations,
306            annotation_style,
307        ));
308    }
309
310    /// Append a rendered bibliography partition to the output string.
311    fn append_rendered_partition<F>(
312        &self,
313        result: &mut String,
314        heading: Option<&BibliographyPartitionHeading>,
315        entries: Vec<ProcEntry>,
316        annotations: Option<&HashMap<String, String>>,
317        annotation_style: Option<&AnnotationStyle>,
318    ) where
319        F: OutputFormat<Output = String>,
320    {
321        if !result.is_empty() {
322            result.push_str("\n\n");
323        }
324
325        if let Some(heading) =
326            heading.and_then(|group_heading| self.resolve_partition_heading(group_heading))
327        {
328            result.push_str(&self.render_group_heading::<F>(&heading));
329        }
330
331        result.push_str(&crate::render::refs_to_string_with_format::<F>(
332            entries,
333            annotations,
334            annotation_style,
335        ));
336    }
337
338    /// Orchestrate the rendering of automatic bibliography partitions with headings.
339    pub(super) fn render_with_partition_sections<F>(
340        &self,
341        sorted_refs: Vec<&Reference>,
342        partitioning: &BibliographySortPartitioning,
343        annotations: Option<&HashMap<String, String>>,
344        annotation_style: Option<&AnnotationStyle>,
345    ) -> String
346    where
347        F: OutputFormat<Output = String>,
348    {
349        let fmt = F::default();
350        let mut result = String::new();
351
352        for (partition_key, references) in
353            crate::sort_partitioning::partition_references(sorted_refs, &self.locale, partitioning)
354        {
355            let heading = partition_key
356                .as_ref()
357                .and_then(|key| partitioning.headings.get(key));
358            let entries = self.merge_compound_entries::<F>(self.process_sorted_refs::<_, F>(
359                references.into_iter(),
360                |reference, entry_number| {
361                    self.process_bibliography_entry_with_format::<F>(reference, entry_number)
362                },
363            ));
364            self.append_rendered_partition::<F>(
365                &mut result,
366                heading,
367                entries,
368                annotations,
369                annotation_style,
370            );
371        }
372
373        fmt.finish(result)
374    }
375
376    /// Render a filtered subset of entries using custom bibliography grouping.
377    ///
378    /// This uses a two-pass grouping strategy:
379    /// 1. Collect and render all populated groups.
380    /// 2. Determine if heading suppression applies (only one group populated).
381    /// 3. Append groups and any remaining unassigned entries.
382    pub(super) fn render_with_custom_groups_filtered<F>(
383        &self,
384        all_entries: &[ProcEntry],
385        groups: &[BibliographyGroup],
386        selected: &HashSet<String>,
387        annotations: Option<&HashMap<String, String>>,
388        annotation_style: Option<&AnnotationStyle>,
389    ) -> String
390    where
391        F: OutputFormat<Output = String>,
392    {
393        let fmt = F::default();
394        let cited_ids = self.cited_ids.borrow();
395        let evaluator = SelectorEvaluator::new(&cited_ids);
396        let sorter = ReferenceSorter::new(&self.locale);
397
398        let mut assigned = HashSet::new();
399        let mut result = String::new();
400
401        // First pass: collect all populated groups with their rendered entries
402        let mut populated_groups: Vec<(&BibliographyGroup, Vec<ProcEntry>)> = Vec::new();
403
404        for group in groups {
405            let matching_refs =
406                self.collect_matching_group_refs(all_entries, &assigned, &evaluator, group);
407
408            let matching_refs: Vec<&Reference> = matching_refs
409                .into_iter()
410                .filter(|r| r.id().as_deref().is_some_and(|id| selected.contains(id)))
411                .collect();
412
413            if matching_refs.is_empty() {
414                continue;
415            }
416
417            Self::mark_group_members_assigned(&mut assigned, &matching_refs);
418
419            let sorted_refs = if let Some(sort_spec) = &group.sort {
420                sorter.sort_references(matching_refs, &sort_spec.resolve())
421            } else {
422                matching_refs
423            };
424            let local_hints = self.build_group_local_hints(&sorted_refs, group);
425            let entries = self.merge_compound_entries::<F>(self.render_group_entries::<F>(
426                all_entries,
427                sorted_refs,
428                group,
429                local_hints.as_ref(),
430            ));
431
432            populated_groups.push((group, entries));
433        }
434
435        // Compute unassigned entries to determine if heading suppression applies
436        let unassigned_refs: Vec<&Reference> = all_entries
437            .iter()
438            .filter(|entry| !assigned.contains(&entry.id) && selected.contains(&entry.id))
439            .filter_map(|entry| self.bibliography.get(&entry.id))
440            .collect();
441
442        let suppress_heading = populated_groups.len() == 1 && unassigned_refs.is_empty();
443
444        // Second pass: render populated groups with optional heading suppression
445        for (group, entries) in populated_groups {
446            self.append_rendered_group::<F>(
447                &mut result,
448                group,
449                entries,
450                annotations,
451                annotation_style,
452                suppress_heading,
453            );
454        }
455
456        self.append_unassigned_entries_filtered::<F>(
457            &mut result,
458            all_entries,
459            &assigned,
460            selected,
461            annotations,
462            annotation_style,
463        );
464        fmt.finish(result)
465    }
466
467    /// Append unassigned bibliography entries to the output string.
468    fn append_unassigned_entries_filtered<F>(
469        &self,
470        result: &mut String,
471        bibliography: &[ProcEntry],
472        assigned: &HashSet<String>,
473        selected: &HashSet<String>,
474        annotations: Option<&HashMap<String, String>>,
475        annotation_style: Option<&AnnotationStyle>,
476    ) where
477        F: OutputFormat<Output = String>,
478    {
479        let unassigned_refs: Vec<&Reference> = bibliography
480            .iter()
481            .filter(|entry| !assigned.contains(&entry.id) && selected.contains(&entry.id))
482            .filter_map(|entry| self.bibliography.get(&entry.id))
483            .collect();
484
485        if unassigned_refs.is_empty() {
486            return;
487        }
488
489        // Re-process references to ensure correct author substitution and disambiguation
490        // within the unassigned subset.
491        let unassigned = self.merge_compound_entries::<F>(self.process_sorted_refs::<_, F>(
492            unassigned_refs.into_iter(),
493            |reference, entry_number| {
494                self.process_bibliography_entry_with_format::<F>(reference, entry_number)
495            },
496        ));
497
498        if !result.is_empty() {
499            result.push_str("\n\n");
500        }
501
502        result.push_str(&crate::render::refs_to_string_with_format::<F>(
503            unassigned,
504            annotations,
505            annotation_style,
506        ));
507    }
508
509    /// Render bibliography using legacy (cited/uncited) grouping.
510    fn render_with_legacy_grouping<F>(
511        &self,
512        bibliography: &[ProcEntry],
513        annotations: Option<&HashMap<String, String>>,
514        annotation_style: Option<&AnnotationStyle>,
515    ) -> String
516    where
517        F: OutputFormat<Output = String>,
518    {
519        let fmt = F::default();
520        let cited_ids = self.cited_ids.borrow();
521        let cited_entries: Vec<ProcEntry> = bibliography
522            .iter()
523            .filter(|entry| cited_ids.contains(&entry.id))
524            .cloned()
525            .collect();
526
527        let mut result = String::new();
528        if !cited_entries.is_empty() {
529            result.push_str(&crate::render::refs_to_string_with_format::<F>(
530                cited_entries,
531                annotations,
532                annotation_style,
533            ));
534        }
535
536        fmt.finish(result)
537    }
538
539    /// Render the bibliography with grouping for uncited (nocite) items.
540    ///
541    /// If `style.bibliography.groups` is defined, uses configurable grouping
542    /// with per-group sorting. Group selectors apply to individual references
543    /// before compound numeric rows are merged, so each rendered group only
544    /// includes the members that matched its selector. Otherwise, falls back to
545    /// hardcoded cited/uncited grouping for backward compatibility.
546    pub fn render_grouped_bibliography_with_format<F>(&self) -> String
547    where
548        F: OutputFormat<Output = String>,
549    {
550        self.render_grouped_bibliography_with_format_and_annotations::<F>(None, None)
551    }
552
553    /// Render the bibliography with grouping and annotations.
554    pub fn render_grouped_bibliography_with_format_and_annotations<F>(
555        &self,
556        annotations: Option<&HashMap<String, String>>,
557        annotation_style: Option<&AnnotationStyle>,
558    ) -> String
559    where
560        F: OutputFormat<Output = String>,
561    {
562        self.render_grouped_bibliography_inner::<F>(false, annotations, annotation_style)
563    }
564
565    /// Unified document bibliography facade — returns content and per-entry data together.
566    ///
567    /// This is the single entry point for all document-context bibliography rendering:
568    /// batch (`format_document`), interactive session (`DocumentSession`), and the
569    /// document-string (`process_document`) path all funnel through here.
570    ///
571    /// When `restrict_to_cited` is `true` (the document case), only references present
572    /// in `self.cited_ids` — cited in-text or registered via `nocite` — are included.
573    /// When `false`, all loaded references are eligible; this hook is reserved for the
574    /// `allrefs` escape hatch (csl26-f9ri) and is not yet exposed publicly.
575    ///
576    /// Both `content` and `entries` are computed from the same cited subset so
577    /// subsequent-author substitution stays consistent across both outputs.
578    pub(crate) fn render_document_bibliography<F>(
579        &self,
580        restrict_to_cited: bool,
581        annotations: Option<&HashMap<String, String>>,
582        annotation_style: Option<&AnnotationStyle>,
583    ) -> super::DocumentBibliography
584    where
585        F: OutputFormat<Output = String>,
586    {
587        let content = self.render_grouped_bibliography_inner::<F>(
588            restrict_to_cited,
589            annotations,
590            annotation_style,
591        );
592        // Collect IDs before calling process_* so the RefCell borrow is released.
593        let cited_ids: Vec<String> = self.cited_ids.borrow().iter().cloned().collect();
594        let entries = if restrict_to_cited {
595            self.process_selected_references_with_format::<F, _>(cited_ids)
596                .bibliography
597        } else {
598            self.process_references_with_format::<F>().bibliography
599        };
600        super::DocumentBibliography { content, entries }
601    }
602
603    /// Shared implementation for grouped bibliography rendering.
604    ///
605    /// When `restrict_to_cited` is `true`, each branch limits its candidate
606    /// set to references present in `self.cited_ids`. When `false`, all
607    /// loaded references are eligible (the original all-refs behaviour used
608    /// by standalone `render refs`, FFI, and tests).
609    fn render_grouped_bibliography_inner<F>(
610        &self,
611        restrict_to_cited: bool,
612        annotations: Option<&HashMap<String, String>>,
613        annotation_style: Option<&AnnotationStyle>,
614    ) -> String
615    where
616        F: OutputFormat<Output = String>,
617    {
618        if let Some(groups) = self
619            .style
620            .bibliography
621            .as_ref()
622            .and_then(|bibliography| bibliography.groups.as_ref())
623        {
624            let id_stubs = self.sorted_id_stubs();
625            let selected = if restrict_to_cited {
626                let cited = self.cited_ids.borrow();
627                id_stubs
628                    .iter()
629                    .filter(|e| cited.contains(&e.id))
630                    .map(|e| e.id.clone())
631                    .collect::<HashSet<_>>()
632            } else {
633                id_stubs
634                    .iter()
635                    .map(|e| e.id.clone())
636                    .collect::<HashSet<_>>()
637            };
638            return self.render_with_custom_groups_filtered::<F>(
639                &id_stubs,
640                groups,
641                &selected,
642                annotations,
643                annotation_style,
644            );
645        }
646
647        let bibliography_options = self.get_bibliography_options();
648        if let Some(partitioning) = bibliography_options.sort_partitioning.as_ref()
649            && crate::sort_partitioning::should_render_sections(partitioning)
650        {
651            self.initialize_numeric_bibliography_numbers();
652            let mut refs: Vec<&Reference> = self.bibliography.values().collect();
653            if restrict_to_cited {
654                let cited = self.cited_ids.borrow();
655                refs.retain(|r| r.id().as_deref().is_some_and(|id| cited.contains(id)));
656            }
657            let sorted_refs = self.sort_references(refs);
658            return self.render_with_partition_sections::<F>(
659                sorted_refs,
660                partitioning,
661                annotations,
662                annotation_style,
663            );
664        }
665
666        let all_entries = self.process_references_with_format::<F>().bibliography;
667        self.render_with_legacy_grouping::<F>(
668            &self.merge_compound_entries::<F>(all_entries),
669            annotations,
670            annotation_style,
671        )
672    }
673
674    /// Extract and render entries for a bibliography group.
675    ///
676    /// Returns the individual processed entries for the group, threading
677    /// the `assigned` dedup set to ensure each reference appears in only one group.
678    fn entries_for_bibliography_group<F>(
679        &self,
680        group: &BibliographyGroup,
681        assigned: &mut HashSet<String>,
682    ) -> Vec<crate::render::ProcEntry>
683    where
684        F: OutputFormat<Output = String>,
685    {
686        let bibliography = self.sorted_id_stubs();
687        let cited_ids = self.cited_ids.borrow();
688        let evaluator = SelectorEvaluator::new(&cited_ids);
689        let sorter = ReferenceSorter::new(&self.locale);
690
691        let matching_refs =
692            self.collect_matching_group_refs(&bibliography, assigned, &evaluator, group);
693        Self::mark_group_members_assigned(assigned, &matching_refs);
694
695        if matching_refs.is_empty() {
696            return Vec::new();
697        }
698
699        let sorted_refs = if let Some(sort_spec) = &group.sort {
700            sorter.sort_references(matching_refs, &sort_spec.resolve())
701        } else {
702            matching_refs
703        };
704
705        let local_hints = self.build_group_local_hints(&sorted_refs, group);
706        self.merge_compound_entries::<F>(self.render_group_entries::<F>(
707            &bibliography,
708            sorted_refs,
709            group,
710            local_hints.as_ref(),
711        ))
712    }
713
714    /// Render one bibliography block for document output.
715    ///
716    /// Returns heading and body separately so callers can insert headings
717    /// in their own output format.
718    pub(crate) fn render_document_bibliography_block<F>(
719        &self,
720        group: &BibliographyGroup,
721        assigned: &mut HashSet<String>,
722        annotations: Option<&HashMap<String, String>>,
723        annotation_style: Option<&AnnotationStyle>,
724    ) -> RenderedBibliographyGroup
725    where
726        F: OutputFormat<Output = String>,
727    {
728        let mut headingless = group.clone();
729        let heading = headingless
730            .heading
731            .take()
732            .and_then(|group_heading| self.resolve_group_heading(&group_heading));
733
734        let entries = self.entries_for_bibliography_group::<F>(&headingless, assigned);
735        let body = crate::render::refs_to_string_slice_with_format::<F>(
736            &entries,
737            annotations,
738            annotation_style,
739        );
740
741        RenderedBibliographyGroup {
742            heading,
743            body,
744            entries,
745        }
746    }
747
748    /// Render an ordered sequence of sectional bibliography blocks.
749    ///
750    /// Threads a single `assigned` dedup set so each reference appears in
751    /// only one block. Returns rendered groups with heading, body, and entries.
752    pub(crate) fn render_document_bibliography_blocks<F>(
753        &self,
754        groups: &[BibliographyGroup],
755        annotations: Option<&HashMap<String, String>>,
756        annotation_style: Option<&AnnotationStyle>,
757    ) -> Vec<RenderedBibliographyGroup>
758    where
759        F: OutputFormat<Output = String>,
760    {
761        let mut assigned = std::collections::HashSet::new();
762        groups
763            .iter()
764            .map(|group| {
765                self.render_document_bibliography_block::<F>(
766                    group,
767                    &mut assigned,
768                    annotations,
769                    annotation_style,
770                )
771            })
772            .collect()
773    }
774
775    pub(super) fn extract_metadata(&self, reference: &Reference) -> ProcEntryMetadata {
776        let bibliography_config = Rc::new(self.get_bibliography_config().into_owned());
777        let options = RenderOptions {
778            config: bibliography_config.clone(),
779            bibliography_config: Some(Rc::new(self.get_bibliography_options().into_owned())),
780            locale: &self.locale,
781            context: RenderContext::Bibliography,
782            mode: citum_schema::citation::CitationMode::NonIntegral,
783            suppress_author: false,
784            locator_raw: None,
785            ref_type: None,
786            show_semantics: self.show_semantics,
787            current_template_index: None,
788            abbreviation_map: self.abbreviation_map.as_ref(),
789        };
790
791        let ml = bibliography_config.multilingual.as_ref();
792        let preferred_transliteration = ml.and_then(|m| m.preferred_transliteration.as_deref());
793        let preferred_script = ml.and_then(|m| m.preferred_script.as_ref());
794
795        ProcEntryMetadata {
796            author: reference.author().map(|author| {
797                let names = resolve_multilingual_name(
798                    &author,
799                    ml.and_then(|m| m.name_mode.as_ref()),
800                    preferred_transliteration,
801                    preferred_script,
802                    &self.locale.locale,
803                );
804                format_contributors_short(&names, &options)
805            }),
806            year: reference
807                .effective_issued_date()
808                .map(|issued| issued.year().clone()),
809            title: reference.title().map(|title| {
810                use citum_schema::reference::types::{MultilingualString, Title};
811                match &title {
812                    Title::Multilingual(m) => resolve_multilingual_string(
813                        &MultilingualString::Complex(m.clone()),
814                        ml.and_then(|ml| ml.title_mode.as_ref()),
815                        preferred_transliteration,
816                        preferred_script,
817                        &self.locale.locale,
818                    ),
819                    _ => title.to_string(),
820                }
821            }),
822        }
823    }
824
825    fn render_group_heading<F>(&self, heading: &str) -> String
826    where
827        F: OutputFormat<Output = String>,
828    {
829        let fmt = F::default();
830        fmt.finish(fmt.unnumbered_heading(2, fmt.text(heading)))
831    }
832}