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