dom_query 0.29.0

HTML querying and manipulation with CSS selectors
Documentation
use std::cell::Ref;

use html5ever::{QualName, local_name};

use crate::TreeNodeOps;

use super::SerializeOp;
use super::{NodeData, NodeRef, child_nodes};

pub(crate) fn format_text(root_node: &NodeRef, include_node: bool) -> String {
    let id = root_node.id;
    let nodes = root_node.tree.nodes.borrow();
    let mut ops = if include_node {
        vec![SerializeOp::Open(id)]
    } else {
        child_nodes(Ref::clone(&nodes), &id, true)
            .map(SerializeOp::Open)
            .collect()
    };

    let mut text = String::new();

    while let Some(op) = ops.pop() {
        match op {
            SerializeOp::Open(id) => {
                let Some(node) = nodes.get(id.value()) else {
                    continue;
                };

                match node.data {
                    NodeData::Text { ref contents } => {
                        push_normalized_text(&mut text, contents.as_ref());
                    }
                    NodeData::Element(ref e) => {
                        if !(text.is_empty() || text.ends_with("\n\n"))
                            && elem_require_linebreak(&e.name)
                        {
                            text.push('\n');
                        }

                        ops.push(SerializeOp::Close(&e.name));

                        if matches!(e.name.local, local_name!("pre")) {
                            text.push_str(&TreeNodeOps::text_of(Ref::clone(&nodes), id));
                            continue;
                        }

                        ops.extend(
                            child_nodes(Ref::clone(&nodes), &id, true).map(SerializeOp::Open),
                        );
                    }
                    _ => {}
                }
            }
            SerializeOp::Close(name) => {
                adjust_element_offset(&mut text, name);
            }
        }
    }
    if !include_node {
        text.truncate(text.trim_end().len());
    }
    text
}

fn push_normalized_text(text: &mut String, new_text: &str) {
    let follows_newline = text.ends_with(['\n', ' ']) || text.is_empty();
    let push_start_whitespace = !follows_newline && new_text.starts_with(char::is_whitespace);
    let push_end_whitespace = new_text.ends_with(char::is_whitespace);

    let mut result = String::with_capacity(new_text.len());
    let mut iter = new_text.split_whitespace();

    if let Some(first) = iter.next() {
        if push_start_whitespace {
            result.push(' ');
        }
        result.push_str(first);
        for word in iter {
            result.push(' ');
            result.push_str(word);
        }
    }
    if result.is_empty() && follows_newline {
        return;
    }

    text.push_str(&result);

    if push_end_whitespace && !text.ends_with(char::is_whitespace) {
        text.push(' ');
    }
}

fn trim_trailing_space(s: &mut String) {
    s.truncate(s.trim_end_matches(' ').len());
}

fn adjust_element_offset(text: &mut String, name: &QualName) {
    if text.is_empty() || text.ends_with("\n\n") {
        return;
    }

    if elem_require_linebreak(name) {
        trim_trailing_space(text);
        text.push_str("\n\n");
    } else if matches!(
        name.local,
        local_name!("br") | local_name!("hr") | local_name!("li") | local_name!("tr")
    ) {
        trim_trailing_space(text);
        text.push('\n');
    } else if matches!(name.local, local_name!("td") | local_name!("th"))
        && !text.ends_with(['\n', ' '])
    {
        text.push(' ');
    }
}

const fn elem_require_linebreak(name: &QualName) -> bool {
    matches!(
        name.local,
        local_name!("article")
            | local_name!("blockquote")
            | local_name!("section")
            | local_name!("div")
            | local_name!("p")
            | local_name!("pre")
            | local_name!("h1")
            | local_name!("h2")
            | local_name!("h3")
            | local_name!("h4")
            | local_name!("h5")
            | local_name!("h6")
            | local_name!("ul")
            | local_name!("ol")
            | local_name!("dl")
            | local_name!("table")
    )
}

#[cfg(test)]
mod tests {
    use crate::Document;

    #[test]
    fn test_trim_end_multi_byte() {
        // 
        let doc = Document::fragment("<pre>a&nbsp;\n</pre>");
        assert_eq!(&doc.formatted_text(), "a");
        let doc = Document::fragment("<pre>a\u{3000}</pre>");
        assert_eq!(&doc.formatted_text(), "a");
    }
}