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
parent 40c5d51bf9
commit 2f8f304fc9
25 changed files with 959 additions and 23 deletions

View File

@ -501,6 +501,7 @@ mod tests {
stroke_width: 0.75,
float: Default::default(),
story,
effects: Default::default(),
}
}

224
crates/doc/src/effects.rs Normal file
View File

@ -0,0 +1,224 @@
//! Shape effects (DrawingML `a:effectLst`, ECMA-376 Part 1 §20.1.8): an outer shadow, a glow and
//! soft edges. Lengths are points, angles degrees, transparencies percent (0 = opaque).
//!
//! Renderers approximate the blurs with [`bands`]: a few copies of the shape's silhouette, grown
//! or shrunk by steps and overlaid with partial opacity, so the screen and PDF output (which has
//! no blur filter) match.
use serde::{Deserialize, Serialize};
use crate::props::Rgb;
/// Largest blur radius, points.
pub const MAX_BLUR: f32 = 100.0;
/// Largest shadow distance, points.
pub const MAX_DISTANCE: f32 = 200.0;
/// Largest glow size, points.
pub const MAX_GLOW: f32 = 150.0;
/// Largest soft-edge radius, points.
pub const MAX_SOFT_EDGE: f32 = 100.0;
/// The effects on a shape. Empty (the default) draws the shape plainly.
#[derive(Clone, Copy, Debug, PartialEq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ShapeEffects {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shadow: Option<Shadow>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub glow: Option<Glow>,
/// Soft-edge radius, points: the shape fades out over this distance inside its outline.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub soft_edge: Option<f32>,
}
/// An outer shadow: a blurred copy of the shape's silhouette cast `distance` points away in
/// direction `angle` (degrees clockwise from the positive x axis, y pointing down).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct Shadow {
pub color: Rgb,
pub transparency: f32,
pub blur: f32,
pub distance: f32,
pub angle: f32,
}
impl Default for Shadow {
/// "Offset: Bottom Right".
fn default() -> Self {
Shadow { color: Rgb::BLACK, transparency: 60.0, blur: 4.0, distance: 3.0, angle: 45.0 }
}
}
/// A glow: a soft band of colour `size` points wide around the shape's outline.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct Glow {
pub color: Rgb,
pub size: f32,
pub transparency: f32,
}
impl Default for Glow {
fn default() -> Self {
Glow { color: Rgb(0x15, 0x60, 0x82), size: 5.0, transparency: 60.0 }
}
}
fn clamp(v: f32, lo: f32, hi: f32, dflt: f32) -> f32 {
if v.is_finite() { v.clamp(lo, hi) } else { dflt }
}
/// The outer-shadow presets (no perspective shadows): id, label.
pub const SHADOW_PRESETS: [(&str, &str); 9] = [
("offsetBottomRight", "Offset: Bottom Right"),
("offsetBottom", "Offset: Bottom"),
("offsetBottomLeft", "Offset: Bottom Left"),
("offsetRight", "Offset: Right"),
("offsetCenter", "Offset: Center"),
("offsetLeft", "Offset: Left"),
("offsetTopRight", "Offset: Top Right"),
("offsetTop", "Offset: Top"),
("offsetTopLeft", "Offset: Top Left"),
];
impl Shadow {
/// A preset by id (see [`SHADOW_PRESETS`]).
pub fn preset(id: &str) -> Option<Shadow> {
let d = Shadow::default();
let angle = match id {
"offsetBottomRight" => 45.0,
"offsetBottom" => 90.0,
"offsetBottomLeft" => 135.0,
"offsetLeft" => 180.0,
"offsetTopLeft" => 225.0,
"offsetTop" => 270.0,
"offsetTopRight" => 315.0,
"offsetRight" => 0.0,
"offsetCenter" => return Some(Shadow { distance: 0.0, blur: 5.0, ..d }),
_ => return None,
};
Some(Shadow { angle, ..d })
}
/// Finite, in range.
pub fn sanitized(self) -> Shadow {
let angle = clamp(self.angle, -1e6, 1e6, 45.0).rem_euclid(360.0);
Shadow {
color: self.color,
transparency: clamp(self.transparency, 0.0, 100.0, 60.0),
blur: clamp(self.blur, 0.0, MAX_BLUR, 0.0),
distance: clamp(self.distance, 0.0, MAX_DISTANCE, 0.0),
angle: if angle.is_finite() && angle < 360.0 { angle } else { 0.0 },
}
}
/// The shadow's offset from the shape, points (x right, y down).
pub fn offset(&self) -> (f32, f32) {
let s = self.sanitized();
let a = s.angle.to_radians();
(s.distance * a.cos(), s.distance * a.sin())
}
/// Opacity 0..1.
pub fn opacity(&self) -> f32 {
1.0 - self.sanitized().transparency / 100.0
}
}
impl Glow {
/// Finite, in range.
pub fn sanitized(self) -> Glow {
Glow { color: self.color, size: clamp(self.size, 0.0, MAX_GLOW, 0.0), transparency: clamp(self.transparency, 0.0, 100.0, 60.0) }
}
/// Opacity 0..1.
pub fn opacity(&self) -> f32 {
1.0 - self.sanitized().transparency / 100.0
}
}
impl ShapeEffects {
pub fn is_empty(&self) -> bool {
self.shadow.is_none() && self.glow.is_none() && self.soft_edge.is_none()
}
/// Finite, in range; zero-size glows and soft edges dropped.
pub fn sanitized(self) -> ShapeEffects {
ShapeEffects {
shadow: self.shadow.map(Shadow::sanitized),
glow: self.glow.map(Glow::sanitized).filter(|g| g.size > 0.0),
soft_edge: self.soft_edge.map(|r| clamp(r, 0.0, MAX_SOFT_EDGE, 0.0)).filter(|r| *r > 0.0),
}
}
/// How far the effects reach beyond the shape's frame on each side (left, top, right,
/// bottom), points — what `wp:effectExtent` records.
pub fn extent(&self) -> [f32; 4] {
let e = self.sanitized();
let mut out = [0.0f32; 4];
if let Some(g) = e.glow {
out = [g.size; 4];
}
if let Some(s) = e.shadow {
let (dx, dy) = s.offset();
let r = s.blur / 2.0;
let reach = [r - dx, r - dy, r + dx, r + dy];
for (o, v) in out.iter_mut().zip(reach) {
*o = o.max(v.max(0.0));
}
}
out.map(|v| (v * 100.0).round() / 100.0)
}
}
/// Bands approximating a blurred edge: `(grow, alpha)` pairs. Each is a copy of the silhouette
/// grown outward by `grow` points (negative shrinks it), filled with opacity `alpha`; overlaid in
/// any order, in one colour, they ramp linearly from `opacity` at `from` to nothing at `to`.
pub fn bands(from: f32, to: f32, opacity: f32) -> Vec<(f32, f32)> {
let (from, to) = (clamp(from, -1000.0, 1000.0, 0.0), clamp(to, -1000.0, 1000.0, 0.0));
let opacity = clamp(opacity, 0.0, 1.0, 0.0);
if opacity <= 0.0 {
return Vec::new();
}
let span = (to - from).max(0.0);
if span < 0.25 {
return vec![((from + to) / 2.0, opacity)];
}
let n = ((span / 1.5).ceil() as usize).clamp(3, 12);
let nf = n as f32;
(1..=n)
.map(|m| {
// The m-th band from the outside: after it, coverage is opacity·m/n.
let grow = to - span * (m as f32 - 0.5) / nf;
let before = 1.0 - opacity * (m as f32 - 1.0) / nf;
let after = 1.0 - opacity * m as f32 / nf;
let alpha = if before > 0.0 { 1.0 - after / before } else { 1.0 };
(grow, alpha.clamp(0.0, 1.0))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bands_ramp_to_the_requested_opacity() {
for (from, to, o) in [(-2.0, 2.0, 0.4), (-5.0, 0.0, 1.0), (0.0, 8.0, 0.6)] {
let b = bands(from, to, o);
assert!(b.iter().all(|(g, a)| *g > from && *g < to && (0.0..=1.0).contains(a)));
let covered = 1.0 - b.iter().map(|(_, a)| 1.0 - a).product::<f32>();
assert!((covered - o).abs() < 1e-4, "{covered} vs {o}");
}
assert_eq!(bands(0.0, 0.0, 0.5), vec![(0.0, 0.5)]);
assert!(bands(f32::NAN, f32::INFINITY, f32::NAN).is_empty());
}
#[test]
fn hostile_numbers_are_clamped() {
let s = Shadow { color: Rgb::BLACK, transparency: f32::NAN, blur: 1e9, distance: -4.0, angle: -90.0 }.sanitized();
assert_eq!((s.transparency, s.blur, s.distance, s.angle), (60.0, MAX_BLUR, 0.0, 270.0));
let e = ShapeEffects { shadow: None, glow: Some(Glow { size: 1e9, ..Glow::default() }), soft_edge: Some(-1.0) }.sanitized();
assert_eq!(e.glow.map(|g| g.size), Some(MAX_GLOW));
assert_eq!(e.soft_edge, None);
let e = ShapeEffects { glow: Some(Glow { size: f32::INFINITY, ..Glow::default() }), ..Default::default() }.sanitized();
assert!(e.is_empty(), "a non-finite size is no glow");
let (dx, dy) = Shadow::preset("offsetBottomRight").unwrap().offset();
assert!(dx > 2.0 && dy > 2.0);
}
}

View File

@ -12,6 +12,7 @@
pub mod bidi;
pub mod edit;
pub mod effects;
pub mod encoding;
pub mod fields;
pub mod math;

View File

@ -213,6 +213,9 @@ pub enum InlineObject {
/// Text box content: `Document::parts` id.
#[serde(default)]
story: Option<u32>,
/// Shadow, glow and soft edges.
#[serde(default, skip_serializing_if = "crate::effects::ShapeEffects::is_empty")]
effects: crate::effects::ShapeEffects,
},
/// A field: `instr` is the field code (`PAGE`, `NUMPAGES`, `DATE \@ "M/d/yyyy"`, `TOC \o "1-3"`…);
/// `result` the cached display text.

View File

@ -88,6 +88,7 @@ fn text_box_anchor_finds_the_owning_shape() {
stroke_width: 0.75,
float: Default::default(),
story,
effects: Default::default(),
};
// A plain shape before it doesn't count.
d.insert_object(&Pos::body(0, 0), shape(None), &CharProps::default()).unwrap();
@ -108,6 +109,7 @@ fn word_count_including_notes_counts_used_boxes_and_notes_only() {
stroke_width: 0.75,
float: Default::default(),
story: Some(story),
effects: Default::default(),
};
let para_of = |t: &str| vec![para_block(Paragraph::with_text(t, CharProps::default()))];
let boxed = d.add_part(PartKind::TextBox, para_of("three four"));
@ -146,6 +148,7 @@ fn counted_stories_survive_self_nested_boxes() {
stroke_width: 0.0,
float: Default::default(),
story: Some(id),
effects: Default::default(),
};
d.insert_object(&Pos::body(0, 0), shape.clone(), &CharProps::default()).unwrap();
d.insert_object(&Pos { story: StoryRef::Part(id), path: Path::top(0), off: 0 }, shape, &CharProps::default()).unwrap();
@ -163,6 +166,7 @@ fn prune_text_boxes_drops_only_unshown_ones() {
stroke_width: 0.0,
float: Default::default(),
story: Some(story),
effects: Default::default(),
};
let para_of = |t: &str| vec![para_block(Paragraph::with_text(t, CharProps::default()))];
let mut d = Document::from_text("body");
@ -195,6 +199,7 @@ fn notes_in_text_box_follow_nested_boxes_once() {
stroke_width: 0.0,
float: Default::default(),
story: Some(story),
effects: Default::default(),
};
let note = |id| InlineObject::NoteRef { kind: para::NoteKind::Footnote, id, custom: String::new() };
let mut d = Document::new();

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)
}

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")]);

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:?}"),
}
}

View File

@ -252,8 +252,17 @@ fn shape(s: &mut Session, v: &Value) -> CmdResult {
let stroke = p::str(v, "stroke").and_then(Rgb::parse).or(Some(Rgb(0x0E, 0x40, 0x5A)));
let props = s.typing_props();
let at = delete_selection(s)?;
let obj =
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width: 1.0, float: Float { wrap: Wrap::Inline, ..Default::default() }, story: None };
let obj = InlineObject::Shape {
kind,
w,
h,
fill,
stroke,
stroke_width: 1.0,
float: Float { wrap: Wrap::Inline, ..Default::default() },
story: None,
effects: Default::default(),
};
let end = s.doc.insert_object(&at, obj, &props)?;
s.sel = Selection { anchor: at, focus: end };
sel_result(s)
@ -275,6 +284,7 @@ fn text_box(s: &mut Session, v: &Value) -> CmdResult {
stroke_width: 0.75,
float: Float::default(),
story: Some(id),
effects: Default::default(),
};
s.doc.insert_object(&at, obj, &props)?;
// Like Word, type straight into the new box.

View File

@ -294,6 +294,11 @@ pub fn specs() -> Vec<CommandSpec> {
})
.params(r#"{"color": "RRGGBB" | null, "width"?: pt}"#)
.when(has_shape),
CommandSpec::new("shape.effects", "Shape Effects", "Shape Format › Shape Styles", shape_effects)
.params(
r#"{"shadow"?: preset|{"preset"?, "color"?: "RRGGBB", "transparency"?: %, "blur"?: pt, "distance"?: pt, "angle"?: deg}|null, "glow"?: pt|{"color"?: "RRGGBB", "size"?: pt, "transparency"?: %}|null, "softEdge"?: pt|null} (shadow presets: offsetBottomRight, offsetBottom, offsetBottomLeft, offsetRight, offsetCenter, offsetLeft, offsetTopRight, offsetTop, offsetTopLeft; an omitted key is left as it is; applies to every shape in the selection)"#,
)
.when(has_shape),
CommandSpec::new("shape.change", "Change Shape", "Shape Format › Insert Shapes", |s, v| {
let kind: wordcraft_doc::para::ShapeKind = serde_json::from_value(v.get("kind").cloned().unwrap_or(json!("rectangle"))).map_err(|e| CmdError::Params(e.to_string()))?;
with_obj(s, |o| {
@ -320,7 +325,15 @@ pub fn object_selection(s: &Session) -> Option<(Pos, &InlineObject)> {
/// The first picture/shape in the selection, or just before a collapsed caret.
pub fn selected(s: &Session) -> Option<(Pos, InlineObject)> {
selected_all(s, 1).into_iter().next()
}
/// The pictures and shapes in the selection (at most `max`), or the one just before a collapsed
/// caret.
pub fn selected_all(s: &Session, max: usize) -> Vec<(Pos, InlineObject)> {
let mut out = Vec::new();
let (a, b) = s.sel.ordered();
let max = if a == b { 1 } else { max };
let story = a.story;
let paths = if a == b { vec![a.path.clone()] } else { s.doc.paths_between(&a, &b) };
for path in paths {
@ -335,11 +348,97 @@ pub fn selected(s: &Session) -> Option<(Pos, InlineObject)> {
continue;
}
if let Some(o @ (InlineObject::Image { .. } | InlineObject::Shape { .. })) = p.object_at(off) {
return Some((Pos { story, path: path.clone(), off }, o.clone()));
out.push((Pos { story, path: path.clone(), off }, o.clone()));
if out.len() >= max {
return out;
}
}
}
}
None
out
}
/// Most shapes one `shape.effects` changes.
const MAX_EFFECT_SHAPES: usize = 10_000;
/// `shape.effects`: set or clear the shadow, glow and soft edges of the selected shapes.
fn shape_effects(s: &mut Session, v: &Value) -> CmdResult {
use wordcraft_doc::effects::{Glow, Shadow, ShapeEffects};
let color = |o: &Value| p::str(o, "color").and_then(Rgb::parse);
// Each change is `None` (leave as it is) or `Some(new value)`.
let shadow: Option<Option<Shadow>> = match v.get("shadow") {
None => None,
Some(Value::Null) => Some(None),
Some(Value::Bool(b)) => Some(b.then(Shadow::default)),
Some(Value::String(id)) if id == "none" => Some(None),
Some(Value::String(id)) => Some(Some(Shadow::preset(id).ok_or_else(|| CmdError::Params(format!("unknown shadow preset `{id}`")))?)),
Some(o @ Value::Object(_)) => {
let mut sh = match p::str(o, "preset") {
Some(id) => Shadow::preset(id).ok_or_else(|| CmdError::Params(format!("unknown shadow preset `{id}`")))?,
None => Shadow::default(),
};
sh.color = color(o).unwrap_or(sh.color);
sh.transparency = p::f32(o, "transparency").unwrap_or(sh.transparency);
sh.blur = p::f32(o, "blur").unwrap_or(sh.blur);
sh.distance = p::f32(o, "distance").unwrap_or(sh.distance);
sh.angle = p::f32(o, "angle").unwrap_or(sh.angle);
Some(Some(sh.sanitized()))
}
Some(_) => return Err(CmdError::Params("`shadow`: a preset name, an object or null".into())),
};
let glow: Option<Option<Glow>> = match v.get("glow") {
None => None,
Some(Value::Null) => Some(None),
Some(Value::String(id)) if id == "none" => Some(None),
Some(n @ Value::Number(_)) => Some(Some(Glow { size: n.as_f64().unwrap_or(0.0) as f32, ..Glow::default() }.sanitized())),
Some(o @ Value::Object(_)) => {
let d = Glow::default();
Some(Some(
Glow {
color: color(o).unwrap_or(d.color),
size: p::f32(o, "size").unwrap_or(d.size),
transparency: p::f32(o, "transparency").unwrap_or(d.transparency),
}
.sanitized(),
))
}
Some(_) => return Err(CmdError::Params("`glow`: a size in points, an object or null".into())),
};
let soft: Option<Option<f32>> = match v.get("softEdge") {
None => None,
Some(Value::Null) => Some(None),
Some(n @ Value::Number(_)) => Some(n.as_f64().map(|x| x as f32)),
Some(_) => return Err(CmdError::Params("`softEdge`: a radius in points or null".into())),
};
let targets: Vec<Pos> =
selected_all(s, MAX_EFFECT_SHAPES).into_iter().filter(|(_, o)| matches!(o, InlineObject::Shape { .. })).map(|(pos, _)| pos).collect();
if targets.is_empty() {
return Err(CmdError::Disabled("no shape selected".into()));
}
let mut last = ShapeEffects::default();
for pos in &targets {
let o = edit_obj(s, pos, |o| {
if let InlineObject::Shape { effects, float, .. } = o {
let mut e = *effects;
if let Some(x) = shadow {
e.shadow = x;
}
if let Some(x) = glow {
e.glow = x;
}
if let Some(x) = soft {
e.soft_edge = x;
}
*effects = e.sanitized();
// Room around the shape for its shadow and glow, as Word's effect extent.
float.effect = effects.extent();
}
})?;
if let InlineObject::Shape { effects, .. } = o {
last = effects;
}
}
Ok(json!({"shapes": targets.len(), "effects": serde_json::to_value(last).unwrap_or(Value::Null)}))
}
/// Smallest width/height an object can be resized to, points (a text box keeps room for a line).

View File

@ -1700,3 +1700,42 @@ fn timestamps_come_from_the_clock() {
// Fixed-width ISO strings sort by time.
assert!(date >= cmd::iso_from_unix_secs(before) && date <= cmd::iso_from_unix_secs(cmd::now_unix()), "{date}");
}
/// Shape Format › Shape Effects (#275): presets and custom values apply to the selected shape,
/// an omitted key is kept, hostile numbers are clamped and each change is one undo step.
#[test]
fn shape_effects_apply_and_undo() {
use wordcraft_doc::effects::{MAX_BLUR, MAX_DISTANCE, Shadow};
let mut s = s();
run(&mut s, "insert.shape", json!({"kind": "ellipse"}));
let effects = |s: &Session| match cmd::objects::selected(s) {
Some((_, wordcraft_doc::InlineObject::Shape { effects, float, .. })) => (effects, float.effect),
o => panic!("{o:?}"),
};
assert!(effects(&s).0.is_empty());
run(&mut s, "shape.effects", json!({"shadow": "offsetBottomRight", "glow": {"color": "FF0000", "size": 8}, "softEdge": 5}));
let (e, extent) = effects(&s);
assert_eq!(e.shadow, Shadow::preset("offsetBottomRight"));
assert_eq!(e.glow.map(|g| (g.color, g.size)), Some((wordcraft_doc::props::Rgb(255, 0, 0), 8.0)));
assert_eq!(e.soft_edge, Some(5.0));
assert!(extent.iter().all(|v| *v >= 8.0), "room for the glow: {extent:?}");
// Clearing the glow leaves the shadow and soft edges.
run(&mut s, "shape.effects", json!({"glow": null}));
let (e, _) = effects(&s);
assert!(e.glow.is_none() && e.shadow.is_some() && e.soft_edge == Some(5.0));
run(&mut s, "edit.undo", json!({}));
assert_eq!(effects(&s).0.glow.map(|g| g.size), Some(8.0));
// Hostile numbers.
run(&mut s, "shape.effects", json!({"shadow": {"blur": 1e30, "distance": -5, "angle": 1e20, "transparency": 400}, "softEdge": -3}));
let (e, _) = effects(&s);
let sh = e.shadow.unwrap();
assert!(sh.blur == MAX_BLUR && sh.distance == 0.0 && (0.0..360.0).contains(&sh.angle) && sh.transparency == 100.0, "{sh:?}");
assert!(e.soft_edge.is_none());
run(&mut s, "shape.effects", json!({"shadow": {"distance": 1e9}}));
assert_eq!(effects(&s).0.shadow.map(|s| s.distance), Some(MAX_DISTANCE));
assert!(s.run("shape.effects", &json!({"shadow": "perspective"})).is_err());
// Off a shape it's disabled.
run(&mut s, "select.collapse", json!({"end": true}));
run(&mut s, "text.insert", json!({"text": "x"}));
assert!(s.run("shape.effects", &json!({"shadow": null})).is_err());
}

View File

@ -64,6 +64,8 @@ pub enum Draw {
fill: Option<Rgb>,
stroke: Option<Rgb>,
stroke_width: f32,
/// Shadow, glow and soft edges (drawn with [`wordcraft_doc::effects::bands`]).
effects: wordcraft_doc::effects::ShapeEffects,
},
/// A formatting mark (¶ · → ↵) in the UI's mark colour, or in `color` (a tracked
/// paragraph mark in its reviser's colour).
@ -188,8 +190,8 @@ fn item(doc: &Document, it: &Placed, opts: &DisplayOptions, alpha: f32, out: &mu
Placed::Fill { rect, color } => out.push(Draw::Fill { rect: *rect, color: *color, alpha }),
Placed::Rule { x0, y0, x1, y1, border } => out.push(rule(*x0, *y0, *x1, *y1, border, alpha)),
Placed::Image { rect, media, crop, .. } => out.push(Draw::Image { rect: *rect, media: media.clone(), crop: *crop, alpha }),
Placed::Shape { rect, kind, fill, stroke, stroke_width } => {
out.push(Draw::Shape { rect: *rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width })
Placed::Shape { rect, kind, fill, stroke, stroke_width, effects } => {
out.push(Draw::Shape { rect: *rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width, effects: *effects })
}
Placed::Cell { .. } | Placed::Object { .. } => {}
Placed::Lines { story, path, para, l0, l1, x, y, turn } if turn.is_turned() => {
@ -512,8 +514,8 @@ fn lines(
let rect = inline_rect(obj, cx, base, c.adv, c.obj_h);
match obj {
Some(InlineObject::Image { media, crop, .. }) => out.push(Draw::Image { rect, media: media.clone(), crop: *crop, alpha }),
Some(InlineObject::Shape { kind, fill, stroke, stroke_width, .. }) => {
out.push(Draw::Shape { rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width })
Some(InlineObject::Shape { kind, fill, stroke, stroke_width, effects, .. }) => {
out.push(Draw::Shape { rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width, effects: *effects })
}
Some(InlineObject::Equation { .. }) => {
if let Some((_, ml)) = pl.maths.iter().find(|(k, _)| *k == oi) {

View File

@ -95,6 +95,7 @@ pub enum Placed {
fill: Option<Rgb>,
stroke: Option<Rgb>,
stroke_width: f32,
effects: wordcraft_doc::effects::ShapeEffects,
},
/// A table cell's area (for hit testing and cell selection).
Cell {
@ -1108,8 +1109,8 @@ fn float_item(o: &InlineObject, rect: Rect, story: StoryRef, path: &[u32], off:
InlineObject::Image { media, crop, .. } => {
Some(Placed::Image { rect, media: media.clone(), crop: *crop, story, path: Path(path.to_vec()), off })
}
InlineObject::Shape { kind, fill, stroke, stroke_width, .. } => {
Some(Placed::Shape { rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width })
InlineObject::Shape { kind, fill, stroke, stroke_width, effects, .. } => {
Some(Placed::Shape { rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width, effects: *effects })
}
_ => None,
}

View File

@ -231,6 +231,7 @@ fn many_hidden_float_anchors_lay_out_in_linear_time() {
stroke_width: 1.0,
float,
story: None,
effects: Default::default(),
};
let obj = wordcraft_doc::para::OBJ.to_string();
let mut p = wordcraft_doc::Paragraph::with_text("Text ", Default::default());
@ -970,6 +971,7 @@ fn text_wraps_around_square_float() {
stroke_width: 1.0,
float,
story: None,
effects: Default::default(),
};
d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap();
let l = lay(&d);
@ -1007,6 +1009,7 @@ fn deleted_float_leaves_no_wrap_area_without_markup() {
stroke_width: 1.0,
float,
story: None,
effects: Default::default(),
};
d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap();
let obj = wordcraft_doc::para::OBJ.len_utf8();
@ -1058,6 +1061,7 @@ fn hidden_float_leaves_no_wrap_area_when_hidden_text_is_not_shown() {
stroke_width: 1.0,
float,
story: None,
effects: Default::default(),
};
d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap();
let obj = wordcraft_doc::para::OBJ.len_utf8();
@ -1094,6 +1098,7 @@ fn line_numbers_borders_text_boxes() {
stroke_width: 1.0,
float: Default::default(),
story: Some(id),
effects: Default::default(),
};
d.insert_object(&Pos::body(2, 5), tb, &Default::default()).unwrap();
let l = lay(&d);
@ -1387,6 +1392,7 @@ fn text_box_at(d: &mut Document, pos: &Pos, text: &str, w: f32, h: f32, float: w
stroke_width: 0.75,
float,
story: Some(id),
effects: Default::default(),
};
d.insert_object(pos, shape, &Default::default()).unwrap();
id
@ -1457,6 +1463,7 @@ fn presses_grab_pictures_anywhere_and_text_boxes_by_their_border() {
stroke_width: 1.0,
float: Default::default(),
story: None,
effects: Default::default(),
};
d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap();
let l = lay(&d);
@ -1676,6 +1683,7 @@ fn box_fan_out(levels: usize, fan: usize) -> Document {
stroke_width: 0.0,
float: Default::default(),
story: Some(story),
effects: Default::default(),
};
for (k, id) in ids.iter().enumerate() {
let next = *ids.get(k + 1).unwrap_or(id);
@ -1739,7 +1747,17 @@ fn a_text_box_inside_a_text_box_still_shows_its_text() {
}
fn picture(w: f32, h: f32, float: wordcraft_doc::para::Float) -> InlineObject {
InlineObject::Shape { kind: wordcraft_doc::para::ShapeKind::Rectangle, w, h, fill: None, stroke: None, stroke_width: 1.0, float, story: None }
InlineObject::Shape {
kind: wordcraft_doc::para::ShapeKind::Rectangle,
w,
h,
fill: None,
stroke: None,
stroke_width: 1.0,
float,
story: None,
effects: Default::default(),
}
}
#[test]
@ -1877,7 +1895,17 @@ fn lines_with_tabs_never_shrink() {
}
fn rect_shape(w: f32, h: f32, float: wordcraft_doc::para::Float) -> InlineObject {
InlineObject::Shape { kind: wordcraft_doc::para::ShapeKind::Rectangle, w, h, fill: None, stroke: None, stroke_width: 1.0, float, story: None }
InlineObject::Shape {
kind: wordcraft_doc::para::ShapeKind::Rectangle,
w,
h,
fill: None,
stroke: None,
stroke_width: 1.0,
float,
story: None,
effects: Default::default(),
}
}
fn shapes(items: &[Placed]) -> Vec<Rect> {
@ -2042,6 +2070,7 @@ fn floating_header_text_box_leaves_the_body_at_the_top_margin() {
stroke_width: 0.75,
float,
story: Some(story),
effects: Default::default(),
};
anchor.insert_object(0, tb, &Default::default()).unwrap();
}

View File

@ -312,6 +312,46 @@ fn rect_path(r: &Rect) -> Option<Path> {
pb.finish()
}
/// `r` grown by `g` points on every side and moved by (`dx`, `dy`).
fn grown(r: &Rect, g: f32, dx: f32, dy: f32) -> Rect {
Rect { x: r.x - g + dx, y: r.y - g + dy, w: r.w + 2.0 * g, h: r.h + 2.0 * g }
}
/// A shape's shadow and glow, behind it: bands of its silhouette (the same approximation of a
/// blur as the raster renderer, see [`wordcraft_doc::effects::bands`]).
fn shape_effects(s: &mut Surface, kind: ShapeKind, rect: &Rect, fx: &wordcraft_doc::effects::ShapeEffects, filled: bool, stroked: bool, sw: f32) {
let sw = if sw.is_finite() { sw.clamp(0.25, 200.0) } else { 0.75 };
let silhouette = |s: &mut Surface, c: Rgb, a: f32, g: f32, dx: f32, dy: f32| {
if filled {
let grow = g + if stroked { sw / 2.0 } else { 0.0 };
if let Some(p) = shape_path(kind, &grown(rect, grow, dx, dy)) {
s.set_stroke(None);
s.set_fill(Some(fill(c, a)));
s.draw_path(&p);
}
} else if sw + 2.0 * g > 0.05
&& let Some(p) = shape_path(kind, &grown(rect, 0.0, dx, dy))
{
s.set_fill(None);
s.set_stroke(Some(Stroke { paint: rgb::Color::new(c.0, c.1, c.2).into(), width: sw + 2.0 * g, opacity: norm(a), ..Default::default() }));
s.draw_path(&p);
}
};
if let Some(sh) = fx.shadow {
let (dx, dy) = sh.offset();
for (g, a) in wordcraft_doc::effects::bands(-sh.blur / 2.0, sh.blur / 2.0, sh.opacity()) {
silhouette(s, sh.color, a, g, dx, dy);
}
}
if let Some(gl) = fx.glow {
for (g, a) in wordcraft_doc::effects::bands(0.0, gl.size, gl.opacity()) {
silhouette(s, gl.color, a, g, 0.0, 0.0);
}
}
s.set_fill(None);
s.set_stroke(None);
}
/// Outline of a basic shape in a rectangle (same geometry as the raster renderer).
fn shape_path(kind: ShapeKind, r: &Rect) -> Option<Path> {
if !(ok(r.x) && ok(r.y) && ok(r.w) && ok(r.h)) || r.w <= 0.0 || r.h <= 0.0 {
@ -635,9 +675,26 @@ impl Exporter<'_> {
}
Draw::Line { x0, y0, x1, y1, width, color, stroke, alpha } => self.line(s, (*x0, *y0, *x1, *y1), *width, *color, *stroke, *alpha),
Draw::Image { rect, media, crop, alpha } => self.picture(s, rect, media, crop, *alpha),
Draw::Shape { rect, kind, fill: f, stroke, stroke_width } => {
Draw::Shape { rect, kind, fill: f, stroke, stroke_width, effects } => {
let Some(p) = shape_path(*kind, rect) else { return };
let can_fill = *kind != ShapeKind::Line;
let fx = effects.sanitized();
if !fx.is_empty() && (f.is_some() && can_fill || stroke.is_some()) {
shape_effects(s, *kind, rect, &fx, f.is_some() && can_fill, stroke.is_some(), *stroke_width);
}
// Soft edges: the fill fades out toward the outline (which fades with it).
if let (Some(rad), Some(c), true) = (fx.soft_edge, f, can_fill) {
let rad = rad.min(rect.w.min(rect.h) / 2.0).max(0.0);
s.set_stroke(None);
for (g, a) in wordcraft_doc::effects::bands(-rad, 0.0, 1.0) {
if let Some(p) = shape_path(*kind, &grown(rect, g, 0.0, 0.0)) {
s.set_fill(Some(fill(*c, a)));
s.draw_path(&p);
}
}
s.set_fill(None);
return;
}
s.set_fill(f.filter(|_| can_fill).map(|c| fill(c, 1.0)));
s.set_stroke(stroke.map(|c| Stroke {
paint: rgb::Color::new(c.0, c.1, c.2).into(),

View File

@ -417,10 +417,54 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl
ctx.pop_layer();
}
}
Draw::Shape { rect, kind, fill, stroke, stroke_width } => {
Draw::Shape { rect, kind, fill, stroke, stroke_width, effects } => {
let r = kurbo::Rect::new(rect.x as f64, rect.y as f64, rect.right() as f64, rect.bottom() as f64);
let path = shape_path(*kind, r);
ctx.set_transform(view);
let fx = effects.sanitized();
let can_fill = *kind != ShapeKind::Line && fill.is_some();
if !fx.is_empty() && (can_fill || stroke.is_some()) {
let sw = if stroke_width.is_finite() { stroke_width.clamp(0.25, 200.0) as f64 } else { 0.75 };
// The shape's silhouette grown by `g` points and moved by `d`, in the current paint.
let silhouette = |ctx: &mut RenderContext, g: f32, d: kurbo::Vec2| {
let g = g as f64;
if can_fill {
let grow = g + if stroke.is_some() { sw / 2.0 } else { 0.0 };
let rr = r.inflate(grow, grow) + d;
if rr.width() > 0.0 && rr.height() > 0.0 {
ctx.fill_path(&shape_path(*kind, rr));
}
} else if sw + 2.0 * g > 0.05 {
ctx.set_stroke(kurbo::Stroke::new(sw + 2.0 * g));
ctx.stroke_path(&shape_path(*kind, r + d));
}
};
if let Some(sh) = fx.shadow {
let (dx, dy) = sh.offset();
for (g, a) in wordcraft_doc::effects::bands(-sh.blur / 2.0, sh.blur / 2.0, sh.opacity()) {
ctx.set_paint(color(opts.ink(sh.color), a));
silhouette(ctx, g, kurbo::Vec2::new(dx as f64, dy as f64));
}
}
if let Some(gl) = fx.glow {
for (g, a) in wordcraft_doc::effects::bands(0.0, gl.size, gl.opacity()) {
ctx.set_paint(color(opts.ink(gl.color), a));
silhouette(ctx, g, kurbo::Vec2::ZERO);
}
}
}
// Soft edges: the fill fades out toward the outline (which fades with it).
if let (Some(rad), Some(f), true) = (fx.soft_edge, fill, can_fill) {
let rad = rad.min(rect.w.min(rect.h) / 2.0).max(0.0);
for (g, a) in wordcraft_doc::effects::bands(-rad, 0.0, 1.0) {
let rr = r.inflate(g as f64, g as f64);
if rr.width() > 0.0 && rr.height() > 0.0 {
ctx.set_paint(color(opts.ink(*f), a));
ctx.fill_path(&shape_path(*kind, rr));
}
}
return;
}
if let Some(f) = fill {
ctx.set_paint(color(opts.ink(*f), 1.0));
ctx.fill_path(&path);
@ -552,6 +596,61 @@ mod tests {
assert!(ink(TextDirection::Up) > 30, "up");
}
/// Shape Effects (#275): an outer shadow paints dark pixels beside the shape on the side it is
/// cast to, and none on the other; a glow surrounds it.
#[test]
fn shape_shadow_is_cast_offset_from_the_shape() {
use wordcraft_doc::effects::{Glow, Shadow, ShapeEffects};
use wordcraft_doc::para::InlineObject;
let draw = |effects: ShapeEffects| {
let mut d = Document::from_text("");
let shape = InlineObject::Shape {
kind: ShapeKind::Rectangle,
w: 100.0,
h: 60.0,
fill: Some(Rgb(0, 0, 255)),
stroke: None,
stroke_width: 0.0,
float: Default::default(),
story: None,
effects,
};
let at = wordcraft_doc::Pos { story: wordcraft_doc::StoryRef::Body, path: wordcraft_doc::Path::top(0), off: 0 };
d.insert_object(&at, shape, &Default::default()).unwrap();
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
render_page(&d, &l.pages[0], 1.0, &RenderOptions::default())
};
let plain = draw(ShapeEffects::default());
let blue = |img: &Rendered| {
let px: Vec<(u32, u32)> = (0..612)
.flat_map(|x| (0..400).map(move |y| (x, y)))
.filter(|(x, y)| matches!(img.pixel(*x, *y), [r, _, b, _] if r < 40 && b > 200))
.collect();
let (x0, x1) = (px.iter().map(|p| p.0).min().unwrap(), px.iter().map(|p| p.0).max().unwrap());
let (y0, y1) = (px.iter().map(|p| p.1).min().unwrap(), px.iter().map(|p| p.1).max().unwrap());
(x0, y0, x1, y1)
};
let (x0, y0, x1, y1) = blue(&plain);
assert!(x1 - x0 > 90 && y1 - y0 > 50, "{:?}", (x0, y0, x1, y1));
// Grey (not white, not blue) pixels in a strip just outside an edge.
let grey = |img: &Rendered, xs: std::ops::Range<u32>, ys: std::ops::Range<u32>| {
xs.flat_map(|x| ys.clone().map(move |y| (x, y)))
.filter(|(x, y)| matches!(img.pixel(*x, *y), [r, g, b, _] if r < 200 && r == g && g == b))
.count()
};
let right = |img: &Rendered| grey(img, x1 + 2..x1 + 6, y0 + 10..y1);
let left = |img: &Rendered| grey(img, x0.saturating_sub(6)..x0.saturating_sub(2), y0 + 10..y1);
assert_eq!((right(&plain), left(&plain)), (0, 0));
let shadow = Shadow { color: Rgb::BLACK, transparency: 30.0, blur: 2.0, distance: 8.0, angle: 0.0 };
let img = draw(ShapeEffects { shadow: Some(shadow), ..Default::default() });
assert_eq!(blue(&img), (x0, y0, x1, y1), "the shape itself is unchanged");
assert!(right(&img) > 100, "shadow to the right: {}", right(&img));
assert_eq!(left(&img), 0, "nothing on the left");
let img = draw(ShapeEffects { glow: Some(Glow { color: Rgb(255, 0, 0), size: 6.0, transparency: 0.0 }), ..Default::default() });
let reddish = |x: u32, y: u32| matches!(img.pixel(x, y), [r, g, _, _] if r > 200 && g < 200);
assert!(reddish(x1 + 2, (y0 + y1) / 2) && reddish(x0 - 2, (y0 + y1) / 2), "glow on both sides");
}
#[test]
fn stem_darkening_follows_the_macos_curve() {
assert_eq!(stem_darkening(0.0), 0.0);

View File

@ -948,3 +948,21 @@
Dark Oscuro
Use system setting Usar la configuración del sistema
A file dialog is already open. Ya hay un cuadro de diálogo de archivo abierto.
Shape Effects Efectos de forma
Glow Resplandor
Soft Edges Bordes suaves
No Shadow Sin sombra
No Glow Sin resplandor
No Soft Edges Sin bordes suaves
Glow Colors Colores de resplandor
Weight Grosor
Shadow, glow and soft edges Sombra, resplandor y bordes suaves
Offset: Bottom Right Desplazamiento: abajo a la derecha
Offset: Bottom Desplazamiento: abajo
Offset: Bottom Left Desplazamiento: abajo a la izquierda
Offset: Right Desplazamiento: derecha
Offset: Center Desplazamiento: centro
Offset: Left Desplazamiento: izquierda
Offset: Top Right Desplazamiento: arriba a la derecha
Offset: Top Desplazamiento: arriba
Offset: Top Left Desplazamiento: arriba a la izquierda

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -931,3 +931,21 @@
Dark ダーク
Use system setting システム設定を使用
A file dialog is already open. ファイル ダイアログが既に開いています。
Shape Effects 図形の効果
Glow 光彩
Soft Edges 輪郭のぼかし
No Shadow 影なし
No Glow 光彩なし
No Soft Edges ぼかしなし
Glow Colors 光彩の色
Weight 太さ
Shadow, glow and soft edges 影、光彩、輪郭のぼかし
Offset: Bottom Right オフセット: 右下
Offset: Bottom オフセット: 下
Offset: Bottom Left オフセット: 左下
Offset: Right オフセット: 右
Offset: Center オフセット: 中央
Offset: Left オフセット: 左
Offset: Top Right オフセット: 右上
Offset: Top オフセット: 上
Offset: Top Left オフセット: 左上

Can't render this file because it has a wrong number of fields in line 3.

View File

@ -943,3 +943,21 @@
Dark Escuro
Use system setting Usar a configuração do sistema
A file dialog is already open. Já há uma caixa de diálogo de arquivo aberta.
Shape Effects Efeitos de forma
Glow Brilho
Soft Edges Bordas suaves
No Shadow Sem sombra
No Glow Sem brilho
No Soft Edges Sem bordas suaves
Glow Colors Cores do brilho
Weight Espessura
Shadow, glow and soft edges Sombra, brilho e bordas suaves
Offset: Bottom Right Deslocamento: inferior direito
Offset: Bottom Deslocamento: inferior
Offset: Bottom Left Deslocamento: inferior esquerdo
Offset: Right Deslocamento: direita
Offset: Center Deslocamento: centro
Offset: Left Deslocamento: esquerda
Offset: Top Right Deslocamento: superior direito
Offset: Top Deslocamento: superior
Offset: Top Left Deslocamento: superior esquerdo

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -943,3 +943,21 @@
Dark Темна
Use system setting Як у системі
A file dialog is already open. Діалогове вікно файлу вже відкрито.
Shape Effects Ефекти фігури
Glow Світіння
Soft Edges М'які краї
No Shadow Без тіні
No Glow Без світіння
No Soft Edges Без м'яких країв
Glow Colors Кольори світіння
Weight Товщина
Shadow, glow and soft edges Тінь, світіння та м'які краї
Offset: Bottom Right Зміщення: вниз праворуч
Offset: Bottom Зміщення: вниз
Offset: Bottom Left Зміщення: вниз ліворуч
Offset: Right Зміщення: праворуч
Offset: Center Зміщення: по центру
Offset: Left Зміщення: ліворуч
Offset: Top Right Зміщення: вгору праворуч
Offset: Top Зміщення: вгору
Offset: Top Left Зміщення: вгору ліворуч

Can't render this file because it contains an unexpected character in line 6 and column 31.

View File

@ -942,3 +942,21 @@
Dark 深色
Use system setting 使用系统设置
A file dialog is already open. 已有一个文件对话框处于打开状态。
Shape Effects 形状效果
Glow 发光
Soft Edges 柔化边缘
No Shadow 无阴影
No Glow 无发光
No Soft Edges 无柔化边缘
Glow Colors 发光颜色
Weight 粗细
Shadow, glow and soft edges 阴影、发光和柔化边缘
Offset: Bottom Right 偏移:右下
Offset: Bottom 偏移:下
Offset: Bottom Left 偏移:左下
Offset: Right 偏移:右
Offset: Center 偏移:居中
Offset: Left 偏移:左
Offset: Top Right 偏移:右上
Offset: Top 偏移:上
Offset: Top Left 偏移:左上

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -932,3 +932,21 @@
Dark 深色
Use system setting 使用系統設定
A file dialog is already open. 已有一個檔案對話方塊開啟中。
Shape Effects 圖案效果
Glow 光暈
Soft Edges 柔化邊緣
No Shadow 無陰影
No Glow 無光暈
No Soft Edges 無柔化邊緣
Glow Colors 光暈色彩
Weight 粗細
Shadow, glow and soft edges 陰影、光暈和柔化邊緣
Offset: Bottom Right 位移:右下
Offset: Bottom 位移:下
Offset: Bottom Left 位移:左下
Offset: Right 位移:右
Offset: Center 位移:置中
Offset: Left 位移:左
Offset: Top Right 位移:右上
Offset: Top 位移:上
Offset: Top Left 位移:左上

Can't render this file because it has a wrong number of fields in line 4.

View File

@ -312,6 +312,14 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) {
pen.rect(3.0, 3.0, 11.0, 11.0, c);
pen.fcircle(13.0, 13.0, 4.5, a);
}
// Shape Effects: a square casting a soft shadow down and to the right.
"shapeEffects" => {
for (k, alpha) in [(2.0, 30), (1.0, 50), (0.0, 80)] {
pen.frect(7.0 - k, 7.0 - k, 18.0 + k, 18.0 + k, Color32::from_rgba_unmultiplied(0, 0, 0, alpha));
}
pen.frect(3.0, 3.0, 14.0, 14.0, a);
pen.rect(3.0, 3.0, 14.0, 14.0, c);
}
"icons" => {
pen.circle(10.0, 10.0, 7.5, c);
pen.fcircle(7.5, 8.0, 1.0, c);

View File

@ -14,6 +14,11 @@ pub fn has_picture_selected(s: &wordcraft_engine::Session) -> bool {
matches!(wordcraft_engine::cmd::objects::selected(s), Some((_, wordcraft_doc::para::InlineObject::Image { .. })))
}
/// Whether the selection is a shape or text box (so Shape Format is shown).
pub fn has_shape_selected(s: &wordcraft_engine::Session) -> bool {
matches!(wordcraft_engine::cmd::objects::selected(s), Some((_, wordcraft_doc::para::InlineObject::Shape { .. })))
}
/// Contextual tabs for the current selection (pure, tested).
pub fn contextual_tabs(s: &wordcraft_engine::Session) -> Vec<&'static str> {
let mut tabs = Vec::new();
@ -24,6 +29,9 @@ pub fn contextual_tabs(s: &wordcraft_engine::Session) -> Vec<&'static str> {
if has_picture_selected(s) {
tabs.push("Picture Format");
}
if has_shape_selected(s) {
tabs.push("Shape Format");
}
// Editing an equation.
if s.math.is_some() {
tabs.push("Equation");
@ -77,7 +85,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
}
}
for tab in tabs {
let contextual = tab.starts_with("Table ") || tab == "Picture Format" || tab == "Equation";
let contextual = tab.starts_with("Table ") || tab.ends_with(" Format") || tab == "Equation";
let shown = tl!(tab);
let w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(shown.to_string(), medium(12.5), t.text).size().x) + 18.0;
let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click());
@ -168,6 +176,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
"Table Layout" => table_layout(app, ui),
"Equation" if app.session.math.is_some() => crate::equation_tab::show(app, ui),
"Picture Format" => picture_format(app, ui),
"Shape Format" => shape_format(app, ui),
_ => home(app, ui),
}
});
@ -1081,6 +1090,83 @@ fn help(app: &mut WordApp, ui: &mut Ui) {
});
}
/// Shape Format: fill, outline and effects of the selected shape, and its arrangement.
fn shape_format(app: &mut WordApp, ui: &mut Ui) {
let (stroke, glow_size) = match wordcraft_engine::cmd::objects::selected(&app.session) {
Some((_, wordcraft_doc::para::InlineObject::Shape { stroke, effects, .. })) => (stroke, effects.glow.map(|g| g.size)),
_ => (None, None),
};
let theme = app.session.doc.settings.theme_colors.clone();
group(ui, "Shape Styles", None, app, |ui, app| {
stack(ui, |ui| {
menu_button(ui, app, "shading", Some("Shape Fill"), "Shape Fill", false, |ui, app| {
mi(ui, app, "No Color", "shape.fill", json!({"color": null}));
if let Some(hex) = color_grid(ui, &theme) {
let _ = app.run("shape.fill", json!({"color": hex}));
ui.close();
}
});
menu_button(ui, app, "pen", Some("Shape Outline"), "Shape Outline", false, |ui, app| {
mi(ui, app, "No Color", "shape.outline", json!({"color": null}));
if let Some(hex) = color_grid(ui, &theme) {
let _ = app.run("shape.outline", json!({"color": hex}));
ui.close();
}
ui.separator();
ui.menu_button(tl!("Weight"), |ui| {
let color = stroke.unwrap_or(wordcraft_doc::Rgb::BLACK).hex();
for w in [0.25, 0.5, 0.75, 1.0, 1.5, 2.25, 3.0, 4.5, 6.0] {
mi(ui, app, &format!("{w} pt"), "shape.outline", json!({"color": color, "width": w}));
}
});
});
menu_button(ui, app, "shapeEffects", Some("Shape Effects"), "Shadow, glow and soft edges", false, |ui, app| {
shape_effects_menu(ui, app, &theme, glow_size)
});
});
});
group(ui, "Arrange", None, app, |ui, app| {
big(ui, app, "position", "Position", "arrange.position", json!({}), false);
big(ui, app, "wrapText", "Wrap\nText", "arrange.wrap", json!({}), false);
stack(ui, |ui| {
small(ui, app, "rotate", Some("Rotate"), "Rotate", "arrange.rotate", json!({}), false);
small(ui, app, "align", Some("Align"), "Align", "arrange.align", json!({}), false);
});
});
}
/// Shape Format › Shape Effects: Shadow, Glow and Soft Edges, each with presets and a "No …" entry.
fn shape_effects_menu(ui: &mut Ui, app: &mut WordApp, theme: &[wordcraft_doc::Rgb], glow_size: Option<f32>) {
ui.menu_button(tl!("Shadow"), |ui| {
mi(ui, app, "No Shadow", "shape.effects", json!({"shadow": null}));
ui.separator();
for (id, label) in wordcraft_doc::effects::SHADOW_PRESETS {
mi(ui, app, label, "shape.effects", json!({"shadow": id}));
}
});
ui.menu_button(tl!("Glow"), |ui| {
mi(ui, app, "No Glow", "shape.effects", json!({"glow": null}));
ui.separator();
for size in [5.0, 8.0, 11.0, 18.0] {
mi(ui, app, &format!("{size} pt"), "shape.effects", json!({"glow": size}));
}
ui.separator();
ui.menu_button(tl!("Glow Colors"), |ui| {
if let Some(hex) = color_grid(ui, theme) {
let _ = app.run("shape.effects", json!({"glow": {"color": hex, "size": glow_size.unwrap_or(8.0)}}));
ui.close();
}
});
});
ui.menu_button(tl!("Soft Edges"), |ui| {
mi(ui, app, "No Soft Edges", "shape.effects", json!({"softEdge": null}));
ui.separator();
for r in [1.0, 2.5, 5.0, 10.0, 25.0, 50.0] {
mi(ui, app, &format!("{r} pt"), "shape.effects", json!({"softEdge": r}));
}
});
}
fn picture_format(app: &mut WordApp, ui: &mut Ui) {
group(ui, "Adjust", None, app, |ui, app| {
menu_button(ui, app, "picture", Some("Corrections"), "Brightness, contrast and sharpness", true, |ui, app| {
@ -1375,6 +1461,17 @@ mod tests {
assert!(!has_picture_selected(&s));
}
#[test]
fn shape_format_tab_appears_when_shape_selected() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("insert.shape", &json!({"kind": "rectangle"})).unwrap();
assert!(contextual_tabs(&s).contains(&"Shape Format"));
assert!(!contextual_tabs(&s).contains(&"Picture Format"));
s.run("select.collapse", &json!({"end": true})).unwrap();
s.run("text.insert", &json!({"text": "x"})).unwrap();
assert!(!contextual_tabs(&s).contains(&"Shape Format"));
}
#[test]
fn resolve_tab_falls_back_when_contextual_tab_expires() {
let all = ["Home", "Table Design", "Picture Format"];

View File

@ -2,7 +2,7 @@
Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry.
**378 of 428 catalog features have commands (88.3%).**
**379 of 428 catalog features have commands (88.6%).**
| Tab | Live | Total |
|---|---|---|
@ -21,7 +21,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-
| Table Layout | 28 | 30 |
| Equation | 21 | 22 |
| Picture Format | 13 | 13 |
| Shape Format | 2 | 6 |
| Shape Format | 3 | 6 |
| Header & Footer | 5 | 5 |
| Editing | 23 | 24 |
| Tools | 3 | 6 |
@ -96,7 +96,6 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-
### Shape Format
- Shape Styles › Shape Effects (`shape.effects`)
- Text › Text Direction (`shape.textDirection`)
- Text › Align Text (`shape.alignText`)
- Text › Create Link (`shape.link`)