From 15740f3083ccc62695a1bbdc98f97517d422f3ed Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 14 Nov 2026 10:53:46 +0000 Subject: [PATCH] fix(docx): mark declared Normal as the default --- crates/docx/src/read/styles.rs | 14 ++++++++++++++ crates/docx/tests/it/undefined_style.rs | 14 +++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/docx/src/read/styles.rs b/crates/docx/src/read/styles.rs index d2946b8ec..267fd9f15 100644 --- a/crates/docx/src/read/styles.rs +++ b/crates/docx/src/read/styles.rs @@ -93,6 +93,7 @@ impl Reader<'_> { } } let mut sheet = StyleSheet::empty(); + let mut normal_is_default = None; sheet.default_para_present = root.child("w:docDefaults").and_then(|dd| dd.child("w:pPrDefault")).is_some(); if let Some(dd) = root.child("w:docDefaults") { if let Some(r) = dd.child("w:rPrDefault").and_then(|d| d.child("w:rPr")) { @@ -120,6 +121,13 @@ impl Reader<'_> { "numbering" => StyleKind::Numbering, _ => StyleKind::Paragraph, }; + if kind == StyleKind::Paragraph && id == "Normal" { + // Word treats the built-in Normal style as the paragraph default when the file + // doesn't nominate another style. Keep that fact for an explicitly declared + // Normal; the gallery later synthesizes a visually identical Normal when the + // file did not declare one, and that synthetic style is not source provenance. + normal_is_default = Some(s.attr("w:default").is_none_or(|value| !matches!(value.trim(), "0" | "false" | "off" | "False" | "FALSE"))); + } let name = s.child_val("w:name").map(ui_style_name).unwrap_or_else(|| id.clone()); let r = |n: &str| s.child_val(n).filter(|v| !v.is_empty()).map(|v| self.pc.style_id(v)); let mut st = Style { @@ -213,6 +221,12 @@ impl Reader<'_> { for (s, n) in sheet.styles.iter_mut().zip(fixed) { s.para.numbering = n; } + if !sheet.styles.iter().any(|style| style.kind == StyleKind::Paragraph && style.default) + && normal_is_default == Some(true) + && let Some(normal) = sheet.styles.iter_mut().find(|style| style.kind == StyleKind::Paragraph && style.id == "Normal") + { + normal.default = true; + } let old_fixed: Vec>> = sheet .styles .iter() diff --git a/crates/docx/tests/it/undefined_style.rs b/crates/docx/tests/it/undefined_style.rs index e1a2611cb..48cf03410 100644 --- a/crates/docx/tests/it/undefined_style.rs +++ b/crates/docx/tests/it/undefined_style.rs @@ -9,6 +9,10 @@ const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; const R: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; fn package(normal_id: &str) -> Vec { + package_with_default(normal_id, true) +} + +fn package_with_default(normal_id: &str, is_default: bool) -> Vec { let root = r#""#; let doc = format!( r#"bodynormal"# @@ -16,8 +20,9 @@ fn package(normal_id: &str) -> Vec { let footer = format!( r#"1"# ); + let default_attr = if is_default { r#" w:default="1""# } else { "" }; let styles = format!( - r#""# + r#""# ); let rels = r#""#; let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); @@ -34,6 +39,13 @@ fn package(normal_id: &str) -> Vec { zw.finish().unwrap().into_inner() } +#[test] +fn an_explicit_normal_style_is_the_default_even_without_w_default() { + let doc = craft_docx::read(&package_with_default("Normal", false)).unwrap(); + let normal = doc.styles.get("Normal").expect("the explicit Normal style"); + assert!(normal.default, "Word treats the explicitly declared built-in Normal style as the default"); +} + fn footer_para(d: &Document) -> &craft_wordmodel::Paragraph { let part = d.parts.values().find(|p| p.kind == PartKind::Footer).expect("a footer"); match part.blocks.first().map(|b| &**b) {