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 all entries using custom bibliography grouping.
375    pub(super) fn render_with_custom_groups<F>(
376        &self,
377        all_entries: &[ProcEntry],
378        groups: &[BibliographyGroup],
379    ) -> String
380    where
381        F: OutputFormat<Output = String>,
382    {
383        let selected: HashSet<String> = all_entries.iter().map(|e| e.id.clone()).collect();
384        self.render_with_custom_groups_filtered::<F>(all_entries, groups, &selected, None, None)
385    }
386
387    /// Render a filtered subset of entries using custom bibliography grouping.
388    ///
389    /// This uses a two-pass grouping strategy:
390    /// 1. Collect and render all populated groups.
391    /// 2. Determine if heading suppression applies (only one group populated).
392    /// 3. Append groups and any remaining unassigned entries.
393    pub(super) fn render_with_custom_groups_filtered<F>(
394        &self,
395        all_entries: &[ProcEntry],
396        groups: &[BibliographyGroup],
397        selected: &HashSet<String>,
398        annotations: Option<&HashMap<String, String>>,
399        annotation_style: Option<&AnnotationStyle>,
400    ) -> String
401    where
402        F: OutputFormat<Output = String>,
403    {
404        let fmt = F::default();
405        let cited_ids = self.cited_ids.borrow();
406        let evaluator = SelectorEvaluator::new(&cited_ids);
407        let sorter = GroupSorter::new(&self.locale);
408
409        let mut assigned = HashSet::new();
410        let mut result = String::new();
411
412        // First pass: collect all populated groups with their rendered entries
413        let mut populated_groups: Vec<(&BibliographyGroup, Vec<ProcEntry>)> = Vec::new();
414
415        for group in groups {
416            let matching_refs =
417                self.collect_matching_group_refs(all_entries, &assigned, &evaluator, group);
418
419            let matching_refs: Vec<&Reference> = matching_refs
420                .into_iter()
421                .filter(|r| r.id().as_deref().is_some_and(|id| selected.contains(id)))
422                .collect();
423
424            if matching_refs.is_empty() {
425                continue;
426            }
427
428            Self::mark_group_members_assigned(&mut assigned, &matching_refs);
429
430            let sorted_refs = if let Some(sort_spec) = &group.sort {
431                sorter.sort_references(matching_refs, &sort_spec.resolve())
432            } else {
433                matching_refs
434            };
435            let local_hints = self.build_group_local_hints(&sorted_refs, group);
436            let entries = self.merge_compound_entries::<F>(self.render_group_entries::<F>(
437                all_entries,
438                sorted_refs,
439                group,
440                local_hints.as_ref(),
441            ));
442
443            populated_groups.push((group, entries));
444        }
445
446        // Compute unassigned entries to determine if heading suppression applies
447        let unassigned_refs: Vec<&Reference> = all_entries
448            .iter()
449            .filter(|entry| !assigned.contains(&entry.id) && selected.contains(&entry.id))
450            .filter_map(|entry| self.bibliography.get(&entry.id))
451            .collect();
452
453        let suppress_heading = populated_groups.len() == 1 && unassigned_refs.is_empty();
454
455        // Second pass: render populated groups with optional heading suppression
456        for (group, entries) in populated_groups {
457            self.append_rendered_group::<F>(
458                &mut result,
459                group,
460                entries,
461                annotations,
462                annotation_style,
463                suppress_heading,
464            );
465        }
466
467        self.append_unassigned_entries_filtered::<F>(
468            &mut result,
469            all_entries,
470            &assigned,
471            selected,
472            annotations,
473            annotation_style,
474        );
475        fmt.finish(result)
476    }
477
478    /// Append unassigned bibliography entries to the output string.
479    fn append_unassigned_entries_filtered<F>(
480        &self,
481        result: &mut String,
482        bibliography: &[ProcEntry],
483        assigned: &HashSet<String>,
484        selected: &HashSet<String>,
485        annotations: Option<&HashMap<String, String>>,
486        annotation_style: Option<&AnnotationStyle>,
487    ) where
488        F: OutputFormat<Output = String>,
489    {
490        let unassigned_refs: Vec<&Reference> = bibliography
491            .iter()
492            .filter(|entry| !assigned.contains(&entry.id) && selected.contains(&entry.id))
493            .filter_map(|entry| self.bibliography.get(&entry.id))
494            .collect();
495
496        if unassigned_refs.is_empty() {
497            return;
498        }
499
500        // Re-process references to ensure correct author substitution and disambiguation
501        // within the unassigned subset.
502        let unassigned = self.merge_compound_entries::<F>(self.process_sorted_refs::<_, F>(
503            unassigned_refs.into_iter(),
504            |reference, entry_number| {
505                self.process_bibliography_entry_with_format::<F>(reference, entry_number)
506            },
507        ));
508
509        if !result.is_empty() {
510            result.push_str("\n\n");
511        }
512
513        result.push_str(&crate::render::refs_to_string_with_format::<F>(
514            unassigned,
515            annotations,
516            annotation_style,
517        ));
518    }
519
520    /// Render bibliography using legacy (cited/uncited) grouping.
521    fn render_with_legacy_grouping<F>(
522        &self,
523        bibliography: &[ProcEntry],
524        annotations: Option<&HashMap<String, String>>,
525        annotation_style: Option<&AnnotationStyle>,
526    ) -> String
527    where
528        F: OutputFormat<Output = String>,
529    {
530        let fmt = F::default();
531        let cited_ids = self.cited_ids.borrow();
532        let cited_entries: Vec<ProcEntry> = bibliography
533            .iter()
534            .filter(|entry| cited_ids.contains(&entry.id))
535            .cloned()
536            .collect();
537
538        let mut result = String::new();
539        if !cited_entries.is_empty() {
540            result.push_str(&crate::render::refs_to_string_with_format::<F>(
541                cited_entries,
542                annotations,
543                annotation_style,
544            ));
545        }
546
547        fmt.finish(result)
548    }
549
550    /// Render a standalone bibliography block for a group.
551    fn render_bibliography_for_group<F>(
552        &self,
553        group: &BibliographyGroup,
554        annotations: Option<&HashMap<String, String>>,
555        annotation_style: Option<&AnnotationStyle>,
556        assigned: &mut HashSet<String>,
557    ) -> String
558    where
559        F: OutputFormat<Output = String>,
560    {
561        let bibliography = self.sorted_id_stubs();
562        let fmt = F::default();
563        let cited_ids = self.cited_ids.borrow();
564        let evaluator = SelectorEvaluator::new(&cited_ids);
565        let sorter = GroupSorter::new(&self.locale);
566
567        let matching_refs =
568            self.collect_matching_group_refs(&bibliography, assigned, &evaluator, group);
569        Self::mark_group_members_assigned(assigned, &matching_refs);
570
571        if matching_refs.is_empty() {
572            return fmt.finish(String::new());
573        }
574
575        let sorted_refs = if let Some(sort_spec) = &group.sort {
576            sorter.sort_references(matching_refs, &sort_spec.resolve())
577        } else {
578            matching_refs
579        };
580
581        let local_hints = self.build_group_local_hints(&sorted_refs, group);
582        let entries = self.merge_compound_entries::<F>(self.render_group_entries::<F>(
583            &bibliography,
584            sorted_refs,
585            group,
586            local_hints.as_ref(),
587        ));
588
589        fmt.finish(crate::render::refs_to_string_with_format::<F>(
590            entries,
591            annotations,
592            annotation_style,
593        ))
594    }
595
596    /// Render the bibliography with grouping for uncited (nocite) items.
597    ///
598    /// If `style.bibliography.groups` is defined, uses configurable grouping
599    /// with per-group sorting. Group selectors apply to individual references
600    /// before compound numeric rows are merged, so each rendered group only
601    /// includes the members that matched its selector. Otherwise, falls back to
602    /// hardcoded cited/uncited grouping for backward compatibility.
603    pub fn render_grouped_bibliography_with_format<F>(&self) -> String
604    where
605        F: OutputFormat<Output = String>,
606    {
607        self.render_grouped_bibliography_with_format_and_annotations::<F>(None, None)
608    }
609
610    /// Render the bibliography with grouping and annotations.
611    pub fn render_grouped_bibliography_with_format_and_annotations<F>(
612        &self,
613        annotations: Option<&HashMap<String, String>>,
614        annotation_style: Option<&AnnotationStyle>,
615    ) -> String
616    where
617        F: OutputFormat<Output = String>,
618    {
619        self.render_grouped_bibliography_inner::<F>(false, annotations, annotation_style)
620    }
621
622    /// Render the trailing document bibliography, restricted to cited references.
623    ///
624    /// Equivalent to [`Self::render_grouped_bibliography_with_format`] but
625    /// intersects every branch's candidate set with `self.cited_ids` so that
626    /// references not cited in the document are excluded — matching
627    /// standard citeproc semantics for document rendering.
628    pub(crate) fn render_grouped_document_bibliography_with_format<F>(&self) -> String
629    where
630        F: OutputFormat<Output = String>,
631    {
632        self.render_grouped_bibliography_inner::<F>(true, None, None)
633    }
634
635    /// Shared implementation for grouped bibliography rendering.
636    ///
637    /// When `restrict_to_cited` is `true`, each branch limits its candidate
638    /// set to references present in `self.cited_ids`. When `false`, all
639    /// loaded references are eligible (the original all-refs behaviour used
640    /// by standalone `render refs`, FFI, and tests).
641    fn render_grouped_bibliography_inner<F>(
642        &self,
643        restrict_to_cited: bool,
644        annotations: Option<&HashMap<String, String>>,
645        annotation_style: Option<&AnnotationStyle>,
646    ) -> String
647    where
648        F: OutputFormat<Output = String>,
649    {
650        if let Some(groups) = self
651            .style
652            .bibliography
653            .as_ref()
654            .and_then(|bibliography| bibliography.groups.as_ref())
655        {
656            let id_stubs = self.sorted_id_stubs();
657            let selected = if restrict_to_cited {
658                let cited = self.cited_ids.borrow();
659                id_stubs
660                    .iter()
661                    .filter(|e| cited.contains(&e.id))
662                    .map(|e| e.id.clone())
663                    .collect::<HashSet<_>>()
664            } else {
665                id_stubs
666                    .iter()
667                    .map(|e| e.id.clone())
668                    .collect::<HashSet<_>>()
669            };
670            return self.render_with_custom_groups_filtered::<F>(
671                &id_stubs,
672                groups,
673                &selected,
674                annotations,
675                annotation_style,
676            );
677        }
678
679        let bibliography_options = self.get_bibliography_options();
680        if let Some(partitioning) = bibliography_options.sort_partitioning.as_ref()
681            && crate::sort_partitioning::should_render_sections(partitioning)
682        {
683            self.initialize_numeric_bibliography_numbers();
684            let mut refs: Vec<&Reference> = self.bibliography.values().collect();
685            if restrict_to_cited {
686                let cited = self.cited_ids.borrow();
687                refs.retain(|r| r.id().as_deref().is_some_and(|id| cited.contains(id)));
688            }
689            let sorted_refs = self.sort_references(refs);
690            return self.render_with_partition_sections::<F>(
691                sorted_refs,
692                partitioning,
693                annotations,
694                annotation_style,
695            );
696        }
697
698        let all_entries = self.process_references().bibliography;
699        self.render_with_legacy_grouping::<F>(
700            &self.merge_compound_entries::<F>(all_entries),
701            annotations,
702            annotation_style,
703        )
704    }
705
706    /// Render frontmatter-defined bibliography groups for document output.
707    ///
708    /// This uses the same pre-merge selector semantics as
709    /// [`Self::render_grouped_bibliography_with_format`].
710    pub(crate) fn render_document_bibliography_groups<F>(
711        &self,
712        groups: &[BibliographyGroup],
713    ) -> String
714    where
715        F: OutputFormat<Output = String>,
716    {
717        let all_entries = self.sorted_id_stubs();
718        self.render_with_custom_groups::<F>(&all_entries, groups)
719    }
720
721    /// Render one bibliography block for document output.
722    ///
723    /// Returns heading and body separately so callers can insert headings
724    /// in their own output format.
725    pub(crate) fn render_document_bibliography_block<F>(
726        &self,
727        group: &BibliographyGroup,
728        assigned: &mut HashSet<String>,
729    ) -> RenderedBibliographyGroup
730    where
731        F: OutputFormat<Output = String>,
732    {
733        let mut headingless = group.clone();
734        let heading = headingless
735            .heading
736            .take()
737            .and_then(|group_heading| self.resolve_group_heading(&group_heading));
738        let body = self.render_bibliography_for_group::<F>(&headingless, None, None, assigned);
739
740        RenderedBibliographyGroup { heading, body }
741    }
742
743    pub(super) fn extract_metadata(&self, reference: &Reference) -> ProcEntryMetadata {
744        let bibliography_config = self.get_bibliography_config();
745        let options = RenderOptions {
746            config: &bibliography_config,
747            bibliography_config: Some(self.get_bibliography_options().into_owned()),
748            locale: &self.locale,
749            context: RenderContext::Bibliography,
750            mode: citum_schema::citation::CitationMode::NonIntegral,
751            suppress_author: false,
752            locator_raw: None,
753            ref_type: None,
754            show_semantics: self.show_semantics,
755            current_template_index: None,
756            abbreviation_map: self.abbreviation_map.as_ref(),
757        };
758
759        let ml = bibliography_config.multilingual.as_ref();
760        let preferred_transliteration = ml.and_then(|m| m.preferred_transliteration.as_deref());
761        let preferred_script = ml.and_then(|m| m.preferred_script.as_ref());
762
763        ProcEntryMetadata {
764            author: reference.author().map(|author| {
765                let names = resolve_multilingual_name(
766                    &author,
767                    ml.and_then(|m| m.name_mode.as_ref()),
768                    preferred_transliteration,
769                    preferred_script,
770                    &self.locale.locale,
771                );
772                format_contributors_short(&names, &options)
773            }),
774            year: reference
775                .csl_issued_date()
776                .map(|issued| issued.year().clone()),
777            title: reference.title().map(|title| {
778                use citum_schema::reference::types::{MultilingualString, Title};
779                match &title {
780                    Title::Multilingual(m) => resolve_multilingual_string(
781                        &MultilingualString::Complex(m.clone()),
782                        ml.and_then(|ml| ml.title_mode.as_ref()),
783                        preferred_transliteration,
784                        preferred_script,
785                        &self.locale.locale,
786                    ),
787                    _ => title.to_string(),
788                }
789            }),
790        }
791    }
792
793    fn render_group_heading<F>(&self, heading: &str) -> String
794    where
795        F: OutputFormat<Output = String>,
796    {
797        if std::any::type_name::<F>() == std::any::type_name::<crate::render::html::Html>() {
798            return format!("<h2>{heading}</h2>\n\n");
799        }
800
801        format!("# {heading}\n\n")
802    }
803}