Skip to main content

max / pter

24.9 KB · 823 lines History Blame Raw
1 use scraper::node::Node;
2 use scraper::{ElementRef, Html};
3
4 use crate::elements::{self, BlockKind, ElementAction, InlineKind};
5 use crate::replies;
6 use crate::tables;
7 use crate::whitespace;
8
9 /// Convert an HTML email body into readable markdown.
10 ///
11 /// This is the main entry point for pter. Pass in an HTML string
12 /// (just the body, not MIME structure) and get back clean markdown.
13 ///
14 /// ```
15 /// let md = pter::convert("<p>Hello <strong>world</strong></p>");
16 /// assert_eq!(md, "Hello **world**");
17 /// ```
18 pub fn convert(html: &str) -> String {
19 if html.is_empty() {
20 return String::new();
21 }
22
23 let document = Html::parse_document(html);
24 let mut ctx = Context::new();
25 walk_children(document.root_element(), &mut ctx);
26 whitespace::normalize(&ctx.output)
27 }
28
29 /// Maximum DOM nesting depth walked before children are dropped. html5ever
30 /// imposes no depth limit of its own, so a flat but deeply-nested body (e.g.
31 /// `<div>`×500k) would otherwise recurse `walk_children`/`handle_element` once
32 /// per level and overflow the stack — an uncatchable `abort()`. 512 is the
33 /// browser-conventional nesting cap; no legitimate email nests remotely that
34 /// deep, and 512 frames stay well within even a 2 MB worker stack.
35 const MAX_DEPTH: u32 = 512;
36
37 /// Conversion state threaded through the tree walk.
38 struct Context {
39 output: String,
40 /// Current DOM recursion depth (guards against pathological nesting).
41 depth: u32,
42 /// Current list nesting depth (for indentation).
43 list_depth: u32,
44 /// Whether we're inside a <pre> block (preserve whitespace).
45 in_pre: bool,
46 /// Whether we're inside an <a> tag (don't nest links).
47 in_link: bool,
48 /// Stack of list types for proper ordered/unordered rendering.
49 list_stack: Vec<ListType>,
50 }
51
52 #[derive(Clone, Copy)]
53 enum ListType {
54 Unordered,
55 Ordered(u32), // current item number
56 }
57
58 impl Context {
59 fn new() -> Self {
60 Self {
61 output: String::with_capacity(4096),
62 depth: 0,
63 list_depth: 0,
64 in_pre: false,
65 in_link: false,
66 list_stack: Vec::new(),
67 }
68 }
69
70 fn push(&mut self, s: &str) {
71 self.output.push_str(s);
72 }
73
74 fn push_char(&mut self, c: char) {
75 self.output.push(c);
76 }
77
78 fn ensure_blank_line(&mut self) {
79 let trimmed = self.output.trim_end_matches(' ');
80 if trimmed.is_empty() {
81 return;
82 }
83 if trimmed.ends_with("\n\n") {
84 return;
85 }
86 self.output.truncate(trimmed.len());
87 self.output.push_str("\n\n");
88 }
89
90 fn ensure_newline(&mut self) {
91 if !self.output.is_empty() && !self.output.ends_with('\n') {
92 self.output.push('\n');
93 }
94 }
95
96 /// Trim the blank lines a fence body picked up from block markup inside it.
97 ///
98 /// A `<pre>` holding block elements gets their separators too: each one
99 /// calls `ensure_blank_line`, and a `<pre><ul> </ul></pre>` whose list has
100 /// nothing to say leaves only those separators behind. The fence then
101 /// closes over three consecutive newlines, which is the one shape markdown
102 /// output is never supposed to carry.
103 ///
104 /// The body loses its trailing whitespace and its leading newlines, and
105 /// interior runs collapse to a single blank line, which is what a blank
106 /// line inside a code block means anyway. A first line's indent survives,
107 /// and an ordinary `<pre><code>` body has none of these to lose, so it
108 /// comes out as it went in.
109 fn settle_fence_body(&mut self, body_start: usize) {
110 // Trailing whitespace goes entirely, leading newlines with it: the
111 // opening fence already ended a line and the closing one starts its
112 // own. Leading spaces stay, since those are the first line's indent.
113 let trimmed = self.output[body_start..]
114 .trim_end()
115 .trim_start_matches('\n');
116 let mut settled = String::with_capacity(trimmed.len());
117 let mut runs = 0usize;
118 for c in trimmed.chars() {
119 if c == '\n' {
120 runs += 1;
121 if runs <= 2 {
122 settled.push(c);
123 }
124 } else {
125 runs = 0;
126 settled.push(c);
127 }
128 }
129 self.output.truncate(body_start);
130 self.output.push_str(&settled);
131 }
132
133 fn list_indent(&self) -> String {
134 if self.list_depth <= 1 {
135 return String::new();
136 }
137 " ".repeat((self.list_depth - 1) as usize)
138 }
139 }
140
141 /// Walk all children of a node, converting each to markdown.
142 fn walk_children(parent: ElementRef, ctx: &mut Context) {
143 // Bound recursion depth. Beyond MAX_DEPTH we stop descending — the content
144 // is either pathological or hostile, and dropping it is vastly preferable to
145 // a stack-overflow abort that would wedge the caller (a hostile email would
146 // otherwise re-crash on every subsequent sync).
147 if ctx.depth >= MAX_DEPTH {
148 return;
149 }
150 ctx.depth += 1;
151 for child in parent.children() {
152 match child.value() {
153 Node::Text(text) => {
154 handle_text(&text.text, ctx);
155 }
156 Node::Element(_) => {
157 if let Some(el_ref) = ElementRef::wrap(child) {
158 handle_element(el_ref, ctx);
159 }
160 }
161 _ => {}
162 }
163 }
164 ctx.depth -= 1;
165 }
166
167 /// Handle a text node.
168 fn handle_text(text: &str, ctx: &mut Context) {
169 if ctx.in_pre {
170 ctx.push(text);
171 return;
172 }
173
174 // Collapse whitespace in normal flow
175 let mut last_was_space = ctx.output.ends_with(' ') || ctx.output.ends_with('\n');
176 for ch in text.chars() {
177 if ch.is_ascii_whitespace() {
178 if !last_was_space {
179 ctx.push_char(' ');
180 last_was_space = true;
181 }
182 } else {
183 ctx.push_char(ch);
184 last_was_space = false;
185 }
186 }
187 }
188
189 /// Handle an element node — classify it and render accordingly.
190 fn handle_element(el: ElementRef, ctx: &mut Context) {
191 let element = el.value();
192
193 // Check hidden elements
194 if elements::is_hidden(element) {
195 return;
196 }
197
198 // Check for reply boundaries before normal classification.
199 // Reply boundaries (gmail_quote, type=cite, etc.) get rendered
200 // as blockquotes regardless of their actual element type.
201 if replies::is_reply_boundary(el) {
202 render_reply_block(el, ctx);
203 return;
204 }
205
206 // Check for Outlook-style "From: ... Sent: ..." separator blocks.
207 // These introduce quoted content that follows them.
208 if replies::is_outlook_separator(el) {
209 ctx.ensure_blank_line();
210 // Render the separator header as attribution
211 let text: String = el.text().collect();
212 let trimmed = text.split_whitespace().collect::<Vec<_>>().join(" ");
213 ctx.push(&trimmed);
214 ctx.ensure_blank_line();
215 return;
216 }
217
218 match elements::classify(element) {
219 ElementAction::Skip => {}
220 ElementAction::Transparent => walk_children(el, ctx),
221 ElementAction::Block(kind) => handle_block(el, ctx, kind),
222 ElementAction::Inline(kind) => handle_inline(el, ctx, kind),
223 }
224 }
225
226 fn handle_block(el: ElementRef, ctx: &mut Context, kind: BlockKind) {
227 match kind {
228 BlockKind::Paragraph => {
229 ctx.ensure_blank_line();
230 walk_children(el, ctx);
231 ctx.ensure_blank_line();
232 }
233
234 BlockKind::Heading(level) => {
235 ctx.ensure_blank_line();
236 let prefix = "#".repeat(level as usize);
237 ctx.push(&prefix);
238 ctx.push_char(' ');
239 walk_children(el, ctx);
240 ctx.ensure_blank_line();
241 }
242
243 BlockKind::Blockquote => {
244 ctx.ensure_blank_line();
245 // Render children into a temporary buffer, then prefix each line with >
246 let mut inner_ctx = Context::new();
247 inner_ctx.in_pre = ctx.in_pre;
248 inner_ctx.in_link = ctx.in_link;
249 walk_children(el, &mut inner_ctx);
250 let inner = whitespace::normalize(&inner_ctx.output);
251 for line in inner.lines() {
252 ctx.push("> ");
253 ctx.push(line);
254 ctx.push_char('\n');
255 }
256 ctx.push_char('\n');
257 }
258
259 BlockKind::UnorderedList => {
260 ctx.ensure_blank_line();
261 ctx.list_depth += 1;
262 ctx.list_stack.push(ListType::Unordered);
263 walk_children(el, ctx);
264 ctx.list_stack.pop();
265 ctx.list_depth -= 1;
266 ctx.ensure_blank_line();
267 }
268
269 BlockKind::OrderedList => {
270 ctx.ensure_blank_line();
271 ctx.list_depth += 1;
272 ctx.list_stack.push(ListType::Ordered(0));
273 walk_children(el, ctx);
274 ctx.list_stack.pop();
275 ctx.list_depth -= 1;
276 ctx.ensure_blank_line();
277 }
278
279 BlockKind::ListItem => {
280 ctx.ensure_newline();
281 let indent = ctx.list_indent();
282 ctx.push(&indent);
283
284 // Determine bullet or number
285 let marker = match ctx.list_stack.last_mut() {
286 Some(ListType::Ordered(n)) => {
287 *n += 1;
288 format!("{}. ", *n)
289 }
290 Some(ListType::Unordered) | None => "- ".to_string(),
291 };
292 ctx.push(&marker);
293 walk_children(el, ctx);
294 ctx.ensure_newline();
295 }
296
297 BlockKind::PreFormatted => {
298 ctx.ensure_blank_line();
299 ctx.push("```\n");
300 let body_start = ctx.output.len();
301 ctx.in_pre = true;
302 walk_children(el, ctx);
303 ctx.in_pre = false;
304 ctx.settle_fence_body(body_start);
305 ctx.ensure_newline();
306 ctx.push("```");
307 ctx.ensure_blank_line();
308 }
309
310 BlockKind::HorizontalRule => {
311 ctx.ensure_blank_line();
312 ctx.push("---");
313 ctx.ensure_blank_line();
314 }
315
316 BlockKind::Table => {
317 ctx.ensure_blank_line();
318 if tables::is_data_table(el) {
319 let (headers, rows) = tables::extract_table_data(el);
320 let md = tables::render_markdown_table(&headers, &rows);
321 if !md.is_empty() {
322 ctx.push(&md);
323 }
324 } else {
325 // Layout table — unwrap and render cell contents directly
326 render_layout_table(el, ctx);
327 }
328 ctx.ensure_blank_line();
329 }
330
331 BlockKind::Div => {
332 // Divs act as block separators but don't add their own markup
333 ctx.ensure_blank_line();
334 walk_children(el, ctx);
335 ctx.ensure_blank_line();
336 }
337 }
338 }
339
340 fn handle_inline(el: ElementRef, ctx: &mut Context, kind: InlineKind) {
341 match kind {
342 InlineKind::Bold => {
343 ctx.push("**");
344 walk_children(el, ctx);
345 ctx.push("**");
346 }
347
348 InlineKind::Italic => {
349 ctx.push("*");
350 walk_children(el, ctx);
351 ctx.push("*");
352 }
353
354 InlineKind::Strikethrough => {
355 ctx.push("~~");
356 walk_children(el, ctx);
357 ctx.push("~~");
358 }
359
360 InlineKind::Code => {
361 if ctx.in_pre {
362 // Inside a <pre>, don't double-wrap
363 walk_children(el, ctx);
364 } else {
365 ctx.push("`");
366 walk_children(el, ctx);
367 ctx.push("`");
368 }
369 }
370
371 InlineKind::Link => {
372 if ctx.in_link {
373 // Don't nest links
374 walk_children(el, ctx);
375 return;
376 }
377
378 let href = el.value().attr("href").unwrap_or("");
379
380 if href.is_empty() || href == "#" {
381 walk_children(el, ctx);
382 return;
383 }
384
385 // Collect the link text
386 let mut text_ctx = Context::new();
387 text_ctx.in_link = true;
388 walk_children(el, &mut text_ctx);
389 let text = text_ctx.output.trim().to_string();
390
391 if text.is_empty() {
392 // Link with no text — just show the URL
393 ctx.push(href);
394 } else if text == href {
395 // Link text matches URL — no need for markdown link syntax
396 ctx.push(href);
397 } else {
398 ctx.push("[");
399 ctx.push(&text);
400 ctx.push("](");
401 ctx.push(href);
402 ctx.push(")");
403 }
404 }
405
406 InlineKind::Image => {
407 let element = el.value();
408 if elements::is_tracking_pixel(element) {
409 return;
410 }
411
412 let alt = element.attr("alt").unwrap_or("");
413 let src = element.attr("src").unwrap_or("");
414
415 if src.is_empty() {
416 return;
417 }
418
419 ctx.push("![");
420 ctx.push(alt);
421 ctx.push("](");
422 ctx.push(src);
423 ctx.push(")");
424 }
425
426 InlineKind::LineBreak => {
427 ctx.push_char('\n');
428 }
429
430 InlineKind::Superscript => {
431 ctx.push("^");
432 walk_children(el, ctx);
433 }
434
435 InlineKind::Subscript => {
436 ctx.push("~");
437 walk_children(el, ctx);
438 }
439 }
440 }
441
442 /// Render a reply boundary as a quoted block.
443 ///
444 /// This is the same rendering logic as `<blockquote>` — children are
445 /// rendered into a temporary buffer and each line gets `> ` prefixed.
446 /// Attribution lines (e.g. "On ... wrote:") are rendered above the quote.
447 fn render_reply_block(el: ElementRef, ctx: &mut Context) {
448 ctx.ensure_blank_line();
449
450 // Look for attribution text
451 if let Some(attribution) = replies::find_attribution(el) {
452 ctx.push(&attribution);
453 ctx.push_char('\n');
454 }
455
456 // Render children into temp buffer, then prefix with >
457 let mut inner_ctx = Context::new();
458 inner_ctx.in_pre = ctx.in_pre;
459 inner_ctx.in_link = ctx.in_link;
460 walk_children(el, &mut inner_ctx);
461 let inner = whitespace::normalize(&inner_ctx.output);
462
463 if !inner.is_empty() {
464 for line in inner.lines() {
465 ctx.push("> ");
466 ctx.push(line);
467 ctx.push_char('\n');
468 }
469 ctx.push_char('\n');
470 }
471 }
472
473 /// Unwrap a layout table by rendering cell contents sequentially.
474 ///
475 /// Walks through rows and cells, rendering each cell's content as if
476 /// the table wrapper didn't exist. This handles the common email pattern
477 /// of wrapping everything in `<table><tr><td>...</td></tr></table>`.
478 fn render_layout_table(table: ElementRef, ctx: &mut Context) {
479 for descendant in table.descendants() {
480 if let Some(el_ref) = ElementRef::wrap(descendant) {
481 let name = el_ref.value().name();
482 if name == "td" || name == "th" {
483 // Check if the cell itself is hidden
484 if !elements::is_hidden(el_ref.value()) {
485 walk_children(el_ref, ctx);
486 ctx.ensure_blank_line();
487 }
488 }
489 }
490 }
491 }
492
493 #[cfg(test)]
494 mod tests {
495 use super::*;
496
497 // -- Basic elements --
498
499 #[test]
500 fn empty_input() {
501 assert_eq!(convert(""), "");
502 }
503
504 #[test]
505 fn plain_text() {
506 assert_eq!(convert("hello world"), "hello world");
507 }
508
509 #[test]
510 fn pathological_nesting_does_not_overflow() {
511 // A flat but deeply-nested body (far past MAX_DEPTH) must not recurse
512 // the tree walk into a stack-overflow abort — it returns bounded output.
513 // Guards the hostile-email DoS: without the depth cap this aborts the
514 // process and every subsequent sync re-crashes on the same message.
515 let n = (MAX_DEPTH as usize) + 5_000;
516 let deep = format!("text{}{}", "<div>".repeat(n), "</div>".repeat(n));
517 let md = convert(&deep);
518 // Reaching this line at all proves no stack-overflow abort; the shallow
519 // "text" (depth 1, within the cap) still renders.
520 assert!(md.contains("text"), "content within the cap still renders");
521 }
522
523 #[test]
524 fn paragraph() {
525 assert_eq!(convert("<p>one</p><p>two</p>"), "one\n\ntwo");
526 }
527
528 #[test]
529 fn headings() {
530 assert_eq!(convert("<h1>Title</h1>"), "# Title");
531 assert_eq!(convert("<h3>Sub</h3>"), "### Sub");
532 }
533
534 #[test]
535 fn bold_and_italic() {
536 assert_eq!(
537 convert("<p><strong>bold</strong> and <em>italic</em></p>"),
538 "**bold** and *italic*"
539 );
540 }
541
542 #[test]
543 fn link() {
544 assert_eq!(
545 convert(r#"<a href="https://example.com">click</a>"#),
546 "[click](https://example.com)"
547 );
548 }
549
550 #[test]
551 fn link_text_matches_url() {
552 assert_eq!(
553 convert(r#"<a href="https://example.com">https://example.com</a>"#),
554 "https://example.com"
555 );
556 }
557
558 #[test]
559 fn link_empty_href() {
560 assert_eq!(convert(r#"<a href="">click</a>"#), "click");
561 }
562
563 #[test]
564 fn image() {
565 assert_eq!(
566 convert(r#"<img src="photo.jpg" alt="A photo">"#),
567 "![A photo](photo.jpg)"
568 );
569 }
570
571 #[test]
572 fn tracking_pixel_skipped() {
573 assert_eq!(convert(r#"<img src="track.gif" width="1" height="1">"#), "");
574 }
575
576 #[test]
577 fn unordered_list() {
578 assert_eq!(convert("<ul><li>one</li><li>two</li></ul>"), "- one\n- two");
579 }
580
581 #[test]
582 fn ordered_list() {
583 assert_eq!(
584 convert("<ol><li>first</li><li>second</li></ol>"),
585 "1. first\n2. second"
586 );
587 }
588
589 #[test]
590 fn nested_list() {
591 let html = "<ul><li>outer<ul><li>inner</li></ul></li></ul>";
592 let md = convert(html);
593 assert!(md.contains("- outer"));
594 assert!(md.contains(" - inner"));
595 }
596
597 #[test]
598 fn nested_list_exact_indent_depth_2() {
599 // At depth 2, `list_indent` returns `" "` (exactly two spaces, one indent level).
600 // Catches `list_indent` mutations:
601 // - `(depth - 1)` → `(depth + 1)`: would produce 3 indent levels (6 spaces).
602 // - `(depth - 1)` → `(depth / 1)`: would produce 2 indent levels (4 spaces).
603 // Either makes this exact-match assertion fail.
604 // (The converter emits a blank line before each nested list — that's a
605 // separate stylistic question; the *indent* is what we're pinning down here.)
606 assert_eq!(
607 convert("<ul><li>A<ul><li>B</li></ul></li></ul>"),
608 "- A\n\n - B"
609 );
610 }
611
612 #[test]
613 fn triple_nested_list_exact_indent_depth_3() {
614 // At depth 3, indent is exactly `" "` (four spaces).
615 assert_eq!(
616 convert("<ul><li>A<ul><li>B<ul><li>C</li></ul></li></ul></li></ul>"),
617 "- A\n\n - B\n\n - C"
618 );
619 }
620
621 #[test]
622 fn sibling_top_level_lists_have_no_indent_after_nesting() {
623 // After a nested <ul> closes, `list_depth -= 1` must execute to return
624 // to outer scope. If mutated to `+= 1` or `/= 1`, list_depth stays
625 // elevated and the SECOND top-level list ends up incorrectly indented.
626 let md = convert("<ul><li>A<ul><li>B</li></ul></li></ul><ul><li>C</li></ul>");
627 // The second list's "C" item must appear at column 0, not indented.
628 // We check the exact substring "\n- C" (newline then no leading whitespace).
629 assert!(
630 md.contains("\n- C"),
631 "second top-level list must not be indented after a nested list closes; got: {md:?}"
632 );
633 // And explicitly: it must NOT appear with leading spaces.
634 assert!(
635 !md.contains("\n - C"),
636 "second list incorrectly indented; got: {md:?}"
637 );
638 }
639
640 #[test]
641 fn ordered_list_decrements_depth_after_nesting() {
642 // Same shape but with <ol> — exercises the L218 `-= 1` mutation in the
643 // OrderedList block, distinct from UnorderedList's L208.
644 let md = convert("<ol><li>A<ol><li>B</li></ol></li></ol><ol><li>C</li></ol>");
645 assert!(
646 md.contains("\n1. C"),
647 "second ol must restart at depth 1: {md:?}"
648 );
649 assert!(
650 !md.contains("\n 1. C"),
651 "second ol indented incorrectly: {md:?}"
652 );
653 }
654
655 #[test]
656 fn blockquote() {
657 assert_eq!(
658 convert("<blockquote>quoted text</blockquote>"),
659 "> quoted text"
660 );
661 }
662
663 #[test]
664 fn nested_blockquote() {
665 let html = "<blockquote>outer<blockquote>inner</blockquote></blockquote>";
666 let md = convert(html);
667 assert!(md.contains("> outer"));
668 assert!(md.contains("> > inner"));
669 }
670
671 #[test]
672 fn preformatted() {
673 let html = "<pre><code>fn main() {\n println!(\"hi\");\n}</code></pre>";
674 let md = convert(html);
675 assert!(md.starts_with("```\n"));
676 assert!(md.contains("fn main()"));
677 assert!(md.ends_with("\n```"));
678 }
679
680 #[test]
681 fn horizontal_rule() {
682 assert_eq!(
683 convert("<p>above</p><hr><p>below</p>"),
684 "above\n\n---\n\nbelow"
685 );
686 }
687
688 #[test]
689 fn br_tag() {
690 assert_eq!(convert("line one<br>line two"), "line one\nline two");
691 }
692
693 #[test]
694 fn strikethrough() {
695 assert_eq!(convert("<del>removed</del>"), "~~removed~~");
696 }
697
698 #[test]
699 fn inline_code() {
700 assert_eq!(convert("use <code>pter</code> here"), "use `pter` here");
701 }
702
703 #[test]
704 fn script_and_style_stripped() {
705 assert_eq!(
706 convert("<p>text</p><script>alert('x')</script><style>.x{}</style>"),
707 "text"
708 );
709 }
710
711 #[test]
712 fn unknown_elements_transparent() {
713 assert_eq!(convert("<span>hello</span>"), "hello");
714 }
715
716 #[test]
717 fn hidden_element_skipped() {
718 assert_eq!(
719 convert(r#"<p>visible</p><div style="display:none">hidden</div>"#),
720 "visible"
721 );
722 }
723
724 #[test]
725 fn whitespace_collapsed() {
726 assert_eq!(convert(" lots of space "), "lots of space");
727 }
728
729 #[test]
730 fn entities_decoded() {
731 // html5ever decodes entities during parsing
732 assert_eq!(convert("<p>&amp; &lt; &gt; &quot;</p>"), "& < > \"");
733 }
734
735 #[test]
736 fn sup_and_sub() {
737 assert_eq!(convert("x<sup>2</sup>"), "x^2");
738 assert_eq!(convert("H<sub>2</sub>O"), "H~2O");
739 }
740
741 // -- Div / section as block separator --
742
743 #[test]
744 fn div_separates_blocks() {
745 assert_eq!(convert("<div>one</div><div>two</div>"), "one\n\ntwo");
746 }
747
748 // -- Tables --
749
750 #[test]
751 fn layout_table_single_cell_unwrapped() {
752 let html = "<table><tr><td><p>Hello world</p></td></tr></table>";
753 assert_eq!(convert(html), "Hello world");
754 }
755
756 #[test]
757 fn layout_table_multi_column_linearized() {
758 let html = "<table><tr><td>Left</td><td>Right</td></tr></table>";
759 let md = convert(html);
760 assert!(md.contains("Left"));
761 assert!(md.contains("Right"));
762 }
763
764 #[test]
765 fn data_table_rendered_as_markdown() {
766 let html = "<table><tr><th>Name</th><th>Age</th></tr>\
767 <tr><td>Alice</td><td>30</td></tr>\
768 <tr><td>Bob</td><td>25</td></tr></table>";
769 let md = convert(html);
770 assert!(md.contains("| Name | Age |"));
771 assert!(md.contains("| --- | --- |"));
772 assert!(md.contains("| Alice | 30 |"));
773 assert!(md.contains("| Bob | 25 |"));
774 }
775
776 #[test]
777 fn nested_layout_tables_unwrapped() {
778 let html = "<table><tr><td>\
779 <table><tr><td>Inner content</td></tr></table>\
780 </td></tr></table>";
781 let md = convert(html);
782 assert!(md.contains("Inner content"));
783 assert!(!md.contains('|'));
784 }
785
786 #[test]
787 fn presentation_role_is_layout() {
788 let html = r#"<table role="presentation"><tr><td>Content</td><td>&nbsp;</td></tr></table>"#;
789 let md = convert(html);
790 assert!(md.contains("Content"));
791 assert!(!md.contains('|'));
792 }
793
794 #[test]
795 fn spacer_element_hidden() {
796 let html = r#"<p>real</p><div style="font-size:0">spacer</div><p>also real</p>"#;
797 let md = convert(html);
798 assert!(md.contains("real"));
799 assert!(!md.contains("spacer"));
800 assert!(md.contains("also real"));
801 }
802
803 // -- Combined --
804
805 #[test]
806 fn mixed_content() {
807 let html = r#"
808 <h1>Subject</h1>
809 <p>Hello <strong>Max</strong>,</p>
810 <p>Check out <a href="https://example.com">this link</a>.</p>
811 <ul>
812 <li>Item one</li>
813 <li>Item two</li>
814 </ul>
815 "#;
816 let md = convert(html);
817 assert!(md.starts_with("# Subject"));
818 assert!(md.contains("Hello **Max**,"));
819 assert!(md.contains("[this link](https://example.com)"));
820 assert!(md.contains("- Item one\n- Item two"));
821 }
822 }
823