Shape Format: Shape Effects — shadow, glow and soft edges (#275)
Shapes and text boxes get an effects model (outer shadow with colour, transparency, blur, distance and angle; glow with colour, size and transparency; soft-edge radius), the shape.effects command (presets, custom values or null per effect, applied to every selected shape, undoable, hostile numbers clamped), screen and PDF rendering with the same banded approximation of a blur, DOCX a:effectLst read/write (outerShdw, glow, softEdge) with a matching wp:effectExtent, and a contextual Shape Format tab with Shape Fill, Shape Outline and Shape Effects (Shadow, Glow, Soft Edges submenus) plus Arrange. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
40c5d51bf9
commit
2f8f304fc9
|
|
@ -501,6 +501,7 @@ mod tests {
|
|||
stroke_width: 0.75,
|
||||
float: Default::default(),
|
||||
story,
|
||||
effects: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,224 @@
|
|||
//! Shape effects (DrawingML `a:effectLst`, ECMA-376 Part 1 §20.1.8): an outer shadow, a glow and
|
||||
//! soft edges. Lengths are points, angles degrees, transparencies percent (0 = opaque).
|
||||
//!
|
||||
//! Renderers approximate the blurs with [`bands`]: a few copies of the shape's silhouette, grown
|
||||
//! or shrunk by steps and overlaid with partial opacity, so the screen and PDF output (which has
|
||||
//! no blur filter) match.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::props::Rgb;
|
||||
|
||||
/// Largest blur radius, points.
|
||||
pub const MAX_BLUR: f32 = 100.0;
|
||||
/// Largest shadow distance, points.
|
||||
pub const MAX_DISTANCE: f32 = 200.0;
|
||||
/// Largest glow size, points.
|
||||
pub const MAX_GLOW: f32 = 150.0;
|
||||
/// Largest soft-edge radius, points.
|
||||
pub const MAX_SOFT_EDGE: f32 = 100.0;
|
||||
|
||||
/// The effects on a shape. Empty (the default) draws the shape plainly.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ShapeEffects {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub shadow: Option<Shadow>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub glow: Option<Glow>,
|
||||
/// Soft-edge radius, points: the shape fades out over this distance inside its outline.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub soft_edge: Option<f32>,
|
||||
}
|
||||
|
||||
/// An outer shadow: a blurred copy of the shape's silhouette cast `distance` points away in
|
||||
/// direction `angle` (degrees clockwise from the positive x axis, y pointing down).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct Shadow {
|
||||
pub color: Rgb,
|
||||
pub transparency: f32,
|
||||
pub blur: f32,
|
||||
pub distance: f32,
|
||||
pub angle: f32,
|
||||
}
|
||||
|
||||
impl Default for Shadow {
|
||||
/// "Offset: Bottom Right".
|
||||
fn default() -> Self {
|
||||
Shadow { color: Rgb::BLACK, transparency: 60.0, blur: 4.0, distance: 3.0, angle: 45.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// A glow: a soft band of colour `size` points wide around the shape's outline.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct Glow {
|
||||
pub color: Rgb,
|
||||
pub size: f32,
|
||||
pub transparency: f32,
|
||||
}
|
||||
|
||||
impl Default for Glow {
|
||||
fn default() -> Self {
|
||||
Glow { color: Rgb(0x15, 0x60, 0x82), size: 5.0, transparency: 60.0 }
|
||||
}
|
||||
}
|
||||
|
||||
fn clamp(v: f32, lo: f32, hi: f32, dflt: f32) -> f32 {
|
||||
if v.is_finite() { v.clamp(lo, hi) } else { dflt }
|
||||
}
|
||||
|
||||
/// The outer-shadow presets (no perspective shadows): id, label.
|
||||
pub const SHADOW_PRESETS: [(&str, &str); 9] = [
|
||||
("offsetBottomRight", "Offset: Bottom Right"),
|
||||
("offsetBottom", "Offset: Bottom"),
|
||||
("offsetBottomLeft", "Offset: Bottom Left"),
|
||||
("offsetRight", "Offset: Right"),
|
||||
("offsetCenter", "Offset: Center"),
|
||||
("offsetLeft", "Offset: Left"),
|
||||
("offsetTopRight", "Offset: Top Right"),
|
||||
("offsetTop", "Offset: Top"),
|
||||
("offsetTopLeft", "Offset: Top Left"),
|
||||
];
|
||||
|
||||
impl Shadow {
|
||||
/// A preset by id (see [`SHADOW_PRESETS`]).
|
||||
pub fn preset(id: &str) -> Option<Shadow> {
|
||||
let d = Shadow::default();
|
||||
let angle = match id {
|
||||
"offsetBottomRight" => 45.0,
|
||||
"offsetBottom" => 90.0,
|
||||
"offsetBottomLeft" => 135.0,
|
||||
"offsetLeft" => 180.0,
|
||||
"offsetTopLeft" => 225.0,
|
||||
"offsetTop" => 270.0,
|
||||
"offsetTopRight" => 315.0,
|
||||
"offsetRight" => 0.0,
|
||||
"offsetCenter" => return Some(Shadow { distance: 0.0, blur: 5.0, ..d }),
|
||||
_ => return None,
|
||||
};
|
||||
Some(Shadow { angle, ..d })
|
||||
}
|
||||
/// Finite, in range.
|
||||
pub fn sanitized(self) -> Shadow {
|
||||
let angle = clamp(self.angle, -1e6, 1e6, 45.0).rem_euclid(360.0);
|
||||
Shadow {
|
||||
color: self.color,
|
||||
transparency: clamp(self.transparency, 0.0, 100.0, 60.0),
|
||||
blur: clamp(self.blur, 0.0, MAX_BLUR, 0.0),
|
||||
distance: clamp(self.distance, 0.0, MAX_DISTANCE, 0.0),
|
||||
angle: if angle.is_finite() && angle < 360.0 { angle } else { 0.0 },
|
||||
}
|
||||
}
|
||||
/// The shadow's offset from the shape, points (x right, y down).
|
||||
pub fn offset(&self) -> (f32, f32) {
|
||||
let s = self.sanitized();
|
||||
let a = s.angle.to_radians();
|
||||
(s.distance * a.cos(), s.distance * a.sin())
|
||||
}
|
||||
/// Opacity 0..1.
|
||||
pub fn opacity(&self) -> f32 {
|
||||
1.0 - self.sanitized().transparency / 100.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Glow {
|
||||
/// Finite, in range.
|
||||
pub fn sanitized(self) -> Glow {
|
||||
Glow { color: self.color, size: clamp(self.size, 0.0, MAX_GLOW, 0.0), transparency: clamp(self.transparency, 0.0, 100.0, 60.0) }
|
||||
}
|
||||
/// Opacity 0..1.
|
||||
pub fn opacity(&self) -> f32 {
|
||||
1.0 - self.sanitized().transparency / 100.0
|
||||
}
|
||||
}
|
||||
|
||||
impl ShapeEffects {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.shadow.is_none() && self.glow.is_none() && self.soft_edge.is_none()
|
||||
}
|
||||
/// Finite, in range; zero-size glows and soft edges dropped.
|
||||
pub fn sanitized(self) -> ShapeEffects {
|
||||
ShapeEffects {
|
||||
shadow: self.shadow.map(Shadow::sanitized),
|
||||
glow: self.glow.map(Glow::sanitized).filter(|g| g.size > 0.0),
|
||||
soft_edge: self.soft_edge.map(|r| clamp(r, 0.0, MAX_SOFT_EDGE, 0.0)).filter(|r| *r > 0.0),
|
||||
}
|
||||
}
|
||||
/// How far the effects reach beyond the shape's frame on each side (left, top, right,
|
||||
/// bottom), points — what `wp:effectExtent` records.
|
||||
pub fn extent(&self) -> [f32; 4] {
|
||||
let e = self.sanitized();
|
||||
let mut out = [0.0f32; 4];
|
||||
if let Some(g) = e.glow {
|
||||
out = [g.size; 4];
|
||||
}
|
||||
if let Some(s) = e.shadow {
|
||||
let (dx, dy) = s.offset();
|
||||
let r = s.blur / 2.0;
|
||||
let reach = [r - dx, r - dy, r + dx, r + dy];
|
||||
for (o, v) in out.iter_mut().zip(reach) {
|
||||
*o = o.max(v.max(0.0));
|
||||
}
|
||||
}
|
||||
out.map(|v| (v * 100.0).round() / 100.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Bands approximating a blurred edge: `(grow, alpha)` pairs. Each is a copy of the silhouette
|
||||
/// grown outward by `grow` points (negative shrinks it), filled with opacity `alpha`; overlaid in
|
||||
/// any order, in one colour, they ramp linearly from `opacity` at `from` to nothing at `to`.
|
||||
pub fn bands(from: f32, to: f32, opacity: f32) -> Vec<(f32, f32)> {
|
||||
let (from, to) = (clamp(from, -1000.0, 1000.0, 0.0), clamp(to, -1000.0, 1000.0, 0.0));
|
||||
let opacity = clamp(opacity, 0.0, 1.0, 0.0);
|
||||
if opacity <= 0.0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let span = (to - from).max(0.0);
|
||||
if span < 0.25 {
|
||||
return vec![((from + to) / 2.0, opacity)];
|
||||
}
|
||||
let n = ((span / 1.5).ceil() as usize).clamp(3, 12);
|
||||
let nf = n as f32;
|
||||
(1..=n)
|
||||
.map(|m| {
|
||||
// The m-th band from the outside: after it, coverage is opacity·m/n.
|
||||
let grow = to - span * (m as f32 - 0.5) / nf;
|
||||
let before = 1.0 - opacity * (m as f32 - 1.0) / nf;
|
||||
let after = 1.0 - opacity * m as f32 / nf;
|
||||
let alpha = if before > 0.0 { 1.0 - after / before } else { 1.0 };
|
||||
(grow, alpha.clamp(0.0, 1.0))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bands_ramp_to_the_requested_opacity() {
|
||||
for (from, to, o) in [(-2.0, 2.0, 0.4), (-5.0, 0.0, 1.0), (0.0, 8.0, 0.6)] {
|
||||
let b = bands(from, to, o);
|
||||
assert!(b.iter().all(|(g, a)| *g > from && *g < to && (0.0..=1.0).contains(a)));
|
||||
let covered = 1.0 - b.iter().map(|(_, a)| 1.0 - a).product::<f32>();
|
||||
assert!((covered - o).abs() < 1e-4, "{covered} vs {o}");
|
||||
}
|
||||
assert_eq!(bands(0.0, 0.0, 0.5), vec![(0.0, 0.5)]);
|
||||
assert!(bands(f32::NAN, f32::INFINITY, f32::NAN).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_numbers_are_clamped() {
|
||||
let s = Shadow { color: Rgb::BLACK, transparency: f32::NAN, blur: 1e9, distance: -4.0, angle: -90.0 }.sanitized();
|
||||
assert_eq!((s.transparency, s.blur, s.distance, s.angle), (60.0, MAX_BLUR, 0.0, 270.0));
|
||||
let e = ShapeEffects { shadow: None, glow: Some(Glow { size: 1e9, ..Glow::default() }), soft_edge: Some(-1.0) }.sanitized();
|
||||
assert_eq!(e.glow.map(|g| g.size), Some(MAX_GLOW));
|
||||
assert_eq!(e.soft_edge, None);
|
||||
let e = ShapeEffects { glow: Some(Glow { size: f32::INFINITY, ..Glow::default() }), ..Default::default() }.sanitized();
|
||||
assert!(e.is_empty(), "a non-finite size is no glow");
|
||||
let (dx, dy) = Shadow::preset("offsetBottomRight").unwrap().offset();
|
||||
assert!(dx > 2.0 && dy > 2.0);
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@
|
|||
|
||||
pub mod bidi;
|
||||
pub mod edit;
|
||||
pub mod effects;
|
||||
pub mod encoding;
|
||||
pub mod fields;
|
||||
pub mod math;
|
||||
|
|
|
|||
|
|
@ -213,6 +213,9 @@ pub enum InlineObject {
|
|||
/// Text box content: `Document::parts` id.
|
||||
#[serde(default)]
|
||||
story: Option<u32>,
|
||||
/// Shadow, glow and soft edges.
|
||||
#[serde(default, skip_serializing_if = "crate::effects::ShapeEffects::is_empty")]
|
||||
effects: crate::effects::ShapeEffects,
|
||||
},
|
||||
/// A field: `instr` is the field code (`PAGE`, `NUMPAGES`, `DATE \@ "M/d/yyyy"`, `TOC \o "1-3"`…);
|
||||
/// `result` the cached display text.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::effects::{Glow, Shadow, ShapeEffects};
|
||||
use wordcraft_doc::para::{Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap};
|
||||
use wordcraft_doc::props::{CharProps, Rgb};
|
||||
use wordcraft_doc::table::{Cell, MAX_COLS, MAX_ROWS, Row, Table};
|
||||
|
|
@ -695,11 +696,12 @@ impl Reader<'_> {
|
|||
let ln = sppr.and_then(|s| s.child("a:ln"));
|
||||
let stroke = solid(ln);
|
||||
let stroke_width = ln.and_then(|l| l.attr("w")).and_then(|v| measure(v, 12_700.0)).unwrap_or(0.0).clamp(0.0, 100.0);
|
||||
let effects = sppr.and_then(|s| s.child("a:effectLst")).map(effect_list).unwrap_or_default();
|
||||
let story = match txbx {
|
||||
Some(t) if sc.story_depth < MAX_STORY_DEPTH => Some(self.read_textbox(sc, t, rels)),
|
||||
_ => None,
|
||||
};
|
||||
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story }
|
||||
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story, effects }
|
||||
}
|
||||
|
||||
fn read_textbox(&mut self, sc: &StoryCtx, content: &El, rels: &Rels) -> u32 {
|
||||
|
|
@ -729,7 +731,17 @@ impl Reader<'_> {
|
|||
let story = Some(self.read_textbox(sc, t, rels));
|
||||
let fill = shape.attr("fillcolor").and_then(Rgb::parse);
|
||||
let stroke = shape.attr("strokecolor").and_then(Rgb::parse);
|
||||
return Some(InlineObject::Shape { kind: ShapeKind::TextBox, w, h, fill, stroke, stroke_width: 0.75, float, story });
|
||||
return Some(InlineObject::Shape {
|
||||
kind: ShapeKind::TextBox,
|
||||
w,
|
||||
h,
|
||||
fill,
|
||||
stroke,
|
||||
stroke_width: 0.75,
|
||||
float,
|
||||
story,
|
||||
effects: Default::default(),
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
|
|
@ -914,3 +926,44 @@ fn vml_float(shape: &El, style: &str) -> Float {
|
|||
..Float::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A shape's `a:effectLst` (ECMA-376 Part 1 §20.1.8.26): its outer shadow, glow and soft edges.
|
||||
/// Other effects are dropped.
|
||||
fn effect_list(l: &El) -> ShapeEffects {
|
||||
let pt = |e: &El, n: &str| e.attr(n).and_then(|v| measure(v, 12_700.0)).unwrap_or(0.0);
|
||||
let shadow = l.child("a:outerShdw").map(|e| {
|
||||
let (color, transparency) = effect_color(e);
|
||||
// `dir`: 60000ths of a degree, clockwise.
|
||||
let angle = e.attr("dir").and_then(int).map(|v| (v.rem_euclid(21_600_000) as f32) / 60_000.0).unwrap_or(0.0);
|
||||
Shadow { color: color.unwrap_or(Rgb::BLACK), transparency, blur: pt(e, "blurRad"), distance: pt(e, "dist"), angle }
|
||||
});
|
||||
let glow = l.child("a:glow").map(|e| {
|
||||
let (color, transparency) = effect_color(e);
|
||||
Glow { color: color.unwrap_or(Glow::default().color), size: pt(e, "rad"), transparency }
|
||||
});
|
||||
let soft_edge = l.child("a:softEdge").map(|e| pt(e, "rad"));
|
||||
ShapeEffects { shadow, glow, soft_edge }.sanitized()
|
||||
}
|
||||
|
||||
/// The colour inside an effect (`a:srgbClr`, `a:prstClr`, `a:sysClr`; theme colours aren't
|
||||
/// resolved here) and its transparency, percent (from `a:alpha`, opaque when absent).
|
||||
fn effect_color(e: &El) -> (Option<Rgb>, f32) {
|
||||
let Some(c) = e.els().find(|c| matches!(c.name.as_str(), "a:srgbClr" | "a:prstClr" | "a:sysClr" | "a:schemeClr" | "a:scrgbClr" | "a:hslClr"))
|
||||
else {
|
||||
return (None, 0.0);
|
||||
};
|
||||
let rgb = match c.name.as_str() {
|
||||
"a:srgbClr" => c.attr("val").and_then(Rgb::parse),
|
||||
"a:sysClr" => c.attr("lastClr").and_then(Rgb::parse),
|
||||
"a:prstClr" => match c.attr("val").unwrap_or("") {
|
||||
"black" => Some(Rgb::BLACK),
|
||||
"white" => Some(Rgb::WHITE),
|
||||
"gray" | "grey" => Some(Rgb(0x80, 0x80, 0x80)),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
};
|
||||
// `a:alpha`: opacity in 1000ths of a percent.
|
||||
let opacity = c.child("a:alpha").and_then(|a| a.attr("val")).and_then(int).map(|v| v.clamp(0, 100_000) as f32 / 1000.0).unwrap_or(100.0);
|
||||
(rgb, 100.0 - opacity)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
//! Blocks, paragraphs, runs, objects and tables → WordprocessingML.
|
||||
|
||||
use wordcraft_doc::effects::ShapeEffects;
|
||||
use wordcraft_doc::para::{Anchor, Float, NoteKind, ShapeKind, Wrap};
|
||||
use wordcraft_doc::props::CharProps;
|
||||
use wordcraft_doc::props::{CharProps, Rgb};
|
||||
use wordcraft_doc::section::{LineNumberRestart, SectionProps, SectionStart};
|
||||
use wordcraft_doc::table::Table;
|
||||
use wordcraft_doc::{Block, Blocks, InlineObject, Paragraph, RevisionKind};
|
||||
|
|
@ -414,13 +415,19 @@ impl Writer<'_> {
|
|||
w.close("w:r");
|
||||
self.rev_close(w, props);
|
||||
}
|
||||
InlineObject::Shape { kind, w: sw, h: sh, fill, stroke, stroke_width, float, story } => {
|
||||
InlineObject::Shape { kind, w: sw, h: sh, fill, stroke, stroke_width, float, story, effects } => {
|
||||
self.rev_open(w, props);
|
||||
w.open("w:r", &[]);
|
||||
rpr(w, props);
|
||||
w.open("w:drawing", &[]);
|
||||
let docpr = self.next_docpr();
|
||||
let name = format!("Shape {docpr}");
|
||||
// The effect extent leaves room for the shadow and glow.
|
||||
let mut float = *float;
|
||||
for (e, fx) in float.effect.iter_mut().zip(effects.extent()) {
|
||||
*e = wordcraft_geom::finite(*e).max(fx);
|
||||
}
|
||||
let float = &float;
|
||||
self.drawing_open(w, float, *sw, *sh, &docpr, &name, "");
|
||||
w.empty("wp:cNvGraphicFramePr", &[]);
|
||||
w.open("a:graphic", &[]);
|
||||
|
|
@ -469,6 +476,7 @@ impl Writer<'_> {
|
|||
w.close("a:ln");
|
||||
}
|
||||
}
|
||||
effect_list(w, effects);
|
||||
w.close("wps:spPr");
|
||||
// Its text, within the same bounds layout shows boxes inside boxes with.
|
||||
if let Some(id) = *story
|
||||
|
|
@ -774,6 +782,50 @@ impl Writer<'_> {
|
|||
}
|
||||
}
|
||||
|
||||
/// A shape's `a:effectLst` (ECMA-376 Part 1 §20.1.8.26), children in schema order.
|
||||
fn effect_list(w: &mut W, effects: &ShapeEffects) {
|
||||
let fx = effects.sanitized();
|
||||
if fx.is_empty() {
|
||||
return;
|
||||
}
|
||||
let color = |w: &mut W, c: Rgb, transparency: f32| {
|
||||
let alpha = n(((100.0 - transparency.clamp(0.0, 100.0)) * 1000.0).round() as i64);
|
||||
w.open("a:srgbClr", &[("val", &c.hex())]);
|
||||
w.empty("a:alpha", &[("val", &alpha)]);
|
||||
w.close("a:srgbClr");
|
||||
};
|
||||
w.open("a:effectLst", &[]);
|
||||
if let Some(g) = fx.glow {
|
||||
w.open("a:glow", &[("rad", &emu(g.size))]);
|
||||
color(w, g.color, g.transparency);
|
||||
w.close("a:glow");
|
||||
}
|
||||
if let Some(s) = fx.shadow {
|
||||
let dir = n((s.angle * 60_000.0).round() as i64);
|
||||
// Anchor scaling at the corner the shadow falls away from (no scaling is written, so
|
||||
// this only matters to editors that resize it).
|
||||
let (dx, dy) = s.offset();
|
||||
let algn = match (dy > 0.01, dy < -0.01, dx > 0.01, dx < -0.01) {
|
||||
(true, _, true, _) => "tl",
|
||||
(true, _, _, true) => "tr",
|
||||
(true, _, _, _) => "t",
|
||||
(_, true, true, _) => "bl",
|
||||
(_, true, _, true) => "br",
|
||||
(_, true, _, _) => "b",
|
||||
(_, _, true, _) => "l",
|
||||
(_, _, _, true) => "r",
|
||||
_ => "ctr",
|
||||
};
|
||||
w.open("a:outerShdw", &[("blurRad", &emu(s.blur)), ("dist", &emu(s.distance)), ("dir", &dir), ("algn", algn), ("rotWithShape", "0")]);
|
||||
color(w, s.color, s.transparency);
|
||||
w.close("a:outerShdw");
|
||||
}
|
||||
if let Some(r) = fx.soft_edge {
|
||||
w.empty("a:softEdge", &[("rad", &emu(r))]);
|
||||
}
|
||||
w.close("a:effectLst");
|
||||
}
|
||||
|
||||
fn xfrm(w: &mut W, cw: f32, ch: f32) {
|
||||
w.open("a:xfrm", &[]);
|
||||
w.empty("a:off", &[("x", "0"), ("y", "0")]);
|
||||
|
|
|
|||
|
|
@ -670,6 +670,7 @@ fn toc_field_skips_nested_stories() {
|
|||
stroke_width: 0.0,
|
||||
float: Float::default(),
|
||||
story: Some(story),
|
||||
effects: Default::default(),
|
||||
}
|
||||
};
|
||||
let mut head = Paragraph::with_text("Contents", CharProps::default()).styled("TOCHeading");
|
||||
|
|
@ -791,6 +792,7 @@ fn shapes_textboxes_equations_dropcaps_round_trip() {
|
|||
stroke_width: 1.0,
|
||||
float: Float { wrap: Wrap::Square, h_rel: Anchor::Margin, v_rel: Anchor::Paragraph, x: 10.0, y: 20.0, dist: 0.0, ..Default::default() },
|
||||
story: Some(story),
|
||||
effects: Default::default(),
|
||||
};
|
||||
let star = InlineObject::Shape {
|
||||
kind: ShapeKind::Star,
|
||||
|
|
@ -801,6 +803,7 @@ fn shapes_textboxes_equations_dropcaps_round_trip() {
|
|||
stroke_width: 0.0,
|
||||
float: Float::default(),
|
||||
story: None,
|
||||
effects: Default::default(),
|
||||
};
|
||||
let eq = InlineObject::Equation { linear: "x=(-b±√(b^2-4ac))/2a".into(), display: false, math: Default::default() };
|
||||
let mut p = Paragraph::with_text("shapes ", CharProps::default());
|
||||
|
|
@ -816,7 +819,7 @@ fn shapes_textboxes_equations_dropcaps_round_trip() {
|
|||
let got = paras(&r);
|
||||
assert_eq!(got[0].objects.len(), 3);
|
||||
match &got[0].objects[0] {
|
||||
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story } => {
|
||||
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story, .. } => {
|
||||
assert_eq!(
|
||||
(*kind, *w, *h, *fill, *stroke, *stroke_width),
|
||||
(ShapeKind::TextBox, 144.0, 72.0, Some(Rgb(255, 255, 200)), Some(Rgb(0, 0, 0)), 1.0)
|
||||
|
|
@ -1017,6 +1020,7 @@ fn self_showing_text_box_saves_bounded() {
|
|||
stroke_width: 0.0,
|
||||
float: Float::default(),
|
||||
story: Some(id),
|
||||
effects: Default::default(),
|
||||
};
|
||||
for _ in 0..30 {
|
||||
d.insert_object(
|
||||
|
|
@ -1097,3 +1101,48 @@ fn list_level_overrides_round_trip() {
|
|||
assert_eq!(c.next_label(&back.numbering, restart, 0).unwrap().0, "1.");
|
||||
assert_eq!(c.next_label(&back.numbering, restart, 1).unwrap().0, "1.01");
|
||||
}
|
||||
|
||||
/// Shape effects (#275): `a:effectLst` with an outer shadow, a glow and soft edges round-trips,
|
||||
/// and the effect extent leaves room for them.
|
||||
#[test]
|
||||
fn shape_effects_round_trip() {
|
||||
use wordcraft_doc::effects::{Glow, Shadow, ShapeEffects};
|
||||
let effects = ShapeEffects {
|
||||
shadow: Some(Shadow { color: Rgb(0x20, 0x30, 0x40), transparency: 60.0, blur: 4.0, distance: 3.0, angle: 135.0 }),
|
||||
glow: Some(Glow { color: Rgb(0xC0, 0x50, 0x10), size: 8.0, transparency: 40.0 }),
|
||||
soft_edge: Some(2.5),
|
||||
};
|
||||
let shape = InlineObject::Shape {
|
||||
kind: ShapeKind::RoundedRectangle,
|
||||
w: 100.0,
|
||||
h: 50.0,
|
||||
fill: Some(Rgb(0x15, 0x60, 0x82)),
|
||||
stroke: None,
|
||||
stroke_width: 0.0,
|
||||
float: Float::default(),
|
||||
story: None,
|
||||
effects,
|
||||
};
|
||||
let mut p = Paragraph::with_text("x", CharProps::default());
|
||||
p.insert_object(1, shape, &CharProps::default()).unwrap();
|
||||
let bytes = wordcraft_docx::write(&doc_with(vec![p])).expect("write");
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||||
assert!(
|
||||
xml.contains(
|
||||
r#"<a:outerShdw blurRad="50800" dist="38100" dir="8100000" algn="tr" rotWithShape="0"><a:srgbClr val="203040"><a:alpha val="40000"/>"#
|
||||
),
|
||||
"{xml}"
|
||||
);
|
||||
assert!(xml.contains(r#"<a:glow rad="101600"><a:srgbClr val="C05010"><a:alpha val="60000"/>"#), "{xml}");
|
||||
assert!(xml.contains(r#"<a:softEdge rad="31750"/>"#), "{xml}");
|
||||
let r = wordcraft_docx::read(&bytes).expect("read");
|
||||
match paras(&r)[0].objects.first() {
|
||||
Some(InlineObject::Shape { effects: got, float, .. }) => {
|
||||
assert_eq!(*got, effects);
|
||||
assert_eq!(float.effect_extent(), effects.extent());
|
||||
}
|
||||
o => panic!("{o:?}"),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -294,6 +294,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
})
|
||||
.params(r#"{"color": "RRGGBB" | null, "width"?: pt}"#)
|
||||
.when(has_shape),
|
||||
CommandSpec::new("shape.effects", "Shape Effects", "Shape Format › Shape Styles", shape_effects)
|
||||
.params(
|
||||
r#"{"shadow"?: preset|{"preset"?, "color"?: "RRGGBB", "transparency"?: %, "blur"?: pt, "distance"?: pt, "angle"?: deg}|null, "glow"?: pt|{"color"?: "RRGGBB", "size"?: pt, "transparency"?: %}|null, "softEdge"?: pt|null} (shadow presets: offsetBottomRight, offsetBottom, offsetBottomLeft, offsetRight, offsetCenter, offsetLeft, offsetTopRight, offsetTop, offsetTopLeft; an omitted key is left as it is; applies to every shape in the selection)"#,
|
||||
)
|
||||
.when(has_shape),
|
||||
CommandSpec::new("shape.change", "Change Shape", "Shape Format › Insert Shapes", |s, v| {
|
||||
let kind: wordcraft_doc::para::ShapeKind = serde_json::from_value(v.get("kind").cloned().unwrap_or(json!("rectangle"))).map_err(|e| CmdError::Params(e.to_string()))?;
|
||||
with_obj(s, |o| {
|
||||
|
|
@ -320,7 +325,15 @@ pub fn object_selection(s: &Session) -> Option<(Pos, &InlineObject)> {
|
|||
|
||||
/// The first picture/shape in the selection, or just before a collapsed caret.
|
||||
pub fn selected(s: &Session) -> Option<(Pos, InlineObject)> {
|
||||
selected_all(s, 1).into_iter().next()
|
||||
}
|
||||
|
||||
/// The pictures and shapes in the selection (at most `max`), or the one just before a collapsed
|
||||
/// caret.
|
||||
pub fn selected_all(s: &Session, max: usize) -> Vec<(Pos, InlineObject)> {
|
||||
let mut out = Vec::new();
|
||||
let (a, b) = s.sel.ordered();
|
||||
let max = if a == b { 1 } else { max };
|
||||
let story = a.story;
|
||||
let paths = if a == b { vec![a.path.clone()] } else { s.doc.paths_between(&a, &b) };
|
||||
for path in paths {
|
||||
|
|
@ -335,11 +348,97 @@ pub fn selected(s: &Session) -> Option<(Pos, InlineObject)> {
|
|||
continue;
|
||||
}
|
||||
if let Some(o @ (InlineObject::Image { .. } | InlineObject::Shape { .. })) = p.object_at(off) {
|
||||
return Some((Pos { story, path: path.clone(), off }, o.clone()));
|
||||
out.push((Pos { story, path: path.clone(), off }, o.clone()));
|
||||
if out.len() >= max {
|
||||
return out;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
out
|
||||
}
|
||||
|
||||
/// Most shapes one `shape.effects` changes.
|
||||
const MAX_EFFECT_SHAPES: usize = 10_000;
|
||||
|
||||
/// `shape.effects`: set or clear the shadow, glow and soft edges of the selected shapes.
|
||||
fn shape_effects(s: &mut Session, v: &Value) -> CmdResult {
|
||||
use wordcraft_doc::effects::{Glow, Shadow, ShapeEffects};
|
||||
let color = |o: &Value| p::str(o, "color").and_then(Rgb::parse);
|
||||
// Each change is `None` (leave as it is) or `Some(new value)`.
|
||||
let shadow: Option<Option<Shadow>> = match v.get("shadow") {
|
||||
None => None,
|
||||
Some(Value::Null) => Some(None),
|
||||
Some(Value::Bool(b)) => Some(b.then(Shadow::default)),
|
||||
Some(Value::String(id)) if id == "none" => Some(None),
|
||||
Some(Value::String(id)) => Some(Some(Shadow::preset(id).ok_or_else(|| CmdError::Params(format!("unknown shadow preset `{id}`")))?)),
|
||||
Some(o @ Value::Object(_)) => {
|
||||
let mut sh = match p::str(o, "preset") {
|
||||
Some(id) => Shadow::preset(id).ok_or_else(|| CmdError::Params(format!("unknown shadow preset `{id}`")))?,
|
||||
None => Shadow::default(),
|
||||
};
|
||||
sh.color = color(o).unwrap_or(sh.color);
|
||||
sh.transparency = p::f32(o, "transparency").unwrap_or(sh.transparency);
|
||||
sh.blur = p::f32(o, "blur").unwrap_or(sh.blur);
|
||||
sh.distance = p::f32(o, "distance").unwrap_or(sh.distance);
|
||||
sh.angle = p::f32(o, "angle").unwrap_or(sh.angle);
|
||||
Some(Some(sh.sanitized()))
|
||||
}
|
||||
Some(_) => return Err(CmdError::Params("`shadow`: a preset name, an object or null".into())),
|
||||
};
|
||||
let glow: Option<Option<Glow>> = match v.get("glow") {
|
||||
None => None,
|
||||
Some(Value::Null) => Some(None),
|
||||
Some(Value::String(id)) if id == "none" => Some(None),
|
||||
Some(n @ Value::Number(_)) => Some(Some(Glow { size: n.as_f64().unwrap_or(0.0) as f32, ..Glow::default() }.sanitized())),
|
||||
Some(o @ Value::Object(_)) => {
|
||||
let d = Glow::default();
|
||||
Some(Some(
|
||||
Glow {
|
||||
color: color(o).unwrap_or(d.color),
|
||||
size: p::f32(o, "size").unwrap_or(d.size),
|
||||
transparency: p::f32(o, "transparency").unwrap_or(d.transparency),
|
||||
}
|
||||
.sanitized(),
|
||||
))
|
||||
}
|
||||
Some(_) => return Err(CmdError::Params("`glow`: a size in points, an object or null".into())),
|
||||
};
|
||||
let soft: Option<Option<f32>> = match v.get("softEdge") {
|
||||
None => None,
|
||||
Some(Value::Null) => Some(None),
|
||||
Some(n @ Value::Number(_)) => Some(n.as_f64().map(|x| x as f32)),
|
||||
Some(_) => return Err(CmdError::Params("`softEdge`: a radius in points or null".into())),
|
||||
};
|
||||
let targets: Vec<Pos> =
|
||||
selected_all(s, MAX_EFFECT_SHAPES).into_iter().filter(|(_, o)| matches!(o, InlineObject::Shape { .. })).map(|(pos, _)| pos).collect();
|
||||
if targets.is_empty() {
|
||||
return Err(CmdError::Disabled("no shape selected".into()));
|
||||
}
|
||||
let mut last = ShapeEffects::default();
|
||||
for pos in &targets {
|
||||
let o = edit_obj(s, pos, |o| {
|
||||
if let InlineObject::Shape { effects, float, .. } = o {
|
||||
let mut e = *effects;
|
||||
if let Some(x) = shadow {
|
||||
e.shadow = x;
|
||||
}
|
||||
if let Some(x) = glow {
|
||||
e.glow = x;
|
||||
}
|
||||
if let Some(x) = soft {
|
||||
e.soft_edge = x;
|
||||
}
|
||||
*effects = e.sanitized();
|
||||
// Room around the shape for its shadow and glow, as Word's effect extent.
|
||||
float.effect = effects.extent();
|
||||
}
|
||||
})?;
|
||||
if let InlineObject::Shape { effects, .. } = o {
|
||||
last = effects;
|
||||
}
|
||||
}
|
||||
Ok(json!({"shapes": targets.len(), "effects": serde_json::to_value(last).unwrap_or(Value::Null)}))
|
||||
}
|
||||
|
||||
/// Smallest width/height an object can be resized to, points (a text box keeps room for a line).
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,6 +64,8 @@ pub enum Draw {
|
|||
fill: Option<Rgb>,
|
||||
stroke: Option<Rgb>,
|
||||
stroke_width: f32,
|
||||
/// Shadow, glow and soft edges (drawn with [`wordcraft_doc::effects::bands`]).
|
||||
effects: wordcraft_doc::effects::ShapeEffects,
|
||||
},
|
||||
/// A formatting mark (¶ · → ↵) in the UI's mark colour, or in `color` (a tracked
|
||||
/// paragraph mark in its reviser's colour).
|
||||
|
|
@ -188,8 +190,8 @@ fn item(doc: &Document, it: &Placed, opts: &DisplayOptions, alpha: f32, out: &mu
|
|||
Placed::Fill { rect, color } => out.push(Draw::Fill { rect: *rect, color: *color, alpha }),
|
||||
Placed::Rule { x0, y0, x1, y1, border } => out.push(rule(*x0, *y0, *x1, *y1, border, alpha)),
|
||||
Placed::Image { rect, media, crop, .. } => out.push(Draw::Image { rect: *rect, media: media.clone(), crop: *crop, alpha }),
|
||||
Placed::Shape { rect, kind, fill, stroke, stroke_width } => {
|
||||
out.push(Draw::Shape { rect: *rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width })
|
||||
Placed::Shape { rect, kind, fill, stroke, stroke_width, effects } => {
|
||||
out.push(Draw::Shape { rect: *rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width, effects: *effects })
|
||||
}
|
||||
Placed::Cell { .. } | Placed::Object { .. } => {}
|
||||
Placed::Lines { story, path, para, l0, l1, x, y, turn } if turn.is_turned() => {
|
||||
|
|
@ -512,8 +514,8 @@ fn lines(
|
|||
let rect = inline_rect(obj, cx, base, c.adv, c.obj_h);
|
||||
match obj {
|
||||
Some(InlineObject::Image { media, crop, .. }) => out.push(Draw::Image { rect, media: media.clone(), crop: *crop, alpha }),
|
||||
Some(InlineObject::Shape { kind, fill, stroke, stroke_width, .. }) => {
|
||||
out.push(Draw::Shape { rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width })
|
||||
Some(InlineObject::Shape { kind, fill, stroke, stroke_width, effects, .. }) => {
|
||||
out.push(Draw::Shape { rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width, effects: *effects })
|
||||
}
|
||||
Some(InlineObject::Equation { .. }) => {
|
||||
if let Some((_, ml)) = pl.maths.iter().find(|(k, _)| *k == oi) {
|
||||
|
|
|
|||
|
|
@ -95,6 +95,7 @@ pub enum Placed {
|
|||
fill: Option<Rgb>,
|
||||
stroke: Option<Rgb>,
|
||||
stroke_width: f32,
|
||||
effects: wordcraft_doc::effects::ShapeEffects,
|
||||
},
|
||||
/// A table cell's area (for hit testing and cell selection).
|
||||
Cell {
|
||||
|
|
@ -1108,8 +1109,8 @@ fn float_item(o: &InlineObject, rect: Rect, story: StoryRef, path: &[u32], off:
|
|||
InlineObject::Image { media, crop, .. } => {
|
||||
Some(Placed::Image { rect, media: media.clone(), crop: *crop, story, path: Path(path.to_vec()), off })
|
||||
}
|
||||
InlineObject::Shape { kind, fill, stroke, stroke_width, .. } => {
|
||||
Some(Placed::Shape { rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width })
|
||||
InlineObject::Shape { kind, fill, stroke, stroke_width, effects, .. } => {
|
||||
Some(Placed::Shape { rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width, effects: *effects })
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -231,6 +231,7 @@ fn many_hidden_float_anchors_lay_out_in_linear_time() {
|
|||
stroke_width: 1.0,
|
||||
float,
|
||||
story: None,
|
||||
effects: Default::default(),
|
||||
};
|
||||
let obj = wordcraft_doc::para::OBJ.to_string();
|
||||
let mut p = wordcraft_doc::Paragraph::with_text("Text ", Default::default());
|
||||
|
|
@ -970,6 +971,7 @@ fn text_wraps_around_square_float() {
|
|||
stroke_width: 1.0,
|
||||
float,
|
||||
story: None,
|
||||
effects: Default::default(),
|
||||
};
|
||||
d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
|
|
@ -1007,6 +1009,7 @@ fn deleted_float_leaves_no_wrap_area_without_markup() {
|
|||
stroke_width: 1.0,
|
||||
float,
|
||||
story: None,
|
||||
effects: Default::default(),
|
||||
};
|
||||
d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap();
|
||||
let obj = wordcraft_doc::para::OBJ.len_utf8();
|
||||
|
|
@ -1058,6 +1061,7 @@ fn hidden_float_leaves_no_wrap_area_when_hidden_text_is_not_shown() {
|
|||
stroke_width: 1.0,
|
||||
float,
|
||||
story: None,
|
||||
effects: Default::default(),
|
||||
};
|
||||
d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap();
|
||||
let obj = wordcraft_doc::para::OBJ.len_utf8();
|
||||
|
|
@ -1094,6 +1098,7 @@ fn line_numbers_borders_text_boxes() {
|
|||
stroke_width: 1.0,
|
||||
float: Default::default(),
|
||||
story: Some(id),
|
||||
effects: Default::default(),
|
||||
};
|
||||
d.insert_object(&Pos::body(2, 5), tb, &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
|
|
@ -1387,6 +1392,7 @@ fn text_box_at(d: &mut Document, pos: &Pos, text: &str, w: f32, h: f32, float: w
|
|||
stroke_width: 0.75,
|
||||
float,
|
||||
story: Some(id),
|
||||
effects: Default::default(),
|
||||
};
|
||||
d.insert_object(pos, shape, &Default::default()).unwrap();
|
||||
id
|
||||
|
|
@ -1457,6 +1463,7 @@ fn presses_grab_pictures_anywhere_and_text_boxes_by_their_border() {
|
|||
stroke_width: 1.0,
|
||||
float: Default::default(),
|
||||
story: None,
|
||||
effects: Default::default(),
|
||||
};
|
||||
d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
|
|
@ -1676,6 +1683,7 @@ fn box_fan_out(levels: usize, fan: usize) -> Document {
|
|||
stroke_width: 0.0,
|
||||
float: Default::default(),
|
||||
story: Some(story),
|
||||
effects: Default::default(),
|
||||
};
|
||||
for (k, id) in ids.iter().enumerate() {
|
||||
let next = *ids.get(k + 1).unwrap_or(id);
|
||||
|
|
@ -1739,7 +1747,17 @@ fn a_text_box_inside_a_text_box_still_shows_its_text() {
|
|||
}
|
||||
|
||||
fn picture(w: f32, h: f32, float: wordcraft_doc::para::Float) -> InlineObject {
|
||||
InlineObject::Shape { kind: wordcraft_doc::para::ShapeKind::Rectangle, w, h, fill: None, stroke: None, stroke_width: 1.0, float, story: None }
|
||||
InlineObject::Shape {
|
||||
kind: wordcraft_doc::para::ShapeKind::Rectangle,
|
||||
w,
|
||||
h,
|
||||
fill: None,
|
||||
stroke: None,
|
||||
stroke_width: 1.0,
|
||||
float,
|
||||
story: None,
|
||||
effects: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1877,7 +1895,17 @@ fn lines_with_tabs_never_shrink() {
|
|||
}
|
||||
|
||||
fn rect_shape(w: f32, h: f32, float: wordcraft_doc::para::Float) -> InlineObject {
|
||||
InlineObject::Shape { kind: wordcraft_doc::para::ShapeKind::Rectangle, w, h, fill: None, stroke: None, stroke_width: 1.0, float, story: None }
|
||||
InlineObject::Shape {
|
||||
kind: wordcraft_doc::para::ShapeKind::Rectangle,
|
||||
w,
|
||||
h,
|
||||
fill: None,
|
||||
stroke: None,
|
||||
stroke_width: 1.0,
|
||||
float,
|
||||
story: None,
|
||||
effects: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn shapes(items: &[Placed]) -> Vec<Rect> {
|
||||
|
|
@ -2042,6 +2070,7 @@ fn floating_header_text_box_leaves_the_body_at_the_top_margin() {
|
|||
stroke_width: 0.75,
|
||||
float,
|
||||
story: Some(story),
|
||||
effects: Default::default(),
|
||||
};
|
||||
anchor.insert_object(0, tb, &Default::default()).unwrap();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -312,6 +312,46 @@ fn rect_path(r: &Rect) -> Option<Path> {
|
|||
pb.finish()
|
||||
}
|
||||
|
||||
/// `r` grown by `g` points on every side and moved by (`dx`, `dy`).
|
||||
fn grown(r: &Rect, g: f32, dx: f32, dy: f32) -> Rect {
|
||||
Rect { x: r.x - g + dx, y: r.y - g + dy, w: r.w + 2.0 * g, h: r.h + 2.0 * g }
|
||||
}
|
||||
|
||||
/// A shape's shadow and glow, behind it: bands of its silhouette (the same approximation of a
|
||||
/// blur as the raster renderer, see [`wordcraft_doc::effects::bands`]).
|
||||
fn shape_effects(s: &mut Surface, kind: ShapeKind, rect: &Rect, fx: &wordcraft_doc::effects::ShapeEffects, filled: bool, stroked: bool, sw: f32) {
|
||||
let sw = if sw.is_finite() { sw.clamp(0.25, 200.0) } else { 0.75 };
|
||||
let silhouette = |s: &mut Surface, c: Rgb, a: f32, g: f32, dx: f32, dy: f32| {
|
||||
if filled {
|
||||
let grow = g + if stroked { sw / 2.0 } else { 0.0 };
|
||||
if let Some(p) = shape_path(kind, &grown(rect, grow, dx, dy)) {
|
||||
s.set_stroke(None);
|
||||
s.set_fill(Some(fill(c, a)));
|
||||
s.draw_path(&p);
|
||||
}
|
||||
} else if sw + 2.0 * g > 0.05
|
||||
&& let Some(p) = shape_path(kind, &grown(rect, 0.0, dx, dy))
|
||||
{
|
||||
s.set_fill(None);
|
||||
s.set_stroke(Some(Stroke { paint: rgb::Color::new(c.0, c.1, c.2).into(), width: sw + 2.0 * g, opacity: norm(a), ..Default::default() }));
|
||||
s.draw_path(&p);
|
||||
}
|
||||
};
|
||||
if let Some(sh) = fx.shadow {
|
||||
let (dx, dy) = sh.offset();
|
||||
for (g, a) in wordcraft_doc::effects::bands(-sh.blur / 2.0, sh.blur / 2.0, sh.opacity()) {
|
||||
silhouette(s, sh.color, a, g, dx, dy);
|
||||
}
|
||||
}
|
||||
if let Some(gl) = fx.glow {
|
||||
for (g, a) in wordcraft_doc::effects::bands(0.0, gl.size, gl.opacity()) {
|
||||
silhouette(s, gl.color, a, g, 0.0, 0.0);
|
||||
}
|
||||
}
|
||||
s.set_fill(None);
|
||||
s.set_stroke(None);
|
||||
}
|
||||
|
||||
/// Outline of a basic shape in a rectangle (same geometry as the raster renderer).
|
||||
fn shape_path(kind: ShapeKind, r: &Rect) -> Option<Path> {
|
||||
if !(ok(r.x) && ok(r.y) && ok(r.w) && ok(r.h)) || r.w <= 0.0 || r.h <= 0.0 {
|
||||
|
|
@ -635,9 +675,26 @@ impl Exporter<'_> {
|
|||
}
|
||||
Draw::Line { x0, y0, x1, y1, width, color, stroke, alpha } => self.line(s, (*x0, *y0, *x1, *y1), *width, *color, *stroke, *alpha),
|
||||
Draw::Image { rect, media, crop, alpha } => self.picture(s, rect, media, crop, *alpha),
|
||||
Draw::Shape { rect, kind, fill: f, stroke, stroke_width } => {
|
||||
Draw::Shape { rect, kind, fill: f, stroke, stroke_width, effects } => {
|
||||
let Some(p) = shape_path(*kind, rect) else { return };
|
||||
let can_fill = *kind != ShapeKind::Line;
|
||||
let fx = effects.sanitized();
|
||||
if !fx.is_empty() && (f.is_some() && can_fill || stroke.is_some()) {
|
||||
shape_effects(s, *kind, rect, &fx, f.is_some() && can_fill, stroke.is_some(), *stroke_width);
|
||||
}
|
||||
// Soft edges: the fill fades out toward the outline (which fades with it).
|
||||
if let (Some(rad), Some(c), true) = (fx.soft_edge, f, can_fill) {
|
||||
let rad = rad.min(rect.w.min(rect.h) / 2.0).max(0.0);
|
||||
s.set_stroke(None);
|
||||
for (g, a) in wordcraft_doc::effects::bands(-rad, 0.0, 1.0) {
|
||||
if let Some(p) = shape_path(*kind, &grown(rect, g, 0.0, 0.0)) {
|
||||
s.set_fill(Some(fill(*c, a)));
|
||||
s.draw_path(&p);
|
||||
}
|
||||
}
|
||||
s.set_fill(None);
|
||||
return;
|
||||
}
|
||||
s.set_fill(f.filter(|_| can_fill).map(|c| fill(c, 1.0)));
|
||||
s.set_stroke(stroke.map(|c| Stroke {
|
||||
paint: rgb::Color::new(c.0, c.1, c.2).into(),
|
||||
|
|
|
|||
|
|
@ -417,10 +417,54 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl
|
|||
ctx.pop_layer();
|
||||
}
|
||||
}
|
||||
Draw::Shape { rect, kind, fill, stroke, stroke_width } => {
|
||||
Draw::Shape { rect, kind, fill, stroke, stroke_width, effects } => {
|
||||
let r = kurbo::Rect::new(rect.x as f64, rect.y as f64, rect.right() as f64, rect.bottom() as f64);
|
||||
let path = shape_path(*kind, r);
|
||||
ctx.set_transform(view);
|
||||
let fx = effects.sanitized();
|
||||
let can_fill = *kind != ShapeKind::Line && fill.is_some();
|
||||
if !fx.is_empty() && (can_fill || stroke.is_some()) {
|
||||
let sw = if stroke_width.is_finite() { stroke_width.clamp(0.25, 200.0) as f64 } else { 0.75 };
|
||||
// The shape's silhouette grown by `g` points and moved by `d`, in the current paint.
|
||||
let silhouette = |ctx: &mut RenderContext, g: f32, d: kurbo::Vec2| {
|
||||
let g = g as f64;
|
||||
if can_fill {
|
||||
let grow = g + if stroke.is_some() { sw / 2.0 } else { 0.0 };
|
||||
let rr = r.inflate(grow, grow) + d;
|
||||
if rr.width() > 0.0 && rr.height() > 0.0 {
|
||||
ctx.fill_path(&shape_path(*kind, rr));
|
||||
}
|
||||
} else if sw + 2.0 * g > 0.05 {
|
||||
ctx.set_stroke(kurbo::Stroke::new(sw + 2.0 * g));
|
||||
ctx.stroke_path(&shape_path(*kind, r + d));
|
||||
}
|
||||
};
|
||||
if let Some(sh) = fx.shadow {
|
||||
let (dx, dy) = sh.offset();
|
||||
for (g, a) in wordcraft_doc::effects::bands(-sh.blur / 2.0, sh.blur / 2.0, sh.opacity()) {
|
||||
ctx.set_paint(color(opts.ink(sh.color), a));
|
||||
silhouette(ctx, g, kurbo::Vec2::new(dx as f64, dy as f64));
|
||||
}
|
||||
}
|
||||
if let Some(gl) = fx.glow {
|
||||
for (g, a) in wordcraft_doc::effects::bands(0.0, gl.size, gl.opacity()) {
|
||||
ctx.set_paint(color(opts.ink(gl.color), a));
|
||||
silhouette(ctx, g, kurbo::Vec2::ZERO);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Soft edges: the fill fades out toward the outline (which fades with it).
|
||||
if let (Some(rad), Some(f), true) = (fx.soft_edge, fill, can_fill) {
|
||||
let rad = rad.min(rect.w.min(rect.h) / 2.0).max(0.0);
|
||||
for (g, a) in wordcraft_doc::effects::bands(-rad, 0.0, 1.0) {
|
||||
let rr = r.inflate(g as f64, g as f64);
|
||||
if rr.width() > 0.0 && rr.height() > 0.0 {
|
||||
ctx.set_paint(color(opts.ink(*f), a));
|
||||
ctx.fill_path(&shape_path(*kind, rr));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(f) = fill {
|
||||
ctx.set_paint(color(opts.ink(*f), 1.0));
|
||||
ctx.fill_path(&path);
|
||||
|
|
@ -552,6 +596,61 @@ mod tests {
|
|||
assert!(ink(TextDirection::Up) > 30, "up");
|
||||
}
|
||||
|
||||
/// Shape Effects (#275): an outer shadow paints dark pixels beside the shape on the side it is
|
||||
/// cast to, and none on the other; a glow surrounds it.
|
||||
#[test]
|
||||
fn shape_shadow_is_cast_offset_from_the_shape() {
|
||||
use wordcraft_doc::effects::{Glow, Shadow, ShapeEffects};
|
||||
use wordcraft_doc::para::InlineObject;
|
||||
let draw = |effects: ShapeEffects| {
|
||||
let mut d = Document::from_text("");
|
||||
let shape = InlineObject::Shape {
|
||||
kind: ShapeKind::Rectangle,
|
||||
w: 100.0,
|
||||
h: 60.0,
|
||||
fill: Some(Rgb(0, 0, 255)),
|
||||
stroke: None,
|
||||
stroke_width: 0.0,
|
||||
float: Default::default(),
|
||||
story: None,
|
||||
effects,
|
||||
};
|
||||
let at = wordcraft_doc::Pos { story: wordcraft_doc::StoryRef::Body, path: wordcraft_doc::Path::top(0), off: 0 };
|
||||
d.insert_object(&at, shape, &Default::default()).unwrap();
|
||||
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
|
||||
render_page(&d, &l.pages[0], 1.0, &RenderOptions::default())
|
||||
};
|
||||
let plain = draw(ShapeEffects::default());
|
||||
let blue = |img: &Rendered| {
|
||||
let px: Vec<(u32, u32)> = (0..612)
|
||||
.flat_map(|x| (0..400).map(move |y| (x, y)))
|
||||
.filter(|(x, y)| matches!(img.pixel(*x, *y), [r, _, b, _] if r < 40 && b > 200))
|
||||
.collect();
|
||||
let (x0, x1) = (px.iter().map(|p| p.0).min().unwrap(), px.iter().map(|p| p.0).max().unwrap());
|
||||
let (y0, y1) = (px.iter().map(|p| p.1).min().unwrap(), px.iter().map(|p| p.1).max().unwrap());
|
||||
(x0, y0, x1, y1)
|
||||
};
|
||||
let (x0, y0, x1, y1) = blue(&plain);
|
||||
assert!(x1 - x0 > 90 && y1 - y0 > 50, "{:?}", (x0, y0, x1, y1));
|
||||
// Grey (not white, not blue) pixels in a strip just outside an edge.
|
||||
let grey = |img: &Rendered, xs: std::ops::Range<u32>, ys: std::ops::Range<u32>| {
|
||||
xs.flat_map(|x| ys.clone().map(move |y| (x, y)))
|
||||
.filter(|(x, y)| matches!(img.pixel(*x, *y), [r, g, b, _] if r < 200 && r == g && g == b))
|
||||
.count()
|
||||
};
|
||||
let right = |img: &Rendered| grey(img, x1 + 2..x1 + 6, y0 + 10..y1);
|
||||
let left = |img: &Rendered| grey(img, x0.saturating_sub(6)..x0.saturating_sub(2), y0 + 10..y1);
|
||||
assert_eq!((right(&plain), left(&plain)), (0, 0));
|
||||
let shadow = Shadow { color: Rgb::BLACK, transparency: 30.0, blur: 2.0, distance: 8.0, angle: 0.0 };
|
||||
let img = draw(ShapeEffects { shadow: Some(shadow), ..Default::default() });
|
||||
assert_eq!(blue(&img), (x0, y0, x1, y1), "the shape itself is unchanged");
|
||||
assert!(right(&img) > 100, "shadow to the right: {}", right(&img));
|
||||
assert_eq!(left(&img), 0, "nothing on the left");
|
||||
let img = draw(ShapeEffects { glow: Some(Glow { color: Rgb(255, 0, 0), size: 6.0, transparency: 0.0 }), ..Default::default() });
|
||||
let reddish = |x: u32, y: u32| matches!(img.pixel(x, y), [r, g, _, _] if r > 200 && g < 200);
|
||||
assert!(reddish(x1 + 2, (y0 + y1) / 2) && reddish(x0 - 2, (y0 + y1) / 2), "glow on both sides");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stem_darkening_follows_the_macos_curve() {
|
||||
assert_eq!(stem_darkening(0.0), 0.0);
|
||||
|
|
|
|||
|
|
@ -948,3 +948,21 @@
|
|||
Dark Oscuro
|
||||
Use system setting Usar la configuración del sistema
|
||||
A file dialog is already open. Ya hay un cuadro de diálogo de archivo abierto.
|
||||
Shape Effects Efectos de forma
|
||||
Glow Resplandor
|
||||
Soft Edges Bordes suaves
|
||||
No Shadow Sin sombra
|
||||
No Glow Sin resplandor
|
||||
No Soft Edges Sin bordes suaves
|
||||
Glow Colors Colores de resplandor
|
||||
Weight Grosor
|
||||
Shadow, glow and soft edges Sombra, resplandor y bordes suaves
|
||||
Offset: Bottom Right Desplazamiento: abajo a la derecha
|
||||
Offset: Bottom Desplazamiento: abajo
|
||||
Offset: Bottom Left Desplazamiento: abajo a la izquierda
|
||||
Offset: Right Desplazamiento: derecha
|
||||
Offset: Center Desplazamiento: centro
|
||||
Offset: Left Desplazamiento: izquierda
|
||||
Offset: Top Right Desplazamiento: arriba a la derecha
|
||||
Offset: Top Desplazamiento: arriba
|
||||
Offset: Top Left Desplazamiento: arriba a la izquierda
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
|
|
@ -931,3 +931,21 @@
|
|||
Dark ダーク
|
||||
Use system setting システム設定を使用
|
||||
A file dialog is already open. ファイル ダイアログが既に開いています。
|
||||
Shape Effects 図形の効果
|
||||
Glow 光彩
|
||||
Soft Edges 輪郭のぼかし
|
||||
No Shadow 影なし
|
||||
No Glow 光彩なし
|
||||
No Soft Edges ぼかしなし
|
||||
Glow Colors 光彩の色
|
||||
Weight 太さ
|
||||
Shadow, glow and soft edges 影、光彩、輪郭のぼかし
|
||||
Offset: Bottom Right オフセット: 右下
|
||||
Offset: Bottom オフセット: 下
|
||||
Offset: Bottom Left オフセット: 左下
|
||||
Offset: Right オフセット: 右
|
||||
Offset: Center オフセット: 中央
|
||||
Offset: Left オフセット: 左
|
||||
Offset: Top Right オフセット: 右上
|
||||
Offset: Top オフセット: 上
|
||||
Offset: Top Left オフセット: 左上
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 3.
|
|
|
@ -943,3 +943,21 @@
|
|||
Dark Escuro
|
||||
Use system setting Usar a configuração do sistema
|
||||
A file dialog is already open. Já há uma caixa de diálogo de arquivo aberta.
|
||||
Shape Effects Efeitos de forma
|
||||
Glow Brilho
|
||||
Soft Edges Bordas suaves
|
||||
No Shadow Sem sombra
|
||||
No Glow Sem brilho
|
||||
No Soft Edges Sem bordas suaves
|
||||
Glow Colors Cores do brilho
|
||||
Weight Espessura
|
||||
Shadow, glow and soft edges Sombra, brilho e bordas suaves
|
||||
Offset: Bottom Right Deslocamento: inferior direito
|
||||
Offset: Bottom Deslocamento: inferior
|
||||
Offset: Bottom Left Deslocamento: inferior esquerdo
|
||||
Offset: Right Deslocamento: direita
|
||||
Offset: Center Deslocamento: centro
|
||||
Offset: Left Deslocamento: esquerda
|
||||
Offset: Top Right Deslocamento: superior direito
|
||||
Offset: Top Deslocamento: superior
|
||||
Offset: Top Left Deslocamento: superior esquerdo
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
|
|
@ -943,3 +943,21 @@
|
|||
Dark Темна
|
||||
Use system setting Як у системі
|
||||
A file dialog is already open. Діалогове вікно файлу вже відкрито.
|
||||
Shape Effects Ефекти фігури
|
||||
Glow Світіння
|
||||
Soft Edges М'які краї
|
||||
No Shadow Без тіні
|
||||
No Glow Без світіння
|
||||
No Soft Edges Без м'яких країв
|
||||
Glow Colors Кольори світіння
|
||||
Weight Товщина
|
||||
Shadow, glow and soft edges Тінь, світіння та м'які краї
|
||||
Offset: Bottom Right Зміщення: вниз праворуч
|
||||
Offset: Bottom Зміщення: вниз
|
||||
Offset: Bottom Left Зміщення: вниз ліворуч
|
||||
Offset: Right Зміщення: праворуч
|
||||
Offset: Center Зміщення: по центру
|
||||
Offset: Left Зміщення: ліворуч
|
||||
Offset: Top Right Зміщення: вгору праворуч
|
||||
Offset: Top Зміщення: вгору
|
||||
Offset: Top Left Зміщення: вгору ліворуч
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 31.
|
|
|
@ -942,3 +942,21 @@
|
|||
Dark 深色
|
||||
Use system setting 使用系统设置
|
||||
A file dialog is already open. 已有一个文件对话框处于打开状态。
|
||||
Shape Effects 形状效果
|
||||
Glow 发光
|
||||
Soft Edges 柔化边缘
|
||||
No Shadow 无阴影
|
||||
No Glow 无发光
|
||||
No Soft Edges 无柔化边缘
|
||||
Glow Colors 发光颜色
|
||||
Weight 粗细
|
||||
Shadow, glow and soft edges 阴影、发光和柔化边缘
|
||||
Offset: Bottom Right 偏移:右下
|
||||
Offset: Bottom 偏移:下
|
||||
Offset: Bottom Left 偏移:左下
|
||||
Offset: Right 偏移:右
|
||||
Offset: Center 偏移:居中
|
||||
Offset: Left 偏移:左
|
||||
Offset: Top Right 偏移:右上
|
||||
Offset: Top 偏移:上
|
||||
Offset: Top Left 偏移:左上
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
|
|
@ -932,3 +932,21 @@
|
|||
Dark 深色
|
||||
Use system setting 使用系統設定
|
||||
A file dialog is already open. 已有一個檔案對話方塊開啟中。
|
||||
Shape Effects 圖案效果
|
||||
Glow 光暈
|
||||
Soft Edges 柔化邊緣
|
||||
No Shadow 無陰影
|
||||
No Glow 無光暈
|
||||
No Soft Edges 無柔化邊緣
|
||||
Glow Colors 光暈色彩
|
||||
Weight 粗細
|
||||
Shadow, glow and soft edges 陰影、光暈和柔化邊緣
|
||||
Offset: Bottom Right 位移:右下
|
||||
Offset: Bottom 位移:下
|
||||
Offset: Bottom Left 位移:左下
|
||||
Offset: Right 位移:右
|
||||
Offset: Center 位移:置中
|
||||
Offset: Left 位移:左
|
||||
Offset: Top Right 位移:右上
|
||||
Offset: Top 位移:上
|
||||
Offset: Top Left 位移:左上
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 4.
|
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,11 @@ pub fn has_picture_selected(s: &wordcraft_engine::Session) -> bool {
|
|||
matches!(wordcraft_engine::cmd::objects::selected(s), Some((_, wordcraft_doc::para::InlineObject::Image { .. })))
|
||||
}
|
||||
|
||||
/// Whether the selection is a shape or text box (so Shape Format is shown).
|
||||
pub fn has_shape_selected(s: &wordcraft_engine::Session) -> bool {
|
||||
matches!(wordcraft_engine::cmd::objects::selected(s), Some((_, wordcraft_doc::para::InlineObject::Shape { .. })))
|
||||
}
|
||||
|
||||
/// Contextual tabs for the current selection (pure, tested).
|
||||
pub fn contextual_tabs(s: &wordcraft_engine::Session) -> Vec<&'static str> {
|
||||
let mut tabs = Vec::new();
|
||||
|
|
@ -24,6 +29,9 @@ pub fn contextual_tabs(s: &wordcraft_engine::Session) -> Vec<&'static str> {
|
|||
if has_picture_selected(s) {
|
||||
tabs.push("Picture Format");
|
||||
}
|
||||
if has_shape_selected(s) {
|
||||
tabs.push("Shape Format");
|
||||
}
|
||||
// Editing an equation.
|
||||
if s.math.is_some() {
|
||||
tabs.push("Equation");
|
||||
|
|
@ -77,7 +85,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
|||
}
|
||||
}
|
||||
for tab in tabs {
|
||||
let contextual = tab.starts_with("Table ") || tab == "Picture Format" || tab == "Equation";
|
||||
let contextual = tab.starts_with("Table ") || tab.ends_with(" Format") || tab == "Equation";
|
||||
let shown = tl!(tab);
|
||||
let w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(shown.to_string(), medium(12.5), t.text).size().x) + 18.0;
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click());
|
||||
|
|
@ -168,6 +176,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
|||
"Table Layout" => table_layout(app, ui),
|
||||
"Equation" if app.session.math.is_some() => crate::equation_tab::show(app, ui),
|
||||
"Picture Format" => picture_format(app, ui),
|
||||
"Shape Format" => shape_format(app, ui),
|
||||
_ => home(app, ui),
|
||||
}
|
||||
});
|
||||
|
|
@ -1081,6 +1090,83 @@ fn help(app: &mut WordApp, ui: &mut Ui) {
|
|||
});
|
||||
}
|
||||
|
||||
/// Shape Format: fill, outline and effects of the selected shape, and its arrangement.
|
||||
fn shape_format(app: &mut WordApp, ui: &mut Ui) {
|
||||
let (stroke, glow_size) = match wordcraft_engine::cmd::objects::selected(&app.session) {
|
||||
Some((_, wordcraft_doc::para::InlineObject::Shape { stroke, effects, .. })) => (stroke, effects.glow.map(|g| g.size)),
|
||||
_ => (None, None),
|
||||
};
|
||||
let theme = app.session.doc.settings.theme_colors.clone();
|
||||
group(ui, "Shape Styles", None, app, |ui, app| {
|
||||
stack(ui, |ui| {
|
||||
menu_button(ui, app, "shading", Some("Shape Fill"), "Shape Fill", false, |ui, app| {
|
||||
mi(ui, app, "No Color", "shape.fill", json!({"color": null}));
|
||||
if let Some(hex) = color_grid(ui, &theme) {
|
||||
let _ = app.run("shape.fill", json!({"color": hex}));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
menu_button(ui, app, "pen", Some("Shape Outline"), "Shape Outline", false, |ui, app| {
|
||||
mi(ui, app, "No Color", "shape.outline", json!({"color": null}));
|
||||
if let Some(hex) = color_grid(ui, &theme) {
|
||||
let _ = app.run("shape.outline", json!({"color": hex}));
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
ui.menu_button(tl!("Weight"), |ui| {
|
||||
let color = stroke.unwrap_or(wordcraft_doc::Rgb::BLACK).hex();
|
||||
for w in [0.25, 0.5, 0.75, 1.0, 1.5, 2.25, 3.0, 4.5, 6.0] {
|
||||
mi(ui, app, &format!("{w} pt"), "shape.outline", json!({"color": color, "width": w}));
|
||||
}
|
||||
});
|
||||
});
|
||||
menu_button(ui, app, "shapeEffects", Some("Shape Effects"), "Shadow, glow and soft edges", false, |ui, app| {
|
||||
shape_effects_menu(ui, app, &theme, glow_size)
|
||||
});
|
||||
});
|
||||
});
|
||||
group(ui, "Arrange", None, app, |ui, app| {
|
||||
big(ui, app, "position", "Position", "arrange.position", json!({}), false);
|
||||
big(ui, app, "wrapText", "Wrap\nText", "arrange.wrap", json!({}), false);
|
||||
stack(ui, |ui| {
|
||||
small(ui, app, "rotate", Some("Rotate"), "Rotate", "arrange.rotate", json!({}), false);
|
||||
small(ui, app, "align", Some("Align"), "Align", "arrange.align", json!({}), false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Shape Format › Shape Effects: Shadow, Glow and Soft Edges, each with presets and a "No …" entry.
|
||||
fn shape_effects_menu(ui: &mut Ui, app: &mut WordApp, theme: &[wordcraft_doc::Rgb], glow_size: Option<f32>) {
|
||||
ui.menu_button(tl!("Shadow"), |ui| {
|
||||
mi(ui, app, "No Shadow", "shape.effects", json!({"shadow": null}));
|
||||
ui.separator();
|
||||
for (id, label) in wordcraft_doc::effects::SHADOW_PRESETS {
|
||||
mi(ui, app, label, "shape.effects", json!({"shadow": id}));
|
||||
}
|
||||
});
|
||||
ui.menu_button(tl!("Glow"), |ui| {
|
||||
mi(ui, app, "No Glow", "shape.effects", json!({"glow": null}));
|
||||
ui.separator();
|
||||
for size in [5.0, 8.0, 11.0, 18.0] {
|
||||
mi(ui, app, &format!("{size} pt"), "shape.effects", json!({"glow": size}));
|
||||
}
|
||||
ui.separator();
|
||||
ui.menu_button(tl!("Glow Colors"), |ui| {
|
||||
if let Some(hex) = color_grid(ui, theme) {
|
||||
let _ = app.run("shape.effects", json!({"glow": {"color": hex, "size": glow_size.unwrap_or(8.0)}}));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.menu_button(tl!("Soft Edges"), |ui| {
|
||||
mi(ui, app, "No Soft Edges", "shape.effects", json!({"softEdge": null}));
|
||||
ui.separator();
|
||||
for r in [1.0, 2.5, 5.0, 10.0, 25.0, 50.0] {
|
||||
mi(ui, app, &format!("{r} pt"), "shape.effects", json!({"softEdge": r}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn picture_format(app: &mut WordApp, ui: &mut Ui) {
|
||||
group(ui, "Adjust", None, app, |ui, app| {
|
||||
menu_button(ui, app, "picture", Some("Corrections"), "Brightness, contrast and sharpness", true, |ui, app| {
|
||||
|
|
@ -1375,6 +1461,17 @@ mod tests {
|
|||
assert!(!has_picture_selected(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_format_tab_appears_when_shape_selected() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("insert.shape", &json!({"kind": "rectangle"})).unwrap();
|
||||
assert!(contextual_tabs(&s).contains(&"Shape Format"));
|
||||
assert!(!contextual_tabs(&s).contains(&"Picture Format"));
|
||||
s.run("select.collapse", &json!({"end": true})).unwrap();
|
||||
s.run("text.insert", &json!({"text": "x"})).unwrap();
|
||||
assert!(!contextual_tabs(&s).contains(&"Shape Format"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_tab_falls_back_when_contextual_tab_expires() {
|
||||
let all = ["Home", "Table Design", "Picture Format"];
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
|
|
|||
Loading…
Reference in New Issue