From 2e61c73cb74ea2fa4d778489e9aa6557dc47b3f3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:38:50 +0000 Subject: [PATCH] refactor(formats): split markdown.rs into markdown/ --- crates/formats/src/markdown.rs | 1643 +----------------------- crates/formats/src/markdown/blocks.rs | 673 ++++++++++ crates/formats/src/markdown/export.rs | 400 ++++++ crates/formats/src/markdown/inlines.rs | 570 ++++++++ 4 files changed, 1652 insertions(+), 1634 deletions(-) create mode 100644 crates/formats/src/markdown/blocks.rs create mode 100644 crates/formats/src/markdown/export.rs create mode 100644 crates/formats/src/markdown/inlines.rs diff --git a/crates/formats/src/markdown.rs b/crates/formats/src/markdown.rs index ec9a92243..460bb9c99 100644 --- a/crates/formats/src/markdown.rs +++ b/crates/formats/src/markdown.rs @@ -17,1641 +17,16 @@ use wordcraft_doc::{Align, Document}; use crate::html::decode_entity; use crate::model::{self, Cell, FBlock, FTable, Fmt, Inline, Kind, ListInfo, Para, make_img}; -/// Nesting limit for block containers (quotes, list items) and inline links. -const MAX_NEST: usize = 32; +mod blocks; +mod export; +mod inlines; -// --------------------------------------------------------------------------------------------- -// Import - -/// Parse Markdown text into a document. -pub fn import(text: &str) -> Document { - model::begin_import(); - model::to_doc(&parse(text)) -} - -/// [`import`] onto `page`: Markdown gives no page size (#1381). -pub fn import_on(text: &str, page: &wordcraft_doc::SectionProps) -> Document { - model::begin_import(); - model::to_doc_in(&parse(text), page.clone()) -} - -/// Link reference definitions: the normalised label to its destination. -/// A destination is shared by every reference to it, not copied into each (#3713). -pub type Refs = HashMap>; - -/// A label as CommonMark compares them: case-folded, and any run of white space one space -/// (§4.7, "matches"). `[FOO bar]` and `[foo BAR]` are the same label. -fn ref_label(raw: &str) -> String { - raw.split_whitespace().collect::>().join(" ").to_lowercase() -} - -/// A link reference definition on its own line: `[label]: destination "optional title"`. The -/// destination may be wrapped in `<...>`. Returns the label and the destination. -/// -/// CommonMark allows the title on the following line and the label to span lines; a definition -/// written across lines is left as text here, which is what it was before these were read at all. -fn link_definition(line: &str) -> Option<(String, String)> { - let t = line.trim_start(); - // Up to three spaces of indentation; four makes it indented code. - if line.len() - t.len() > 3 || !t.starts_with('[') { - return None; - } - let bytes = t.as_bytes(); - // The label ends at the first unescaped `]`, and cannot be empty or hold an unescaped `[`. - let mut i = 1; - let mut close = None; - while i < bytes.len() { - match bytes[i] { - b'\\' => i += 1, - b'[' => return None, - b']' => { - close = Some(i); - break; - } - _ => {} - } - i += 1; - } - let close = close?; - let label = ref_label(t.get(1..close)?); - // `[^label]: ...` is a footnote definition, which this importer leaves as text. - if label.starts_with('^') { - return None; - } - if label.is_empty() || t.as_bytes().get(close + 1) != Some(&b':') { - return None; - } - let rest = t.get(close + 2..)?.trim_start(); - if rest.is_empty() { - return None; - } - // The destination: `<...>`, else up to the next space. What follows may only be a title. - let (dest, after) = match rest.strip_prefix('<') { - Some(inner) => { - let end = inner.find('>')?; - (inner.get(..end)?.to_string(), inner.get(end + 1..)?) - } - None => { - let end = rest.find(char::is_whitespace).unwrap_or(rest.len()); - (rest.get(..end)?.to_string(), rest.get(end..)?) - } - }; - if dest.is_empty() { - return None; - } - let title = after.trim(); - let titled = - title.is_empty() || (title.len() >= 2 && matches!((title.chars().next()?, title.chars().last()?), ('"', '"') | ('\'', '\'') | ('(', ')'))); - titled.then_some((label, dest)) -} - -/// Take the link reference definitions out of `lines`, leaving the rest. Lines inside a fenced -/// code block are left alone: a definition there is code, not a definition. Fences open and -/// close by the same rules [`parse_blocks`] applies, or a line it reads as code would be read -/// here as a definition and deleted. -/// -/// A definition has to start a block: CommonMark §4.7 forbids one from interrupting a paragraph, -/// and a line like `[Warning]: unstable` in the middle of one is the author's text. This pass -/// sees lines without the block structure around them, so it takes a definition only after a -/// blank line, at the start of the document, after a heading or thematic break, or after another -/// definition. A definition anywhere else stays in the document and reads as text, which is what -/// it did before definitions were understood at all; deleting a line that was part of a -/// paragraph would lose it silently. -fn take_definitions(lines: &mut Vec) -> Refs { - let mut refs = Refs::new(); - let mut open: Option<(char, usize)> = None; - // The start of the document begins a block. - let mut starts_block = true; - lines.retain(|line| { - let t = line.trim_start_matches([' ', '\t']); - // Four spaces of indentation is code, not a fence (`parse_blocks`), on both sides. - let marker = (indent(line) < 4).then(|| fence(t)).flatten(); - match open { - Some((c, n)) => { - // A closing fence holds nothing but its own run of fence characters. - if marker.is_some_and(|(fc, fl)| fc == c && fl >= n) && t.trim_start_matches(c).trim().is_empty() { - open = None; - starts_block = true; - } - return true; - } - None => { - if let Some(f) = marker { - open = Some(f); - return true; - } - } - } - if starts_block && let Some((label, dest)) = link_definition(line) { - // The first definition of a label wins, as CommonMark says, and definitions may - // follow one another. - refs.entry(label).or_insert(dest.into()); - return false; - } - starts_block = is_blank(line) || atx_heading(t).is_some() || thematic_break(t); - true - }); - refs -} - -/// Parse Markdown text into the flow model. -pub fn parse(text: &str) -> model::Flow { - let text = text.strip_prefix('\u{FEFF}').unwrap_or(text); - let mut lines: Vec = text.split('\n').map(|l| expand_tabs(l.trim_end_matches('\r'))).collect(); - let refs = take_definitions(&mut lines); - let mut blocks = parse_blocks(&lines, 0, 0, &refs); - // A leading "Title" from front matter isn't supported; a first-level heading stays a heading. - if blocks.is_empty() { - blocks.push(FBlock::Para(Para::default())); - } - model::Flow { blocks, meta: Default::default() } -} - -/// Leading tabs → spaces (tab stops of 4); the rest of the line is kept. -fn expand_tabs(l: &str) -> String { - if !l.starts_with([' ', '\t']) || !l.contains('\t') { - return l.to_string(); - } - let mut out = String::with_capacity(l.len() + 8); - let mut col = 0usize; - let mut rest = l; - while let Some(c) = rest.chars().next() { - match c { - ' ' => { - out.push(' '); - col += 1; - } - '\t' => { - let n = 4 - col % 4; - out.push_str(&" ".repeat(n)); - col += n; - } - _ => break, - } - rest = &rest[c.len_utf8()..]; - } - out.push_str(rest); - out -} - -fn indent(l: &str) -> usize { - l.len() - l.trim_start_matches(' ').len() -} - -fn is_blank(l: &str) -> bool { - l.trim().is_empty() -} - -/// Strip up to `n` leading spaces. -fn strip_indent(l: &str, n: usize) -> &str { - let k = indent(l).min(n); - l.get(k..).unwrap_or("") -} - -fn atx_heading(t: &str) -> Option<(u8, &str)> { - let n = t.chars().take_while(|c| *c == '#').count(); - if !(1..=6).contains(&n) { - return None; - } - let rest = t.get(n..)?; - if !rest.is_empty() && !rest.starts_with([' ', '\t']) { - return None; - } - let mut body = rest.trim(); - // Optional closing sequence. - let trimmed = body.trim_end_matches('#'); - if trimmed.len() != body.len() && (trimmed.is_empty() || trimmed.ends_with(' ')) { - body = trimmed.trim_end(); - } - Some((n as u8, body)) -} - -fn thematic_break(t: &str) -> bool { - let mut ch = None; - let mut n = 0; - for c in t.chars() { - match c { - ' ' | '\t' => {} - '*' | '-' | '_' => { - if ch.is_some_and(|x| x != c) { - return false; - } - ch = Some(c); - n += 1; - } - _ => return false, - } - } - n >= 3 -} - -fn fence(t: &str) -> Option<(char, usize)> { - let c = t.chars().next().filter(|c| *c == '`' || *c == '~')?; - let n = t.chars().take_while(|x| *x == c).count(); - if n < 3 { - return None; - } - if c == '`' && t.get(n..).is_some_and(|info| info.contains('`')) { - return None; - } - Some((c, n)) -} - -#[derive(Clone, Copy, PartialEq)] -struct Marker { - ordered: bool, - /// Bullet char or ordered delimiter. - ch: char, - /// Bytes of the marker itself (after the indent). - len: usize, - start: u32, -} - -fn list_marker(t: &str) -> Option { - let first = t.chars().next()?; - let (m, len) = if matches!(first, '-' | '+' | '*') { - (Marker { ordered: false, ch: first, len: 1, start: 1 }, 1) - } else { - let digits = t.chars().take_while(char::is_ascii_digit).count(); - if !(1..=9).contains(&digits) { - return None; - } - let d = t.get(digits..)?.chars().next()?; - if d != '.' && d != ')' { - return None; - } - let start = t.get(..digits)?.parse::().unwrap_or(1); - (Marker { ordered: true, ch: d, len: digits + 1, start }, digits + 1) - }; - let rest = t.get(len..)?; - if rest.is_empty() || rest.starts_with([' ', '\t']) { Some(m) } else { None } -} - -fn split_row(l: &str) -> Vec { - let t = l.trim(); - let t = t.strip_prefix('|').unwrap_or(t); - let t = if let Some(before) = t.strip_suffix('|') { - let num_bs = before.chars().rev().take_while(|&c| c == '\\').count(); - if num_bs % 2 == 0 { before } else { t } - } else { - t - }; - let mut cells = Vec::new(); - let mut cur = String::new(); - let mut chars = t.chars().peekable(); - while let Some(c) = chars.next() { - match c { - '\\' => match chars.peek() { - Some(&'|') => { - cur.push('|'); - chars.next(); - } - Some(&'\\') => { - cur.push('\\'); - cur.push('\\'); - chars.next(); - } - _ => cur.push('\\'), - }, - '|' => cells.push(std::mem::take(&mut cur).trim().to_string()), - _ => cur.push(c), - } - } - cells.push(cur.trim().to_string()); - cells -} - -fn delimiter_row(l: &str) -> Option>> { - if !l.contains('-') { - return None; - } - let cells = split_row(l); - let mut out = Vec::new(); - for c in cells { - let c = c.trim(); - let left = c.starts_with(':'); - let right = c.ends_with(':'); - let core = c.trim_matches(':'); - if core.is_empty() || !core.chars().all(|x| x == '-') { - return None; - } - out.push(match (left, right) { - (true, true) => Some(Align::Center), - (false, true) => Some(Align::Right), - (true, false) => Some(Align::Left), - _ => None, - }); - } - Some(out) -} - -/// Does this line start a block that interrupts a paragraph? -fn interrupts(l: &str) -> bool { - if indent(l) >= 4 { - return false; - } - let t = l.trim_start_matches([' ', '\t']); - atx_heading(t).is_some() - || thematic_break(t) - || fence(t).is_some() - || t.starts_with('>') - || t.starts_with("") { - break; - } - } - continue; - } - if t.starts_with('>') { - flush_para(&mut para, &mut out, depth, refs); - let mut inner: Vec = Vec::new(); - while let Some(l) = lines.get(i) { - let lt = l.trim_start_matches([' ', '\t']); - if indent(l) < 4 && lt.starts_with('>') { - let s = <[1..]; - inner.push(s.strip_prefix(' ').unwrap_or(s).to_string()); - } else if !is_blank(l) && !interrupts(l) && inner.last().is_some_and(|x| !is_blank(x)) { - inner.push(l.clone()); - } else { - break; - } - i += 1; - } - if depth >= MAX_NEST { - let mut p = Para::new(Kind::Quote); - p.push_text(&inner.join(" "), &Fmt::default()); - out.push(FBlock::Para(p)); - } else { - for mut b in parse_blocks(&inner, depth + 1, level, refs) { - if let FBlock::Para(p) = &mut b - && p.kind == Kind::Normal - { - p.kind = Kind::Quote; - } - out.push(b); - } - } - continue; - } - if para.is_empty() - && t.contains('|') - && let Some(aligns) = lines.get(i + 1).and_then(|l| delimiter_row(l)) - { - let head = split_row(t); - if head.len() == aligns.len() { - let mut table = FTable::default(); - let cell = |s: &str, header: bool, a: Option| { - let p = Para { align: a, inlines: parse_inlines(s, &Fmt::default(), depth, refs), ..Default::default() }; - Cell { blocks: vec![FBlock::Para(p)], header, ..Default::default() } - }; - table.rows.push(head.iter().zip(&aligns).map(|(s, a)| cell(s, true, *a)).collect()); - i += 2; - while let Some(l) = lines.get(i) { - if is_blank(l) || !l.contains('|') || interrupts(l) { - break; - } - let mut cells = split_row(l); - cells.resize(aligns.len(), String::new()); - table.rows.push(cells.iter().zip(&aligns).map(|(s, a)| cell(s, false, *a)).collect()); - i += 1; - } - out.push(FBlock::Table(table)); - continue; - } - } - if let Some(m) = list_marker(t) - && (para.is_empty() || ((!m.ordered || m.start == 1) && !is_blank(t.get(m.len..).unwrap_or("")))) - { - flush_para(&mut para, &mut out, depth, refs); - i = parse_list(lines, i, depth, level, &mut out, refs); - continue; - } - para.push(line.clone()); - i += 1; - } - flush_para(&mut para, &mut out, depth, refs); - out -} - -fn marker_space(ind: usize, marker_len: usize, after: &str) -> (usize, usize) { - let mut cols = 0; - let mut bytes = 0; - let mut rest = after; - while let Some(c) = rest.chars().next() { - match c { - ' ' => { - cols += 1; - bytes += 1; - rest = &rest[1..]; - if cols >= 4 { - break; - } - } - '\t' => { - let col = ind + marker_len + cols; - let n = 4 - col % 4; - cols += n; - bytes += 1; - break; - } - _ => break, - } - } - (cols, bytes) -} - -/// Parse a list starting at `lines[i]`; returns the index after it. -fn parse_list(lines: &[String], mut i: usize, depth: usize, level: u8, out: &mut Vec, refs: &Refs) -> usize { - let Some(first) = lines.get(i).and_then(|l| list_marker(l.trim_start_matches([' ', '\t']))) else { return i + 1 }; - while let Some(line) = lines.get(i) { - let ind = indent(line); - if ind >= 4 { - break; - } - let t = line.trim_start_matches([' ', '\t']); - let Some(m) = list_marker(t) else { break }; - if m.ordered != first.ordered || m.ch != first.ch { - break; - } - let after = t.get(m.len..).unwrap_or(""); - let (spaces, skip) = if indent(after) > 4 { - (1, 1) - } else if after.starts_with([' ', '\t']) { - marker_space(ind, m.len, after) - } else { - (0, 0) - }; - let content_indent = if is_blank(after) { ind + m.len + 1 } else { ind + m.len + spaces }; - let item0 = if is_blank(after) { String::new() } else { after.get(skip.min(after.len())..).unwrap_or("").to_string() }; - let mut item: Vec = vec![item0]; - i += 1; - while let Some(l) = lines.get(i) { - if is_blank(l) { - item.push(String::new()); - } else if indent(l) >= content_indent { - item.push(strip_indent(l, content_indent).to_string()); - } else if item.last().is_some_and(|x| !is_blank(x)) && !interrupts(l) && list_marker(l.trim_start_matches([' ', '\t'])).is_none() { - // Lazy continuation of the item's paragraph. - item.push(l.trim_start_matches([' ', '\t']).to_string()); - } else { - break; - } - i += 1; - } - // Blank lines at the end of an item belong between items. - while item.len() > 1 && item.last().is_some_and(String::is_empty) { - item.pop(); - } - let li = ListInfo { ordered: first.ordered, level: level.min(8), start: first.start }; - let inner = if depth >= MAX_NEST { - let mut p = Para::default(); - p.push_text(&item.join(" "), &Fmt::default()); - vec![FBlock::Para(p)] - } else { - parse_blocks(&item, depth + 1, level.saturating_add(1).min(8), refs) - }; - let mut placed = false; - if inner.is_empty() { - out.push(FBlock::Para(Para { list: Some(li), ..Default::default() })); - placed = true; - } - for b in inner { - match b { - FBlock::Para(mut p) if p.list.is_none() => { - if !placed { - p.list = Some(li); - placed = true; - out.push(FBlock::Para(p)); - } else if let Some(FBlock::Para(prev)) = out.last_mut() - && prev.list.is_some_and(|x| x.level == li.level) - { - // A continuation paragraph joins the item with a line break. - prev.push_text("\n", &Fmt::default()); - prev.inlines.extend(p.inlines); - } else { - p.list = Some(li); - out.push(FBlock::Para(p)); - } - } - FBlock::Para(p) => { - if !placed { - // The item starts with a nested list: give it an empty first line. - out.push(FBlock::Para(Para { list: Some(li), ..Default::default() })); - placed = true; - } - out.push(FBlock::Para(p)); - } - other => { - placed = true; - out.push(other); - } - } - } - // Blank lines between items. - while lines.get(i).is_some_and(|l| is_blank(l)) { - let next_is_item = lines.get(i + 1).is_some_and(|l| { - indent(l) < 4 && list_marker(l.trim_start_matches([' ', '\t'])).is_some_and(|m| m.ordered == first.ordered && m.ch == first.ch) - }); - if !next_is_item { - return i; - } - i += 1; - } - } - i -} - -// ---- inlines ---- - -#[derive(Clone, Copy, PartialEq, Eq)] -enum Emph { - Em, - Strong, - Strike, -} - -enum Node { - Text(String, Fmt), - Inl(Inline), - Delim { ch: char, orig: usize, left: usize, open: bool, close: bool, opens: Vec, closes: Vec }, -} - -fn is_punct(c: char) -> bool { - c.is_ascii_punctuation() || (!c.is_alphanumeric() && !c.is_whitespace() && !c.is_control()) -} - -/// Parse inline Markdown into formatted spans (on top of `base`). -pub fn parse_inlines(s: &str, base: &Fmt, depth: usize, refs: &Refs) -> Vec { - let nodes = tokenize(s, base, depth, refs); - emit(nodes) -} - -fn push_text(nodes: &mut Vec, s: &str, f: &Fmt) { - if s.is_empty() { - return; - } - if let Some(Node::Text(t, pf)) = nodes.last_mut() - && pf == f - { - t.push_str(s); - return; - } - nodes.push(Node::Text(s.to_string(), f.clone())); -} - -/// The closing `]` matching the `[` at byte `open` (escapes and code spans respected). Gives up -/// after looking `max` bytes ahead. Records in `known` every bracket pair it passes, and every `[` -/// still open at the end of the text: a scan from one of those would take the same path. -fn matching_bracket(s: &str, open: usize, max: usize, ticks: &Backticks, known: &mut HashMap>) -> Option { - let b = s.as_bytes(); - let mut opens: Vec = Vec::new(); - let mut i = open; - while let Some(&c) = b.get(i) { - if i - open > max { - return None; - } - match c { - b'\\' => i += 1, - b'`' => { - let n = b.get(i..).map(|r| r.iter().take_while(|x| **x == b'`').count()).unwrap_or(1); - if let Some(end) = ticks.find(i + n, n) { - i = end + n - 1; - } else { - i += n - 1; - } - } - b'[' => opens.push(i), - b']' => { - if let Some(o) = opens.pop() { - known.insert(o, Some(i)); - } - if opens.is_empty() { - return Some(i); - } - } - _ => {} - } - i += 1; - } - for o in opens { - known.insert(o, None); - } - None -} - -/// Where a text's runs of backticks start, by run length. Finding the run that closes a code span -/// is then a lookup: scanning the rest of the text for it, again for every run nothing closes -/// (each of a different length) and from every link bracket over them, was superlinear. -struct Backticks(HashMap>); - -impl Backticks { - fn new(s: &str) -> Self { - let b = s.as_bytes(); - let mut runs: HashMap> = HashMap::new(); - let mut i = 0; - while let Some(&c) = b.get(i) { - if c == b'`' { - let k = b.get(i..).map(|r| r.iter().take_while(|x| **x == b'`').count()).unwrap_or(1); - runs.entry(k).or_default().push(i); - i += k; - } else { - i += 1; - } - } - Self(runs) - } - - /// Byte index of a run of exactly `n` backticks at or after `from` (which is not inside a run). - fn find(&self, from: usize, n: usize) -> Option { - let starts = self.0.get(&n)?; - starts.get(starts.partition_point(|&p| p < from)).copied() - } -} - -/// Parse `(dest "title")` at byte `at` (which holds `(`): (destination, index after `)`). -fn link_dest(s: &str, at: usize) -> Option<(String, usize)> { - let rest = s.get(at..)?.strip_prefix('(')?; - let mut i = at + 1; - let ws = rest.len() - rest.trim_start().len(); - i += ws; - let r = s.get(i..)?; - let (dest, used) = if let Some(inner) = r.strip_prefix('<') { - // The first `>` not escaped by a backslash ends it; a line break first means no link. - let b = inner.as_bytes(); - let mut k = 0; - let end = loop { - match b.get(k)? { - b'\\' => k += 1, - b'>' => break k, - b'\n' => return None, - _ => {} - } - k += 1; - }; - (inner.get(..end)?.to_string(), end + 2) - } else { - let mut depth = 0i32; - let mut end = r.len(); - let mut chars = r.char_indices().peekable(); - while let Some((k, c)) = chars.next() { - match c { - '\\' => { - chars.next(); - } - '(' => depth += 1, - ')' if depth == 0 => { - end = k; - break; - } - ')' => depth -= 1, - c if c.is_whitespace() => { - end = k; - break; - } - _ => {} - } - } - (r.get(..end)?.to_string(), end) - }; - i += used; - let r = s.get(i..)?; - let ws = r.len() - r.trim_start().len(); - i += ws; - let r = s.get(i..)?; - if let Some(q) = r.chars().next().filter(|c| matches!(c, '"' | '\'' | '(')) { - let close = if q == '(' { ')' } else { q }; - let end = r.get(1..)?.find(close)?; - i += end + 2; - let r = s.get(i..)?; - i += r.len() - r.trim_start().len(); - } - if s.get(i..)?.starts_with(')') { Some((unescape(&dest), i + 1)) } else { None } -} - -fn unescape(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - let mut chars = s.chars().peekable(); - while let Some(c) = chars.next() { - if c == '\\' - && let Some(n) = chars.peek().copied().filter(char::is_ascii_punctuation) - { - out.push(n); - chars.next(); - } else { - out.push(c); - } - } - out -} - -/// The value of attribute `name` in an HTML tag's text. -fn tag_attr(tag: &str, name: &str) -> Option { - let lower = tag.to_ascii_lowercase(); - let mut from = 0; - while let Some(k) = lower.get(from..)?.find(name) { - let at = from + k; - let before = lower.get(..at)?.chars().next_back(); - let after = lower.get(at + name.len()..)?.trim_start(); - if before.is_some_and(char::is_whitespace) && after.starts_with('=') { - let v = after.get(1..)?.trim_start(); - let q = v.chars().next()?; - return Some(if q == '"' || q == '\'' { - let inner = v.get(1..)?; - let off = tag.len() - inner.len(); - let end = inner.find(q)?; - tag.get(off..off + end)?.to_string() - } else { - let end = v.find(|c: char| c.is_whitespace() || c == '>').unwrap_or(v.len()); - let off = tag.len() - v.len(); - tag.get(off..off + end)?.to_string() - }); - } - from = at + name.len(); - } - None -} - -fn tokenize(s: &str, base: &Fmt, depth: usize, refs: &Refs) -> Vec { - let mut nodes: Vec = Vec::new(); - // Bytes the link and image scans below may look ahead in total. Each unmatched `[` or `![(` - // would otherwise rescan the rest of the text, which is quadratic on hostile input; once the - // budget is gone, further brackets are plain text. A scan pays for what it reads; brackets an - // earlier scan passed are looked up in `known` instead. - let mut scan_budget = s.len().saturating_mul(8).saturating_add(8192); - let ticks = Backticks::new(s); - let mut known: HashMap> = HashMap::new(); - let b = s.as_bytes(); - let mut i = 0usize; - let mut text_start = 0usize; - macro_rules! flush { - () => { - if text_start < i { - push_text(&mut nodes, s.get(text_start..i).unwrap_or(""), base); - } - }; - } - while let Some(&c) = b.get(i) { - match c { - b'\\' => { - flush!(); - match s.get(i + 1..).and_then(|r| r.chars().next()) { - Some(n) if n.is_ascii_punctuation() => { - push_text(&mut nodes, &n.to_string(), base); - i += 2; - } - _ => { - push_text(&mut nodes, "\\", base); - i += 1; - } - } - text_start = i; - } - b'`' => { - flush!(); - let n = b.get(i..).map(|r| r.iter().take_while(|x| **x == b'`').count()).unwrap_or(1); - match ticks.find(i + n, n) { - Some(end) => { - let mut code = s.get(i + n..end).unwrap_or("").replace('\n', " "); - if code.len() >= 2 && code.starts_with(' ') && code.ends_with(' ') && !code.trim().is_empty() { - code = code[1..code.len() - 1].to_string(); - } - let mut f = base.clone(); - f.code = true; - push_text(&mut nodes, &code, &f); - i = end + n; - } - None => { - push_text(&mut nodes, s.get(i..i + n).unwrap_or(""), base); - i += n; - } - } - text_start = i; - } - b'*' | b'_' | b'~' => { - flush!(); - let ch = c as char; - let n = b.get(i..).map(|r| r.iter().take_while(|x| **x == c).count()).unwrap_or(1); - let before = s.get(..i).and_then(|r| r.chars().next_back()).unwrap_or(' '); - let after = s.get(i + n..).and_then(|r| r.chars().next()).unwrap_or(' '); - let lf = !after.is_whitespace() && (!is_punct(after) || before.is_whitespace() || is_punct(before)); - let rf = !before.is_whitespace() && (!is_punct(before) || after.is_whitespace() || is_punct(after)); - let (open, close) = match ch { - '_' => (lf && (!rf || is_punct(before)), rf && (!lf || is_punct(after))), - '~' if n != 2 => (false, false), - _ => (lf, rf), - }; - if open || close { - nodes.push(Node::Delim { ch, orig: n, left: n, open, close, opens: Vec::new(), closes: Vec::new() }); - } else { - push_text(&mut nodes, s.get(i..i + n).unwrap_or(""), base); - } - i += n; - text_start = i; - } - b'!' | b'[' if depth < MAX_NEST => { - let img = c == b'!'; - let open = if img { i + 1 } else { i }; - let parsed = if b.get(open) == Some(&b'[') { - let found = match known.get(&open).copied() { - Some(found) => found, - None if scan_budget > 0 => { - let found = matching_bracket(s, open, scan_budget, &ticks, &mut known); - scan_budget = scan_budget.saturating_sub(found.unwrap_or(s.len()).saturating_sub(open)); - found - } - None => None, - }; - found.filter(|_| scan_budget > 0).and_then(|close| { - let text = s.get(open + 1..close)?; - if b.get(close + 1) == Some(&b'(') { - // A destination that does not parse may have been read to the end of the text. - let dest = link_dest(s, close + 1); - scan_budget = scan_budget.saturating_sub(dest.as_ref().map_or(s.len(), |d| d.1).saturating_sub(close)); - let (dest, end) = dest?; - return Some((text.to_string(), Arc::from(dest), end)); - } - // A reference link: `[text][label]`, `[text][]` and `[text]` all take - // their destination from a definition collected before parsing (#3238). - // The shortcut form is only a link when the label is defined, so an - // ordinary bracketed phrase is still text. - let (label, end) = match s.get(close + 1..).and_then(|rest| rest.strip_prefix('[')) { - Some(rest) => { - let shut = rest.find(']')?; - let inner = rest.get(..shut)?; - let label = if inner.trim().is_empty() { text } else { inner }; - (label, close + 2 + shut + 1) - } - None => (text, close + 1), - }; - Some((text.to_string(), refs.get(&ref_label(label))?.clone(), end)) - }) - } else { - None - }; - match parsed { - Some((inner, dest, end)) if img => { - flush!(); - let alt: String = parse_inlines(&inner, &Fmt::default(), depth + 1, refs) - .iter() - .filter_map(|x| if let Inline::Text(t, _) = x { Some(t.as_str()) } else { None }) - .collect(); - match model::data_uri(&dest).and_then(|d| make_img(d, None, None, &alt)) { - Some(im) => nodes.push(Node::Inl(Inline::Image(im))), - None => push_text(&mut nodes, &alt, base), - } - i = end; - text_start = i; - } - Some((inner, dest, end)) if base.link.is_none() => { - flush!(); - let mut f = base.clone(); - // A destination too long to keep leaves its text unlinked. - f.link = (dest.len() <= model::MAX_LINK).then_some(dest); - if f.link.is_none() { - model::dropped(model::Dropped::Link); - } - for x in parse_inlines(&inner, &f, depth + 1, refs) { - nodes.push(Node::Inl(x)); - } - i = end; - text_start = i; - } - _ => i += 1, - } - } - b'<' => { - if let Some(pos) = s.get(i..).and_then(|r| r.strip_prefix("")) { - flush!(); - i += 4 + pos + 3; - text_start = i; - continue; - } - let rest = s.get(i + 1..).unwrap_or(""); - let end = within(rest, MAX_TAG).find('>'); - let inner = end.and_then(|e| rest.get(..e)).unwrap_or(""); - let lower = inner.to_ascii_lowercase(); - let is_auto = !inner.is_empty() - && !inner.contains([' ', '<', '\n']) - && (inner.contains("://") || lower.starts_with("mailto:") || (inner.contains('@') && !inner.contains('/'))); - if is_auto { - flush!(); - let mut f = base.clone(); - f.link = Some(if inner.contains('@') && !inner.contains(':') { format!("mailto:{inner}").into() } else { inner.into() }); - push_text(&mut nodes, inner, &f); - i += inner.len() + 2; - text_start = i; - } else if matches!(lower.trim_end_matches('/').trim(), "br") { - flush!(); - push_text(&mut nodes, "\n", base); - i += inner.len() + 2; - text_start = i; - } else if lower.starts_with("a ") && rest.get(inner.len() + 1..).is_some_and(|r| r.to_ascii_lowercase().starts_with("")) { - flush!(); - if let Some(name) = tag_attr(inner, "id").or_else(|| tag_attr(inner, "name")) { - nodes.push(Node::Inl(Inline::Anchor(name))); - } - i += inner.len() + 2 + 4; - text_start = i; - } else { - i += 1; - } - } - b'&' => { - let rest = s.get(i..).unwrap_or(""); - match within(rest, 33).find(';').and_then(|k| decode_entity(rest.get(1..k)?).map(|ch| (ch, k))) { - Some((ch, k)) => { - flush!(); - push_text(&mut nodes, &ch, base); - i += k + 1; - text_start = i; - } - None => i += 1, - } - } - _ => i += 1, - } - } - flush!(); - nodes -} - -/// Longest `<...>` tag or autolink taken as inline HTML. -const MAX_TAG: usize = 4096; - -/// The first `n` bytes of `s` (fewer if that would cut a character), so a search for a closing -/// character stays short however long the text is. -fn within(s: &str, n: usize) -> &str { - let mut end = n.min(s.len()); - while !s.is_char_boundary(end) { - end -= 1; - } - s.get(..end).unwrap_or("") -} - -fn emph_index(ch: char) -> usize { - match ch { - '*' => 0, - '_' => 1, - _ => 2, - } -} - -/// The CommonMark "process emphasis" pass over the delimiter runs, then flattening into spans. -fn emit(mut nodes: Vec) -> Vec { - // Delimiter stack of node indexes; bottoms per (char, closer length % 3, closer can open). - let mut stack: Vec = Vec::new(); - let mut bottom = [[[None::; 2]; 3]; 3]; - for c in 0..nodes.len() { - let (ch, corig, copen, cclose) = match nodes.get(c) { - Some(Node::Delim { ch, orig, open, close, .. }) => (*ch, *orig, *open, *close), - _ => continue, - }; - if cclose { - loop { - let cleft = match nodes.get(c) { - Some(Node::Delim { left, .. }) => *left, - _ => 0, - }; - if cleft == 0 { - break; - } - let bot = bottom.get(emph_index(ch)).and_then(|x| x.get(corig % 3)).and_then(|x| x.get(copen as usize)).copied().flatten(); - let mut found = None; - for (k, &o) in stack.iter().enumerate().rev() { - if bot.is_some_and(|b| o <= b) { - break; - } - if let Some(Node::Delim { ch: och, orig: oorig, left: oleft, open: oopen, close: oclose, .. }) = nodes.get(o) { - if *och != ch || !*oopen || *oleft == 0 { - continue; - } - if ch == '~' && (*oleft < 2 || cleft < 2) { - continue; - } - let odd = (*oclose || copen) && (oorig + corig) % 3 == 0 && !(oorig % 3 == 0 && corig % 3 == 0); - if odd { - continue; - } - found = Some((k, o, *oleft)); - break; - } - } - let Some((k, o, oleft)) = found else { - if let Some(slot) = bottom.get_mut(emph_index(ch)).and_then(|x| x.get_mut(corig % 3)).and_then(|x| x.get_mut(copen as usize)) { - *slot = Some(c.saturating_sub(1)); - } - break; - }; - let (used, kind) = if ch == '~' { - (2, Emph::Strike) - } else if oleft >= 2 && cleft >= 2 { - (2, Emph::Strong) - } else { - (1, Emph::Em) - }; - if let Some(Node::Delim { left, opens, .. }) = nodes.get_mut(o) { - *left -= used; - opens.push(kind); - } - if let Some(Node::Delim { left, closes, .. }) = nodes.get_mut(c) { - *left -= used; - closes.push(kind); - } - // Delimiters between opener and closer can no longer match. - stack.truncate(k + 1); - if oleft - used == 0 { - stack.truncate(k); - } - } - } - let left = match nodes.get(c) { - Some(Node::Delim { left, .. }) => *left, - _ => 0, - }; - if copen && left > 0 { - stack.push(c); - } - } - // Flatten. - let mut out: Vec = Vec::new(); - let (mut em, mut strong, mut strike) = (0usize, 0usize, 0usize); - let push = |out: &mut Vec, t: &str, f: &Fmt, em: usize, strong: usize, strike: usize| { - if t.is_empty() { - return; - } - let mut f = f.clone(); - f.italic |= em > 0; - f.bold |= strong > 0; - f.strike |= strike > 0; - if let Some(Inline::Text(pt, pf)) = out.last_mut() - && *pf == f - { - pt.push_str(t); - return; - } - out.push(Inline::Text(t.to_string(), f)); - }; - // Delimiter literals take the formatting (link, …) of the text before them: the last text - // node, tracked here rather than searched for backwards from every delimiter (quadratic on - // a paragraph of delimiters). - let mut last_text: Option<&Fmt> = None; - for n in &nodes { - match n { - Node::Text(t, f) => { - push(&mut out, t, f, em, strong, strike); - last_text = Some(f); - } - Node::Inl(Inline::Text(t, f)) => push(&mut out, t, f, em, strong, strike), - Node::Inl(other) => out.push(other.clone()), - Node::Delim { ch, left, opens, closes, .. } => { - for e in closes { - match e { - Emph::Em => em = em.saturating_sub(1), - Emph::Strong => strong = strong.saturating_sub(1), - Emph::Strike => strike = strike.saturating_sub(1), - } - } - if *left > 0 { - let lit: String = std::iter::repeat_n(*ch, *left).collect(); - let f = last_text.map_or_else(Fmt::default, |f| Fmt { bold: false, italic: false, strike: false, ..f.clone() }); - push(&mut out, &lit, &f, em, strong, strike); - } - for e in opens { - match e { - Emph::Em => em += 1, - Emph::Strong => strong += 1, - Emph::Strike => strike += 1, - } - } - } - } - } - out -} - -// --------------------------------------------------------------------------------------------- -// Export - -/// Escape text for Markdown (inline context). -pub(crate) fn escape(s: &str) -> String { - let mut out = String::with_capacity(s.len() + 8); - for c in s.chars() { - match c { - '\\' | '`' | '*' | '_' | '[' | ']' | '<' | '>' | '~' | '|' | '&' => { - out.push('\\'); - out.push(c); - } - '\u{000C}' | '\u{000E}' => {} - _ => out.push(c), - } - } - out -} - -/// Escape what would start a block at the beginning of a line. Leading spaces are kept, but at -/// most three (a tab counts as four): four would turn the line into an indented code block. -fn escape_line_start(s: String) -> String { - let t = s.trim_start_matches([' ', '\t']); - let lead = s.get(..s.len() - t.len()).unwrap_or(""); - let cols: usize = lead.chars().map(|c| if c == '\t' { 4 } else { 1 }).sum(); - let indent = " ".repeat(cols.min(3)); - let needs = t.starts_with('#') - || t.starts_with('>') - || t.starts_with("- ") - || t.starts_with("+ ") - || t.starts_with("-\t") - || t.starts_with("+\t") - || t == "-" - || t == "+" - || t.starts_with('=') - || (t.starts_with('-') && t.trim_end_matches([' ', '\t']).chars().all(|c| c == '-')) - || thematic_break(t) - || { - let d = t.chars().take_while(char::is_ascii_digit).count(); - d > 0 - && t.get(d..).is_some_and(|r| { - r.starts_with(". ") || r.starts_with(") ") || r.starts_with(".\t") || r.starts_with(")\t") || r == "." || r == ")" - }) - }; - if !needs { - return if cols > 3 || lead.contains('\t') { format!("{indent}{t}") } else { s }; - } - if let Some(d) = t.find(['.', ')']).filter(|_| t.starts_with(|c: char| c.is_ascii_digit())) { - return format!("{indent}{}\\{}", &t[..d], &t[d..]); - } - format!("{indent}\\{t}") -} - -/// [`escape_line_start`] for every line of a body, including those after a manual line break. -fn escape_lines(body: String) -> String { - if !body.contains("\\\n") { - return escape_line_start(body); - } - body.split("\\\n").map(|l| escape_line_start(l.to_string())).collect::>().join("\\\n") -} - -fn code_span(t: &str) -> String { - let t = t.replace("\r\n", " ").replace(['\r', '\n'], " "); - let mut longest = 0; - let mut cur = 0; - for c in t.chars() { - if c == '`' { - cur += 1; - longest = longest.max(cur); - } else { - cur = 0; - } - } - let ticks = "`".repeat(longest + 1); - let pad = if t.starts_with('`') || t.ends_with('`') || (t.starts_with(' ') && t.ends_with(' ') && !t.trim().is_empty()) { " " } else { "" }; - format!("{ticks}{pad}{t}{pad}{ticks}") -} - -#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)] -enum Mark { - Bold, - Italic, - Strike, -} - -/// Inline spans → Markdown with properly nested markers. -fn inlines_md(inlines: &[Inline]) -> String { - inlines_md_impl(inlines, false) -} - -fn inlines_md_table(inlines: &[Inline]) -> String { - inlines_md_impl(inlines, true) -} - -fn inlines_md_impl(inlines: &[Inline], in_table: bool) -> String { - // Split into (text, fmt) pieces with whitespace moved outside emphasis. - let mut pieces: Vec<(String, Fmt, Option)> = Vec::new(); - let eq = crate::model::equations_as_text(inlines); - // Markdown keeps no ruby: its base text goes through the text path below. - let inlines = crate::model::ruby_as_text(&eq); - for i in inlines.iter() { - match i { - Inline::Text(t, f) => { - let has_emph = f.bold || f.italic || f.strike; - if has_emph && !f.code { - let core = t.trim_matches([' ', '\t', '\n']); - let lead = &t[..t.len() - t.trim_start_matches([' ', '\t', '\n']).len()]; - let trail = &t[t.trim_end_matches([' ', '\t', '\n']).len()..]; - let plain = Fmt { bold: false, italic: false, strike: false, ..f.clone() }; - if !lead.is_empty() { - pieces.push((lead.to_string(), plain.clone(), None)); - } - if !core.is_empty() { - pieces.push((core.to_string(), f.clone(), None)); - } - if !trail.is_empty() && !core.is_empty() { - pieces.push((trail.to_string(), plain, None)); - } - } else { - pieces.push((t.clone(), f.clone(), None)); - } - } - Inline::Image(img) => { - let name = format!("image.{}", img.ext); - pieces.push((String::new(), Fmt::default(), Some(format!("![{}]({})", escape(&img.alt), name)))); - } - Inline::Anchor(a) => { - let safe: String = a.chars().filter(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.' | ':')).collect(); - pieces.push((String::new(), Fmt::default(), Some(format!("")))); - } - Inline::Figure(alt) => pieces.push((String::new(), Fmt::default(), Some(format!("![{}]()", escape(alt))))), - Inline::Equation { .. } => {} - // Read as its base text by `ruby_as_text` above. - Inline::Ruby { .. } => {} - } - } - let mut out = String::new(); - let mut stack: Vec = Vec::new(); - let mut link: Option> = None; - let close_all = |out: &mut String, stack: &mut Vec| { - while let Some(m) = stack.pop() { - out.push_str(marker(m)); - } - }; - for (text, f, raw) in pieces { - // Links are the outermost construct; a script or other non-web target is plain text. - let f_link = f.link.clone().filter(|l| wordcraft_doc::link::is_safe_href(l)); - if f_link != link || raw.is_some() { - close_all(&mut out, &mut stack); - if let Some(l) = &link { - out.push_str(&format!("]({})", link_target(l))); - } - link = None; - if let Some(r) = raw { - out.push_str(&r); - continue; - } - if let Some(l) = f_link { - out.push('['); - link = Some(l); - } - } - let want: Vec = - [(f.bold, Mark::Bold), (f.italic, Mark::Italic), (f.strike, Mark::Strike)].iter().filter(|x| x.0).map(|x| x.1).collect(); - // Close until the stack is a prefix of what we want. - while let Some(pos) = stack.iter().position(|m| !want.contains(m)) { - while stack.len() > pos { - if let Some(m) = stack.pop() { - out.push_str(marker(m)); - } - } - } - for m in want { - if !stack.contains(&m) { - out.push_str(marker(m)); - stack.push(m); - } - } - if f.code { - if in_table { - let t = text.replace('|', "\\|"); - out.push_str(&code_span(&t)); - } else { - out.push_str(&code_span(&text)); - } - } else { - out.push_str(&escape(&text).replace('\n', "\\\n").replace('\t', " ")); - } - } - close_all(&mut out, &mut stack); - if let Some(l) = &link { - out.push_str(&format!("]({})", link_target(l))); - } - out -} - -/// `l` as a Markdown link destination that reads back as exactly `l`, the target -/// `is_safe_href` passed. What Markdown would decode (a backslash escape, an entity reference) -/// or end the destination on (`<`, `>`) is backslash-escaped, never deleted: deleting `<` or `>` -/// could turn an accepted relative target into `javascript:…` or `//host`. Control characters -/// can't be written in a destination and are left out, as the safety check ignores them. -fn link_target(l: &str) -> String { - let l: String = l.chars().filter(|c| !c.is_ascii_control()).collect(); - let mut out = String::with_capacity(l.len() + 2); - for (i, c) in l.char_indices() { - let rest = l.get(i + c.len_utf8()..).unwrap_or(""); - match c { - '<' | '>' => out.push('\\'), - // A backslash is literal unless punctuation (or the closing `)`/`>`) follows it. - '\\' if rest.chars().next().is_none_or(|n| n.is_ascii_punctuation()) => out.push('\\'), - '&' if entity_like(rest) => out.push('\\'), - _ => {} - } - out.push(c); - } - if l.contains(|c: char| c.is_whitespace() || matches!(c, '(' | ')' | '<' | '>')) { format!("<{out}>") } else { out } -} - -/// Whether `rest` (the text after a `&`) starts like an entity reference (`amp;`, `#106;`, -/// `#x6A;`), which a Markdown reader would decode. -fn entity_like(rest: &str) -> bool { - let Some(end) = within(rest, 34).find(';') else { return false }; - let body = rest.get(..end).unwrap_or(""); - let body = body.strip_prefix('#').map_or(body, |n| n.strip_prefix(['x', 'X']).unwrap_or(n)); - !body.is_empty() && body.chars().all(|c| c.is_ascii_alphanumeric()) -} - -fn marker(m: Mark) -> &'static str { - match m { - Mark::Bold => "**", - Mark::Italic => "*", - Mark::Strike => "~~", - } -} - -fn table_md(t: &FTable, out: &mut Vec) { - let cols = t.cols().max(1); - let cell_md = |c: &Cell| -> String { - let mut parts = Vec::new(); - for b in &c.blocks { - match b { - FBlock::Para(p) => parts.push(inlines_md_table(&p.inlines).replace("\\\n", "
")), - FBlock::Table(inner) => parts.push(escape(&inner.rows.iter().flat_map(|r| r.iter().map(Cell::text)).collect::>().join(" "))), - } - } - parts.join("
").replace('\n', " ") - }; - let align_of = |c: &Cell| c.blocks.iter().find_map(|b| if let FBlock::Para(p) = b { Some(p.align) } else { None }).flatten(); - let row_md = |r: &Vec| -> Vec { - let mut v: Vec = Vec::new(); - for c in r { - v.push(if c.covered { String::new() } else { cell_md(c) }); - for _ in 1..c.colspan.clamp(1, 63) { - v.push(String::new()); - } - } - v.resize(cols, String::new()); - v - }; - let Some(head) = t.rows.first() else { return }; - out.push(format!("| {} |", row_md(head).join(" | "))); - let mut aligns: Vec> = Vec::new(); - for c in head { - aligns.push(align_of(c)); - for _ in 1..c.colspan.clamp(1, 63) { - aligns.push(None); - } - } - aligns.resize(cols, None); - let delim: Vec<&str> = aligns - .iter() - .map(|a| match a { - Some(Align::Center) => ":---:", - Some(Align::Right) => "---:", - Some(Align::Left) => ":---", - _ => "---", - }) - .collect(); - out.push(format!("| {} |", delim.join(" | "))); - for r in t.rows.iter().skip(1) { - out.push(format!("| {} |", row_md(r).join(" | "))); - } -} - -/// Markdown text of a document. -pub fn export(doc: &Document) -> String { - let flow = model::from_doc(doc); - let mut out: Vec = Vec::new(); - write_blocks(&flow.blocks, &mut out); - let mut s = out.join("\n"); - while s.ends_with("\n\n") { - s.pop(); - } - if !s.ends_with('\n') { - s.push('\n'); - } - s -} - -fn write_blocks(blocks: &[FBlock], out: &mut Vec) { - let mut i = 0; - let mut counters = [0u32; 9]; - let mut prev_list = false; - let mut prev_top_ordered = false; - while let Some(b) = blocks.get(i) { - match b { - FBlock::Table(t) => { - table_md(t, out); - out.push(String::new()); - prev_list = false; - } - FBlock::Para(p) if p.kind == Kind::Code && p.list.is_none() => { - let mut lines = Vec::new(); - while let Some(FBlock::Para(q)) = blocks.get(i) { - if q.kind != Kind::Code || q.list.is_some() { - break; - } - lines.push(q.text()); - i += 1; - } - let longest = lines.iter().map(|l| l.chars().filter(|c| *c == '`').count()).max().unwrap_or(0); - // The fence must be longer than the longest run in the content, or a content - // line is read back as the closing fence (the `.min(64)` cap did exactly that). - let fence = "`".repeat(3.max(longest + 1)); - out.push(fence.clone()); - out.extend(lines); - out.push(fence); - out.push(String::new()); - prev_list = false; - continue; - } - FBlock::Para(p) => { - let body = inlines_md(&p.inlines); - match p.list { - Some(li) => { - let lv = li.level.min(8) as usize; - if !prev_list || (lv == 0 && prev_top_ordered != li.ordered) { - counters = [0; 9]; - } - if lv == 0 { - prev_top_ordered = li.ordered; - } - let n = counters.get_mut(lv).map(|c| { - if *c == 0 { - *c = li.start.saturating_sub(1); - } - *c = c.saturating_add(1); - *c - }); - for d in counters.iter_mut().skip(lv + 1) { - *d = 0; - } - let marker = if li.ordered { format!("{}.", n.unwrap_or(1)) } else { "-".to_string() }; - let pad = " ".repeat(4 * lv); - let cont = " ".repeat(4 * lv + marker.len() + 1); - let body = if p.kind == Kind::Code { - code_span(&p.text()) - } else { - let body = escape_lines(body).replace("\\\n", &format!("\\\n{cont}")); - // The bullet marker can turn a short run of hyphens into a rule. - if thematic_break(&format!("{marker} {body}")) { format!("\\{body}") } else { body } - }; - out.push(format!("{pad}{marker} {body}").trim_end().to_string()); - prev_list = true; - let next_is_list = matches!(blocks.get(i + 1), Some(FBlock::Para(q)) if q.list.is_some()); - if !next_is_list { - out.push(String::new()); - } - } - None => { - prev_list = false; - let line = match p.kind { - Kind::Heading(n) => format!("{} {}", "#".repeat(n.clamp(1, 6) as usize), body.replace("\\\n", " ")), - Kind::Title => format!("# {}", body.replace("\\\n", " ")), - Kind::Rule => "---".to_string(), - Kind::Quote => { - let b = escape_lines(body); - format!("> {}", b.replace("\\\n", "\\\n> ")) - } - _ => { - if body.is_empty() { - i += 1; - continue; - } - escape_lines(body) - } - }; - out.push(line); - out.push(String::new()); - } - } - } - } - i += 1; - } -} +pub use blocks::{Refs, import, import_on, parse}; +pub(crate) use export::escape; +pub use export::export; +#[cfg(test)] +use export::{escape_line_start, inlines_md, table_md, write_blocks}; +pub use inlines::parse_inlines; #[cfg(test)] mod tests; diff --git a/crates/formats/src/markdown/blocks.rs b/crates/formats/src/markdown/blocks.rs new file mode 100644 index 000000000..dae80aabf --- /dev/null +++ b/crates/formats/src/markdown/blocks.rs @@ -0,0 +1,673 @@ +use super::inlines::parse_inlines; +use super::*; + +/// Nesting limit for block containers (quotes, list items) and inline links. +pub(super) const MAX_NEST: usize = 32; + +// --------------------------------------------------------------------------------------------- +// Import + +/// Parse Markdown text into a document. +pub fn import(text: &str) -> Document { + model::begin_import(); + model::to_doc(&parse(text)) +} + +/// [`import`] onto `page`: Markdown gives no page size (#1381). +pub fn import_on(text: &str, page: &wordcraft_doc::SectionProps) -> Document { + model::begin_import(); + model::to_doc_in(&parse(text), page.clone()) +} + +/// Link reference definitions: the normalised label to its destination. +/// A destination is shared by every reference to it, not copied into each (#3713). +pub type Refs = HashMap>; + +/// A label as CommonMark compares them: case-folded, and any run of white space one space +/// (§4.7, "matches"). `[FOO bar]` and `[foo BAR]` are the same label. +pub(super) fn ref_label(raw: &str) -> String { + raw.split_whitespace().collect::>().join(" ").to_lowercase() +} + +/// A link reference definition on its own line: `[label]: destination "optional title"`. The +/// destination may be wrapped in `<...>`. Returns the label and the destination. +/// +/// CommonMark allows the title on the following line and the label to span lines; a definition +/// written across lines is left as text here, which is what it was before these were read at all. +fn link_definition(line: &str) -> Option<(String, String)> { + let t = line.trim_start(); + // Up to three spaces of indentation; four makes it indented code. + if line.len() - t.len() > 3 || !t.starts_with('[') { + return None; + } + let bytes = t.as_bytes(); + // The label ends at the first unescaped `]`, and cannot be empty or hold an unescaped `[`. + let mut i = 1; + let mut close = None; + while i < bytes.len() { + match bytes[i] { + b'\\' => i += 1, + b'[' => return None, + b']' => { + close = Some(i); + break; + } + _ => {} + } + i += 1; + } + let close = close?; + let label = ref_label(t.get(1..close)?); + // `[^label]: ...` is a footnote definition, which this importer leaves as text. + if label.starts_with('^') { + return None; + } + if label.is_empty() || t.as_bytes().get(close + 1) != Some(&b':') { + return None; + } + let rest = t.get(close + 2..)?.trim_start(); + if rest.is_empty() { + return None; + } + // The destination: `<...>`, else up to the next space. What follows may only be a title. + let (dest, after) = match rest.strip_prefix('<') { + Some(inner) => { + let end = inner.find('>')?; + (inner.get(..end)?.to_string(), inner.get(end + 1..)?) + } + None => { + let end = rest.find(char::is_whitespace).unwrap_or(rest.len()); + (rest.get(..end)?.to_string(), rest.get(end..)?) + } + }; + if dest.is_empty() { + return None; + } + let title = after.trim(); + let titled = + title.is_empty() || (title.len() >= 2 && matches!((title.chars().next()?, title.chars().last()?), ('"', '"') | ('\'', '\'') | ('(', ')'))); + titled.then_some((label, dest)) +} + +/// Take the link reference definitions out of `lines`, leaving the rest. Lines inside a fenced +/// code block are left alone: a definition there is code, not a definition. Fences open and +/// close by the same rules [`parse_blocks`] applies, or a line it reads as code would be read +/// here as a definition and deleted. +/// +/// A definition has to start a block: CommonMark §4.7 forbids one from interrupting a paragraph, +/// and a line like `[Warning]: unstable` in the middle of one is the author's text. This pass +/// sees lines without the block structure around them, so it takes a definition only after a +/// blank line, at the start of the document, after a heading or thematic break, or after another +/// definition. A definition anywhere else stays in the document and reads as text, which is what +/// it did before definitions were understood at all; deleting a line that was part of a +/// paragraph would lose it silently. +fn take_definitions(lines: &mut Vec) -> Refs { + let mut refs = Refs::new(); + let mut open: Option<(char, usize)> = None; + // The start of the document begins a block. + let mut starts_block = true; + lines.retain(|line| { + let t = line.trim_start_matches([' ', '\t']); + // Four spaces of indentation is code, not a fence (`parse_blocks`), on both sides. + let marker = (indent(line) < 4).then(|| fence(t)).flatten(); + match open { + Some((c, n)) => { + // A closing fence holds nothing but its own run of fence characters. + if marker.is_some_and(|(fc, fl)| fc == c && fl >= n) && t.trim_start_matches(c).trim().is_empty() { + open = None; + starts_block = true; + } + return true; + } + None => { + if let Some(f) = marker { + open = Some(f); + return true; + } + } + } + if starts_block && let Some((label, dest)) = link_definition(line) { + // The first definition of a label wins, as CommonMark says, and definitions may + // follow one another. + refs.entry(label).or_insert(dest.into()); + return false; + } + starts_block = is_blank(line) || atx_heading(t).is_some() || thematic_break(t); + true + }); + refs +} + +/// Parse Markdown text into the flow model. +pub fn parse(text: &str) -> model::Flow { + let text = text.strip_prefix('\u{FEFF}').unwrap_or(text); + let mut lines: Vec = text.split('\n').map(|l| expand_tabs(l.trim_end_matches('\r'))).collect(); + let refs = take_definitions(&mut lines); + let mut blocks = parse_blocks(&lines, 0, 0, &refs); + // A leading "Title" from front matter isn't supported; a first-level heading stays a heading. + if blocks.is_empty() { + blocks.push(FBlock::Para(Para::default())); + } + model::Flow { blocks, meta: Default::default() } +} + +/// Leading tabs → spaces (tab stops of 4); the rest of the line is kept. +fn expand_tabs(l: &str) -> String { + if !l.starts_with([' ', '\t']) || !l.contains('\t') { + return l.to_string(); + } + let mut out = String::with_capacity(l.len() + 8); + let mut col = 0usize; + let mut rest = l; + while let Some(c) = rest.chars().next() { + match c { + ' ' => { + out.push(' '); + col += 1; + } + '\t' => { + let n = 4 - col % 4; + out.push_str(&" ".repeat(n)); + col += n; + } + _ => break, + } + rest = &rest[c.len_utf8()..]; + } + out.push_str(rest); + out +} + +fn indent(l: &str) -> usize { + l.len() - l.trim_start_matches(' ').len() +} + +fn is_blank(l: &str) -> bool { + l.trim().is_empty() +} + +/// Strip up to `n` leading spaces. +fn strip_indent(l: &str, n: usize) -> &str { + let k = indent(l).min(n); + l.get(k..).unwrap_or("") +} + +fn atx_heading(t: &str) -> Option<(u8, &str)> { + let n = t.chars().take_while(|c| *c == '#').count(); + if !(1..=6).contains(&n) { + return None; + } + let rest = t.get(n..)?; + if !rest.is_empty() && !rest.starts_with([' ', '\t']) { + return None; + } + let mut body = rest.trim(); + // Optional closing sequence. + let trimmed = body.trim_end_matches('#'); + if trimmed.len() != body.len() && (trimmed.is_empty() || trimmed.ends_with(' ')) { + body = trimmed.trim_end(); + } + Some((n as u8, body)) +} + +pub(super) fn thematic_break(t: &str) -> bool { + let mut ch = None; + let mut n = 0; + for c in t.chars() { + match c { + ' ' | '\t' => {} + '*' | '-' | '_' => { + if ch.is_some_and(|x| x != c) { + return false; + } + ch = Some(c); + n += 1; + } + _ => return false, + } + } + n >= 3 +} + +fn fence(t: &str) -> Option<(char, usize)> { + let c = t.chars().next().filter(|c| *c == '`' || *c == '~')?; + let n = t.chars().take_while(|x| *x == c).count(); + if n < 3 { + return None; + } + if c == '`' && t.get(n..).is_some_and(|info| info.contains('`')) { + return None; + } + Some((c, n)) +} + +#[derive(Clone, Copy, PartialEq)] +struct Marker { + ordered: bool, + /// Bullet char or ordered delimiter. + ch: char, + /// Bytes of the marker itself (after the indent). + len: usize, + start: u32, +} + +fn list_marker(t: &str) -> Option { + let first = t.chars().next()?; + let (m, len) = if matches!(first, '-' | '+' | '*') { + (Marker { ordered: false, ch: first, len: 1, start: 1 }, 1) + } else { + let digits = t.chars().take_while(char::is_ascii_digit).count(); + if !(1..=9).contains(&digits) { + return None; + } + let d = t.get(digits..)?.chars().next()?; + if d != '.' && d != ')' { + return None; + } + let start = t.get(..digits)?.parse::().unwrap_or(1); + (Marker { ordered: true, ch: d, len: digits + 1, start }, digits + 1) + }; + let rest = t.get(len..)?; + if rest.is_empty() || rest.starts_with([' ', '\t']) { Some(m) } else { None } +} + +fn split_row(l: &str) -> Vec { + let t = l.trim(); + let t = t.strip_prefix('|').unwrap_or(t); + let t = if let Some(before) = t.strip_suffix('|') { + let num_bs = before.chars().rev().take_while(|&c| c == '\\').count(); + if num_bs % 2 == 0 { before } else { t } + } else { + t + }; + let mut cells = Vec::new(); + let mut cur = String::new(); + let mut chars = t.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '\\' => match chars.peek() { + Some(&'|') => { + cur.push('|'); + chars.next(); + } + Some(&'\\') => { + cur.push('\\'); + cur.push('\\'); + chars.next(); + } + _ => cur.push('\\'), + }, + '|' => cells.push(std::mem::take(&mut cur).trim().to_string()), + _ => cur.push(c), + } + } + cells.push(cur.trim().to_string()); + cells +} + +fn delimiter_row(l: &str) -> Option>> { + if !l.contains('-') { + return None; + } + let cells = split_row(l); + let mut out = Vec::new(); + for c in cells { + let c = c.trim(); + let left = c.starts_with(':'); + let right = c.ends_with(':'); + let core = c.trim_matches(':'); + if core.is_empty() || !core.chars().all(|x| x == '-') { + return None; + } + out.push(match (left, right) { + (true, true) => Some(Align::Center), + (false, true) => Some(Align::Right), + (true, false) => Some(Align::Left), + _ => None, + }); + } + Some(out) +} + +/// Does this line start a block that interrupts a paragraph? +fn interrupts(l: &str) -> bool { + if indent(l) >= 4 { + return false; + } + let t = l.trim_start_matches([' ', '\t']); + atx_heading(t).is_some() + || thematic_break(t) + || fence(t).is_some() + || t.starts_with('>') + || t.starts_with("") { + break; + } + } + continue; + } + if t.starts_with('>') { + flush_para(&mut para, &mut out, depth, refs); + let mut inner: Vec = Vec::new(); + while let Some(l) = lines.get(i) { + let lt = l.trim_start_matches([' ', '\t']); + if indent(l) < 4 && lt.starts_with('>') { + let s = <[1..]; + inner.push(s.strip_prefix(' ').unwrap_or(s).to_string()); + } else if !is_blank(l) && !interrupts(l) && inner.last().is_some_and(|x| !is_blank(x)) { + inner.push(l.clone()); + } else { + break; + } + i += 1; + } + if depth >= MAX_NEST { + let mut p = Para::new(Kind::Quote); + p.push_text(&inner.join(" "), &Fmt::default()); + out.push(FBlock::Para(p)); + } else { + for mut b in parse_blocks(&inner, depth + 1, level, refs) { + if let FBlock::Para(p) = &mut b + && p.kind == Kind::Normal + { + p.kind = Kind::Quote; + } + out.push(b); + } + } + continue; + } + if para.is_empty() + && t.contains('|') + && let Some(aligns) = lines.get(i + 1).and_then(|l| delimiter_row(l)) + { + let head = split_row(t); + if head.len() == aligns.len() { + let mut table = FTable::default(); + let cell = |s: &str, header: bool, a: Option| { + let p = Para { align: a, inlines: parse_inlines(s, &Fmt::default(), depth, refs), ..Default::default() }; + Cell { blocks: vec![FBlock::Para(p)], header, ..Default::default() } + }; + table.rows.push(head.iter().zip(&aligns).map(|(s, a)| cell(s, true, *a)).collect()); + i += 2; + while let Some(l) = lines.get(i) { + if is_blank(l) || !l.contains('|') || interrupts(l) { + break; + } + let mut cells = split_row(l); + cells.resize(aligns.len(), String::new()); + table.rows.push(cells.iter().zip(&aligns).map(|(s, a)| cell(s, false, *a)).collect()); + i += 1; + } + out.push(FBlock::Table(table)); + continue; + } + } + if let Some(m) = list_marker(t) + && (para.is_empty() || ((!m.ordered || m.start == 1) && !is_blank(t.get(m.len..).unwrap_or("")))) + { + flush_para(&mut para, &mut out, depth, refs); + i = parse_list(lines, i, depth, level, &mut out, refs); + continue; + } + para.push(line.clone()); + i += 1; + } + flush_para(&mut para, &mut out, depth, refs); + out +} + +fn marker_space(ind: usize, marker_len: usize, after: &str) -> (usize, usize) { + let mut cols = 0; + let mut bytes = 0; + let mut rest = after; + while let Some(c) = rest.chars().next() { + match c { + ' ' => { + cols += 1; + bytes += 1; + rest = &rest[1..]; + if cols >= 4 { + break; + } + } + '\t' => { + let col = ind + marker_len + cols; + let n = 4 - col % 4; + cols += n; + bytes += 1; + break; + } + _ => break, + } + } + (cols, bytes) +} + +/// Parse a list starting at `lines[i]`; returns the index after it. +fn parse_list(lines: &[String], mut i: usize, depth: usize, level: u8, out: &mut Vec, refs: &Refs) -> usize { + let Some(first) = lines.get(i).and_then(|l| list_marker(l.trim_start_matches([' ', '\t']))) else { return i + 1 }; + while let Some(line) = lines.get(i) { + let ind = indent(line); + if ind >= 4 { + break; + } + let t = line.trim_start_matches([' ', '\t']); + let Some(m) = list_marker(t) else { break }; + if m.ordered != first.ordered || m.ch != first.ch { + break; + } + let after = t.get(m.len..).unwrap_or(""); + let (spaces, skip) = if indent(after) > 4 { + (1, 1) + } else if after.starts_with([' ', '\t']) { + marker_space(ind, m.len, after) + } else { + (0, 0) + }; + let content_indent = if is_blank(after) { ind + m.len + 1 } else { ind + m.len + spaces }; + let item0 = if is_blank(after) { String::new() } else { after.get(skip.min(after.len())..).unwrap_or("").to_string() }; + let mut item: Vec = vec![item0]; + i += 1; + while let Some(l) = lines.get(i) { + if is_blank(l) { + item.push(String::new()); + } else if indent(l) >= content_indent { + item.push(strip_indent(l, content_indent).to_string()); + } else if item.last().is_some_and(|x| !is_blank(x)) && !interrupts(l) && list_marker(l.trim_start_matches([' ', '\t'])).is_none() { + // Lazy continuation of the item's paragraph. + item.push(l.trim_start_matches([' ', '\t']).to_string()); + } else { + break; + } + i += 1; + } + // Blank lines at the end of an item belong between items. + while item.len() > 1 && item.last().is_some_and(String::is_empty) { + item.pop(); + } + let li = ListInfo { ordered: first.ordered, level: level.min(8), start: first.start }; + let inner = if depth >= MAX_NEST { + let mut p = Para::default(); + p.push_text(&item.join(" "), &Fmt::default()); + vec![FBlock::Para(p)] + } else { + parse_blocks(&item, depth + 1, level.saturating_add(1).min(8), refs) + }; + let mut placed = false; + if inner.is_empty() { + out.push(FBlock::Para(Para { list: Some(li), ..Default::default() })); + placed = true; + } + for b in inner { + match b { + FBlock::Para(mut p) if p.list.is_none() => { + if !placed { + p.list = Some(li); + placed = true; + out.push(FBlock::Para(p)); + } else if let Some(FBlock::Para(prev)) = out.last_mut() + && prev.list.is_some_and(|x| x.level == li.level) + { + // A continuation paragraph joins the item with a line break. + prev.push_text("\n", &Fmt::default()); + prev.inlines.extend(p.inlines); + } else { + p.list = Some(li); + out.push(FBlock::Para(p)); + } + } + FBlock::Para(p) => { + if !placed { + // The item starts with a nested list: give it an empty first line. + out.push(FBlock::Para(Para { list: Some(li), ..Default::default() })); + placed = true; + } + out.push(FBlock::Para(p)); + } + other => { + placed = true; + out.push(other); + } + } + } + // Blank lines between items. + while lines.get(i).is_some_and(|l| is_blank(l)) { + let next_is_item = lines.get(i + 1).is_some_and(|l| { + indent(l) < 4 && list_marker(l.trim_start_matches([' ', '\t'])).is_some_and(|m| m.ordered == first.ordered && m.ch == first.ch) + }); + if !next_is_item { + return i; + } + i += 1; + } + } + i +} diff --git a/crates/formats/src/markdown/export.rs b/crates/formats/src/markdown/export.rs new file mode 100644 index 000000000..a6e85553e --- /dev/null +++ b/crates/formats/src/markdown/export.rs @@ -0,0 +1,400 @@ +use super::blocks::thematic_break; +use super::inlines::within; +use super::*; + +// --------------------------------------------------------------------------------------------- +// Export + +/// Escape text for Markdown (inline context). +pub(crate) fn escape(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 8); + for c in s.chars() { + match c { + '\\' | '`' | '*' | '_' | '[' | ']' | '<' | '>' | '~' | '|' | '&' => { + out.push('\\'); + out.push(c); + } + '\u{000C}' | '\u{000E}' => {} + _ => out.push(c), + } + } + out +} + +/// Escape what would start a block at the beginning of a line. Leading spaces are kept, but at +/// most three (a tab counts as four): four would turn the line into an indented code block. +pub(super) fn escape_line_start(s: String) -> String { + let t = s.trim_start_matches([' ', '\t']); + let lead = s.get(..s.len() - t.len()).unwrap_or(""); + let cols: usize = lead.chars().map(|c| if c == '\t' { 4 } else { 1 }).sum(); + let indent = " ".repeat(cols.min(3)); + let needs = t.starts_with('#') + || t.starts_with('>') + || t.starts_with("- ") + || t.starts_with("+ ") + || t.starts_with("-\t") + || t.starts_with("+\t") + || t == "-" + || t == "+" + || t.starts_with('=') + || (t.starts_with('-') && t.trim_end_matches([' ', '\t']).chars().all(|c| c == '-')) + || thematic_break(t) + || { + let d = t.chars().take_while(char::is_ascii_digit).count(); + d > 0 + && t.get(d..).is_some_and(|r| { + r.starts_with(". ") || r.starts_with(") ") || r.starts_with(".\t") || r.starts_with(")\t") || r == "." || r == ")" + }) + }; + if !needs { + return if cols > 3 || lead.contains('\t') { format!("{indent}{t}") } else { s }; + } + if let Some(d) = t.find(['.', ')']).filter(|_| t.starts_with(|c: char| c.is_ascii_digit())) { + return format!("{indent}{}\\{}", &t[..d], &t[d..]); + } + format!("{indent}\\{t}") +} + +/// [`escape_line_start`] for every line of a body, including those after a manual line break. +fn escape_lines(body: String) -> String { + if !body.contains("\\\n") { + return escape_line_start(body); + } + body.split("\\\n").map(|l| escape_line_start(l.to_string())).collect::>().join("\\\n") +} + +fn code_span(t: &str) -> String { + let t = t.replace("\r\n", " ").replace(['\r', '\n'], " "); + let mut longest = 0; + let mut cur = 0; + for c in t.chars() { + if c == '`' { + cur += 1; + longest = longest.max(cur); + } else { + cur = 0; + } + } + let ticks = "`".repeat(longest + 1); + let pad = if t.starts_with('`') || t.ends_with('`') || (t.starts_with(' ') && t.ends_with(' ') && !t.trim().is_empty()) { " " } else { "" }; + format!("{ticks}{pad}{t}{pad}{ticks}") +} + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)] +enum Mark { + Bold, + Italic, + Strike, +} + +/// Inline spans → Markdown with properly nested markers. +pub(super) fn inlines_md(inlines: &[Inline]) -> String { + inlines_md_impl(inlines, false) +} + +fn inlines_md_table(inlines: &[Inline]) -> String { + inlines_md_impl(inlines, true) +} + +fn inlines_md_impl(inlines: &[Inline], in_table: bool) -> String { + // Split into (text, fmt) pieces with whitespace moved outside emphasis. + let mut pieces: Vec<(String, Fmt, Option)> = Vec::new(); + let eq = crate::model::equations_as_text(inlines); + // Markdown keeps no ruby: its base text goes through the text path below. + let inlines = crate::model::ruby_as_text(&eq); + for i in inlines.iter() { + match i { + Inline::Text(t, f) => { + let has_emph = f.bold || f.italic || f.strike; + if has_emph && !f.code { + let core = t.trim_matches([' ', '\t', '\n']); + let lead = &t[..t.len() - t.trim_start_matches([' ', '\t', '\n']).len()]; + let trail = &t[t.trim_end_matches([' ', '\t', '\n']).len()..]; + let plain = Fmt { bold: false, italic: false, strike: false, ..f.clone() }; + if !lead.is_empty() { + pieces.push((lead.to_string(), plain.clone(), None)); + } + if !core.is_empty() { + pieces.push((core.to_string(), f.clone(), None)); + } + if !trail.is_empty() && !core.is_empty() { + pieces.push((trail.to_string(), plain, None)); + } + } else { + pieces.push((t.clone(), f.clone(), None)); + } + } + Inline::Image(img) => { + let name = format!("image.{}", img.ext); + pieces.push((String::new(), Fmt::default(), Some(format!("![{}]({})", escape(&img.alt), name)))); + } + Inline::Anchor(a) => { + let safe: String = a.chars().filter(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.' | ':')).collect(); + pieces.push((String::new(), Fmt::default(), Some(format!("")))); + } + Inline::Figure(alt) => pieces.push((String::new(), Fmt::default(), Some(format!("![{}]()", escape(alt))))), + Inline::Equation { .. } => {} + // Read as its base text by `ruby_as_text` above. + Inline::Ruby { .. } => {} + } + } + let mut out = String::new(); + let mut stack: Vec = Vec::new(); + let mut link: Option> = None; + let close_all = |out: &mut String, stack: &mut Vec| { + while let Some(m) = stack.pop() { + out.push_str(marker(m)); + } + }; + for (text, f, raw) in pieces { + // Links are the outermost construct; a script or other non-web target is plain text. + let f_link = f.link.clone().filter(|l| wordcraft_doc::link::is_safe_href(l)); + if f_link != link || raw.is_some() { + close_all(&mut out, &mut stack); + if let Some(l) = &link { + out.push_str(&format!("]({})", link_target(l))); + } + link = None; + if let Some(r) = raw { + out.push_str(&r); + continue; + } + if let Some(l) = f_link { + out.push('['); + link = Some(l); + } + } + let want: Vec = + [(f.bold, Mark::Bold), (f.italic, Mark::Italic), (f.strike, Mark::Strike)].iter().filter(|x| x.0).map(|x| x.1).collect(); + // Close until the stack is a prefix of what we want. + while let Some(pos) = stack.iter().position(|m| !want.contains(m)) { + while stack.len() > pos { + if let Some(m) = stack.pop() { + out.push_str(marker(m)); + } + } + } + for m in want { + if !stack.contains(&m) { + out.push_str(marker(m)); + stack.push(m); + } + } + if f.code { + if in_table { + let t = text.replace('|', "\\|"); + out.push_str(&code_span(&t)); + } else { + out.push_str(&code_span(&text)); + } + } else { + out.push_str(&escape(&text).replace('\n', "\\\n").replace('\t', " ")); + } + } + close_all(&mut out, &mut stack); + if let Some(l) = &link { + out.push_str(&format!("]({})", link_target(l))); + } + out +} + +/// `l` as a Markdown link destination that reads back as exactly `l`, the target +/// `is_safe_href` passed. What Markdown would decode (a backslash escape, an entity reference) +/// or end the destination on (`<`, `>`) is backslash-escaped, never deleted: deleting `<` or `>` +/// could turn an accepted relative target into `javascript:…` or `//host`. Control characters +/// can't be written in a destination and are left out, as the safety check ignores them. +fn link_target(l: &str) -> String { + let l: String = l.chars().filter(|c| !c.is_ascii_control()).collect(); + let mut out = String::with_capacity(l.len() + 2); + for (i, c) in l.char_indices() { + let rest = l.get(i + c.len_utf8()..).unwrap_or(""); + match c { + '<' | '>' => out.push('\\'), + // A backslash is literal unless punctuation (or the closing `)`/`>`) follows it. + '\\' if rest.chars().next().is_none_or(|n| n.is_ascii_punctuation()) => out.push('\\'), + '&' if entity_like(rest) => out.push('\\'), + _ => {} + } + out.push(c); + } + if l.contains(|c: char| c.is_whitespace() || matches!(c, '(' | ')' | '<' | '>')) { format!("<{out}>") } else { out } +} + +/// Whether `rest` (the text after a `&`) starts like an entity reference (`amp;`, `#106;`, +/// `#x6A;`), which a Markdown reader would decode. +fn entity_like(rest: &str) -> bool { + let Some(end) = within(rest, 34).find(';') else { return false }; + let body = rest.get(..end).unwrap_or(""); + let body = body.strip_prefix('#').map_or(body, |n| n.strip_prefix(['x', 'X']).unwrap_or(n)); + !body.is_empty() && body.chars().all(|c| c.is_ascii_alphanumeric()) +} + +fn marker(m: Mark) -> &'static str { + match m { + Mark::Bold => "**", + Mark::Italic => "*", + Mark::Strike => "~~", + } +} + +pub(super) fn table_md(t: &FTable, out: &mut Vec) { + let cols = t.cols().max(1); + let cell_md = |c: &Cell| -> String { + let mut parts = Vec::new(); + for b in &c.blocks { + match b { + FBlock::Para(p) => parts.push(inlines_md_table(&p.inlines).replace("\\\n", "
")), + FBlock::Table(inner) => parts.push(escape(&inner.rows.iter().flat_map(|r| r.iter().map(Cell::text)).collect::>().join(" "))), + } + } + parts.join("
").replace('\n', " ") + }; + let align_of = |c: &Cell| c.blocks.iter().find_map(|b| if let FBlock::Para(p) = b { Some(p.align) } else { None }).flatten(); + let row_md = |r: &Vec| -> Vec { + let mut v: Vec = Vec::new(); + for c in r { + v.push(if c.covered { String::new() } else { cell_md(c) }); + for _ in 1..c.colspan.clamp(1, 63) { + v.push(String::new()); + } + } + v.resize(cols, String::new()); + v + }; + let Some(head) = t.rows.first() else { return }; + out.push(format!("| {} |", row_md(head).join(" | "))); + let mut aligns: Vec> = Vec::new(); + for c in head { + aligns.push(align_of(c)); + for _ in 1..c.colspan.clamp(1, 63) { + aligns.push(None); + } + } + aligns.resize(cols, None); + let delim: Vec<&str> = aligns + .iter() + .map(|a| match a { + Some(Align::Center) => ":---:", + Some(Align::Right) => "---:", + Some(Align::Left) => ":---", + _ => "---", + }) + .collect(); + out.push(format!("| {} |", delim.join(" | "))); + for r in t.rows.iter().skip(1) { + out.push(format!("| {} |", row_md(r).join(" | "))); + } +} + +/// Markdown text of a document. +pub fn export(doc: &Document) -> String { + let flow = model::from_doc(doc); + let mut out: Vec = Vec::new(); + write_blocks(&flow.blocks, &mut out); + let mut s = out.join("\n"); + while s.ends_with("\n\n") { + s.pop(); + } + if !s.ends_with('\n') { + s.push('\n'); + } + s +} + +pub(super) fn write_blocks(blocks: &[FBlock], out: &mut Vec) { + let mut i = 0; + let mut counters = [0u32; 9]; + let mut prev_list = false; + let mut prev_top_ordered = false; + while let Some(b) = blocks.get(i) { + match b { + FBlock::Table(t) => { + table_md(t, out); + out.push(String::new()); + prev_list = false; + } + FBlock::Para(p) if p.kind == Kind::Code && p.list.is_none() => { + let mut lines = Vec::new(); + while let Some(FBlock::Para(q)) = blocks.get(i) { + if q.kind != Kind::Code || q.list.is_some() { + break; + } + lines.push(q.text()); + i += 1; + } + let longest = lines.iter().map(|l| l.chars().filter(|c| *c == '`').count()).max().unwrap_or(0); + // The fence must be longer than the longest run in the content, or a content + // line is read back as the closing fence (the `.min(64)` cap did exactly that). + let fence = "`".repeat(3.max(longest + 1)); + out.push(fence.clone()); + out.extend(lines); + out.push(fence); + out.push(String::new()); + prev_list = false; + continue; + } + FBlock::Para(p) => { + let body = inlines_md(&p.inlines); + match p.list { + Some(li) => { + let lv = li.level.min(8) as usize; + if !prev_list || (lv == 0 && prev_top_ordered != li.ordered) { + counters = [0; 9]; + } + if lv == 0 { + prev_top_ordered = li.ordered; + } + let n = counters.get_mut(lv).map(|c| { + if *c == 0 { + *c = li.start.saturating_sub(1); + } + *c = c.saturating_add(1); + *c + }); + for d in counters.iter_mut().skip(lv + 1) { + *d = 0; + } + let marker = if li.ordered { format!("{}.", n.unwrap_or(1)) } else { "-".to_string() }; + let pad = " ".repeat(4 * lv); + let cont = " ".repeat(4 * lv + marker.len() + 1); + let body = if p.kind == Kind::Code { + code_span(&p.text()) + } else { + let body = escape_lines(body).replace("\\\n", &format!("\\\n{cont}")); + // The bullet marker can turn a short run of hyphens into a rule. + if thematic_break(&format!("{marker} {body}")) { format!("\\{body}") } else { body } + }; + out.push(format!("{pad}{marker} {body}").trim_end().to_string()); + prev_list = true; + let next_is_list = matches!(blocks.get(i + 1), Some(FBlock::Para(q)) if q.list.is_some()); + if !next_is_list { + out.push(String::new()); + } + } + None => { + prev_list = false; + let line = match p.kind { + Kind::Heading(n) => format!("{} {}", "#".repeat(n.clamp(1, 6) as usize), body.replace("\\\n", " ")), + Kind::Title => format!("# {}", body.replace("\\\n", " ")), + Kind::Rule => "---".to_string(), + Kind::Quote => { + let b = escape_lines(body); + format!("> {}", b.replace("\\\n", "\\\n> ")) + } + _ => { + if body.is_empty() { + i += 1; + continue; + } + escape_lines(body) + } + }; + out.push(line); + out.push(String::new()); + } + } + } + } + i += 1; + } +} diff --git a/crates/formats/src/markdown/inlines.rs b/crates/formats/src/markdown/inlines.rs new file mode 100644 index 000000000..df8bf4569 --- /dev/null +++ b/crates/formats/src/markdown/inlines.rs @@ -0,0 +1,570 @@ +use super::blocks::{MAX_NEST, Refs, ref_label}; +use super::*; + +// ---- inlines ---- + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Emph { + Em, + Strong, + Strike, +} + +enum Node { + Text(String, Fmt), + Inl(Inline), + Delim { ch: char, orig: usize, left: usize, open: bool, close: bool, opens: Vec, closes: Vec }, +} + +fn is_punct(c: char) -> bool { + c.is_ascii_punctuation() || (!c.is_alphanumeric() && !c.is_whitespace() && !c.is_control()) +} + +/// Parse inline Markdown into formatted spans (on top of `base`). +pub fn parse_inlines(s: &str, base: &Fmt, depth: usize, refs: &Refs) -> Vec { + let nodes = tokenize(s, base, depth, refs); + emit(nodes) +} + +fn push_text(nodes: &mut Vec, s: &str, f: &Fmt) { + if s.is_empty() { + return; + } + if let Some(Node::Text(t, pf)) = nodes.last_mut() + && pf == f + { + t.push_str(s); + return; + } + nodes.push(Node::Text(s.to_string(), f.clone())); +} + +/// The closing `]` matching the `[` at byte `open` (escapes and code spans respected). Gives up +/// after looking `max` bytes ahead. Records in `known` every bracket pair it passes, and every `[` +/// still open at the end of the text: a scan from one of those would take the same path. +fn matching_bracket(s: &str, open: usize, max: usize, ticks: &Backticks, known: &mut HashMap>) -> Option { + let b = s.as_bytes(); + let mut opens: Vec = Vec::new(); + let mut i = open; + while let Some(&c) = b.get(i) { + if i - open > max { + return None; + } + match c { + b'\\' => i += 1, + b'`' => { + let n = b.get(i..).map(|r| r.iter().take_while(|x| **x == b'`').count()).unwrap_or(1); + if let Some(end) = ticks.find(i + n, n) { + i = end + n - 1; + } else { + i += n - 1; + } + } + b'[' => opens.push(i), + b']' => { + if let Some(o) = opens.pop() { + known.insert(o, Some(i)); + } + if opens.is_empty() { + return Some(i); + } + } + _ => {} + } + i += 1; + } + for o in opens { + known.insert(o, None); + } + None +} + +/// Where a text's runs of backticks start, by run length. Finding the run that closes a code span +/// is then a lookup: scanning the rest of the text for it, again for every run nothing closes +/// (each of a different length) and from every link bracket over them, was superlinear. +struct Backticks(HashMap>); + +impl Backticks { + fn new(s: &str) -> Self { + let b = s.as_bytes(); + let mut runs: HashMap> = HashMap::new(); + let mut i = 0; + while let Some(&c) = b.get(i) { + if c == b'`' { + let k = b.get(i..).map(|r| r.iter().take_while(|x| **x == b'`').count()).unwrap_or(1); + runs.entry(k).or_default().push(i); + i += k; + } else { + i += 1; + } + } + Self(runs) + } + + /// Byte index of a run of exactly `n` backticks at or after `from` (which is not inside a run). + fn find(&self, from: usize, n: usize) -> Option { + let starts = self.0.get(&n)?; + starts.get(starts.partition_point(|&p| p < from)).copied() + } +} + +/// Parse `(dest "title")` at byte `at` (which holds `(`): (destination, index after `)`). +fn link_dest(s: &str, at: usize) -> Option<(String, usize)> { + let rest = s.get(at..)?.strip_prefix('(')?; + let mut i = at + 1; + let ws = rest.len() - rest.trim_start().len(); + i += ws; + let r = s.get(i..)?; + let (dest, used) = if let Some(inner) = r.strip_prefix('<') { + // The first `>` not escaped by a backslash ends it; a line break first means no link. + let b = inner.as_bytes(); + let mut k = 0; + let end = loop { + match b.get(k)? { + b'\\' => k += 1, + b'>' => break k, + b'\n' => return None, + _ => {} + } + k += 1; + }; + (inner.get(..end)?.to_string(), end + 2) + } else { + let mut depth = 0i32; + let mut end = r.len(); + let mut chars = r.char_indices().peekable(); + while let Some((k, c)) = chars.next() { + match c { + '\\' => { + chars.next(); + } + '(' => depth += 1, + ')' if depth == 0 => { + end = k; + break; + } + ')' => depth -= 1, + c if c.is_whitespace() => { + end = k; + break; + } + _ => {} + } + } + (r.get(..end)?.to_string(), end) + }; + i += used; + let r = s.get(i..)?; + let ws = r.len() - r.trim_start().len(); + i += ws; + let r = s.get(i..)?; + if let Some(q) = r.chars().next().filter(|c| matches!(c, '"' | '\'' | '(')) { + let close = if q == '(' { ')' } else { q }; + let end = r.get(1..)?.find(close)?; + i += end + 2; + let r = s.get(i..)?; + i += r.len() - r.trim_start().len(); + } + if s.get(i..)?.starts_with(')') { Some((unescape(&dest), i + 1)) } else { None } +} + +fn unescape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + if c == '\\' + && let Some(n) = chars.peek().copied().filter(char::is_ascii_punctuation) + { + out.push(n); + chars.next(); + } else { + out.push(c); + } + } + out +} + +/// The value of attribute `name` in an HTML tag's text. +fn tag_attr(tag: &str, name: &str) -> Option { + let lower = tag.to_ascii_lowercase(); + let mut from = 0; + while let Some(k) = lower.get(from..)?.find(name) { + let at = from + k; + let before = lower.get(..at)?.chars().next_back(); + let after = lower.get(at + name.len()..)?.trim_start(); + if before.is_some_and(char::is_whitespace) && after.starts_with('=') { + let v = after.get(1..)?.trim_start(); + let q = v.chars().next()?; + return Some(if q == '"' || q == '\'' { + let inner = v.get(1..)?; + let off = tag.len() - inner.len(); + let end = inner.find(q)?; + tag.get(off..off + end)?.to_string() + } else { + let end = v.find(|c: char| c.is_whitespace() || c == '>').unwrap_or(v.len()); + let off = tag.len() - v.len(); + tag.get(off..off + end)?.to_string() + }); + } + from = at + name.len(); + } + None +} + +fn tokenize(s: &str, base: &Fmt, depth: usize, refs: &Refs) -> Vec { + let mut nodes: Vec = Vec::new(); + // Bytes the link and image scans below may look ahead in total. Each unmatched `[` or `![(` + // would otherwise rescan the rest of the text, which is quadratic on hostile input; once the + // budget is gone, further brackets are plain text. A scan pays for what it reads; brackets an + // earlier scan passed are looked up in `known` instead. + let mut scan_budget = s.len().saturating_mul(8).saturating_add(8192); + let ticks = Backticks::new(s); + let mut known: HashMap> = HashMap::new(); + let b = s.as_bytes(); + let mut i = 0usize; + let mut text_start = 0usize; + macro_rules! flush { + () => { + if text_start < i { + push_text(&mut nodes, s.get(text_start..i).unwrap_or(""), base); + } + }; + } + while let Some(&c) = b.get(i) { + match c { + b'\\' => { + flush!(); + match s.get(i + 1..).and_then(|r| r.chars().next()) { + Some(n) if n.is_ascii_punctuation() => { + push_text(&mut nodes, &n.to_string(), base); + i += 2; + } + _ => { + push_text(&mut nodes, "\\", base); + i += 1; + } + } + text_start = i; + } + b'`' => { + flush!(); + let n = b.get(i..).map(|r| r.iter().take_while(|x| **x == b'`').count()).unwrap_or(1); + match ticks.find(i + n, n) { + Some(end) => { + let mut code = s.get(i + n..end).unwrap_or("").replace('\n', " "); + if code.len() >= 2 && code.starts_with(' ') && code.ends_with(' ') && !code.trim().is_empty() { + code = code[1..code.len() - 1].to_string(); + } + let mut f = base.clone(); + f.code = true; + push_text(&mut nodes, &code, &f); + i = end + n; + } + None => { + push_text(&mut nodes, s.get(i..i + n).unwrap_or(""), base); + i += n; + } + } + text_start = i; + } + b'*' | b'_' | b'~' => { + flush!(); + let ch = c as char; + let n = b.get(i..).map(|r| r.iter().take_while(|x| **x == c).count()).unwrap_or(1); + let before = s.get(..i).and_then(|r| r.chars().next_back()).unwrap_or(' '); + let after = s.get(i + n..).and_then(|r| r.chars().next()).unwrap_or(' '); + let lf = !after.is_whitespace() && (!is_punct(after) || before.is_whitespace() || is_punct(before)); + let rf = !before.is_whitespace() && (!is_punct(before) || after.is_whitespace() || is_punct(after)); + let (open, close) = match ch { + '_' => (lf && (!rf || is_punct(before)), rf && (!lf || is_punct(after))), + '~' if n != 2 => (false, false), + _ => (lf, rf), + }; + if open || close { + nodes.push(Node::Delim { ch, orig: n, left: n, open, close, opens: Vec::new(), closes: Vec::new() }); + } else { + push_text(&mut nodes, s.get(i..i + n).unwrap_or(""), base); + } + i += n; + text_start = i; + } + b'!' | b'[' if depth < MAX_NEST => { + let img = c == b'!'; + let open = if img { i + 1 } else { i }; + let parsed = if b.get(open) == Some(&b'[') { + let found = match known.get(&open).copied() { + Some(found) => found, + None if scan_budget > 0 => { + let found = matching_bracket(s, open, scan_budget, &ticks, &mut known); + scan_budget = scan_budget.saturating_sub(found.unwrap_or(s.len()).saturating_sub(open)); + found + } + None => None, + }; + found.filter(|_| scan_budget > 0).and_then(|close| { + let text = s.get(open + 1..close)?; + if b.get(close + 1) == Some(&b'(') { + // A destination that does not parse may have been read to the end of the text. + let dest = link_dest(s, close + 1); + scan_budget = scan_budget.saturating_sub(dest.as_ref().map_or(s.len(), |d| d.1).saturating_sub(close)); + let (dest, end) = dest?; + return Some((text.to_string(), Arc::from(dest), end)); + } + // A reference link: `[text][label]`, `[text][]` and `[text]` all take + // their destination from a definition collected before parsing (#3238). + // The shortcut form is only a link when the label is defined, so an + // ordinary bracketed phrase is still text. + let (label, end) = match s.get(close + 1..).and_then(|rest| rest.strip_prefix('[')) { + Some(rest) => { + let shut = rest.find(']')?; + let inner = rest.get(..shut)?; + let label = if inner.trim().is_empty() { text } else { inner }; + (label, close + 2 + shut + 1) + } + None => (text, close + 1), + }; + Some((text.to_string(), refs.get(&ref_label(label))?.clone(), end)) + }) + } else { + None + }; + match parsed { + Some((inner, dest, end)) if img => { + flush!(); + let alt: String = parse_inlines(&inner, &Fmt::default(), depth + 1, refs) + .iter() + .filter_map(|x| if let Inline::Text(t, _) = x { Some(t.as_str()) } else { None }) + .collect(); + match model::data_uri(&dest).and_then(|d| make_img(d, None, None, &alt)) { + Some(im) => nodes.push(Node::Inl(Inline::Image(im))), + None => push_text(&mut nodes, &alt, base), + } + i = end; + text_start = i; + } + Some((inner, dest, end)) if base.link.is_none() => { + flush!(); + let mut f = base.clone(); + // A destination too long to keep leaves its text unlinked. + f.link = (dest.len() <= model::MAX_LINK).then_some(dest); + if f.link.is_none() { + model::dropped(model::Dropped::Link); + } + for x in parse_inlines(&inner, &f, depth + 1, refs) { + nodes.push(Node::Inl(x)); + } + i = end; + text_start = i; + } + _ => i += 1, + } + } + b'<' => { + if let Some(pos) = s.get(i..).and_then(|r| r.strip_prefix("")) { + flush!(); + i += 4 + pos + 3; + text_start = i; + continue; + } + let rest = s.get(i + 1..).unwrap_or(""); + let end = within(rest, MAX_TAG).find('>'); + let inner = end.and_then(|e| rest.get(..e)).unwrap_or(""); + let lower = inner.to_ascii_lowercase(); + let is_auto = !inner.is_empty() + && !inner.contains([' ', '<', '\n']) + && (inner.contains("://") || lower.starts_with("mailto:") || (inner.contains('@') && !inner.contains('/'))); + if is_auto { + flush!(); + let mut f = base.clone(); + f.link = Some(if inner.contains('@') && !inner.contains(':') { format!("mailto:{inner}").into() } else { inner.into() }); + push_text(&mut nodes, inner, &f); + i += inner.len() + 2; + text_start = i; + } else if matches!(lower.trim_end_matches('/').trim(), "br") { + flush!(); + push_text(&mut nodes, "\n", base); + i += inner.len() + 2; + text_start = i; + } else if lower.starts_with("a ") && rest.get(inner.len() + 1..).is_some_and(|r| r.to_ascii_lowercase().starts_with("")) { + flush!(); + if let Some(name) = tag_attr(inner, "id").or_else(|| tag_attr(inner, "name")) { + nodes.push(Node::Inl(Inline::Anchor(name))); + } + i += inner.len() + 2 + 4; + text_start = i; + } else { + i += 1; + } + } + b'&' => { + let rest = s.get(i..).unwrap_or(""); + match within(rest, 33).find(';').and_then(|k| decode_entity(rest.get(1..k)?).map(|ch| (ch, k))) { + Some((ch, k)) => { + flush!(); + push_text(&mut nodes, &ch, base); + i += k + 1; + text_start = i; + } + None => i += 1, + } + } + _ => i += 1, + } + } + flush!(); + nodes +} + +/// Longest `<...>` tag or autolink taken as inline HTML. +const MAX_TAG: usize = 4096; + +/// The first `n` bytes of `s` (fewer if that would cut a character), so a search for a closing +/// character stays short however long the text is. +pub(super) fn within(s: &str, n: usize) -> &str { + let mut end = n.min(s.len()); + while !s.is_char_boundary(end) { + end -= 1; + } + s.get(..end).unwrap_or("") +} + +fn emph_index(ch: char) -> usize { + match ch { + '*' => 0, + '_' => 1, + _ => 2, + } +} + +/// The CommonMark "process emphasis" pass over the delimiter runs, then flattening into spans. +fn emit(mut nodes: Vec) -> Vec { + // Delimiter stack of node indexes; bottoms per (char, closer length % 3, closer can open). + let mut stack: Vec = Vec::new(); + let mut bottom = [[[None::; 2]; 3]; 3]; + for c in 0..nodes.len() { + let (ch, corig, copen, cclose) = match nodes.get(c) { + Some(Node::Delim { ch, orig, open, close, .. }) => (*ch, *orig, *open, *close), + _ => continue, + }; + if cclose { + loop { + let cleft = match nodes.get(c) { + Some(Node::Delim { left, .. }) => *left, + _ => 0, + }; + if cleft == 0 { + break; + } + let bot = bottom.get(emph_index(ch)).and_then(|x| x.get(corig % 3)).and_then(|x| x.get(copen as usize)).copied().flatten(); + let mut found = None; + for (k, &o) in stack.iter().enumerate().rev() { + if bot.is_some_and(|b| o <= b) { + break; + } + if let Some(Node::Delim { ch: och, orig: oorig, left: oleft, open: oopen, close: oclose, .. }) = nodes.get(o) { + if *och != ch || !*oopen || *oleft == 0 { + continue; + } + if ch == '~' && (*oleft < 2 || cleft < 2) { + continue; + } + let odd = (*oclose || copen) && (oorig + corig) % 3 == 0 && !(oorig % 3 == 0 && corig % 3 == 0); + if odd { + continue; + } + found = Some((k, o, *oleft)); + break; + } + } + let Some((k, o, oleft)) = found else { + if let Some(slot) = bottom.get_mut(emph_index(ch)).and_then(|x| x.get_mut(corig % 3)).and_then(|x| x.get_mut(copen as usize)) { + *slot = Some(c.saturating_sub(1)); + } + break; + }; + let (used, kind) = if ch == '~' { + (2, Emph::Strike) + } else if oleft >= 2 && cleft >= 2 { + (2, Emph::Strong) + } else { + (1, Emph::Em) + }; + if let Some(Node::Delim { left, opens, .. }) = nodes.get_mut(o) { + *left -= used; + opens.push(kind); + } + if let Some(Node::Delim { left, closes, .. }) = nodes.get_mut(c) { + *left -= used; + closes.push(kind); + } + // Delimiters between opener and closer can no longer match. + stack.truncate(k + 1); + if oleft - used == 0 { + stack.truncate(k); + } + } + } + let left = match nodes.get(c) { + Some(Node::Delim { left, .. }) => *left, + _ => 0, + }; + if copen && left > 0 { + stack.push(c); + } + } + // Flatten. + let mut out: Vec = Vec::new(); + let (mut em, mut strong, mut strike) = (0usize, 0usize, 0usize); + let push = |out: &mut Vec, t: &str, f: &Fmt, em: usize, strong: usize, strike: usize| { + if t.is_empty() { + return; + } + let mut f = f.clone(); + f.italic |= em > 0; + f.bold |= strong > 0; + f.strike |= strike > 0; + if let Some(Inline::Text(pt, pf)) = out.last_mut() + && *pf == f + { + pt.push_str(t); + return; + } + out.push(Inline::Text(t.to_string(), f)); + }; + // Delimiter literals take the formatting (link, …) of the text before them: the last text + // node, tracked here rather than searched for backwards from every delimiter (quadratic on + // a paragraph of delimiters). + let mut last_text: Option<&Fmt> = None; + for n in &nodes { + match n { + Node::Text(t, f) => { + push(&mut out, t, f, em, strong, strike); + last_text = Some(f); + } + Node::Inl(Inline::Text(t, f)) => push(&mut out, t, f, em, strong, strike), + Node::Inl(other) => out.push(other.clone()), + Node::Delim { ch, left, opens, closes, .. } => { + for e in closes { + match e { + Emph::Em => em = em.saturating_sub(1), + Emph::Strong => strong = strong.saturating_sub(1), + Emph::Strike => strike = strike.saturating_sub(1), + } + } + if *left > 0 { + let lit: String = std::iter::repeat_n(*ch, *left).collect(); + let f = last_text.map_or_else(Fmt::default, |f| Fmt { bold: false, italic: false, strike: false, ..f.clone() }); + push(&mut out, &lit, &f, em, strong, strike); + } + for e in opens { + match e { + Emph::Em => em += 1, + Emph::Strong => strong += 1, + Emph::Strike => strike += 1, + } + } + } + } + } + out +}