Skip to main content

max / pter

11.9 KB · 400 lines History Blame Raw
1 use scraper::node::Element;
2
3 /// What kind of markdown wrapper an element produces.
4 pub enum ElementAction {
5 /// Skip this element and all its children entirely.
6 Skip,
7 /// Render children only, no wrapper (transparent element).
8 Transparent,
9 /// Block element with specific rendering.
10 Block(BlockKind),
11 /// Inline element with specific rendering.
12 Inline(InlineKind),
13 }
14
15 pub enum BlockKind {
16 Paragraph,
17 Heading(u8),
18 Blockquote,
19 UnorderedList,
20 OrderedList,
21 ListItem,
22 PreFormatted,
23 HorizontalRule,
24 Table,
25 Div,
26 }
27
28 pub enum InlineKind {
29 Bold,
30 Italic,
31 Strikethrough,
32 Code,
33 Link,
34 Image,
35 LineBreak,
36 Superscript,
37 Subscript,
38 }
39
40 /// Classify an HTML element into the action pter should take.
41 pub fn classify(el: &Element) -> ElementAction {
42 match el.name() {
43 // Skip entirely
44 "script" | "style" | "head" | "meta" | "link" | "title" | "noscript" => {
45 ElementAction::Skip
46 }
47
48 // Block elements
49 "p" => ElementAction::Block(BlockKind::Paragraph),
50 "h1" => ElementAction::Block(BlockKind::Heading(1)),
51 "h2" => ElementAction::Block(BlockKind::Heading(2)),
52 "h3" => ElementAction::Block(BlockKind::Heading(3)),
53 "h4" => ElementAction::Block(BlockKind::Heading(4)),
54 "h5" => ElementAction::Block(BlockKind::Heading(5)),
55 "h6" => ElementAction::Block(BlockKind::Heading(6)),
56 "blockquote" => ElementAction::Block(BlockKind::Blockquote),
57 "ul" | "menu" => ElementAction::Block(BlockKind::UnorderedList),
58 "ol" => ElementAction::Block(BlockKind::OrderedList),
59 "li" => ElementAction::Block(BlockKind::ListItem),
60 "pre" => ElementAction::Block(BlockKind::PreFormatted),
61 "hr" => ElementAction::Block(BlockKind::HorizontalRule),
62 "table" => ElementAction::Block(BlockKind::Table),
63 // Table sub-elements are handled by the Table block handler, not individually
64 "thead" | "tbody" | "tfoot" | "tr" | "td" | "th" | "caption" | "colgroup" | "col" => {
65 ElementAction::Transparent
66 }
67 "div" | "section" | "article" | "main" | "header" | "footer" | "nav" | "aside"
68 | "figure" | "figcaption" | "details" | "summary" => {
69 ElementAction::Block(BlockKind::Div)
70 }
71
72 // Inline elements
73 "strong" | "b" => ElementAction::Inline(InlineKind::Bold),
74 "em" | "i" => ElementAction::Inline(InlineKind::Italic),
75 "del" | "s" | "strike" => ElementAction::Inline(InlineKind::Strikethrough),
76 "code" | "tt" => ElementAction::Inline(InlineKind::Code),
77 "a" => ElementAction::Inline(InlineKind::Link),
78 "img" => ElementAction::Inline(InlineKind::Image),
79 "br" => ElementAction::Inline(InlineKind::LineBreak),
80 "sup" => ElementAction::Inline(InlineKind::Superscript),
81 "sub" => ElementAction::Inline(InlineKind::Subscript),
82
83 // Everything else: transparent (render children)
84 _ => ElementAction::Transparent,
85 }
86 }
87
88 /// Check if an <img> element is a tracking pixel.
89 /// Returns true if it should be skipped.
90 pub fn is_tracking_pixel(el: &Element) -> bool {
91 let width = el.attr("width");
92 let height = el.attr("height");
93
94 // 1x1 or 0x0 images
95 if matches!(width, Some("1" | "0")) || matches!(height, Some("1" | "0")) {
96 return true;
97 }
98
99 // No src attribute
100 let Some(src) = el.attr("src") else {
101 return true;
102 };
103
104 // Empty or data:image/gif (common transparent pixel)
105 if src.is_empty() {
106 return true;
107 }
108 if src.starts_with("data:image/gif;base64,R0lGOD") {
109 return true;
110 }
111
112 // Check inline style for tiny dimensions
113 if let Some(style) = el.attr("style") {
114 let style_lower = style.to_lowercase();
115 if style_lower.contains("width:1px")
116 || style_lower.contains("width: 1px")
117 || style_lower.contains("width:0")
118 || style_lower.contains("height:1px")
119 || style_lower.contains("height: 1px")
120 || style_lower.contains("height:0")
121 || style_lower.contains("display:none")
122 || style_lower.contains("display: none")
123 {
124 return true;
125 }
126 }
127
128 false
129 }
130
131 /// Check if an element is hidden via inline style.
132 ///
133 /// Catches display:none, visibility:hidden, and spacer tricks
134 /// like font-size:0 or line-height:0 (commonly used in email templates).
135 pub fn is_hidden(el: &Element) -> bool {
136 if let Some(style) = el.attr("style") {
137 let s = style.to_lowercase();
138 if s.contains("display:none")
139 || s.contains("display: none")
140 || s.contains("visibility:hidden")
141 || s.contains("visibility: hidden")
142 || s.contains("font-size:0")
143 || s.contains("font-size: 0")
144 || s.contains("line-height:0")
145 || s.contains("line-height: 0")
146 || (s.contains("height:0") && s.contains("overflow:hidden"))
147 || (s.contains("height: 0") && s.contains("overflow: hidden"))
148 || s.contains("max-height:0")
149 || s.contains("max-height: 0")
150 {
151 return true;
152 }
153 }
154 false
155 }
156
157 #[cfg(test)]
158 mod tests {
159 use super::*;
160 use scraper::{Html, Selector};
161
162 fn classify_tag(tag: &str) -> ElementAction {
163 let html = format!("<{tag}></{tag}>");
164 let doc = Html::parse_fragment(&html);
165 let sel = Selector::parse(tag).unwrap();
166 let el = doc.select(&sel).next().unwrap();
167 classify(el.value())
168 }
169
170 fn img_is_pixel(attrs: &str) -> bool {
171 let html = format!("<div><img {attrs} ></div>");
172 let doc = Html::parse_fragment(&html);
173 let sel = Selector::parse("img").unwrap();
174 let el = doc.select(&sel).next().unwrap();
175 is_tracking_pixel(el.value())
176 }
177
178 fn div_is_hidden(attrs: &str) -> bool {
179 let html = format!("<div {attrs}></div>");
180 let doc = Html::parse_fragment(&html);
181 let sel = Selector::parse("div").unwrap();
182 let el = doc.select(&sel).next().unwrap();
183 is_hidden(el.value())
184 }
185
186 // -- classify: heading levels (h4/h5/h6 arms) --
187 // Without these arms, the elements fall through to `_ => Transparent`,
188 // which differs from `Block(Heading(n))`. Tests catch the deletion.
189
190 #[test]
191 fn classify_h1_is_heading_1() {
192 assert!(matches!(classify_tag("h1"), ElementAction::Block(BlockKind::Heading(1))));
193 }
194
195 #[test]
196 fn classify_h4_is_heading_4() {
197 assert!(matches!(classify_tag("h4"), ElementAction::Block(BlockKind::Heading(4))));
198 }
199
200 #[test]
201 fn classify_h5_is_heading_5() {
202 assert!(matches!(classify_tag("h5"), ElementAction::Block(BlockKind::Heading(5))));
203 }
204
205 #[test]
206 fn classify_h6_is_heading_6() {
207 assert!(matches!(classify_tag("h6"), ElementAction::Block(BlockKind::Heading(6))));
208 }
209
210 #[test]
211 fn classify_script_is_skip() {
212 assert!(matches!(classify_tag("script"), ElementAction::Skip));
213 }
214
215 #[test]
216 fn classify_table_is_block_table() {
217 assert!(matches!(classify_tag("table"), ElementAction::Block(BlockKind::Table)));
218 }
219
220 #[test]
221 fn classify_strong_is_inline_bold() {
222 assert!(matches!(classify_tag("strong"), ElementAction::Inline(InlineKind::Bold)));
223 }
224
225 // -- is_tracking_pixel: each || arm needs its own positive test --
226
227 #[test]
228 fn pixel_width_1_only() {
229 assert!(img_is_pixel(r#"src="x" width="1" height="100""#));
230 }
231
232 #[test]
233 fn pixel_height_1_only() {
234 // Catches L95 mutating || to && (width OR height; not AND)
235 assert!(img_is_pixel(r#"src="x" width="100" height="1""#));
236 }
237
238 #[test]
239 fn pixel_width_0_only() {
240 assert!(img_is_pixel(r#"src="x" width="0" height="100""#));
241 }
242
243 #[test]
244 fn pixel_no_src_is_pixel() {
245 assert!(img_is_pixel(r#"width="100" height="100""#));
246 }
247
248 #[test]
249 fn pixel_empty_src_is_pixel() {
250 assert!(img_is_pixel(r#"src="" width="100" height="100""#));
251 }
252
253 #[test]
254 fn pixel_transparent_gif_data_uri_is_pixel() {
255 assert!(img_is_pixel(
256 r#"src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" width="100" height="100""#
257 ));
258 }
259
260 // Each `||` arm in the style chain (L115–122) — each needs its own input
261 // that triggers ONLY that arm. Catches `replace || with &&` mutants.
262
263 #[test]
264 fn pixel_style_width_1px() {
265 assert!(img_is_pixel(r#"src="x" style="width:1px""#));
266 }
267
268 #[test]
269 fn pixel_style_width_space_1px() {
270 assert!(img_is_pixel(r#"src="x" style="width: 1px""#));
271 }
272
273 #[test]
274 fn pixel_style_width_0() {
275 assert!(img_is_pixel(r#"src="x" style="width:0""#));
276 }
277
278 #[test]
279 fn pixel_style_height_1px() {
280 assert!(img_is_pixel(r#"src="x" style="height:1px""#));
281 }
282
283 #[test]
284 fn pixel_style_height_space_1px() {
285 assert!(img_is_pixel(r#"src="x" style="height: 1px""#));
286 }
287
288 #[test]
289 fn pixel_style_height_0() {
290 assert!(img_is_pixel(r#"src="x" style="height:0""#));
291 }
292
293 #[test]
294 fn pixel_style_display_none() {
295 assert!(img_is_pixel(r#"src="x" style="display:none""#));
296 }
297
298 #[test]
299 fn pixel_style_display_space_none() {
300 assert!(img_is_pixel(r#"src="x" style="display: none""#));
301 }
302
303 #[test]
304 fn pixel_normal_image_is_not_pixel() {
305 assert!(!img_is_pixel(
306 r#"src="https://example.com/cat.jpg" width="500" height="300""#
307 ));
308 }
309
310 // -- is_hidden: each || arm with its own targeted test --
311
312 #[test]
313 fn hidden_display_none() {
314 assert!(div_is_hidden(r#"style="display:none""#));
315 }
316
317 #[test]
318 fn hidden_display_space_none() {
319 assert!(div_is_hidden(r#"style="display: none""#));
320 }
321
322 #[test]
323 fn hidden_visibility_hidden() {
324 assert!(div_is_hidden(r#"style="visibility:hidden""#));
325 }
326
327 #[test]
328 fn hidden_visibility_space_hidden() {
329 assert!(div_is_hidden(r#"style="visibility: hidden""#));
330 }
331
332 #[test]
333 fn hidden_font_size_0() {
334 assert!(div_is_hidden(r#"style="font-size:0""#));
335 }
336
337 #[test]
338 fn hidden_font_size_space_0() {
339 assert!(div_is_hidden(r#"style="font-size: 0""#));
340 }
341
342 #[test]
343 fn hidden_line_height_0() {
344 assert!(div_is_hidden(r#"style="line-height:0""#));
345 }
346
347 #[test]
348 fn hidden_line_height_space_0() {
349 assert!(div_is_hidden(r#"style="line-height: 0""#));
350 }
351
352 // The (height:0 && overflow:hidden) and (height: 0 && overflow: hidden) arms
353 // need both halves present to fire. Tests cover each form, plus the negative
354 // case where height:0 alone is NOT hidden (catches && → || mutation on L146/147).
355
356 #[test]
357 fn hidden_height_0_with_overflow_no_spaces() {
358 assert!(div_is_hidden(r#"style="height:0;overflow:hidden""#));
359 }
360
361 #[test]
362 fn hidden_height_0_with_overflow_with_spaces() {
363 assert!(div_is_hidden(r#"style="height: 0;overflow: hidden""#));
364 }
365
366 #[test]
367 fn hidden_height_0_alone_is_not_hidden() {
368 // Catches the L146 && → || mutation: with ||, this would erroneously be hidden.
369 assert!(!div_is_hidden(r#"style="height:0""#));
370 }
371
372 #[test]
373 fn hidden_height_space_0_alone_is_not_hidden() {
374 // Same boundary check for the space variant — catches the && → || mutation
375 // on the `(height: 0 && overflow: hidden)` arm specifically.
376 assert!(!div_is_hidden(r#"style="height: 0""#));
377 }
378
379 #[test]
380 fn hidden_max_height_0() {
381 assert!(div_is_hidden(r#"style="max-height:0""#));
382 }
383
384 #[test]
385 fn hidden_max_height_space_0() {
386 assert!(div_is_hidden(r#"style="max-height: 0""#));
387 }
388
389 #[test]
390 fn hidden_no_signal_in_style() {
391 assert!(!div_is_hidden(r#"style="color:red;font-weight:bold""#));
392 }
393
394 #[test]
395 fn hidden_no_style_attr_is_not_hidden() {
396 assert!(!div_is_hidden(""));
397 }
398 }
399
400