diff --git a/crates/docx/src/read/styles.rs b/crates/docx/src/read/styles.rs index d2946b8ec..080eaff21 100644 --- a/crates/docx/src/read/styles.rs +++ b/crates/docx/src/read/styles.rs @@ -13,6 +13,10 @@ fn numbering_level_index(value: Option<&str>) -> Option { } } +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. pub(crate) const LOWER_NAMES: &[(&str, &str)] = &[ ("caption", "Caption"), @@ -67,9 +71,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()); @@ -93,6 +95,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 +123,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()); + } 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 { @@ -135,7 +145,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") @@ -213,6 +223,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/src/write/styles.rs b/crates/docx/src/write/styles.rs index b67c92ef1..3bcf9ab97 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 && 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 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 { diff --git a/crates/docx/tests/it/undefined_style.rs b/crates/docx/tests/it/undefined_style.rs index e1a2611cb..a00ac4323 100644 --- a/crates/docx/tests/it/undefined_style.rs +++ b/crates/docx/tests/it/undefined_style.rs @@ -3,12 +3,20 @@ //! `` 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"; fn package(normal_id: &str) -> Vec { + package_with_default(normal_id, true) +} + +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"# @@ -16,8 +24,11 @@ fn package(normal_id: &str) -> Vec { let footer = format!( 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())); @@ -34,6 +45,27 @@ 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"); +} + +#[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)); + 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 { let part = d.parts.values().find(|p| p.kind == PartKind::Footer).expect("a footer"); match part.blocks.first().map(|b| &**b) {