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