mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-24 18:36:58 -06:00
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:
co-authored by
Claude Opus 5.5
parent
48bdb571f9
commit
b7a6cc70f9
+3
-1
@@ -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) |
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 ¶s(&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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user