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    ) -> String
557    where
558        F: OutputFormat<Output = String>,
559    {
560        let bibliography = self.sorted_id_stubs();
561        let fmt = F::default();
562        let cited_ids = self.cited_ids.borrow();
563        let evaluator = SelectorEvaluator::new(&cited_ids);
564        let sorter = GroupSorter::new(&self.locale);
565
566        let matching_refs =
567            self.collect_matching_group_refs(&bibliography, &HashSet::new(), &evaluator, group);
568
569        if matching_refs.is_empty() {
570            return fmt.finish(String::new());
571        }
572
573        let sorted_refs = if let Some(sort_spec) = &group.sort {
574            sorter.sort_references(matching_refs, &sort_spec.resolve())
575        } else {
576            matching_refs
577        };
578
579        let local_hints = self.build_group_local_hints(&sorted_refs, group);
580        let entries = self.merge_compound_entries::<F>(self.render_group_entries::<F>(
581            &bibliography,
582            sorted_refs,
583            group,
584            local_hints.as_ref(),
585        ));
586
587        fmt.finish(crate::render::refs_to_string_with_format::<F>(
588            entries,
589            annotations,
590            annotation_style,
591        ))
592    }
593
594    /// Render the bibliography with grouping for uncited (nocite) items.
595    ///
596    /// If `style.bibliography.groups` is defined, uses configurable grouping
597    /// with per-group sorting. Group selectors apply to individual references
598    /// before compound numeric rows are merged, so each rendered group only
599    /// includes the members that matched its selector. Otherwise, falls back to
600    /// hardcoded cited/uncited grouping for backward compatibility.
601    pub fn render_grouped_bibliography_with_format<F>(&self) -> String
602    where
603        F: OutputFormat<Output = String>,
604    {
605        self.render_grouped_bibliography_with_format_and_annotations::<F>(None, None)
606    }
607
608    /// Render the bibliography with grouping and annotations.
609    pub fn render_grouped_bibliography_with_format_and_annotations<F>(
610        &self,
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 = id_stubs
625                .iter()
626                .map(|e| e.id.clone())
627                .collect::<HashSet<_>>();
628            return self.render_with_custom_groups_filtered::<F>(
629                &id_stubs,
630                groups,
631                &selected,
632                annotations,
633                annotation_style,
634            );
635        }
636
637        let bibliography_options = self.get_bibliography_options();
638        if let Some(partitioning) = bibliography_options.sort_partitioning.as_ref()
639            && crate::sort_partitioning::should_render_sections(partitioning)
640        {
641            self.initialize_numeric_bibliography_numbers();
642            let sorted_refs = self.sort_references(self.bibliography.values().collect());
643            return self.render_with_partition_sections::<F>(
644                sorted_refs,
645                partitioning,
646                annotations,
647                annotation_style,
648            );
649        }
650
651        let all_entries = self.process_references().bibliography;
652        self.render_with_legacy_grouping::<F>(
653            &self.merge_compound_entries::<F>(all_entries),
654            annotations,
655            annotation_style,
656        )
657    }
658
659    /// Render frontmatter-defined bibliography groups for document output.
660    ///
661    /// This uses the same pre-merge selector semantics as
662    /// [`Self::render_grouped_bibliography_with_format`].
663    pub(crate) fn render_document_bibliography_groups<F>(
664        &self,
665        groups: &[BibliographyGroup],
666    ) -> String
667    where
668        F: OutputFormat<Output = String>,
669    {
670        let all_entries = self.sorted_id_stubs();
671        self.render_with_custom_groups::<F>(&all_entries, groups)
672    }
673
674    /// Render one bibliography block for document output.
675    ///
676    /// Returns heading and body separately so callers can insert headings
677    /// in their own output format.
678    pub(crate) fn render_document_bibliography_block<F>(
679        &self,
680        group: &BibliographyGroup,
681    ) -> RenderedBibliographyGroup
682    where
683        F: OutputFormat<Output = String>,
684    {
685        let mut headingless = group.clone();
686        let heading = headingless
687            .heading
688            .take()
689            .and_then(|group_heading| self.resolve_group_heading(&group_heading));
690        let body = self.render_bibliography_for_group::<F>(&headingless, None, None);
691
692        RenderedBibliographyGroup { heading, body }
693    }
694
695    pub(super) fn extract_metadata(&self, reference: &Reference) -> ProcEntryMetadata {
696        let bibliography_config = self.get_bibliography_config();
697        let options = RenderOptions {
698            config: &bibliography_config,
699            bibliography_config: Some(self.get_bibliography_options().into_owned()),
700            locale: &self.locale,
701            context: RenderContext::Bibliography,
702            mode: citum_schema::citation::CitationMode::NonIntegral,
703            suppress_author: false,
704            locator_raw: None,
705            ref_type: None,
706            show_semantics: self.show_semantics,
707            current_template_index: None,
708            abbreviation_map: self.abbreviation_map.as_ref(),
709        };
710
711        let ml = bibliography_config.multilingual.as_ref();
712        let preferred_transliteration = ml.and_then(|m| m.preferred_transliteration.as_deref());
713        let preferred_script = ml.and_then(|m| m.preferred_script.as_ref());
714
715        ProcEntryMetadata {
716            author: reference.author().map(|author| {
717                let names = resolve_multilingual_name(
718                    &author,
719                    ml.and_then(|m| m.name_mode.as_ref()),
720                    preferred_transliteration,
721                    preferred_script,
722                    &self.locale.locale,
723                );
724                format_contributors_short(&names, &options)
725            }),
726            year: reference
727                .csl_issued_date()
728                .map(|issued| issued.year().clone()),
729            title: reference.title().map(|title| {
730                use citum_schema::reference::types::{MultilingualString, Title};
731                match &title {
732                    Title::Multilingual(m) => resolve_multilingual_string(
733                        &MultilingualString::Complex(m.clone()),
734                        ml.and_then(|ml| ml.title_mode.as_ref()),
735                        preferred_transliteration,
736                        preferred_script,
737                        &self.locale.locale,
738                    ),
739                    _ => title.to_string(),
740                }
741            }),
742        }
743    }
744
745    fn render_group_heading<F>(&self, heading: &str) -> String
746    where
747        F: OutputFormat<Output = String>,
748    {
749        if std::any::type_name::<F>() == std::any::type_name::<crate::render::html::Html>() {
750            return format!("<h2>{heading}</h2>\n\n");
751        }
752
753        format!("# {heading}\n\n")
754    }
755}