From fbbebecf273bc6c967f99445eb1638136ec612b8 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 14 Nov 2026 02:28:04 +0000 Subject: [PATCH] Preserve conventional Word part fallback --- crates/docx/src/read.rs | 19 ++++++++++++++++++- crates/docx/tests/it/undefined_style.rs | 24 ++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/crates/docx/src/read.rs b/crates/docx/src/read.rs index 3e6ea5e61..95da45d0a 100644 --- a/crates/docx/src/read.rs +++ b/crates/docx/src/read.rs @@ -197,7 +197,24 @@ fn read_opened_in(pkg: &Package, chunks: ChunkState) -> Result<(Document, usize, r.doc.missing_pictures.main = main.clone(); r.pc.major_font = r.doc.settings.major_font.clone(); r.pc.minor_font = r.doc.settings.minor_font.clone(); - let part = |t: &str| rels.by_type(t).filter(|x| !x.external).map(|x| x.target.clone()); + let part = |t: &str| { + let related = rels.by_type(t).filter(|x| !x.external).map(|x| x.target.clone()).filter(|p| pkg.get(p).is_some()); + related.or_else(|| { + // Older DesignCraft imports read these two conventional parts directly. Keep opening + // those files when a producer omitted the document relationship; the bounded package + // reader still applies the same entry and XML limits. An explicit usable relationship + // always wins, including when it points outside the conventional `word/` paths. + if main != "word/document.xml" { + return None; + } + let fallback = match t { + rt::STYLES => "word/styles.xml", + rt::NUMBERING => "word/numbering.xml", + _ => return None, + }; + pkg.get(fallback).map(|_| fallback.to_string()) + }) + }; // The font table and the fonts embedded through it ride along as opaque parts. if let Some(p) = part(rt::FONT_TABLE) { diff --git a/crates/docx/tests/it/undefined_style.rs b/crates/docx/tests/it/undefined_style.rs index c50394a2a..c50a9c8b7 100644 --- a/crates/docx/tests/it/undefined_style.rs +++ b/crates/docx/tests/it/undefined_style.rs @@ -69,3 +69,27 @@ fn an_undefined_style_is_written_back_as_it_was_named() { assert_eq!(footer_para(&again).props.style.as_deref(), Some("Footer")); assert_eq!(again.styles.resolve_para(&footer_para(&again).props).space_after, 2.0); } + +#[test] +fn conventional_styles_and_numbering_parts_without_relationships_are_read() { + // The old DesignCraft reader opened these conventional paths directly. Keep that tolerated + // input behavior while preferring OPC relationships whenever they resolve successfully. + let doc = format!( + r#"text"# + ); + let styles = format!(r#""#); + let numbering = format!( + r#""# + ); + let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, body) in [("word/document.xml", doc.as_str()), ("word/styles.xml", styles.as_str()), ("word/numbering.xml", numbering.as_str())] { + zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + zw.write_all(body.as_bytes()).unwrap(); + } + let d = craft_docx::read(&zw.finish().unwrap().into_inner()).unwrap(); + assert!(d.styles.styles.iter().any(|style| style.id == "Body")); + let Some(Block::Para(p)) = d.body.first().map(|b| &**b) else { panic!("a body paragraph") }; + assert_eq!(p.props.style.as_deref(), Some("Body")); + assert_eq!(p.props.numbering.map(|n| (n.num, n.level)), Some((7, 0))); + assert!(d.numbering.level(7, 0).is_some()); +}