From 6f282df367094a6d466a21878bc4e724c29b4beb Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:16:21 +0200 Subject: [PATCH] HTML import: tables without borders stay borderless in .docx (#99) (#102) --- crates/formats/src/html.rs | 47 +++++++++++++++++++++++++++++++++++-- crates/formats/src/model.rs | 13 ++++++++-- crates/formats/src/odt.rs | 4 ++-- crates/formats/src/rtf.rs | 2 +- 4 files changed, 59 insertions(+), 7 deletions(-) diff --git a/crates/formats/src/html.rs b/crates/formats/src/html.rs index d651ea246..839f973e8 100644 --- a/crates/formats/src/html.rs +++ b/crates/formats/src/html.rs @@ -341,6 +341,25 @@ struct El { struct TableB { rows: Vec>, row: Option>, + /// The table or one of its cells asks for borders. + bordered: bool, +} + +/// 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::().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")) + }) } struct Builder { @@ -750,7 +769,8 @@ impl Builder { if let Some(r) = t.row.take() { t.rows.push(r); } - let mut ft = FTable { rows: t.rows.into_iter().filter(|r| !r.is_empty()).collect(), widths: Vec::new() }; + let borderless = !t.bordered; + let mut ft = FTable { rows: t.rows.into_iter().filter(|r| !r.is_empty()).collect(), widths: Vec::new(), borderless }; if !ft.rows.is_empty() { ft.insert_covered(); self.container().push(FBlock::Table(ft)); @@ -959,7 +979,7 @@ impl Builder { preserve = true; } match kind { - ElKind::Table => self.tables.push(TableB { rows: Vec::new(), row: None }), + 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() @@ -969,6 +989,11 @@ impl Builder { } 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() { @@ -1389,6 +1414,24 @@ mod tests { .collect() } + fn table_style(html: &str) -> Option { + let doc = crate::model::to_doc(&parse(html)); + doc.body.iter().find_map(|b| match &**b { + wordcraft_doc::Block::Table(t) => Some(t.props.style.clone().unwrap_or_default()), + _ => None, + }) + } + + #[test] + fn borderless_tables_get_no_grid_style() { + assert_eq!(table_style("
ab
").as_deref(), Some("")); + assert_eq!(table_style("
a
").as_deref(), Some("")); + assert_eq!(table_style("
a
").as_deref(), Some("")); + assert_eq!(table_style("
a
").as_deref(), Some("TableGrid")); + assert_eq!(table_style("
a
").as_deref(), Some("TableGrid")); + assert_eq!(table_style("
a
").as_deref(), Some("TableGrid")); + } + #[test] fn tag_soup() { let f = parse( diff --git a/crates/formats/src/model.rs b/crates/formats/src/model.rs index 78efba93f..f4f3ba911 100644 --- a/crates/formats/src/model.rs +++ b/crates/formats/src/model.rs @@ -180,6 +180,8 @@ pub struct FTable { pub rows: Vec>, /// Grid column widths in points (empty = equal widths). pub widths: Vec, + /// The source table had no borders (HTML default); the document table gets no grid style. + pub borderless: bool, } impl FTable { @@ -426,6 +428,9 @@ impl Builder<'_> { let cols = t.cols().clamp(1, wordcraft_doc::table::MAX_COLS); let width = self.doc.last_section.text_width().max(72.0); let mut tb = Table::new(rows.len(), cols, width); + if t.borderless { + tb.props.style = None; + } if t.widths.len() == cols && t.widths.iter().all(|w| w.is_finite() && *w > 1.0) { tb.grid = t.widths.iter().map(|w| w.clamp(6.0, 1584.0)).collect(); } @@ -704,7 +709,7 @@ fn flow_table(doc: &Document, t: &Table, depth: usize) -> FTable { } rows.push(cells); } - FTable { rows, widths: t.grid.clone() } + FTable { rows, widths: t.grid.clone(), borderless: false } } /// The document body as a flow. @@ -980,7 +985,11 @@ mod tests { #[test] fn covered_cells_inserted() { - let mut t = FTable { rows: vec![vec![Cell { rowspan: 2, ..Default::default() }, Cell::default()], vec![Cell::default()]], widths: vec![] }; + let mut t = FTable { + rows: vec![vec![Cell { rowspan: 2, ..Default::default() }, Cell::default()], vec![Cell::default()]], + widths: vec![], + borderless: false, + }; t.insert_covered(); assert_eq!(t.rows[1].len(), 2); assert!(t.rows[1][0].covered); diff --git a/crates/formats/src/odt.rs b/crates/formats/src/odt.rs index af6c134d3..151b8c2ed 100644 --- a/crates/formats/src/odt.rs +++ b/crates/formats/src/odt.rs @@ -1243,7 +1243,7 @@ impl Body<'_> { if let Some(t) = self.tables.pop() && !t.rows.is_empty() { - self.container().push(FBlock::Table(FTable { rows: t.rows, widths: Vec::new() })); + self.container().push(FBlock::Table(FTable { rows: t.rows, widths: Vec::new(), borderless: false })); } } "desc" | "title" => self.in_desc = false, @@ -1366,7 +1366,7 @@ pub fn parse(bytes: &[u8]) -> Result { let mut blocks = body.containers.pop().unwrap_or_default(); for t in body.tables.drain(..) { if !t.rows.is_empty() { - blocks.push(FBlock::Table(FTable { rows: t.rows, widths: Vec::new() })); + blocks.push(FBlock::Table(FTable { rows: t.rows, widths: Vec::new(), borderless: false })); } } if blocks.is_empty() { diff --git a/crates/formats/src/rtf.rs b/crates/formats/src/rtf.rs index ebe033ee5..e8aebf09f 100644 --- a/crates/formats/src/rtf.rs +++ b/crates/formats/src/rtf.rs @@ -850,7 +850,7 @@ impl Reader { } if !self.table.is_empty() { let rows = std::mem::take(&mut self.table); - self.body.push(FBlock::Table(FTable { rows, widths: Vec::new() })); + self.body.push(FBlock::Table(FTable { rows, widths: Vec::new(), borderless: false })); } }