#611: DOCX diagrams: theme-styled shapes and connectors, VML lines (#671)

Shapes and connectors that take their fill and outline from the theme
(wps:style a:fillRef/a:lnRef) now show; scheme, preset, system, scRGB and
HSL colours resolve. Callouts and flowchart boxes draw as the nearest
shape. Legacy VML lines (with arrowheads), rectangles and ovals are read.
A DrawingML line runs top-left to bottom-right, as the preset does.

Rebuilt on main, which now has connectors, arrowheads and canvases (#416,
#344): those parts of the earlier branch were dropped in favour of main's.

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Dmitrii Khlapov 2026-10-12 05:15:28 +02:00 committed by GitHub
parent 39e541fc8b
commit 2544372a22
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 394 additions and 36 deletions

View File

@ -148,6 +148,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-11 | Any text file opens (#569): `.m3u`/`.m3u8` playlists and other unknown extensions open as plain text with encoding detection (UTF-8, BOMs, UTF-16, Windows-1252), misnamed Word/RTF files by their signature; binary files give a clear error |
| 2026-10-11 | Select by dragging whole units (#549): after a double-click the drag grows by words, after a triple-click by paragraphs, after Ctrl/⌘+click by sentences, as in Word |
| 2026-10-11 | Compare / Combine dialog with comparison settings, Restrict Editing pane (exceptions, SHA-512 password, locked styles; DOCX round trip), AutoCorrect Options dialog saved per user (#412) |
| 2026-10-11 | DOCX diagrams (#611): lines, connectors and boxes styled by the theme now show (they were invisible); callouts and flowchart boxes drawn as the nearest shape; VML lines, rectangles and ovals |
| 2026-10-11 | WordArt (#416): text fill/outline/shadow/glow/reflection, Transform warps, our own styles; straight/elbow/curved connectors glued to shapes that re-route; DOCX round trip |
| 2026-10-11 | Content controls (`w:sdt`) kept, shown and edited; Developer tab, Design Mode, Properties dialog (#381) |
| 2026-10-11 | Insert › Chart (#409): column, bar, line, area, pie, doughnut and scatter charts with Chart Design (type, Edit Data, title, legend, data labels), saved as real chart parts; catalog 408/439 (92.9%) |

View File

@ -31,7 +31,7 @@ pub(crate) fn fill_colour(e: &El, theme: &[Rgb]) -> Option<Rgb> {
}
fn is_color(e: &El) -> bool {
matches!(e.name.as_str(), "a:srgbClr" | "a:schemeClr" | "a:sysClr")
matches!(e.name.as_str(), "a:srgbClr" | "a:schemeClr" | "a:sysClr" | "a:prstClr" | "a:scrgbClr" | "a:hslClr")
}
/// A colour element (`a:srgbClr`, `a:schemeClr`, `a:sysClr`), or the one inside `e`, with its
@ -41,7 +41,25 @@ pub(crate) fn color(e: &El, theme: &[Rgb]) -> Option<Rgb> {
let base = match c.name.as_str() {
"a:srgbClr" => Rgb::parse(c.attr("val")?)?,
"a:schemeClr" => *theme.get(scheme_slot(c.attr("val")?)?)?,
"a:sysClr" => Rgb::parse(c.attr("lastClr")?)?,
"a:sysClr" => match c.attr("lastClr").and_then(Rgb::parse) {
Some(rgb) => rgb,
None => sys_color(c.attr("val")?)?,
},
"a:prstClr" => preset_color(c.attr("val")?)?,
"a:scrgbClr" => {
// Linear 0–100 000 per channel, to sRGB.
let ch = |n: &str| -> u8 {
let l = c.attr(n).and_then(int).map_or(0.0, |v| (v as f64 / 100_000.0).clamp(0.0, 1.0));
let s = if l <= 0.003_130_8 { l * 12.92 } else { 1.055 * l.powf(1.0 / 2.4) - 0.055 };
(s * 255.0).round().clamp(0.0, 255.0) as u8
};
Rgb(ch("r"), ch("g"), ch("b"))
}
"a:hslClr" => {
let v = |n: &str| c.attr(n).and_then(int).unwrap_or(0) as f64;
let hue = (v("hue") / 60_000.0).rem_euclid(360.0) / 360.0;
from_hsl(hue, (v("sat") / 100_000.0).clamp(0.0, 1.0), (v("lum") / 100_000.0).clamp(0.0, 1.0))
}
_ => return None,
};
let val = |m: &El| m.attr("val").and_then(int).map_or(0.0, |v| (v as f64 / 100_000.0).clamp(-10.0, 10.0));
@ -58,6 +76,47 @@ pub(crate) fn color(e: &El, theme: &[Rgb]) -> Option<Rgb> {
Some(rgb)
}
/// A preset colour (`a:prstClr/@val`, ECMA-376 Part 1 §20.1.10.48): the common ones; others are
/// unknown (`None`).
pub(crate) fn preset_color(name: &str) -> Option<Rgb> {
Some(match name {
"black" => Rgb(0, 0, 0),
"white" => Rgb(0xFF, 0xFF, 0xFF),
"red" => Rgb(0xFF, 0, 0),
"green" => Rgb(0, 0x80, 0),
"lime" => Rgb(0, 0xFF, 0),
"blue" => Rgb(0, 0, 0xFF),
"yellow" => Rgb(0xFF, 0xFF, 0),
"cyan" | "aqua" => Rgb(0, 0xFF, 0xFF),
"magenta" | "fuchsia" => Rgb(0xFF, 0, 0xFF),
"gray" | "grey" => Rgb(0x80, 0x80, 0x80),
"silver" => Rgb(0xC0, 0xC0, 0xC0),
"ltGray" | "ltGrey" => Rgb(0xD3, 0xD3, 0xD3),
"dkGray" | "dkGrey" => Rgb(0xA9, 0xA9, 0xA9),
"maroon" => Rgb(0x80, 0, 0),
"navy" => Rgb(0, 0, 0x80),
"olive" => Rgb(0x80, 0x80, 0),
"purple" => Rgb(0x80, 0, 0x80),
"teal" => Rgb(0, 0x80, 0x80),
"orange" => Rgb(0xFF, 0xA5, 0),
"brown" => Rgb(0xA5, 0x2A, 0x2A),
"pink" => Rgb(0xFF, 0xC0, 0xCB),
_ => return None,
})
}
/// A system colour without its `lastClr`: the usual light-theme values of the common ones.
fn sys_color(name: &str) -> Option<Rgb> {
Some(match name {
"windowText" | "btnText" | "menuText" | "captionText" | "infoText" => Rgb(0, 0, 0),
"window" | "menu" | "highlightText" => Rgb(0xFF, 0xFF, 0xFF),
"btnFace" | "3dLight" => Rgb(0xF0, 0xF0, 0xF0),
"grayText" | "btnShadow" => Rgb(0xA0, 0xA0, 0xA0),
"highlight" => Rgb(0x00, 0x78, 0xD7),
_ => return None,
})
}
/// Index into `Document::settings.theme_colors` (dk1, lt1, dk2, lt2, accent1..6, hlink, folHlink).
fn scheme_slot(name: &str) -> Option<usize> {
Some(match name {

View File

@ -8,6 +8,7 @@ mod freeform;
mod math;
mod props;
mod sdt;
mod shape_style;
mod story;
mod wordart;

View File

@ -0,0 +1,177 @@
//! How a Word shape (`wps:wsp`) looks: its preset geometry, and the fill and outline it gets from its own properties (`wps:spPr`) or, failing those, from the theme through its
//! style references (`wps:style`: `a:fillRef`, `a:lnRef`, ECMA-376 Part 1 §20.1.4.2).
use wordcraft_doc::para::ShapeKind;
use wordcraft_doc::props::Rgb;
use super::drawing_color::{color, fill_colour};
use crate::units::{int, measure};
use crate::xml::El;
/// Outline widths of the theme's three line styles (`a:lnStyleLst`), points: what Word's
/// default themes give (6 350, 12 700 and 19 050 EMU).
const THEME_LINE_WIDTHS: [f32; 3] = [0.5, 1.0, 1.5];
/// The shape kind drawn for DrawingML preset geometry `prst` (ECMA-376 Part 1 §20.1.10.56).
/// Presets WordCraft has no shape for are drawn as the nearest one it has: callouts as the box,
/// round or cloud they point from, flowchart boxes as rectangles.
pub(crate) fn preset_kind(prst: &str) -> ShapeKind {
match prst {
"roundRect"
| "round1Rect"
| "round2SameRect"
| "round2DiagRect"
| "snipRoundRect"
| "wedgeRoundRectCallout"
| "flowChartAlternateProcess"
| "flowChartTerminator"
| "plaque" => ShapeKind::RoundedRectangle,
"ellipse" | "wedgeEllipseCallout" | "cloudCallout" | "cloud" | "flowChartConnector" | "donut" | "flowChartOnlineStorage" => {
ShapeKind::Ellipse
}
"triangle" | "rtTriangle" | "flowChartExtract" | "flowChartMerge" => ShapeKind::Triangle,
"diamond" | "flowChartDecision" => ShapeKind::Diamond,
"line" | "lineInv" => ShapeKind::Line,
"straightConnector1" => ShapeKind::StraightConnector,
"bentConnector2" | "bentConnector3" | "bentConnector4" | "bentConnector5" => ShapeKind::ElbowConnector,
"curvedConnector2" | "curvedConnector3" | "curvedConnector4" | "curvedConnector5" => ShapeKind::CurvedConnector,
"rightArrow" | "leftArrow" | "upArrow" | "downArrow" | "leftRightArrow" | "upDownArrow" | "notchedRightArrow" | "stripedRightArrow" => {
ShapeKind::Arrow
}
"star4" | "star5" | "star6" | "star7" | "star8" | "star10" | "star12" | "star16" | "star24" | "star32" => ShapeKind::Star,
"heart" => ShapeKind::Heart,
_ => ShapeKind::Rectangle,
}
}
/// A shape's fill and outline.
#[derive(Debug, Default, PartialEq)]
pub(crate) struct ShapePaint {
pub fill: Option<Rgb>,
pub stroke: Option<Rgb>,
/// Points.
pub stroke_width: f32,
}
/// The paint of a shape from its properties `sppr`, falling back on its style `style` (theme
/// colours `theme`) for what they leave out: an explicit `a:noFill` is no fill or outline, an
/// explicit colour wins, and a style reference with index 0 is none.
pub(crate) fn shape_paint(sppr: Option<&El>, style: Option<&El>, theme: &[Rgb]) -> ShapePaint {
let style_ref = |name: &str| -> Option<(i64, &El)> {
let r = style?.child(name)?;
Some((r.attr("idx").and_then(int).unwrap_or(0), r))
};
let fill = match sppr {
Some(s) if s.child("a:noFill").is_some() || s.child("a:blipFill").is_some() => None,
Some(s) if s.els().any(|c| matches!(c.name.as_str(), "a:solidFill" | "a:gradFill" | "a:pattFill")) => fill_colour(s, theme),
_ => style_ref("a:fillRef").filter(|(idx, _)| *idx > 0).and_then(|(_, r)| color(r, theme)),
};
let ln = sppr.and_then(|s| s.child("a:ln"));
let line_ref = style_ref("a:lnRef").filter(|(idx, _)| *idx > 0);
let stroke = match ln {
Some(l) if l.child("a:noFill").is_some() => None,
Some(l) if l.els().any(|c| matches!(c.name.as_str(), "a:solidFill" | "a:gradFill" | "a:pattFill")) => fill_colour(l, theme),
_ => line_ref.and_then(|(_, r)| color(r, theme)),
};
let themed_width = line_ref.and_then(|(idx, _)| THEME_LINE_WIDTHS.get(usize::try_from(idx.clamp(1, 3) - 1).unwrap_or(0)).copied());
let stroke_width = ln
.and_then(|l| l.attr("w"))
.and_then(|v| measure(v, 12_700.0))
.or(themed_width)
// Word's default outline when the file names none (no outline: no width).
.unwrap_or(if stroke.is_some() { 0.75 } else { 0.0 })
.clamp(0.0, 100.0);
ShapePaint { fill, stroke, stroke_width }
}
/// A VML colour (`strokecolor`, `fillcolor`): `#rrggbb` or `#rgb` (Word follows it with the
/// theme slot, as in `#4472c4 [3204]`), or a colour name.
pub(crate) fn vml_color(v: &str) -> Option<Rgb> {
let v = v.split_whitespace().next()?;
if let Some(hex) = v.strip_prefix('#') {
if hex.len() == 3 && hex.is_ascii() {
let long: String = hex.chars().flat_map(|c| [c, c]).collect();
return Rgb::parse(&long);
}
return Rgb::parse(hex);
}
super::drawing_color::preset_color(v)
}
#[cfg(test)]
mod tests {
use super::*;
use wordcraft_doc::THEME_COLORS;
fn el(xml: &str) -> El {
let ns = r#"xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main""#;
crate::xml::parse(xml.replace(r#"xmlns:wps="w" xmlns:a="a""#, ns).as_bytes()).unwrap()
}
#[test]
fn theme_styled_connector_gets_its_line_from_the_style() {
// A connector with no colour of its own (what Word writes for Insert › Shapes › Line):
// its outline comes from `a:lnRef`, its width from the theme's first line style.
let sp = el(r#"<wps:spPr xmlns:wps="w" xmlns:a="a"><a:prstGeom prst="line"/><a:ln><a:tailEnd type="triangle"/></a:ln></wps:spPr>"#);
let st = el(
r#"<wps:style xmlns:wps="w" xmlns:a="a"><a:lnRef idx="1"><a:schemeClr val="accent4"/></a:lnRef><a:fillRef idx="0"><a:schemeClr val="accent4"/></a:fillRef></wps:style>"#,
);
let p = shape_paint(Some(&sp), Some(&st), &THEME_COLORS);
assert_eq!(p.stroke, Some(THEME_COLORS[7]));
assert_eq!(p.stroke_width, 0.5);
assert_eq!(p.fill, None);
}
#[test]
fn explicit_paint_wins_over_the_style() {
let sp = el(r#"<wps:spPr xmlns:wps="w" xmlns:a="a"><a:noFill/><a:ln><a:solidFill><a:srgbClr val="EE0000"/></a:solidFill></a:ln></wps:spPr>"#);
let st = el(
r#"<wps:style xmlns:wps="w" xmlns:a="a"><a:lnRef idx="2"><a:schemeClr val="accent1"><a:shade val="15000"/></a:schemeClr></a:lnRef><a:fillRef idx="1"><a:schemeClr val="accent1"/></a:fillRef></wps:style>"#,
);
let p = shape_paint(Some(&sp), Some(&st), &THEME_COLORS);
assert_eq!((p.fill, p.stroke, p.stroke_width), (None, Some(Rgb(0xEE, 0, 0)), 1.0));
// Preset and scheme colours as the box's own fill and outline.
let sp = el(
r#"<wps:spPr xmlns:wps="w" xmlns:a="a"><a:solidFill><a:schemeClr val="lt1"/></a:solidFill><a:ln w="6350"><a:solidFill><a:prstClr val="black"/></a:solidFill></a:ln></wps:spPr>"#,
);
let p = shape_paint(Some(&sp), None, &THEME_COLORS);
assert_eq!((p.fill, p.stroke, p.stroke_width), (Some(THEME_COLORS[1]), Some(Rgb(0, 0, 0)), 0.5));
// A style fill with index 0 is none; an outline set to no fill is none.
let sp = el(r#"<wps:spPr xmlns:wps="w" xmlns:a="a"><a:ln><a:noFill/></a:ln></wps:spPr>"#);
let st = el(
r#"<wps:style xmlns:wps="w" xmlns:a="a"><a:lnRef idx="2"><a:schemeClr val="accent1"/></a:lnRef><a:fillRef idx="0"><a:schemeClr val="accent1"/></a:fillRef></wps:style>"#,
);
let p = shape_paint(Some(&sp), Some(&st), &THEME_COLORS);
assert_eq!((p.fill, p.stroke), (None, None));
}
#[test]
fn hostile_style_references_do_not_panic() {
let st = el(
r#"<wps:style xmlns:wps="w" xmlns:a="a"><a:lnRef idx="-99999999999"><a:schemeClr val="nope"/></a:lnRef><a:fillRef idx="99999999999"/></wps:style>"#,
);
let p = shape_paint(None, Some(&st), &[]);
assert_eq!((p.fill, p.stroke), (None, None));
let st = el(r#"<wps:style xmlns:wps="w" xmlns:a="a"><a:lnRef idx="77"><a:srgbClr val="010203"/></a:lnRef></wps:style>"#);
assert_eq!(shape_paint(None, Some(&st), &[]).stroke_width, 1.5);
}
#[test]
fn presets_map_to_the_nearest_shape() {
assert_eq!(preset_kind("straightConnector1"), ShapeKind::StraightConnector);
assert_eq!(preset_kind("bentConnector3"), ShapeKind::ElbowConnector);
assert_eq!(preset_kind("wedgeRoundRectCallout"), ShapeKind::RoundedRectangle);
assert_eq!(preset_kind("wedgeEllipseCallout"), ShapeKind::Ellipse);
assert_eq!(preset_kind("wedgeRectCallout"), ShapeKind::Rectangle);
assert_eq!(preset_kind("flowChartDecision"), ShapeKind::Diamond);
}
#[test]
fn vml_colours() {
assert_eq!(vml_color("#ffc000 [3207]"), Some(Rgb(0xFF, 0xC0, 0)));
assert_eq!(vml_color("#f00"), Some(Rgb(0xFF, 0, 0)));
assert_eq!(vml_color("black"), Some(Rgb(0, 0, 0)));
assert_eq!(vml_color("#é"), None);
assert_eq!(vml_color(""), None);
}
}

View File

@ -13,6 +13,7 @@ use wordcraft_doc::{Block, Blocks, InlineObject, Paragraph, PartKind, RevisionKi
use super::Reader;
use super::props::{sectpr, tcpr, trpr};
use super::shape_style::{preset_kind, shape_paint, vml_color};
use crate::package::{Rels, rt};
use crate::units::{int, measure, u32_of};
use crate::xml::El;
@ -1110,31 +1111,16 @@ impl Reader<'_> {
float.set_spin(xfrm_spin(sppr));
let prst = sppr.and_then(|s| s.child("a:prstGeom")).and_then(|g| g.attr("prst")).unwrap_or("rect");
let txbx = wsp.child("wps:txbx").and_then(|t| t.child("w:txbxContent"));
let mut kind = match prst {
"roundRect" => ShapeKind::RoundedRectangle,
"ellipse" => ShapeKind::Ellipse,
"triangle" | "rtTriangle" => ShapeKind::Triangle,
"diamond" => ShapeKind::Diamond,
"line" => ShapeKind::Line,
"straightConnector1" => ShapeKind::StraightConnector,
"bentConnector2" | "bentConnector3" | "bentConnector4" | "bentConnector5" => ShapeKind::ElbowConnector,
"curvedConnector2" | "curvedConnector3" | "curvedConnector4" | "curvedConnector5" => ShapeKind::CurvedConnector,
"rightArrow" | "leftArrow" | "upArrow" | "downArrow" => ShapeKind::Arrow,
"star5" | "star4" | "star6" => ShapeKind::Star,
"heart" => ShapeKind::Heart,
_ => ShapeKind::Rectangle,
};
let mut kind = preset_kind(prst);
let is_tb = wsp.child("wps:cNvSpPr").and_then(|c| c.attr("txBox")).is_some_and(|v| v == "1" || v == "true");
if is_tb || (txbx.is_some() && kind == ShapeKind::Rectangle) {
kind = ShapeKind::TextBox;
}
let solid = |e: Option<&El>| {
e.and_then(|f| f.child("a:solidFill")).and_then(|f| f.child("a:srgbClr")).and_then(|c| c.attr("val")).and_then(Rgb::parse)
};
let fill = solid(sppr);
// Explicit paint, else the theme's through the style references (#611).
let paint = shape_paint(sppr, wsp.child("wps:style"), &self.doc.settings.theme_colors);
let (stroke, stroke_width) = (paint.stroke, paint.stroke_width);
let fill = if kind.is_open() { None } else { paint.fill };
let ln = sppr.and_then(|s| s.child("a:ln"));
let stroke = solid(ln);
let stroke_width = ln.and_then(|l| l.attr("w")).and_then(|v| measure(v, 12_700.0)).unwrap_or(0.0).clamp(0.0, 100.0);
let effects = sppr.and_then(|s| s.child("a:effectLst")).map(effect_list).unwrap_or_default();
let story = match txbx {
Some(t) if sc.story_depth < MAX_STORY_DEPTH => Some(self.read_textbox(sc, t, rels)),
@ -1214,32 +1200,34 @@ impl Reader<'_> {
self.doc.add_part(PartKind::TextBox, blocks)
}
/// Legacy VML pictures and text boxes (best effort).
/// Legacy VML pictures, text boxes, lines, rectangles and ovals (best effort).
fn read_vml(&mut self, sc: &mut StoryCtx, pict: &El, rels: &Rels) -> Option<InlineObject> {
let shape = pict.els().find(|e| e.name.starts_with("v:") && e.local() != "shapetype")?;
let style = shape.attr("style").unwrap_or("");
let len = |k: &str| vml_style(style, k).and_then(|v| measure(v, 1.0)).unwrap_or(0.0).clamp(0.0, crate::units::MAX_LEN_PT);
let (w, h) = (len("width"), len("height"));
let float = vml_float(shape, style);
let (mut w, mut h) = (len("width"), len("height"));
let mut float = vml_float(shape, style);
if let Some(img) = shape.find("v:imagedata") {
let id = img.attr("r:id").or_else(|| img.attr("r:pict"))?;
let media = self.media_for(rels, id)?;
let alt = shape.attr("alt").or_else(|| img.attr("o:title")).unwrap_or("").to_string();
return Some(InlineObject::Image { media, w, h, alt, float, crop: [0.0; 4], ole: None });
}
let on = |name: &str| shape.attr(name).is_none_or(|v| !matches!(v, "f" | "false" | "0"));
let stroke = on("stroked").then(|| shape.attr("strokecolor").map_or(Some(Rgb::BLACK), vml_color)).flatten();
let stroke_width = shape.attr("strokeweight").and_then(|v| measure(v, 1.0)).unwrap_or(0.75).clamp(0.0, 100.0);
let fill = on("filled").then(|| shape.attr("fillcolor").map_or(Some(Rgb::WHITE), vml_color)).flatten();
if let Some(t) = shape.find("w:txbxContent")
&& sc.story_depth < MAX_STORY_DEPTH
{
let story = Some(self.read_textbox(sc, t, rels));
let fill = shape.attr("fillcolor").and_then(Rgb::parse);
let stroke = shape.attr("strokecolor").and_then(Rgb::parse);
return Some(InlineObject::Shape {
kind: ShapeKind::TextBox,
w,
h,
fill,
stroke,
stroke_width: 0.75,
stroke_width,
float,
story,
freeform: None,
@ -1247,7 +1235,51 @@ impl Reader<'_> {
extra: Default::default(),
});
}
None
let kind = match shape.local() {
"line" => ShapeKind::Line,
"rect" => ShapeKind::Rectangle,
"roundrect" => ShapeKind::RoundedRectangle,
"oval" => ShapeKind::Ellipse,
_ => return None,
};
let mut extra = ShapeExtra::default();
if kind == ShapeKind::Line {
// A line runs `from` → `to` (points, offsets from where its style places it).
let pt = |name: &str| -> (f32, f32) {
let v = shape.attr(name).unwrap_or("0,0");
let (x, y) = v.split_once(',').unwrap_or((v, "0"));
let m = |s: &str| measure(s, 1.0).map_or(0.0, wordcraft_geom::finite).clamp(-crate::units::MAX_LEN_PT, crate::units::MAX_LEN_PT);
(m(x), m(y))
};
let ((x0, y0), (x1, y1)) = (pt("from"), pt("to"));
let max = crate::units::MAX_LEN_PT;
(w, h) = ((x1 - x0).abs().min(max), (y1 - y0).abs().min(max));
float.x = (float.x + x0.min(x1)).clamp(-max, max);
float.y = (float.y + y0.min(y1)).clamp(-max, max);
// Drawn top-left to bottom-right; flipped for the other directions.
float.flip_h ^= x1 < x0;
float.flip_v ^= y1 < y0;
// Arrowheads (`v:stroke` startarrow / endarrow): any but `none` is drawn.
let arrow = |name: &str| shape.child("v:stroke").and_then(|s| s.attr(name)).is_some_and(|v| v != "none");
extra.arrow_start = arrow("startarrow");
extra.arrow_end = arrow("endarrow");
}
if stroke.is_none() && (fill.is_none() || kind == ShapeKind::Line) {
return None;
}
Some(InlineObject::Shape {
kind,
w,
h,
fill: fill.filter(|_| kind != ShapeKind::Line),
stroke,
stroke_width,
float,
story: None,
freeform: None,
effects: Default::default(),
extra,
})
}
/// An OLE object (`w:object`, ECMA-376 §17.3.3.19): shown as its picture (VML, or a DrawingML

View File

@ -1412,3 +1412,80 @@ fn includepicture_keeps_its_embedded_picture() {
check(&wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap());
}
}
/// One anchored Word shape (`wps:wsp` inside `mc:AlternateContent`, as Word writes them) with
/// shape properties `sppr`, style `style` and extra `wsp` content.
fn anchored_wsp(name: &str, cx: u32, cy: u32, sppr: &str, style: &str, extra: &str) -> String {
format!(
r#"<w:r><mc:AlternateContent><mc:Choice Requires="wps"><w:drawing><wp:anchor distT="0" distB="0" distL="114300" distR="114300" simplePos="0" relativeHeight="1" behindDoc="0" locked="0" layoutInCell="1" allowOverlap="1"><wp:simplePos x="0" y="0"/><wp:positionH relativeFrom="column"><wp:posOffset>12700</wp:posOffset></wp:positionH><wp:positionV relativeFrom="paragraph"><wp:posOffset>25400</wp:posOffset></wp:positionV><wp:extent cx="{cx}" cy="{cy}"/><wp:effectExtent l="0" t="0" r="0" b="0"/><wp:wrapNone/><wp:docPr id="1" name="{name}"/><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"><wps:wsp>{extra}<wps:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="{cx}" cy="{cy}"/></a:xfrm>{sppr}</wps:spPr>{style}<wps:bodyPr/></wps:wsp></a:graphicData></a:graphic></wp:anchor></w:drawing></mc:Choice><mc:Fallback><w:pict><v:line from="0,0" to="1pt,2pt"/></w:pict></mc:Fallback></mc:AlternateContent></w:r>"#
)
}
/// #611: diagrams drawn with Word's shapes were mostly missing. Connectors and boxes take their
/// outline and fill from the theme through their style references, or name preset and scheme
/// colours; all of these are read, with line widths and arrowheads.
#[test]
fn theme_styled_connectors_and_callout_boxes_keep_their_paint() {
let style = |ln: u8, fill: u8, clr: &str| {
format!(
r#"<wps:style><a:lnRef idx="{ln}"><a:schemeClr val="{clr}"/></a:lnRef><a:fillRef idx="{fill}"><a:schemeClr val="{clr}"/></a:fillRef><a:effectRef idx="0"><a:schemeClr val="{clr}"/></a:effectRef><a:fontRef idx="minor"><a:schemeClr val="tx1"/></a:fontRef></wps:style>"#
)
};
let body = [
// A connector with only a style: accent4, the theme's thinnest line, an arrowhead.
anchored_wsp("Arrow", 673100, 889000, r#"<a:prstGeom prst="straightConnector1"><a:avLst/></a:prstGeom><a:ln><a:tailEnd type="triangle"/></a:ln>"#, &style(1, 0, "accent4"), "<wps:cNvCnPr/>"),
// A callout text box: scheme fill, preset black outline.
anchored_wsp(
"Text Box",
3073400,
673100,
r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:solidFill><a:schemeClr val="lt1"/></a:solidFill><a:ln w="6350"><a:solidFill><a:prstClr val="black"/></a:solidFill></a:ln>"#,
"",
r#"<wps:cNvSpPr txBox="1"/><wps:txbx><w:txbxContent><w:p><w:r><w:t>Callout</w:t></w:r></w:p></w:txbxContent></wps:txbx>"#,
),
// A filled rectangle from its style alone.
anchored_wsp("Rect", 292100, 647700, r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>"#, &style(2, 1, "accent1"), "<wps:cNvSpPr/>"),
// An elbow connector.
anchored_wsp("Elbow", 1270000, 635000, r#"<a:prstGeom prst="bentConnector3"><a:avLst/></a:prstGeom>"#, &style(1, 0, "accent2"), "<wps:cNvCnPr/>"),
// A rounded-rectangle callout.
anchored_wsp("Callout", 1270000, 635000, r#"<a:prstGeom prst="wedgeRoundRectCallout"><a:avLst/></a:prstGeom>"#, &style(2, 1, "accent1"), "<wps:cNvSpPr/>"),
]
.concat();
let d = read_body(&format!("<w:p>{body}</w:p>"));
let theme = &d.settings.theme_colors;
let objs = &paras(&d)[0].objects;
assert_eq!(objs.len(), 5);
let shape = |i: usize| match &objs[i] {
InlineObject::Shape { kind, fill, stroke, stroke_width, extra, story, .. } => {
(*kind, *fill, *stroke, *stroke_width, extra.arrow_end, story.is_some())
}
o => panic!("{o:?}"),
};
assert_eq!(shape(0), (ShapeKind::StraightConnector, None, Some(theme[7]), 0.5, true, false));
assert_eq!(shape(1), (ShapeKind::TextBox, Some(theme[1]), Some(Rgb(0, 0, 0)), 0.5, false, true));
assert_eq!(shape(2), (ShapeKind::Rectangle, Some(theme[4]), Some(theme[4]), 1.0, false, false));
assert_eq!(shape(3), (ShapeKind::ElbowConnector, None, Some(theme[5]), 0.5, false, false));
assert_eq!(shape(4).0, ShapeKind::RoundedRectangle);
// The themed outline and the arrowhead survive saving.
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert!(matches!(&paras(&again)[0].objects[0], InlineObject::Shape { stroke, extra, .. } if *stroke == Some(theme[7]) && extra.arrow_end));
}
/// #611: legacy VML lines and rectangles (documents without DrawingML).
#[test]
fn vml_lines_and_rectangles() {
let body = r##"<w:p><w:r><w:pict><v:line style="position:absolute;flip:y;z-index:1" from="10pt,20pt" to="40pt,60pt" strokecolor="#4472c4 [3204]" strokeweight=".5pt"><v:stroke endarrow="block"/></v:line></w:pict></w:r>
<w:r><w:pict><v:rect style="position:absolute;margin-left:5pt;margin-top:6pt;width:30pt;height:20pt" fillcolor="#ff0000" stroked="f"/></w:pict></w:r></w:p>"##;
let d = read_body(body);
let objs = &paras(&d)[0].objects;
assert_eq!(objs.len(), 2, "{objs:?}");
match &objs[0] {
InlineObject::Shape { kind: ShapeKind::Line, w, h, stroke, stroke_width, float, extra, .. } => {
assert_eq!((*w, *h, *stroke, *stroke_width), (30.0, 40.0, Some(Rgb(0x44, 0x72, 0xC4)), 0.5));
assert_eq!((float.x, float.y, float.flip_h, float.flip_v), (10.0, 20.0, false, true));
assert!(extra.arrow_end && !extra.arrow_start);
}
o => panic!("{o:?}"),
}
assert!(matches!(&objs[1], InlineObject::Shape { kind: ShapeKind::Rectangle, fill: Some(Rgb(255, 0, 0)), stroke: None, w: 30.0, h: 20.0, .. }));
}

View File

@ -409,9 +409,10 @@ fn shape_path(kind: ShapeKind, r: &Rect) -> Option<Path> {
ShapeKind::Ellipse => ellipse(&mut pb, cx, cy, r.w / 2.0, r.h / 2.0),
ShapeKind::Triangle => poly(&mut pb, &[(cx, y0), (x1, y1), (x0, y1)]),
ShapeKind::Diamond => poly(&mut pb, &[(cx, y0), (x1, cy), (cx, y1), (x0, cy)]),
// DrawingML's `line`: top-left to bottom-right (flips give the other directions).
ShapeKind::Line => {
pb.move_to(x0, y1);
pb.line_to(x1, y0);
pb.move_to(x0, y0);
pb.line_to(x1, y1);
}
// Connectors are drawn as paths (`Draw::Path`); this is their frame's diagonal.
ShapeKind::StraightConnector | ShapeKind::ElbowConnector | ShapeKind::CurvedConnector => {

View File

@ -824,10 +824,11 @@ pub fn shape_path(kind: ShapeKind, r: kurbo::Rect) -> BezPath {
ShapeKind::Ellipse => kurbo::Ellipse::from_rect(r).to_path(0.1),
ShapeKind::Triangle => poly(&[(cx, r.y0), (r.x1, r.y1), (r.x0, r.y1)]),
ShapeKind::Diamond => poly(&[(cx, r.y0), (r.x1, cy), (cx, r.y1), (r.x0, cy)]),
// DrawingML's `line`: top-left to bottom-right (flips give the other directions).
ShapeKind::Line => {
let mut p = BezPath::new();
p.move_to((r.x0, r.y1));
p.line_to((r.x1, r.y0));
p.move_to((r.x0, r.y0));
p.line_to((r.x1, r.y1));
p
}
// Connectors are drawn as paths (`Draw::Path`); this is their frame's diagonal.
@ -1202,6 +1203,14 @@ mod tests {
assert!(!shape_path(k, r).elements().is_empty());
}
}
/// #611: a line runs as DrawingML's `line` preset does, top-left to bottom-right (Word's
/// files flip it for the other diagonal), not bottom-left to top-right.
#[test]
fn a_line_runs_from_top_left_to_bottom_right() {
let p = shape_path(ShapeKind::Line, kurbo::Rect::new(10.0, 20.0, 30.0, 60.0));
assert_eq!(p.elements(), &[kurbo::PathEl::MoveTo((10.0, 20.0).into()), kurbo::PathEl::LineTo((30.0, 60.0).into())]);
}
}
#[cfg(test)]

View File

@ -1,6 +1,6 @@
# File-format parity with Microsoft Word
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (unknown text extensions such as `.m3u` open as plain text, #569; previously trivial: Word XML Document (Flat OPC) opens and saves, Word 2003 XML opens, #548; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Word diagrams: theme-styled shapes and VML lines, #611; previously trivial: Word XML Document (Flat OPC) opens and saves, Word 2003 XML opens, #548; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the
share of a typical real-world file's content and formatting that survives (read: shows correctly;
@ -60,10 +60,10 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
| Legacy form fields (`w:ffData`), check boxes, drop-downs | ❌ | ❌ | |
| DrawingML pictures inline and anchored (`wp:anchor`), wrap square/tight/through/top-bottom | ✅ | ✅ | Tight/through read and written with their `wp:wrapPolygon` (and `edited`); without one, a shape's outline is written (a picture's rectangle). `wrapText` (both sides, left, right, largest) read and written for square, tight and through |
| WordArt text effects (`w14:textFill`, `w14:textOutline`, `w14:shadow`, `w14:glow`, `w14:reflection` in `w:rPr`) | ✅ | ✅ | Solid, gradient (linear) and no fill, outline colour/width, shadow, glow, reflection (#416); `w14:scene3d`/`w14:props3d` dropped |
| DrawingML shapes and text boxes (`wps:`) | ✅ | ✅ | Preset geometries subset; 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`, 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) |
| 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`, 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 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 and text boxes, best effort (#242) |
| VML (`w:pict`, `v:shape`, `v:textbox`) | 🟡 | ❌ | Pictures, text boxes, lines (with arrowheads), rectangles and ovals, best effort (#242, #611) |
| 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 |
@ -121,6 +121,7 @@ coverage and WordCraft layout matching remain separate gaps.
| 2026-10-11 | trivial | Files with an extension WordCraft doesn't know open by content: text (`.m3u`/`.m3u8` playlists, logs…) through the encoding-detecting text import, misnamed Word/RTF files by signature, binary files refused with an error (#569) |
| 2026-10-11 | trivial | Restrict Editing: Word's legacy password hash (`w:cryptAlgorithmSid`, `w:hash`, `w:salt`, `w:cryptSpinCount`) verified per ECMA-376 Part 4 §14.8.1; unsupported hash forms refused (#412) |
| 2026-10-11 | trivial | Restrict Editing: `w:documentProtection` (edit, formatting, password hash), `w:locked` styles and `w:permStart`/`w:permEnd` exceptions read and written (#412) |
| 2026-10-11 | trivial | DOCX diagrams (#611): shapes and connectors styled by the theme (`wps:style`) show their outline and fill; preset/system colours; VML lines and rectangles; lines run top-left to bottom-right as in DrawingML |
| 2026-10-11 | trivial | WordArt text effects (`w14:` run properties), text warps (`a:prstTxWarp`) and connectors with glued ends read and written (#416) |
| 2026-10-11 | trivial | Content controls read and written with their properties and nesting (#381) |
| 2026-10-11 | trivial | Charts made in WordCraft written as chart parts and read back as editable (#409) |