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:
parent
39e541fc8b
commit
2544372a22
|
|
@ -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%) |
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ mod freeform;
|
|||
mod math;
|
||||
mod props;
|
||||
mod sdt;
|
||||
mod shape_style;
|
||||
mod story;
|
||||
mod wordart;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = ¶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##"<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 = ¶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, .. }));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
Loading…
Reference in New Issue