Shape Format: Shape Effects — shadow, glow and soft edges (#275)

Shapes and text boxes get an effects model (outer shadow with colour,
transparency, blur, distance and angle; glow with colour, size and
transparency; soft-edge radius), the shape.effects command (presets,
custom values or null per effect, applied to every selected shape,
undoable, hostile numbers clamped), screen and PDF rendering with the
same banded approximation of a blur, DOCX a:effectLst read/write
(outerShdw, glow, softEdge) with a matching wp:effectExtent, and a
contextual Shape Format tab with Shape Fill, Shape Outline and Shape
Effects (Shadow, Glow, Soft Edges submenus) plus Arrange.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-10 19:35:05 +02:00
co-authored by Claude Opus 5.5
parent 40c5d51bf9
commit 2f8f304fc9
25 changed files with 959 additions and 23 deletions
+55 -2
View File
@@ -2,6 +2,7 @@
use std::sync::Arc;
use wordcraft_doc::effects::{Glow, Shadow, ShapeEffects};
use wordcraft_doc::para::{Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap};
use wordcraft_doc::props::{CharProps, Rgb};
use wordcraft_doc::table::{Cell, MAX_COLS, MAX_ROWS, Row, Table};
@@ -695,11 +696,12 @@ impl Reader<'_> {
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)),
_ => None,
};
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story }
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story, effects }
}
fn read_textbox(&mut self, sc: &StoryCtx, content: &El, rels: &Rels) -> u32 {
@@ -729,7 +731,17 @@ impl Reader<'_> {
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, float, story });
return Some(InlineObject::Shape {
kind: ShapeKind::TextBox,
w,
h,
fill,
stroke,
stroke_width: 0.75,
float,
story,
effects: Default::default(),
});
}
None
}
@@ -914,3 +926,44 @@ fn vml_float(shape: &El, style: &str) -> Float {
..Float::default()
}
}
/// A shape's `a:effectLst` (ECMA-376 Part 1 §20.1.8.26): its outer shadow, glow and soft edges.
/// Other effects are dropped.
fn effect_list(l: &El) -> ShapeEffects {
let pt = |e: &El, n: &str| e.attr(n).and_then(|v| measure(v, 12_700.0)).unwrap_or(0.0);
let shadow = l.child("a:outerShdw").map(|e| {
let (color, transparency) = effect_color(e);
// `dir`: 60000ths of a degree, clockwise.
let angle = e.attr("dir").and_then(int).map(|v| (v.rem_euclid(21_600_000) as f32) / 60_000.0).unwrap_or(0.0);
Shadow { color: color.unwrap_or(Rgb::BLACK), transparency, blur: pt(e, "blurRad"), distance: pt(e, "dist"), angle }
});
let glow = l.child("a:glow").map(|e| {
let (color, transparency) = effect_color(e);
Glow { color: color.unwrap_or(Glow::default().color), size: pt(e, "rad"), transparency }
});
let soft_edge = l.child("a:softEdge").map(|e| pt(e, "rad"));
ShapeEffects { shadow, glow, soft_edge }.sanitized()
}
/// The colour inside an effect (`a:srgbClr`, `a:prstClr`, `a:sysClr`; theme colours aren't
/// resolved here) and its transparency, percent (from `a:alpha`, opaque when absent).
fn effect_color(e: &El) -> (Option<Rgb>, f32) {
let Some(c) = e.els().find(|c| matches!(c.name.as_str(), "a:srgbClr" | "a:prstClr" | "a:sysClr" | "a:schemeClr" | "a:scrgbClr" | "a:hslClr"))
else {
return (None, 0.0);
};
let rgb = match c.name.as_str() {
"a:srgbClr" => c.attr("val").and_then(Rgb::parse),
"a:sysClr" => c.attr("lastClr").and_then(Rgb::parse),
"a:prstClr" => match c.attr("val").unwrap_or("") {
"black" => Some(Rgb::BLACK),
"white" => Some(Rgb::WHITE),
"gray" | "grey" => Some(Rgb(0x80, 0x80, 0x80)),
_ => None,
},
_ => None,
};
// `a:alpha`: opacity in 1000ths of a percent.
let opacity = c.child("a:alpha").and_then(|a| a.attr("val")).and_then(int).map(|v| v.clamp(0, 100_000) as f32 / 1000.0).unwrap_or(100.0);
(rgb, 100.0 - opacity)
}
+54 -2
View File
@@ -1,7 +1,8 @@
//! Blocks, paragraphs, runs, objects and tables → WordprocessingML.
use wordcraft_doc::effects::ShapeEffects;
use wordcraft_doc::para::{Anchor, Float, NoteKind, ShapeKind, Wrap};
use wordcraft_doc::props::CharProps;
use wordcraft_doc::props::{CharProps, Rgb};
use wordcraft_doc::section::{LineNumberRestart, SectionProps, SectionStart};
use wordcraft_doc::table::Table;
use wordcraft_doc::{Block, Blocks, InlineObject, Paragraph, RevisionKind};
@@ -414,13 +415,19 @@ impl Writer<'_> {
w.close("w:r");
self.rev_close(w, props);
}
InlineObject::Shape { kind, w: sw, h: sh, fill, stroke, stroke_width, float, story } => {
InlineObject::Shape { kind, w: sw, h: sh, fill, stroke, stroke_width, float, story, effects } => {
self.rev_open(w, props);
w.open("w:r", &[]);
rpr(w, props);
w.open("w:drawing", &[]);
let docpr = self.next_docpr();
let name = format!("Shape {docpr}");
// The effect extent leaves room for the shadow and glow.
let mut float = *float;
for (e, fx) in float.effect.iter_mut().zip(effects.extent()) {
*e = wordcraft_geom::finite(*e).max(fx);
}
let float = &float;
self.drawing_open(w, float, *sw, *sh, &docpr, &name, "");
w.empty("wp:cNvGraphicFramePr", &[]);
w.open("a:graphic", &[]);
@@ -469,6 +476,7 @@ impl Writer<'_> {
w.close("a:ln");
}
}
effect_list(w, effects);
w.close("wps:spPr");
// Its text, within the same bounds layout shows boxes inside boxes with.
if let Some(id) = *story
@@ -774,6 +782,50 @@ impl Writer<'_> {
}
}
/// A shape's `a:effectLst` (ECMA-376 Part 1 §20.1.8.26), children in schema order.
fn effect_list(w: &mut W, effects: &ShapeEffects) {
let fx = effects.sanitized();
if fx.is_empty() {
return;
}
let color = |w: &mut W, c: Rgb, transparency: f32| {
let alpha = n(((100.0 - transparency.clamp(0.0, 100.0)) * 1000.0).round() as i64);
w.open("a:srgbClr", &[("val", &c.hex())]);
w.empty("a:alpha", &[("val", &alpha)]);
w.close("a:srgbClr");
};
w.open("a:effectLst", &[]);
if let Some(g) = fx.glow {
w.open("a:glow", &[("rad", &emu(g.size))]);
color(w, g.color, g.transparency);
w.close("a:glow");
}
if let Some(s) = fx.shadow {
let dir = n((s.angle * 60_000.0).round() as i64);
// Anchor scaling at the corner the shadow falls away from (no scaling is written, so
// this only matters to editors that resize it).
let (dx, dy) = s.offset();
let algn = match (dy > 0.01, dy < -0.01, dx > 0.01, dx < -0.01) {
(true, _, true, _) => "tl",
(true, _, _, true) => "tr",
(true, _, _, _) => "t",
(_, true, true, _) => "bl",
(_, true, _, true) => "br",
(_, true, _, _) => "b",
(_, _, true, _) => "l",
(_, _, _, true) => "r",
_ => "ctr",
};
w.open("a:outerShdw", &[("blurRad", &emu(s.blur)), ("dist", &emu(s.distance)), ("dir", &dir), ("algn", algn), ("rotWithShape", "0")]);
color(w, s.color, s.transparency);
w.close("a:outerShdw");
}
if let Some(r) = fx.soft_edge {
w.empty("a:softEdge", &[("rad", &emu(r))]);
}
w.close("a:effectLst");
}
fn xfrm(w: &mut W, cw: f32, ch: f32) {
w.open("a:xfrm", &[]);
w.empty("a:off", &[("x", "0"), ("y", "0")]);
+50 -1
View File
@@ -670,6 +670,7 @@ fn toc_field_skips_nested_stories() {
stroke_width: 0.0,
float: Float::default(),
story: Some(story),
effects: Default::default(),
}
};
let mut head = Paragraph::with_text("Contents", CharProps::default()).styled("TOCHeading");
@@ -791,6 +792,7 @@ fn shapes_textboxes_equations_dropcaps_round_trip() {
stroke_width: 1.0,
float: Float { wrap: Wrap::Square, h_rel: Anchor::Margin, v_rel: Anchor::Paragraph, x: 10.0, y: 20.0, dist: 0.0, ..Default::default() },
story: Some(story),
effects: Default::default(),
};
let star = InlineObject::Shape {
kind: ShapeKind::Star,
@@ -801,6 +803,7 @@ fn shapes_textboxes_equations_dropcaps_round_trip() {
stroke_width: 0.0,
float: Float::default(),
story: None,
effects: Default::default(),
};
let eq = InlineObject::Equation { linear: "x=(-b±√(b^2-4ac))/2a".into(), display: false, math: Default::default() };
let mut p = Paragraph::with_text("shapes ", CharProps::default());
@@ -816,7 +819,7 @@ fn shapes_textboxes_equations_dropcaps_round_trip() {
let got = paras(&r);
assert_eq!(got[0].objects.len(), 3);
match &got[0].objects[0] {
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story } => {
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story, .. } => {
assert_eq!(
(*kind, *w, *h, *fill, *stroke, *stroke_width),
(ShapeKind::TextBox, 144.0, 72.0, Some(Rgb(255, 255, 200)), Some(Rgb(0, 0, 0)), 1.0)
@@ -1017,6 +1020,7 @@ fn self_showing_text_box_saves_bounded() {
stroke_width: 0.0,
float: Float::default(),
story: Some(id),
effects: Default::default(),
};
for _ in 0..30 {
d.insert_object(
@@ -1097,3 +1101,48 @@ fn list_level_overrides_round_trip() {
assert_eq!(c.next_label(&back.numbering, restart, 0).unwrap().0, "1.");
assert_eq!(c.next_label(&back.numbering, restart, 1).unwrap().0, "1.01");
}
/// Shape effects (#275): `a:effectLst` with an outer shadow, a glow and soft edges round-trips,
/// and the effect extent leaves room for them.
#[test]
fn shape_effects_round_trip() {
use wordcraft_doc::effects::{Glow, Shadow, ShapeEffects};
let effects = ShapeEffects {
shadow: Some(Shadow { color: Rgb(0x20, 0x30, 0x40), transparency: 60.0, blur: 4.0, distance: 3.0, angle: 135.0 }),
glow: Some(Glow { color: Rgb(0xC0, 0x50, 0x10), size: 8.0, transparency: 40.0 }),
soft_edge: Some(2.5),
};
let shape = InlineObject::Shape {
kind: ShapeKind::RoundedRectangle,
w: 100.0,
h: 50.0,
fill: Some(Rgb(0x15, 0x60, 0x82)),
stroke: None,
stroke_width: 0.0,
float: Float::default(),
story: None,
effects,
};
let mut p = Paragraph::with_text("x", CharProps::default());
p.insert_object(1, shape, &CharProps::default()).unwrap();
let bytes = wordcraft_docx::write(&doc_with(vec![p])).expect("write");
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
assert!(
xml.contains(
r#"<a:outerShdw blurRad="50800" dist="38100" dir="8100000" algn="tr" rotWithShape="0"><a:srgbClr val="203040"><a:alpha val="40000"/>"#
),
"{xml}"
);
assert!(xml.contains(r#"<a:glow rad="101600"><a:srgbClr val="C05010"><a:alpha val="60000"/>"#), "{xml}");
assert!(xml.contains(r#"<a:softEdge rad="31750"/>"#), "{xml}");
let r = wordcraft_docx::read(&bytes).expect("read");
match paras(&r)[0].objects.first() {
Some(InlineObject::Shape { effects: got, float, .. }) => {
assert_eq!(*got, effects);
assert_eq!(float.effect_extent(), effects.extent());
}
o => panic!("{o:?}"),
}
}