//! CommonMark source → [`Block`]s, dressed from the theme's tokens. //! //! pulldown-cmark answers "what does this text mean"; everything here is the //! translation of that answer into the framework's own vocabulary — a //! [`RichText`] per block, with a [`TextSpan`] per inline run. The spans are //! byte ranges into the flattened prose, which is exactly the coordinate //! system `Paragraph` hit-tests and wraps in, so a link that breaks across a //! line needs nothing said about it here. use guiduck_core::text::{Paragraph, RichText, TextSpan}; use guiduck_scene::geom::Point; use pulldown_cmark::{Alignment, Event, HeadingLevel, Options, Parser, Tag, TagEnd}; use crate::block::{Block, BlockKind, CellAlign, ImageContent, Table, TableCell}; use crate::tokens::MarkdownTokens; /// Parse `source` into blocks dressed by `tokens`. /// /// The blocks are rebuilt whenever either input changes: the tokens decide /// font sizes, so a theme change is a re-layout of the document either way, and /// one construction path means the dressing cannot drift from the parse. pub fn parse(source: &str, tokens: &MarkdownTokens) -> Vec { let mut builder = Builder::new(tokens); let mut options = Options::empty(); // Strikethrough the span vocabulary already carries; tables the block model // now does. options.insert(Options::ENABLE_STRIKETHROUGH); options.insert(Options::ENABLE_TABLES); for event in Parser::new_ext(source, options) { builder.event(event); } builder.finish() } struct Builder<'a> { tokens: &'a MarkdownTokens, blocks: Vec, /// The block being accumulated, if any. kind: Option, /// The marker of the list item being accumulated. marker: Option, /// The flattened prose of the current block, and the runs over it. Byte /// offsets into `text` are the only coordinate anything downstream uses. text: String, spans: Vec<(std::ops::Range, TextSpan)>, /// Open inline runs, by the byte they started at. A stack per kind, so /// `**a *b* c**` closes the inner one first without either knowing about /// the other. emphasis: Vec, strong: Vec, strikethrough: Vec, links: Vec<(usize, String)>, /// Enclosing block quotes. quote_depth: u8, /// Enclosing lists: `Some(n)` is the next number of an ordered one, `None` /// a bulleted one. lists: Vec>, /// Whether `Event::Text` is a code block's contents rather than prose. in_code: bool, /// The table being accumulated, if inside one. Its cells reuse the same /// inline machinery every block's prose does; only where a finished run of /// text *goes* differs. table: Option, /// Images seen in the current block: the byte range their alt text occupies /// in `text`, and the `src`. A paragraph that is *exactly* one image /// becomes a block image; anywhere else, the alt text stands in the prose. images: Vec<(std::ops::Range, String)>, /// The alt-text start and `src` while inside an image tag. image: Option<(usize, String)>, } /// A table under construction: the alignments, the rows finished so far, and /// the row in progress. struct TableBuild { aligns: Vec, rows: Vec>, current: Vec, } impl<'a> Builder<'a> { fn new(tokens: &'a MarkdownTokens) -> Self { Self { tokens, blocks: Vec::new(), kind: None, marker: None, text: String::new(), spans: Vec::new(), emphasis: Vec::new(), strong: Vec::new(), strikethrough: Vec::new(), links: Vec::new(), quote_depth: 0, lists: Vec::new(), in_code: false, table: None, images: Vec::new(), image: None, } } fn event(&mut self, event: Event<'_>) { match event { Event::Start(Tag::Paragraph) => { // A list item's own first paragraph is the item, not a block // under it — which is the whole difference between a tight and // a loose list, and it is no difference at all once the marker // is already ours. if self.kind == Some(BlockKind::ListItem) && self.text.is_empty() { return; } self.flush(); self.kind = Some(BlockKind::Paragraph); } Event::End(TagEnd::Paragraph) => self.flush(), Event::Start(Tag::Heading { level, .. }) => { self.flush(); self.kind = Some(BlockKind::Heading(heading_level(level))); } Event::End(TagEnd::Heading(_)) => self.flush(), Event::Start(Tag::CodeBlock(_)) => { self.flush(); self.kind = Some(BlockKind::Code); self.in_code = true; } Event::End(TagEnd::CodeBlock) => { self.in_code = false; // The fence's own trailing newline is the fence, not a blank // last line of the program. while self.text.ends_with('\n') { self.text.pop(); } self.flush(); } Event::Start(Tag::BlockQuote(_)) => { self.flush(); self.quote_depth += 1; } Event::End(TagEnd::BlockQuote(_)) => { self.flush(); self.quote_depth = self.quote_depth.saturating_sub(1); } Event::Start(Tag::List(start)) => { self.flush(); self.lists.push(start); } Event::End(TagEnd::List(_)) => { self.flush(); self.lists.pop(); } Event::Start(Tag::Item) => { self.flush(); self.marker = Some(self.next_marker()); self.kind = Some(BlockKind::ListItem); } Event::End(TagEnd::Item) => self.flush(), Event::Rule => { self.flush(); let block = Block::new(BlockKind::Rule, self.quote_depth, self.lists.len(), None); self.blocks.push(block); } Event::Start(Tag::Table(aligns)) => { self.flush(); self.table = Some(TableBuild { aligns: aligns.iter().map(map_align).collect(), rows: Vec::new(), current: Vec::new(), }); } Event::End(TagEnd::Table) => self.finish_table(), // A head or a body row: both accumulate cells into `current`, which // is closed into a row when the row ends. Event::Start(Tag::TableHead | Tag::TableRow) => self.start_cell_text(), Event::End(TagEnd::TableHead | TagEnd::TableRow) => { if let Some(table) = &mut self.table { let row = std::mem::take(&mut table.current); table.rows.push(row); } } Event::Start(Tag::TableCell) => self.start_cell_text(), Event::End(TagEnd::TableCell) => { let text = std::mem::take(&mut self.text); let spans = std::mem::take(&mut self.spans); let body = self.prose_paragraph(text, spans); if let Some(table) = &mut self.table { table.current.push(TableCell { body, origin: Point::ORIGIN, }); } } Event::Start(Tag::Emphasis) => self.emphasis.push(self.text.len()), Event::End(TagEnd::Emphasis) => { if let Some(start) = self.emphasis.pop() { self.close(start, TextSpan::new().italic()); } } Event::Start(Tag::Strong) => self.strong.push(self.text.len()), Event::End(TagEnd::Strong) => { if let Some(start) = self.strong.pop() { self.close(start, TextSpan::new().bold()); } } Event::Start(Tag::Strikethrough) => self.strikethrough.push(self.text.len()), Event::End(TagEnd::Strikethrough) => { if let Some(start) = self.strikethrough.pop() { self.close(start, TextSpan::new().strikethrough()); } } Event::Start(Tag::Link { dest_url, .. }) => { self.links.push((self.text.len(), dest_url.into_string())); } Event::End(TagEnd::Link) => { if let Some((start, target)) = self.links.pop() { // The span carries the *target*, not a color: what a link // looks like is `link-color`/`link-underline-width`, which // `Paragraph` puts under whatever else the span says. self.close(start, TextSpan::new().link(target)); } } Event::Start(Tag::Image { dest_url, .. }) => { self.image = Some((self.text.len(), dest_url.into_string())); } Event::End(TagEnd::Image) => { // The alt text flowed into `text` between the tags, so its range // is the start we remembered to here. Kept in the prose, so a // mid-sentence image degrades to its alt text; lifted out only // when the image is the paragraph's whole content. if let Some((start, src)) = self.image.take() { self.images.push((start..self.text.len(), src)); } } Event::Text(text) => self.push_str(&text), Event::Code(code) => { let start = self.text.len(); self.push_str(&code); self.close(start, self.code_span()); } Event::SoftBreak => self.push_str(if self.in_code { "\n" } else { " " }), Event::HardBreak => self.push_str("\n"), // HTML, footnotes and task markers are not in this widget's // vocabulary; see the crate docs for which are deferred and why. // Dropping the event renders the document without them rather than // refusing to render it. _ => {} } } fn finish(mut self) -> Vec { self.flush(); self.blocks } fn push_str(&mut self, text: &str) { self.text.push_str(text); } /// A run that ran from `start` to here. fn close(&mut self, start: usize, span: TextSpan) { let end = self.text.len(); if start < end { self.spans.push((start..end, span)); } } /// Begin a fresh run of inline text, for the next cell (or the first cell /// of a row). The inline stacks are per-cell, and a well-formed table /// closes each run it opens inside the cell, so clearing the text is all it /// takes to isolate one cell from the next. fn start_cell_text(&mut self) { self.text.clear(); self.spans.clear(); } /// A cell's prose, in the document's body font — the same construction a /// paragraph block gets, minus the heading and code special-casing a cell /// never needs. fn prose_paragraph( &self, text: String, spans: Vec<(std::ops::Range, TextSpan)>, ) -> Paragraph { let mut content = RichText::new(text); for (range, span) in spans { content = content.span(range, span); } let mut body = Paragraph::default(); body.set_content(content); body.set_link_style(self.tokens.link.clone()); body.set_font_size(self.tokens.font_size as f32); body.set_brush(self.tokens.color.clone()); body.set_family(Some(self.tokens.font_family.clone())); body } /// Close the current table into a block, padding ragged rows to the column /// count so every `rows[r][c]` is a real cell. fn finish_table(&mut self) { let Some(mut build) = self.table.take() else { return; }; let columns = build.aligns.len(); for row in &mut build.rows { while row.len() < columns { row.push(TableCell { body: self.prose_paragraph(String::new(), Vec::new()), origin: Point::ORIGIN, }); } row.truncate(columns); } // A table with no body is still a table: the header alone is content. let table = Table { aligns: build.aligns, rows: build.rows, header_rows: 1, col_edges: Vec::new(), row_edges: Vec::new(), }; let mut block = Block::new(BlockKind::Table, self.quote_depth, self.lists.len(), None); block.table = Some(table); self.blocks.push(block); } fn code_span(&self) -> TextSpan { TextSpan::new() .family(self.tokens.code_font_family.clone()) .color(self.tokens.code_color.clone()) .font_size(self.tokens.code_font_size as f32) .background(self.tokens.code_background.clone()) } /// The marker of the item now starting, and the counter moved on. fn next_marker(&mut self) -> String { match self.lists.last_mut() { Some(Some(number)) => { let marker = format!("{number}."); *number += 1; marker } _ => self.tokens.bullet.clone(), } } /// Turn everything accumulated into a block, if there is anything to turn. fn flush(&mut self) { let Some(kind) = self.kind.take() else { // A `(rule …)` or a stray end tag: nothing was accumulating. self.text.clear(); self.spans.clear(); self.marker = None; self.images.clear(); return; }; let text = std::mem::take(&mut self.text); let spans = std::mem::take(&mut self.spans); let marker = self.marker.take(); let images = std::mem::take(&mut self.images); // A paragraph that is exactly one image — its alt text spanning the // whole prose — is a block image, resolved by the widget later. Checked // before the empty-text return so `![](src)` (an image with no alt) is // still a block, not nothing. if kind == BlockKind::Paragraph && images.len() == 1 && images[0].0 == (0..text.len()) { let (alt_range, src) = &images[0]; let alt = self.prose_paragraph(text[alt_range.clone()].to_owned(), Vec::new()); let mut block = Block::new(BlockKind::Image, self.quote_depth, self.lists.len(), None); block.image = Some(ImageContent { src: src.clone(), alt, graphic: None, deferred: false, }); self.blocks.push(block); return; } // An item with no text is still an item: its marker is content. if text.is_empty() && kind != BlockKind::ListItem { return; } let mut content = RichText::new(text); // The heading's weight goes on first, under the inline runs, so // `# a *b*` is bold-italic rather than bold-then-not. if matches!(kind, BlockKind::Heading(_)) && self.tokens.heading_weight != 400.0 { let whole = 0..content.text.len(); content = content.span( whole, TextSpan::new().weight(self.tokens.heading_weight as f32), ); } for (range, span) in spans { content = content.span(range, span); } let mut body = Paragraph::default(); body.set_content(content); body.set_link_style(self.tokens.link.clone()); match kind { BlockKind::Heading(level) => { body.set_font_size(self.tokens.heading_font_size(level) as f32); body.set_brush(self.tokens.heading_color.clone()); body.set_family(Some(self.tokens.font_family.clone())); } BlockKind::Code => { body.set_font_size(self.tokens.code_font_size as f32); body.set_brush(self.tokens.code_color.clone()); body.set_family(Some(self.tokens.code_font_family.clone())); } // A table and an image never flush a body (a table's cells and an // image's block are built directly), so their arms here are // unreachable; they take the body styling all the same rather than // being special-cased. BlockKind::Paragraph | BlockKind::ListItem | BlockKind::Rule | BlockKind::Table | BlockKind::Image => { body.set_font_size(self.tokens.font_size as f32); body.set_brush(self.tokens.color.clone()); body.set_family(Some(self.tokens.font_family.clone())); } } let mut block = Block::new(kind, self.quote_depth, self.lists.len(), Some(body)); if let Some(marker) = marker { let mut paragraph = Paragraph::default(); paragraph.set_content(RichText::new(marker)); paragraph.set_font_size(self.tokens.font_size as f32); paragraph.set_brush(self.tokens.color.clone()); paragraph.set_family(Some(self.tokens.font_family.clone())); block.marker = Some(paragraph); } self.blocks.push(block); } } /// A column's delimiter-row alignment. `None` (no colons) and `Left` are both /// start-aligned; there is no widget-side difference between them. fn map_align(align: &Alignment) -> CellAlign { match align { Alignment::Right => CellAlign::End, Alignment::Center => CellAlign::Center, Alignment::None | Alignment::Left => CellAlign::Start, } } /// `HeadingLevel` is an enum of the six levels; this is the number a reader /// wrote. fn heading_level(level: HeadingLevel) -> u8 { match level { HeadingLevel::H1 => 1, HeadingLevel::H2 => 2, HeadingLevel::H3 => 3, HeadingLevel::H4 => 4, HeadingLevel::H5 => 5, HeadingLevel::H6 => 6, } }