From baeb8d9c6c9d7ee7d1e6ca96166183d49d88524d Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Mon, 12 Oct 2026 03:59:00 +0200 Subject: [PATCH] Open and save Word XML Document (Flat OPC); open Word 2003 XML (#548) (#560) - docx: Flat OPC (`pkg:package`) read by unpacking the parts into an in-memory package for the DOCX reader; written from the DOCX writer's parts (XML inline, binary parts in base64). Part count, part size, total size and base64 length are capped. - docx: Word 2003 XML (`w:wordDocument`) imported by rewriting it as the corresponding package (hyphenated names, w:hlink, w:listPr, w:lists, inline headers/footers, notes, aml annotations, w:binData pictures, o:DocumentProperties) and reading that with the DOCX reader. - `.xml` told apart by root element; other XML is refused with "not a Word XML document". File > Export gets "Word XML Document (*.xml)" (all 9 catalogs); .xml saves keep the document like .docx. - Tests: Flat OPC round trip equals DOCX round trip, hand-written Flat OPC package, Word 2003 import, hostile inputs, engine io. Fixes #548 Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 1 + apps/wordcraft-web/src/web.rs | 3 +- apps/wordcraft/src/file_dialogs.rs | 2 +- crates/docx/src/flat.rs | 411 ++++++++++++++++++++ crates/docx/src/lib.rs | 23 ++ crates/docx/src/package.rs | 6 + crates/docx/src/read/mod.rs | 9 +- crates/docx/src/wordml2003.rs | 576 ++++++++++++++++++++++++++++ crates/docx/src/write/mod.rs | 13 +- crates/docx/src/xml.rs | 19 +- crates/docx/tests/roundtrip.rs | 15 + crates/docx/tests/word_xml.rs | 215 +++++++++++ crates/engine/src/cmd/file.rs | 2 +- crates/engine/src/io.rs | 3 +- crates/engine/src/io_ext.rs | 3 + crates/engine/src/tests.rs | 13 + crates/ui-egui/src/backstage.rs | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/et.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sr-latn.tsv | 1 + crates/ui-egui/src/i18n/sr.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/lib.rs | 2 +- docs/file-format-parity.md | 9 +- docs/gaps.md | 5 +- 31 files changed, 1322 insertions(+), 22 deletions(-) create mode 100644 crates/docx/src/flat.rs create mode 100644 crates/docx/src/wordml2003.rs create mode 100644 crates/docx/tests/word_xml.rs diff --git a/ROADMAP.md b/ROADMAP.md index 250708ed2d..d9e54301f8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| +| 2026-10-11 | Word XML files (#548): Word XML Document (Flat OPC `.xml`) opens and saves through the DOCX reader and writer; Word 2003 XML opens (styles, lists, tables, sections, notes, comments, pictures, hyperlinks) | | 2026-10-11 | Font fallback indexes which characters each installed font maps, reading each font file once (#605): text in a script no installed font covers no longer re-reads every system font per distinct character (200 such characters: 13 s → 0.2 s) | | 2026-10-11 | Find and Replace special characters (`^p`, `^t`, `^#`, `^&` …) and Use wildcards (ranges, counts, word anchors, groups with `\1` in Replace); `^p` finds and splits paragraphs; Special ▾ menu and search options in the dialog (#543) | | 2026-10-11 | Typing parity (#546): `+---+` Enter makes a table, `> ` `-> ` `=> ` arrow bullets, smileys inside words (`ok:)`), Undo right after a border line brings the characters back | diff --git a/apps/wordcraft-web/src/web.rs b/apps/wordcraft-web/src/web.rs index 5d68374d22..a49a04d83d 100644 --- a/apps/wordcraft-web/src/web.rs +++ b/apps/wordcraft-web/src/web.rs @@ -8,7 +8,7 @@ use wasm_bindgen::JsCast as _; use wordcraft_engine::Session; use wordcraft_ui_egui::{Inbox, Services, WordApp}; -const DOC_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json"]; +const DOC_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json", "xml"]; const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp"]; const RECIPIENT_EXTS: &[&str] = &["csv", "tsv", "txt", "xlsx", "xlsm", "ods"]; const CANVAS_ID: &str = "wordcraft_canvas"; @@ -306,6 +306,7 @@ fn mime_for(name: &str) -> &'static str { Some("md" | "txt") => "text/plain", Some("tex") => "application/x-tex", Some("json") => "application/json", + Some("xml") => "application/xml", _ => "application/octet-stream", } } diff --git a/apps/wordcraft/src/file_dialogs.rs b/apps/wordcraft/src/file_dialogs.rs index f348b168ab..a194c9642f 100644 --- a/apps/wordcraft/src/file_dialogs.rs +++ b/apps/wordcraft/src/file_dialogs.rs @@ -75,7 +75,7 @@ fn open_filters(d: rfd::AsyncFileDialog, purpose: &str) -> rfd::AsyncFileDialog } else if purpose == "recipients" { d.add_filter("Recipient lists", &["csv", "tsv", "txt", "xlsx", "xlsm", "ods"]).add_filter("All files", &["*"]) } else { - d.add_filter("Documents", &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json"]) + d.add_filter("Documents", &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json", "xml"]) .add_filter("Word document", &["docx"]) .add_filter("All files", &["*"]) } diff --git a/crates/docx/src/flat.rs b/crates/docx/src/flat.rs new file mode 100644 index 0000000000..f0949a200b --- /dev/null +++ b/crates/docx/src/flat.rs @@ -0,0 +1,411 @@ +//! Word XML Document: a whole WordprocessingML package as one XML file (the Flat OPC format Word +//! saves as "Word XML Document (*.xml)"): a `pkg:package` root holding one `pkg:part` per package +//! part, each with its name and content type, and the part's XML inline (`pkg:xmlData`) or its +//! bytes in base64 (`pkg:binaryData`). +//! +//! Reading unpacks the parts into an in-memory [`Package`] and hands it to the DOCX reader; +//! writing runs the DOCX writer and lays its parts out in one file. Bounded like the zip reader: +//! part count, part size, total size and base64 length are capped. + +use std::collections::BTreeMap; + +use base64::Engine as _; +use quick_xml::events::{BytesStart, Event}; +use quick_xml::name::ResolveResult; +use quick_xml::reader::NsReader; +use wordcraft_doc::Document; + +use crate::package::{ContentTypes, MAX_ENTRIES, MAX_PART, MAX_TOTAL, Package}; +use crate::xml::{self, W, esc}; +use crate::{DocxError, Flavor}; + +/// The Flat OPC package namespace. +pub const PKG_NS: &str = "http://schemas.microsoft.com/office/2006/xmlPackage"; +/// The WordprocessingML 2003 namespace (Word 2003 XML Document). +pub const W2003_NS: &str = "http://schemas.microsoft.com/office/word/2003/wordml"; + +/// What kind of XML file a `.xml` holds. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum XmlKind { + /// A Flat OPC package (Word XML Document, Word 2007 and later). + FlatOpc, + /// A Word 2003 XML Document (WordprocessingML 2003). + WordMl2003, + /// Some other XML, or not XML at all. + Other, +} + +/// Elements and declarations looked at before the root element (comments, processing +/// instructions) before giving up. +const MAX_PROLOG_EVENTS: usize = 10_000; + +/// The text of `bytes` as UTF-8: UTF-16 with a byte-order mark is transcoded, a UTF-8 one dropped. +fn utf8_text(bytes: &[u8]) -> std::borrow::Cow<'_, [u8]> { + match xml::utf16_to_utf8(bytes) { + Some(s) => std::borrow::Cow::Owned(s.into_bytes()), + None => std::borrow::Cow::Borrowed(bytes.strip_prefix(&[0xEF, 0xBB, 0xBF][..]).unwrap_or(bytes)), + } +} + +/// Tell a Word XML file by its root element: `pkg:package` (Flat OPC) or `w:wordDocument` +/// (Word 2003), each in its namespace. +pub fn sniff_xml(bytes: &[u8]) -> XmlKind { + let text = utf8_text(bytes); + let mut r = NsReader::from_reader(text.as_ref()); + for _ in 0..MAX_PROLOG_EVENTS { + match r.read_resolved_event() { + Ok((res, Event::Start(e) | Event::Empty(e))) => { + let ns = match res { + ResolveResult::Bound(ns) => ns.as_ref().to_vec(), + _ => Vec::new(), + }; + return match (e.local_name().as_ref(), ns.as_slice()) { + (b"package", ns) if ns == PKG_NS.as_bytes() => XmlKind::FlatOpc, + (b"wordDocument", ns) if ns == W2003_NS.as_bytes() => XmlKind::WordMl2003, + _ => XmlKind::Other, + }; + } + Ok((_, Event::Eof)) | Err(_) => return XmlKind::Other, + Ok(_) => {} + } + } + XmlKind::Other +} + +/// Read a Flat OPC file. +pub fn read(bytes: &[u8]) -> Result { + crate::read::read_opened(&unpack(bytes)?) +} + +/// The data element of the part being read. +enum Data { + /// `pkg:xmlData`: its content starts at this byte offset. + Xml(usize), + /// `pkg:binaryData`: the base64 text so far. + Binary(String), +} + +/// The part being read: name, content type, data. +struct Part { + name: String, + content_type: String, + data: Option, +} + +/// Longest base64 text of one part (the size limit, encoded; whitespace isn't kept). +const MAX_BASE64: u64 = (MAX_PART / 3 + 1) * 4; + +/// Unpack a Flat OPC file into its parts, with a `[Content_Types].xml` made from the parts' +/// content types (unless the file carries one). +pub(crate) fn unpack(bytes: &[u8]) -> Result { + let text = utf8_text(bytes); + let src: &[u8] = text.as_ref(); + let mut r = NsReader::from_reader(src); + r.config_mut().check_end_names = true; + let mut depth = 0usize; + let mut files: BTreeMap> = BTreeMap::new(); + let mut types: Vec<(String, String)> = Vec::new(); + let mut total: u64 = 0; + let mut part: Option = None; + // `xmlns` declarations on the package, part and data elements, in scope for the inline XML. + let mut scope: Vec> = Vec::new(); + let bad = |e: quick_xml::Error| DocxError::Xml(e.to_string()); + loop { + let before = usize::try_from(r.buffer_position()).unwrap_or(usize::MAX); + let (res, ev) = r.read_resolved_event().map_err(bad)?; + let in_pkg = matches!(&res, ResolveResult::Bound(ns) if ns.as_ref() == PKG_NS.as_bytes()); + match ev { + Event::Start(ref e) | Event::Empty(ref e) => { + let empty = matches!(ev, Event::Empty(_)); + depth += 1; + let local = e.local_name().as_ref().to_vec(); + match depth { + 1 if !(in_pkg && local == b"package") => { + return Err(DocxError::NotWord("the root element isn't a Flat OPC pkg:package".into())); + } + 2 if in_pkg && local == b"part" => { + if files.len() + usize::from(part.is_some()) >= MAX_ENTRIES { + return Err(DocxError::Limit(format!("more than {MAX_ENTRIES} parts"))); + } + let name = attr(e, b"name").unwrap_or_default(); + let content_type = attr(e, b"contentType").unwrap_or_default(); + part = Some(Part { name, content_type, data: None }); + } + 3 if in_pkg => { + if let Some(p) = part.as_mut().filter(|p| p.data.is_none()) { + let pos = usize::try_from(r.buffer_position()).unwrap_or(usize::MAX); + p.data = match local.as_slice() { + // ``: an empty part. + b"xmlData" if empty => Some(Data::Binary(String::new())), + b"xmlData" => Some(Data::Xml(pos)), + b"binaryData" => Some(Data::Binary(String::new())), + _ => None, + }; + } + } + _ => {} + } + if depth <= 3 { + scope.push(ns_decls(e)); + } + if empty { + if depth == 2 + && let Some(p) = part.take() + { + finish_part(p, &mut files, &mut types, &mut total)?; + } + if depth <= 3 { + scope.pop(); + } + depth -= 1; + } + } + Event::End(_) => { + // The inline XML ends where its data element's end tag starts. + if depth == 3 + && let Some(p) = part.as_mut() + && let Some(Data::Xml(start)) = p.data + { + p.data = None; + let xml = with_scope(src.get(start..before).unwrap_or_default(), &scope); + store(&p.name, &p.content_type, xml, &mut files, &mut types, &mut total)?; + } + if depth == 2 + && let Some(p) = part.take() + { + finish_part(p, &mut files, &mut types, &mut total)?; + } + if depth <= 3 { + scope.pop(); + } + depth = depth.saturating_sub(1); + if depth == 0 { + break; + } + } + Event::Text(ref t) if depth == 3 => { + if let Some(Part { data: Some(Data::Binary(b64)), .. }) = part.as_mut() { + let s = std::str::from_utf8(t.as_ref()).map_err(|e| DocxError::Xml(e.to_string()))?; + b64.extend(s.chars().filter(|c| !c.is_ascii_whitespace())); + if b64.len() as u64 > MAX_BASE64 { + return Err(DocxError::Limit("a binary part is larger than the size limit".into())); + } + } + } + Event::Eof => break, + _ => {} + } + } + if files.is_empty() { + return Err(DocxError::NotWord("the Flat OPC package has no parts".into())); + } + if !files.contains_key("[Content_Types].xml") { + let mut w = W::new(); + w.open("Types", &[("xmlns", "http://schemas.openxmlformats.org/package/2006/content-types")]); + for (name, ct) in &types { + w.empty("Override", &[("PartName", &format!("/{name}")), ("ContentType", ct)]); + } + w.close("Types"); + files.insert("[Content_Types].xml".into(), w.into_bytes()); + } + Ok(Package::from_parts(files)) +} + +/// End of a `pkg:part`: store its binary data (inline XML was stored when it ended). +fn finish_part(p: Part, files: &mut BTreeMap>, types: &mut Vec<(String, String)>, total: &mut u64) -> Result<(), DocxError> { + match p.data { + Some(Data::Binary(b64)) => { + let bytes = base64::engine::general_purpose::STANDARD_NO_PAD + .decode(b64.trim_end_matches('=')) + .map_err(|_| DocxError::Xml(format!("part {} has invalid base64 data", p.name)))?; + store(&p.name, &p.content_type, bytes, files, types, total) + } + // A part without data, or whose inline XML was already stored. + _ => Ok(()), + } +} + +/// Add a part, within the size limits; a repeated or empty name is ignored. +fn store( + name: &str, + content_type: &str, + bytes: Vec, + files: &mut BTreeMap>, + types: &mut Vec<(String, String)>, + total: &mut u64, +) -> Result<(), DocxError> { + let name = name.trim_start_matches('/'); + if name.is_empty() || files.contains_key(name) { + return Ok(()); + } + let len = bytes.len() as u64; + if len > MAX_PART { + return Err(DocxError::Limit(format!("part {name} is larger than the size limit"))); + } + *total = total.saturating_add(len); + if *total > MAX_TOTAL { + return Err(DocxError::Limit("the package is larger than the size limit".into())); + } + if !content_type.is_empty() && !content_type.contains(['\r', '\n']) { + types.push((name.to_string(), content_type.to_string())); + } + files.insert(name.to_string(), bytes); + Ok(()) +} + +/// An attribute's unescaped value, by local name (any prefix). +fn attr(e: &BytesStart, local: &[u8]) -> Option { + e.attributes().with_checks(false).flatten().find(|a| a.key.local_name().as_ref() == local).map(|a| { + match a.normalized_value(quick_xml::XmlVersion::Explicit1_0) { + Ok(v) => v.into_owned(), + Err(_) => String::from_utf8_lossy(&a.value).into_owned(), + } + }) +} + +/// The `xmlns` / `xmlns:p` declarations of a start tag, as written (value still escaped). +fn ns_decls(e: &BytesStart) -> Vec<(String, String)> { + e.attributes() + .with_checks(false) + .flatten() + .filter(|a| a.key.as_ref() == b"xmlns" || a.key.as_ref().starts_with(b"xmlns:")) + .filter(|a| !a.value.contains(&b'"')) + .map(|a| (String::from_utf8_lossy(a.key.as_ref()).into_owned(), String::from_utf8_lossy(&a.value).into_owned())) + .take(256) + .collect() +} + +/// The inline XML of a part as a document of its own: namespaces it inherits from the package +/// elements are declared on its root element (unless it declares them itself). +fn with_scope(frag: &[u8], scope: &[Vec<(String, String)>]) -> Vec { + let decls: Vec<&(String, String)> = scope.iter().flatten().filter(|(k, _)| k != "xmlns:pkg").collect(); + let Some(open) = root_tag_start(frag) else { return frag.to_vec() }; + if decls.is_empty() { + return frag.get(open..).unwrap_or_default().to_vec(); + } + let rest = frag.get(open..).unwrap_or_default(); + let name_end = rest.iter().position(|b| b.is_ascii_whitespace() || *b == b'>' || *b == b'/').unwrap_or(rest.len()); + let tag_end = rest.iter().position(|b| *b == b'>').unwrap_or(rest.len()); + let tag = rest.get(..tag_end).unwrap_or_default(); + let mut out = Vec::with_capacity(rest.len() + 256); + out.extend_from_slice(rest.get(..name_end).unwrap_or_default()); + // Innermost declarations win: walk from the data element out, skipping prefixes seen. + let mut seen: Vec<&str> = Vec::new(); + for (k, v) in decls.iter().rev() { + let declared = tag.windows(k.len() + 1).any(|w| w.get(..k.len()) == Some(k.as_bytes()) && matches!(w.last(), Some(b'=' | b' '))); + if declared || seen.contains(&k.as_str()) { + continue; + } + seen.push(k); + out.extend_from_slice(format!(" {k}=\"{v}\"").as_bytes()); + } + out.extend_from_slice(rest.get(name_end..).unwrap_or_default()); + out +} + +/// Offset of the `<` of the first element in `frag` (after whitespace, comments and processing +/// instructions). +fn root_tag_start(frag: &[u8]) -> Option { + let mut i = 0usize; + loop { + while frag.get(i).is_some_and(u8::is_ascii_whitespace) { + i += 1; + } + let rest = frag.get(i..)?; + if rest.starts_with(b"")? + 3; + } else if rest.starts_with(b"")? + 2; + } else if rest.first() == Some(&b'<') { + return Some(i); + } else { + return None; + } + } +} + +/// Write `doc` as a Word XML Document (Flat OPC). +pub fn write(doc: &Document) -> Result, DocxError> { + let parts = crate::write::write_parts(doc, Flavor::Document)?; + let types = parts + .iter() + .find(|(n, _)| n == "[Content_Types].xml") + .map(|(n, b)| ContentTypes::read(&Package::from_parts(BTreeMap::from([(n.clone(), b.clone())])))) + .unwrap_or_default(); + let mut s = String::from("\n\n"); + s.push_str(&format!("")); + for (name, bytes) in &parts { + if name == "[Content_Types].xml" { + continue; + } + let ct = types.of(name).unwrap_or("application/octet-stream"); + s.push_str(&format!(" { + s.push_str(">"); + s.push_str(x); + s.push_str(""); + } + None => { + s.push_str(" pkg:compression=\"store\">"); + let b64 = base64::engine::general_purpose::STANDARD.encode(bytes); + for (i, chunk) in b64.as_bytes().chunks(76).enumerate() { + if i > 0 { + s.push('\n'); + } + s.push_str(std::str::from_utf8(chunk).unwrap_or_default()); + } + s.push_str(""); + } + } + } + s.push_str(""); + Ok(s.into_bytes()) +} + +/// An XML part's markup without its XML declaration, to inline as `pkg:xmlData`; `None` for +/// a part that isn't UTF-8 XML (or has a DOCTYPE), which goes in base64. +fn xml_fragment<'a>(ct: &str, bytes: &'a [u8]) -> Option<&'a str> { + let ct = ct.to_ascii_lowercase(); + if !(ct.ends_with("+xml") || ct.ends_with("/xml")) { + return None; + } + let s = std::str::from_utf8(bytes).ok()?; + let s = s.strip_prefix('\u{FEFF}').unwrap_or(s).trim_start(); + let s = match s.strip_prefix(" rest.split_once("?>").map(|(_, r)| r)?.trim_start(), + None => s, + }; + (s.starts_with('<') && !s.contains("").as_bytes() + ), + XmlKind::FlatOpc + ); + assert_eq!(sniff_xml(format!("\u{FEFF}").as_bytes()), XmlKind::WordMl2003); + assert_eq!(sniff_xml(b""), XmlKind::Other); + assert_eq!(sniff_xml(b""), XmlKind::Other); + assert_eq!(sniff_xml(b"\x00\xff garbage"), XmlKind::Other); + assert_eq!(sniff_xml(b""), XmlKind::Other); + } + + #[test] + fn inline_xml_inherits_namespaces_from_the_package() { + let frag = b"\n "; + let scope = vec![vec![("xmlns:pkg".to_string(), PKG_NS.to_string()), ("xmlns:w".to_string(), "urn:w".to_string())]]; + let out = String::from_utf8(with_scope(frag, &scope)).unwrap(); + assert_eq!(out, ""); + let own = b""; + assert_eq!(with_scope(own, &scope), own.to_vec()); + assert_eq!(with_scope(b"no element", &scope), b"no element".to_vec()); + } +} diff --git a/crates/docx/src/lib.rs b/crates/docx/src/lib.rs index 62b8478034..e5895d32fb 100644 --- a/crates/docx/src/lib.rs +++ b/crates/docx/src/lib.rs @@ -10,10 +10,12 @@ mod chart_spec; mod crypt; mod custom; +mod flat; mod package; mod read; mod smart_art; mod units; +mod wordml2003; mod write; mod xml; @@ -22,10 +24,28 @@ pub use crypt::{ DEFAULT_SPIN_COUNT, MAX_PASSWORD_CHARS, PasswordCheck, check_password, check_protection_password, decrypt, encrypt, encrypt_with_spin_count, is_encrypted, protection_hash, }; +pub use flat::{XmlKind, sniff_xml, write as write_flat}; pub use read::read; pub use smart_art::smart_art_items; pub use write::{write, write_as}; +/// Largest `.xml` file read as a Word XML document. +pub const MAX_XML_FILE: usize = 1 << 30; + +/// Read a Word XML file (`.xml`): a Word XML Document (Flat OPC, the whole package in one XML +/// file) or a Word 2003 XML Document (WordprocessingML 2003), told apart by the root element. +/// Any other XML gives [`DocxError::NotWordXml`]. +pub fn read_xml(bytes: &[u8]) -> Result { + if bytes.len() > MAX_XML_FILE { + return Err(DocxError::Limit(format!("XML file of {} bytes", bytes.len()))); + } + match sniff_xml(bytes) { + XmlKind::FlatOpc => flat::read(bytes), + XmlKind::WordMl2003 => wordml2003::read(bytes), + XmlKind::Other => Err(DocxError::NotWordXml), + } +} + /// What [`DocxError::PasswordRequired`] says. pub const PASSWORD_REQUIRED: &str = "this document is protected with a password"; /// What [`DocxError::WrongPassword`] says. @@ -108,6 +128,9 @@ pub enum DocxError { /// The package is readable but isn't a WordprocessingML document. #[error("not a Word document: {0}")] NotWord(String), + /// An `.xml` file that is neither a Word XML Document (Flat OPC) nor Word 2003 XML. + #[error("not a Word XML document")] + NotWordXml, /// The package is encrypted and no password was given. #[error("{}", PASSWORD_REQUIRED)] PasswordRequired, diff --git a/crates/docx/src/package.rs b/crates/docx/src/package.rs index 58d7e8d277..002dd70ad5 100644 --- a/crates/docx/src/package.rs +++ b/crates/docx/src/package.rs @@ -178,6 +178,12 @@ impl Package { Ok(Package { files }) } + /// A package from parts already in memory (a Flat OPC file, a converted document), by part + /// name without the leading `/`. + pub fn from_parts(files: BTreeMap>) -> Package { + Package { files } + } + /// Part bytes by name (leading `/` ignored; case-insensitive fallback). pub fn get(&self, name: &str) -> Option<&[u8]> { let n = name.trim_start_matches('/'); diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs index b0654e37d5..0ac67dc2fd 100644 --- a/crates/docx/src/read/mod.rs +++ b/crates/docx/src/read/mod.rs @@ -99,7 +99,12 @@ pub fn read(bytes: &[u8]) -> Result { /// [`read`] for bytes known not to be an encrypted package. pub(crate) fn read_package(bytes: &[u8]) -> Result { - let pkg = Package::open(bytes)?; + read_opened(&Package::open(bytes)?) +} + +/// Read a package whose parts are already unpacked (from a zip, a Flat OPC file or a converted +/// Word 2003 XML document). +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 { @@ -112,7 +117,7 @@ pub(crate) fn read_package(bytes: &[u8]) -> Result { let mut doc = Document::new(); doc.body.clear(); let mut r = Reader { - pkg: &pkg, + pkg, doc, pc: PropCtx::default(), revs: HashMap::new(), diff --git a/crates/docx/src/wordml2003.rs b/crates/docx/src/wordml2003.rs new file mode 100644 index 0000000000..31de867697 --- /dev/null +++ b/crates/docx/src/wordml2003.rs @@ -0,0 +1,576 @@ +//! Word 2003 XML Document (WordprocessingML 2003, root `w:wordDocument`, from the public Office +//! 2003 XML Reference Schemas). Import only. +//! +//! ECMA-376 kept most of the 2003 vocabulary (`w:p`, `w:r`, `w:pPr`, `w:rPr`, `w:tbl`, +//! `w:sectPr`, `w:styles`…), so the file is opened by rewriting it as the package it corresponds +//! to and reading that with the DOCX reader: one reader, one set of property rules. The rewrite +//! renames what changed (hyphenated names, `w:hlink`, `w:listPr`, `w:nfc`, `w:vmerge`…), moves +//! what 2003 keeps inline into parts (lists into numbering, headers and footers, footnotes, +//! endnotes, comments, `w:binData` pictures into media) and turns `aml:annotation` bookmarks, +//! comments and tracked insertions/deletions into their ECMA-376 elements. Bounded: the parser +//! caps depth and element count, pictures are capped in number and size. + +use std::collections::{BTreeMap, HashMap}; + +use base64::Engine as _; +use wordcraft_doc::Document; + +use crate::DocxError; +use crate::flat::W2003_NS; +use crate::package::{MAX_PART, MAX_TOTAL, Package, rt}; +use crate::write::PartRels; +use crate::xml::{self, El, MAX_DEPTH, NAMESPACES, Node, W}; + +/// Auxiliary hints (`wx:`): display hints Word writes alongside the real markup. +const AUX_NS: &str = "http://schemas.microsoft.com/office/word/2003/auxHint"; +/// Annotation markup (`aml:`): bookmarks, comments, tracked changes. +const AML_NS: &str = "http://schemas.microsoft.com/aml/2001/core"; + +/// Most pictures (`w:binData`) taken from one file. +const MAX_MEDIA: usize = 4096; +/// Most header, footer, footnote, endnote and comment stories. +const MAX_STORIES: usize = 50_000; + +/// Read a Word 2003 XML Document. +pub fn read(bytes: &[u8]) -> Result { + crate::read::read_opened(&convert(bytes)?) +} + +/// Parts the converted package gets relationships for. +const DOC: usize = 0; +const FOOTNOTES: usize = 1; +const ENDNOTES: usize = 2; +const COMMENTS: usize = 3; + +struct Conv { + /// `w:binData` name (`wordml://…`) → media target relative to `word/`. + media: HashMap, + files: BTreeMap>, + /// Relationships per part: document, footnotes, endnotes, comments, then one per header or + /// footer (index in `stories` + 4). + rels: Vec, + footnotes: Vec, + endnotes: Vec, + comments: Vec, + /// Header and footer parts: (part name under `word/`, root element). + stories: Vec<(String, El)>, + next_note: i64, + story_count: usize, +} + +/// Rewrite a Word 2003 XML Document as a WordprocessingML package. +pub(crate) fn convert(bytes: &[u8]) -> Result { + let root = xml::parse_ns(bytes, &[("w", W2003_NS), ("wx", AUX_NS), ("aml", AML_NS)])?; + if root.name != "w:wordDocument" { + return Err(DocxError::NotWord(format!("root element is {}", root.name))); + } + let mut c = Conv { + media: HashMap::new(), + files: BTreeMap::new(), + rels: (0..4).map(|_| PartRels::default()).collect(), + footnotes: Vec::new(), + endnotes: Vec::new(), + comments: Vec::new(), + stories: Vec::new(), + next_note: 1, + story_count: 0, + }; + let mut total = 0u64; + c.collect_media(&root, 0, &mut total)?; + + // Body: `wx:sect` / `wx:sub-section` wrappers unwrapped; the last section's properties last. + let mut body = El { name: "w:body".into(), ..El::default() }; + if let Some(b) = root.child("w:body") { + body.kids = c.kids(b, DOC, 1); + } + let last_sect = body.kids.iter().rposition(|n| matches!(n, Node::El(e) if e.name == "w:sectPr")); + if let Some(i) = last_sect { + let s = body.kids.remove(i); + body.kids.retain(|n| !matches!(n, Node::El(e) if e.name == "w:sectPr")); + body.kids.push(s); + } + let mut document = El { name: "w:document".into(), attrs: xml::body_ns(), kids: Vec::new() }; + document.kids.push(Node::El(body)); + + let mut overrides: Vec<(String, &str)> = Vec::new(); + let wml = |s: &str| format!("application/vnd.openxmlformats-officedocument.wordprocessingml.{s}+xml"); + let styles = c.styles(&root); + c.put("word/styles.xml", &styles); + c.rels[DOC].add(rt::STYLES, "styles.xml", false); + overrides.push(("/word/styles.xml".into(), "styles")); + if let Some(n) = root.child("w:lists").map(|l| c.numbering(l)) { + c.put("word/numbering.xml", &n); + c.rels[DOC].add(rt::NUMBERING, "numbering.xml", false); + overrides.push(("/word/numbering.xml".into(), "numbering")); + } + for (part, root_name, kids, rel, ct) in [ + (FOOTNOTES, "w:footnotes", std::mem::take(&mut c.footnotes), rt::FOOTNOTES, "footnotes"), + (ENDNOTES, "w:endnotes", std::mem::take(&mut c.endnotes), rt::ENDNOTES, "endnotes"), + (COMMENTS, "w:comments", std::mem::take(&mut c.comments), rt::COMMENTS, "comments"), + ] { + if kids.is_empty() { + continue; + } + let file = format!("{ct}.xml"); + c.put(&format!("word/{file}"), &El { name: root_name.into(), attrs: xml::body_ns(), kids }); + c.put_rels(&file, part); + c.rels[DOC].add(rel, &file, false); + overrides.push((format!("/word/{file}"), ct)); + } + for (i, (file, story)) in std::mem::take(&mut c.stories).into_iter().enumerate() { + c.put(&format!("word/{file}"), &story); + c.put_rels(&file, i + 4); + overrides.push((format!("/word/{file}"), if story.name == "w:ftr" { "footer" } else { "header" })); + } + c.put("word/document.xml", &document); + c.put_rels("document.xml", DOC); + + let mut root_rels = PartRels::default(); + root_rels.add(rt::OFFICE_DOC, "word/document.xml", false); + if let Some(props) = root.child("o:DocumentProperties") { + c.files.insert("docProps/core.xml".into(), core_xml(props)); + root_rels.add(rt::CORE, "docProps/core.xml", false); + } + c.files.insert("_rels/.rels".into(), root_rels.xml()); + + let mut w = W::new(); + w.open("Types", &[("xmlns", "http://schemas.openxmlformats.org/package/2006/content-types")]); + w.empty("Default", &[("Extension", "rels"), ("ContentType", "application/vnd.openxmlformats-package.relationships+xml")]); + w.empty("Default", &[("Extension", "xml"), ("ContentType", "application/xml")]); + let mut exts: Vec<&str> = c.media.values().filter_map(|t| t.rsplit_once('.').map(|(_, e)| e)).collect(); + exts.sort_unstable(); + exts.dedup(); + for ext in exts { + w.empty("Default", &[("Extension", ext), ("ContentType", media_type(ext))]); + } + w.empty("Override", &[("PartName", "/word/document.xml"), ("ContentType", &wml("document.main"))]); + for (part, kind) in &overrides { + w.empty("Override", &[("PartName", part), ("ContentType", &wml(kind))]); + } + if c.files.contains_key("docProps/core.xml") { + w.empty("Override", &[("PartName", "/docProps/core.xml"), ("ContentType", "application/vnd.openxmlformats-package.core-properties+xml")]); + } + w.close("Types"); + c.files.insert("[Content_Types].xml".into(), w.into_bytes()); + Ok(Package::from_parts(c.files)) +} + +impl Conv { + /// Store an element as a part. + fn put(&mut self, path: &str, root: &El) { + let mut s = String::from("\n"); + s.push_str(&root.to_xml()); + self.files.insert(path.into(), s.into_bytes()); + } + + /// Store the relationships of `word/{file}` (if it has any). + fn put_rels(&mut self, file: &str, part: usize) { + if let Some(r) = self.rels.get(part).filter(|r| !r.is_empty()) { + let x = r.xml(); + self.files.insert(format!("word/_rels/{file}.rels"), x); + } + } + + /// Every `w:binData` in the file, decoded into a media part. + fn collect_media(&mut self, e: &El, depth: usize, total: &mut u64) -> Result<(), DocxError> { + if depth > MAX_DEPTH { + return Ok(()); + } + for k in e.els() { + if k.name == "w:binData" { + let Some(name) = k.attr("w:name").filter(|n| !self.media.contains_key(*n)) else { continue }; + if self.media.len() >= MAX_MEDIA { + continue; + } + let text = k.text(); + let b64: String = text.chars().filter(|c| !c.is_ascii_whitespace()).collect(); + if (b64.len() / 4 * 3) as u64 > MAX_PART { + return Err(DocxError::Limit(format!("picture {name} is larger than the size limit"))); + } + let Ok(bytes) = base64::engine::general_purpose::STANDARD_NO_PAD.decode(b64.trim_end_matches('=')) else { + log::warn!("wordml2003: skipping picture {name}: invalid base64"); + continue; + }; + *total = total.saturating_add(bytes.len() as u64); + if *total > MAX_TOTAL { + return Err(DocxError::Limit("pictures larger than the size limit".into())); + } + let ext = picture_ext(&bytes, name); + let target = format!("media/image{}.{ext}", self.media.len() + 1); + self.files.insert(format!("word/{target}"), bytes); + self.media.insert(name.to_string(), target); + } else { + self.collect_media(k, depth + 1, total)?; + } + } + Ok(()) + } + + /// The converted children of `e`. + fn kids(&mut self, e: &El, part: usize, depth: usize) -> Vec { + let mut out = Vec::with_capacity(e.kids.len()); + for n in &e.kids { + match n { + Node::Text(t) => out.push(Node::Text(t.clone())), + Node::El(k) => out.extend(self.node(k, part, depth + 1)), + } + } + out + } + + /// `e` converted: nothing (dropped), its children (unwrapped) or one element. + fn node(&mut self, e: &El, part: usize, depth: usize) -> Vec { + if depth > MAX_DEPTH { + return Vec::new(); + } + match e.name.as_str() { + "wx:sect" | "wx:sub-section" | "wx:pBdrGroup" => return self.kids(e, part, depth), + "w:binData" | "w:fonts" | "w:docPr" | "w:lists" | "w:styles" | "w:latentStyles" | "w:versionOfBuiltInStyleNames" => return Vec::new(), + "w:hlink" => return vec![Node::El(self.hyperlink(e, part, depth))], + "w:listPr" => return vec![Node::El(list_pr(e))], + "w:footnote" | "w:endnote" => return self.note(e, depth).into_iter().map(Node::El).collect(), + "aml:annotation" => return self.annotation(e, part, depth), + "w:sectPr" => return vec![Node::El(self.section(e, part, depth))], + "w:nfc" => return vec![Node::El(num_fmt(e))], + _ => {} + } + if !keep(&e.name) { + return Vec::new(); + } + let mut el = El { name: rename(&e.name), attrs: attrs(e), kids: self.kids(e, part, depth) }; + if el.name == "v:imagedata" + && let Some(target) = e.attr("src").and_then(|s| self.media.get(s)).cloned() + && let Some(r) = self.rels.get_mut(part) + { + let id = r.add(rt::IMAGE, &target, false); + el.attrs.retain(|(k, _)| k != "r:id"); + el.attrs.push(("r:id".into(), id)); + } + if el.name == "w:style" { + for (k, v) in &mut el.attrs { + if k == "w:default" && v == "on" { + *v = "1".into(); + } + } + } + vec![Node::El(el)] + } + + /// `w:hlink` → `w:hyperlink` (an external relationship for `w:dest`). + fn hyperlink(&mut self, e: &El, part: usize, depth: usize) -> El { + let mut attrs = Vec::new(); + if let Some(dest) = e.attr("w:dest").filter(|d| !d.is_empty()) + && let Some(r) = self.rels.get_mut(part) + { + attrs.push(("r:id".into(), r.add(rt::HYPERLINK, dest, true))); + } + for (from, to) in [("w:bookmark", "w:anchor"), ("w:screenTip", "w:tooltip"), ("w:target", "w:tgtFrame")] { + if let Some(v) = e.attr(from) { + attrs.push((to.into(), v.to_string())); + } + } + El { name: "w:hyperlink".into(), attrs, kids: self.kids(e, part, depth) } + } + + /// A footnote or endnote written inline in its run → a reference, the note in its part. + fn note(&mut self, e: &El, depth: usize) -> Option { + // Separator definitions (`w:type`) live in document settings, not in the text. + if e.attr("w:type").is_some() || self.story_count >= MAX_STORIES { + return None; + } + self.story_count += 1; + let foot = e.name == "w:footnote"; + let id = self.next_note.to_string(); + self.next_note = self.next_note.saturating_add(1); + let part = if foot { FOOTNOTES } else { ENDNOTES }; + let kids = self.kids(e, part, depth); + let note = El { name: e.name.clone(), attrs: vec![("w:id".into(), id.clone())], kids }; + if foot { + self.footnotes.push(Node::El(note)); + } else { + self.endnotes.push(Node::El(note)); + } + let name = if foot { "w:footnoteReference" } else { "w:endnoteReference" }; + Some(El { name: name.into(), attrs: vec![("w:id".into(), id)], kids: Vec::new() }) + } + + /// `aml:annotation`: bookmarks, comments and tracked insertions/deletions. + fn annotation(&mut self, e: &El, part: usize, depth: usize) -> Vec { + let id = e.attr("aml:id").filter(|v| v.parse::().is_ok()).unwrap_or("0").to_string(); + let el = |name: &str, attrs: Vec<(String, String)>| vec![Node::El(El { name: name.into(), attrs, kids: Vec::new() })]; + let author_date = |e: &El| { + let mut a = vec![("w:id".to_string(), id.clone())]; + if let Some(v) = e.attr("aml:author") { + a.push(("w:author".into(), v.to_string())); + } + if let Some(v) = e.attr("aml:createdate") { + a.push(("w:date".into(), v.to_string())); + } + a + }; + let content = |c: &mut Conv, part: usize| e.child("aml:content").map(|k| c.kids(k, part, depth + 1)).unwrap_or_default(); + match e.attr("w:type").unwrap_or("") { + "Word.Bookmark.Start" => { + el("w:bookmarkStart", vec![("w:id".into(), id.clone()), ("w:name".into(), e.attr("w:name").unwrap_or("").to_string())]) + } + "Word.Bookmark.End" => el("w:bookmarkEnd", vec![("w:id".into(), id)]), + "Word.Comment.Start" => el("w:commentRangeStart", vec![("w:id".into(), id)]), + "Word.Comment.End" => el("w:commentRangeEnd", vec![("w:id".into(), id)]), + "Word.Comment" => { + if self.story_count >= MAX_STORIES { + return Vec::new(); + } + self.story_count += 1; + let mut attrs = author_date(e); + if let Some(v) = e.attr("w:initials") { + attrs.push(("w:initials".into(), v.to_string())); + } + let kids = content(self, COMMENTS); + self.comments.push(Node::El(El { name: "w:comment".into(), attrs, kids })); + el("w:commentReference", vec![("w:id".into(), id)]) + } + t @ ("Word.Insertion" | "Word.Deletion") => { + let name = if t == "Word.Insertion" { "w:ins" } else { "w:del" }; + vec![Node::El(El { name: name.into(), attrs: author_date(e), kids: content(self, part) })] + } + _ => Vec::new(), + } + } + + /// `w:sectPr`: inline `w:hdr` / `w:ftr` become header and footer parts. + fn section(&mut self, e: &El, part: usize, depth: usize) -> El { + let mut refs = Vec::new(); + let mut rest = Vec::new(); + for n in &e.kids { + let Node::El(k) = n else { continue }; + if (k.name == "w:hdr" || k.name == "w:ftr") && self.story_count < MAX_STORIES { + self.story_count += 1; + let footer = k.name == "w:ftr"; + let kind = match k.attr("w:type") { + Some("even") => "even", + Some("first") => "first", + _ => "default", + }; + let idx = self.stories.len() + 4; + self.rels.push(PartRels::default()); + let kids = self.kids(k, idx, depth + 1); + let file = format!("{}{}.xml", if footer { "footer" } else { "header" }, self.stories.len() + 1); + self.stories.push((file.clone(), El { name: k.name.clone(), attrs: xml::body_ns(), kids })); + let id = self.rels.get_mut(part).map(|r| r.add(if footer { rt::FOOTER } else { rt::HEADER }, &file, false)).unwrap_or_default(); + let name = if footer { "w:footerReference" } else { "w:headerReference" }; + refs.push(Node::El(El { name: name.into(), attrs: vec![("w:type".into(), kind.into()), ("r:id".into(), id)], kids: Vec::new() })); + } else if k.name != "w:hdr" && k.name != "w:ftr" { + rest.extend(self.node(k, part, depth + 1)); + } + } + refs.extend(rest); + El { name: "w:sectPr".into(), attrs: attrs(e), kids: refs } + } + + /// `w:styles` → `styles.xml`, with the document's default fonts (`w:fonts/w:defaultFonts`). + fn styles(&mut self, root: &El) -> El { + let mut kids = Vec::new(); + if let Some(f) = root.child("w:fonts").and_then(|f| f.child("w:defaultFonts")) { + let fonts = El { name: "w:rFonts".into(), attrs: attrs(f), kids: Vec::new() }; + let rpr = El { name: "w:rPr".into(), attrs: Vec::new(), kids: vec![Node::El(fonts)] }; + let rpr_default = El { name: "w:rPrDefault".into(), attrs: Vec::new(), kids: vec![Node::El(rpr)] }; + kids.push(Node::El(El { name: "w:docDefaults".into(), attrs: Vec::new(), kids: vec![Node::El(rpr_default)] })); + } + if let Some(s) = root.child("w:styles") { + for st in s.children("w:style") { + kids.extend(self.node(st, DOC, 2)); + } + } + El { name: "w:styles".into(), attrs: xml::body_ns(), kids } + } + + /// `w:lists` → `numbering.xml`: `w:listDef` → `w:abstractNum`, `w:list` → `w:num`. + fn numbering(&mut self, lists: &El) -> El { + let mut kids = Vec::new(); + for def in lists.children("w:listDef") { + let Some(id) = def.attr("w:listDefId") else { continue }; + let mut a = El { name: "w:abstractNum".into(), attrs: vec![("w:abstractNumId".into(), id.to_string())], kids: Vec::new() }; + for k in def.els() { + let converted = match k.name.as_str() { + "w:plt" => { + let v = match k.attr("w:val").unwrap_or("") { + "SingleLevel" => "singleLevel", + "HybridMultilevel" => "hybridMultilevel", + _ => "multilevel", + }; + vec![Node::El(val_el("w:multiLevelType", v))] + } + "w:lsid" => vec![Node::El(val_el("w:nsid", k.attr("w:val").unwrap_or("")))], + "w:listStyleLink" => vec![Node::El(val_el("w:numStyleLink", k.attr("w:val").unwrap_or("")))], + _ => self.node(k, DOC, 2), + }; + a.kids.extend(converted); + } + kids.push(Node::El(a)); + } + for list in lists.children("w:list") { + let Some(id) = list.attr("w:ilfo") else { continue }; + let Some(abs) = list.child_val("w:ilst") else { continue }; + let mut n = + El { name: "w:num".into(), attrs: vec![("w:numId".into(), id.to_string())], kids: vec![Node::El(val_el("w:abstractNumId", abs))] }; + for o in list.children("w:lvlOverride") { + n.kids.extend(self.node(o, DOC, 2)); + } + kids.push(Node::El(n)); + } + El { name: "w:numbering".into(), attrs: xml::body_ns(), kids } + } +} + +/// ``. +fn val_el(name: &str, v: &str) -> El { + El { name: name.into(), attrs: vec![("w:val".into(), v.into())], kids: Vec::new() } +} + +/// `w:listPr` (`w:ilvl`, `w:ilfo`) → `w:numPr` (`w:ilvl`, `w:numId`). +fn list_pr(e: &El) -> El { + let mut kids = Vec::new(); + if let Some(v) = e.child_val("w:ilvl") { + kids.push(Node::El(val_el("w:ilvl", v))); + } + if let Some(v) = e.child_val("w:ilfo") { + kids.push(Node::El(val_el("w:numId", v))); + } + El { name: "w:numPr".into(), attrs: Vec::new(), kids } +} + +/// `w:nfc` (a number format code) → `w:numFmt`. +fn num_fmt(e: &El) -> El { + let f = match e.attr("w:val").and_then(|v| v.trim().parse::().ok()).unwrap_or(0) { + 1 => "upperRoman", + 2 => "lowerRoman", + 3 => "upperLetter", + 4 => "lowerLetter", + 5 => "ordinal", + 6 => "cardinalText", + 7 => "ordinalText", + 9 => "chicago", + 14 | 19 => "decimalFullWidth", + 15 => "decimalHalfWidth", + 18 => "decimalEnclosedCircle", + 22 => "decimalZero", + 23 => "bullet", + 24 => "ganada", + 25 => "chosung", + 26 => "decimalEnclosedFullstop", + 27 => "decimalEnclosedParen", + 255 => "none", + _ => "decimal", + }; + val_el("w:numFmt", f) +} + +/// Is an element or attribute with this name kept (a namespace the package knows, or none)? +fn keep(name: &str) -> bool { + match name.split_once(':') { + None => true, + Some(("xml", _)) => true, + Some((p, _)) => NAMESPACES.iter().any(|(q, _)| *q == p), + } +} + +/// A WordprocessingML 2003 name in its ECMA-376 spelling: `w:sz-cs` → `w:szCs`, `w:first-line` +/// → `w:firstLine`, `w:fareast` → `w:eastAsia`, `w:vmerge` → `w:vMerge`. +fn rename(name: &str) -> String { + let Some(local) = name.strip_prefix("w:") else { return name.to_string() }; + match local { + "fareast" => return "w:eastAsia".into(), + "vmerge" => return "w:vMerge".into(), + "hmerge" => return "w:hMerge".into(), + _ => {} + } + let mut out = String::from("w:"); + let mut up = false; + for c in local.chars() { + if c == '-' { + up = true; + } else if up { + out.extend(c.to_uppercase()); + up = false; + } else { + out.push(c); + } + } + out +} + +/// The attributes of `e` that the package keeps, renamed. +fn attrs(e: &El) -> Vec<(String, String)> { + e.attrs.iter().filter(|(k, _)| keep(k)).map(|(k, v)| (rename(k), v.clone())).collect() +} + +/// A picture's file extension: from its bytes, else from its `wordml://` name. +fn picture_ext(b: &[u8], name: &str) -> String { + let sniffed = if b.starts_with(b"\x89PNG") { + "png" + } else if b.starts_with(&[0xFF, 0xD8]) { + "jpeg" + } else if b.starts_with(b"GIF8") { + "gif" + } else if b.starts_with(b"BM") { + "bmp" + } else if b.get(40..44) == Some(b" EMF") { + "emf" + } else if b.starts_with(&[0xD7, 0xCD, 0xC6, 0x9A]) || b.starts_with(&[0x01, 0x00, 0x09, 0x00]) { + "wmf" + } else { + "" + }; + if !sniffed.is_empty() { + return sniffed.into(); + } + let ext = name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()).unwrap_or_default(); + if !ext.is_empty() && ext.len() <= 5 && ext.chars().all(|c| c.is_ascii_alphanumeric()) && ext != "xml" && ext != "rels" { + ext + } else { + "bin".into() + } +} + +fn media_type(ext: &str) -> &'static str { + match ext { + "png" => "image/png", + "jpeg" | "jpg" => "image/jpeg", + "gif" => "image/gif", + "bmp" => "image/bmp", + "emf" => "image/x-emf", + "wmf" => "image/x-wmf", + _ => "application/octet-stream", + } +} + +/// `o:DocumentProperties` → `docProps/core.xml`. +fn core_xml(props: &El) -> Vec { + let mut w = W::new(); + w.open( + "cp:coreProperties", + &[ + ("xmlns:cp", "http://schemas.openxmlformats.org/package/2006/metadata/core-properties"), + ("xmlns:dc", "http://purl.org/dc/elements/1.1/"), + ("xmlns:dcterms", "http://purl.org/dc/terms/"), + ], + ); + for (from, to) in [ + ("o:Title", "dc:title"), + ("o:Subject", "dc:subject"), + ("o:Author", "dc:creator"), + ("o:Keywords", "cp:keywords"), + ("o:Description", "dc:description"), + ("o:LastAuthor", "cp:lastModifiedBy"), + ("o:Revision", "cp:revision"), + ("o:Category", "cp:category"), + ("o:Created", "dcterms:created"), + ("o:LastSaved", "dcterms:modified"), + ] { + if let Some(t) = props.child(from).map(El::text).filter(|t| !t.trim().is_empty()) { + w.leaf(to, &[], t.trim()); + } + } + w.close("cp:coreProperties"); + w.into_bytes() +} diff --git a/crates/docx/src/write/mod.rs b/crates/docx/src/write/mod.rs index 9ab3066107..d2fbe91769 100644 --- a/crates/docx/src/write/mod.rs +++ b/crates/docx/src/write/mod.rs @@ -44,10 +44,10 @@ impl PartRels { } self.list.push((id.to_string(), kind.to_string(), target.to_string(), external)); } - fn is_empty(&self) -> bool { + pub(crate) fn is_empty(&self) -> bool { self.list.is_empty() } - fn xml(&self) -> Vec { + pub(crate) fn xml(&self) -> Vec { let mut w = W::new(); w.open("Relationships", &[("xmlns", "http://schemas.openxmlformats.org/package/2006/relationships")]); for (id, kind, target, external) in &self.list { @@ -117,6 +117,12 @@ pub fn write(doc: &Document) -> Result, DocxError> { /// Write a package of the given flavour (`.docx`, `.docm`, `.dotx`, `.dotm`). pub fn write_as(doc: &Document, flavor: Flavor) -> Result, DocxError> { + zip_entries(&write_parts(doc, flavor)?) +} + +/// The parts of the package [`write_as`] zips, in order, `[Content_Types].xml` first: (part name +/// without the leading `/`, bytes). +pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result)>, DocxError> { // A range field must be whole in the file: drop markers that lost their partner in editing. let balanced; let doc = if doc.has_unbalanced_field_ranges() { @@ -451,8 +457,7 @@ pub fn write_as(doc: &Document, flavor: Flavor) -> Result, DocxError> { } w.close("Types"); entries.insert(0, ("[Content_Types].xml".into(), w.into_bytes())); - - zip_entries(&entries) + Ok(entries) } fn push_part(entries: &mut Vec<(String, Vec)>, overrides: &mut Vec<(String, String)>, path: &str, bytes: Vec, ct: &str, rels: PartRels) { diff --git a/crates/docx/src/xml.rs b/crates/docx/src/xml.rs index c0c43c0f7d..d38ea80285 100644 --- a/crates/docx/src/xml.rs +++ b/crates/docx/src/xml.rs @@ -68,7 +68,10 @@ const STRICT: &[(&str, &str)] = &[ ("m", "http://purl.oclc.org/ooxml/officeDocument/math"), ]; -fn prefix_for(uri: &[u8]) -> &'static str { +fn prefix_for(uri: &[u8], extra: &[(&'static str, &str)]) -> &'static str { + if let Some((p, _)) = extra.iter().find(|(_, u)| u.as_bytes() == uri) { + return p; + } for (p, u) in NAMESPACES.iter().chain(OTHER_NAMESPACES).chain(STRICT) { if u.as_bytes() == uri { return p; @@ -210,10 +213,9 @@ impl El { } } -/// Parse XML bytes into the root element. /// UTF-16 LE/BE text with a byte-order mark as UTF-8 (`None` without one). Lossy: an unpaired /// surrogate becomes U+FFFD and a trailing odd byte is dropped. The result is at most 1.5× the input. -fn utf16_to_utf8(bytes: &[u8]) -> Option { +pub(crate) fn utf16_to_utf8(bytes: &[u8]) -> Option { let (rest, le) = match bytes.get(..2) { Some([0xFF, 0xFE]) => (bytes.get(2..)?, true), Some([0xFE, 0xFF]) => (bytes.get(2..)?, false), @@ -223,7 +225,14 @@ fn utf16_to_utf8(bytes: &[u8]) -> Option { Some(char::decode_utf16(units).map(|c| c.unwrap_or(char::REPLACEMENT_CHARACTER)).collect()) } +/// Parse XML bytes into the root element. pub fn parse(bytes: &[u8]) -> Result { + parse_ns(bytes, &[]) +} + +/// [`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 { // 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 { @@ -246,7 +255,7 @@ pub fn parse(bytes: &[u8]) -> Result { buf.clear(); let (res, ev) = r.read_resolved_event_into(&mut buf).map_err(|e| DocxError::Xml(e.to_string()))?; let el_prefix = match &res { - ResolveResult::Bound(ns) => Some(prefix_for(ns.as_ref())), + ResolveResult::Bound(ns) => Some(prefix_for(ns.as_ref(), extra)), _ => None, }; match ev { @@ -271,7 +280,7 @@ pub fn parse(bytes: &[u8]) -> Result { let (ares, alocal) = r.resolver_mut().resolve_attribute(key); let alocal = String::from_utf8_lossy(alocal.as_ref()).into_owned(); let aname = match ares { - ResolveResult::Bound(ns) => format!("{}:{alocal}", prefix_for(ns.as_ref())), + ResolveResult::Bound(ns) => format!("{}:{alocal}", prefix_for(ns.as_ref(), extra)), _ => alocal, }; let val = match a.normalized_value(XmlVersion::Explicit1_0) { diff --git a/crates/docx/tests/roundtrip.rs b/crates/docx/tests/roundtrip.rs index 7807dd55e1..49f7b540a0 100644 --- a/crates/docx/tests/roundtrip.rs +++ b/crates/docx/tests/roundtrip.rs @@ -1278,6 +1278,21 @@ fn second_generation_is_stable() { assert_eq!(d2, d1); } +/// A Word XML Document (Flat OPC) carries the same package as a .docx: reading it back gives +/// what the .docx round trip gives, pictures included (#548). +#[test] +fn flat_opc_round_trip_equals_docx() { + let d = kitchen_sink(); + let xml = wordcraft_docx::write_flat(&d).expect("write flat"); + let text = std::str::from_utf8(&xml).expect("utf-8"); + assert!(text.starts_with("")); + assert!(text.contains("pkg:name=\"/word/document.xml\"") && text.contains("")); + assert_eq!(wordcraft_docx::sniff_xml(&xml), wordcraft_docx::XmlKind::FlatOpc); + let back = wordcraft_docx::read_xml(&xml).expect("read flat"); + assert_eq!(back, rt(&d)); + assert_eq!(back.media.len(), 1); +} + #[test] fn builtin_stylesheet_survives() { let d = Document::new(); diff --git a/crates/docx/tests/word_xml.rs b/crates/docx/tests/word_xml.rs new file mode 100644 index 0000000000..1a4287b801 --- /dev/null +++ b/crates/docx/tests/word_xml.rs @@ -0,0 +1,215 @@ +//! Word XML files (#548): Flat OPC (Word XML Document) and Word 2003 XML, from hand-written +//! samples (never files produced by Word). + +use base64::Engine as _; +use wordcraft_doc::props::Align; +use wordcraft_doc::section::NumFormat; +use wordcraft_doc::{Block, Document, InlineObject, Paragraph}; +use wordcraft_docx::{DocxError, XmlKind, read_xml, sniff_xml}; + +fn paras(doc: &Document) -> Vec<&Paragraph> { + doc.body.iter().filter_map(|b| b.as_para()).collect() +} + +fn part_text(doc: &Document, id: Option) -> String { + id.and_then(|i| doc.parts.get(&i)) + .map(|p| p.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect::>().join("\n")) + .unwrap_or_default() +} + +fn tiny_png_b64() -> String { + let img = image::RgbaImage::from_fn(2, 2, |x, y| image::Rgba([x as u8 * 100, y as u8 * 100, 50, 255])); + let mut out = std::io::Cursor::new(Vec::new()); + img.write_to(&mut out, image::ImageFormat::Png).unwrap(); + let b64 = base64::engine::general_purpose::STANDARD.encode(out.into_inner()); + // Line breaks inside base64, as the formats write it. + b64.as_bytes().chunks(20).map(|c| std::str::from_utf8(c).unwrap()).collect::>().join("\n") +} + +/// A minimal Flat OPC package written by hand: namespaces declared on the package element only, +/// a hyperlink relationship, a picture in base64 and an empty part. +#[test] +fn reads_a_hand_written_flat_opc_package() { + let png = tiny_png_b64(); + let xml = format!( + r#" + + + + + + + + + + + + + + Seed & soil + link + + + + + + {png} + + +"# + ); + assert_eq!(sniff_xml(xml.as_bytes()), XmlKind::FlatOpc); + let doc = read_xml(xml.as_bytes()).unwrap(); + let ps = paras(&doc); + assert_eq!(ps[0].plain_text(), "Seed & soil"); + assert_eq!(ps[0].props_at(0).bold, Some(true)); + assert_eq!(ps[1].props_at(0).link.as_deref(), Some("https://example.org/flat")); + assert!(matches!(ps[2].objects.first(), Some(InlineObject::Image { w, h, .. }) if *w == 24.0 && *h == 12.0)); + assert_eq!(doc.media.len(), 1); +} + +/// A Word 2003 XML Document, hand-written from the Office 2003 XML Reference Schemas. +fn wordml_2003_sample() -> String { + let png = tiny_png_b64(); + format!( + r##" + + + Garden notesAda Gardenerbeans + + + + + + + + + + + + + + + + PlanToo short? + + Plain + loud + thenmore + Sown in May. + + bean guide + Water the beans daily + + + + A1B1 + B2 + + {png} + + Garden header + + + + +"## + ) +} + +#[test] +fn imports_word_2003_xml() { + let xml = wordml_2003_sample(); + assert_eq!(sniff_xml(xml.as_bytes()), XmlKind::WordMl2003); + let doc = read_xml(xml.as_bytes()).unwrap(); + assert_eq!(doc.core.title, "Garden notes"); + assert_eq!(doc.core.creator, "Ada Gardener"); + + let h1 = doc.styles.get("Heading1").expect("Heading1 style"); + assert_eq!(h1.chr.bold, Some(true)); + assert_eq!(h1.chr.size, Some(16.0)); + + let ps = paras(&doc); + assert_eq!(ps[0].plain_text(), "Plan"); + assert_eq!(ps[0].props.style.as_deref(), Some("Heading1")); + assert!(ps[0].objects.iter().any(|o| matches!(o, InlineObject::BookmarkStart { name, .. } if name == "plan"))); + + let p = ps[1]; + assert_eq!(p.props.align, Some(Align::Center)); + assert_eq!(p.props.indent_first, Some(18.0)); + let loud = p.text.find("loud").unwrap(); + let c = p.props_of_char(loud); + assert_eq!((c.bold, c.italic, c.size, c.font.as_deref()), (Some(true), Some(true), Some(14.0), Some("Courier New"))); + assert!(c.underline.is_some() && c.color.is_some()); + assert!(p.plain_text().contains("Plain loud\tthen")); + let note = p.objects.iter().find_map(|o| match o { + InlineObject::NoteRef { id, .. } => Some(*id), + _ => None, + }); + assert_eq!(part_text(&doc, note), "Sown in May."); + + assert_eq!(ps[2].props_at(0).link.as_deref(), Some("https://example.org/beans")); + let listed = ps[3]; + let num = listed.props.numbering.expect("list paragraph"); + let lvl = doc.numbering.level(num.num, num.level).expect("list level"); + assert_eq!((lvl.format, lvl.text.as_str(), lvl.indent), (NumFormat::LowerLetter, "%1)", 36.0)); + let ins = listed.props_of_char(listed.text.find("daily").unwrap()).ins.and_then(|i| doc.revisions.get(i as usize)); + assert_eq!(ins.map(|r| r.author.as_str()), Some("Ada")); + let comment = doc.comments.values().next().expect("comment"); + assert_eq!((comment.author.as_str(), part_text(&doc, Some(comment.part)).as_str()), ("Bo", "Too short?")); + + let table = doc.body.iter().find_map(|b| match b.as_ref() { + Block::Table(t) => Some(t), + _ => None, + }); + let table = table.expect("table"); + assert_eq!(table.rows.len(), 2); + assert_eq!(table.rows[0].cells[1].blocks.first().and_then(|b| b.as_para()).map(|p| p.plain_text()).as_deref(), Some("B1")); + + let pic = ps.iter().flat_map(|p| p.objects.iter()).find(|o| matches!(o, InlineObject::Image { .. })); + assert!(matches!(pic, Some(InlineObject::Image { w, h, .. }) if *w == 36.0 && *h == 18.0)); + assert_eq!(doc.media.len(), 1); + + assert_eq!(doc.last_section.page_w, 612.0); + assert_eq!(doc.last_section.margin_left, 90.0); + assert_eq!(part_text(&doc, doc.last_section.headers.default), "Garden header"); +} + +#[test] +fn hostile_word_xml_is_an_error_not_a_panic() { + let flat = r#""#; + let w2003 = r#""#; + // Other XML (and non-XML) is refused with the Word XML error. + for other in ["hi", "", "not xml at all", ""] { + assert_eq!(read_xml(other.as_bytes()), Err(DocxError::NotWordXml), "{other:?}"); + } + // Broken packages: errors. + for bad in [ + flat.to_string(), + format!("{flat}"), + format!("{flat}!!!not base64"), + format!("{flat}"), + ] { + assert!(read_xml(bad.as_bytes()).is_err(), "{bad}"); + } + // Lenient leftovers never panic. + for odd in [ + format!("{flat}"), + format!( + "{w2003}%%%" + ), + format!( + "{w2003}" + ), + format!("{w2003}{}", "".repeat(5000)), + ] { + let _ = read_xml(odd.as_bytes()); + } + // Too many parts. + let mut many = String::from(flat); + for i in 0..20_001 { + many.push_str(&format!("AA==")); + } + many.push_str(""); + assert!(matches!(read_xml(many.as_bytes()), Err(DocxError::Limit(_)))); +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 831774803c..a958ab8645 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -176,7 +176,7 @@ fn save(s: &mut Session, v: &Value) -> CmdResult { crate::io::save_path_with(&path, &s.doc, password).map_err(CmdError::Failed)?; let encrypted = password.is_some() && crate::io::can_encrypt(&path.to_string_lossy()); let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase(); - if ["docx", "docm", "dotx", "dotm", "odt", "rtf", "json"].contains(&ext.as_str()) { + if ["docx", "docm", "dotx", "dotm", "odt", "rtf", "json", "xml"].contains(&ext.as_str()) { s.path = Some(path.clone()); s.dirty = false; } diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 6de19524cd..59a43fa1ca 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -20,11 +20,12 @@ pub const OPEN_EXTS: &[&str] = &[ "tex", "latex", "ltx", + "xml", "wcraft.json", "json", ]; /// Formats WordCraft saves (Save As). -pub const SAVE_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "pdf", "txt", "md", "html", "rtf", "odt", "tex", "png", "json"]; +pub const SAVE_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "pdf", "txt", "md", "html", "rtf", "odt", "tex", "png", "json", "xml"]; fn ext_of(name: &str) -> String { let lower = name.to_ascii_lowercase(); diff --git a/crates/engine/src/io_ext.rs b/crates/engine/src/io_ext.rs index 6c41656673..9fbbb2f790 100644 --- a/crates/engine/src/io_ext.rs +++ b/crates/engine/src/io_ext.rs @@ -7,6 +7,8 @@ pub fn open(ext: &str, bytes: &[u8]) -> Option> { match ext { "docx" | "docm" | "dotx" | "dotm" => Some(wordcraft_docx::read(bytes).map_err(|e| e.to_string())), "doc" | "dot" => Some(wordcraft_docbin::read(bytes).map_err(|e| e.to_string())), + // Word XML Document (Flat OPC) or Word 2003 XML, told apart by the root element. + "xml" => Some(wordcraft_docx::read_xml(bytes).map_err(|e| e.to_string())), other => wordcraft_formats::import(other, bytes), } } @@ -18,6 +20,7 @@ pub fn save(ext: &str, doc: &Document) -> Option, String>> { return Some(wordcraft_docx::write_as(doc, flavor).map_err(|e| e.to_string())); } match ext { + "xml" => Some(wordcraft_docx::write_flat(doc).map_err(|e| e.to_string())), "png" => Some(render_png(doc, 0, 2.0)), "pdf" => Some(wordcraft_pdf::export(doc, &Default::default()).map_err(|e| e.to_string())), other => wordcraft_formats::export(other, doc), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d00b2320e0..d1b4d685ab 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2954,6 +2954,19 @@ fn style_visibility_round_trips_through_docx() { assert!(s.doc.styles.get("Heading4").is_some_and(|h| !h.quick), "undoable"); } +/// #548: `.xml` saves a Word XML Document (Flat OPC) and opens it again; other XML is refused. +#[test] +fn word_xml_saves_and_opens_through_io() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Kept in one XML file"})); + let bytes = crate::io::save_bytes("notes.xml", &s.doc).unwrap(); + assert_eq!(wordcraft_docx::sniff_xml(&bytes), wordcraft_docx::XmlKind::FlatOpc); + let doc = crate::io::open_bytes("notes.XML", &bytes).unwrap(); + assert_eq!(doc.body, crate::io::open_bytes("n.docx", &crate::io::save_bytes("n.docx", &s.doc).unwrap()).unwrap().body); + let err = crate::io::open_bytes("feed.xml", b"").unwrap_err(); + assert!(err.contains("not a Word XML document"), "{err}"); +} + /// #146: the first/last column, total row and banded column regions apply in layout (later /// regions win: the total row's fill beats the first column's) and round-trip through .docx. #[test] diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index dc30262d1f..4397f80863 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -350,6 +350,7 @@ fn export_page(app: &mut WordApp, ui: &mut Ui) { for (label, ext) in [ ("PDF document (*.pdf)", "pdf"), ("Word document (*.docx)", "docx"), + ("Word XML Document (*.xml)", "xml"), ("OpenDocument Text (*.odt)", "odt"), ("Rich Text Format (*.rtf)", "rtf"), ("Web page (*.html)", "html"), diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 2a7cc68c1a..ee89c6bcd8 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -956,6 +956,7 @@ Word Left Palabra a la izquierda Word Right Palabra a la derecha Word document (*.docx) Documento de Word (*.docx) + Word XML Document (*.xml) Documento XML de Word (*.xml) WordArt WordArt WordCraft WordCraft Words Palabras diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index c643e4e6d2..338533bdd8 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -840,6 +840,7 @@ Word Left Sõna vasakule Word Right Sõna paremale Word document (*.docx) Wordi dokument (*.docx) + Word XML Document (*.xml) Wordi XML-dokument (*.xml) WordArt WordArt WordCraft WordCraft Words Sõnad diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 20d155b04c..b4f3a7c015 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -930,6 +930,7 @@ Window ウィンドウ Word Count 文字カウント Word document (*.docx) Word 文書 (*.docx) + Word XML Document (*.xml) Word XML ドキュメント (*.xml) Word Left 前の単語へ Word Right 次の単語へ WordArt ワードアート diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 946f47a278..1bc856d31f 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -930,6 +930,7 @@ Window Janela Word Count Contagem de palavras Word document (*.docx) Documento Word (*.docx) + Word XML Document (*.xml) Documento XML do Word (*.xml) Word Left Palavra à esquerda Word Right Palavra à direita WordArt Texto artístico diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 105386051c..52e4409088 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2112,6 +2112,8 @@ preview Title Пример Apply Применить Convert Notes Преобразовать сноски +# Word XML Document (#548). + Word XML Document (*.xml) Документ Word XML (*.xml) # Find and Replace special characters and wildcards (#543). Use wildcards Подстановочные знаки Special Специальный diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 01e287f74d..d8b53f9dea 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -990,6 +990,7 @@ Window Prozor Word Count Broj reči Word document (*.docx) Word dokument (*.docx) + Word XML Document (*.xml) Word XML dokument (*.xml) Word Left Reč levo Word Right Reč desno WordArt WordArt diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index a032f91c8f..862b1ac5aa 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -994,6 +994,7 @@ Window Прозор Word Count Број речи Word document (*.docx) Word документ (*.docx) + Word XML Document (*.xml) Word XML документ (*.xml) Word Left Реч лево Word Right Реч десно WordArt WordArt diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 021718e18c..df024f82c6 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1734,6 +1734,8 @@ Apply Uygula Convert Notes Notları Dönüştür +# Word XML Document (#548). + Word XML Document (*.xml) Word XML Belgesi (*.xml) # Find and Replace special characters and wildcards (#543). Use wildcards Joker karakter kullan Special Özel diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index da053e75e0..e0ad736bcf 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -956,6 +956,7 @@ Window Вікно Word Count Кількість слів Word document (*.docx) Документ Word (*.docx) + Word XML Document (*.xml) Документ Word XML (*.xml) Word Left На слово ліворуч Word Right На слово праворуч WordArt WordArt diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index eadfa0eb0b..b956e3bbfd 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -942,6 +942,7 @@ Window 窗口 Word Count 字数统计 Word document (*.docx) Word 文档 (*.docx) + Word XML Document (*.xml) Word XML 文档 (*.xml) Word Left 左移一个单词 Word Right 右移一个单词 WordArt 艺术字 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 4af58aacb6..ecc2f83146 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -931,6 +931,7 @@ Window 視窗 Word Count 字數統計 Word document (*.docx) Word 文件 (*.docx) + Word XML Document (*.xml) Word XML 文件 (*.xml) Word Left 左移一個單字 Word Right 右移一個單字 WordArt 文字藝術師 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7a24d86b3f..c458e354c6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1266,7 +1266,7 @@ impl AutoSaveBlock { /// copy that leaves it unsaved. AutoSave writes only these formats. fn keeps_everything(name: &str) -> bool { let ext = std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); - matches!(ext.as_str(), "docx" | "docm" | "dotx" | "dotm" | "odt" | "rtf" | "json") + matches!(ext.as_str(), "docx" | "docm" | "dotx" | "dotm" | "odt" | "rtf" | "json" | "xml") } /// The dialog a user-run command opens when it lacks the input it needs (scripts and agents get diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 89886e3f4b..2920629468 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,13 +1,13 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (every `w:numFmt` number format kept, East Asian ones drawn, #534; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Word XML Document (Flat OPC) opens and saves, Word 2003 XML opens, #548; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the share of a typical real-world file's content and formatting that survives (read: shows correctly; write: Word opens it showing the same). Summary in [`target-app-parity.md`](target-app-parity.md); the work items are in [`gaps.md`](gaps.md). -**Dimension: ~60% (estimated), 185–300 h to full** (the sum of the rows below). The weighted table +**Dimension: ~60% (estimated), 179–290 h to full** (the sum of the rows below). The weighted table gives ~64%; we discount it to ~60% because DOCX fidelity has never been checked on real-world files. DOCX dominates the weight: a Word user sends and receives .docx; everything else is occasional. @@ -30,8 +30,8 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | `.txt` Plain text | 2% | R/W | ✅ | ✅ | ~95% | 1–2 | unit tests | Encoding detection; Word's encoding choice dialog and line-ending options missing | | `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~60% | 6–10 | `crates/formats/src/tests.rs` | Tables (#102), relative pictures embedded (#113), missing `` tolerated (#154). No Word "Web Page" (with `_files` folder) round-trip | | `.mht/.mhtml` Single File Web Page | <1% | R/W | ❌ | ❌ | 0% | 3–5 | — | | -| `.xml` Word XML Document (Flat OPC) | <1% | R/W | ❌ | ❌ | 0% | 3–5 | — | The DOCX parts in one XML file; cheap once the DOCX code is reused | -| `.xml` Word 2003 XML (WordprocessingML 2003) | <1% | R/W | ❌ | ❌ | 0% | 5–8 | — | Legacy | +| `.xml` Word XML Document (Flat OPC) | <1% | R/W | ✅ | ✅ | ~65% | — | `crates/docx/tests/word_xml.rs`, `roundtrip.rs` (`flat_opc_round_trip_equals_docx`) | The DOCX package in one XML file, read and written by the DOCX code (#548); File › Export › Word XML Document. `.xml` files are told apart by their root element | +| `.xml` Word 2003 XML (WordprocessingML 2003) | <1% | R/W | 🟡 | ❌ | ~55% | 2–3 | `crates/docx/tests/word_xml.rs` | Opened by rewriting it as a package for the DOCX reader (#548): styles, default fonts, lists, tables, sections, headers/footers, footnotes/endnotes, comments, bookmarks, tracked insertions/deletions, hyperlinks, `w:binData` pictures, document properties. Not read: formatting changes, `w:docPr` settings, smart tags. Saving as 2003 XML is not planned (legacy) | | `.wps` Works 6–9, WordPerfect `.wpd` (Windows) | <1% | R | ❌ | — | 0% | 10–20 | — | Windows-only converters; low value | | Encrypted/password-protected `.docx`/`.doc` | 1% | R/W | ❌ | ❌ | 0% | 6–10 | — | ECMA-376 Agile Encryption; `.doc` RC4/XOR detected and refused (`docbin` fib). Issue #55 | | `.xlsx`/`.xlsm`/`.ods` as a mail-merge recipient list | — | R (data source) | ✅ | — | ~90% | 1–3 | `crates/formats/src/sheet.rs`, `cmd/mailings.rs` | Read-only, one sheet's cell values (#334): shared/inline/rich strings, numbers, booleans, cached formula results, dates and times as ISO 8601 (`yyyy-mm-dd`) from the cell's number format, sparse cells; ODS value types, repeated rows/columns, covered cells. Select Table picks the sheet. Capped (64 MiB per part, 100,000 rows, 1,000 columns, 2 M cells, 32,767 characters a cell). Not shown: number formats other than dates (currency, percent, thousands separators) in `.xlsx`; no `.xls`, Access or Outlook sources | @@ -91,6 +91,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Word XML Document (Flat OPC) read and written, Word 2003 XML read (#548) | | 2026-10-11 | trivial | #534: DOCX indents in characters read and written; `.doc` first-line indents (`sprmPDxaLeft1`, its two-byte operand, and `sprmPDxaLeft1_80`), indents in characters (`sprmPDxc*`) and exact/at-least line spacing (LSPD) read correctly | | 2026-10-11 | trivial | List and page number formats: all ST_NumberFormat values round-trip; Chinese, Japanese and Korean counting, legal and digit formats, stems/branches, kana, hangul, enclosed and full-width numbers drawn; unknown values are decimal; .doc number format codes mapped (#534) | | 2026-10-11 | trivial | SmartArt made in WordCraft written as diagram parts (data, layout, quick style, colours, drawing) and read back editable (#497) | diff --git a/docs/gaps.md b/docs/gaps.md index 49e94147c5..d49c07c360 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Insert › SmartArt landed, #497; previously trivial: #24 narrowed: Customize Keyboard and Customize Ribbon landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Word XML Document and Word 2003 XML landed, #548; previously trivial: #24 narrowed: Customize Keyboard and Customize Ribbon landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 The work list. One entry per shortfall, ranked by how much it stops a Word user from switching (**B** = blocks beta). Each says what's missing, the evidence, who it hurts, an Opus 5.5 agent @@ -36,7 +36,7 @@ first (`gh pr list`), and update this file and the parity doc when a gap closes. | 23 | **PDF Reflow** (open a PDF as an editable document, Word for Windows) | | | Users who edit PDFs they receive | 30–50 | [file-format-parity.md](file-format-parity.md) | | 24 | **Default shortcuts**: ~135 of Word's ~250 (#330, #339, #343; Customize Keyboard #368, Customize Ribbon and the Quick Access Toolbar #379 landed) | | [ui-parity.md](ui-parity.md) | Power users | 4–7 | [ui-parity.md](ui-parity.md) | | 25 | **In-app AI assistant** (Copilot-style draft, rewrite, summarize) | | Chat PR #178 open | Users expecting Copilot | 15–30 + owner (provider) | [target-app-parity.md](target-app-parity.md) | -| 26 | **Minor formats**: Flat OPC XML, Word 2003 XML, MHT, Works/WordPerfect | | | Rare | 20–35 | [file-format-parity.md](file-format-parity.md) | +| 26 | **Minor formats**: MHT, Works/WordPerfect, saving Word 2003 XML | | Word XML Document (Flat OPC) opens and saves, Word 2003 XML opens (#548) | Rare | 15–28 | [file-format-parity.md](file-format-parity.md) | | 27 | **View windows**: Side by Side, Synchronous Scrolling, Arrange All, Switch Windows | | [parity-checklist.md](parity-checklist.md) (View 25/29) | Comparing documents | 3–5 | [ui-parity.md](ui-parity.md) | | 28 | **Dictate** | | `tools.dictate` missing | Dictation users | 8–15 + owner (speech model) | [hardware-parity.md](hardware-parity.md) | | 29 | **Equations**: ink equations | | #191 notes; long display equations wrap at operators (#326) | Maths-heavy documents | 2–3 | [layout-parity.md](layout-parity.md) | @@ -64,6 +64,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Word XML Document (Flat OPC) and Word 2003 XML opening landed (#548); gap #26 narrowed to 15–28 h | | 2026-10-11 | trivial | #13 narrowed: Insert › SmartArt with eight layouts, Text Pane and SmartArt Design landed (#497); 25–40 h left for charts | | 2026-10-11 | trivial | File › Options panes landed (#485); removed from the dialog-depth list | | 2026-10-11 | trivial | #18: the document canvas describes its text, structure and caret to screen readers through AccessKit (#488); real screen-reader testing remains |