Merge pull request #1278 from naxvog/docx/cell-margins-without-table-style

craft-docx: a file with no default table style gives its tables no cell margins
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-14 11:36:33 -05:00
commit 1f96d6b6bb
4 files changed
+108 -1

No files matched your search

+5
View File
@@ -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<craft_wordmodel::fill::Fill>,
/// 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();
+18 -1
View File
@@ -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<Table> {
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")
+15
View File
@@ -64,6 +64,20 @@ fn builtin_heading_level(name: &str) -> Option<u8> {
}
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<String> = 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<Option<craft_wordmodel::props::NumRef>> = sheet
@@ -28,3 +28,73 @@ fn partial_margin_stays_partial() {
assert!(!margins.contains("<w:bottom"));
assert!(!margins.contains("<w:right"));
}
/// A package with this body and, if given, this styles part.
fn package(body: &str, styles: Option<&str>) -> Vec<u8> {
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#"<w:document xmlns:w="{W}"><w:body>{body}</w:body></w:document>"#));
if let Some(styles) = styles {
add("word/styles.xml", &format!(r#"<w:styles xmlns:w="{W}">{styles}</w:styles>"#));
add(
"word/_rels/document.xml.rels",
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#,
);
}
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#"<w:tbl><w:tblPr><w:tblW w:w="6000" w:type="dxa"/></w:tblPr><w:tblGrid><w:gridCol w:w="3000"/></w:tblGrid><w:tr><w:tc><w:tcPr><w:tcMar><w:right w:w="108" w:type="dxa"/></w:tcMar></w:tcPr><w:p/></w:tc></w:tr></w:tbl>"#;
let normal_table = r#"<w:style w:type="table" w:default="1" w:styleId="TableNormal"><w:name w:val="Normal Table"/><w:tblPr><w:tblCellMar><w:left w:w="108" w:type="dxa"/><w:right w:w="108" w:type="dxa"/></w:tblCellMar></w:tblPr></w:style>"#;
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#"<w:docDefaults/>"#)), (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}<w:style w:type="table" w:styleId="TableNormal"><w:name w:val="Normal Table"/></w:style>"#);
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#"<w:style w:type="table" w:default="on" w:customStyle="1" w:styleId="Plain"><w:name w:val="Plain"/></w:style>"#;
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("<w:tblPr>", r#"<w:tblPr><w:tblCellMar><w:left w:w="144" w:type="dxa"/></w:tblCellMar>"#);
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(
"</w:tblPr>",
r#"<w:tblPrChange w:id="1" w:author="A" w:date="2026-11-13T00:00:00Z"><w:tblPr><w:tblW w:w="5000" w:type="dxa"/></w:tblPr></w:tblPrChange></w:tblPr>"#,
);
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("<w:tblPr>", r#"<w:tblPr><w:tblStyle w:val="Boxed"/>"#);
let boxed = r#"<w:style w:type="table" w:styleId="Boxed"><w:name w:val="Boxed"/><w:tblPr><w:tblCellMar><w:left w:w="144" w:type="dxa"/></w:tblCellMar></w:tblPr></w:style>"#;
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("<w:tblCellMar>"), "{xml}");
let reopened = craft_docx::read(&bytes).unwrap();
assert_eq!(reopened.body[0].as_table().unwrap().props.cell_margins, Some([0.0; 4]));
}