From 2f8f304fc9e10540887c3e49e2d75d8ee7baac2a Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 19:35:05 +0200 Subject: [PATCH] =?UTF-8?q?Shape=20Format:=20Shape=20Effects=20=E2=80=94?= =?UTF-8?q?=20shadow,=20glow=20and=20soft=20edges=20(#275)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/doc/src/edit.rs | 1 + crates/doc/src/effects.rs | 224 ++++++++++++++++++++++++++++ crates/doc/src/lib.rs | 1 + crates/doc/src/para.rs | 3 + crates/doc/src/tests.rs | 5 + crates/docx/src/read/story.rs | 57 ++++++- crates/docx/src/write/story.rs | 56 ++++++- crates/docx/tests/roundtrip.rs | 51 ++++++- crates/engine/src/cmd/insert.rs | 14 +- crates/engine/src/cmd/objects.rs | 103 ++++++++++++- crates/engine/src/tests.rs | 39 +++++ crates/layout/src/display.rs | 10 +- crates/layout/src/lib.rs | 5 +- crates/layout/src/tests.rs | 33 +++- crates/pdf/src/lib.rs | 59 +++++++- crates/render/src/lib.rs | 101 ++++++++++++- crates/ui-egui/src/i18n/es.tsv | 18 +++ crates/ui-egui/src/i18n/ja.tsv | 18 +++ crates/ui-egui/src/i18n/pt-br.tsv | 18 +++ crates/ui-egui/src/i18n/uk.tsv | 18 +++ crates/ui-egui/src/i18n/zh-hans.tsv | 18 +++ crates/ui-egui/src/i18n/zh-hant.tsv | 18 +++ crates/ui-egui/src/icons.rs | 8 + crates/ui-egui/src/ribbon.rs | 99 +++++++++++- docs/parity.md | 5 +- 25 files changed, 959 insertions(+), 23 deletions(-) create mode 100644 crates/doc/src/effects.rs diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 006b9b6..fc46e24 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -501,6 +501,7 @@ mod tests { stroke_width: 0.75, float: Default::default(), story, + effects: Default::default(), } } diff --git a/crates/doc/src/effects.rs b/crates/doc/src/effects.rs new file mode 100644 index 0000000..d5040e1 --- /dev/null +++ b/crates/doc/src/effects.rs @@ -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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub glow: Option, + /// 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, +} + +/// 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 { + 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::(); + 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); + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index ada4b6c..2d8d1cc 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -12,6 +12,7 @@ pub mod bidi; pub mod edit; +pub mod effects; pub mod encoding; pub mod fields; pub mod math; diff --git a/crates/doc/src/para.rs b/crates/doc/src/para.rs index 1012908..02ceca6 100644 --- a/crates/doc/src/para.rs +++ b/crates/doc/src/para.rs @@ -213,6 +213,9 @@ pub enum InlineObject { /// Text box content: `Document::parts` id. #[serde(default)] story: Option, + /// 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. diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 703856a..84ecf5d 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -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(); diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index f575708..44bb169 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -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, 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) +} diff --git a/crates/docx/src/write/story.rs b/crates/docx/src/write/story.rs index f397e6c..65644f2 100644 --- a/crates/docx/src/write/story.rs +++ b/crates/docx/src/write/story.rs @@ -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")]); diff --git a/crates/docx/tests/roundtrip.rs b/crates/docx/tests/roundtrip.rs index 44e7715..4a9a9a2 100644 --- a/crates/docx/tests/roundtrip.rs +++ b/crates/docx/tests/roundtrip.rs @@ -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#""# + ), + "{xml}" + ); + assert!(xml.contains(r#""#), "{xml}"); + assert!(xml.contains(r#""#), "{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:?}"), + } +} diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 0be2dd5..5a0dfd0 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -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. diff --git a/crates/engine/src/cmd/objects.rs b/crates/engine/src/cmd/objects.rs index 7360d7a..f930cd4 100644 --- a/crates/engine/src/cmd/objects.rs +++ b/crates/engine/src/cmd/objects.rs @@ -294,6 +294,11 @@ pub fn specs() -> Vec { }) .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> = 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> = 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> = 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 = + 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). diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d5830ad..6af6afd 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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()); +} diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs index c9e19dc..df66b01 100644 --- a/crates/layout/src/display.rs +++ b/crates/layout/src/display.rs @@ -64,6 +64,8 @@ pub enum Draw { fill: Option, stroke: Option, 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) { diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 91624a0..eefc946 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -95,6 +95,7 @@ pub enum Placed { fill: Option, stroke: Option, 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, } diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 601fb80..9fbeaed 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -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 { @@ -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(); } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 3a79b92..378d2d6 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -312,6 +312,46 @@ fn rect_path(r: &Rect) -> Option { 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 { 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(), diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index ac11793..7729e89 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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, ys: std::ops::Range| { + 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); diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 2ef46ec..f26d333 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index a73def0..39a438f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -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 オフセット: 左上 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 921391b..3e7a192 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index aeff3d2..63e7cd8 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -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 Зміщення: вгору ліворуч diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index b2f9d3c..1f99398 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -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 偏移:左上 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index a46dcca..a55ddfb 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -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 位移:左上 diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 2491375..c4ed30f 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -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); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 27c9993..67fea3e 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -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) { + 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"]; diff --git a/docs/parity.md b/docs/parity.md index afb6269..43c28e8 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -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`)