diff --git a/ROADMAP.md b/ROADMAP.md index 44e8068..1a7c00a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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%) | diff --git a/crates/docx/src/read/drawing_color.rs b/crates/docx/src/read/drawing_color.rs index 21a5a58..4d2c7b9 100644 --- a/crates/docx/src/read/drawing_color.rs +++ b/crates/docx/src/read/drawing_color.rs @@ -31,7 +31,7 @@ pub(crate) fn fill_colour(e: &El, theme: &[Rgb]) -> Option { } 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 { 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 { 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 { + 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 { + 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 { Some(match name { diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs index 2e67411..058db92 100644 --- a/crates/docx/src/read/mod.rs +++ b/crates/docx/src/read/mod.rs @@ -8,6 +8,7 @@ mod freeform; mod math; mod props; mod sdt; +mod shape_style; mod story; mod wordart; diff --git a/crates/docx/src/read/shape_style.rs b/crates/docx/src/read/shape_style.rs new file mode 100644 index 0000000..23cf106 --- /dev/null +++ b/crates/docx/src/read/shape_style.rs @@ -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, + pub stroke: Option, + /// 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 { + 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#""#); + let st = el( + r#""#, + ); + 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#""#); + let st = el( + r#""#, + ); + 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#""#, + ); + 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#""#); + let st = el( + r#""#, + ); + 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#""#, + ); + let p = shape_paint(None, Some(&st), &[]); + assert_eq!((p.fill, p.stroke), (None, None)); + let st = el(r#""#); + 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); + } +} diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index 94e5522..c6f724c 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -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 { 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 diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index 5f9358e..912d213 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -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#"1270025400{extra}{sppr}{style}"# + ) +} + +/// #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#""# + ) + }; + let body = [ + // A connector with only a style: accent4, the theme's thinnest line, an arrowhead. + anchored_wsp("Arrow", 673100, 889000, r#""#, &style(1, 0, "accent4"), ""), + // A callout text box: scheme fill, preset black outline. + anchored_wsp( + "Text Box", + 3073400, + 673100, + r#""#, + "", + r#"Callout"#, + ), + // A filled rectangle from its style alone. + anchored_wsp("Rect", 292100, 647700, r#""#, &style(2, 1, "accent1"), ""), + // An elbow connector. + anchored_wsp("Elbow", 1270000, 635000, r#""#, &style(1, 0, "accent2"), ""), + // A rounded-rectangle callout. + anchored_wsp("Callout", 1270000, 635000, r#""#, &style(2, 1, "accent1"), ""), + ] + .concat(); + let d = read_body(&format!("{body}")); + let theme = &d.settings.theme_colors; + let objs = ¶s(&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!(¶s(&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##" +"##; + let d = read_body(body); + let objs = ¶s(&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, .. })); +} diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index dc35288..6c17302 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -409,9 +409,10 @@ fn shape_path(kind: ShapeKind, r: &Rect) -> Option { 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 => { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index f5c79af..894d5a5 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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)] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d5cdcb6..c54d114 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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) |