html-outliner 0.2.0

Outline HTML documents for better SEO.
Documentation
use std::fmt::{self, Display, Formatter, Write};

use crate::OutlineStructure;

const EXTRA_INDENT_WIDTH: usize = 1;

// The level is present only for heading-created outlines, while sectioning-content outlines keep their place without changing heading-level nesting.
type StackEntry = (Option<u8>, Outline);

/// A displayable HTML outline.
#[derive(Debug, Clone, Default)]
pub struct Outline {
    /// The visible text for this outline item.
    pub text:         Option<String>,
    /// The nested outline items under this item.
    pub sub_outlines: Vec<Outline>,
}

impl Outline {
    /// Parses HTML and builds a displayable outline, ignoring any nodes deeper than `max_depth`.
    #[inline]
    pub fn parse_html<S: AsRef<str>>(html: S, max_depth: usize) -> Outline {
        OutlineStructure::parse_html(html, max_depth).into()
    }
}

/// Converts parsed HTML into a displayable outline.
impl From<OutlineStructure> for Outline {
    #[inline]
    fn from(os: OutlineStructure) -> Self {
        if os.sectioning_type.is_heading() {
            Outline {
                text:         os.heading.map(|heading| heading.into()),
                sub_outlines: Vec::new(),
            }
        } else {
            let mut sub_outlines = Vec::new();

            let mut stack: Vec<StackEntry> = vec![];

            for sub_os in os.sub_outline_structures.into_iter().rev() {
                if sub_os.sectioning_type.is_heading() {
                    let heading = sub_os.heading.unwrap();
                    let heading_level = heading.get_start_level();
                    let heading_text = heading.into();

                    push_heading_outline(&mut stack, heading_level, heading_text);
                } else {
                    stack.push((None, sub_os.into()));
                }
            }

            let text = if let Some(heading) = os.heading {
                let heading_level = heading.get_start_level();
                let heading_text = heading.into();

                let need_flatten = stack
                    .iter()
                    .any(|(level, _)| level.is_some_and(|level| level >= heading_level));

                if need_flatten {
                    push_heading_outline(&mut stack, heading_level, heading_text);

                    None
                } else {
                    Some(heading_text)
                }
            } else if os.sectioning_type.is_sectioning_content_type() {
                Some(format!("Untitled {}", os.sectioning_type.as_str()))
            } else {
                None
            };

            while let Some(sub_os) = stack.pop() {
                sub_outlines.push(sub_os.1);
            }

            Outline {
                text,
                sub_outlines,
            }
        }
    }
}

fn push_heading_outline(stack: &mut Vec<StackEntry>, heading_level: u8, heading_text: String) {
    let mut outline = Outline {
        text: Some(heading_text), sub_outlines: Vec::new()
    };

    // Only heading-created outlines can be absorbed by a new heading, and sectioning-content outlines must stay in document order.
    while let Some((level, _)) = stack.last() {
        if level.is_some_and(|level| level <= heading_level) {
            break;
        }

        outline.sub_outlines.push(stack.pop().unwrap().1);
    }

    stack.push((Some(heading_level), outline));
}

impl Display for Outline {
    #[inline]
    fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), fmt::Error> {
        format(f, self, 1, 0)
    }
}

fn format(
    f: &mut Formatter<'_>,
    outline: &Outline,
    number: usize,
    indent: usize,
) -> Result<(), fmt::Error> {
    let new_ident = if let Some(text) = outline.text.as_ref() {
        if indent > 0 {
            f.write_char('\n')?;

            for _ in 0..indent {
                f.write_char(' ')?;
            }
        } else if number > 1 {
            f.write_char('\n')?;
        }

        f.write_fmt(format_args!("{}. ", number))?;
        f.write_str(text.as_str())?;

        indent + count_digit(outline.sub_outlines.len()) + 2 + EXTRA_INDENT_WIDTH
    } else {
        indent
    };

    for (i, sub_outline) in outline.sub_outlines.iter().enumerate() {
        format(f, sub_outline, i + 1, new_ident)?;
    }

    Ok(())
}

#[inline]
fn count_digit(n: usize) -> usize {
    // Integer digit counting avoids the platform-dependent rounding of floating-point log10 and needs no float unit.
    n.checked_ilog10().unwrap_or(0) as usize + 1
}