diff --git a/crates/docx/src/read.rs b/crates/docx/src/read.rs index 3e6ea5e61..d50625285 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.eq_ignore_ascii_case("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..e1a2611cb 100644 --- a/crates/docx/tests/it/undefined_style.rs +++ b/crates/docx/tests/it/undefined_style.rs @@ -69,3 +69,37 @@ 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#""# + ); + for main_path in ["word/document.xml", "WORD/DOCUMENT.XML"] { + let root = format!( + r#""# + ); + let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, body) in [ + ("_rels/.rels", root.as_str()), + (main_path, 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()); + } +}