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}