Shapes in Strict Open XML documents open (#3548) (#5697)

A Strict document opened with its text and no shapes at all. Strict puts
shapes, groups and canvases (wsp, wgp, wpc) and their parts in its one
WordprocessingDrawing namespace, which the reader maps to wp:, while the
drawing reader looks for Word 2010's wps:, wpg: and wpc: names. Word also
writes a Strict text box's text in wne:txbxContent.

The XML reader now names those elements as Transitional documents do,
from the element and its parent (xml::element_prefix), and reads a text
box's content from wne:txbxContent and from the schema's own
txbxContent. Saving writes them as Transitional.


Claude-Session: https://claude.ai/code/session_01LvG6E1XTfHtZDbfYwfREhR

Co-authored-by: Filip Strajnar <filip.strajnar@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
This commit is contained in:
authored and GitHub committed 2026-11-08 04:08:36 +01:00
1 parent 0f5a701171
commit caeb87c754
5 files changed
+272 -4

No files matched your search

+2
View File
@@ -173,6 +173,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
- 2026-11-05: Linux: a WordCraft started by an AppImage app no longer takes that app's file for its own (#5653). The AppImage runtime's `APPIMAGE`, `APPDIR`, `ARGV0` and `OWD` pass to whatever the app starts, so an installed WordCraft opened through `xdg-open` saw another program's `APPIMAGE`, and its self-update replaced that program's file. The app now starts over without those variables unless its executable is under `APPDIR`, so the update goes through the package or plain file it really is. Should starting over fail, the self-update is off in that window and Help › Check for Updates says why. Readiness estimates unchanged.
- 2026-11-05: A Word template (`.dotx`, `.dotm`, `.dot`) opened from the file manager, the command line or the Finder starts a new, untitled document based on it, as Word's default verb for a template does (#3433). It opened the template itself, so Save (and AutoSave) wrote over it. The new document is attached to the template and Save asks where to write; a protected template asks for its password and still starts a new document. File › Open and a file dropped on the window still open a template itself, to edit it.
- 2026-11-05: Arabic letters join across a change of formatting (gap 16, #4093). Each run was shaped on its own, so a letter at a run's edge took the form of a word's start or end: a coloured letter stood apart, and a word edited with Track Changes on read with a space in it (ال ذكاء). Each run is now shaped knowing the text shown on either side, past hidden or deleted text left out of the view, as for Syriac, Mongolian and the other joining scripts. Readiness estimates unchanged.
- 2026-11-05: Shapes, text boxes, groups and drawing canvases in Strict Open XML documents open (#3548). Strict puts them in its one drawing namespace, where Transitional documents use Word 2010's `wps`, `wpg` and `wpc`, and Word writes a Strict text box's text in `wne:txbxContent`; the reader knew neither, so a Strict file opened with its text and no shapes at all. They now read like their Transitional twins and are saved as Transitional. Charts and SmartArt in Strict namespaces are not mapped yet.
- 2026-10-28: Ink replay, eraser picking, lasso selection and arrow-candidate discovery reuse the paragraph prefixes they have already scanned (#3614). Page item order, stroke geometry and saved data retain their behavior. This narrows the stability work; ink import fidelity, native pen input and broader corpus qualification remain open.
- 2026-10-29: Page drawing finds text-box warps without rescanning the paragraph for every box (#3614). Only requested offsets are indexed, and each paragraph walk stops after its last requested character. Original Linux discovery, complete display-output and CLI/MCP save/reopen controls; native Word comparison untested. Readiness estimates unchanged.
- 2026-10-28: Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged. Existing mutation and protection checks retain their costs; this does not close the broader performance gap.
@@ -829,6 +830,7 @@ MATH glyphs that reference the same construction now share its immutable variant
> 2026-11-05 · Linux self-update ignores another program's AppImage variables (#5653); recorded in the progress log, readiness unchanged.
> 2026-11-05 · A template opened from the file manager starts a new document instead of being edited and saved over (#3433); recorded in the progress log, readiness unchanged.
> 2026-11-05 · Arabic letters join across a change of formatting and past hidden or deleted text (#4093); recorded in the progress log, readiness unchanged.
> 2026-11-05 · Shapes, text boxes, groups and canvases in Strict Open XML documents open (#3548); recorded in the progress log, readiness unchanged.
>
> 2026-11-03 · Arabic dialogs read right to left (rtl::Table); recorded in the progress log, readiness unchanged.
>
+83 -4
View File
@@ -94,6 +94,49 @@ fn prefix_for(uri: &[u8], extra: &[(&'static str, &str)]) -> &'static str {
"?"
}
/// Word's 2006 namespace (`wne`). A Strict document from Word keeps a text box's content in
/// `wne:txbxContent`; a Transitional one uses `w:txbxContent`.
const WNE: &[u8] = b"http://schemas.microsoft.com/office/word/2006/wordml";
/// The canonical prefix of an element named `local` in namespace `uri`, inside an element
/// named `parent` (canonical, `None` at the root). This is [`prefix_for`] with two exceptions
/// for Strict documents (#3548):
///
/// - Shapes, groups and canvases (`wsp`, `wgp`, `wpc`) and their parts are elements of the
/// WordprocessingDrawing namespace in ECMA-376 Part 1, 5th edition (`dml-wordprocessingDrawing.xsd`,
/// Strict and Transitional alike). A Strict document from Word puts them there; a Transitional
/// one uses Word 2010's own namespaces, whose names the reader knows. So they read as `wps:`,
/// `wpg:` and `wpc:` (see [`drawing_prefix`]).
/// - A shape's text box content reads as `w:txbxContent` from the drawing namespace (the
/// schema's name) and from Word's `wne:txbxContent`.
fn element_prefix(uri: &[u8], local: &str, parent: Option<&str>, extra: &[(&'static str, &str)]) -> &'static str {
match prefix_for(uri, extra) {
"wp" => drawing_prefix(local, parent),
"?" if uri == WNE && local == "txbxContent" && parent == Some("wps:txbx") => "w",
p => p,
}
}
/// The prefix for `local`, an element of the WordprocessingDrawing namespace inside `parent`.
/// Word 2010's namespaces split what this one holds: a shape and its parts are `wps:`, a group
/// (`wgp`, a nested `grpSp`) and a graphic frame in it are `wpg:`, and a canvas is `wpc:`. Its
/// members are named for what they are. A text box's content is `w:txbxContent`, and anything
/// else (`inline`, `anchor`, `docPr`…) stays `wp:`.
fn drawing_prefix(local: &str, parent: Option<&str>) -> &'static str {
match (parent, local) {
(_, "wsp") => "wps",
(_, "wgp" | "grpSp" | "graphicFrame") => "wpg",
(_, "wpc") => "wpc",
(Some("wps:wsp"), "cNvPr" | "cNvSpPr" | "cNvCnPr" | "spPr" | "style" | "extLst" | "txbx" | "linkedTxbx" | "bodyPr") => "wps",
(Some("wps:txbx"), "txbxContent") => "w",
(Some("wps:txbx" | "wps:linkedTxbx"), "extLst") => "wps",
(Some("wpg:wgp" | "wpg:grpSp"), "cNvPr" | "cNvGrpSpPr" | "grpSpPr" | "extLst") => "wpg",
(Some("wpg:graphicFrame"), "cNvPr" | "cNvFrPr" | "xfrm" | "extLst") => "wpg",
(Some("wpc:wpc"), "bg" | "whole" | "extLst") => "wpc",
_ => "wp",
}
}
/// Compatibility feature namespaces whose elements are not otherwise handled by this tree.
/// Keep the reader's existing feature set when canonicalizing `mc:Choice/@Requires` aliases.
fn requirement_prefix(uri: &[u8]) -> &'static str {
@@ -342,8 +385,8 @@ fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Res
loop {
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(), extra)),
let el_ns = match &res {
ResolveResult::Bound(ns) => Some(ns.as_ref()),
_ => None,
};
match ev {
@@ -356,8 +399,8 @@ fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Res
return Err(DocxError::Limit("too many XML elements".into()));
}
let local = String::from_utf8_lossy(e.local_name().as_ref()).into_owned();
let name = match el_prefix {
Some(p) => format!("{p}:{local}"),
let name = match el_ns {
Some(ns) => format!("{}:{local}", element_prefix(ns, &local, stack.last().map(|p| p.name.as_str()), extra)),
None => local,
};
let mut el = El { name, attrs: Vec::new(), kids: Vec::new() };
@@ -593,6 +636,42 @@ mod tests {
assert!(parse(b"<a></a><b></b>").is_ok(), "lenient parsing still stops at the root");
}
/// Every element name in `e`, depth first.
fn names(e: &El, out: &mut Vec<String>) {
out.push(e.name.clone());
for c in e.els() {
names(c, out);
}
}
#[test]
fn strict_drawing_names_read_as_word_2010_names() {
// A Strict shape with a text box, a group with a nested group and a graphic frame, and
// a canvas, each in the one Strict WordprocessingDrawing namespace (#3548).
let xml = br#"<w:body xmlns:w="http://purl.oclc.org/ooxml/wordprocessingml/main" xmlns:a="http://purl.oclc.org/ooxml/drawingml/main" xmlns:d="http://purl.oclc.org/ooxml/drawingml/wordprocessingDrawing" xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml">
<d:anchor><d:extent/><d:docPr/><d:cNvGraphicFramePr/><a:graphic><a:graphicData>
<d:wsp><d:cNvPr/><d:cNvSpPr/><d:spPr/><d:style/><d:extLst/><d:txbx><wne:txbxContent><w:p/></wne:txbxContent></d:txbx><d:bodyPr/></d:wsp>
<d:wgp><d:cNvPr/><d:cNvGrpSpPr/><d:grpSpPr/><d:grpSp><d:cNvGrpSpPr/><d:grpSpPr/><d:wsp><d:cNvCnPr/><d:spPr/><d:linkedTxbx/><d:bodyPr/></d:wsp></d:grpSp>
<d:graphicFrame><d:cNvPr/><d:cNvFrPr/><d:xfrm/><a:graphic/><d:extLst/></d:graphicFrame><d:extLst/></d:wgp>
<d:wpc><d:bg/><d:whole/><d:wsp><d:spPr/><d:txbx><d:txbxContent/><d:extLst/></d:txbx></d:wsp><d:wgp><d:grpSpPr/></d:wgp><d:extLst/></d:wpc>
</a:graphicData></a:graphic></d:anchor>
<wne:txbxContent/><d:inline><d:docPr/><d:bodyPr/><d:bg/></d:inline></w:body>"#;
let mut got = Vec::new();
names(&parse(xml).unwrap(), &mut got);
let want = "w:body wp:anchor wp:extent wp:docPr wp:cNvGraphicFramePr a:graphic a:graphicData \
wps:wsp wps:cNvPr wps:cNvSpPr wps:spPr wps:style wps:extLst wps:txbx w:txbxContent w:p wps:bodyPr \
wpg:wgp wpg:cNvPr wpg:cNvGrpSpPr wpg:grpSpPr wpg:grpSp wpg:cNvGrpSpPr wpg:grpSpPr wps:wsp wps:cNvCnPr wps:spPr wps:linkedTxbx wps:bodyPr \
wpg:graphicFrame wpg:cNvPr wpg:cNvFrPr wpg:xfrm a:graphic wpg:extLst wpg:extLst \
wpc:wpc wpc:bg wpc:whole wps:wsp wps:spPr wps:txbx w:txbxContent wps:extLst wpg:wgp wpg:grpSpPr wpc:extLst \
?:txbxContent wp:inline wp:docPr wp:bodyPr wp:bg";
assert_eq!(got.join(" "), want);
// The Transitional namespaces read as before.
let xml = br#"<w:body xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml"><wp:inline><wp:docPr/></wp:inline><wps:wsp><wps:txbx><w:txbxContent/></wps:txbx></wps:wsp><wne:toolbars/></w:body>"#;
let mut got = Vec::new();
names(&parse(xml).unwrap(), &mut got);
assert_eq!(got.join(" "), "w:body wp:inline wp:docPr wps:wsp wps:txbx w:txbxContent ?:toolbars");
}
#[test]
fn a_relationship_attribute_is_the_namespaced_one_or_the_alias_of_one() {
// The relationship namespace, and VML's `o:relid`.
+1
View File
@@ -35,6 +35,7 @@ mod section_page_number_type;
mod section_text_direction;
mod section_valign;
mod settings_merge;
mod strict_drawings;
mod text_alignment;
mod text_boxes;
mod toc_ranges;
+182
View File
@@ -0,0 +1,182 @@
//! Shapes, text boxes, groups and canvases in a Strict document (#3548). ISO/IEC 29500 Strict
//! puts them in the one WordprocessingDrawing namespace, where Transitional documents use Word
//! 2010's `wps`, `wpg` and `wpc` namespaces. Fixtures are original and hand-written from
//! ECMA-376 Part 1, 5th edition (`dml-wordprocessingDrawing.xsd`, Strict and Transitional): each
//! drawing is written once and filled in with either set of namespaces.
use std::io::{Read, Write};
use wordcraft_doc::{Document, InlineObject, PartKind};
const STRICT_NS: &str = r#"xmlns:w="http://purl.oclc.org/ooxml/wordprocessingml/main" xmlns:r="http://purl.oclc.org/ooxml/officeDocument/relationships" xmlns:wp="http://purl.oclc.org/ooxml/drawingml/wordprocessingDrawing" xmlns:a="http://purl.oclc.org/ooxml/drawingml/main" xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml" w:conformance="strict""#;
const TRANSITIONAL_NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:wpg="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup" xmlns:wpc="http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas""#;
const STRICT_RELS: &str = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://purl.oclc.org/ooxml/officeDocument/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
const TRANSITIONAL_RELS: &str = r#"<?xml version="1.0" encoding="UTF-8"?><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>"#;
/// How a fixture names its drawing elements: the shape, group and canvas prefixes, and the
/// text box content element.
struct Names {
ns: &'static str,
rels: &'static str,
shape: &'static str,
group: &'static str,
canvas: &'static str,
content: &'static str,
}
/// Word's Strict output: everything in `wp:`, text box content in `wne:txbxContent`.
const STRICT_WORD: Names = Names { ns: STRICT_NS, rels: STRICT_RELS, shape: "wp", group: "wp", canvas: "wp", content: "wne:txbxContent" };
/// The Strict schema's own names: the text box content in `wp:` too.
const STRICT_SCHEMA: Names = Names { ns: STRICT_NS, rels: STRICT_RELS, shape: "wp", group: "wp", canvas: "wp", content: "wp:txbxContent" };
/// The same drawings in a Transitional document.
const TRANSITIONAL: Names =
Names { ns: TRANSITIONAL_NS, rels: TRANSITIONAL_RELS, shape: "wps", group: "wpg", canvas: "wpc", content: "w:txbxContent" };
fn zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in entries {
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(bytes).unwrap();
}
zw.finish().unwrap().into_inner()
}
fn document_xml(bytes: &[u8]) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut s = String::new();
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
s
}
fn solid(rgb: &str) -> String {
format!(r#"<a:solidFill><a:srgbClr val="{rgb}"/></a:solidFill>"#)
}
/// A `wsp` at (`x`, `y`) EMUs in its group, `cx` × `cy`. `text` gives it a text box.
fn shape(n: &Names, prst: &str, (x, y, cx, cy): (u32, u32, u32, u32), fill: &str, text: Option<&str>) -> String {
let s = n.shape;
let connector = prst.contains("Connector");
let nv = if connector {
format!("<{s}:cNvCnPr/>")
} else if text.is_some() {
format!(r#"<{s}:cNvSpPr txBox="1"/>"#)
} else {
format!("<{s}:cNvSpPr/>")
};
let fill = if connector { String::new() } else { solid(fill) };
let line =
if connector { r#"<a:ln w="25400"><a:solidFill><a:srgbClr val="117A65"/></a:solidFill><a:tailEnd type="triangle"/></a:ln>"# } else { "" };
let txbx = match text {
Some(t) => {
let c = n.content;
format!(r#"<{s}:txbx><{c}><w:p><w:r><w:t>{t}</w:t></w:r></w:p></{c}></{s}:txbx>"#)
}
None => String::new(),
};
format!(
r#"<{s}:wsp><{s}:cNvPr id="{cx}" name="{prst}"/>{nv}<{s}:spPr><a:xfrm><a:off x="{x}" y="{y}"/><a:ext cx="{cx}" cy="{cy}"/></a:xfrm><a:prstGeom prst="{prst}"><a:avLst/></a:prstGeom>{fill}{line}</{s}:spPr>{txbx}<{s}:bodyPr anchor="ctr"/></{s}:wsp>"#
)
}
/// Drawing `id`, anchored `x` EMUs from the page's left edge, 1 in × ½ in.
fn anchor(id: u32, x: u32, uri: &str, payload: &str) -> String {
format!(
r#"<w:r><w:drawing><wp:anchor distT="0" distB="0" distL="0" distR="0" simplePos="0" relativeHeight="{id}" behindDoc="0" locked="0" layoutInCell="1" allowOverlap="1"><wp:simplePos x="0" y="0"/><wp:positionH relativeFrom="page"><wp:posOffset>{x}</wp:posOffset></wp:positionH><wp:positionV relativeFrom="page"><wp:posOffset>914400</wp:posOffset></wp:positionV><wp:extent cx="914400" cy="457200"/><wp:effectExtent l="0" t="0" r="0" b="0"/><wp:wrapNone/><wp:docPr id="{id}" name="Drawing {id}"/><wp:cNvGraphicFramePr/><a:graphic><a:graphicData uri="{uri}">{payload}</a:graphicData></a:graphic></wp:anchor></w:drawing></w:r>"#
)
}
/// A rectangle, a text box, an arrow, a group holding a nested group, and a canvas holding a
/// shape and a group, each 1 in × ½ in.
fn package(n: &Names) -> Vec<u8> {
const WPS: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingShape";
const WPG: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingGroup";
const WPC: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas";
let full = (0, 0, 914_400, 457_200);
let half = (0, 0, 457_200, 457_200);
let g = n.group;
let group_xfrm = |(x, y, cx, cy): (u32, u32, u32, u32)| {
format!(
r#"<{g}:cNvGrpSpPr/><{g}:grpSpPr><a:xfrm><a:off x="{x}" y="{y}"/><a:ext cx="{cx}" cy="{cy}"/><a:chOff x="0" y="0"/><a:chExt cx="{cx}" cy="{cy}"/></a:xfrm>{}</{g}:grpSpPr>"#,
solid("F39C12")
)
};
let group = format!(
"<{g}:wgp>{}{}<{g}:grpSp>{}{}</{g}:grpSp></{g}:wgp>",
group_xfrm(full),
shape(n, "ellipse", half, "28B463", None),
group_xfrm((457_200, 0, 457_200, 457_200)),
shape(n, "triangle", half, "8E44AD", None),
);
let c = n.canvas;
let canvas = format!(
r#"<{c}:wpc><{c}:bg>{}</{c}:bg><{c}:whole><a:ln w="12700">{}</a:ln></{c}:whole>{}<{g}:wgp>{}{}</{g}:wgp></{c}:wpc>"#,
solid("FDF2E9"),
solid("A04000"),
shape(n, "roundRect", (45_720, 45_720, 365_760, 365_760), "16A085", None),
group_xfrm((457_200, 45_720, 365_760, 365_760)),
shape(n, "diamond", (0, 0, 365_760, 365_760), "2E86C1", None),
);
let drawings = [
anchor(1, 914_400, WPS, &shape(n, "rect", full, "C0392B", None)),
anchor(2, 2_286_000, WPS, &shape(n, "rect", full, "EAF2F8", Some("Strict text box"))),
anchor(3, 3_657_600, WPS, &shape(n, "straightConnector1", full, "", None)),
anchor(4, 5_029_200, WPG, &group),
anchor(5, 6_400_800, WPC, &canvas),
];
let doc = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document {}><w:body><w:p><w:r><w:t>Shapes</w:t></w:r>{}</w:p><w:sectPr><w:pgSz w:w="12240" w:h="15840"/></w:sectPr></w:body></w:document>"#,
n.ns,
drawings.concat()
);
zip(&[("_rels/.rels", n.rels.as_bytes()), ("word/document.xml", doc.as_bytes())])
}
fn objects(d: &Document) -> Vec<InlineObject> {
d.body[0].as_para().unwrap().objects.clone()
}
/// The text of each text box story, in reading order.
fn box_texts(d: &Document) -> Vec<String> {
let mut ids = Vec::new();
d.objects_in_reading_order(&mut |o| ids.extend(o.text_boxes()));
ids.iter()
.filter_map(|id| d.parts.get(id).filter(|p| p.kind == PartKind::TextBox))
.map(|p| p.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect::<Vec<_>>().join("\n"))
.collect()
}
#[test]
fn strict_shapes_read_like_their_transitional_twins() {
let transitional = wordcraft_docx::read(&package(&TRANSITIONAL)).unwrap();
let want = objects(&transitional);
// The twin itself reads every drawing: a rectangle, a text box, an arrow, a group whose
// nested group is flattened into it, and a canvas with its background.
assert_eq!(want.len(), 5, "{want:#?}");
assert!(matches!(&want[0], InlineObject::Shape { story: None, freeform: None, .. }), "{:?}", want[0]);
assert!(matches!(&want[1], InlineObject::Shape { story: Some(_), .. }), "{:?}", want[1]);
assert!(matches!(&want[3], InlineObject::Group { children, canvas: None, .. } if children.len() == 2), "{:?}", want[3]);
assert!(
matches!(&want[4], InlineObject::Group { children, canvas: Some(c), .. } if children.len() == 2 && c.fill.is_some() && c.stroke.is_some()),
"{:?}",
want[4]
);
assert_eq!(box_texts(&transitional), ["Strict text box"]);
for (name, names) in [("Word's Strict names", &STRICT_WORD), ("the Strict schema's names", &STRICT_SCHEMA)] {
let strict = wordcraft_docx::read(&package(names)).unwrap();
assert_eq!(objects(&strict), want, "{name}");
assert_eq!(box_texts(&strict), ["Strict text box"], "{name}");
}
}
#[test]
fn strict_shapes_are_saved_and_reopened() {
let strict = wordcraft_docx::read(&package(&STRICT_WORD)).unwrap();
let saved = wordcraft_docx::write(&strict).unwrap();
let xml = document_xml(&saved);
for name in ["<wps:wsp>", "<wpg:wgp>", "<wpc:wpc>", "<wpc:bg>", "<w:txbxContent>", "<wps:cNvCnPr"] {
assert!(xml.contains(name), "{name} missing from the saved document");
}
let reopened = wordcraft_docx::read(&saved).unwrap();
assert_eq!(objects(&reopened), objects(&strict));
assert_eq!(box_texts(&reopened), ["Strict text box"]);
}
+4
View File
@@ -1,5 +1,6 @@
# File-format parity with Microsoft Word
> **2026-11-05:** A Word template opened from the file manager starts a new document based on it, and is never saved over (#3433).
> **2026-11-05:** Strict Open XML documents open with their shapes, text boxes, groups and canvases (#3548).
> **2026-11-05:** DOCX paragraph `w:adjustRightInd` keeps its default and explicit values through style resolution and schema-ordered repeated saves.
@@ -71,6 +72,8 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅
| `.docx` Word Document (ECMA-376 Transitional) | 70% | R/W | ✅ | ✅ | ~65% | 70–110 | `crates/docx/tests` (roundtrip 34, fixtures 29, malformed 8, bidi 5, bidi_runs 12, citations 5); synthetic fixtures only | Word opens our files. Untested on a real-world corpus. Tracked changes inside a field result stay revisions (the field is kept as a range, #1037). Details below |
| `.docx` Strict Open XML | 1% | R/W | ✅ (namespace mapping in `xml.rs`) | ❌ | ~60% | 2–4 | `strict_namespace_and_bad_numbers`, `strict_line_heights_with_a_unit_are_read_as_point_multiples` | Writing Strict is rare; unit-suffixed measures (`13.90pt`) read as points (#2329) |
| `.docm` / `.dotx` / `.dotm` | 3% | R/W | ✅ | ✅ | ~65% | incl. above | `macro_packages.rs`, `a_template_the_platform_hands_the_app_starts_a_new_document` | Macros and signatures kept on save (#172); macros never run. A template (`.dotx`, `.dotm`, `.dot`) the platform hands the app (file manager, command line, Finder) starts a new, untitled document attached to it, as in Word; File › Open edits the template itself (#3433) |
| `.docx` Strict Open XML | 1% | R/W | ✅ (namespace mapping in `xml.rs`) | ❌ | ~60% | 2–4 | `strict_namespace_and_bad_numbers`, `strict_line_heights_with_a_unit_are_read_as_point_multiples`, `strict_drawings` | Writing Strict is rare; unit-suffixed measures (`13.90pt`) read as points (#2329); shapes, text boxes, groups and canvases (`wsp`, `wgp`, `wpc` in the Strict drawing namespace, a text box's `wne:txbxContent` or `txbxContent`) read as their Word 2010 names and are saved as Transitional (#3548). Charts and SmartArt in Strict namespaces are not mapped |
| `.docm` / `.dotx` / `.dotm` | 3% | R/W | ✅ | ✅ | ~65% | incl. above | `macro_packages.rs` | Macros and signatures kept on save (#172); macros never run |
| `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` | Spec-based reader ([MS-DOC]): text, formatting, styles, sections, headers/footers, tables, lists, notes, fields, bookmarks; inline and floating pictures (PNG, JPEG, DIB, TIFF, EMF, WMF; Macintosh PICT as a sized placeholder) with crop, alt text, position and wrap; basic shapes, lines, groups and text boxes ([MS-ODRAW]). Table cell shading from every SHD record (`sprmTDefTableShd`, `sprmTDefTableShdRaw` and their 2nd/3rd ranges, with the Word 97 `Shd80` as fallback; a clear pattern shows its background colour); the row-end mark is not a cell, and each table has its own grid and properties (#1380). Horizontally merged rows stored as fewer, wider cells come with #1123. Freeform shapes, z-order and Word 6/95 files are dropped or refused. Password-protected files (XOR obfuscation, RC4, RC4 CryptoAPI) open after a password prompt, from the public specs; not yet tried on a file Word wrote (AES-encrypted ones are refused). No writer. DIB conversion caps BMP bytes (header included) and encoded PNG at the supplied BLIP byte limit, and uses it as the decoder's allocation limit; a BMP fallback is kept if it fits (related #1334 and #1554). Hostile files are bounded: a sector table longer than the file is refused before parsing (#3642); formatting and link runs are read once per change, not per character (#3692, #3708); copies of alt text, link targets and drawn objects share a 64 MB budget (#3709, #3710), and what it leaves out is logged once a kind per document; picture decoding shares a 512 MB budget and aliased store entries decode once (#3711, #3712). The default fonts are the stylesheet's standard fonts (`rgftcStandardChpStsh` and `ftcBi` in the `Stshi`, [MS-DOC] §2.9.273): Aptos in a Word 365 file, whose font table still starts with Times New Roman; a header too short for them, or an index past the font table, keeps the table's first font (#5066). Runs' East Asian and complex-script fonts (`sprmCRgFtc1`, `sprmCFtcBi`) are read. |
| PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133); Hebrew and Arabic copy and search as whole words (right-to-left glyphs written in visual order, #1383, also with fonts that draw lam-alef as two glyphs at one x, #2310); All Caps and Small Caps text extracts as the capitals shown, as from Word's PDFs, and never changes the case of other text (#1473), links, outline, tagging. Outline destinations follow leading page breaks to the heading text. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. Horizontal and vertical lines inside groups are drawn (#1474). No PDF/A option, no "best for printing/online" choice |
| PDF open (PDF Reflow, Word for Windows) | 1% | R | ❌ | — | 0% | 30–50 | — | Converting a PDF into an editable document. A sibling app (PdfCraft) may supply the parser |
@@ -339,6 +342,7 @@ For field results, the engine parser treats a backslash in quoted arguments as e
| 2026-11-04 | trivial | CLI input paths keep their native filename when a value-taking option comes first; regression coverage includes conversion to Flat OPC and rejection of non-Unicode option values. |
| 2026-11-05 | trivial | `.dotx`/`.dotm`/`.dot`: a template the platform hands the app starts a new, untitled document attached to it, as Word's default verb does; File › Open still edits the template (#3433); fidelity estimate unchanged |
| 2026-11-02 | trivial | DOCX: a picture's or shape's hyperlink is read from its `wp:docPr/a:hlinkClick` (Word's place for it) and written there too, beside the `w:hyperlink` around the run (#5267) |
| 2026-11-05 | trivial | Strict Open XML: shapes, text boxes, groups and canvases in the Strict drawing namespace, and Word's `wne:txbxContent`, read as their Word 2010 names (#3548); fidelity estimate unchanged |
| 2026-11-05 | trivial | DOCX paragraph `w:adjustRightInd` keeps its default and explicit values in schema-ordered repeated saves |
| 2026-11-04 | trivial | REF context update: 2026-11-04 · #4609 retains the label and template actually rendered by an ancestor list instance; implicitly counted levels remain distinct from explicitly rendered paragraphs. Synthetic instance-override and field-update regressions; no new native Word qualification or readiness change. |
| 2026-11-04 | trivial | PR #4609: relative REF keeps context already displayed by the own or a needed ancestor label; six synthetic legal-template cases and separate-list context follow the Word for Windows measurements reported by @pakiat in PR discussion. Existing unknown-switch/cache boundaries and readiness estimates unchanged; no new native Word run. |