Skip to main content

html_outliner/
outline.rs

1use std::fmt::{self, Display, Formatter, Write};
2
3use crate::OutlineStructure;
4
5const EXTRA_INDENT_WIDTH: usize = 1;
6
7// The level is present only for heading-created outlines, while sectioning-content outlines keep their place without changing heading-level nesting.
8type StackEntry = (Option<u8>, Outline);
9
10/// A displayable HTML outline.
11#[derive(Debug, Clone, Default)]
12pub struct Outline {
13    /// The visible text for this outline item.
14    pub text:         Option<String>,
15    /// The nested outline items under this item.
16    pub sub_outlines: Vec<Outline>,
17}
18
19impl Outline {
20    /// Parses HTML and builds a displayable outline, ignoring any nodes deeper than `max_depth`.
21    #[inline]
22    pub fn parse_html<S: AsRef<str>>(html: S, max_depth: usize) -> Outline {
23        OutlineStructure::parse_html(html, max_depth).into()
24    }
25}
26
27/// Converts parsed HTML into a displayable outline.
28impl From<OutlineStructure> for Outline {
29    #[inline]
30    fn from(os: OutlineStructure) -> Self {
31        if os.sectioning_type.is_heading() {
32            Outline {
33                text:         os.heading.map(|heading| heading.into()),
34                sub_outlines: Vec::new(),
35            }
36        } else {
37            let mut sub_outlines = Vec::new();
38
39            let mut stack: Vec<StackEntry> = vec![];
40
41            for sub_os in os.sub_outline_structures.into_iter().rev() {
42                if sub_os.sectioning_type.is_heading() {
43                    let heading = sub_os.heading.unwrap();
44                    let heading_level = heading.get_start_level();
45                    let heading_text = heading.into();
46
47                    push_heading_outline(&mut stack, heading_level, heading_text);
48                } else {
49                    stack.push((None, sub_os.into()));
50                }
51            }
52
53            let text = if let Some(heading) = os.heading {
54                let heading_level = heading.get_start_level();
55                let heading_text = heading.into();
56
57                let need_flatten = stack
58                    .iter()
59                    .any(|(level, _)| level.is_some_and(|level| level >= heading_level));
60
61                if need_flatten {
62                    push_heading_outline(&mut stack, heading_level, heading_text);
63
64                    None
65                } else {
66                    Some(heading_text)
67                }
68            } else if os.sectioning_type.is_sectioning_content_type() {
69                Some(format!("Untitled {}", os.sectioning_type.as_str()))
70            } else {
71                None
72            };
73
74            while let Some(sub_os) = stack.pop() {
75                sub_outlines.push(sub_os.1);
76            }
77
78            Outline {
79                text,
80                sub_outlines,
81            }
82        }
83    }
84}
85
86fn push_heading_outline(stack: &mut Vec<StackEntry>, heading_level: u8, heading_text: String) {
87    let mut outline = Outline {
88        text: Some(heading_text), sub_outlines: Vec::new()
89    };
90
91    // Only heading-created outlines can be absorbed by a new heading, and sectioning-content outlines must stay in document order.
92    while let Some((level, _)) = stack.last() {
93        if level.is_some_and(|level| level <= heading_level) {
94            break;
95        }
96
97        outline.sub_outlines.push(stack.pop().unwrap().1);
98    }
99
100    stack.push((Some(heading_level), outline));
101}
102
103impl Display for Outline {
104    #[inline]
105    fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), fmt::Error> {
106        format(f, self, 1, 0)
107    }
108}
109
110fn format(
111    f: &mut Formatter<'_>,
112    outline: &Outline,
113    number: usize,
114    indent: usize,
115) -> Result<(), fmt::Error> {
116    let new_ident = if let Some(text) = outline.text.as_ref() {
117        if indent > 0 {
118            f.write_char('\n')?;
119
120            for _ in 0..indent {
121                f.write_char(' ')?;
122            }
123        } else if number > 1 {
124            f.write_char('\n')?;
125        }
126
127        f.write_fmt(format_args!("{}. ", number))?;
128        f.write_str(text.as_str())?;
129
130        indent + count_digit(outline.sub_outlines.len()) + 2 + EXTRA_INDENT_WIDTH
131    } else {
132        indent
133    };
134
135    for (i, sub_outline) in outline.sub_outlines.iter().enumerate() {
136        format(f, sub_outline, i + 1, new_ident)?;
137    }
138
139    Ok(())
140}
141
142#[inline]
143fn count_digit(n: usize) -> usize {
144    // Integer digit counting avoids the platform-dependent rounding of floating-point log10 and needs no float unit.
145    n.checked_ilog10().unwrap_or(0) as usize + 1
146}