DOCX: read VML freeforms (v:shape with a path) as freeform shapes

Legacy VML shapes with their own `path` (ECMA-376 Part 4), such as rule
lines in headers, were dropped on open and lost on save. They are now
read as freeforms: m/t/l/r/c/v/x/e commands in the coordsize/coordorigin
space, nf/ns, v:fill and v:stroke opacity; arcs drawn straight, formula
references read as 0. Saved as DrawingML custom geometry.

Tests: header rule read and round-tripped; hostile paths and coordinate
spaces stay finite and never panic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Mauro Mazzieri
2026-10-13 15:39:34 +02:00
co-authored by Claude Opus 5.5
parent 48bdb571f9
commit b7a6cc70f9
6 changed files with 356 additions and 6 deletions
+3 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (progress log: Word's text watermark read from and written to DOCX, #1290; previously trivial: progress log: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; 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 tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (VML freeforms read and kept on save; previously trivial: progress log: Word's text watermark read from and written to DOCX, #1290; previously trivial: progress log: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; 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 tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
@@ -135,6 +135,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| Date | What landed |
|---|---|
| 2026-10-13 | Legacy VML freeforms (`v:shape` with its own `path`), such as the rule lines in many older headers, are read as freeform shapes: they show on screen and in PDFs and are saved back as DrawingML custom geometry instead of vanishing |
| 2026-10-13 | Saving to DOCX keeps the watermark: it is written as Word's own text-watermark shape in every header, and a document without headers gains one to hold it (#1290) |
| 2026-10-13 | DOCX files with Word's text watermark (Design › Watermark, a VML text shape in the headers) show it on every page on screen and in the PDF (#1290) |
| 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 |
@@ -275,6 +276,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | trivial | Progress log: VML freeforms read and kept on save |
| 2026-10-13 | trivial | Progress log: the watermark written to DOCX (#1290) |
| 2026-10-13 | trivial | Progress log: Word's text watermark read from DOCX (#1290) |
| 2026-10-12 | trivial | Progress log: Accept All and Reject All keep the right paragraph marks (#864) |
+221 -1
View File
@@ -1,4 +1,5 @@
//! Freeform shapes (DrawingML custom geometry, `a:custGeom`) and ink strokes drawn with them.
//! Freeform shapes (DrawingML custom geometry, `a:custGeom`; legacy VML paths) and ink strokes
//! drawn with them.
use wordcraft_doc::freeform::{FreePath, Freeform, InkTool, MAX_PATHS, MAX_POINTS};
@@ -75,6 +76,225 @@ pub fn cust_geom(sppr: &El, w: f32, h: f32) -> Option<Freeform> {
(!f.paths.is_empty()).then_some(f)
}
/// Largest VML path coordinate kept (coordinate-space units).
const MAX_VML: f64 = 1.0e12;
/// Characters of a VML `path` attribute read; the rest is ignored.
const MAX_VML_PATH: usize = 1 << 20;
/// Parameters of a VML path kept, all commands together.
const MAX_VML_PARAMS: usize = MAX_POINTS * 8;
/// A legacy VML freeform (`v:shape` with a `path`, ECMA-376 Part 4 §14.1.2.19).
pub struct VmlPath {
pub freeform: Freeform,
/// `nf` in the path: no fill.
pub no_fill: bool,
/// `ns` in the path: no outline.
pub no_stroke: bool,
}
/// The VML path commands that are two letters long.
const VML_TWO: [&str; 11] = ["nf", "ns", "ae", "al", "at", "ar", "wa", "wr", "qx", "qy", "qb"];
/// The VML `path` of a shape, mapped onto its `w` × `h` frame (points) through its `coordsize`
/// (default `1000,1000`) and `coordorigin` (default `0,0`). Commands: `m`/`t` move (absolute /
/// relative), `l`/`r` line, `c`/`v` cubic Bézier (flattened), `x` close, `e` end, `nf`/`ns` no
/// fill / no outline; arcs (`at`, `ar`, `wa`, `wr`) and quadrants (`qx`, `qy`) are drawn straight
/// to their end, `qb` straight to its last point, and ellipses (`ae`, `al`) are skipped. An empty
/// parameter is 0, and so is a formula reference (`@n`). `None` when the path has no points.
pub fn vml_path(path: &str, coordsize: Option<&str>, coordorigin: Option<&str>, w: f32, h: f32) -> Option<VmlPath> {
let pair = |v: Option<&str>| -> Option<(f64, f64)> {
let v = v?;
let (a, b) = v.split_once(',').unwrap_or((v, ""));
let n = |s: &str| int(s).map(|i| (i as f64).clamp(-MAX_VML, MAX_VML));
Some((n(a)?, n(b).unwrap_or(0.0)))
};
let size = |v: f64| if v > 0.0 { v } else { 1000.0 };
let (cw, ch) = pair(coordsize).map_or((1000.0, 1000.0), |(a, b)| (size(a), size(b)));
let (ox, oy) = pair(coordorigin).unwrap_or((0.0, 0.0));
let (sx, sy) = (w as f64 / cw, h as f64 / ch);
let scale = |[x, y]: [f64; 2]| -> [f32; 2] { [((x - ox) * sx) as f32, ((y - oy) * sy) as f32] };
let mut d = Draw::default();
let (mut no_fill, mut no_stroke) = (false, false);
for (cmd, params) in vml_commands(path) {
if d.full() {
break;
}
let pts: Vec<[f64; 2]> = params.as_chunks::<2>().0.to_vec();
match cmd.as_str() {
"m" | "t" => {
for (i, &p) in pts.iter().enumerate() {
let p = if cmd == "t" { d.rel(p) } else { p };
if i == 0 {
d.move_to(p);
} else {
d.line_to(p);
}
}
}
"l" | "r" | "qx" | "qy" => {
for &p in &pts {
let p = if cmd == "r" { d.rel(p) } else { p };
d.line_to(p);
}
}
"c" | "v" => {
for c in pts.as_chunks::<3>().0 {
let start = d.at;
let ctrl: Vec<[f64; 2]> = std::iter::once(start).chain(c.iter().map(|&p| if cmd == "v" { d.rel(p) } else { p })).collect();
for i in 1..=CURVE_STEPS {
d.line_to(bezier(&ctrl, i as f64 / CURVE_STEPS as f64));
}
}
}
"qb" => {
if let Some(&p) = pts.last() {
d.line_to(p);
}
}
// Bounding box, then start and end: `at`/`wa` draw a line to the start, `ar`/`wr` move there.
"at" | "ar" | "wa" | "wr" => {
for [_, _, s, e] in pts.as_chunks::<4>().0 {
if matches!(cmd.as_str(), "ar" | "wr") {
d.move_to(*s);
} else {
d.line_to(*s);
}
d.line_to(*e);
}
}
"x" => d.end(true),
"e" => d.end(false),
"nf" => no_fill = true,
"ns" => no_stroke = true,
_ => {}
}
}
d.end(false);
let paths = d.done.into_iter().map(|(pts, closed)| FreePath { pts: pts.into_iter().map(scale).collect(), closed }).collect();
let freeform = Freeform { w, h, paths, alpha: 1.0, ink: None, order: None }.sanitized();
(!freeform.paths.is_empty()).then_some(VmlPath { freeform, no_fill, no_stroke })
}
/// The pen while a VML path is drawn: where it is, where the subpath began, the subpath being
/// drawn and those done (closed or not).
#[derive(Default)]
struct Draw {
at: [f64; 2],
start: [f64; 2],
cur: Vec<[f64; 2]>,
done: Vec<(Vec<[f64; 2]>, bool)>,
}
impl Draw {
fn full(&self) -> bool {
self.done.len() >= MAX_PATHS
}
fn rel(&self, [x, y]: [f64; 2]) -> [f64; 2] {
[(self.at[0] + x).clamp(-MAX_VML, MAX_VML), (self.at[1] + y).clamp(-MAX_VML, MAX_VML)]
}
fn move_to(&mut self, p: [f64; 2]) {
self.end(false);
self.at = p;
self.start = p;
self.cur.push(p);
}
fn line_to(&mut self, p: [f64; 2]) {
if self.cur.is_empty() {
self.cur.push(self.at);
self.start = self.at;
}
if self.cur.len() < MAX_POINTS {
self.cur.push(p);
}
self.at = p;
}
/// Finish the subpath being drawn, closed back to its start or not.
fn end(&mut self, closed: bool) {
if !self.cur.is_empty() && !self.full() {
self.done.push((std::mem::take(&mut self.cur), closed));
}
self.cur.clear();
if closed {
self.at = self.start;
}
}
}
/// The commands of a VML path with their parameters: a command is a letter (or one of
/// [`VML_TWO`]) followed by numbers separated by commas or spaces. An empty parameter (nothing
/// before a comma) is 0, and so is a formula reference (`@n`); fractions are dropped. Anything
/// else is skipped.
fn vml_commands(path: &str) -> Vec<(String, Vec<f64>)> {
let mut out: Vec<(String, Vec<f64>)> = Vec::new();
let mut chars = path.chars().take(MAX_VML_PATH).peekable();
// Whether a number was read since the command or the last comma.
let mut has_num = true;
let mut kept = 0usize;
let mut push = |out: &mut Vec<(String, Vec<f64>)>, v: f64| {
if kept < MAX_VML_PARAMS
&& let Some((_, p)) = out.last_mut()
{
p.push(v);
kept += 1;
}
};
while let Some(c) = chars.next() {
if c.is_ascii_alphabetic() {
if !has_num {
push(&mut out, 0.0);
}
has_num = false;
let lower = c.to_ascii_lowercase();
let mut cmd = lower.to_string();
if let Some(&n) = chars.peek() {
let two: String = [lower, n.to_ascii_lowercase()].iter().collect();
if VML_TWO.contains(&two.as_str()) {
chars.next();
cmd = two;
}
}
out.push((cmd, Vec::new()));
} else if c == ',' {
if !has_num {
push(&mut out, 0.0);
}
has_num = false;
} else if matches!(c, '@' | '-' | '+' | '.') || c.is_ascii_digit() {
let mut v = c.to_digit(10).map_or(0.0, f64::from);
let mut frac = c == '.';
while let Some(&n) = chars.peek() {
if n == '.' {
frac = true;
} else if n.is_ascii_digit() {
if !frac {
v = (v * 10.0 + n.to_digit(10).map_or(0.0, f64::from)).min(MAX_VML);
}
} else {
break;
}
chars.next();
}
let v = match c {
'@' => 0.0,
'-' => -v,
_ => v,
};
has_num = true;
push(&mut out, v);
}
// Whitespace and anything else only separate numbers.
}
if !has_num {
push(&mut out, 0.0);
}
out
}
/// The pen of an ink stroke written by WordCraft, from its drawing's name (`Ink Pen 3`,
/// `Ink Highlighter 4`).
pub fn ink_tool(name: &str) -> Option<InkTool> {
+43
View File
@@ -1321,6 +1321,35 @@ impl Reader<'_> {
extra: Default::default(),
});
}
// A freeform: its `path`, in its `coordsize` space; a `v:fill` with no opacity
// or `nf` in the path leaves it unfilled. Not WordArt (`v:textpath`).
if shape.local() == "shape"
&& shape.child("v:textpath").is_none()
&& let Some(path) = shape.attr("path")
&& let Some(vp) = super::freeform::vml_path(path, shape.attr("coordsize"), shape.attr("coordorigin"), w, h)
{
let opacity = |el: &str| shape.child(el).and_then(|e| e.attr("opacity")).and_then(vml_fraction).unwrap_or(1.0);
let fill = fill.filter(|_| !vp.no_fill && opacity("v:fill") > 0.0);
let stroke = stroke.filter(|_| !vp.no_stroke);
if stroke.is_none() && fill.is_none() {
return None;
}
let mut f = vp.freeform;
f.alpha = opacity("v:stroke");
return Some(InlineObject::Shape {
kind: ShapeKind::Freeform,
w,
h,
fill,
stroke,
stroke_width,
float,
story: None,
freeform: Some(Arc::new(f)),
effects: Default::default(),
extra: Default::default(),
});
}
let kind = match shape.local() {
"line" => ShapeKind::Line,
"rect" => ShapeKind::Rectangle,
@@ -1582,6 +1611,20 @@ fn wrap_polygon(e: &El) -> Option<wordcraft_doc::wrap::WrapPolygon> {
wordcraft_doc::wrap::WrapPolygon::new(edited, pts)
}
/// A VML fraction (opacity), 0–1: a decimal (`0.5`), a percentage (`50%`) or 65536ths with an
/// `f` suffix (`32768f`).
fn vml_fraction(v: &str) -> Option<f32> {
let v = v.trim();
let f = if let Some(fd) = v.strip_suffix('f') {
fd.trim().parse::<f32>().ok()? / 65_536.0
} else if let Some(pc) = v.strip_suffix('%') {
pc.trim().parse::<f32>().ok()? / 100.0
} else {
v.parse::<f32>().ok()?
};
f.is_finite().then(|| f.clamp(0.0, 1.0))
}
/// The value of property `key` in a VML `style` attribute (CSS declarations; the last one wins).
fn vml_style<'a>(style: &'a str, key: &str) -> Option<&'a str> {
style.rsplit(';').filter_map(|d| d.split_once(':')).find(|(k, _)| k.trim().eq_ignore_ascii_case(key)).map(|(_, v)| v.trim())
+83
View File
@@ -2079,3 +2079,86 @@ fn text_frames_read_and_round_trip() {
assert_eq!(q[1].props.frame, p[1].props.frame);
assert!(q.iter().any(|x| x.props.drop_cap == Some(2) && x.props.frame.is_none()));
}
/// A legacy VML freeform (`v:shape` with a `path`, ECMA-376 Part 4): a rule across a header,
/// drawn in a 1000 × 1000 coordinate space, outlined, its fill transparent. It is read as a
/// freeform of the shape's size and kept when saved (as DrawingML custom geometry) and read back.
#[test]
fn vml_freeform_rule_in_header() {
let w10 = r#"xmlns:w10="urn:schemas-microsoft-com:office:word""#;
let rule = r##"<w:p><w:r><w:pict><v:shape style="position:absolute;margin-left:0;margin-top:30pt;width:360pt;height:12pt;z-index:2;mso-position-horizontal-relative:margin;mso-position-vertical-relative:margin" coordsize="1000,1000" path="m,500l1000,500xe" fillcolor="this" stroked="t" strokecolor="#3366cc" strokeweight="1.5pt"><v:fill opacity="0"/><w10:wrap anchorx="margin"/></v:shape></w:pict></w:r>
<w:r><w:pict><v:shape style="width:20pt;height:10pt" coordorigin="100,100" coordsize="200,100" path="t100,100r100,0,0,100nfx e" strokecolor="red"><v:stroke opacity="32768f"/></v:shape></w:pict></w:r></w:p>"##;
let header = format!(r#"<w:hdr {W_NS} {w10}><w:p><w:r><w:t>Header</w:t></w:r></w:p>{rule}</w:hdr>"#);
let body = r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rIdH"/></w:sectPr>"#;
let bytes = docx(body, &[("rIdH", "header", "header1.xml")], &[("word/header1.xml", &header)]);
let near = |a: f32, b: f32| (a - b).abs() < 0.05;
let check = |d: &Document| {
let hid = d.last_section.headers.default.unwrap();
let shapes: Vec<&InlineObject> = d.parts.get(&hid).unwrap().blocks.iter().filter_map(|b| b.as_para()).flat_map(|p| &p.objects).collect();
let [rule, tri] = shapes.as_slice() else { panic!("{shapes:?}") };
let InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, freeform: Some(f), .. } = rule else { panic!("{rule:?}") };
assert_eq!((*kind, *fill, *stroke), (ShapeKind::Freeform, None, Some(Rgb(0x33, 0x66, 0xCC))));
assert!(near(*w, 360.0) && near(*h, 12.0) && near(*stroke_width, 1.5), "{w} x {h}, {stroke_width}");
assert_eq!((float.h_rel, float.v_rel, float.wrap), (Anchor::Margin, Anchor::Margin, Wrap::InFrontOfText));
assert!(near(float.y, 30.0), "{float:?}");
let [p] = f.paths.as_slice() else { panic!("{f:?}") };
let pts = f.placed(0.0, 0.0, *w, *h);
let [(pts, _)] = pts.as_slice() else { panic!("{pts:?}") };
assert!(p.closed && pts.len() == 2, "{pts:?}");
assert!(near(pts[0][0], 0.0) && near(pts[0][1], 6.0) && near(pts[1][0], 360.0) && near(pts[1][1], 6.0), "{pts:?}");
// Relative moves and lines from the origin, `nf`: an outlined triangle, half opaque.
let InlineObject::Shape { kind: ShapeKind::Freeform, fill: None, stroke: Some(Rgb(255, 0, 0)), w, h, freeform: Some(f), .. } = tri else {
panic!("{tri:?}")
};
let placed = f.placed(0.0, 0.0, *w, *h);
let [(pts, true)] = placed.as_slice() else { panic!("{placed:?}") };
let want = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]];
assert!(pts.len() == 3 && pts.iter().zip(want).all(|(a, b)| near(a[0], b[0]) && near(a[1], b[1])), "{pts:?}");
assert!(near(f.alpha, 0.5), "{}", f.alpha);
};
let d = wordcraft_docx::read(&bytes).unwrap();
check(&d);
check(&wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap());
}
/// Hostile VML paths and coordinate spaces read without panicking and stay finite.
#[test]
fn hostile_vml_paths_stay_finite() {
let paths = [
"m,",
"l",
"mxe",
"m99999999999999999999999999,0l-99999999999999999999,1e",
"minf,nanlNaN,-inf",
"m@1,@2l@3,@4nfnse",
"wa1",
"c1",
"m١٢,٣l٤,٥",
"m0,0c",
"m0,0v1,2,3",
"ar0,0,10,10,0,0,10,10at",
"m0,0r1,1,1,1,1,1,1,1,1,1x x x l",
"--..,,@@ ,,, xm1.5.5,2..3l+-+,-e",
"\u{1F600}m1,1l2,2",
"qbqxqyaeal1,2,3,4,5,6",
];
let spaces = [("0,0", "0,0"), ("-5,-5", "x,y"), ("1e39,x", "-99999999999999999999,99999999999999999999"), ("", ""), ("1,1", "1e308,1e308")];
for path in paths {
for (size, origin) in spaces {
let body = format!(
r#"<w:p><w:r><w:pict><v:shape style="width:1e30pt;height:-4pt" coordsize="{size}" coordorigin="{origin}" path="{path}" strokecolor="black"/></w:pict></w:r></w:p>"#
);
let d = read_body(&body);
for o in &paras(&d)[0].objects {
if let InlineObject::Shape { w, h, freeform: Some(f), .. } = o {
assert!(w.is_finite() && h.is_finite() && f.alpha.is_finite(), "{path}");
assert!(f.paths.iter().flat_map(|p| &p.pts).all(|[x, y]| x.is_finite() && y.is_finite()), "{path} {size} {origin}");
for (pts, _) in f.placed(0.0, 0.0, *w, *h) {
assert!(pts.iter().all(|[x, y]| x.is_finite() && y.is_finite()), "{path}");
}
}
}
wordcraft_docx::write(&d).unwrap();
}
}
}
+3 -2
View File
@@ -1,6 +1,6 @@
# File-format parity with Microsoft Word
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** trivial (Word's text watermark written back to DOCX headers, #1290; previously trivial: Word's text watermark read from DOCX headers, #1290; 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-13 · **Change:** trivial (VML freeforms read and kept on save; previously trivial: Word's text watermark written back to DOCX headers, #1290; previously trivial: Word's text watermark read from DOCX headers, #1290; 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;
@@ -64,7 +64,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
| DrawingML shapes and text boxes (`wps:`) | ✅ | ✅ | Preset geometries subset (callouts and flowchart boxes drawn as the nearest shape, #611); fill and outline from the shape or from the theme through its style references (`a:fillRef`/`a:lnRef`, #611), scheme, preset, system, scRGB and HSL colours; rotation and flips (`a:xfrm` `rot`/`flipH`/`flipV`, pictures and groups too, #332), the effect extent covering the rotated bounds; `wps:bodyPr` `vert` and `anchor`, internal margins (`lIns`/`tIns`/`rIns`/`bIns`) and Resize shape to fit text (`a:spAutoFit`: the text is never cut, #743), linked text boxes (`wps:txbx/@id`, `wps:linkedTxbx` `id`/`seq`, #369); text warps (`a:prstTxWarp` with adjust values, all 41 presets kept; unknown ones dropped), connectors (`straightConnector1`, `bentConnector2`–`5`, `curvedConnector2`–`5` read; 3-segment forms written) with glued ends (`wps:cNvCnPr` `a:stCxn`/`a:endCxn`; the glued shapes keep their drawing ids) and arrowheads (`a:headEnd`/`a:tailEnd`, drawn as triangles) (#416) |
| Freeform shapes (`a:custGeom`: `a:moveTo`, `a:lnTo`, Bézier curves flattened) and WordCraft ink | ✅ | ✅ | Ink is written as a freeform `wps:wsp` in a `wp:anchor` (round caps, alpha for highlighter) and recognised again by its drawing name (#307). It is saved as a custom-geometry shape, not as Word's own ink (InkML in `w14:contentPart`), so Word shows WordCraft ink as a freeform shape it can move and recolour but not erase with its ink eraser; arcs drawn straight |
| Group shapes (`wpg:`), drawing canvas (`wpc:`) | ✅ | ✅ | Groups (#267) and canvases (#344) with pictures, shapes, freeforms (custom geometry, #743) and text boxes; nested groups flattened. The canvas schema has no transform, so a turned or flipped canvas keeps its turn in our own `wpc:extLst` extension (`urn:wordcraft:canvas-xfrm`; Word shows it unturned). Legacy VML canvases (`v:group editas="canvas"`) aren't read as canvases |
| VML (`w:pict`, `v:shape`, `v:textbox`) | 🟡 | 🟡 | Pictures, text boxes, lines (with arrowheads), rectangles and ovals, best effort (#242, #611). Word's text watermark (a `v:textpath` shape in a header or footer, recognised by Word's `PowerPlusWaterMarkObject` id or by being centred on the page both ways) becomes the document's watermark: text, font, fill colour, semitransparent (`v:fill` `opacity`) and diagonal (any turn); drawn with WordCraft's own size and angle, on every page even when only some sections' headers (or only the first-page header) have it (#1290). Saving writes the watermark back the same way: a `v:textpath` shape centred on the margins, behind the text, at the start of every header's first paragraph; a section whose pages would show no header gets a header holding only the watermark (default, and first-page or even-page ones when those are on), while sections linked to an earlier header stay linked. Other VML is written only for OLE object pictures. Other VML WordArt (`v:textpath` in the body, or a header logo) is still dropped |
| VML (`w:pict`, `v:shape`, `v:textbox`) | 🟡 | 🟡 | Pictures, text boxes, lines (with arrowheads), rectangles and ovals, best effort (#242, #611). Word's text watermark (a `v:textpath` shape in a header or footer, recognised by Word's `PowerPlusWaterMarkObject` id or by being centred on the page both ways) becomes the document's watermark: text, font, fill colour, semitransparent (`v:fill` `opacity`) and diagonal (any turn); drawn with WordCraft's own size and angle, on every page even when only some sections' headers (or only the first-page header) have it (#1290). Saving writes the watermark back the same way: a `v:textpath` shape centred on the margins, behind the text, at the start of every header's first paragraph; a section whose pages would show no header gets a header holding only the watermark (default, and first-page or even-page ones when those are on), while sections linked to an earlier header stay linked. Freeforms (`v:shape` with its own `path`: moves, lines, Béziers and closes in its `coordsize`/`coordorigin` space, `nf`/`ns`, `v:stroke` opacity) are read as freeforms and saved as DrawingML custom geometry; fill opacity: none at 0, otherwise opaque (as for DrawingML fills); arcs are drawn straight; shapes whose path uses formulas (`@n`) and paths inherited from a `v:shapetype` aren't read. Other VML is written only for OLE object pictures. Other VML WordArt (`v:textpath` in the body, or a header logo) is still dropped |
| Alt text of drawings (`wp:docPr`/`cNvPr` `descr` and `title`, decorative mark `adec:decorative` from [MS-ODRAWXML]) and tables (`w:tblCaption`, `w:tblDescription`) | ✅ | ✅ | Pictures, shapes, text boxes, groups and their members, charts and SmartArt; OLE objects get theirs written into the kept XML (#386) |
| Charts (`c:chart`), SmartArt (`dgm`) | ✅ | ✅ | Drawn from the chart's cached data and SmartArt's stored drawing (#292), with data labels (`c:showVal`). Charts made in WordCraft (Insert › Chart, #409) are written as a `c:chartSpace` part with their data as literals (`c:strLit`/`c:numLit`, no embedded workbook, so Word shows them and offers to create a workbook on Edit Data) and read back as editable; a chart part holding anything more (any chart from Word, or one re-saved by Word) stays read-only. Saved back as read, with every part they reach (chart, embedded workbook, colours and style; diagram data, layout, quick style, colours and drawing) and their content types; a moved or resized one keeps its new frame, and a pasted copy gets its own parts (#319). SmartArt made in WordCraft (#497) is written from its model: data (`dgm:dataModel` points and connections), our own layout, quick-style and colour definitions under the built-in layout identifiers, and a `dsp:drawing` with every shape placed; it reads back editable only when its data part is exactly what WordCraft writes |
| OLE objects (`w:object`: embedded or linked files, ActiveX) | 🟡 | ✅ | Shown as their picture (VML or DrawingML); saved back whole with the embedding and picture parts, at the size, rotation and flips set in WordCraft (#319, #332). Can't be opened or edited |
@@ -163,6 +163,7 @@ raise readiness estimates. No Word-derived files are fixtures.
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | trivial | VML freeforms (`v:shape` with a `path`, such as rule lines in headers) read as freeforms and kept on save instead of being dropped |
| 2026-10-13 | trivial | DOCX: the document's watermark is written as a VML text shape in every header, adding a header where pages had none (#1290) |
| 2026-10-13 | trivial | DOCX: Word's VML text watermark in headers is read as the document's watermark (#1290) |
| 2026-10-12 | trivial | Paste keeps the formatting of HTML and RTF on the system clipboard; Paste Special lists them and pictures (#687, #833) |
+3 -2
View File
@@ -1,6 +1,6 @@
# Where WordCraft falls short of Word
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (Word's text watermark kept on DOCX save, #1290; previously trivial: a DOCX field report fixed: Word's text watermark, #1290; 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-13 · **Change:** trivial (VML freeforms read and kept on save; previously trivial: Word's text watermark kept on DOCX save, #1290; previously trivial: a DOCX field report fixed: Word's text watermark, #1290; 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.
@@ -15,7 +15,7 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr
| # | Gap | B | Evidence | Impact | Hours | Doc |
|---|---|---|---|---|---|---|
| 1 | **DOCX never tested on real-world files.** No corpus of files from Word, Google Docs, LibreOffice and Pages has been opened, rendered and round-tripped and compared with Word page by page | B | No corpus in the repo; `crates/docx/tests` are synthetic fixtures (including omitted/empty paragraph defaults and imported TOC range preservation); "DOCX fidelity corpus: not started" since 2026-10-06. Field reports fixed one by one: a cover page's theme-coloured shapes (#611), freeforms inside groups and text-box margins and Resize to fit (#743), Word's text watermark in headers, read and written (#1290) | Everyone: the files people receive are the files that break | 30–50 | [file-format-parity.md](file-format-parity.md) |
| 1 | **DOCX never tested on real-world files.** No corpus of files from Word, Google Docs, LibreOffice and Pages has been opened, rendered and round-tripped and compared with Word page by page | B | No corpus in the repo; `crates/docx/tests` are synthetic fixtures (including omitted/empty paragraph defaults and imported TOC range preservation); "DOCX fidelity corpus: not started" since 2026-10-06. Field reports fixed one by one: a cover page's theme-coloured shapes (#611), freeforms inside groups and text-box margins and Resize to fit (#743), Word's text watermark in headers, read and written (#1290), VML freeform rule lines in headers | Everyone: the files people receive are the files that break | 30–50 | [file-format-parity.md](file-format-parity.md) |
| 2 | **Remaining native ink forms are dropped on open and lost on save, silently**; OLE objects show only their picture | B | Drawing-level native ink is preserved with its optional picture fallback; direct run-level content parts and ink nested in groups/canvases remain unsupported. Drawing canvases are read and written (#344); group shapes (`wpg`) read and written (#267). Charts and SmartArt are drawn (#292) and, with OLE objects, written back with all their parts on save (#319) | Documents with diagrams drawn in Word or pen notes lose content without warning | 4–8 to preserve remaining native ink forms; 45–70 to render and edit charts and SmartArt (#13) | [file-format-parity.md](file-format-parity.md) |
| 3 | **Crashes and freezes in the field**: startup crash on Intel UHD Graphics (#170) (lag on GPUs without a DirectX 12 driver fixed, #316; 32-bit Windows exited at launch because DirectX 12 graphics pipeline creation failed, now OpenGL), freezes (#77, #59 Thai fonts, #31 font selector), no writes without admin on Windows 11 (#218), broken shape tool (#142), images can't be moved (#82); memory growth on every font change fixed (#848: the font list no longer loads each family it previews) | B | 62 open issues, ~15 of them crashes, hangs or data-path failures | Anyone hit loses work or trust; the never-crash standard covers panics, not hangs or GPU driver faults | 20–30 | [target-app-parity.md](target-app-parity.md) (Stability) |
| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options ignored; no page-by-page comparison harness | B | [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) |
@@ -73,6 +73,7 @@ The same gaps grouped the way the parity documents are, for agents working in on
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | trivial | #1: VML freeforms (rule lines in headers) read and kept on save |
| 2026-10-13 | trivial | Row 1: the watermark is saved to DOCX headers (#1290) |
| 2026-10-13 | trivial | Row 1: Word's text watermark in DOCX headers is drawn (#1290) |
| 2026-10-11 | trivial | Table column and row markers on the rulers landed (#700): the ruler's column markers are off the UI/UX gaps |