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 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Valentin Klein
2026-10-11 21:59:00 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent 7c5f72a31b
commit baeb8d9c6c
31 changed files with 1322 additions and 22 deletions
+1
View File
@@ -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 |
+2 -1
View File
@@ -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",
}
}
+1 -1
View File
@@ -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", &["*"])
}
+411
View File
@@ -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<Document, DocxError> {
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<Data>,
}
/// 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<Package, DocxError> {
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<String, Vec<u8>> = BTreeMap::new();
let mut types: Vec<(String, String)> = Vec::new();
let mut total: u64 = 0;
let mut part: Option<Part> = None;
// `xmlns` declarations on the package, part and data elements, in scope for the inline XML.
let mut scope: Vec<Vec<(String, String)>> = 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() {
// `<pkg:xmlData/>`: 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<String, Vec<u8>>, 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<u8>,
files: &mut BTreeMap<String, Vec<u8>>,
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<String> {
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<u8> {
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<usize> {
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"<!--") {
i += rest.windows(3).position(|w| w == b"-->")? + 3;
} else if rest.starts_with(b"<?") {
i += rest.windows(2).position(|w| w == 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<Vec<u8>, 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("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<?mso-application progid=\"Word.Document\"?>\n");
s.push_str(&format!("<pkg:package xmlns:pkg=\"{PKG_NS}\">"));
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!("<pkg:part pkg:name=\"/{}\" pkg:contentType=\"{}\"", esc(name), esc(ct)));
match xml_fragment(ct, bytes) {
Some(x) => {
s.push_str("><pkg:xmlData>");
s.push_str(x);
s.push_str("</pkg:xmlData></pkg:part>");
}
None => {
s.push_str(" pkg:compression=\"store\"><pkg:binaryData>");
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("</pkg:binaryData></pkg:part>");
}
}
}
s.push_str("</pkg:package>");
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("<?xml") {
Some(rest) => rest.split_once("?>").map(|(_, r)| r)?.trim_start(),
None => s,
};
(s.starts_with('<') && !s.contains("<!DOCTYPE")).then_some(s)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sniffs_root_elements() {
assert_eq!(
sniff_xml(
format!("<?xml version=\"1.0\"?><?mso-application progid=\"Word.Document\"?><!-- x --><p:package xmlns:p=\"{PKG_NS}\"/>").as_bytes()
),
XmlKind::FlatOpc
);
assert_eq!(sniff_xml(format!("\u{FEFF}<w:wordDocument xmlns:w=\"{W2003_NS}\"/>").as_bytes()), XmlKind::WordMl2003);
assert_eq!(sniff_xml(b"<package/>"), XmlKind::Other);
assert_eq!(sniff_xml(b"<html><body/></html>"), 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 <!-- c --> <w:document><w:body/></w:document>";
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, "<w:document xmlns:w=\"urn:w\"><w:body/></w:document>");
let own = b"<w:document xmlns:w=\"urn:own\"/>";
assert_eq!(with_scope(own, &scope), own.to_vec());
assert_eq!(with_scope(b"no element", &scope), b"no element".to_vec());
}
}
+23
View File
@@ -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<wordcraft_doc::Document, DocxError> {
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,
+6
View File
@@ -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<String, Vec<u8>>) -> 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('/');
+7 -2
View File
@@ -99,7 +99,12 @@ pub fn read(bytes: &[u8]) -> Result<Document, DocxError> {
/// [`read`] for bytes known not to be an encrypted package.
pub(crate) fn read_package(bytes: &[u8]) -> Result<Document, DocxError> {
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<Document, DocxError> {
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<Document, DocxError> {
let mut doc = Document::new();
doc.body.clear();
let mut r = Reader {
pkg: &pkg,
pkg,
doc,
pc: PropCtx::default(),
revs: HashMap::new(),
+576
View File
@@ -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<Document, DocxError> {
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<String, String>,
files: BTreeMap<String, Vec<u8>>,
/// Relationships per part: document, footnotes, endnotes, comments, then one per header or
/// footer (index in `stories` + 4).
rels: Vec<PartRels>,
footnotes: Vec<Node>,
endnotes: Vec<Node>,
comments: Vec<Node>,
/// 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<Package, DocxError> {
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("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\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<Node> {
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<Node> {
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<El> {
// 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<Node> {
let id = e.attr("aml:id").filter(|v| v.parse::<i64>().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 }
}
}
/// `<name w:val="v"/>`.
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::<u32>().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<u8> {
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()
}
+9 -4
View File
@@ -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<u8> {
pub(crate) fn xml(&self) -> Vec<u8> {
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<Vec<u8>, DocxError> {
/// Write a package of the given flavour (`.docx`, `.docm`, `.dotx`, `.dotm`).
pub fn write_as(doc: &Document, flavor: Flavor) -> Result<Vec<u8>, 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<Vec<(String, Vec<u8>)>, 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<Vec<u8>, 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<u8>)>, overrides: &mut Vec<(String, String)>, path: &str, bytes: Vec<u8>, ct: &str, rels: PartRels) {
+14 -5
View File
@@ -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<String> {
pub(crate) fn utf16_to_utf8(bytes: &[u8]) -> Option<String> {
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<String> {
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<El, DocxError> {
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<El, DocxError> {
// 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<El, DocxError> {
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<El, DocxError> {
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) {
+15
View File
@@ -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("<?xml") && text.contains("<?mso-application progid=\"Word.Document\"?>"));
assert!(text.contains("pkg:name=\"/word/document.xml\"") && text.contains("<pkg:binaryData>"));
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();
+215
View File
@@ -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<u32>) -> 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::<Vec<_>>().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::<Vec<_>>().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#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<?mso-application progid="Word.Document"?>
<pkg:package xmlns:pkg="http://schemas.microsoft.com/office/2006/xmlPackage" xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<pkg:part pkg:name="/_rels/.rels" pkg:contentType="application/vnd.openxmlformats-package.relationships+xml">
<pkg:xmlData>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>
</pkg:xmlData>
</pkg:part>
<pkg:part pkg:name="/word/_rels/document.xml.rels" pkg:contentType="application/vnd.openxmlformats-package.relationships+xml">
<pkg:xmlData><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId5" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.org/flat" TargetMode="External"/><Relationship Id="rId6" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/dot.png"/></Relationships></pkg:xmlData>
</pkg:part>
<pkg:part pkg:name="/word/document.xml" pkg:contentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml">
<pkg:xmlData>
<w:document xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:v="urn:schemas-microsoft-com:vml"><w:body>
<w:p><w:r><w:rPr><w:b/></w:rPr><w:t>Seed &amp; soil</w:t></w:r></w:p>
<w:p><w:hyperlink r:id="rId5"><w:r><w:t>link</w:t></w:r></w:hyperlink></w:p>
<w:p><w:r><w:pict><v:shape style="width:24pt;height:12pt"><v:imagedata r:id="rId6"/></v:shape></w:pict></w:r></w:p>
</w:body></w:document>
</pkg:xmlData>
</pkg:part>
<pkg:part pkg:name="/word/media/dot.png" pkg:contentType="image/png" pkg:compression="store">
<pkg:binaryData>{png}</pkg:binaryData>
</pkg:part>
<pkg:part pkg:name="/word/empty.xml" pkg:contentType="application/xml"><pkg:xmlData/></pkg:part>
</pkg:package>"#
);
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##"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<?mso-application progid="Word.Document"?>
<w:wordDocument xmlns:w="http://schemas.microsoft.com/office/word/2003/wordml" xmlns:wx="http://schemas.microsoft.com/office/word/2003/auxHint" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:aml="http://schemas.microsoft.com/aml/2001/core" w:macrosPresent="no">
<o:DocumentProperties><o:Title>Garden notes</o:Title><o:Author>Ada Gardener</o:Author><o:Keywords>beans</o:Keywords></o:DocumentProperties>
<w:fonts><w:defaultFonts w:ascii="Georgia" w:fareast="Georgia" w:h-ansi="Georgia" w:cs="Georgia"/></w:fonts>
<w:lists>
<w:listDef w:listDefId="3"><w:plt w:val="HybridMultilevel"/>
<w:lvl w:ilvl="0"><w:start w:val="1"/><w:nfc w:val="4"/><w:lvlText w:val="%1)"/><w:lvlJc w:val="left"/><w:pPr><w:ind w:left="720" w:hanging="360"/></w:pPr></w:lvl>
</w:listDef>
<w:list w:ilfo="2"><w:ilst w:val="3"/></w:list>
</w:lists>
<w:styles>
<w:versionOfBuiltInStyleNames w:val="4"/>
<w:style w:type="paragraph" w:default="on" w:styleId="Normal"><w:name w:val="Normal"/><w:rPr><wx:font wx:val="Georgia"/><w:sz w:val="24"/><w:sz-cs w:val="24"/></w:rPr></w:style>
<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><wx:uiName wx:val="Heading 1"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:pPr><w:keepNext/><w:spacing w:before="240" w:after="60"/><w:outlineLvl w:val="0"/></w:pPr><w:rPr><w:b/><w:sz w:val="32"/></w:rPr></w:style>
</w:styles>
<w:docPr><w:view w:val="print"/><w:zoom w:percent="100"/><w:footnotePr><w:footnote w:type="separator"><w:p><w:r><w:separator/></w:r></w:p></w:footnote></w:footnotePr></w:docPr>
<w:body>
<wx:sect>
<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><aml:annotation aml:id="0" w:type="Word.Bookmark.Start" w:name="plan"/><aml:annotation aml:id="1" w:type="Word.Comment.Start"/><w:r><w:t>Plan</w:t></w:r><aml:annotation aml:id="1" w:type="Word.Comment.End"/><w:r><aml:annotation aml:id="1" aml:author="Bo" w:initials="B" w:type="Word.Comment"><aml:content><w:p><w:r><w:t>Too short?</w:t></w:r></w:p></aml:content></aml:annotation></w:r><aml:annotation aml:id="0" w:type="Word.Bookmark.End"/></w:p>
<w:p><w:pPr><w:jc w:val="center"/><w:ind w:first-line="360"/><w:spacing w:line="360" w:line-rule="auto"/></w:pPr>
<w:r><w:t>Plain </w:t></w:r>
<w:r><w:rPr><w:rFonts w:ascii="Courier New" w:h-ansi="Courier New"/><w:b/><w:i/><w:u w:val="single"/><w:sz w:val="28"/><w:color w:val="FF0000"/></w:rPr><w:t>loud</w:t></w:r>
<w:r><w:tab/><w:t>then</w:t><w:br/><w:t>more</w:t></w:r>
<w:r><w:footnote><w:p><w:r><w:footnoteRef/></w:r><w:r><w:t>Sown in May.</w:t></w:r></w:p></w:footnote></w:r>
</w:p>
<w:p><w:hlink w:dest="https://example.org/beans" w:screenTip="Beans"><w:r><w:rPr><w:rStyle w:val="Hyperlink"/></w:rPr><w:t>bean guide</w:t></w:r></w:hlink></w:p>
<w:p><w:pPr><w:listPr><w:ilvl w:val="0"/><w:ilfo w:val="2"/><wx:t wx:val="a)"/></w:listPr></w:pPr><w:r><w:t>Water the beans</w:t></w:r><aml:annotation aml:id="7" aml:author="Ada" aml:createdate="2004-05-01T10:00:00Z" w:type="Word.Insertion"><aml:content><w:r><w:t> daily</w:t></w:r></aml:content></aml:annotation></w:p>
<w:tbl>
<w:tblPr><w:tblW w:w="0" w:type="auto"/></w:tblPr>
<w:tblGrid><w:gridCol w:w="2000"/><w:gridCol w:w="2000"/></w:tblGrid>
<w:tr><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/><w:vmerge w:val="restart"/></w:tcPr><w:p><w:r><w:t>A1</w:t></w:r></w:p></w:tc><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/></w:tcPr><w:p><w:r><w:t>B1</w:t></w:r></w:p></w:tc></w:tr>
<w:tr><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/><w:vmerge/></w:tcPr><w:p/></w:tc><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/></w:tcPr><w:p><w:r><w:t>B2</w:t></w:r></w:p></w:tc></w:tr>
</w:tbl>
<w:p><w:r><w:pict><w:binData w:name="wordml://02000001.png">{png}</w:binData><v:shape id="_x0000_i1025" type="#_x0000_t75" style="width:36pt;height:18pt"><v:imagedata src="wordml://02000001.png" o:title="dot"/></v:shape></w:pict></w:r></w:p>
<w:sectPr>
<w:hdr w:type="odd"><w:p><w:r><w:t>Garden header</w:t></w:r></w:p></w:hdr>
<w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1800" w:bottom="1440" w:left="1800" w:header="720" w:footer="720" w:gutter="0"/>
</w:sectPr>
</wx:sect>
</w:body>
</w:wordDocument>"##
)
}
#[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#"<pkg:package xmlns:pkg="http://schemas.microsoft.com/office/2006/xmlPackage">"#;
let w2003 = r#"<w:wordDocument xmlns:w="http://schemas.microsoft.com/office/word/2003/wordml">"#;
// Other XML (and non-XML) is refused with the Word XML error.
for other in ["<html><body>hi</body></html>", "", "not xml at all", "<package/>"] {
assert_eq!(read_xml(other.as_bytes()), Err(DocxError::NotWordXml), "{other:?}");
}
// Broken packages: errors.
for bad in [
flat.to_string(),
format!("{flat}</pkg:package>"),
format!("{flat}<pkg:part pkg:name=\"/word/document.xml\"><pkg:binaryData>!!!not base64</pkg:binaryData></pkg:part></pkg:package>"),
format!("{flat}<pkg:part pkg:name=\"/word/document.xml\"><pkg:xmlData><w:document></pkg:xmlData></pkg:part></pkg:package>"),
] {
assert!(read_xml(bad.as_bytes()).is_err(), "{bad}");
}
// Lenient leftovers never panic.
for odd in [
format!("{flat}<pkg:part/><pkg:part pkg:name=\"\"><pkg:xmlData/></pkg:part><x/></pkg:package>"),
format!(
"{w2003}<w:body><w:p><w:r><w:footnote w:type=\"separator\"/><w:pict><w:binData w:name=\"a\">%%%</w:binData><v:imagedata src=\"a\"/></w:pict></w:r></w:p></w:body></w:wordDocument>"
),
format!(
"{w2003}<w:lists><w:list/><w:listDef/></w:lists><w:body><aml:annotation xmlns:aml=\"http://schemas.microsoft.com/aml/2001/core\" aml:id=\"x\" w:type=\"Word.Comment\"/><w:sectPr><w:hdr/><w:ftr w:type=\"first\"/></w:sectPr></w:body></w:wordDocument>"
),
format!("{w2003}{}", "<w:p>".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!("<pkg:part pkg:name=\"/p{i}.bin\"><pkg:binaryData>AA==</pkg:binaryData></pkg:part>"));
}
many.push_str("</pkg:package>");
assert!(matches!(read_xml(many.as_bytes()), Err(DocxError::Limit(_))));
}
+1 -1
View File
@@ -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;
}
+2 -1
View File
@@ -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();
+3
View File
@@ -7,6 +7,8 @@ pub fn open(ext: &str, bytes: &[u8]) -> Option<Result<Document, String>> {
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<Result<Vec<u8>, 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),
+13
View File
@@ -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"<rss><channel/></rss>").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]
+1
View File
@@ -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"),
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 25.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 444 and column 13.
+1
View File
@@ -930,6 +930,7 @@
Window ウィンドウ
Word Count 文字カウント
Word document (*.docx) Word 文書 (*.docx)
Word XML Document (*.xml) Word XML ドキュメント (*.xml)
Word Left 前の単語へ
Word Right 次の単語へ
WordArt ワードアート
Can't render this file because it contains an unexpected character in line 552 and column 13.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 25.
+2
View File
@@ -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 Специальный
Can't render this file because it contains an unexpected character in line 7 and column 25.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 25.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 11 and column 25.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 430 and column 13.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 31.
+1
View File
@@ -942,6 +942,7 @@
Window 窗口
Word Count 字数统计
Word document (*.docx) Word 文档 (*.docx)
Word XML Document (*.xml) Word XML 文档 (*.xml)
Word Left 左移一个单词
Word Right 右移一个单词
WordArt 艺术字
Can't render this file because it contains an unexpected character in line 7 and column 25.
+1
View File
@@ -931,6 +931,7 @@
Window 視窗
Word Count 字數統計
Word document (*.docx) Word 文件 (*.docx)
Word XML Document (*.xml) Word XML 文件 (*.xml)
Word Left 左移一個單字
Word Right 右移一個單字
WordArt 文字藝術師
Can't render this file because it contains an unexpected character in line 553 and column 13.
+1 -1
View File
@@ -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
+5 -4
View File
@@ -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 `</head>` 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) |
+3 -2
View File
@@ -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 |