parse.rs
raw
//! 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<Block> {
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<Block>,
/// The block being accumulated, if any.
kind: Option<BlockKind>,
/// The marker of the list item being accumulated.
marker: Option<String>,
/// 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<usize>, 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<usize>,
strong: Vec<usize>,
strikethrough: Vec<usize>,
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<Option<u64>>,
/// 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<TableBuild>,
/// 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<usize>, 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<CellAlign>,
rows: Vec<Vec<TableCell>>,
current: Vec<TableCell>,
}
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<Block> {
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<usize>, 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 `` (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,
}
}