mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-30 13:27:27 -06:00
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:
co-authored by
Claude Opus 5.5
parent
40c5d51bf9
commit
2f8f304fc9
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")]);
|
||||
|
||||
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user