diff --git a/crates/docx/src/read.rs b/crates/docx/src/read.rs index 5ea11dbd8..0f95b4f76 100644 --- a/crates/docx/src/read.rs +++ b/crates/docx/src/read.rs @@ -119,6 +119,10 @@ pub(crate) struct Reader<'p> { pub theme_fills: shape_style::ThemeFills, /// The fill of the group being read (what its members' `a:grpFill` stands for). pub group_fill: Option, + /// The package has no default table style (no styles part, or none marked `w:default`): + /// Word then gives tables no cell margins rather than its Normal Table's 0.08". Set by + /// `read_styles`, which runs before any story with tables is read. + pub no_default_table_style: bool, } /// The package path that internal relationship `id` points at, if it has type `kind` (an `rt` @@ -193,6 +197,7 @@ fn read_opened_in(pkg: &Package, chunks: ChunkState) -> Result<(Document, usize, theme_fills: Default::default(), group_fill: None, chunks, + no_default_table_style: true, }; r.doc.missing_pictures.main = main.clone(); r.pc.major_font = r.doc.settings.major_font.clone(); diff --git a/crates/docx/src/read/story/blocks.rs b/crates/docx/src/read/story/blocks.rs index e72f16bf6..5efd87068 100644 --- a/crates/docx/src/read/story/blocks.rs +++ b/crates/docx/src/read/story/blocks.rs @@ -314,15 +314,32 @@ impl Reader<'_> { // ---- tables ---- + /// Word gives a table no cell margins when the file has no default table style and neither the + /// table nor its style sets them; saving may write a default Normal Table, so the zeros are + /// kept as the table's own. shortcut: they stay direct formatting (a table style applied later + /// keeps them), until the model knows the file's default table style. A `w:tblCellMar` that sets + /// only some sides keeps them: only the sides it omits become 0. + fn no_default_margins(&self, props: &mut craft_wordmodel::props::TableProps) { + if self.no_default_table_style + && props.cell_margins.is_none() + && props.style.as_deref().and_then(|id| self.doc.styles.table_style(id)).is_none_or(|s| s.parts.cell_margins.is_none()) + { + props.cell_margins = Some([0.0; 4]); + } + } + fn read_table(&mut self, sc: &mut StoryCtx, t: &El, rels: &Rels, depth: usize) -> Option { let mut table = Table::default(); if let Some(p) = t.child("w:tblPr") { table.props = self.pc.tblpr(p); if let Some(ch) = p.child("w:tblPrChange") { - let old = ch.child("w:tblPr").map(|o| self.pc.tblpr(o)).unwrap_or_default(); + let mut old = ch.child("w:tblPr").map(|o| self.pc.tblpr(o)).unwrap_or_default(); + // Rejecting the change brings back these properties: with the same margins. + self.no_default_margins(&mut old); table.props.fmt_change = PropChange::boxed(self.revision(RevisionKind::Format, ch), old); } } + self.no_default_margins(&mut table.props); if let Some(g) = t.child("w:tblGrid") { table.grid = g .children("w:gridCol") diff --git a/crates/docx/src/read/styles.rs b/crates/docx/src/read/styles.rs index 080eaff21..1fa9caeb4 100644 --- a/crates/docx/src/read/styles.rs +++ b/crates/docx/src/read/styles.rs @@ -64,6 +64,20 @@ fn builtin_heading_level(name: &str) -> Option { } impl Reader<'_> { + /// Whether a style's kept (last) definition is a default table style. + fn has_default_table_style(&self, root: &El) -> bool { + let mut defaults: Vec = Vec::new(); + for s in root.children("w:style").take(10_000) { + let Some(raw_id) = s.attr("w:styleId").filter(|i| !i.is_empty()) else { continue }; + let id = self.pc.style_id(raw_id); + defaults.retain(|d| *d != id); + if s.attr("w:type") == Some("table") && style_is_default(s) { + defaults.push(id); + } + } + !defaults.is_empty() + } + pub(super) fn read_styles(&mut self, path: &str) -> Result<(), DocxError> { /// Largest `w:latentStyles` kept (Word's has ~380 exceptions, ~40 KB). const MAX_LATENT_STYLES_XML: usize = 1 << 20; @@ -207,6 +221,7 @@ impl Reader<'_> { None => sheet.styles.push(st), } } + self.no_default_table_style = !self.has_default_table_style(&root); // Styles that give only a list level take the list from the style they are based on // (Heading 3: level 3 of Heading 2's outline list). let fixed: Vec> = sheet diff --git a/crates/docx/tests/it/cell_margin_inheritance.rs b/crates/docx/tests/it/cell_margin_inheritance.rs index f4d8de5a5..83aafc7d0 100644 --- a/crates/docx/tests/it/cell_margin_inheritance.rs +++ b/crates/docx/tests/it/cell_margin_inheritance.rs @@ -28,3 +28,73 @@ fn partial_margin_stays_partial() { assert!(!margins.contains(") -> Vec { + const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new())); + let mut add = |name: &str, xml: &str| { + zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + zip.write_all(xml.as_bytes()).unwrap(); + }; + add("word/document.xml", &format!(r#"{body}"#)); + if let Some(styles) = styles { + add("word/styles.xml", &format!(r#"{styles}"#)); + add( + "word/_rels/document.xml.rels", + r#""#, + ); + } + zip.finish().unwrap().into_inner() +} + +/// Without a default table style (no styles part, or none marked `w:default`) Word gives a table +/// no cell margins: a cell that sets only its right margin has none on its other sides. +#[test] +fn no_default_table_style_means_no_cell_margins() { + let body = r#""#; + let normal_table = r#""#; + let margins = |styles: Option<&str>| { + let doc = craft_docx::read(&package(body, styles)).unwrap(); + let table = doc.body[0].as_table().unwrap(); + (table.props.cell_margins, table.rows[0].cells[0].props.resolved_margins(table.props.cell_margins.unwrap_or([0.0, 5.4, 0.0, 5.4]))) + }; + assert_eq!(margins(None), (Some([0.0; 4]), [0.0, 0.0, 0.0, 5.4])); + assert_eq!(margins(Some(r#""#)), (Some([0.0; 4]), [0.0, 0.0, 0.0, 5.4])); + // With Word's usual Normal Table the table keeps no margins of its own (the defaults apply). + assert_eq!(margins(Some(normal_table)).0, None); + // A style defined twice is its last definition: a default one redefined as not default leaves none. + let redefined = format!(r#"{normal_table}"#); + assert_eq!(margins(Some(&redefined)).0, Some([0.0; 4])); + // A custom default table style marked `on` is a default too, also after saving and reopening. + let plain = r#""#; + let doc = craft_docx::read(&package(body, Some(plain))).unwrap(); + assert_eq!(doc.body[0].as_table().unwrap().props.cell_margins, None); + let reopened = craft_docx::read(&craft_docx::write(&doc).unwrap()).unwrap(); + assert_eq!(reopened.body[0].as_table().unwrap().props.cell_margins, None); + // A table that sets only some sides keeps them; the sides it omits are 0. + let partial = body.replace("", r#""#); + let doc = craft_docx::read(&package(&partial, None)).unwrap(); + assert_eq!(doc.body[0].as_table().unwrap().props.cell_margins, Some([0.0, 7.2, 0.0, 0.0])); + // Rejecting a tracked change to the table's properties brings back the same zero margins. + let tracked = body.replace( + "", + r#""#, + ); + let doc = craft_docx::read(&package(&tracked, None)).unwrap(); + let change = doc.body[0].as_table().unwrap().props.fmt_change.as_deref().expect("a tracked table change"); + assert_eq!(change.old.cell_margins, Some([0.0; 4])); + // A table whose own style gives margins keeps them. + let styled = body.replace("", r#""#); + let boxed = r#""#; + let doc = craft_docx::read(&package(&styled, Some(boxed))).unwrap(); + assert_eq!(doc.body[0].as_table().unwrap().props.cell_margins, None); + // The zeros survive saving, which writes a default Normal Table. + let doc = craft_docx::read(&package(body, None)).unwrap(); + let bytes = craft_docx::write(&doc).unwrap(); + let mut xml = String::new(); + zip::ZipArchive::new(Cursor::new(bytes.clone())).unwrap().by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap(); + assert!(xml.contains(""), "{xml}"); + let reopened = craft_docx::read(&bytes).unwrap(); + assert_eq!(reopened.body[0].as_table().unwrap().props.cell_margins, Some([0.0; 4])); +}