Files
craft-libs/crates/html/src/doc/builder.rs
T
Dylan M. Taylorandshuff57 db616480e2 craft-html: HTML documents on craft-flow (feature flow), from WordCraft (#1050)
* 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-mime: detect, for bytes that name no character set

is_text, decode (BOM, unmarked UTF-16, UTF-8, else the legacy encoding
the bytes look like), legacy and legacy_label, behind a detect feature
that brings in chardetng. Moved from WordCraft's crates/formats/src/txt.rs
at 06c2ae6f with its text-sniffing tests. Also charset::is_known, so a
caller can tell a label decode would read as UTF-8 from one it knows.

* 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.

* craft-codepage: one table per legacy code page, for craft-rtf, craft-dxf and craft-mime

craft-rtf and craft-dxf each carried tables for DOS 437, 737, 850, 852 and
857 (and craft-rtf one for 866). They are now one set in craft-codepage,
which takes craft-rtf's codepage module (Windows and Mac tables, OEM glyphs,
font charsets) and craft-dxf's DOS tables. craft_rtf::codepage re-exports it
unchanged; craft-dxf keeps its $DWGCODEPAGE names and Decoder and looks the
DOS tables up there.

Every byte of every code page either crate decodes is unchanged: the
public decoders of both crates were dumped over all 256 bytes of each page
(and the East Asian pairs) before and after, and the dumps are identical.

craft-mime now reads the DOS pages by their IANA names (IBM437, cp850,
ibm-852, 857...), which it read as UTF-8 before.

* Add craft-math: the equation tree with OMML, MathML, LaTeX and UnicodeMath

The math tree, LaTeX, linear-format, symbol and tree-walk modules come
from craft-wordmodel (storytold/craft-libs#963, WordCraft's crates/doc at
07f97737) unchanged, with the run colour (Rgb) and the symbol-font tables
they use. MathML is WordCraft's crates/formats/src/mathml.rs and OMML is
WordCraft's crates/docx/src/read/math.rs and write/math.rs (06c2ae6f /
07f97737), moved onto craft-xml. The HTML-specific MathML tests stay in
WordCraft; the rest moved with the code.

mathml::parse and omml::parse/read_str/write_str are new: string entry
points for callers that have no tree of their own.

* craft-math: read MathLive's and other spellings of LaTeX math commands

\abs{...} (an absolute value), \lvert \rvert \lVert \rVert, \nparallel,
\backprime, \infinity, \cross, \differentialD, \exponentialE and
\imaginaryI (MathLive's d, upright e and i), \emph, \textsf, \hbox,
\mathnormal and \underleftarrow / \underrightarrow /
\underleftrightarrow were unknown and read as literal text. They are read
as equaSHion's typed-input parser reads them (storytold/craft-libs#29).

* craft-math: equaSHion's equation corpus as test data

corpus/equations.tsv, corpus/latex.tsv and the OMML samples of
storytold/craft-libs#29 (7a6ef6b9), unmodified.

* craft-math: corpus round trips, GridCraft's OMML cases, entry points and limits tested; README

The corpus equations go through OMML, MathML and LaTeX and back; the OMML
samples are written back as kept and from the tree. GridCraft's OMML to
LaTeX test cases (crates/xlsx/src/omml.rs) give LaTeX that reads back the
same through the tree. omml::parse and read_str are tested against other
prefixes, the strict namespace, kept OMML, junk, the element cap and deep
nesting.

* 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.

* WIP: craft-latex sources from WordCraft (unmodified)

* Add craft-latex: LaTeX documents read and written, on craft-flow

WordCraft's crates/formats/src/latex (07f97737) with its 21 tests, moved
onto craft-flow and craft-wordmodel; bytes are decoded by craft-mime's
detect. The LaTeX parts of WordCraft's cross-format tests (nesting,
column specs, labels, no TeX commands from the document, the fuzz and
round-trip properties) come along as tests/hostile.rs. The ligature test
builds its three-font paragraph as a flow instead of reading HTML.

* craft-math: latex_text, LaTeX math and the linear format converted as text

WordCraft's crates/formats/src/latex/math.rs (07f97737): the converter its
LaTeX document reader and writer use, which keeps the source's spacing.
It moves here so craft-math is the one place that converts LaTeX math;
craft-latex calls it, and its output does not change. Its tests (from
WordCraft's latex tests and cross-format tests) come along.

* craft-math: latex_text's group finders are public, for craft-latex's length parser

* craft-latex: math through craft_math::latex_text; label timing test by ratio

The document math converter moved to craft-math (#1040) unchanged; this
crate calls it there (and its length parser uses its group finders), so
export output is the same. The label test compared one read with a
ten-second wall clock and failed on a loaded machine; it now compares the
fastest of three reads of 25,000 and 100,000 labels, which a linear read
keeps near 4x and a quadratic one near 16x.

* Cargo.lock after the merge

* WIP: WordCraft's HTML document reader and writer, unmodified (crates/formats/src/html at 07f97737)

* craft-html: HTML documents on craft-flow, behind a flow feature

WordCraft's crates/formats/src/html (07f97737) as craft_html::doc: the
tag-soup builder, CSS subset, ruby and MathML reading, page decoding and
the writer, with its 44 tests. It reads tokens from this crate's
tokenizer through a small adapter (decoded text and attributes, names in
lower case) instead of its own lexer, and its character references are
this crate's. Lexer::set_pos is new: the ruby reader hands a token back.
The HTML parts of WordCraft's MathML and cross-format tests come along as
tests/doc.rs, the linear-time check as a ratio.

* craft-math: operator names take their LaTeX scripts directly again (restores #1019)

#1019 (22161d2d) wrote \sin^2\theta as \sin ^{2}{\theta } so it reads back as
the same tree. The squash merge of #1038, whose branch predated it, put
math_latex.rs and the corpus test back as they were before. This applies
#1019's change again, unchanged, with its test.

* Cargo.lock: craft-html's flow dependencies

---------

Co-authored-by: shuff57 <62350898+shuff57@users.noreply.github.com>
2026-11-11 21:33:47 -05:00

1194 lines
47 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::css::{apply_style, css_len_pt, strip_important};
use super::import::{PAGE_FONT, PAGE_HEADING_SIZES, collapse_ws};
use super::{
Align, Arc, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, LineSpacing, ListInfo, Meta, Para, Spacing, VAlign, data_uri, make_img, model,
parse_color,
};
/// Open elements kept at most (deeper start tags are ignored).
const MAX_STACK: usize = 256;
// ---------------------------------------------------------------------------------------------
// Builder
#[derive(Clone, Copy, PartialEq, Debug)]
pub(super) enum ElKind {
Inline,
Block,
Heading(u8),
Quote,
Pre,
List(bool),
Li,
Table,
Tr,
Cell,
Head,
}
#[derive(Clone, Debug)]
struct El {
name: String,
kind: ElKind,
fmt: Fmt,
/// Margins, indents and line height its CSS states (blocks only; they don't inherit; on a
/// page's spans, their margins).
spacing: Spacing,
/// A page's `p` or heading: margins its CSS leaves out (or writes invalidly) are HTML's
/// automatic spacing.
auto_margins: bool,
align: Option<Align>,
preserve: bool,
page_break: bool,
page_break_after: bool,
/// Li: a paragraph of this item was already emitted.
used: bool,
}
struct TableB {
rows: Vec<Vec<Cell>>,
row: Option<Vec<Cell>>,
/// The table or one of its cells asks for borders.
bordered: bool,
}
struct ElementStyle {
fmt: Fmt,
align: Option<Align>,
preserve: bool,
page_break: bool,
page_break_after: bool,
}
fn apply_font_attributes(attrs: &[(String, String)], fmt: &mut Fmt) {
if let Some(color) = attr(attrs, "color").and_then(parse_color) {
fmt.color = Some(color);
}
if let Some(face) = attr(attrs, "face") {
let family = face.split(',').next().unwrap_or("").trim().to_string();
if model::is_mono(&family) {
fmt.code = true;
} else if !family.is_empty() {
fmt.font = model::kept_font(&family).or(fmt.font.take());
}
}
let Some(size) = attr(attrs, "size") else { return };
let size = size.trim();
let points = if let Some(rest) = size.strip_prefix('+') {
rest.parse::<i32>().ok().map(|delta| 3i32.saturating_add(delta))
} else if let Some(rest) = size.strip_prefix('-') {
rest.parse::<i32>().ok().map(|delta| 3i32.saturating_sub(delta))
} else {
size.parse::<i32>().ok()
};
if let Some(size) = points {
fmt.size = Some([7.5, 10.0, 12.0, 13.5, 18.0, 24.0, 36.0].get((size.clamp(1, 7) - 1) as usize).copied().unwrap_or(12.0));
}
}
/// Whether an element's `border` attribute or its CSS declares a visible border.
fn declares_border(attrs: &[(String, String)], css: &str, is_table: bool) -> bool {
if is_table && attr(attrs, "border").is_some_and(|b| b.trim().parse::<u32>().map(|n| n > 0).unwrap_or(b.trim().is_empty())) {
return true;
}
// Each side's last declaration decides (the `style` attribute's come after the sheet's).
let mut sides = [false; 4];
for (name, value) in decls(css) {
let set = match name.as_str() {
"border" => 0..4,
"border-top" => 0..1,
"border-right" => 1..2,
"border-bottom" => 2..3,
"border-left" => 3..4,
_ => continue,
};
let value = value.to_ascii_lowercase();
let on = !value.is_empty() && !value.split_whitespace().any(|tok| matches!(tok, "none" | "hidden" | "0" | "0px" | "0pt"));
if let Some(s) = sides.get_mut(set) {
s.fill(on);
}
}
sides.contains(&true)
}
/// A declaration block's declarations, at most [`MAX_DECLS`]: (property in lower case, value
/// without `!important`).
pub(super) fn decls(css: &str) -> impl Iterator<Item = (String, &str)> {
css.split(';').take(MAX_DECLS).filter_map(|d| {
let (k, v) = d.split_once(':')?;
Some((k.trim().to_ascii_lowercase(), strip_important(v)))
})
}
/// Loads the bytes of an image an `img` names by a source other than a `data:` URI (a path
/// relative to the HTML file). `None` when it can't be found. A picture used more than once can
/// share one buffer.
pub type ImageLoader<'a> = &'a dyn Fn(&str) -> Option<Arc<Vec<u8>>>;
pub(super) struct Builder<'r> {
images: ImageLoader<'r>,
containers: Vec<Vec<FBlock>>,
cells: Vec<Cell>,
tables: Vec<TableB>,
stack: Vec<El>,
para: Option<Para>,
/// The current paragraph continues a list item (joins the previous paragraph).
para_cont: bool,
/// Pending page break from preceding elements with page-break-after or unconsumed break.
pending_page_break: bool,
/// Whether the current paragraph's first / last text was read with preserved white space
/// (CSS `white-space: pre…`): those edge spaces are kept when the paragraph ends.
pre_lead: Option<bool>,
pre_tail: bool,
meta: Meta,
title: Option<String>,
/// Rules of the page's `style` elements.
pub(super) sheet: Sheet,
/// Read as Word opens a web page ([`import_page`]), not as a pasted fragment.
page: bool,
/// What is left of [`MAX_CSS_WORK`].
css_work: usize,
}
fn is_void(n: &str) -> bool {
matches!(
n,
"area"
| "base"
| "br"
| "col"
| "embed"
| "hr"
| "img"
| "input"
| "link"
| "meta"
| "param"
| "source"
| "track"
| "wbr"
| "basefont"
| "frame"
)
}
pub(super) fn block_kind(n: &str) -> Option<ElKind> {
Some(match n {
"p" | "div" | "section" | "article" | "header" | "footer" | "main" | "nav" | "aside" | "figure" | "figcaption" | "address" | "dl" | "dt"
| "dd" | "center" | "form" | "fieldset" | "body" | "html" | "caption" | "thead" | "tbody" | "tfoot" | "details" | "summary" | "legend"
| "hgroup" => ElKind::Block,
"h1" | "h2" | "h3" | "h4" | "h5" | "h6" => ElKind::Heading(n.get(1..2).and_then(|d| d.parse().ok()).unwrap_or(1)),
"blockquote" => ElKind::Quote,
"pre" | "listing" | "xmp" | "plaintext" => ElKind::Pre,
"ul" | "menu" | "dir" => ElKind::List(false),
"ol" => ElKind::List(true),
"li" => ElKind::Li,
"table" => ElKind::Table,
"tr" => ElKind::Tr,
"td" | "th" => ElKind::Cell,
"head" => ElKind::Head,
_ => return None,
})
}
/// Closes an open `p` (HTML's "close a p element" on block starts).
fn closes_p(n: &str) -> bool {
matches!(
n,
"address"
| "article"
| "aside"
| "blockquote"
| "div"
| "dl"
| "fieldset"
| "footer"
| "form"
| "h1"
| "h2"
| "h3"
| "h4"
| "h5"
| "h6"
| "header"
| "hr"
| "main"
| "nav"
| "ol"
| "p"
| "pre"
| "section"
| "table"
| "ul"
| "figure"
| "center"
| "dd"
| "dt"
| "li"
)
}
/// The paragraph spacing a block's CSS states: `margin` (and its sides) as space before / after
/// and left / right indents, `text-indent` as the first-line indent, `line-height` as line
/// spacing (a number or percentage: a multiple; a length: exactly). `auto` and values that don't
/// parse state nothing.
fn block_spacing(css: &str) -> Spacing {
let mut sp = Spacing::default();
// A length with a unit, or 0: a bare number, a percentage or a keyword states nothing.
let len = |v: &str| match v.trim() {
"0" => Some(0.0),
v if v.ends_with('%') || v.parse::<f32>().is_ok() || !v.starts_with(|c: char| c.is_ascii_digit() || matches!(c, '.' | '-' | '+')) => None,
v => css_len_pt(v).filter(|x| x.abs() <= 1584.0),
};
for (k, v) in decls(css) {
match k.as_str() {
"margin" => {
let parts: Vec<Option<f32>> = v.split_whitespace().map(len).collect();
let [top, right, bottom, left] = match parts.as_slice() {
[a] => [*a; 4],
[a, b] => [*a, *b, *a, *b],
[a, b, c] => [*a, *b, *c, *b],
[a, b, c, d] => [*a, *b, *c, *d],
_ => continue,
};
sp.before = top.map(|x| x.max(0.0)).or(sp.before);
sp.after = bottom.map(|x| x.max(0.0)).or(sp.after);
sp.left = left.or(sp.left);
sp.right = right.or(sp.right);
}
"margin-top" => sp.before = len(v).map(|x| x.max(0.0)).or(sp.before),
"margin-bottom" => sp.after = len(v).map(|x| x.max(0.0)).or(sp.after),
"margin-left" => sp.left = len(v).or(sp.left),
"margin-right" => sp.right = len(v).or(sp.right),
"text-indent" => sp.first = len(v).or(sp.first),
"line-height" => {
let lv = v.to_ascii_lowercase();
let multiple = |m: f32| (m.is_finite() && m > 0.0).then(|| LineSpacing::Multiple(m.clamp(0.25, 10.0)));
sp.line = if let Some(pc) = lv.strip_suffix('%') {
pc.trim().parse::<f32>().ok().and_then(|p| multiple(p / 100.0))
} else if let Ok(m) = lv.parse::<f32>() {
multiple(m)
} else if lv == "normal" {
Some(LineSpacing::Multiple(1.0))
} else {
len(&lv).filter(|x| *x > 0.0).map(LineSpacing::Exactly)
}
.or(sp.line);
}
_ => {}
}
}
sp
}
/// A rule of a `style` element with a simple selector: an element name, classes or both.
#[derive(Clone, Debug)]
struct Rule {
tag: Option<String>,
classes: Vec<String>,
/// Classes count ten, an element name one.
specificity: usize,
/// Shared by the selectors of one block.
decls: Arc<str>,
/// How many declarations `decls` holds.
count: usize,
}
/// Most rules kept from a page's style elements.
const MAX_RULES: usize = 10_000;
/// Most declarations read for one element (its rules' and its `style` attribute's).
const MAX_DECLS: usize = 512;
/// Most declarations read from one `style` attribute.
const INLINE_DECLS: usize = 64;
/// Rule matching (rules looked at plus bytes gathered) one page may spend.
const MAX_CSS_WORK: usize = 64 << 20;
/// Most bytes of declarations gathered from the rules one element matches.
const MAX_RULE_CSS: usize = 64 * 1024;
/// Most of an element's classes looked up.
const MAX_CLASSES: usize = 32;
/// A page's style rules, found by the element name or a class they need.
#[derive(Default)]
pub(super) struct Sheet {
rules: Vec<Rule>,
/// Rules naming classes, by their first class.
by_class: std::collections::HashMap<String, Vec<usize>>,
/// Rules naming only an element, by its name.
by_tag: std::collections::HashMap<String, Vec<usize>>,
/// `*` rules.
any: Vec<usize>,
}
impl Sheet {
fn push(&mut self, r: Rule) {
let i = self.rules.len();
match (r.classes.first(), &r.tag) {
(Some(c), _) => self.by_class.entry(c.clone()).or_default().push(i),
(None, Some(t)) => self.by_tag.entry(t.clone()).or_default().push(i),
(None, None) => self.any.push(i),
}
self.rules.push(r);
}
}
/// Read the rules of a style sheet into `out`. At-rules (`@media`, `@page`…) and rules whose
/// selectors aren't simple (descendants, pseudo-classes, attributes…) are skipped.
pub(super) fn parse_sheet(css: &str, out: &mut Sheet) {
// Comments go, as do the `<!-- -->` old pages hide their styles in.
let mut text = String::with_capacity(css.len());
let mut rest = css;
while let Some(i) = rest.find("/*") {
text.push_str(rest.get(..i).unwrap_or(""));
rest = rest.get(i + 2..).and_then(|r| r.find("*/").and_then(|j| r.get(j + 2..))).unwrap_or("");
}
text.push_str(rest);
let text = text.replace("<!--", " ").replace("-->", " ");
let mut rest = text.as_str();
while let Some(open) = rest.find('{') {
// A statement at-rule before it (`@charset "x";`, `@import url(a);`) isn't a selector.
let selectors = rest.get(..open).unwrap_or("").rsplit(';').next().unwrap_or("").trim();
let body = rest.get(open + 1..).unwrap_or("");
// The block's end, counting nested braces (an at-rule's own rules).
let mut depth = 1usize;
let mut end = body.len();
for (i, c) in body.char_indices() {
match c {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
end = i;
break;
}
}
_ => {}
}
}
let decls = body.get(..end).unwrap_or("");
rest = body.get(end + 1..).unwrap_or("");
if selectors.starts_with('@') || decls.contains('{') || decls.len() > MAX_RULE_CSS {
continue;
}
let (count, decls) = (decls.split(';').count(), Arc::<str>::from(decls));
for sel in selectors.split(',') {
if out.rules.len() >= MAX_RULES {
return;
}
if let Some((tag, classes)) = simple_selector(sel.trim()) {
let specificity = classes.len() * 10 + usize::from(tag.is_some());
out.push(Rule { tag, classes, specificity, decls: decls.clone(), count });
}
}
}
}
/// `p`, `.note`, `p.note.wide` or `*`: (element name, classes). Class names may start with a
/// digit (`.000003`): browsers drop such a rule, but Word's HTML import applies it, and
/// converters write them.
fn simple_selector(sel: &str) -> Option<(Option<String>, Vec<String>)> {
if sel == "*" {
return Some((None, Vec::new()));
}
let mut parts = sel.split('.');
let tag = parts.next()?;
let tag = if tag.is_empty() {
None
} else if tag.chars().all(|c| c.is_ascii_alphanumeric()) && tag.starts_with(|c: char| c.is_ascii_alphabetic()) {
Some(tag.to_ascii_lowercase())
} else {
return None;
};
let ident = |c: &str| !c.is_empty() && c.chars().all(|ch| ch.is_alphanumeric() || ch == '_' || ch == '-');
let classes: Vec<String> = parts.map(str::to_string).collect();
if classes.iter().any(|c| !ident(c)) || (tag.is_none() && classes.is_empty()) {
return None;
}
Some((tag, classes))
}
impl Builder<'_> {
/// An element's CSS: the declarations of the style sheet rules it matches, in cascade order,
/// then its `style` attribute (later declarations win).
fn css_of(&mut self, name: &str, attrs: &[(String, String)]) -> String {
// The `style` attribute's first declarations always fit in what is read of the result.
let inline: Vec<&str> = attr(attrs, "style").unwrap_or("").split(';').take(INLINE_DECLS).collect();
let inline = inline.join(";");
let sheet = &self.sheet;
if sheet.rules.is_empty() || self.css_work == 0 {
return important_last(&inline);
}
let mut classes: Vec<&str> = attr(attrs, "class").map(|c| c.split_whitespace().take(MAX_CLASSES).collect()).unwrap_or_default();
classes.sort_unstable();
classes.dedup();
let mut candidates: Vec<usize> = sheet.any.clone();
candidates.extend(sheet.by_tag.get(name).into_iter().flatten());
for c in &classes {
candidates.extend(sheet.by_class.get(*c).into_iter().flatten());
}
let mut hits: Vec<(usize, usize)> = candidates
.iter()
.filter_map(|&i| sheet.rules.get(i).map(|r| (i, r)))
.filter(|(_, r)| r.tag.as_deref().is_none_or(|t| t == name) && r.classes.iter().all(|c| classes.contains(&c.as_str())))
.map(|(i, r)| (r.specificity, i))
.collect();
hits.sort_unstable();
let mut css = String::new();
let mut count = 0;
for (_, i) in hits {
let Some(r) = sheet.rules.get(i) else { continue };
if css.len() + r.decls.len() > MAX_RULE_CSS || count + r.count > MAX_DECLS - INLINE_DECLS {
break;
}
css.push_str(&r.decls);
css.push(';');
count += r.count;
}
// A page whose rules cost too much to apply reads on without them.
self.css_work = self.css_work.saturating_sub(candidates.len() + css.len());
css.push_str(&inline);
important_last(&css)
}
}
/// `css`'s declarations in order, the `!important` ones after the others: they win over normal
/// ones wherever they come from (CSS Cascade 5 §6.4), an inline one over the sheet's.
fn important_last(css: &str) -> String {
let (important, normal): (Vec<&str>, Vec<&str>) = css.split(';').filter(|d| !d.trim().is_empty()).partition(|d| strip_important(d) != d.trim());
normal.into_iter().chain(important).collect::<Vec<_>>().join(";")
}
/// The borders a cell's CSS gives it (`border`, `border-top`…: style, width and colour in any
/// order; `windowtext` is the automatic colour). `None` when it states none.
fn cell_borders(css: &str) -> Option<craft_wordmodel::props::Borders> {
use craft_wordmodel::props::{Border, BorderStyle, Borders};
let mut b = Borders::default();
let mut any = false;
for (k, v) in decls(css) {
let sides: &[u8] = match k.as_str() {
"border" => b"tlbr",
"border-top" => b"t",
"border-left" => b"l",
"border-bottom" => b"b",
"border-right" => b"r",
_ => continue,
};
// CSS's default width is `medium`.
let (mut style, mut width, mut color) = (None, 2.25_f32, None);
for tok in v.split_whitespace() {
let lt = tok.to_ascii_lowercase();
match lt.as_str() {
"none" | "hidden" => style = Some(BorderStyle::None),
"solid" => style = Some(BorderStyle::Single),
"double" => style = Some(BorderStyle::Double),
"dotted" => style = Some(BorderStyle::Dotted),
"dashed" => style = Some(BorderStyle::Dashed),
"thin" => width = 0.75,
"medium" => width = 2.25,
"thick" => width = 3.75,
"windowtext" => {}
_ => match css_len_pt(tok).filter(|_| tok.starts_with(|c: char| c.is_ascii_digit() || c == '.')) {
Some(w) => width = w.clamp(0.0, 12.0),
None => color = parse_color(tok).or(color),
},
}
}
// CSS draws no border without a style, or at no width.
let style = if width > 0.0 { style.unwrap_or(BorderStyle::None) } else { BorderStyle::None };
let border = Border { style, width, color, space: 0.0 };
for side in sides {
let slot = match side {
b't' => &mut b.top,
b'l' => &mut b.left,
b'b' => &mut b.bottom,
_ => &mut b.right,
};
*slot = Some(border);
}
any = true;
}
any.then_some(b)
}
/// Most columns given widths in one table.
const MAX_TABLE_COLS: usize = 63;
/// A cell's width, points: its CSS `width`, else its `width` attribute (pixels); percentages
/// and widths that don't parse give none.
fn cell_width(css: &str, attrs: &[(String, String)]) -> Option<f32> {
let from_css = decls(css).filter(|(k, v)| k == "width" && !v.ends_with('%')).filter_map(|(_, v)| css_len_pt(v));
let from_attr = || {
attr(attrs, "width")
.map(str::trim)
.filter(|v| !v.ends_with('%'))
.and_then(|v| v.trim_end_matches("px").trim().parse::<f32>().ok())
.map(|px| px * 0.75)
};
from_css.last().or_else(from_attr).filter(|w| w.is_finite() && *w > 1.0 && *w <= 1584.0)
}
fn attr<'a>(attrs: &'a [(String, String)], name: &str) -> Option<&'a str> {
attrs.iter().find(|(k, _)| k == name).map(|(_, v)| v.as_str())
}
impl<'r> Builder<'r> {
pub(super) fn new(images: ImageLoader<'r>, page: bool) -> Builder<'r> {
Builder {
images,
containers: vec![Vec::new()],
cells: Vec::new(),
tables: Vec::new(),
stack: Vec::new(),
para: None,
para_cont: false,
pending_page_break: false,
pre_lead: None,
pre_tail: false,
meta: Meta::default(),
title: None,
sheet: Sheet::default(),
page,
css_work: MAX_CSS_WORK,
}
}
fn fmt(&self) -> Fmt {
match self.stack.last() {
Some(e) => e.fmt.clone(),
// A web page's text is Times New Roman 12 pt unless its CSS says otherwise.
None if self.page => Fmt { font: Some(PAGE_FONT.into()), size: Some(12.0), ..Fmt::default() },
None => Fmt::default(),
}
}
fn preserve(&self) -> bool {
self.stack.last().is_some_and(|e| e.preserve)
}
pub(super) fn in_head(&self) -> bool {
self.stack.iter().any(|e| e.kind == ElKind::Head)
}
/// Paragraph context from the open elements (innermost first, up to the enclosing cell).
pub(super) fn start_para(&mut self) -> &mut Para {
if self.para.is_none() {
let mut p = Para::default();
let mut list_level: Option<(bool, usize)> = None;
let mut li_at: Option<usize> = None;
let mut lists = 0usize;
let mut spaced = false;
// A page's first span stating margins (see below).
let span = self
.stack
.iter()
.rev()
.take_while(|e| e.kind == ElKind::Inline)
.find(|e| e.spacing.before.is_some() || e.spacing.after.is_some())
.map(|e| (e.spacing.before, e.spacing.after));
for (i, e) in self.stack.iter().enumerate().rev() {
match e.kind {
ElKind::Cell => {
// CSS inherits `text-align` from the cell, its row, row group and table
// (#1381); the paragraph's own (found above) wins.
if p.align.is_none() {
p.align = self.stack.get(..=i).and_then(|outer| {
outer
.iter()
.rev()
.take_while(|o| o.kind != ElKind::Table)
.chain(outer.iter().rev().find(|o| o.kind == ElKind::Table))
.find_map(|o| o.align)
});
}
break;
}
ElKind::Table => break,
ElKind::Heading(n) if p.kind == Kind::Normal => p.kind = Kind::Heading(n.clamp(1, 6)),
ElKind::Pre if p.kind == Kind::Normal => p.kind = Kind::Code,
ElKind::Quote if p.kind == Kind::Normal => p.kind = Kind::Quote,
ElKind::Li if li_at.is_none() => li_at = Some(i),
ElKind::List(o) => {
if list_level.is_none() {
list_level = Some((o, 0));
}
lists += 1;
}
_ => {}
}
if p.align.is_none() && e.align.is_some() && e.kind != ElKind::Inline {
p.align = e.align;
}
// Margins belong to the paragraph's own block, not to those around it.
if !spaced && e.kind != ElKind::Inline {
p.spacing = e.spacing;
if self.page {
// Word opens a page's paragraphs with single lines; a margin a span
// states replaces the one its paragraph states, and one the paragraph
// leaves out is automatic spacing.
let (top, bottom) = span.unwrap_or_default();
let sp = &mut p.spacing;
sp.before_auto = sp.before.is_none() && e.auto_margins;
sp.after_auto = sp.after.is_none() && e.auto_margins;
sp.before = sp.before.map(|own| top.unwrap_or(own));
sp.after = sp.after.map(|own| bottom.unwrap_or(own));
sp.line = sp.line.or(Some(LineSpacing::Multiple(1.0)));
}
spaced = true;
}
}
// Page text without a block of its own (straight in a cell) is single-spaced too.
if self.page && !spaced {
p.spacing.line = Some(LineSpacing::Multiple(1.0));
}
if let (Some((o, _)), Some(li)) = (list_level, li_at) {
p.list = Some(ListInfo { ordered: o, level: lists.saturating_sub(1).min(8) as u8, start: 1, ..Default::default() });
self.para_cont = self.stack.get(li).is_some_and(|e| e.used);
if let Some(e) = self.stack.get_mut(li) {
e.used = true;
}
} else {
self.para_cont = false;
}
let pb_before = self.stack.iter_mut().rev().take_while(|e| e.kind != ElKind::Cell).any(|e| std::mem::take(&mut e.page_break));
p.page_break = pb_before || std::mem::take(&mut self.pending_page_break);
self.para = Some(p);
}
self.para.get_or_insert_with(Para::default)
}
fn container(&mut self) -> &mut Vec<FBlock> {
if self.containers.is_empty() {
self.containers.push(Vec::new());
}
let n = self.containers.len() - 1;
&mut self.containers[n]
}
fn flush(&mut self) {
let Some(mut p) = self.para.take() else { return };
let (pre_lead, pre_tail) = (self.pre_lead.take().unwrap_or(false), std::mem::take(&mut self.pre_tail));
if p.kind != Kind::Code {
// Preserved white space keeps its spaces and tabs at the edges; line breaks there
// (source formatting) still go.
const ALL: &[char] = &[' ', '\t', '\r', '\n'];
const BREAKS: &[char] = &['\r', '\n'];
p.trim_with(if pre_lead { BREAKS } else { ALL }, if pre_tail { BREAKS } else { ALL });
if p.text().trim().is_empty() {
// Only white space (indentation between blocks): nothing to keep.
p.trim();
}
}
if p.inlines.len() == 1 && p.text() == "\u{00A0}" {
// `<p>&nbsp;</p>`: an empty paragraph on purpose, whose line is as high as the
// space's formatting makes it.
if let Some(Inline::Text(_, f)) = p.inlines.first() {
p.mark = Some(Box::new(craft_flow::char_props(f)));
}
p.inlines.clear();
self.para_cont = false;
self.container().push(FBlock::Para(p));
return;
}
if p.is_empty() && p.kind != Kind::Code && p.list.is_none() {
// Whitespace-only text between blocks.
return;
}
let cont = std::mem::take(&mut self.para_cont);
let level = p.list.map(|l| l.level);
let c = self.container();
if cont
&& let Some(FBlock::Para(prev)) = c.last_mut()
&& prev.list.is_some()
&& prev.list.map(|l| l.level) == level
{
prev.push_text("\n", &Fmt::default());
prev.inlines.extend(p.inlines);
return;
}
c.push(FBlock::Para(p));
}
pub(super) fn text(&mut self, t: &str) {
if let Some(title) = &mut self.title {
title.push_str(t);
return;
}
if self.in_head() {
return;
}
if t.is_empty() {
return;
}
let inside_table_only = matches!(
self.stack.iter().rev().find(|e| matches!(e.kind, ElKind::Table | ElKind::Tr | ElKind::Cell)).map(|e| e.kind),
Some(ElKind::Table | ElKind::Tr)
);
if inside_table_only && t.trim().is_empty() {
return;
}
let code = self.stack.iter().any(|e| e.kind == ElKind::Pre);
let f = self.fmt();
if code {
let mut first = true;
for line in t.split('\n') {
if !first {
// A newline right after <pre> is ignored.
let empty_start = self.para.as_ref().is_none_or(|p| p.inlines.is_empty()) && self.pre_fresh();
if !empty_start {
self.start_para();
self.flush();
}
}
first = false;
let line = line.trim_end_matches('\r');
if !line.is_empty() {
self.start_para().push_text(line, &Fmt { code: false, ..f.clone() });
self.mark_pre_used();
}
}
return;
}
if self.preserve() {
self.note_edge(true);
self.start_para().push_text(t, &f);
return;
}
// Collapse whitespace.
let mut s = String::with_capacity(t.len());
let mut prev_space = self.para.as_ref().is_none_or(|p| match p.inlines.last() {
Some(Inline::Text(x, _)) => x.ends_with([' ', '\n']),
Some(_) => false,
None => true,
});
for c in t.chars() {
if matches!(c, ' ' | '\t' | '\n' | '\r' | '\u{000C}') {
if !prev_space {
s.push(' ');
prev_space = true;
}
} else {
s.push(c);
prev_space = false;
}
}
if s.is_empty() {
return;
}
self.note_edge(false);
self.start_para().push_text(&s, &f);
}
/// Record whether text about to be added keeps its white space (see `pre_lead`).
fn note_edge(&mut self, preserved: bool) {
if self.para.as_ref().is_none_or(|p| p.inlines.is_empty()) {
self.pre_lead = Some(preserved);
}
self.pre_tail = preserved;
}
/// Add a `<ruby>` element's base text and reading as one inline (#1681). Half-marked ruby (no
/// base, or no reading) is plain text instead: a guide that annotates nothing is no guide, and
/// the text it held is never lost.
pub(super) fn push_ruby(&mut self, base: &str, ruby: &str) {
let preserve = self.preserve();
let text = |s: &str| if preserve { s.to_string() } else { collapse_ws(s) };
let (base, ruby) = (text(base), text(ruby));
let f = self.fmt();
if base.is_empty() || ruby.is_empty() {
self.start_para().push_text(if base.is_empty() { &ruby } else { &base }, &f);
return;
}
self.start_para().inlines.push(Inline::Ruby { base, ruby, fmt: f });
}
fn pre_fresh(&self) -> bool {
self.stack.iter().rev().find(|e| e.kind == ElKind::Pre).is_some_and(|e| !e.used)
}
fn mark_pre_used(&mut self) {
if let Some(e) = self.stack.iter_mut().rev().find(|e| e.kind == ElKind::Pre) {
e.used = true;
}
}
/// Pop elements down to and including index `k`.
fn pop_to(&mut self, k: usize) {
while self.stack.len() > k {
let Some(e) = self.stack.pop() else { break };
self.close(&e);
}
}
fn close(&mut self, e: &El) {
if e.page_break_after || e.page_break {
self.pending_page_break = true;
}
if e.kind != ElKind::Inline {
self.flush();
}
match e.kind {
ElKind::Cell => {
self.flush();
let blocks = if self.containers.len() > 1 { self.containers.pop().unwrap_or_default() } else { Vec::new() };
let mut cell = self.cells.pop().unwrap_or_default();
cell.blocks = blocks;
if let Some(t) = self.tables.last_mut() {
t.row.get_or_insert_with(Vec::new).push(cell);
}
}
ElKind::Tr => {
if let Some(t) = self.tables.last_mut()
&& let Some(r) = t.row.take()
{
t.rows.push(r);
}
}
ElKind::Table => {
if let Some(mut t) = self.tables.pop() {
if let Some(r) = t.row.take() {
t.rows.push(r);
}
let borderless = !t.bordered;
// Keep at most MAX_ROWS rows (the model drops the rest anyway): bounds the
// O(rows × rowspan) work of `insert_covered` on a table of millions of `<tr>`.
let rows: Vec<_> = t.rows.into_iter().filter(|r| !r.is_empty()).take(craft_wordmodel::table::MAX_ROWS).collect();
let mut ft = FTable { rows, widths: Vec::new(), borderless, extra: None, kept: None, wrap: None };
if !ft.rows.is_empty() {
ft.insert_covered();
// Columns keep the widths their cells give when every column has one.
if ft.cols() <= MAX_TABLE_COLS {
ft.widths_from_cells();
}
self.container().push(FBlock::Table(ft));
}
}
}
ElKind::Head => {}
_ => {}
}
}
/// Index of the innermost open element named `name`, not looking past a table boundary
/// unless `cross_tables`.
fn find_open(&self, pred: impl Fn(&El) -> bool, stop: impl Fn(&El) -> bool) -> Option<usize> {
for (i, e) in self.stack.iter().enumerate().rev() {
if pred(e) {
return Some(i);
}
if stop(e) {
return None;
}
}
None
}
pub(super) fn close_head_for_start(&mut self, name: &str) {
// A body-content start tag implicitly ends an omitted </head>.
if !matches!(
name,
"html" | "head" | "base" | "basefont" | "bgsound" | "link" | "meta" | "noframes" | "noscript" | "script" | "style" | "template" | "title"
) && let Some(k) = self.find_open(|e| e.kind == ElKind::Head, |_| false)
{
self.pop_to(k);
}
}
fn start_head_metadata(&mut self, name: &str, attrs: &[(String, String)]) -> bool {
match name {
"title" => self.title = Some(String::new()),
"meta" => {
let name = attr(attrs, "name").unwrap_or("").to_ascii_lowercase();
let content = attr(attrs, "content").unwrap_or("").to_string();
match name.as_str() {
"author" => self.meta.author = content,
"description" => self.meta.description = content,
"keywords" => self.meta.keywords = content,
"subject" => self.meta.subject = content,
_ => {}
}
return true;
}
_ => {}
}
false
}
fn close_implicitly(&mut self, name: &str, is_table_part: bool) {
if closes_p(name)
&& let Some(k) = self.find_open(|e| e.name == "p", |e| matches!(e.kind, ElKind::Cell | ElKind::Table | ElKind::Li | ElKind::Quote))
{
self.pop_to(k);
}
if matches!(name, "h1" | "h2" | "h3" | "h4" | "h5" | "h6") && self.stack.last().is_some_and(|e| matches!(e.kind, ElKind::Heading(_))) {
let k = self.stack.len() - 1;
self.pop_to(k);
}
if name == "li"
&& let Some(k) = self.find_open(|e| e.kind == ElKind::Li, |e| matches!(e.kind, ElKind::List(_) | ElKind::Table | ElKind::Cell))
{
self.pop_to(k);
}
if matches!(name, "dt" | "dd")
&& let Some(k) =
self.find_open(|e| e.name == "dt" || e.name == "dd", |e| matches!(e.kind, ElKind::Table | ElKind::Cell) || e.name == "dl")
{
self.pop_to(k);
}
if is_table_part && !self.tables.is_empty() {
if matches!(name, "td" | "th")
&& let Some(k) = self.find_open(|e| e.kind == ElKind::Cell, |e| matches!(e.kind, ElKind::Table | ElKind::Tr))
{
self.pop_to(k);
}
if name == "tr"
&& let Some(k) = self.find_open(|e| matches!(e.kind, ElKind::Tr | ElKind::Cell), |e| e.kind == ElKind::Table)
{
let mut kk = k;
while kk > 0 && self.stack.get(kk - 1).is_some_and(|e| matches!(e.kind, ElKind::Tr | ElKind::Cell)) {
kk -= 1;
}
self.pop_to(kk);
}
}
}
fn start_special(&mut self, name: &str, attrs: &[(String, String)]) -> bool {
match name {
"br" => {
if self.stack.iter().any(|e| e.kind == ElKind::Pre) {
self.start_para();
self.flush();
} else {
let fmt = self.fmt();
self.start_para().push_text("\n", &fmt);
}
}
"hr" => {
self.flush();
self.container().push(FBlock::Para(Para::new(Kind::Rule)));
}
"wbr" => {
let fmt = self.fmt();
self.start_para().push_text("\u{200B}", &fmt);
}
"img" => {
let alt = attr(attrs, "alt").unwrap_or("").to_string();
let dim = |key: &str| attr(attrs, key).and_then(|v| v.trim().trim_end_matches("px").trim().parse::<f32>().ok()).map(|p| p * 0.75);
let mut width = dim("width");
let mut height = dim("height");
if let Some(style) = attr(attrs, "style") {
for decl in style.split(';') {
if let Some((key, value)) = decl.split_once(':') {
match key.trim().to_ascii_lowercase().as_str() {
"width" => width = css_len_pt(value).or(width),
"height" => height = css_len_pt(value).or(height),
_ => {}
}
}
}
}
let image = attr(attrs, "src")
.and_then(|src| if src.trim_start().starts_with("data:") { data_uri(src).map(Arc::new) } else { (self.images)(src.trim()) })
.and_then(|data| make_img(data, width, height, &alt));
let fmt = self.fmt();
match image {
Some(image) => self.start_para().inlines.push(Inline::Image(image)),
None if !alt.is_empty() => self.start_para().push_text(&alt, &fmt),
None => {}
}
}
_ => return false,
}
true
}
/// `css`: the element's declarations in cascade order ([`Builder::css_of`]).
fn element_style(&self, name: &str, attrs: &[(String, String)], kind: ElKind, css: &str) -> ElementStyle {
let mut fmt = self.fmt();
let mut align = None;
let mut preserve = self.preserve();
let mut page_break = false;
let mut page_break_after = false;
match name {
// A header cell is bold, as in browsers and Word, unless its own CSS says otherwise.
"b" | "strong" | "th" => fmt.bold = true,
"i" | "em" | "cite" | "var" | "dfn" | "address" => fmt.italic = true,
"u" | "ins" => fmt.underline = true,
"s" | "strike" | "del" => fmt.strike = true,
"code" | "kbd" | "tt" | "samp" => fmt.code = true,
"sup" => fmt.sup = true,
"sub" => fmt.sub = true,
"small" => fmt.size = Some(fmt.size.map_or(10.0, |s| (s * 0.833).max(1.0))),
"big" => fmt.size = Some(fmt.size.map_or(14.0, |s| (s * 1.2).min(1638.0))),
"mark" => fmt.background = Some(craft_wordmodel::Rgb(255, 255, 0)),
"font" => apply_font_attributes(attrs, &mut fmt),
"a" => {
if let Some(href) = attr(attrs, "href").filter(|h| craft_wordmodel::link::is_safe_href(h)) {
fmt.link = model::kept_link(href.trim());
}
}
_ => {}
}
if let Some(value) = attr(attrs, "align").filter(|_| kind != ElKind::Table) {
align = match value.to_ascii_lowercase().as_str() {
"center" | "middle" => Some(Align::Center),
"right" => Some(Align::Right),
"justify" => Some(Align::Justify),
"left" => Some(Align::Left),
_ => None,
};
}
if name == "center" {
align = Some(Align::Center);
}
if self.page
&& let ElKind::Heading(n) = kind
{
// A page's headings are bold, black (unless they inherit a colour) and sized as
// browsers show them.
fmt.bold = true;
fmt.color.get_or_insert(craft_wordmodel::Rgb(0, 0, 0));
fmt.size = PAGE_HEADING_SIZES.get(usize::from(n.clamp(1, 6)) - 1).copied();
}
apply_style(css, &mut fmt, &mut align, &mut preserve, &mut page_break, &mut page_break_after);
if kind == ElKind::Pre {
preserve = true;
}
ElementStyle { fmt, align, preserve, page_break, page_break_after }
}
pub(super) fn start(&mut self, name: &str, attrs: &[(String, String)], self_close: bool) {
if self.start_head_metadata(name, attrs) {
return;
}
let is_table_part = matches!(name, "tr" | "td" | "th");
let in_table = self.tables.len() < model::MAX_DEPTH;
self.close_implicitly(name, is_table_part);
if name == "a"
&& !self.in_head()
&& let Some(id) = attr(attrs, "id").or_else(|| attr(attrs, "name")).filter(|s| !s.is_empty())
{
self.start_para().inlines.push(Inline::Anchor(id.to_string()));
}
if self.start_special(name, attrs) {
return;
}
if is_void(name) || self_close && block_kind(name).is_none() && !matches!(name, "a" | "span") {
return;
}
if self.stack.len() >= MAX_STACK {
return;
}
let mut kind = block_kind(name).unwrap_or(ElKind::Inline);
if matches!(kind, ElKind::Table | ElKind::Tr | ElKind::Cell) && !in_table {
kind = ElKind::Block;
}
if matches!(kind, ElKind::Tr | ElKind::Cell) && self.tables.is_empty() {
kind = ElKind::Block;
}
if kind != ElKind::Inline {
self.flush();
}
let css = self.css_of(name, attrs);
let ElementStyle { mut fmt, align, preserve, page_break, page_break_after } = self.element_style(name, attrs, kind, &css);
match kind {
ElKind::Table => self.tables.push(TableB { rows: Vec::new(), row: None, bordered: declares_border(attrs, &css, true) }),
ElKind::Tr => {
if let Some(t) = self.tables.last_mut()
&& let Some(r) = t.row.take()
{
t.rows.push(r);
}
}
ElKind::Cell => {
let tb = self.find_open(|e| e.kind == ElKind::Tr, |e| e.kind == ElKind::Table).is_none();
if let Some(t) = self.tables.last_mut()
&& declares_border(attrs, &css, false)
{
t.bordered = true;
}
if tb && let Some(t) = self.tables.last_mut() {
// A cell without a row: start one.
if let Some(r) = t.row.take() {
t.rows.push(r);
}
t.row = Some(Vec::new());
}
let num = |k: &str| attr(attrs, k).and_then(|v| v.trim().parse::<u32>().ok()).map(|v| v.clamp(1, 63));
let colspan = num("colspan").unwrap_or(1);
let shading = attr(attrs, "bgcolor").and_then(parse_color).or(fmt.background);
let mut valign = attr(attrs, "valign").and_then(|v| match v.trim().to_ascii_lowercase().as_str() {
"top" => Some(VAlign::Top),
"middle" | "center" => Some(VAlign::Center),
"bottom" => Some(VAlign::Bottom),
_ => None,
});
for (_, v) in decls(&css).filter(|(k, _)| k == "vertical-align") {
match v.to_ascii_lowercase().as_str() {
"top" => valign = Some(VAlign::Top),
"middle" | "center" => valign = Some(VAlign::Center),
"bottom" => valign = Some(VAlign::Bottom),
_ => {}
}
}
self.cells.push(Cell {
blocks: Vec::new(),
colspan,
rowspan: attr(attrs, "rowspan").and_then(|v| v.trim().parse::<u32>().ok()).map(|v| v.clamp(1, 1000)).unwrap_or(1),
covered: false,
header: name == "th",
shading,
borders: cell_borders(&css),
valign,
margins: None,
direction: Default::default(),
picture: None,
width: if colspan == 1 { cell_width(&css, attrs) } else { None },
});
fmt.background = None;
self.containers.push(Vec::new());
}
_ => {}
}
// The page's own margins (`body`, `html`) aren't its paragraphs'.
let spacing =
if (kind != ElKind::Inline || self.page) && !matches!(name, "body" | "html") { block_spacing(&css) } else { Spacing::default() };
let auto_margins = self.page && (name == "p" || matches!(kind, ElKind::Heading(_)));
self.stack.push(El { name: name.to_string(), kind, fmt, spacing, auto_margins, align, preserve, page_break, page_break_after, used: false });
}
pub(super) fn end(&mut self, name: &str) {
if name == "title" {
if let Some(t) = self.title.take() {
self.meta.title = t.split_whitespace().collect::<Vec<_>>().join(" ");
}
return;
}
let crosses = matches!(name, "table");
let target = if matches!(name, "td" | "th") {
self.find_open(|e| e.kind == ElKind::Cell, |e| e.kind == ElKind::Table)
} else if name == "tr" {
self.find_open(|e| e.kind == ElKind::Tr, |e| e.kind == ElKind::Table)
} else {
self.find_open(|e| e.name == name, |e| !crosses && matches!(e.kind, ElKind::Table | ElKind::Cell))
};
if let Some(k) = target {
self.pop_to(k);
}
}
pub(super) fn finish(mut self) -> Flow {
self.pop_to(0);
self.flush();
// Unclosed cells: fold remaining containers into the body.
while self.containers.len() > 1 {
let c = self.containers.pop().unwrap_or_default();
self.container().extend(c);
}
let blocks = self.containers.pop().unwrap_or_default();
if let Some(t) = self.title.take() {
self.meta.title = t;
}
Flow { blocks, meta: self.meta }
}
}