Save as Word 2003 XML Document (#1081) (#1088)

Write WordprocessingML 2003 by rewriting the DOCX writer's package into the 2003 vocabulary; file.save/saveAs take format wordml2003, File > Export lists the format, CLI convert --format wordml2003, a plain save over a 2003 file keeps it.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Valentin Klein
2026-10-14 01:52:42 -04:00
committed by GitHub
co-authored by Claude Sonnet 5.5 Brandon Thomas
parent f322c60bdf
commit 09b8b436cc
32 changed files with 1236 additions and 17 deletions
+1
View File
@@ -135,6 +135,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| Date | What landed |
|---|---|
| 2026-10-12 | File › Export › Word 2003 XML Document (#1081): documents save as WordprocessingML 2003 (`format: "wordml2003"` on the save commands, `wordcraft-cli convert --format wordml2003`); shapes, text boxes, charts and SmartArt don't survive, and it hasn't been opened in Word yet |
| 2026-10-12 | Direct run-level native InkML (`w:contentPart`) now survives DOCX edits and repeated saves with its relationship, content type and bytes intact. Native InkML rendering/editing and ink nested in groups or canvases remain missing |
| 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word |
| 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them |
+20 -5
View File
@@ -14,8 +14,10 @@ const USAGE: &str = "\
wordcraft-cli — WordCraft from the command line
USAGE:
wordcraft-cli convert <in> <out> [--markup]
convert between formats (docx, doc [read], pdf, odt, rtf, html, md, tex, txt, json, png);
wordcraft-cli convert <in> <out> [--markup] [--format wordml2003|flatopc]
convert between formats (docx, doc [read], pdf, odt, rtf, html, md, tex, txt, json, png,
xml); an .xml file is a Word XML Document (Flat OPC), or with
--format wordml2003 a Word 2003 XML Document;
--markup (Print Markup): comments beside each page and
tracked changes in pdf/png, a comment list in html/md/txt
wordcraft-cli info <file> pages, words, paragraphs, properties (JSON)
@@ -111,7 +113,7 @@ fn automation_roots(
}
const NO_OPTIONS: &[OptionSpec] = &[];
const CONVERT_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--markup", takes_value: false }];
const CONVERT_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--markup", takes_value: false }, OptionSpec { name: "--format", takes_value: true }];
const RENDER_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--page", takes_value: true }, OptionSpec { name: "--scale", takes_value: true }];
const RUN_OPTIONS: &[OptionSpec] = &[
OptionSpec { name: "--file", takes_value: true },
@@ -186,13 +188,26 @@ fn run(args: &[String]) -> Result<(), Failure> {
match cmd {
"convert" => {
validate_options("convert", &rest, CONVERT_OPTIONS)?;
let (i, o) = (pos(0)?, pos(1)?);
// `--format wordml2003` (or `flatopc`) picks the kind of `.xml` file; its value isn't a path.
let format = arg_value(&rest, "--format");
let paths: Vec<&String> = rest
.iter()
.enumerate()
.filter(|(n, a)| !a.starts_with("--") && !(*n > 0 && rest.get(n - 1).is_some_and(|p| p == "--format")))
.map(|(_, a)| a)
.collect();
let missing = || format!("missing argument\n\n{USAGE}");
let (i, o) = (paths.first().ok_or_else(missing)?.to_string(), paths.get(1).ok_or_else(missing)?.to_string());
let markup = rest.iter().any(|a| a == "--markup");
let mut s = open(&i)?;
let r = if o.to_ascii_lowercase().ends_with(".png") {
s.run("file.exportPng", &json!({"path": o, "page": 1, "markup": markup}))
} else {
s.run("file.save", &json!({"path": o, "markup": markup}))
let mut params = json!({"path": o, "markup": markup});
if let Some(f) = format {
params["format"] = json!(f);
}
s.run("file.save", &params)
};
r.map_err(|e| e.to_string())?;
eprintln!("wrote {o}");
+2
View File
@@ -16,6 +16,7 @@ mod read;
mod smart_art;
mod units;
mod wordml2003;
mod wordml2003_write;
mod write;
mod xml;
@@ -27,6 +28,7 @@ pub use crypt::{
pub use flat::{XmlKind, sniff_xml, write as write_flat};
pub use read::read;
pub use smart_art::smart_art_items;
pub use wordml2003_write::write as write_wordml2003;
pub use write::{write, write_as};
/// Largest `.xml` file read as a Word XML document.
+1 -1
View File
@@ -1,5 +1,5 @@
//! Word 2003 XML Document (WordprocessingML 2003, root `w:wordDocument`, from the public Office
//! 2003 XML Reference Schemas). Import only.
//! 2003 XML Reference Schemas). Import; the writer is `wordml2003_write`.
//!
//! 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
+921
View File
@@ -0,0 +1,921 @@
//! Word 2003 XML Document writer (WordprocessingML 2003, root `w:wordDocument`, from the public
//! Office 2003 XML Reference Schemas).
//!
//! The mirror image of the reader in `wordml2003`: the DOCX writer produces the package, and the
//! package is rewritten into the 2003 vocabulary. ECMA-376 kept most of the 2003 element names
//! (`w:p`, `w:r`, `w:rPr`, `w:tbl`, `w:sectPr`…), so the body is mostly renamed (`w:szCs` →
//! `w:sz-cs`, `w:eastAsia` → `w:fareast`, `w:vMerge` → `w:vmerge`), and what 2003 keeps inline is
//! moved back inline: hyperlink targets (`w:hlink`), footnotes and endnotes (inside their
//! reference run), headers and footers (inside `w:sectPr`), comments, bookmarks and tracked
//! changes (`aml:annotation`), lists (`w:listPr` and a `w:lists` block), pictures (`w:pict` with
//! `w:binData` in base64) and the core properties (`o:DocumentProperties`).
//!
//! What the 2003 format cannot carry is dropped or degraded, never an error: drawings other than
//! pictures (shapes, text boxes, groups, charts, SmartArt, ink), picture crop, rotation and
//! floating position (pictures become inline), content controls (their content stays), math,
//! revisions of formatting, theme colours and fonts, custom XML, macros, embedded objects and
//! `docDefaults` other than fonts (folded into the Normal style). Elements outside the 2003
//! vocabulary are not written, so Word has nothing to reject. Bounded: nesting is capped by the
//! parser's depth limit, pictures and stories are capped in number.
use std::collections::{BTreeMap, HashMap};
use base64::Engine as _;
use wordcraft_doc::Document;
use crate::flat::W2003_NS;
use crate::package::{Package, Rels, rt};
use crate::xml::{El, MAX_DEPTH, Node};
use crate::{DocxError, Flavor};
const AUX_NS: &str = "http://schemas.microsoft.com/office/word/2003/auxHint";
const AML_NS: &str = "http://schemas.microsoft.com/aml/2001/core";
const VML_NS: &str = "urn:schemas-microsoft-com:vml";
const OFFICE_NS: &str = "urn:schemas-microsoft-com:office:office";
const W10_NS: &str = "urn:schemas-microsoft-com:office:word";
/// Most pictures written (the reader takes as many).
const MAX_MEDIA: usize = 4096;
/// Most header, footer, footnote, endnote and comment stories written.
const MAX_STORIES: usize = 50_000;
/// A picture's longest side, points (a corrupt extent must not give a huge frame).
const MAX_PICT_PT: f64 = 31_680.0;
/// Write `doc` as a Word 2003 XML Document.
pub fn write(doc: &Document) -> Result<Vec<u8>, DocxError> {
let parts: BTreeMap<String, Vec<u8>> = crate::write::write_parts(doc, Flavor::Document)?.into_iter().collect();
let pkg = Package::from_parts(parts);
let doc_part = pkg
.rels("_rels/.rels")
.by_type(rt::OFFICE_DOC)
.map(|r| r.target.clone())
.filter(|t| pkg.get(t).is_some())
.unwrap_or_else(|| "word/document.xml".to_string());
let mut c = Conv::new(&pkg, &doc_part);
let body = match pkg.xml(&doc_part)? {
Some(root) => {
let rels = c.doc_rels.clone();
match root.child("w:body") {
Some(b) => c.kids(b, &rels, "w:body", 1),
None => Vec::new(),
}
}
None => Vec::new(),
};
let mut kids: Vec<Node> = vec![Node::El(core_props(doc))];
if let Some(f) = c.default_fonts() {
kids.push(Node::El(f));
}
if let Some(l) = c.lists() {
kids.push(Node::El(l));
}
kids.push(Node::El(c.styles()));
kids.push(Node::El(plain(
"w:docPr",
vec![Node::El(attr_el("w:view", &[("w:val", "print")])), Node::El(attr_el("w:zoom", &[("w:percent", "100")]))],
)));
kids.push(Node::El(plain("w:body", vec![Node::El(plain("wx:sect", body))])));
let attrs = [
("xmlns:w", W2003_NS),
("xmlns:wx", AUX_NS),
("xmlns:o", OFFICE_NS),
("xmlns:v", VML_NS),
("xmlns:w10", W10_NS),
("xmlns:aml", AML_NS),
("w:macrosPresent", "no"),
("w:embeddedObjPresent", "no"),
("w:ocxPresent", "no"),
("xml:space", "preserve"),
];
let root = El { name: "w:wordDocument".into(), attrs: attrs.iter().map(|(k, v)| ((*k).to_string(), (*v).to_string())).collect(), kids };
let mut s = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<?mso-application progid=\"Word.Document\"?>\n");
s.push_str(&root.to_xml());
Ok(s.into_bytes())
}
fn plain(name: &str, kids: Vec<Node>) -> El {
El { name: name.into(), attrs: Vec::new(), kids }
}
fn attr_el(name: &str, attrs: &[(&str, &str)]) -> El {
El { name: name.into(), attrs: attrs.iter().map(|(k, v)| ((*k).to_string(), (*v).to_string())).collect(), kids: Vec::new() }
}
fn text_el(name: &str, text: &str) -> El {
El { name: name.into(), attrs: Vec::new(), kids: vec![Node::Text(text.to_string())] }
}
/// `o:DocumentProperties` from the core properties.
fn core_props(doc: &Document) -> El {
let c = &doc.core;
let mut kids = Vec::new();
let revision = c.revision.to_string();
for (name, v) in [
("o:Title", c.title.as_str()),
("o:Subject", c.subject.as_str()),
("o:Author", c.creator.as_str()),
("o:Keywords", c.keywords.as_str()),
("o:Description", c.description.as_str()),
("o:LastAuthor", c.last_modified_by.as_str()),
("o:Revision", if c.revision > 0 { revision.as_str() } else { "" }),
("o:Created", c.created.as_str()),
("o:LastSaved", c.modified.as_str()),
("o:Category", c.category.as_str()),
] {
if !v.trim().is_empty() {
kids.push(Node::El(text_el(name, v.trim())));
}
}
plain("o:DocumentProperties", kids)
}
struct Conv<'a> {
pkg: &'a Package,
doc_rels: Rels,
footnotes: HashMap<String, El>,
endnotes: HashMap<String, El>,
comments: HashMap<String, El>,
foot_rels: Rels,
end_rels: Rels,
comment_rels: Rels,
styles_part: Option<El>,
numbering_part: Option<El>,
/// Picture part path → `wordml://` name, for the pictures already given a `w:binData`.
media: HashMap<String, String>,
/// `w:binData` elements waiting for the `w:pict` being written.
bins: Vec<El>,
/// Annotation ids: (kind, original id) → the id written (one number space, as in 2003).
ann_ids: HashMap<(char, String), i64>,
next_ann: i64,
next_shape: i64,
stories: usize,
in_note: bool,
}
impl<'a> Conv<'a> {
fn new(pkg: &'a Package, doc_part: &str) -> Conv<'a> {
let doc_rels = pkg.rels(doc_part);
let part_of = |kind: &str| doc_rels.by_type(kind).map(|r| r.target.clone());
let notes = |kind: &str, local: &str| -> (HashMap<String, El>, Rels) {
let Some(path) = part_of(kind) else { return (HashMap::new(), Rels::default()) };
let map = pkg
.xml(&path)
.ok()
.flatten()
.map(|root| {
root.els()
.filter(|e| e.name == local)
.filter(|e| e.attr("w:type").is_none())
.filter_map(|e| e.attr("w:id").map(|id| (id.to_string(), e.clone())))
.collect()
})
.unwrap_or_default();
(map, pkg.rels(&path))
};
let (footnotes, foot_rels) = notes(rt::FOOTNOTES, "w:footnote");
let (endnotes, end_rels) = notes(rt::ENDNOTES, "w:endnote");
let (comments, comment_rels) = notes(rt::COMMENTS, "w:comment");
let load = |kind: &str| part_of(kind).and_then(|p| pkg.xml(&p).ok().flatten());
Conv {
pkg,
styles_part: load(rt::STYLES),
numbering_part: load(rt::NUMBERING),
doc_rels,
footnotes,
endnotes,
comments,
foot_rels,
end_rels,
comment_rels,
media: HashMap::new(),
bins: Vec::new(),
ann_ids: HashMap::new(),
next_ann: 0,
next_shape: 1025,
stories: 0,
in_note: false,
}
}
/// The annotation id for `(kind, id)`: the same each time it is asked.
fn ann_id(&mut self, kind: char, id: &str) -> i64 {
if let Some(n) = self.ann_ids.get(&(kind, id.to_string())) {
return *n;
}
let n = self.next_ann;
self.next_ann = self.next_ann.saturating_add(1);
self.ann_ids.insert((kind, id.to_string()), n);
n
}
fn kids(&mut self, e: &El, rels: &Rels, parent: &str, 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, rels, parent, depth + 1)),
}
}
out
}
/// `e` rewritten for 2003: nothing (dropped), its content (unwrapped) or one element.
fn node(&mut self, e: &El, rels: &Rels, parent: &str, depth: usize) -> Vec<Node> {
if depth > MAX_DEPTH {
return Vec::new();
}
let name = e.name.as_str();
match name {
// Content controls: the content stays, the control goes.
"w:sdt" => return e.child("w:sdtContent").map(|c| self.kids(c, rels, parent, depth)).unwrap_or_default(),
"w:drawing" => return self.picture(e, rels),
// A VML fallback is kept for what the modern drawing could not be.
"mc:AlternateContent" => return e.child("mc:Fallback").map(|c| self.kids(c, rels, parent, depth)).unwrap_or_default(),
"w:object" | "w:txbxContent" => return Vec::new(),
"w:footnoteReference" | "w:endnoteReference" => return self.note(e, depth),
"w:commentReference" => return self.comment(e, depth),
"w:commentRangeStart" | "w:commentRangeEnd" | "w:bookmarkStart" | "w:bookmarkEnd" => return self.range_marker(e),
"w:ins" | "w:del" | "w:moveFrom" | "w:moveTo" => {
// Paragraph-mark and row revisions sit in property blocks: no 2003 form.
if matches!(parent, "w:rPr" | "w:trPr") {
return Vec::new();
}
return self.revision(e, rels, parent, depth);
}
"w:hyperlink" => return vec![Node::El(self.hyperlink(e, rels, depth))],
"w:numPr" if parent == "w:pPr" => return vec![Node::El(list_pr(e))],
"w:numFmt" if parent == "w:lvl" => return vec![Node::El(nfc(e))],
"w:sectPr" => return vec![Node::El(self.section(e, rels, depth))],
"w:pict" => return vec![Node::El(self.vml_pict(e, rels, depth))],
"w:tblLook" => {
return match e.attr("w:val") {
Some(v) => vec![Node::El(attr_el("w:tblLook", &[("w:val", v)]))],
None => Vec::new(),
};
}
_ => {}
}
if !allowed(name) {
return Vec::new();
}
let mut el = El { name: rename(name), attrs: attrs(e), kids: Vec::new() };
if name == "v:imagedata" {
let id = e.attr("r:id").or_else(|| e.attr("r:pict")).map(str::to_string);
if let Some(src) = id.and_then(|id| rels.by_id(&id).map(|r| r.target.clone())).and_then(|t| self.bin_for(&t)) {
el.attrs.retain(|(k, _)| k != "src");
el.attrs.push(("src".into(), src));
} else {
return Vec::new();
}
}
el.kids = self.kids(e, rels, name, depth);
vec![Node::El(el)]
}
/// A `wordml://` name for the picture at package path `target`, queueing its `w:binData` the
/// first time. `None` for a format 2003 XML pictures don't hold, or past the cap.
fn bin_for(&mut self, target: &str) -> Option<String> {
if let Some(n) = self.media.get(target) {
return Some(n.clone());
}
let ext = target.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase())?;
if !matches!(ext.as_str(), "png" | "jpeg" | "jpg" | "gif" | "bmp" | "emf" | "wmf" | "tif" | "tiff") || self.media.len() >= MAX_MEDIA {
return None;
}
let bytes = self.pkg.get(target)?;
let name = format!("wordml://0300{:04}.{ext}", self.media.len() + 1);
let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
let bin = El {
name: "w:binData".into(),
attrs: vec![("w:name".into(), name.clone()), ("xml:space".into(), "preserve".into())],
kids: vec![Node::Text(b64)],
};
self.bins.push(bin);
self.media.insert(target.to_string(), name.clone());
Some(name)
}
/// A DrawingML picture as a `w:pict` (inline, at its extent); any other drawing is dropped.
fn picture(&mut self, drawing: &El, rels: &Rels) -> Vec<Node> {
let Some(id) = drawing.find("a:blip").and_then(|b| b.attr("r:embed")) else { return Vec::new() };
let Some(target) = rels.by_id(id).filter(|r| !r.external).map(|r| r.target.clone()) else { return Vec::new() };
let Some(src) = self.bin_for(&target) else { return Vec::new() };
let pt = |k: &str| {
let emu = drawing.find("wp:extent").and_then(|x| x.attr(k)).and_then(|v| v.trim().parse::<f64>().ok()).unwrap_or(0.0);
(if emu.is_finite() { emu / 12700.0 } else { 0.0 }).clamp(0.0, MAX_PICT_PT)
};
let style = format!("width:{}pt;height:{}pt", trim_num(pt("cx")), trim_num(pt("cy")));
let alt = drawing.find("wp:docPr").and_then(|d| d.attr("descr")).unwrap_or("").to_string();
let shape_id = format!("_x0000_i{}", self.next_shape);
self.next_shape = self.next_shape.saturating_add(1);
let mut kids: Vec<Node> = std::mem::take(&mut self.bins).into_iter().map(Node::El).collect();
kids.push(Node::El(self.shape_frame(&shape_id, &style, &src, &alt)));
vec![Node::El(plain("w:pict", kids))]
}
/// The `v:shape` holding a picture's `v:imagedata`.
fn shape_frame(&self, id: &str, style: &str, src: &str, alt: &str) -> El {
let mut shape = attr_el(
"v:shape",
&[
("id", id),
("style", style),
("coordsize", "21600,21600"),
("o:spt", "75"),
("path", "m0,0l0,21600,21600,21600,21600,0xe"),
("filled", "f"),
("stroked", "f"),
],
);
if !alt.is_empty() {
shape.attrs.push(("alt".into(), alt.to_string()));
}
let mut data = attr_el("v:imagedata", &[("src", src)]);
data.attrs.push(("o:title".into(), alt.to_string()));
shape.kids.push(Node::El(data));
shape
}
/// A legacy `w:pict` (VML) kept as it is, its pictures given `w:binData`.
fn vml_pict(&mut self, e: &El, rels: &Rels, depth: usize) -> El {
let kids = self.kids(e, rels, "w:pict", depth);
let mut all: Vec<Node> = std::mem::take(&mut self.bins).into_iter().map(Node::El).collect();
all.extend(kids);
plain("w:pict", all)
}
fn hyperlink(&mut self, e: &El, rels: &Rels, depth: usize) -> El {
let mut a = Vec::new();
if let Some(r) = e.attr("r:id").and_then(|id| rels.by_id(id)).filter(|r| r.external) {
a.push(("w:dest".to_string(), r.target.clone()));
}
for (from, to) in [("w:anchor", "w:bookmark"), ("w:tooltip", "w:screenTip"), ("w:tgtFrame", "w:target")] {
if let Some(v) = e.attr(from) {
a.push((to.to_string(), v.to_string()));
}
}
El { name: "w:hlink".into(), attrs: a, kids: self.kids(e, rels, "w:hlink", depth) }
}
/// A footnote or endnote reference as the note itself, inline in its run.
fn note(&mut self, e: &El, depth: usize) -> Vec<Node> {
let foot = e.name == "w:footnoteReference";
let Some(id) = e.attr("w:id") else { return Vec::new() };
if self.in_note || self.stories >= MAX_STORIES {
return Vec::new();
}
let Some(note) = (if foot { self.footnotes.get(id) } else { self.endnotes.get(id) }).cloned() else { return Vec::new() };
self.stories += 1;
let rels = if foot { self.foot_rels.clone() } else { self.end_rels.clone() };
self.in_note = true;
let name = if foot { "w:footnote" } else { "w:endnote" };
let kids = self.kids(&note, &rels, name, depth);
self.in_note = false;
vec![Node::El(plain(name, kids))]
}
fn annotation(&self, kind: &str, id: i64, mut extra: Vec<(String, String)>, content: Option<Vec<Node>>) -> El {
let mut a = vec![("aml:id".to_string(), id.to_string())];
a.append(&mut extra);
a.push(("w:type".into(), kind.into()));
El { name: "aml:annotation".into(), attrs: a, kids: content.map(|c| vec![Node::El(plain("aml:content", c))]).unwrap_or_default() }
}
fn comment(&mut self, e: &El, depth: usize) -> Vec<Node> {
let Some(id) = e.attr("w:id") else { return Vec::new() };
if self.in_note || self.stories >= MAX_STORIES {
return Vec::new();
}
let Some(c) = self.comments.get(id).cloned() else { return Vec::new() };
self.stories += 1;
let rels = self.comment_rels.clone();
let n = self.ann_id('c', id);
self.in_note = true;
let content = self.kids(&c, &rels, "w:comment", depth);
self.in_note = false;
let mut extra = Vec::new();
if let Some(v) = c.attr("w:author") {
extra.push(("aml:author".to_string(), v.to_string()));
}
if let Some(v) = c.attr("w:date") {
extra.push(("aml:createdate".to_string(), v.to_string()));
}
if let Some(v) = c.attr("w:initials") {
extra.push(("w:initials".to_string(), v.to_string()));
}
vec![Node::El(self.annotation("Word.Comment", n, extra, Some(content)))]
}
fn range_marker(&mut self, e: &El) -> Vec<Node> {
let Some(id) = e.attr("w:id") else { return Vec::new() };
let (kind, ty) = match e.name.as_str() {
"w:commentRangeStart" => ('c', "Word.Comment.Start"),
"w:commentRangeEnd" => ('c', "Word.Comment.End"),
"w:bookmarkStart" => ('b', "Word.Bookmark.Start"),
_ => ('b', "Word.Bookmark.End"),
};
let n = self.ann_id(kind, id);
let extra = if e.name == "w:bookmarkStart" { vec![("w:name".to_string(), e.attr("w:name").unwrap_or("").to_string())] } else { Vec::new() };
vec![Node::El(self.annotation(ty, n, extra, None))]
}
/// Tracked insertions and deletions (moves become a deletion and an insertion).
fn revision(&mut self, e: &El, rels: &Rels, parent: &str, depth: usize) -> Vec<Node> {
let ty = if matches!(e.name.as_str(), "w:ins" | "w:moveTo") { "Word.Insertion" } else { "Word.Deletion" };
let n = self.ann_id('r', e.attr("w:id").unwrap_or(""));
let mut extra = Vec::new();
if let Some(v) = e.attr("w:author") {
extra.push(("aml:author".to_string(), v.to_string()));
}
if let Some(v) = e.attr("w:date") {
extra.push(("aml:createdate".to_string(), v.to_string()));
}
let content = self.kids(e, rels, parent, depth);
vec![Node::El(self.annotation(ty, n, extra, Some(content)))]
}
/// `w:sectPr` with its headers and footers inline (`w:hdr`, `w:ftr`).
fn section(&mut self, e: &El, rels: &Rels, depth: usize) -> El {
let mut stories = Vec::new();
let mut rest = Vec::new();
for k in e.els() {
if k.name == "w:headerReference" || k.name == "w:footerReference" {
let footer = k.name == "w:footerReference";
if self.stories >= MAX_STORIES {
continue;
}
let Some(path) = k.attr("r:id").and_then(|id| rels.by_id(id)).filter(|r| !r.external).map(|r| r.target.clone()) else { continue };
let Some(root) = self.pkg.xml(&path).ok().flatten() else { continue };
self.stories += 1;
let story_rels = self.pkg.rels(&path);
let ty = match k.attr("w:type") {
Some("even") => "even",
Some("first") => "first",
_ => "odd",
};
let name = if footer { "w:ftr" } else { "w:hdr" };
let was = std::mem::replace(&mut self.in_note, true);
let kids = self.kids(&root, &story_rels, name, depth + 1);
self.in_note = was;
let mut story = plain(name, kids);
story.attrs.push(("w:type".into(), ty.into()));
stories.push(Node::El(story));
} else {
rest.extend(self.node(k, rels, "w:sectPr", depth + 1));
}
}
stories.extend(rest);
El { name: "w:sectPr".into(), attrs: Vec::new(), kids: stories }
}
/// `w:fonts/w:defaultFonts` from the document defaults.
fn default_fonts(&self) -> Option<El> {
let f = self.styles_part.as_ref()?.child("w:docDefaults")?.child("w:rPrDefault")?.child("w:rPr")?.child("w:rFonts")?;
let a = attrs(f);
(!a.is_empty()).then(|| plain("w:fonts", vec![Node::El(El { name: "w:defaultFonts".into(), attrs: a, kids: Vec::new() })]))
}
/// `w:styles`: the styles, with the document defaults (other than fonts) folded into the
/// default paragraph style, as 2003 has no `docDefaults`.
fn styles(&mut self) -> El {
let Some(root) = self.styles_part.clone() else { return plain("w:styles", Vec::new()) };
let defaults = root.child("w:docDefaults");
let default_of = |part: &str, block: &str| -> Vec<El> {
defaults
.and_then(|d| d.child(part))
.and_then(|p| p.child(block))
.map(|b| b.els().filter(|e| e.name != "w:rFonts").cloned().collect())
.unwrap_or_default()
};
let (def_r, def_p) = (default_of("w:rPrDefault", "w:rPr"), default_of("w:pPrDefault", "w:pPr"));
let rels = self.doc_rels.clone();
let mut kids = Vec::new();
for st in root.children("w:style") {
let mut st = st.clone();
if st.attr("w:type") == Some("paragraph") && matches!(st.attr("w:default"), Some("1" | "true" | "on")) {
fold(&mut st, "w:pPr", &def_p);
fold(&mut st, "w:rPr", &def_r);
}
kids.extend(self.node(&st, &rels, "w:styles", 2));
}
plain("w:styles", kids)
}
/// `w:lists`: `w:abstractNum` → `w:listDef`, `w:num` → `w:list`.
fn lists(&mut self) -> Option<El> {
let root = self.numbering_part.clone()?;
let rels = self.doc_rels.clone();
let mut kids = Vec::new();
for abs in root.children("w:abstractNum") {
let Some(id) = abs.attr("w:abstractNumId") else { continue };
let mut def = El { name: "w:listDef".into(), attrs: vec![("w:listDefId".into(), id.to_string())], kids: Vec::new() };
for k in abs.els() {
match k.name.as_str() {
"w:nsid" => def.kids.push(Node::El(attr_el("w:lsid", &[("w:val", k.attr("w:val").unwrap_or(""))]))),
"w:multiLevelType" => {
let v = match k.attr("w:val").unwrap_or("") {
"singleLevel" => "SingleLevel",
"hybridMultilevel" => "HybridMultilevel",
_ => "Multilevel",
};
def.kids.push(Node::El(attr_el("w:plt", &[("w:val", v)])));
}
"w:numStyleLink" => def.kids.push(Node::El(attr_el("w:listStyleLink", &[("w:val", k.attr("w:val").unwrap_or(""))]))),
"w:lvl" => def.kids.extend(self.node(k, &rels, "w:abstractNum", 3)),
_ => {}
}
}
kids.push(Node::El(def));
}
for num in root.children("w:num") {
let (Some(id), Some(abs)) = (num.attr("w:numId"), num.child_val("w:abstractNumId")) else { continue };
let mut list = El { name: "w:list".into(), attrs: vec![("w:ilfo".into(), id.to_string())], kids: Vec::new() };
list.kids.push(Node::El(attr_el("w:ilst", &[("w:val", abs)])));
for o in num.children("w:lvlOverride") {
list.kids.extend(self.node(o, &rels, "w:num", 3));
}
kids.push(Node::El(list));
}
(!kids.is_empty()).then(|| plain("w:lists", kids))
}
}
/// Add the properties of `defaults` that block `name` of `style` doesn't set (at its start).
fn fold(style: &mut El, name: &str, defaults: &[El]) {
if defaults.is_empty() {
return;
}
let pos = style.kids.iter().position(|n| matches!(n, Node::El(e) if e.name == name));
let idx = match pos {
Some(i) => i,
None => {
// Schema order within a style: name, basedOn, next, link, … then pPr, rPr.
let at = if name == "w:pPr" {
style
.kids
.iter()
.position(|n| matches!(n, Node::El(e) if e.name == "w:rPr" || e.name.starts_with("w:tbl") || e.name.starts_with("w:t")))
} else {
style.kids.iter().position(|n| matches!(n, Node::El(e) if e.name.starts_with("w:tbl") || e.name.starts_with("w:t")))
}
.unwrap_or(style.kids.len());
style.kids.insert(at, Node::El(plain(name, Vec::new())));
at
}
};
if let Some(Node::El(block)) = style.kids.get_mut(idx) {
let have: Vec<String> = block.els().map(|e| e.name.clone()).collect();
let mut add: Vec<Node> = defaults.iter().filter(|d| !have.contains(&d.name)).cloned().map(Node::El).collect();
add.append(&mut block.kids);
block.kids = add;
}
}
/// `w:numPr` (`w:ilvl`, `w:numId`) → `w:listPr` (`w:ilvl`, `w:ilfo`).
fn list_pr(e: &El) -> El {
let mut kids = Vec::new();
if let Some(v) = e.child_val("w:ilvl") {
kids.push(Node::El(attr_el("w:ilvl", &[("w:val", v)])));
}
if let Some(v) = e.child_val("w:numId") {
kids.push(Node::El(attr_el("w:ilfo", &[("w:val", v)])));
}
plain("w:listPr", kids)
}
/// `w:numFmt` → `w:nfc` (a number format code).
fn nfc(e: &El) -> El {
let code = match e.attr("w:val").unwrap_or("decimal") {
"upperRoman" => "1",
"lowerRoman" => "2",
"upperLetter" => "3",
"lowerLetter" => "4",
"ordinal" => "5",
"cardinalText" => "6",
"ordinalText" => "7",
"chicago" => "9",
"decimalFullWidth" => "14",
"decimalHalfWidth" => "15",
"decimalEnclosedCircle" => "18",
"decimalZero" => "22",
"bullet" => "23",
"ganada" => "24",
"chosung" => "25",
"decimalEnclosedFullstop" => "26",
"decimalEnclosedParen" => "27",
"none" => "255",
_ => "0",
};
attr_el("w:nfc", &[("w:val", code)])
}
/// A number without a pointless fraction (`36`, `10.5`).
fn trim_num(v: f64) -> String {
let s = format!("{v:.2}");
s.trim_end_matches('0').trim_end_matches('.').to_string()
}
/// Is this element written? WordprocessingML elements of the 2003 vocabulary, and the VML a
/// picture needs; everything else (DrawingML, `w14`, math, markup compatibility…) is not.
fn allowed(name: &str) -> bool {
match name.split_once(':') {
Some(("w", local)) => allowed_w(local),
Some(("v" | "o" | "w10", _)) => true,
_ => false,
}
}
fn allowed_w(local: &str) -> bool {
matches!(
local,
"p" | "pPr"
| "r"
| "rPr"
| "t"
| "tab"
| "br"
| "cr"
| "noBreakHyphen"
| "softHyphen"
| "sym"
| "pgNum"
| "delText"
| "instrText"
| "fldChar"
| "fldSimple"
| "footnoteRef"
| "endnoteRef"
| "annotationRef"
| "separator"
| "continuationSeparator"
| "tbl"
| "tblPr"
| "tblGrid"
| "gridCol"
| "tr"
| "trPr"
| "tc"
| "tcPr"
| "sectPr"
| "hdr"
| "ftr"
| "pStyle"
| "keepNext"
| "keepLines"
| "pageBreakBefore"
| "framePr"
| "widowControl"
| "suppressLineNumbers"
| "pBdr"
| "shd"
| "tabs"
| "suppressAutoHyphens"
| "kinsoku"
| "wordWrap"
| "overflowPunct"
| "topLinePunct"
| "autoSpaceDE"
| "autoSpaceDN"
| "bidi"
| "adjustRightInd"
| "snapToGrid"
| "spacing"
| "ind"
| "contextualSpacing"
| "mirrorIndents"
| "textAlignment"
| "textDirection"
| "textboxTightWrap"
| "outlineLvl"
| "jc"
| "divId"
| "top"
| "left"
| "bottom"
| "right"
| "between"
| "bar"
| "insideH"
| "insideV"
| "tl2br"
| "tr2bl"
| "rStyle"
| "rFonts"
| "b"
| "bCs"
| "i"
| "iCs"
| "caps"
| "smallCaps"
| "strike"
| "dstrike"
| "outline"
| "shadow"
| "emboss"
| "imprint"
| "noProof"
| "vanish"
| "webHidden"
| "color"
| "w"
| "kern"
| "position"
| "sz"
| "szCs"
| "highlight"
| "u"
| "effect"
| "bdr"
| "fitText"
| "vertAlign"
| "rtl"
| "cs"
| "em"
| "lang"
| "eastAsianLayout"
| "specVanish"
| "tblStyle"
| "tblpPr"
| "tblOverlap"
| "bidiVisual"
| "tblStyleRowBandSize"
| "tblStyleColBandSize"
| "tblW"
| "tblCellSpacing"
| "tblInd"
| "tblBorders"
| "tblLayout"
| "tblCellMar"
| "cantSplit"
| "trHeight"
| "tblHeader"
| "gridBefore"
| "gridAfter"
| "wBefore"
| "wAfter"
| "hidden"
| "tcW"
| "gridSpan"
| "hMerge"
| "vMerge"
| "tcBorders"
| "noWrap"
| "tcMar"
| "tcFitText"
| "vAlign"
| "pgSz"
| "pgMar"
| "paperSrc"
| "pgBorders"
| "lnNumType"
| "pgNumType"
| "cols"
| "col"
| "formProt"
| "noEndnote"
| "titlePg"
| "rtlGutter"
| "docGrid"
| "type"
| "style"
| "name"
| "aliases"
| "basedOn"
| "next"
| "link"
| "autoRedefine"
| "semiHidden"
| "qFormat"
| "locked"
| "tblStylePr"
| "lvl"
| "start"
| "lvlRestart"
| "isLgl"
| "suff"
| "lvlText"
| "legacy"
| "lvlJc"
| "lvlOverride"
| "startOverride"
| "ilvl"
| "binData"
)
}
/// The 2003 spelling of an ECMA-376 name: `w:szCs` → `w:sz-cs`, `w:eastAsia` → `w:fareast`,
/// `w:vMerge` → `w:vmerge`. Names outside `w:` are kept.
fn rename(name: &str) -> String {
let Some(local) = name.strip_prefix("w:") else { return name.to_string() };
let new = match local {
"eastAsia" => "fareast",
"vMerge" => "vmerge",
"hMerge" => "hmerge",
"bCs" => "b-cs",
"iCs" => "i-cs",
"szCs" => "sz-cs",
"hAnsi" => "h-ansi",
"firstLine" => "first-line",
"lineRule" => "line-rule",
"hRule" => "h-rule",
"linePitch" => "line-pitch",
"firstLineChars" => "first-line-chars",
"hangingChars" => "hanging-chars",
"leftChars" => "left-chars",
"rightChars" => "right-chars",
other => other,
};
format!("w:{new}")
}
/// Elements whose `w:val` is an on/off switch (2003 writes `on` / `off`).
fn is_toggle(local: &str) -> bool {
matches!(
local,
"b" | "bCs"
| "i"
| "iCs"
| "caps"
| "smallCaps"
| "strike"
| "dstrike"
| "outline"
| "shadow"
| "emboss"
| "imprint"
| "noProof"
| "snapToGrid"
| "vanish"
| "webHidden"
| "keepNext"
| "keepLines"
| "pageBreakBefore"
| "widowControl"
| "suppressLineNumbers"
| "suppressAutoHyphens"
| "wordWrap"
| "contextualSpacing"
| "mirrorIndents"
| "rtl"
| "cs"
| "specVanish"
| "tblHeader"
| "cantSplit"
| "noWrap"
| "hidden"
| "semiHidden"
| "qFormat"
| "locked"
| "tcFitText"
| "isLgl"
| "bidi"
| "overflowPunct"
| "kinsoku"
| "autoSpaceDE"
| "autoSpaceDN"
| "adjustRightInd"
| "topLinePunct"
| "titlePg"
)
}
/// The attributes of `e` that 2003 has, renamed: theme references, revision ids and attributes of
/// other vocabularies (`w14:`, `r:`…) are dropped.
fn attrs(e: &El) -> Vec<(String, String)> {
let local_el = e.name.strip_prefix("w:").unwrap_or("");
let vml = e.name.starts_with("v:") || e.name.starts_with("o:") || e.name.starts_with("w10:");
let mut out = Vec::new();
for (k, v) in &e.attrs {
let key = match k.split_once(':') {
Some(("w", l)) => {
let lower = l.to_ascii_lowercase();
if lower.starts_with("rsid") || lower.contains("theme") {
continue;
}
rename(k)
}
Some(("xml", "space")) => k.clone(),
Some(("v" | "o" | "w10", _)) if vml => k.clone(),
None if vml => k.clone(),
_ => continue,
};
let mut val = v.clone();
if (key == "w:val" && is_toggle(local_el)) || ((key == "w:default" || key == "w:customStyle") && e.name == "w:style") {
val = switch(&val);
}
out.push((key, val));
}
out
}
/// An on/off value in 2003's spelling.
fn switch(v: &str) -> String {
match v {
"1" | "true" | "on" => "on".into(),
"0" | "false" | "off" => "off".into(),
other => other.into(),
}
}
+181
View File
@@ -0,0 +1,181 @@
//! Saving as Word 2003 XML (#1081): documents built in code, written as WordprocessingML 2003
//! and read back with the 2003 reader.
use std::sync::Arc;
use wordcraft_doc::numbering::ListKind;
use wordcraft_doc::para::NoteKind;
use wordcraft_doc::props::{Align, CharProps, NumRef, Rgb, TextColor};
use wordcraft_doc::table::{Cell, Table};
use wordcraft_doc::{Block, Comment, Document, InlineObject, Paragraph, PartKind, Revision, RevisionKind, para_block};
use wordcraft_docx::{XmlKind, read_xml, sniff_xml, write_wordml2003};
fn tiny_png() -> Vec<u8> {
let img = image::RgbaImage::from_fn(3, 2, |x, y| image::Rgba([x as u8 * 80, y as u8 * 120, 200, 255]));
let mut out = std::io::Cursor::new(Vec::new());
img.write_to(&mut out, image::ImageFormat::Png).unwrap();
out.into_inner()
}
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 sample() -> Document {
let mut d = Document::new();
d.core.title = "Garden <notes> & more".into();
d.core.creator = "Ada".into();
let key = d.add_media(tiny_png(), "png");
let num = d.numbering.add_list(ListKind::Numbered);
let head = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Head", CharProps::default()))]);
let foot = d.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("Sown in May.", CharProps::default()))]);
let com = d.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text("Too short?", CharProps::default()))]);
d.comments.insert(3, Comment { author: "Bo".into(), part: com, ..Default::default() });
d.revisions.push(Revision { kind: RevisionKind::Insert, author: "Ada".into(), date: String::new(), move_name: None });
d.last_section.headers.default = Some(head);
let mut p = Paragraph::new();
for (t, c) in [
("Plain ", CharProps::default()),
(
"loud",
CharProps { bold: Some(true), italic: Some(true), size: Some(14.0), color: Some(TextColor::Rgb(Rgb(255, 0, 0))), ..Default::default() },
),
(" link", CharProps { link: Some("https://example.org/beans?a=1&b=2".into()), ..Default::default() }),
] {
let off = p.len();
p.insert_text(off, t, &c).unwrap();
}
p.props.align = Some(Align::Center);
p.props.indent_left = Some(36.0);
p.props.space_after = Some(12.0);
p.insert_object(0, InlineObject::CommentStart { id: 3 }, &CharProps::default()).unwrap();
let end = p.len();
p.insert_object(end, InlineObject::CommentEnd { id: 3 }, &CharProps::default()).unwrap();
let at = p.text.find("loud").unwrap();
p.insert_object(at, InlineObject::NoteRef { kind: NoteKind::Footnote, id: foot, custom: String::new() }, &CharProps::default()).unwrap();
let mut blocks = vec![para_block(p)];
let mut li = Paragraph::with_text("listed", CharProps { ins: Some(0), ..Default::default() });
li.props.numbering = Some(NumRef { num, level: 0 });
blocks.push(para_block(li));
let mut img = Paragraph::new();
img.insert_object(
0,
InlineObject::Image { media: key, w: 30.0, h: 20.0, alt: "dot".into(), float: Default::default(), crop: [0.0; 4], ole: None },
&CharProps::default(),
)
.unwrap();
blocks.push(para_block(img));
let mut t = Table::new(2, 2, 300.0);
t.rows[0].cells[0] = Cell::with_text("A1");
t.rows[0].cells[1] = Cell::with_text("B1");
t.rows[1].cells[0].props.shading = Some(Rgb(0, 128, 0));
blocks.push(Arc::new(Block::Table(t)));
blocks.push(para_block(Paragraph::new()));
d.body = blocks;
d
}
#[test]
fn round_trips_through_the_2003_reader() {
let d = sample();
let xml = write_wordml2003(&d).expect("write");
let text = std::str::from_utf8(&xml).expect("utf-8");
assert!(text.contains("<w:wordDocument") && text.contains("http://schemas.microsoft.com/office/word/2003/wordml"));
assert!(text.contains("<w:binData") && text.contains("<w:hlink w:dest=\"https://example.org/beans?a=1&amp;b=2\""));
assert!(text.contains("<w:footnote>") && text.contains("<wx:sect>") && text.contains("<aml:annotation"));
assert!(!text.contains("w:szCs") && !text.contains("<w:drawing"));
if std::env::var_os("WML2003_DUMP").is_some() {
println!("{text}");
}
assert_eq!(sniff_xml(&xml), XmlKind::WordMl2003);
let back = read_xml(&xml).expect("read");
assert_eq!(back.core.title, "Garden <notes> & more");
assert_eq!(back.core.creator, "Ada");
let ps = paras(&back);
assert_eq!(ps[0].plain_text(), "Plain loud link");
assert_eq!(ps[0].props.align, Some(Align::Center));
assert_eq!(ps[0].props.indent_left, Some(36.0));
assert_eq!(ps[0].props.space_after, Some(12.0));
let c = ps[0].props_of_char(ps[0].text.find("loud").unwrap());
assert_eq!((c.bold, c.italic, c.size), (Some(true), Some(true), Some(14.0)));
assert!(c.color.is_some());
assert_eq!(ps[0].props_at(ps[0].text.find("link").unwrap()).link.as_deref(), Some("https://example.org/beans?a=1&b=2"));
let note = ps[0].objects.iter().find_map(|o| match o {
InlineObject::NoteRef { id, .. } => Some(*id),
_ => None,
});
assert_eq!(part_text(&back, note), "Sown in May.");
let comment = back.comments.values().next().expect("comment");
assert_eq!((comment.author.as_str(), part_text(&back, Some(comment.part)).as_str()), ("Bo", "Too short?"));
let listed = ps[1];
assert!(listed.props.numbering.is_some(), "list paragraph keeps its list");
let ins = listed.props_of_char(0).ins.and_then(|i| back.revisions.get(i as usize));
assert_eq!(ins.map(|r| r.author.as_str()), Some("Ada"));
let pic = ps.iter().flat_map(|p| p.objects.iter()).find(|o| matches!(o, InlineObject::Image { .. }));
assert!(matches!(pic, Some(InlineObject::Image { w, h, alt, .. }) if *w == 30.0 && *h == 20.0 && alt == "dot"));
assert_eq!(back.media.len(), 1);
let table = back.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"));
assert_eq!(table.rows[1].cells[0].props.shading, Some(Rgb(0, 128, 0)));
assert_eq!(part_text(&back, back.last_section.headers.default), "Head");
}
/// Features 2003 XML can't carry drop out without an error, and the text stays.
#[test]
fn control_characters_and_unsupported_content_do_not_break_the_file() {
let mut d = Document::new();
let mut p = Paragraph::with_text("a\u{1}b\u{FFFE}c <&> \"q\"", CharProps { size: Some(f32::NAN), ..Default::default() });
p.insert_object(
0,
InlineObject::Opaque { format: "docx".into(), xml: "<w:r><w:t>raw</w:t></w:r>".into(), text: "raw".into() },
&CharProps::default(),
)
.unwrap();
d.body = vec![para_block(p)];
let xml = write_wordml2003(&d).expect("write");
assert!(read_xml(&xml).is_ok());
let text = String::from_utf8(xml).expect("utf-8");
assert!(!text.contains('\u{1}') && !text.contains('\u{FFFE}'));
assert!(!text.contains("<w:drawing") && !text.contains("<mc:"));
}
#[test]
fn big_and_deep_documents_write() {
let mut d = Document::new();
d.body = vec![para_block(Paragraph::with_text(&"word ".repeat(400_000), CharProps::default()))];
let xml = write_wordml2003(&d).expect("write");
assert!(read_xml(&xml).is_ok());
// Tables nested deeper than the writers go: no panic, whatever comes out.
let mut inner = Cell::with_text("deep");
for _ in 0..60 {
let mut t = Table::new(1, 1, 100.0);
t.rows[0].cells[0] = inner;
inner = Cell::empty();
inner.blocks = vec![Arc::new(Block::Table(t)), para_block(Paragraph::new())];
}
let mut d = Document::new();
d.body = inner.blocks;
if let Ok(xml) = write_wordml2003(&d) {
let _ = read_xml(&xml);
}
}
+42 -5
View File
@@ -7,6 +7,11 @@ use super::{pos_json, sel_result};
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
/// Longest user name kept, in characters; longer names are cut, not refused.
/// `format` of the save commands for an `.xml` file: Word 2003 XML Document (WordprocessingML 2003).
pub const FORMAT_WORD_2003: &str = "wordml2003";
/// `format` for an `.xml` file as a Word XML Document (Flat OPC), the default.
pub const FORMAT_FLAT_OPC: &str = "flatopc";
pub const MAX_AUTHOR_CHARS: usize = 255;
/// Sets the user name that new tracked changes and comments are recorded under. The name is
@@ -35,9 +40,9 @@ pub fn specs() -> Vec<CommandSpec> {
.pure(),
CommandSpec::new("file.save", "Save", "File", save)
.key("Mod+S / Shift+F12")
.params(r#"{"path"?: string, "password"?: string (encrypt this and later saves with it, as file.encrypt does), "markup"?: bool (Print Markup for a PDF, PNG, HTML, Markdown or text copy: comments shown; default file.printMarkup)}"#)
.params(r#"{"path"?: string, "password"?: string (encrypt this and later saves with it, as file.encrypt does), "markup"?: bool (Print Markup for a PDF, PNG, HTML, Markdown or text copy: comments shown; default file.printMarkup), "format"?: "wordml2003" (an .xml file as a Word 2003 XML Document) | "flatopc" (as a Word XML Document, the default; saving over a Word 2003 XML file keeps that format)}"#)
.pure(),
CommandSpec::new("file.saveAs", "Save As", "File", save_as).key("F12").params(r#"{"path": string, "password"?: string, "markup"?: bool (as file.save)}"#).pure(),
CommandSpec::new("file.saveAs", "Save As", "File", save_as).key("F12").params(r#"{"path": string, "password"?: string, "markup"?: bool, "format"?: string (as file.save)}"#).pure(),
// Pure: the password must not land in Repeat or a recorded macro, and it isn't an edit
// to undo; it marks the document as changed itself.
CommandSpec::new("file.encrypt", "Encrypt with Password", "File › Info › Protect Document", encrypt)
@@ -199,11 +204,22 @@ fn save(s: &mut Session, v: &Value) -> CmdResult {
wordcraft_docx::check_password(pw).map_err(|e| CmdError::Params(e.to_string()))?;
s.password = Some(crate::io::Password::new(pw));
}
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
let mut opts = export_options(s, v);
match v.get("format").filter(|f| !f.is_null()) {
None => {
// Saving over a Word 2003 XML file (one opened as such, say) keeps its format.
opts.word2003 = ext == "xml" && is_word_2003_file(&path);
}
Some(f) if matches!(f.as_str(), Some(FORMAT_WORD_2003 | FORMAT_FLAT_OPC)) && ext == "xml" => {}
Some(_) => {
return Err(CmdError::Params(format!("`format` is \"{FORMAT_WORD_2003}\" or \"{FORMAT_FLAT_OPC}\", and only for an .xml file")));
}
}
s.stamp_save();
let password = s.password.as_ref().map(crate::io::Password::as_str);
crate::io::save_path_with(&path, &s.doc, password, &export_options(s, v)).map_err(CmdError::Failed)?;
crate::io::save_path_with(&path, &s.doc, password, &opts).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", "xml"].contains(&ext.as_str()) {
s.path = Some(path.clone());
s.dirty = false;
@@ -211,6 +227,27 @@ fn save(s: &mut Session, v: &Value) -> CmdResult {
Ok(json!({"saved": true, "path": path.to_string_lossy(), "encrypted": encrypted}))
}
/// Does `path` hold a Word 2003 XML Document already (so a plain save keeps that format)?
#[cfg(not(target_arch = "wasm32"))]
fn is_word_2003_file(path: &std::path::Path) -> bool {
use std::io::Read;
if crate::file_access::check_read(&path.to_string_lossy()).is_err() {
return false;
}
let Ok(f) = std::fs::File::open(path) else { return false };
let mut head = Vec::new();
// The root element and its namespaces come first.
if f.take(64 * 1024).read_to_end(&mut head).is_err() {
return false;
}
wordcraft_docx::sniff_xml(&head) == wordcraft_docx::XmlKind::WordMl2003
}
#[cfg(target_arch = "wasm32")]
fn is_word_2003_file(_path: &std::path::Path) -> bool {
false
}
fn save_as(s: &mut Session, v: &Value) -> CmdResult {
if p::str(v, "path").is_none() {
s.ui_requests.push(json!({"open": "saveAs"}));
@@ -223,7 +260,7 @@ fn save_as(s: &mut Session, v: &Value) -> CmdResult {
/// setting (`file.printMarkup`); comments as Track Changes Options show them.
pub fn export_options(s: &Session, v: &Value) -> crate::io::ExportOptions {
let markup = p::bool(v, "markup").unwrap_or(s.prefs.print_markup);
crate::io::ExportOptions { markup, comments: markup && s.prefs.markup.comments }
crate::io::ExportOptions { markup, comments: markup && s.prefs.markup.comments, word2003: p::str(v, "format") == Some(FORMAT_WORD_2003) }
}
fn export_png(s: &mut Session, v: &Value) -> CmdResult {
+3
View File
@@ -51,6 +51,9 @@ pub struct ExportOptions {
pub markup: bool,
/// With `markup`, show the comments (Track Changes Options › Comments).
pub comments: bool,
/// An `.xml` copy is a Word 2003 XML Document (WordprocessingML 2003) instead of a Word XML
/// Document (Flat OPC): `format: "wordml2003"` on the save commands.
pub word2003: bool,
}
/// A document password: wiped from memory when dropped, never printed (`Debug` shows `***`).
+1
View File
@@ -26,6 +26,7 @@ pub fn save(ext: &str, doc: &Document, opts: &crate::io::ExportOptions) -> Optio
return Some(wordcraft_docx::write_as(doc, flavor).map_err(|e| e.to_string()));
}
match ext {
"xml" if opts.word2003 => Some(wordcraft_docx::write_wordml2003(doc).map_err(|e| e.to_string())),
"xml" => Some(wordcraft_docx::write_flat(doc).map_err(|e| e.to_string())),
"png" => Some(render_png(doc, 0, 2.0, opts)),
"pdf" => {
+27
View File
@@ -3558,6 +3558,33 @@ fn word_xml_saves_and_opens_through_io() {
assert!(err.contains("not a Word XML document"), "{err}");
}
/// #1081: `format: "wordml2003"` saves an `.xml` file as a Word 2003 XML Document, a plain save
/// over such a file keeps that format, and `format: "flatopc"` converts it back.
#[test]
fn word_2003_xml_saves_through_the_save_commands() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Kept for old readers"}));
let dir = std::env::temp_dir().join(format!("wordcraft-2003-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("old.xml").to_string_lossy().to_string();
let kind = |p: &str| wordcraft_docx::sniff_xml(&std::fs::read(p).unwrap());
run(&mut s, "file.saveAs", json!({"path": path, "format": "wordml2003"}));
assert_eq!(kind(&path), wordcraft_docx::XmlKind::WordMl2003);
assert!(crate::io::open_path(std::path::Path::new(&path)).unwrap().plain_text(wordcraft_doc::StoryRef::Body).contains("Kept for old readers"));
run(&mut s, "file.save", json!({}));
assert_eq!(kind(&path), wordcraft_docx::XmlKind::WordMl2003);
run(&mut s, "file.save", json!({"path": path, "format": "flatopc"}));
assert_eq!(kind(&path), wordcraft_docx::XmlKind::FlatOpc);
for bad in [
json!({"path": dir.join("a.docx").to_string_lossy(), "format": "wordml2003"}),
json!({"path": path, "format": "bogus"}),
json!({"path": path, "format": 7}),
] {
assert!(s.run("file.save", &bad).is_err(), "{bad}");
}
let _ = std::fs::remove_dir_all(&dir);
}
/// #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]
+9 -2
View File
@@ -215,7 +215,13 @@ impl WordApp {
}
AfterPick::Export { ext } => {
let Some(path) = picked else { return Ok(json!({"cancelled": true})) };
let r = if ext == "png" { self.run("file.exportPng", json!({"path": path})) } else { self.run("file.saveAs", json!({"path": path})) };
let r = if ext == "png" {
self.run("file.exportPng", json!({"path": path}))
} else if ext == crate::print_page::EXPORT_WORD_2003 {
self.run("file.saveAs", json!({"path": path, "format": ext}))
} else {
self.run("file.saveAs", json!({"path": path}))
};
if r.is_ok() {
self.status(crate::i18n::fmt(tl!("Exported {path}"), &[("path", &path)]));
}
@@ -272,7 +278,8 @@ impl WordApp {
/// File › Export: ask where to save a copy in this format, then write it.
pub(crate) fn export_dialog(&mut self, ext: &str) {
let name = format!("{}.{ext}", self.title_stem());
let file_ext = if ext == crate::print_page::EXPORT_WORD_2003 { "xml" } else { ext };
let name = format!("{}.{file_ext}", self.title_stem());
let _ = self.ask_file(FileDialogRequest::Save { name }, AfterPick::Export { ext: ext.to_string() });
}
+1
View File
@@ -2158,6 +2158,7 @@
Use the Insert key to switch overtype on and off Използване на клавиша Insert за включване и изключване на заместването
Extend Selection by Units Разширяване на селекцията по единици
Word XML Document (*.xml) XML документ на Word (*.xml)
Word 2003 XML Document (*.xml) XML документ на Word 2003 (*.xml)
Deleted Изтрито
Print Markup Печат на маркировките
Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Коментари до всяка страница и проследени промени в PDF файлове, разпечатки и изображения; списък с коментари в края при уеб страници, Markdown и текст.
Can't render this file because it contains an unexpected character in line 7 and column 25.
+1
View File
@@ -1876,6 +1876,7 @@
Animate the caret (it glides and fades) Animovat kurzor (plynule se přesouvá a prolíná)
On: the caret glides to its new place when you type or use the arrow keys, and its blink fades. Off: it jumps and blinks on and off. Zapnuto: kurzor se při psaní nebo použití kláves se šipkami plynule přesune na nové místo a jeho blikání se prolíná. Vypnuto: kurzor přeskakuje a blikání se ostře zapíná a vypíná.
Word XML Document (*.xml) Dokument Word XML (*.xml)
Word 2003 XML Document (*.xml) Dokument Word 2003 XML (*.xml)
Deleted Odstraněno
Resize Table by Dragging Změnit velikost tabulky přetažením
Select Column Vybrat sloupec
Can't render this file because it contains an unexpected character in line 6 and column 25.
+1
View File
@@ -1459,6 +1459,7 @@
Animate the caret (it glides and fades) Einfügemarke animieren (sie gleitet und blendet aus)
On: the caret glides to its new place when you type or use the arrow keys, and its blink fades. Off: it jumps and blinks on and off. Ein: Die Einfügemarke gleitet beim Tippen oder mit den Pfeiltasten an ihre neue Stelle, und ihr Blinken blendet weich. Aus: Sie springt und blinkt hart an und aus.
Word XML Document (*.xml) Word-XML-Dokument (*.xml)
Word 2003 XML Document (*.xml) Word-2003-XML-Dokument (*.xml)
Choose the sheet that holds the recipients. Wählen Sie das Tabellenblatt mit den Empfängern.
First row of data contains column headers Erste Datenzeile enthält Spaltenüberschriften
Deleted Gelöscht
Can't render this file because it contains an unexpected character in line 6 and column 25.
+1
View File
@@ -999,6 +999,7 @@
Word Right Palabra a la derecha
Word document (*.docx) Documento de Word (*.docx)
Word XML Document (*.xml) Documento XML de Word (*.xml)
Word 2003 XML Document (*.xml) Documento XML de Word 2003 (*.xml)
WordArt WordArt
WordCraft WordCraft
WordCraft is now the default app for Word documents. WordCraft es ahora la aplicación predeterminada para los documentos de Word.
Can't render this file because it contains an unexpected character in line 6 and column 25.
+1
View File
@@ -877,6 +877,7 @@
Word Right Sõna paremale
Word document (*.docx) Wordi dokument (*.docx)
Word XML Document (*.xml) Wordi XML-dokument (*.xml)
Word 2003 XML Document (*.xml) Word 2003 XML-dokument (*.xml)
WordArt WordArt
WordCraft WordCraft
Words Sõnad
Can't render this file because it contains an unexpected character in line 459 and column 13.
+1
View File
@@ -1908,6 +1908,7 @@
Animate the caret (it glides and fades) Animer le curseur (il glisse et s’estompe)
On: the caret glides to its new place when you type or use the arrow keys, and its blink fades. Off: it jumps and blinks on and off. Activé : le curseur glisse jusqu’à sa nouvelle position lorsque vous tapez ou utilisez les touches fléchées, et son clignotement s’estompe en douceur. Désactivé : il saute et clignote de façon nette.
Word XML Document (*.xml) Document XML Word (*.xml)
Word 2003 XML Document (*.xml) Document XML Word 2003 (*.xml)
Deleted Supprimé
Resize Table by Dragging Redimensionner le tableau en le faisant glisser
Print Markup Imprimer les marques de révision
Can't render this file because it contains an unexpected character in line 7 and column 25.
+1
View File
@@ -2317,6 +2317,7 @@
Previous Page Előző oldal
Print to PDF Nyomtatás PDF-be
Word XML Document (*.xml) Word XML-dokumentum (*.xml)
Word 2003 XML Document (*.xml) Word 2003 XML-dokumentum (*.xml)
Use wildcards Helyettesítő karakterek használata
Special Különleges
Match prefix Egyezés a szó elején
Can't render this file because it contains an unexpected character in line 6 and column 25.
+1
View File
@@ -1534,6 +1534,7 @@
Use the Insert key to switch overtype on and off Usa il tasto Ins per attivare e disattivare la sovrascrittura
Extend Selection by Units Estendi selezione per unità
Word XML Document (*.xml) Documento Word XML (*.xml)
Word 2003 XML Document (*.xml) Documento Word 2003 XML (*.xml)
Deleted Eliminato
Print Markup Stampa revisioni
Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Commenti accanto a ogni pagina e revisioni in PDF, stampe e immagini; un elenco dei commenti alla fine di pagine web, Markdown e testo.
Can't render this file because it contains an unexpected character in line 6 and column 25.
+1
View File
@@ -950,6 +950,7 @@
Word Count 文字カウント
Word document (*.docx) Word 文書 (*.docx)
Word XML Document (*.xml) Word XML ドキュメント (*.xml)
Word 2003 XML Document (*.xml) Word 2003 XML ドキュメント (*.xml)
Word Left 前の単語へ
Word Right 次の単語へ
WordArt ワードアート
Can't render this file because it contains an unexpected character in line 563 and column 13.
+1
View File
@@ -2052,6 +2052,7 @@
White space Odstępy
With: Na:
Word XML Document (*.xml) Dokument XML programu Word (*.xml)
Word 2003 XML Document (*.xml) Dokument XML programu Word 2003 (*.xml)
Word document Dokument programu Word
Word level Poziom wyrazów
WordArt Styles Style WordArt
Can't render this file because it contains an unexpected character in line 6 and column 25.
+1
View File
@@ -950,6 +950,7 @@
Word Count Contagem de palavras
Word document (*.docx) Documento Word (*.docx)
Word XML Document (*.xml) Documento XML do Word (*.xml)
Word 2003 XML Document (*.xml) Documento XML do Word 2003 (*.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.
+1
View File
@@ -2202,6 +2202,7 @@ preview Title Пример
Print to PDF Печать в PDF
# Word XML Document (#548).
Word XML Document (*.xml) Документ Word XML (*.xml)
Word 2003 XML Document (*.xml) Документ Word 2003 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
@@ -1010,6 +1010,7 @@
Word Count Broj reči
Word document (*.docx) Word dokument (*.docx)
Word XML Document (*.xml) Word XML dokument (*.xml)
Word 2003 XML Document (*.xml) Word 2003 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
@@ -1014,6 +1014,7 @@
Word Count Број речи
Word document (*.docx) Word документ (*.docx)
Word XML Document (*.xml) Word XML документ (*.xml)
Word 2003 XML Document (*.xml) Word 2003 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.
+1
View File
@@ -2201,6 +2201,7 @@ preview Title Örnek
Print to PDF PDF'ye yazdır
# Word XML Document (#548).
Word XML Document (*.xml) Word XML Belgesi (*.xml)
Word 2003 XML Document (*.xml) Word 2003 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 439 and column 13.
+1
View File
@@ -978,6 +978,7 @@
Word Count Кількість слів
Word document (*.docx) Документ Word (*.docx)
Word XML Document (*.xml) Документ Word XML (*.xml)
Word 2003 XML Document (*.xml) Документ Word 2003 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
@@ -962,6 +962,7 @@
Word Count 字数统计
Word document (*.docx) Word 文档 (*.docx)
Word XML Document (*.xml) Word XML 文档 (*.xml)
Word 2003 XML Document (*.xml) Word 2003 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
@@ -951,6 +951,7 @@
Word Count 字數統計
Word document (*.docx) Word 文件 (*.docx)
Word XML Document (*.xml) Word XML 文件 (*.xml)
Word 2003 XML Document (*.xml) Word 2003 XML 文件 (*.xml)
Word Left 左移一個單字
Word Right 右移一個單字
WordArt 文字藝術師
Can't render this file because it contains an unexpected character in line 564 and column 13.
+4
View File
@@ -233,12 +233,16 @@ fn preview(app: &mut WordApp, ui: &mut Ui, lay: &DocLayout, total: usize) {
});
}
/// The export "format" of a Word 2003 XML Document: an `.xml` file saved with `format: "wordml2003"`.
pub(crate) const EXPORT_WORD_2003: &str = "wordml2003";
/// The "save a copy" formats (File › Export, and below the settings on the Print page).
pub fn export_formats(app: &mut WordApp, ui: &mut Ui) {
for (label, ext) in [
("PDF document (*.pdf)", "pdf"),
("Word document (*.docx)", "docx"),
("Word XML Document (*.xml)", "xml"),
("Word 2003 XML Document (*.xml)", EXPORT_WORD_2003),
("OpenDocument Text (*.odt)", "odt"),
("Rich Text Format (*.rtf)", "rtf"),
("Web page (*.html)", "html"),
+3 -2
View File
@@ -1,6 +1,6 @@
# File-format parity with Microsoft Word
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** trivial (direct run-level InkML content parts now survive DOCX saves; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; 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-11 · **Last updated:** 2026-10-12 · **Change:** trivial (saving Word 2003 XML Document, #1081; previously trivial: direct run-level InkML content parts now survive DOCX saves; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; 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;
@@ -31,7 +31,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅
| `.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. The same reader takes HTML from the system clipboard: Paste keeps another program's formatting (a Word document, a web page; Windows' "HTML Format" header understood), Merge Formatting keeps its emphasis (#687) |
| `.mht/.mhtml` Single File Web Page | <1% | R/W | ❌ | ❌ | 0% | 3–5 | — | |
| `.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) |
| `.xml` Word 2003 XML (WordprocessingML 2003) | <1% | R/W | 🟡 | 🟡 | ~55% | 1–2 | `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. Written (#1081) by rewriting the DOCX writer's package into the 2003 vocabulary (`crates/docx/src/wordml2003_write.rs`, `crates/docx/tests/wordml2003_write.rs`): core properties, styles (document defaults folded into Normal), lists, text and paragraph/character formatting, tables, hyperlinks, footnotes/endnotes inline, headers/footers in the section properties, bookmarks, comments and tracked insertions/deletions as `aml:annotation`, section page setup, and PNG/JPEG/GIF/BMP/EMF/WMF pictures as `w:pict` with `w:binData`. **Dropped on write:** shapes, text boxes, groups, charts, SmartArt, ink, equations, OLE objects, picture crop, rotation and floating position (pictures become inline), content-control wrappers (their content stays), formatting-change tracking, theme colours/fonts, custom XML, macros, even/odd header setting. File › Export › Word 2003 XML Document, `file.save`/`file.saveAs` with `format: "wordml2003"`, `wordcraft-cli convert … --format wordml2003`; a plain save over a 2003 file keeps that format. Never opened in Word yet (written from the published schemas; needs a tester) |
| `.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 |
@@ -177,6 +177,7 @@ raise readiness estimates. No Word-derived files are fixtures.
| 2026-10-12 | trivial | DOCX: `w:hideMark` on table cells read and written |
| 2026-10-12 | trivial | DOCX text frames (`w:framePr` other than drop caps) are read and written back; they were dropped on save |
| 2026-10-11 | trivial | `.doc`: inline and floating pictures (BLIP store, PNG/JPEG/DIB/TIFF/EMF/WMF), basic shapes, groups and text boxes read from OfficeArt drawings (#551); fidelity ~55% → ~60% |
| 2026-10-12 | trivial | Word 2003 XML Document (WordprocessingML 2003) written (#1081): Export, `format: "wordml2003"`, CLI `--format`; not yet checked in Word |
| 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) |
+3 -2
View File
@@ -1,6 +1,6 @@
# Where WordCraft falls short of Word
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** trivial (direct run-level native InkML content parts now survive DOCX saves; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; 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-12 · **Change:** trivial (saving Word 2003 XML, #1081; previously trivial: direct run-level native InkML content parts now survive DOCX saves; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; 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
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
@@ -40,7 +40,7 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr
| 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**: 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) |
| 26 | **Minor formats**: MHT, Works/WordPerfect | | Word XML Document (Flat OPC) opens and saves, Word 2003 XML opens (#548) and saves (#1081, not yet checked in Word) | Rare | 14–26 | [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) |
@@ -88,6 +88,7 @@ The same gaps grouped the way the parity documents are, for agents working in on
| 2026-10-11 | trivial | Table column and row borders and the resize handle drag on the page (#217); UI/UX gap narrowed to the ruler's column markers |
| 2026-10-11 | trivial | UI/UX gaps list: the system's animation setting is now read on macOS and the Linux/BSD desktops; trying it on real machines remains |
| 2026-10-11 | trivial | Animation effects setting landed (File › Options › Accessibility); UI/UX gaps list: the system's animation setting isn't read on macOS or Linux/BSD yet |
| 2026-10-12 | trivial | Saving Word 2003 XML Document landed (#1081); gap #26 narrowed to 14–26 h |
| 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-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, collation, duplex and grayscale; the upstream preview, ranges, parity and Print Markup remain available, along with browser printing and PDF export. Remaining print parity includes driver-specific tray/options and pages-per-sheet UI; physical printer fidelity needs hardware validation. |
| 2026-10-11 | trivial | #13 narrowed: Insert › SmartArt with eight layouts, Text Pane and SmartArt Design landed (#497); 25–40 h left for charts |