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