From 15740f3083ccc62695a1bbdc98f97517d422f3ed Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 14 Nov 2026 10:53:46 +0000 Subject: [PATCH 1/8] 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) { From a94c1a386adf3376ba66ae82f57fe748ecda9eb7 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 14 Nov 2026 11:02:36 +0000 Subject: [PATCH 2/8] Respect on-valued default paragraph styles --- crates/docx/src/read/styles.rs | 9 +++++---- crates/docx/tests/it/undefined_style.rs | 15 ++++++++++++++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/crates/docx/src/read/styles.rs b/crates/docx/src/read/styles.rs index 267fd9f15..b1d7388e6 100644 --- a/crates/docx/src/read/styles.rs +++ b/crates/docx/src/read/styles.rs @@ -11,6 +11,9 @@ fn numbering_level_index(value: Option<&str>) -> Option { None => Some(0), Some(value) => int(value).filter(|level| (0..=8).contains(level)).map(|level| level as usize), } + +fn style_is_default(style: &El) -> bool { + style.attr("w:default").is_some_and(|value| !matches!(value.trim(), "0" | "false" | "off" | "False" | "FALSE")) } /// Built-in style names Word stores in lower case, and how the UI shows them. @@ -67,9 +70,7 @@ impl Reader<'_> { // The default paragraph style must be called `Normal` in our model. let has_normal = root.children("w:style").any(|s| s.attr("w:styleId") == Some("Normal")); if !has_normal - && let Some(def) = root - .children("w:style") - .find(|s| s.attr("w:type") == Some("paragraph") && s.attr("w:default").is_some_and(|v| v == "1" || v == "true")) + && let Some(def) = root.children("w:style").find(|s| s.attr("w:type") == Some("paragraph") && style_is_default(s)) && let Some(id) = def.attr("w:styleId") { self.pc.alias.insert(id.to_string(), "Normal".into()); @@ -143,7 +144,7 @@ impl Reader<'_> { builtin: !s.attr("w:customStyle").is_some_and(|v| matches!(v.trim(), "1" | "true" | "on")), locked: flag(s, "w:locked").unwrap_or(false), // Only where the writer couldn't tell from the id (it flags the English ids). - default: s.attr("w:default").is_some_and(|v| v == "1" || v == "true") + default: style_is_default(s) && !matches!( (kind, id.as_str()), (StyleKind::Paragraph, "Normal") diff --git a/crates/docx/tests/it/undefined_style.rs b/crates/docx/tests/it/undefined_style.rs index 48cf03410..8cb0c19be 100644 --- a/crates/docx/tests/it/undefined_style.rs +++ b/crates/docx/tests/it/undefined_style.rs @@ -13,6 +13,10 @@ fn package(normal_id: &str) -> Vec { } fn package_with_default(normal_id: &str, is_default: bool) -> Vec { + package_with_body_default(normal_id, is_default, false) +} + +fn package_with_body_default(normal_id: &str, is_default: bool, has_body_default: bool) -> Vec { let root = r#""#; let doc = format!( r#"bodynormal"# @@ -21,8 +25,10 @@ fn package_with_default(normal_id: &str, is_default: bool) -> Vec { r#"1"# ); let default_attr = if is_default { r#" w:default="1""# } else { "" }; + let body_style = + if has_body_default { r#""# } else { "" }; let styles = format!( - r#""# + r#"{body_style}"# ); let rels = r#""#; let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); @@ -46,6 +52,13 @@ fn an_explicit_normal_style_is_the_default_even_without_w_default() { assert!(normal.default, "Word treats the explicitly declared built-in Normal style as the default"); } +#[test] +fn an_explicit_body_default_on_prevents_normal_inference() { + let doc = craft_docx::read(&package_with_body_default("Normal", false, true)).unwrap(); + assert!(doc.styles.styles.iter().any(|style| style.id == "Body" && style.default)); + assert!(!doc.styles.styles.iter().any(|style| style.id == "Normal" && style.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) { From 00fdde1f06018585ecfc525ca6716184b45e1ffe Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 14 Nov 2026 11:16:16 +0000 Subject: [PATCH 3/8] docx: retain explicit nondefault Normal on write --- crates/docx/src/write/styles.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/docx/src/write/styles.rs b/crates/docx/src/write/styles.rs index b67c92ef1..6979c348c 100644 --- a/crates/docx/src/write/styles.rs +++ b/crates/docx/src/write/styles.rs @@ -75,9 +75,14 @@ fn style_xml(wr: &mut Writer<'_>, w: &mut W, st: &Style, flagged: &[StyleKind]) | (StyleKind::Table, "TableNormal") | (StyleKind::Numbering, "NoList") ); - let is_default = st.default || (english_default && !flagged.contains(&st.kind)); + let normal_is_explicitly_off = st.kind == StyleKind::Paragraph && st.id == "Normal" && !st.default; + let is_default = st.default || (english_default && !flagged.contains(&st.kind) && !normal_is_explicitly_off); let mut a = vec![("w:type", ty), ("w:styleId", st.id.as_str())]; - if is_default { + if normal_is_explicitly_off { + // Preserve a model where Normal is explicitly not default. Without this, the fallback + // `english_default` marker makes the reader infer a change on round trip. + a.push(("w:default", "0")); + } else if is_default { a.push(("w:default", "1")); } if !st.builtin { From 90d20fd39f5442cf38e07aec01e833422a92e922 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 14 Nov 2026 11:48:19 +0000 Subject: [PATCH 4/8] fix(docx): keep default Normal for fresh documents --- crates/docx/src/write/styles.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/docx/src/write/styles.rs b/crates/docx/src/write/styles.rs index 6979c348c..3bcf9ab97 100644 --- a/crates/docx/src/write/styles.rs +++ b/crates/docx/src/write/styles.rs @@ -75,7 +75,7 @@ fn style_xml(wr: &mut Writer<'_>, w: &mut W, st: &Style, flagged: &[StyleKind]) | (StyleKind::Table, "TableNormal") | (StyleKind::Numbering, "NoList") ); - let normal_is_explicitly_off = st.kind == StyleKind::Paragraph && st.id == "Normal" && !st.default; + let normal_is_explicitly_off = st.kind == StyleKind::Paragraph && st.id == "Normal" && !st.default && flagged.contains(&StyleKind::Paragraph); let is_default = st.default || (english_default && !flagged.contains(&st.kind) && !normal_is_explicitly_off); let mut a = vec![("w:type", ty), ("w:styleId", st.id.as_str())]; if normal_is_explicitly_off { From 31f4185fa5901594e3080e3e37f6f5c98c21ca3d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 14 Nov 2026 11:50:08 +0000 Subject: [PATCH 5/8] test(docx): preserve explicit Normal-off serialization --- crates/docx/tests/it/undefined_style.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/docx/tests/it/undefined_style.rs b/crates/docx/tests/it/undefined_style.rs index 8cb0c19be..a00ac4323 100644 --- a/crates/docx/tests/it/undefined_style.rs +++ b/crates/docx/tests/it/undefined_style.rs @@ -3,7 +3,7 @@ //! `` without defining Footer. use craft_wordmodel::props::LineSpacing; use craft_wordmodel::{Block, Document, PartKind}; -use std::io::Write; +use std::io::{Read, Write}; const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; const R: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; @@ -57,6 +57,13 @@ fn an_explicit_body_default_on_prevents_normal_inference() { let doc = craft_docx::read(&package_with_body_default("Normal", false, true)).unwrap(); assert!(doc.styles.styles.iter().any(|style| style.id == "Body" && style.default)); assert!(!doc.styles.styles.iter().any(|style| style.id == "Normal" && style.default)); + let saved = craft_docx::write(&doc).unwrap(); + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(saved)).unwrap(); + let mut file = archive.by_name("word/styles.xml").unwrap(); + let mut styles = String::new(); + file.read_to_string(&mut styles).unwrap(); + assert!(styles.contains(r#"w:styleId="Normal" w:default="0""#) || styles.contains(r#"w:default="0" w:styleId="Normal""#)); + assert!(styles.contains(r#"w:styleId="Body" w:default="1""#) || styles.contains(r#"w:default="1" w:styleId="Body""#)); } fn footer_para(d: &Document) -> &craft_wordmodel::Paragraph { From 6beefd7e9501a6ead5c0d87dc273df3a9c636c60 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 14 Nov 2026 11:58:07 +0000 Subject: [PATCH 6/8] fix(wordmodel): mark Normal as default in new documents --- crates/wordmodel/src/styles.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/wordmodel/src/styles.rs b/crates/wordmodel/src/styles.rs index 5383c1510..8ecbda0c2 100644 --- a/crates/wordmodel/src/styles.rs +++ b/crates/wordmodel/src/styles.rs @@ -538,7 +538,7 @@ impl StyleSheet { latent_styles: None, }; let mut add = |st: Style| s.styles.push(st); - add(Style { id: "Normal".into(), name: "Normal".into(), priority: Some(0), quick: true, ..p_style() }); + add(Style { id: "Normal".into(), name: "Normal".into(), priority: Some(0), quick: true, default: true, ..p_style() }); add(Style { id: "DefaultParagraphFont".into(), name: "Default Paragraph Font".into(), From 6d19bb44f36b97f08fb11d0945f557bb0a5fcb7c Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 14 Nov 2026 12:33:33 +0000 Subject: [PATCH 7/8] fix(docx): preserve Normal inference compatibility --- crates/docx/src/read/styles.rs | 2 +- crates/wordmodel/src/styles.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/docx/src/read/styles.rs b/crates/docx/src/read/styles.rs index b1d7388e6..da28dfd0f 100644 --- a/crates/docx/src/read/styles.rs +++ b/crates/docx/src/read/styles.rs @@ -127,7 +127,7 @@ impl Reader<'_> { // 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"))); + normal_is_default = Some(s.attr("w:default").is_none()); } 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)); diff --git a/crates/wordmodel/src/styles.rs b/crates/wordmodel/src/styles.rs index 8ecbda0c2..5383c1510 100644 --- a/crates/wordmodel/src/styles.rs +++ b/crates/wordmodel/src/styles.rs @@ -538,7 +538,7 @@ impl StyleSheet { latent_styles: None, }; let mut add = |st: Style| s.styles.push(st); - add(Style { id: "Normal".into(), name: "Normal".into(), priority: Some(0), quick: true, default: true, ..p_style() }); + add(Style { id: "Normal".into(), name: "Normal".into(), priority: Some(0), quick: true, ..p_style() }); add(Style { id: "DefaultParagraphFont".into(), name: "Default Paragraph Font".into(), From 8097ede0ffbbbf6e3877ba4e2c9d17280b11fe51 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 14 Nov 2026 13:29:23 +0000 Subject: [PATCH 8/8] Resolve DOCX migration conflict with current main --- crates/docx/src/read/styles.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/docx/src/read/styles.rs b/crates/docx/src/read/styles.rs index da28dfd0f..080eaff21 100644 --- a/crates/docx/src/read/styles.rs +++ b/crates/docx/src/read/styles.rs @@ -11,6 +11,7 @@ fn numbering_level_index(value: Option<&str>) -> Option { None => Some(0), Some(value) => int(value).filter(|level| (0..=8).contains(level)).map(|level| level as usize), } +} fn style_is_default(style: &El) -> bool { style.attr("w:default").is_some_and(|value| !matches!(value.trim(), "0" | "false" | "off" | "False" | "FALSE"))