Skip to main content

max / pter

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