diff --git a/crates/docx/src/package.rs b/crates/docx/src/package.rs
index debef3cb5..f98a3a709 100644
--- a/crates/docx/src/package.rs
+++ b/crates/docx/src/package.rs
@@ -258,6 +258,14 @@ impl Package {
self.files.iter().find(|(k, _)| k.eq_ignore_ascii_case(n)).map(|(_, v)| v)
}
+ /// [`Package::xml`] for the main document part: malformed XML is an error, not a truncated read.
+ pub fn xml_strict(&self, name: &str) -> Result, DocxError> {
+ match self.get(name) {
+ Some(b) => xml::parse_strict(b).map(Some),
+ None => Ok(None),
+ }
+ }
+
pub fn xml(&self, name: &str) -> Result , DocxError> {
match self.get(name) {
Some(b) => xml::parse(b).map(Some),
diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs
index 79bed9f35..8bdb65933 100644
--- a/crates/docx/src/read/mod.rs
+++ b/crates/docx/src/read/mod.rs
@@ -129,7 +129,7 @@ pub(crate) fn read_package(bytes: &[u8]) -> Result {
pub(crate) fn read_opened(pkg: &Package) -> Result {
let root_rels = pkg.rels("");
let main = root_rels.by_type(rt::OFFICE_DOC).filter(|r| !r.external).map(|r| r.target.clone()).unwrap_or_else(|| "word/document.xml".to_string());
- let Some(root) = pkg.xml(&main)? else {
+ let Some(root) = pkg.xml_strict(&main)? else {
return Err(DocxError::MissingPart(main));
};
if root.name != "w:document" {
diff --git a/crates/docx/src/xml.rs b/crates/docx/src/xml.rs
index 1ebb497a8..c517f3669 100644
--- a/crates/docx/src/xml.rs
+++ b/crates/docx/src/xml.rs
@@ -263,6 +263,16 @@ pub fn parse(bytes: &[u8]) -> Result {
/// [`parse`] with `extra` namespaces (prefix, URI) mapped to canonical prefixes first, so a
/// related vocabulary (WordprocessingML 2003) reads with the prefixes the reader knows.
pub fn parse_ns(bytes: &[u8], extra: &[(&'static str, &str)]) -> Result {
+ parse_with(bytes, extra, false)
+}
+
+/// [`parse`] for a part whose loss would be silent data loss (the main document): a mismatched end
+/// tag or an element left open at the end of the data is an error, not a cue to stop reading.
+pub fn parse_strict(bytes: &[u8]) -> Result {
+ parse_with(bytes, &[], true)
+}
+
+fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Result {
// UTF-16 (with a BOM) is transcoded to UTF-8; a UTF-8 BOM is stripped.
let transcoded = utf16_to_utf8(bytes);
let bytes = match &transcoded {
@@ -275,7 +285,7 @@ pub fn parse_ns(bytes: &[u8], extra: &[(&'static str, &str)]) -> Result = Vec::new();
let mut root: Option = None;
@@ -389,6 +399,9 @@ pub fn parse_ns(bytes: &[u8], extra: &[(&'static str, &str)]) -> Result Vec {
+ doc_zip(format!("{body} ").as_bytes())
+}
+
+#[test]
+fn a_malformed_main_part_is_an_error_not_a_truncated_document() {
+ let cases = [
+ ("a less-than sign in a run", "before a <- b after last "),
+ ("a paragraph left open", "first second third "),
+ ("a mismatched end tag", "one two "),
+ ];
+ for (what, body) in cases {
+ assert!(matches!(read(&body_zip(body)), Err(DocxError::Xml(_))), "{what}");
+ }
+ let whole = "a <- b last ";
+ let d = read(&body_zip(whole)).unwrap();
+ assert_eq!(d.plain_text(wordcraft_doc::StoryRef::Body), "a <- b\nlast", "well-formed text still reads whole");
+}
+
#[test]
fn deep_nesting_is_rejected() {
let mut x = format!("");