Files
craft-libs/crates/markdown/src/blocks.rs
T
Dylan M. Taylor 1e3d3968c1 Add craft-markdown: Markdown read and written, on craft-flow (stacked on #1025) (#1030)
* Add craft-wordmodel: WordCraft's document model without its editing code

WordCraft's crates/doc (wordcraft-doc, at 07f97737) moved as it is, so the DOCX,
RTF, ODT and other text-format crates can read into and write from one model.
Module paths, type names and serde attributes are unchanged: an app that
re-exports the crate keeps its native file format.

Left in the app: range editing (edit.rs), IME text and equation editing. Not
model, and already in craft-libs: code pages (craft-rtf), the ZIP entry-count
check (craft-opc), the compound-file guard (craft-cfb). The paragraph walks
moved to walk.rs and the unit helpers of wordcraft-geom to geom.rs.

* craft-wordmodel: math_tree, the arguments of an equation node and a walk over its runs

The DOCX reader and writer walk an equation's runs. The walk and the child
accessors it uses were in WordCraft's equation editor (math_edit.rs), which
stays in the app; they are structure, not editing.

* craft-wordmodel: keep the document operations (edit.rs) with the model

The DOCX and RTF readers use them: alternative-format chunks are merged into
a document with adopt_blocks, and a reader formats ranges with format_range
and inserts objects. They operate on the document, not on a view, so they
belong beside it. The tests that drove them come back with them; the
paragraph walks stay in walk.rs and edit re-exports them.

base64 0.23, as WordCraft and craft-offcrypto use.

* Add craft-flow: the flow model the text formats map to, on craft-wordmodel

WordCraft's crates/formats/src/model (07f97737) moved as it is: Flow,
FBlock, Para, Inline, Fmt, tables and cells, pictures, to_doc and
from_doc, picture sniffing, colours, base64 and the Dropped report. Only
the paths changed (wordcraft_doc is craft_wordmodel); the report helpers
are public so the format crates can call them; the image re-encoding
helper stays with WordCraft's RTF writer.

* Add craft-markdown: Markdown read and written, on craft-flow

WordCraft's crates/formats/src/markdown (07f97737) with its 35 tests,
moved onto craft-flow and craft-wordmodel. Character references come from
craft-html. The table cell budget lives in craft-flow, which names it in
its warning. Two test lines that wrote plain text through WordCraft's txt
module stay in WordCraft.
2026-11-11 20:46:15 -05:00

728 lines
26 KiB
Rust

use super::inlines::parse_inlines;
use super::{Align, Arc, Cell, Document, FBlock, FTable, Fmt, HashMap, Kind, ListInfo, MAX_COLS, MAX_ROWS, Para, model};
/// 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: &craft_wordmodel::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<String, Arc<str>>;
/// 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::<Vec<_>>().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<String>) -> 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<String> = text.split('\n').map(|l| expand_tabs(l.trim_end_matches('\r'))).collect();
let refs = take_definitions(&mut lines);
let budget = std::cell::Cell::new(super::MAX_TABLE_CELLS);
let mut blocks = parse_blocks(&lines, 0, 0, &refs, &budget);
// 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<Marker> {
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::<u32>().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 }
}
/// A table row without its leading and trailing pipes (an escaped last pipe stays).
fn row_body(l: &str) -> &str {
let t = l.trim();
let t = t.strip_prefix('|').unwrap_or(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
}
}
/// The first `keep` cells of a table row, and how many cells it has: the rest are only counted.
pub(super) fn split_row(l: &str, keep: usize) -> (Vec<String>, usize) {
let mut cells = Vec::new();
let mut count = 0usize;
let mut cur = String::new();
let mut chars = row_body(l).chars().peekable();
while let Some(c) = chars.next() {
let full = cells.len() >= keep;
match c {
'\\' => match chars.peek() {
Some(&'|') => {
if !full {
cur.push('|');
}
chars.next();
}
Some(&'\\') => {
if !full {
cur.push_str("\\\\");
}
chars.next();
}
_ if !full => cur.push('\\'),
_ => {}
},
'|' => {
if !full {
cells.push(std::mem::take(&mut cur).trim().to_string());
}
count += 1;
}
_ if !full => cur.push(c),
_ => {}
}
}
if cells.len() < keep {
cells.push(cur.trim().to_string());
}
(cells, count + 1)
}
/// The alignments of the first `keep` cells of a delimiter row, and how many cells it has; every
/// cell is checked, so a bad one past `keep` still means it isn't a delimiter row.
fn delimiter_row(l: &str, keep: usize) -> Option<(Vec<Option<Align>>, usize)> {
if !l.contains('-') {
return None;
}
let mut out = Vec::new();
let mut count = 0usize;
// A valid cell is only dashes, colons and spaces, so an escaped pipe can't be in one.
for c in row_body(l).split('|') {
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;
}
if out.len() < keep {
out.push(match (left, right) {
(true, true) => Some(Align::Center),
(false, true) => Some(Align::Right),
(true, false) => Some(Align::Left),
_ => None,
});
}
count += 1;
}
Some((out, count))
}
/// 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("<!--")
|| list_marker(t).is_some_and(|m| (!m.ordered || m.start == 1) && !is_blank(t.get(m.len..).unwrap_or("")))
}
fn flush_para(lines: &mut Vec<String>, out: &mut Vec<FBlock>, depth: usize, refs: &Refs) {
if lines.is_empty() {
return;
}
let text = join_lines(lines);
lines.clear();
let inlines = parse_inlines(&text, &Fmt::default(), depth, refs);
if inlines.is_empty() {
return;
}
let p = Para { inlines, ..Default::default() };
out.push(FBlock::Para(p));
}
/// Paragraph lines → text with `\n` for hard breaks and spaces for soft ones.
fn join_lines(lines: &[String]) -> String {
let mut s = String::new();
let n = lines.len();
for (i, l) in lines.iter().enumerate() {
let l = l.trim_start_matches([' ', '\t']);
if i + 1 == n {
s.push_str(l.trim_end());
} else if l.ends_with(" ") {
s.push_str(l.trim_end());
s.push('\n');
} else if l.ends_with('\\') && !l.ends_with("\\\\") {
s.push_str(&l[..l.len() - 1]);
s.push('\n');
} else {
s.push_str(l.trim_end());
s.push(' ');
}
}
s
}
fn code_para(line: &str) -> FBlock {
let mut p = Para::new(Kind::Code);
p.push_text(line, &Fmt::default());
FBlock::Para(p)
}
/// Parse block structure. `level` is the list nesting level for lists found here.
/// `budget`: the table cells the document may still make ([`super::MAX_TABLE_CELLS`]).
fn parse_blocks(lines: &[String], depth: usize, level: u8, refs: &Refs, budget: &std::cell::Cell<usize>) -> Vec<FBlock> {
let mut out = Vec::new();
let mut para: Vec<String> = Vec::new();
let mut i = 0;
while let Some(line) = lines.get(i) {
if is_blank(line) {
flush_para(&mut para, &mut out, depth, refs);
i += 1;
continue;
}
let ind = indent(line);
if ind >= 4 {
if !para.is_empty() {
para.push(line.clone());
i += 1;
continue;
}
parse_indented_code(lines, &mut i, &mut out);
continue;
}
let t = line.trim_start_matches([' ', '\t']);
// Setext heading underline.
if !para.is_empty() {
let u = t.trim_end();
if !u.is_empty() && (u.chars().all(|c| c == '=') || (u.chars().all(|c| c == '-'))) {
let lvl = if u.starts_with('=') { 1 } else { 2 };
let text = join_lines(&para);
para.clear();
let mut p = Para::new(Kind::Heading(lvl));
p.inlines = parse_inlines(&text, &Fmt::default(), depth, refs);
out.push(FBlock::Para(p));
i += 1;
continue;
}
}
if let Some((n, body)) = atx_heading(t) {
flush_para(&mut para, &mut out, depth, refs);
let mut p = Para::new(Kind::Heading(n));
p.inlines = parse_inlines(body, &Fmt::default(), depth, refs);
out.push(FBlock::Para(p));
i += 1;
continue;
}
if thematic_break(t) {
flush_para(&mut para, &mut out, depth, refs);
out.push(FBlock::Para(Para::new(Kind::Rule)));
i += 1;
continue;
}
if let Some((fc, fl)) = fence(t) {
flush_para(&mut para, &mut out, depth, refs);
i += 1;
while let Some(l) = lines.get(i) {
i += 1;
let lt = l.trim_start_matches([' ', '\t']);
if indent(l) < 4 && fence(lt).is_some_and(|(c, n)| c == fc && n >= fl) && lt.trim_start_matches(fc).trim().is_empty() {
break;
}
out.push(code_para(strip_indent(l, ind)));
}
continue;
}
if t.starts_with("<!--") {
flush_para(&mut para, &mut out, depth, refs);
while let Some(l) = lines.get(i) {
i += 1;
if l.contains("-->") {
break;
}
}
continue;
}
if t.starts_with('>') {
flush_para(&mut para, &mut out, depth, refs);
parse_quote(lines, &mut i, depth, level, refs, budget, &mut out);
continue;
}
if para.is_empty()
&& t.contains('|')
&& let Some((aligns, width)) = lines.get(i + 1).and_then(|l| delimiter_row(l, MAX_COLS))
{
let (head, head_width) = split_row(t, MAX_COLS);
if head_width == width {
let mut table = FTable::default();
let cell = |s: &str, header: bool, a: Option<Align>| {
let p = Para { align: a, inlines: parse_inlines(s, &Fmt::default(), depth, refs), ..Default::default() };
Cell { blocks: vec![FBlock::Para(p)], header, ..Default::default() }
};
// The document keeps no more columns or rows than this, nor cells past its budget,
// so those aren't built; their lines are still the table's.
let cols = aligns.len();
let fits = |rows: usize| rows < MAX_ROWS && budget.get() >= cols;
let mut cut = head_width > cols;
if fits(0) {
budget.set(budget.get() - cols);
table.rows.push(head.iter().zip(&aligns).map(|(s, a)| cell(s, true, *a)).collect());
} else {
cut = true;
}
i += 2;
while let Some(l) = lines.get(i) {
if is_blank(l) || !l.contains('|') || interrupts(l) {
break;
}
if !table.rows.is_empty() && fits(table.rows.len()) {
budget.set(budget.get() - cols);
let (mut cells, _) = split_row(l, cols);
cells.resize(cols, String::new());
table.rows.push(cells.iter().zip(&aligns).map(|(s, a)| cell(s, false, *a)).collect());
} else {
cut = true;
}
i += 1;
}
if cut {
model::dropped(model::Dropped::TableCells);
}
if !table.rows.is_empty() {
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, budget);
continue;
}
para.push(line.clone());
i += 1;
}
flush_para(&mut para, &mut out, depth, refs);
out
}
fn parse_indented_code(lines: &[String], i: &mut usize, out: &mut Vec<FBlock>) {
let mut code: Vec<&str> = Vec::new();
while let Some(line) = lines.get(*i) {
if is_blank(line) {
code.push("");
} else if indent(line) >= 4 {
code.push(strip_indent(line, 4));
} else {
break;
}
*i += 1;
}
while code.last().is_some_and(|line| line.is_empty()) {
code.pop();
}
out.extend(code.into_iter().map(code_para));
}
fn parse_quote(lines: &[String], i: &mut usize, depth: usize, level: u8, refs: &Refs, budget: &std::cell::Cell<usize>, out: &mut Vec<FBlock>) {
let mut inner = Vec::new();
while let Some(line) = lines.get(*i) {
let trimmed = line.trim_start_matches([' ', '\t']);
if indent(line) < 4 && trimmed.starts_with('>') {
let content = &trimmed[1..];
inner.push(content.strip_prefix(' ').unwrap_or(content).to_string());
} else if !is_blank(line) && !interrupts(line) && inner.last().is_some_and(|line| !is_blank(line)) {
inner.push(line.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));
return;
}
for mut block in parse_blocks(&inner, depth + 1, level, refs, budget) {
if let FBlock::Para(p) = &mut block
&& p.kind == Kind::Normal
{
p.kind = Kind::Quote;
}
out.push(block);
}
}
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<FBlock>, refs: &Refs, budget: &std::cell::Cell<usize>) -> 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<String> = 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, ..Default::default() };
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, budget)
};
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
}