Files
wordcraft/crates/formats/src/html/builder.rs
T
Claude 6925818110 Gap 19: ODT page breaks after, kept lines, widows, hyphenation, table placement
Six features of LibreOffice documents were lost on open and save. A
two-page document came back as one page, because the page break after
a paragraph was dropped. Now read and written:
- a page break after a paragraph (`fo:break-after="page"`), as a page
  break before the next paragraph, as Word has it;
- lines kept together (`fo:keep-together`);
- widow and orphan control (`fo:widows`/`fo:orphans`): on with any line
  count, off with none;
- hyphenation (`fo:hyphenate`): a paragraph's in ODF, the document's in
  WordCraft. A document with a hyphenated paragraph hyphenates, and its
  other paragraphs opt out;
- a table's alignment and width (`table:align`, `style:rel-width`);
- its rows' heights (`style:row-height` exact, `style:min-row-height` at
  least).

Flow model: `FTable::extra` (alignment, width, row heights), filled by
formats that keep them.

A LibreOffice document with all of these round-trips at 573 px
difference instead of 4,407, keeps its two pages, and validates
against the ODF 1.3 schema. No other test document changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 18:30:41 +00:00

806 lines
30 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};
use super::import::collapse_ws;
use super::{Align, Arc, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo, Meta, Para, 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,
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 `style` declares a visible border.
fn declares_border(attrs: &[(String, String)], 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;
}
let Some(style) = attr(attrs, "style") else { return false };
style.split(';').any(|decl| {
let Some((name, value)) = decl.split_once(':') else { return false };
let name = name.trim().to_ascii_lowercase();
if !matches!(name.as_str(), "border" | "border-top" | "border-right" | "border-bottom" | "border-left") {
return false;
}
let value = value.trim().to_ascii_lowercase();
!value.is_empty() && !value.split_whitespace().any(|tok| matches!(tok, "none" | "hidden" | "0" | "0px" | "0pt"))
})
}
/// 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>,
}
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"
)
}
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>) -> 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,
}
}
fn fmt(&self) -> Fmt {
self.stack.last().map(|e| e.fmt.clone()).unwrap_or_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;
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;
}
}
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.
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(wordcraft_doc::table::MAX_ROWS).collect();
let mut ft = FTable { rows, widths: Vec::new(), borderless, extra: None };
if !ft.rows.is_empty() {
ft.insert_covered();
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
}
fn element_style(&self, name: &str, attrs: &[(String, String)], kind: ElKind) -> 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 {
"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(wordcraft_doc::Rgb(255, 255, 0)),
"font" => apply_font_attributes(attrs, &mut fmt),
"a" => {
if let Some(href) = attr(attrs, "href").filter(|h| wordcraft_doc::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 let Some(style) = attr(attrs, "style") {
apply_style(style, &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 ElementStyle { mut fmt, align, preserve, page_break, page_break_after } = self.element_style(name, attrs, kind);
match kind {
ElKind::Table => self.tables.push(TableB { rows: Vec::new(), row: None, bordered: declares_border(attrs, 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, 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 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,
});
if let Some(st) = attr(attrs, "style") {
for decl in st.split(';') {
if let Some((k, v)) = decl.split_once(':')
&& k.trim().eq_ignore_ascii_case("vertical-align")
{
match v.trim().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: num("colspan").unwrap_or(1),
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: None,
valign,
margins: None,
});
fmt.background = None;
self.containers.push(Vec::new());
}
_ => {}
}
self.stack.push(El { name: name.to_string(), kind, fmt, 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 }
}
}