Skip to main content

pdfboss_markdown/
block.rs

1//! The Markdown block tree: a CommonMark+GFM document parsed into blocks
2//! and inline runs, independent of any layout or wrapping concern.
3
4use pulldown_cmark::{Alignment, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
5
6/// A run of inline text sharing one set of styling flags.
7#[derive(Debug, Clone, Default, PartialEq)]
8pub struct Run {
9    pub text: String,
10    pub bold: bool,
11    pub italic: bool,
12    pub code: bool,
13    pub strike: bool,
14    pub link: Option<String>,
15}
16
17/// A block-level element of a parsed document.
18#[derive(Debug, Clone, PartialEq)]
19pub enum Block {
20    Heading {
21        level: u8,
22        runs: Vec<Run>,
23    },
24    Paragraph {
25        runs: Vec<Run>,
26    },
27    CodeBlock {
28        text: String,
29    },
30    BlockQuote {
31        blocks: Vec<Block>,
32    },
33    List {
34        start: Option<u64>,
35        items: Vec<ListItem>,
36    },
37    Table {
38        aligns: Vec<CellAlign>,
39        head: Vec<Vec<Run>>,
40        rows: Vec<Vec<Vec<Run>>>,
41    },
42    Rule,
43    Image {
44        path: String,
45    },
46}
47
48/// One item of a `Block::List`, with its own nested blocks.
49#[derive(Debug, Clone, PartialEq)]
50pub struct ListItem {
51    pub task: Option<bool>,
52    pub blocks: Vec<Block>,
53}
54
55/// A table column's text alignment.
56#[derive(Clone, Copy, Debug, PartialEq)]
57pub enum CellAlign {
58    Default,
59    Left,
60    Center,
61    Right,
62}
63
64/// Parse a CommonMark+GFM document into a block tree. The `u32` counts
65/// raw-HTML fragments that were skipped rather than represented.
66pub(crate) fn parse_blocks(md: &str) -> (Vec<Block>, u32) {
67    let options =
68        Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TASKLISTS;
69    let mut builder = Builder::new();
70    for event in Parser::new_ext(md, options) {
71        builder.event(event);
72    }
73    builder.finish()
74}
75
76#[derive(Default)]
77struct Builder {
78    stack: Vec<Vec<Block>>,
79    runs: Vec<Run>,
80    leaf: Leaf,
81    bold: u32,
82    italic: u32,
83    strike: u32,
84    links: Vec<String>,
85    image_depth: u32,
86    pending_images: Vec<String>,
87    lists: Vec<ListContext>,
88    tables: Vec<TableContext>,
89    skipped_html: u32,
90}
91
92#[derive(Default, PartialEq)]
93enum Leaf {
94    #[default]
95    None,
96    Paragraph,
97    Heading(u8),
98    Code(String),
99    Cell,
100}
101
102#[derive(Default)]
103struct ListContext {
104    start: Option<u64>,
105    items: Vec<ListItem>,
106    pending_task: Option<bool>,
107}
108
109#[derive(Default)]
110struct TableContext {
111    aligns: Vec<CellAlign>,
112    head: Vec<Vec<Run>>,
113    rows: Vec<Vec<Vec<Run>>>,
114    row: Vec<Vec<Run>>,
115    in_head: bool,
116}
117
118impl Builder {
119    fn new() -> Builder {
120        Builder {
121            stack: vec![Vec::new()],
122            ..Builder::default()
123        }
124    }
125
126    fn event(&mut self, event: Event<'_>) {
127        match event {
128            Event::Start(Tag::Paragraph) => self.leaf = Leaf::Paragraph,
129            Event::End(TagEnd::Paragraph) => self.flush_paragraph(),
130            Event::Start(Tag::Heading { level, .. }) => {
131                self.flush_loose_runs();
132                self.leaf = Leaf::Heading(heading_level(level));
133            }
134            Event::End(TagEnd::Heading(_)) => {
135                let Leaf::Heading(level) = std::mem::take(&mut self.leaf) else {
136                    return;
137                };
138                let runs = std::mem::take(&mut self.runs);
139                self.push_block(Block::Heading { level, runs });
140            }
141            Event::Start(Tag::CodeBlock(_)) => {
142                self.flush_loose_runs();
143                self.leaf = Leaf::Code(String::new());
144            }
145            Event::End(TagEnd::CodeBlock) => {
146                let Leaf::Code(text) = std::mem::take(&mut self.leaf) else {
147                    return;
148                };
149                self.push_block(Block::CodeBlock {
150                    text: text.trim_end_matches('\n').to_string(),
151                });
152            }
153            Event::Start(Tag::BlockQuote(_)) => {
154                self.flush_loose_runs();
155                self.stack.push(Vec::new());
156            }
157            Event::End(TagEnd::BlockQuote(_)) => {
158                let blocks = self.stack.pop().unwrap_or_default();
159                self.push_block(Block::BlockQuote { blocks });
160            }
161            Event::Start(Tag::List(start)) => {
162                self.flush_loose_runs();
163                self.lists.push(ListContext {
164                    start,
165                    ..ListContext::default()
166                });
167            }
168            Event::End(TagEnd::List(_)) => {
169                let Some(list) = self.lists.pop() else { return };
170                self.push_block(Block::List {
171                    start: list.start,
172                    items: list.items,
173                });
174            }
175            Event::Start(Tag::Item) => {
176                self.flush_loose_runs();
177                self.stack.push(Vec::new());
178            }
179            Event::End(TagEnd::Item) => {
180                self.flush_loose_runs();
181                let blocks = self.stack.pop().unwrap_or_default();
182                let Some(list) = self.lists.last_mut() else {
183                    return;
184                };
185                list.items.push(ListItem {
186                    task: list.pending_task.take(),
187                    blocks,
188                });
189            }
190            Event::TaskListMarker(checked) => {
191                let Some(list) = self.lists.last_mut() else {
192                    return;
193                };
194                list.pending_task = Some(checked);
195            }
196            Event::Start(Tag::Table(aligns)) => {
197                self.flush_loose_runs();
198                self.tables.push(TableContext {
199                    aligns: aligns.iter().map(cell_align).collect(),
200                    ..TableContext::default()
201                });
202            }
203            Event::End(TagEnd::Table) => {
204                let Some(table) = self.tables.pop() else {
205                    return;
206                };
207                self.push_block(Block::Table {
208                    aligns: table.aligns,
209                    head: table.head,
210                    rows: table.rows,
211                });
212            }
213            Event::Start(Tag::TableHead) => self.set_in_head(true),
214            Event::End(TagEnd::TableHead) => {
215                let Some(table) = self.tables.last_mut() else {
216                    return;
217                };
218                table.head = std::mem::take(&mut table.row);
219                table.in_head = false;
220            }
221            Event::Start(Tag::TableRow) => {}
222            Event::End(TagEnd::TableRow) => self.close_row(),
223            Event::Start(Tag::TableCell) => self.leaf = Leaf::Cell,
224            Event::End(TagEnd::TableCell) => {
225                self.leaf = Leaf::None;
226                let runs = std::mem::take(&mut self.runs);
227                let Some(table) = self.tables.last_mut() else {
228                    return;
229                };
230                table.row.push(runs);
231            }
232            Event::Start(Tag::Emphasis) => self.italic += 1,
233            Event::End(TagEnd::Emphasis) => self.italic = self.italic.saturating_sub(1),
234            Event::Start(Tag::Strong) => self.bold += 1,
235            Event::End(TagEnd::Strong) => self.bold = self.bold.saturating_sub(1),
236            Event::Start(Tag::Strikethrough) => self.strike += 1,
237            Event::End(TagEnd::Strikethrough) => self.strike = self.strike.saturating_sub(1),
238            Event::Start(Tag::Link { dest_url, .. }) => self.links.push(dest_url.to_string()),
239            Event::End(TagEnd::Link) => {
240                self.links.pop();
241            }
242            Event::Start(Tag::Image { dest_url, .. }) => {
243                self.image_depth += 1;
244                self.pending_images.push(dest_url.to_string());
245            }
246            Event::End(TagEnd::Image) => self.image_depth = self.image_depth.saturating_sub(1),
247            Event::Text(text) => self.text(&text),
248            Event::Code(text) => self.code(&text),
249            Event::SoftBreak => self.text(" "),
250            Event::HardBreak => self.text("\n"),
251            Event::Rule => {
252                self.flush_loose_runs();
253                self.push_block(Block::Rule);
254            }
255            Event::Html(_) | Event::InlineHtml(_) => self.skipped_html += 1,
256            _ => {}
257        }
258    }
259
260    fn text(&mut self, text: &str) {
261        if self.image_depth > 0 {
262            return;
263        }
264        if let Leaf::Code(buffer) = &mut self.leaf {
265            buffer.push_str(text);
266            return;
267        }
268        self.run(text, false);
269    }
270
271    fn code(&mut self, text: &str) {
272        if self.image_depth > 0 {
273            return;
274        }
275        self.run(text, true);
276    }
277
278    fn run(&mut self, text: &str, code: bool) {
279        self.runs.push(Run {
280            text: text.to_string(),
281            bold: self.bold > 0,
282            italic: self.italic > 0,
283            code,
284            strike: self.strike > 0,
285            link: self.links.last().cloned(),
286        });
287    }
288
289    fn push_block(&mut self, block: Block) {
290        let Some(frame) = self.stack.last_mut() else {
291            return;
292        };
293        frame.push(block);
294    }
295
296    fn flush_paragraph(&mut self) {
297        self.leaf = Leaf::None;
298        let runs = std::mem::take(&mut self.runs);
299        let blank = runs.iter().all(|run| run.text.trim().is_empty());
300        if blank && self.pending_images.len() == 1 {
301            let Some(path) = self.pending_images.pop() else {
302                return;
303            };
304            self.push_block(Block::Image { path });
305            return;
306        }
307        if !runs.is_empty() {
308            self.push_block(Block::Paragraph { runs });
309        }
310        let images = std::mem::take(&mut self.pending_images);
311        for path in images {
312            self.push_block(Block::Image { path });
313        }
314    }
315
316    fn flush_loose_runs(&mut self) {
317        if !self.runs.is_empty() {
318            let runs = std::mem::take(&mut self.runs);
319            self.push_block(Block::Paragraph { runs });
320        }
321        let images = std::mem::take(&mut self.pending_images);
322        for path in images {
323            self.push_block(Block::Image { path });
324        }
325    }
326
327    fn close_row(&mut self) {
328        let Some(table) = self.tables.last_mut() else {
329            return;
330        };
331        if table.in_head {
332            return;
333        }
334        let row = std::mem::take(&mut table.row);
335        table.rows.push(row);
336    }
337
338    fn set_in_head(&mut self, in_head: bool) {
339        let Some(table) = self.tables.last_mut() else {
340            return;
341        };
342        table.in_head = in_head;
343    }
344
345    fn finish(mut self) -> (Vec<Block>, u32) {
346        self.flush_loose_runs();
347        let blocks = self.stack.pop().unwrap_or_default();
348        (blocks, self.skipped_html)
349    }
350}
351
352fn heading_level(level: HeadingLevel) -> u8 {
353    match level {
354        HeadingLevel::H1 => 1,
355        HeadingLevel::H2 => 2,
356        HeadingLevel::H3 => 3,
357        HeadingLevel::H4 => 4,
358        HeadingLevel::H5 => 5,
359        HeadingLevel::H6 => 6,
360    }
361}
362
363fn cell_align(alignment: &Alignment) -> CellAlign {
364    match alignment {
365        Alignment::None => CellAlign::Default,
366        Alignment::Left => CellAlign::Left,
367        Alignment::Center => CellAlign::Center,
368        Alignment::Right => CellAlign::Right,
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    fn plain(text: &str) -> Run {
377        Run {
378            text: text.to_string(),
379            ..Run::default()
380        }
381    }
382
383    #[test]
384    fn heading_paragraph_and_emphasis() {
385        let (blocks, skipped) = parse_blocks("# Title\n\nplain **bold** *italic* `code`\n");
386        assert_eq!(skipped, 0);
387        assert_eq!(
388            blocks[0],
389            Block::Heading {
390                level: 1,
391                runs: vec![plain("Title")]
392            }
393        );
394        let Block::Paragraph { runs } = &blocks[1] else {
395            panic!("expected paragraph")
396        };
397        assert_eq!(runs[0], plain("plain "));
398        assert_eq!(
399            runs[1],
400            Run {
401                text: "bold".into(),
402                bold: true,
403                ..Run::default()
404            }
405        );
406        assert_eq!(
407            runs[3],
408            Run {
409                text: "italic".into(),
410                italic: true,
411                ..Run::default()
412            }
413        );
414        assert_eq!(
415            runs[5],
416            Run {
417                text: "code".into(),
418                code: true,
419                ..Run::default()
420            }
421        );
422    }
423
424    #[test]
425    fn links_strikethrough_and_hard_breaks() {
426        let (blocks, _) = parse_blocks("[docs](https://x.y) and ~~gone~~ end  \nnext line\n");
427        let Block::Paragraph { runs } = &blocks[0] else {
428            panic!()
429        };
430        assert_eq!(runs[0].link.as_deref(), Some("https://x.y"));
431        assert!(runs.iter().any(|r| r.strike && r.text == "gone"));
432        assert!(
433            runs.iter().any(|r| r.text == "\n"),
434            "hard break becomes a newline run"
435        );
436    }
437
438    #[test]
439    fn nested_list_with_tasks() {
440        let md = "1. first\n2. [x] done\n   - inner\n";
441        let (blocks, _) = parse_blocks(md);
442        let Block::List { start, items } = &blocks[0] else {
443            panic!()
444        };
445        assert_eq!(*start, Some(1));
446        assert_eq!(items[1].task, Some(true));
447        assert!(matches!(
448            items[1].blocks.last(),
449            Some(Block::List { start: None, .. })
450        ));
451    }
452
453    #[test]
454    fn tight_list_text_stays_in_its_item() {
455        let (blocks, _) = parse_blocks("1. first\n2. [x] done\n   - inner\n");
456        let Block::List { items, .. } = &blocks[0] else {
457            panic!()
458        };
459        assert_eq!(
460            items[1].blocks[0],
461            Block::Paragraph {
462                runs: vec![plain("done")]
463            }
464        );
465        let Block::List { items: inner, .. } = items[1].blocks.last().unwrap() else {
466            panic!()
467        };
468        assert_eq!(
469            inner[0].blocks[0],
470            Block::Paragraph {
471                runs: vec![plain("inner")]
472            }
473        );
474    }
475
476    #[test]
477    fn tight_item_image_becomes_the_items_own_block() {
478        let (blocks, _) = parse_blocks("- ![a](x.png)\n");
479        let Block::List { items, .. } = &blocks[0] else {
480            panic!()
481        };
482        assert_eq!(
483            items[0].blocks,
484            vec![Block::Image {
485                path: "x.png".into()
486            }]
487        );
488    }
489
490    #[test]
491    fn code_inside_alt_text_is_suppressed() {
492        let (blocks, _) = parse_blocks("![see `foo`](x.png)\n");
493        assert_eq!(
494            blocks,
495            vec![Block::Image {
496                path: "x.png".into()
497            }]
498        );
499    }
500
501    #[test]
502    fn tight_item_text_precedes_a_following_heading() {
503        let (blocks, _) = parse_blocks("- text\n  # h\n");
504        let Block::List { items, .. } = &blocks[0] else {
505            panic!()
506        };
507        assert_eq!(
508            items[0].blocks,
509            vec![
510                Block::Paragraph {
511                    runs: vec![plain("text")]
512                },
513                Block::Heading {
514                    level: 1,
515                    runs: vec![plain("h")]
516                },
517            ]
518        );
519    }
520
521    #[test]
522    fn tight_item_text_precedes_a_following_code_block() {
523        let (blocks, _) = parse_blocks("- text\n  ```\n  code\n  ```\n");
524        let Block::List { items, .. } = &blocks[0] else {
525            panic!()
526        };
527        assert_eq!(
528            items[0].blocks,
529            vec![
530                Block::Paragraph {
531                    runs: vec![plain("text")]
532                },
533                Block::CodeBlock {
534                    text: "code".into()
535                },
536            ]
537        );
538    }
539
540    #[test]
541    fn blockquote_code_rule_and_table() {
542        let md = "> quoted\n\n```\nlet x = 1;\n```\n\n---\n\n| a | b |\n|:--|--:|\n| 1 | 2 |\n";
543        let (blocks, _) = parse_blocks(md);
544        assert!(matches!(&blocks[0], Block::BlockQuote { blocks } if blocks.len() == 1));
545        assert_eq!(
546            blocks[1],
547            Block::CodeBlock {
548                text: "let x = 1;".into()
549            }
550        );
551        assert_eq!(blocks[2], Block::Rule);
552        let Block::Table { aligns, head, rows } = &blocks[3] else {
553            panic!()
554        };
555        assert_eq!(aligns, &vec![CellAlign::Left, CellAlign::Right]);
556        assert_eq!(head.len(), 2);
557        assert_eq!(rows[0][1], vec![plain("2")]);
558    }
559
560    #[test]
561    fn lone_image_becomes_a_block_and_html_is_counted() {
562        let (blocks, skipped) = parse_blocks("![alt](pic.png)\n\n<div>x</div>\n");
563        assert_eq!(
564            blocks[0],
565            Block::Image {
566                path: "pic.png".into()
567            }
568        );
569        assert!(skipped >= 1);
570    }
571}