Shapes: arrowheads keep their kind and size and are drawn at Word's sizes (#5192)

Word draws an arrowhead 2, 3 or 5 times the line's width long and wide
(small, medium, large; `a:headEnd`/`a:tailEnd` `len`/`w`), a line under
2 points as a 2-point one. Every arrowhead was drawn one size and saved
as a medium triangle: the kind, width and length are now read (DrawingML
and VML), kept and written back, and the size is drawn as Word does.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
pakiat
2026-11-01 14:46:16 -05:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent 499fc6289e
commit 8a191fbf11
10 changed files with 299 additions and 37 deletions
+3 -1
View File
File diff suppressed because one or more lines are too long
+130
View File
@@ -48,6 +48,131 @@ impl LineCap {
}
}
/// An arrowhead's width across its line or length along it (ST_LineEndWidth and
/// ST_LineEndLength, ECMA-376 §20.1.10.39–40): 2, 3 or 5 times the line's width, as Word draws it.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ArrowSize {
Small,
#[default]
Medium,
Large,
}
impl ArrowSize {
/// Read a DrawingML size (`sm`, `med`, `lg`); absent or unknown values are medium.
pub fn from_ooxml(value: &str) -> Self {
match value {
"sm" => Self::Small,
"lg" => Self::Large,
_ => Self::Medium,
}
}
/// The DrawingML spelling.
pub fn ooxml(self) -> &'static str {
match self {
Self::Small => "sm",
Self::Medium => "med",
Self::Large => "lg",
}
}
/// Read a VML width (`narrow`, `medium`, `wide`) or length (`short`, `medium`, `long`).
pub fn from_vml(value: &str) -> Self {
match value {
"narrow" | "short" => Self::Small,
"wide" | "long" => Self::Large,
_ => Self::Medium,
}
}
/// How many [`arrow_unit`]s the arrowhead measures.
pub fn factor(self) -> f32 {
match self {
Self::Small => 2.0,
Self::Medium => 3.0,
Self::Large => 5.0,
}
}
}
/// The kind of an arrowhead (ST_LineEndType, ECMA-376 §20.1.10.33), kept for saving; every kind
/// is drawn as a triangle.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ArrowKind {
#[default]
Triangle,
Stealth,
Diamond,
Oval,
Arrow,
}
impl ArrowKind {
/// Read a DrawingML end type other than `none`; unknown values are triangles.
pub fn from_ooxml(value: &str) -> Self {
match value {
"stealth" => Self::Stealth,
"diamond" => Self::Diamond,
"oval" => Self::Oval,
"arrow" => Self::Arrow,
_ => Self::Triangle,
}
}
/// The DrawingML spelling.
pub fn ooxml(self) -> &'static str {
match self {
Self::Triangle => "triangle",
Self::Stealth => "stealth",
Self::Diamond => "diamond",
Self::Oval => "oval",
Self::Arrow => "arrow",
}
}
/// Read a VML arrow (`block`, `classic`, `diamond`, `oval`, `open`) other than `none`.
pub fn from_vml(value: &str) -> Self {
match value {
"classic" => Self::Stealth,
"diamond" => Self::Diamond,
"oval" => Self::Oval,
"open" => Self::Arrow,
_ => Self::Triangle,
}
}
}
/// An arrowhead's kind and size (`a:headEnd` / `a:tailEnd`). The default is a medium triangle.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ArrowStyle {
pub kind: ArrowKind,
pub width: ArrowSize,
pub length: ArrowSize,
}
impl ArrowStyle {
pub fn is_default(&self) -> bool {
*self == Self::default()
}
/// The arrowhead's length along its line and width across it, for a line `line_width` wide.
pub fn dims(self, line_width: f32) -> (f32, f32) {
let unit = arrow_unit(line_width);
(self.length.factor() * unit, self.width.factor() * unit)
}
}
/// What an arrowhead's size is a multiple of: the line's width, but at least 2 points (Word draws
/// a thinner line's arrowheads as for a 2-point line).
pub fn arrow_unit(line_width: f32) -> f32 {
let w = if line_width.is_finite() { line_width.clamp(0.25, 200.0) } else { 0.75 };
w.max(2.0)
}
/// The rest of a shape's properties. Empty (the default) for most shapes.
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
@@ -69,6 +194,11 @@ pub struct ShapeExtra {
pub arrow_start: bool,
#[serde(skip_serializing_if = "is_false")]
pub arrow_end: bool,
/// The kind and size of the start and end arrowheads, when there are any.
#[serde(skip_serializing_if = "ArrowStyle::is_default")]
pub start_style: ArrowStyle,
#[serde(skip_serializing_if = "ArrowStyle::is_default")]
pub end_style: ArrowStyle,
/// A rounded rectangle's corner radius (`roundRect`'s `adj`, ECMA-376 §20.1.10.56): in
/// 100000ths of the shorter side, at most [`MAX_CORNER`]; `None` draws [`DEFAULT_CORNER`].
#[serde(skip_serializing_if = "Option::is_none")]
+20 -4
View File
@@ -499,10 +499,26 @@ impl Reader<'_> {
.and_then(|g| preset_adj(g, "adj"))
.and_then(|v| u32::try_from(v.clamp(0, i64::from(wordcraft_doc::connector::MAX_CORNER))).ok());
}
// Arrowheads: any end type but `none` (ECMA-376 §20.1.10.33) is drawn as a triangle.
let arrow = |tag: &str| ln.and_then(|l| l.child(tag)).and_then(|e| e.attr("type")).is_some_and(|t| t != "none");
extra.arrow_start = arrow("a:headEnd");
extra.arrow_end = arrow("a:tailEnd");
// Arrowheads: any end type but `none` (ECMA-376 §20.1.10.33) is drawn as a triangle of its
// width and length.
let arrow = |tag: &str| {
let e = ln.and_then(|l| l.child(tag))?;
let kind = e.attr("type").filter(|t| *t != "none")?;
let size = |name: &str| wordcraft_doc::connector::ArrowSize::from_ooxml(e.attr(name).unwrap_or("med"));
Some(wordcraft_doc::connector::ArrowStyle {
kind: wordcraft_doc::connector::ArrowKind::from_ooxml(kind),
width: size("w"),
length: size("len"),
})
};
if let Some(style) = arrow("a:headEnd") {
extra.arrow_start = true;
extra.start_style = style;
}
if let Some(style) = arrow("a:tailEnd") {
extra.arrow_end = true;
extra.end_style = style;
}
// A connector's glued ends (ECMA-376 §20.1.2.2.36, §20.1.2.2.13).
if let Some(cn) = wsp.child("wps:cNvCnPr") {
let end = |tag: &str| {
+18 -4
View File
@@ -296,10 +296,24 @@ impl Reader<'_> {
let kind = kind?;
let mut extra = ShapeExtra { cap, ..Default::default() };
if kind == ShapeKind::Line {
// 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");
// Arrowheads (`v:stroke` startarrow / endarrow, with their width and length): any but
// `none` is drawn.
use wordcraft_doc::connector::{ArrowKind, ArrowSize, ArrowStyle};
let stroke = shape.child("v:stroke");
let arrow = |end: &str| {
let s = stroke?;
let kind = s.attr(&format!("{end}arrow")).filter(|v| *v != "none")?;
let size = |what: &str| ArrowSize::from_vml(s.attr(&format!("{end}arrow{what}")).unwrap_or("medium"));
Some(ArrowStyle { kind: ArrowKind::from_vml(kind), width: size("width"), length: size("length") })
};
if let Some(style) = arrow("start") {
extra.arrow_start = true;
extra.start_style = style;
}
if let Some(style) = arrow("end") {
extra.arrow_end = true;
extra.end_style = style;
}
}
if shape.local() == "line" {
// A line runs `from` → `to` (points, offsets from where its style places it). A preset
+4 -5
View File
@@ -382,11 +382,10 @@ fn line_dash(w: &mut W, dash: &wordcraft_doc::line_dash::LineDash) {
/// A shape's `a:effectLst` (ECMA-376 Part 1 §20.1.8.26), children in schema order.
/// A line's arrowheads (`a:headEnd`, `a:tailEnd`, ECMA-376 §20.1.8.38 / §20.1.8.57).
fn arrow_ends(w: &mut W, extra: &wordcraft_doc::connector::ShapeExtra) {
if extra.arrow_start {
w.empty("a:headEnd", &[("type", "triangle")]);
}
if extra.arrow_end {
w.empty("a:tailEnd", &[("type", "triangle")]);
for (on, tag, style) in [(extra.arrow_start, "a:headEnd", extra.start_style), (extra.arrow_end, "a:tailEnd", extra.end_style)] {
if on {
w.empty(tag, &[("type", style.kind.ooxml()), ("w", style.width.ooxml()), ("len", style.length.ooxml())]);
}
}
}
+46
View File
@@ -2475,6 +2475,52 @@ fn vml_lines_and_rectangles() {
));
}
/// An arrowhead's kind, width and length (DrawingML `a:headEnd`/`a:tailEnd` `type`/`w`/`len`, VML
/// `startarrow`/`startarrowwidth`/`startarrowlength`) are read and saved: they set the size Word
/// draws it at. An end without `w`/`len` is medium.
#[test]
fn arrowheads_keep_their_kind_and_size() {
use wordcraft_doc::connector::{ArrowKind, ArrowSize, ArrowStyle};
let style = |kind, width, length| ArrowStyle { kind, width, length };
let body = [
anchored_wsp(
"Line",
1270000,
0,
r#"<a:prstGeom prst="line"><a:avLst/></a:prstGeom><a:ln w="12700"><a:solidFill><a:srgbClr val="000000"/></a:solidFill><a:headEnd type="stealth" w="sm" len="lg"/><a:tailEnd type="diamond" w="lg" len="sm"/></a:ln>"#,
"",
"<wps:cNvCnPr/>",
),
anchored_wsp("Plain", 1270000, 0, r#"<a:prstGeom prst="line"><a:avLst/></a:prstGeom><a:ln><a:solidFill><a:srgbClr val="000000"/></a:solidFill><a:tailEnd type="triangle"/></a:ln>"#, "", "<wps:cNvCnPr/>"),
r#"<w:r><w:pict><v:line style="position:absolute" from="10pt,20pt" to="40pt,20pt"><v:stroke startarrow="classic" startarrowwidth="narrow" startarrowlength="long" endarrow="oval" endarrowwidth="wide" endarrowlength="short"/></v:line></w:pict></w:r>"#.to_string(),
]
.concat();
let ends = |d: &Document| -> Vec<(bool, ArrowStyle, bool, ArrowStyle)> {
paras(d)[0]
.objects
.iter()
.map(|o| match o {
InlineObject::Shape { extra, .. } => (extra.arrow_start, extra.start_style, extra.arrow_end, extra.end_style),
o => panic!("{o:?}"),
})
.collect()
};
let expected = vec![
(true, style(ArrowKind::Stealth, ArrowSize::Small, ArrowSize::Large), true, style(ArrowKind::Diamond, ArrowSize::Large, ArrowSize::Small)),
(false, ArrowStyle::default(), true, ArrowStyle::default()),
(true, style(ArrowKind::Stealth, ArrowSize::Small, ArrowSize::Large), true, style(ArrowKind::Oval, ArrowSize::Large, ArrowSize::Small)),
];
let d = read_body(&format!("<w:p>{body}</w:p>"));
assert_eq!(ends(&d), expected);
let bytes = wordcraft_docx::write(&d).unwrap();
let mut xml = String::new();
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes.as_slice())).unwrap();
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
assert!(xml.contains(r#"<a:headEnd type="stealth" w="sm" len="lg"/>"#), "{xml}");
assert!(xml.contains(r#"<a:tailEnd type="diamond" w="lg" len="sm"/>"#), "{xml}");
assert_eq!(ends(&wordcraft_docx::read(&bytes).unwrap()), expected);
}
/// The right arrow's shapetype as Word writes it (ECMA-376 Part 4 `v:shapetype`, `o:spt` 13).
const VML_RIGHT_ARROW_TYPE: &str = r#"<v:shapetype id="_x0000_t13" coordsize="21600,21600" o:spt="13" adj="16200,5400" path="m@0,l@0@1,0@1,0@2@0@2@0,21600,21600,10800xe"><v:stroke joinstyle="miter"/><v:formulas><v:f eqn="val #0"/><v:f eqn="val #1"/></v:formulas><v:path o:connecttype="custom"/></v:shapetype>"#;
+17 -19
View File
@@ -1,6 +1,7 @@
use super::options::DisplayOptions;
use super::paint::{Paint, paint_for};
use super::{BezPath, Document, Draw, FaceRef, Graphic, GraphicItem, GraphicKind, InlineObject, PathSeg, Rect, Rgb, ShapeKind, Spin, TextAlign};
use wordcraft_doc::connector::ArrowStyle;
/// The draws of a chart or diagram's items inside `rect` (page coordinates). The items were built
/// for the size `g.w` × `g.h` (0: `rect`'s own size), so they are scaled to `rect`; strokes and text
@@ -137,7 +138,7 @@ pub(super) fn shape_draws(rect: Rect, look: &ShapeLook, opts: &DisplayOptions, o
}
if !closed
&& extra.arrow_end
&& let Some(arrow) = path_arrowhead(&pts, color, width)
&& let Some(arrow) = path_arrowhead(&pts, color, width, extra.end_style)
{
out.push(arrow);
}
@@ -166,7 +167,7 @@ pub(super) fn shape_draws(rect: Rect, look: &ShapeLook, opts: &DisplayOptions, o
&& let Some(color) = stroke
{
let points: Vec<(f32, f32)> = pts.iter().map(|[x, y]| (*x, *y)).collect();
if let Some(arrow) = path_arrowhead(&points, color, width) {
if let Some(arrow) = path_arrowhead(&points, color, width, extra.end_style) {
out.push(arrow);
}
}
@@ -179,10 +180,10 @@ fn path_stroke_width(width: f32) -> f32 {
}
/// Make a filled triangular arrowhead at the last point, in the points' coordinate space.
/// The last distinct segment supplies its direction and the bounded stroke width sets its size.
/// The last distinct segment supplies its direction; `style` and the stroke width set its size.
/// Returns `None` if there is no usable final segment. The triangle has no outline, so the path's
/// cap setting does not affect its appearance.
fn path_arrowhead(points: &[(f32, f32)], color: Rgb, stroke_width: f32) -> Option<Draw> {
fn path_arrowhead(points: &[(f32, f32)], color: Rgb, stroke_width: f32, style: ArrowStyle) -> Option<Draw> {
let tip = *points.last()?;
let (prev_x, prev_y) = points.iter().rev().skip(1).copied().find(|p| *p != tip)?;
let dx = tip.0 - prev_x;
@@ -191,13 +192,17 @@ fn path_arrowhead(points: &[(f32, f32)], color: Rgb, stroke_width: f32) -> Optio
if !length.is_finite() || length <= f32::EPSILON {
return None;
}
let (dx, dy) = (dx / length, dy / length);
let width = if stroke_width.is_finite() { stroke_width.clamp(0.25, 200.0) } else { 0.75 };
let len = (width * 3.5).max(7.0);
Some(arrowhead(tip, (dx / length, dy / length), stroke_width, style, color))
}
/// A filled triangular arrowhead with its tip at `tip`, pointing along unit vector `dir`: as long
/// and as wide as `style` makes it for a `stroke_width` line ([`ArrowStyle::dims`]).
fn arrowhead(tip: (f32, f32), (dx, dy): (f32, f32), stroke_width: f32, style: ArrowStyle, color: Rgb) -> Draw {
let (len, wide) = style.dims(stroke_width);
let (bx, by) = (tip.0 - dx * len, tip.1 - dy * len);
let (nx, ny) = (-dy * len * 0.45, dx * len * 0.45);
let (nx, ny) = (-dy * wide / 2.0, dx * wide / 2.0);
let segs = vec![PathSeg::Move(tip.0, tip.1), PathSeg::Line(bx + nx, by + ny), PathSeg::Line(bx - nx, by - ny), PathSeg::Close];
Some(Draw::Path { segs, fill: Some(Paint::solid(color)), stroke: None, stroke_width: 0.0, dash: Vec::new(), cap: Default::default() })
Draw::Path { segs, fill: Some(Paint::solid(color)), stroke: None, stroke_width: 0.0, dash: Vec::new(), cap: Default::default() }
}
/// A line or connector at `rect` (its unflipped frame: flips are the frame's turn), with its
@@ -243,17 +248,10 @@ fn line_draws(
// to the bottom-right.
let (start, end) =
if kind == ShapeKind::Line { ((rect.x, rect.bottom()), (rect.right(), rect.y)) } else { ((rect.x, rect.y), (rect.right(), rect.bottom())) };
let width = if stroke_width.is_finite() { stroke_width.clamp(0.25, 200.0) } else { 0.75 };
let len = (width * 3.5).max(7.0);
for (on, tip, at_end) in [(extra.arrow_start, start, false), (extra.arrow_end, end, true)] {
if !on {
continue;
for (on, tip, at_end, style) in [(extra.arrow_start, start, false, extra.start_style), (extra.arrow_end, end, true, extra.end_style)] {
if on {
out.push(arrowhead(tip, end_direction(kind, rect.w, rect.h, at_end), stroke_width, style, color));
}
let (dx, dy) = end_direction(kind, rect.w, rect.h, at_end);
let (bx, by) = (tip.0 - dx * len, tip.1 - dy * len);
let (nx, ny) = (-dy * len * 0.45, dx * len * 0.45);
let segs = vec![PathSeg::Move(tip.0, tip.1), PathSeg::Line(bx + nx, by + ny), PathSeg::Line(bx - nx, by - ny), PathSeg::Close];
out.push(Draw::Path { segs, fill: Some(Paint::solid(color)), stroke: None, stroke_width: 0.0, dash: Vec::new(), cap: Default::default() });
}
}
+55
View File
@@ -3285,6 +3285,61 @@ fn freeform_arrow_end_draws_at_the_last_point() {
assert_eq!(arrow_tip, endpoint, "the arrowhead tip meets the custom path's final point");
}
#[test]
fn an_arrowhead_is_as_long_and_wide_as_its_sizes_make_it() {
// Word draws an arrowhead 2, 3 or 5 times the line's width long and wide (small, medium,
// large), and a line thinner than 2 points as if it were 2 points (measured in Word's PDFs).
use wordcraft_doc::connector::{ArrowKind, ArrowSize, ArrowStyle, ShapeExtra};
use wordcraft_doc::graphic::PathSeg;
use wordcraft_doc::para::{Float, ShapeKind};
use wordcraft_doc::props::Rgb;
let style = |width, length| ArrowStyle { kind: ArrowKind::Triangle, width, length };
let cases = [
(0.75, style(ArrowSize::Small, ArrowSize::Large), style(ArrowSize::Large, ArrowSize::Small), (10.0, 4.0), (4.0, 10.0)),
(0.75, ArrowStyle::default(), ArrowStyle::default(), (6.0, 6.0), (6.0, 6.0)),
(3.0, style(ArrowSize::Medium, ArrowSize::Large), style(ArrowSize::Small, ArrowSize::Medium), (15.0, 9.0), (9.0, 6.0)),
(6.0, style(ArrowSize::Large, ArrowSize::Large), style(ArrowSize::Small, ArrowSize::Small), (30.0, 30.0), (12.0, 12.0)),
];
for (line_width, start_style, end_style, start_dims, end_dims) in cases {
let shape = InlineObject::Shape {
kind: ShapeKind::Line,
w: 100.0,
h: 0.0,
fill: None,
stroke: Some(Rgb::BLACK),
stroke_width: line_width,
float: Float::default(),
story: None,
freeform: None,
effects: Default::default(),
extra: ShapeExtra { arrow_start: true, arrow_end: true, start_style, end_style, ..Default::default() },
};
let mut doc = Document::from_text("Scan");
doc.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap();
let draws = flat(display::page_display(&doc, &lay(&doc).pages[0], &Default::default()));
// Each arrowhead: its length (tip to the middle of its base) and width (across its base),
// the start's first.
let heads: Vec<(f32, f32)> = draws
.iter()
.filter_map(|draw| match draw {
display::Draw::Path { fill: Some(display::Paint::Solid(Rgb::BLACK, _)), segs, .. } => match segs.as_slice() {
[PathSeg::Move(tx, ty), PathSeg::Line(ax, ay), PathSeg::Line(bx, by), PathSeg::Close] => {
let (mx, my) = ((ax + bx) / 2.0, (ay + by) / 2.0);
Some(((tx - mx).hypot(ty - my), (ax - bx).hypot(ay - by)))
}
_ => None,
},
_ => None,
})
.collect();
assert_eq!(heads.len(), 2, "{line_width}: {heads:?}");
for ((got, want), end) in heads.iter().zip([start_dims, end_dims]).zip(["start", "end"]) {
assert!((got.0 - want.0).abs() < 0.01 && (got.1 - want.1).abs() < 0.01, "{line_width} pt line, {end}: got {got:?}, want {want:?}");
}
}
}
#[test]
fn hidden_drawings_are_neither_drawn_nor_take_room() {
// A hidden floating shape (VML `visibility:hidden`, `wp:docPr/@hidden`): no wrap area, nothing
File diff suppressed because one or more lines are too long
+2 -1
View File
File diff suppressed because one or more lines are too long