Skip to main content

domtree/serialize/
text.rs

1//! Readable plain text (`to_text`): roughly what a person reads on the page.
2//!
3//! Not `textContent`: hidden elements (`<head>`, `<script>`, `<style>`, …) are
4//! dropped, whitespace collapses as in a browser, block elements start a new
5//! line and paragraphs are separated by one blank line, `<br>` breaks the line,
6//! table cells are tab-separated, and `<pre>`/`<textarea>` text is kept
7//! verbatim. One iterative pass over the tree (`walk`), so depth is no concern.
8
9use alloc::string::String;
10
11use super::{is_space, push_verbatim, skip_newline, walk};
12use crate::node::NodeKind;
13use crate::tags::{self, Display};
14use crate::tree::{Dom, NodeRef};
15
16impl Dom {
17    /// The document as readable plain text.
18    ///
19    /// Unlike [`NodeRef::text_content`], this is what a reader sees: no
20    /// `<head>`/`<script>`/`<style>` content, whitespace collapsed, a new line
21    /// for each block element, a blank line between paragraphs, `<br>` as a line
22    /// break, tab-separated table cells and `<pre>` kept verbatim. The result
23    /// has no trailing whitespace on lines, no runs of blank lines, and no
24    /// leading or trailing blank lines.
25    ///
26    /// ```
27    /// let dom = domtree::parse(
28    ///     "<head><title>T</title></head><h1>Hello</h1>\
29    ///      <p>Some   <b>bold</b>\n text.<br>Next line.</p><script>x()</script>",
30    /// );
31    /// assert_eq!(dom.to_text(), "Hello\n\nSome bold text.\nNext line.");
32    /// ```
33    pub fn to_text(&self) -> String {
34        let mut text = Text::default();
35        for root in self.roots() {
36            walk(root, |node, enter| text.visit(node, enter));
37        }
38        text.finish()
39    }
40}
41
42impl NodeRef<'_> {
43    /// This node's subtree as readable plain text; see [`Dom::to_text`].
44    ///
45    /// ```
46    /// let dom = domtree::parse("<ul><li>One</li><li>Two <i>too</i></li></ul>");
47    /// assert_eq!(dom.root().unwrap().to_text(), "One\nTwo too");
48    /// ```
49    pub fn to_text(&self) -> String {
50        let mut text = Text::default();
51        walk(*self, |node, enter| text.visit(node, enter));
52        text.finish()
53    }
54}
55
56#[derive(Default)]
57struct Text {
58    out: String,
59    /// Newlines owed before the next visible text (at most 2: one blank line).
60    breaks: u8,
61    /// Tabs owed before the next visible text (table cells).
62    tabs: u32,
63    /// A collapsed run of whitespace is pending.
64    space: bool,
65    /// Depth of `<pre>`/`<textarea>`, inside which whitespace is kept.
66    pre: u32,
67    /// The next verbatim text opens a `<pre>`: drop one leading newline.
68    fresh: bool,
69    /// Depth of lists; a nested list has no blank lines around it.
70    lists: u32,
71    /// At the start of a table cell, whose separator (tab or row break) is
72    /// already owed: a block opening inside it adds no line break.
73    cell: bool,
74}
75
76impl Text {
77    fn visit(&mut self, node: NodeRef<'_>, enter: bool) -> bool {
78        let tag = match node.kind() {
79            NodeKind::Element { tag } => tag,
80            NodeKind::Text(t) if self.pre > 0 => {
81                self.verbatim(t);
82                return false;
83            }
84            NodeKind::Text(t) => {
85                self.collapse(t);
86                return false;
87            }
88            NodeKind::Comment(_) | NodeKind::Doctype(_) => return false,
89        };
90        let list = matches!(tag, "ul" | "ol" | "menu" | "dl");
91        if list && !enter {
92            self.lists = self.lists.saturating_sub(1);
93        }
94        match tags::display(tag) {
95            Display::Hidden => return false,
96            Display::Paragraph if list && self.lists > 0 => self.newlines(1),
97            Display::Paragraph => self.newlines(2),
98            Display::Block => self.newlines(1),
99            Display::Inline => {}
100        }
101        if list && enter {
102            self.lists += 1;
103        }
104        match tag {
105            "br" if enter && self.pre > 0 => {
106                self.fresh = false;
107                self.content();
108                self.out.push('\n');
109            }
110            "br" if enter => self.breaks = (self.breaks + 1).min(2),
111            "pre" | "textarea" if enter => {
112                self.pre += 1;
113                self.fresh = true;
114            }
115            "pre" | "textarea" => {
116                self.pre = self.pre.saturating_sub(1);
117                // Trailing blank lines inside a `<pre>` are not content.
118                let end = self.out.trim_end().len();
119                self.out.truncate(end);
120            }
121            "td" | "th" if enter => {
122                if is_cell(node.prev_element_sibling()) {
123                    self.breaks = 0;
124                    self.tabs += 1;
125                }
126                self.cell = true;
127            }
128            "td" | "th" => {
129                // Breaks owed after the cell's own text (a closing `<p>`, a
130                // trailing `<br>`) end with the cell: the next cell or row
131                // decides what follows. An empty cell keeps its row's break.
132                if !self.cell {
133                    self.breaks = 0;
134                }
135                self.cell = false;
136            }
137            _ => {}
138        }
139        true
140    }
141
142    fn newlines(&mut self, n: u8) {
143        if !self.cell {
144            self.breaks = self.breaks.max(n);
145            self.tabs = 0;
146        }
147    }
148
149    fn collapse(&mut self, text: &str) {
150        for (i, word) in text.split(is_space).enumerate() {
151            if i > 0 {
152                self.space = true;
153            }
154            if !word.is_empty() {
155                self.content();
156                self.out.push_str(word);
157            }
158        }
159    }
160
161    fn verbatim(&mut self, text: &str) {
162        let text = if core::mem::take(&mut self.fresh) {
163            skip_newline(text)
164        } else {
165            text
166        };
167        if !text.is_empty() {
168            self.content();
169            push_verbatim(&mut self.out, text);
170        }
171    }
172
173    /// Settle the pending separators before visible text.
174    fn content(&mut self) {
175        if self.out.is_empty() {
176            // Nothing to separate from: leading separators are dropped.
177        } else if self.breaks > 0 {
178            // Newlines already there (from `<pre>` text) count; look no further
179            // back than needed, or long blank runs would make this quadratic.
180            let want = usize::from(self.breaks);
181            let have = self
182                .out
183                .bytes()
184                .rev()
185                .take(want)
186                .take_while(|&b| b == b'\n')
187                .count();
188            for _ in have..want {
189                self.out.push('\n');
190            }
191            for _ in 0..self.tabs {
192                self.out.push('\t');
193            }
194        } else if self.tabs > 0 {
195            for _ in 0..self.tabs {
196                self.out.push('\t');
197            }
198        } else if self.space && !self.out.ends_with('\n') {
199            self.out.push(' ');
200        }
201        self.breaks = 0;
202        self.tabs = 0;
203        self.space = false;
204        self.cell = false;
205    }
206
207    fn finish(mut self) -> String {
208        let end = self.out.trim_end().len();
209        self.out.truncate(end);
210        // Only `<pre>` text can open with blank lines; drop them but keep the
211        // first line's indentation.
212        let first = self.out.find(|c: char| !c.is_whitespace()).unwrap_or(0);
213        let start = self
214            .out
215            .get(..first)
216            .and_then(|lead| lead.rfind('\n'))
217            .map_or(0, |i| i + 1);
218        self.out.drain(..start);
219        self.out
220    }
221}
222
223fn is_cell(node: Option<NodeRef<'_>>) -> bool {
224    matches!(node.and_then(|n| n.tag_name()), Some("td" | "th"))
225}