mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-27 03:47:09 -06:00
* #1500: shape fills keep gradients and transparency (model, DOCX read/write) A shape's, text box's and canvas's fill is now a `Fill` (solid with transparency, or a gradient kept as the file states it) instead of one opaque colour, so gradients and transparency survive opening and saving. - doc: `fill::Fill`/`Gradient` with sanitizing for hostile input; `.json` keeps writing opaque solid fills as `[r,g,b]` and reads both forms; `ShapeExtra::outline_fill` keeps an outline gradient. - docx read: `a:solidFill` with `a:alpha`, `a:gradFill` (stops, `a:lin`, `a:path`/`fillToRect`, `tileRect`, `rotWithShape`, `flip`), theme fill styles through `a:fillRef` 1-999/1001+ with `phClr`, `a:grpFill`, `satMod`, outline gradients, VML `v:fill` gradients and opacity, canvas backgrounds. - docx write: `a:solidFill` + `a:alpha`, `a:gradFill` as read. - engine/docbin/layout: call sites; drawing still uses the main colour (next commit draws gradients). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * #1500: draw shape gradients and transparency (layout, render, PDF) - layout: WordArt's paint becomes a shared `display::Paint` (solid, linear, radial); `paint_for` lays a fill over the shape's frame (linear at its angle, `scaled` honoured; path gradients circular from their focus to the farthest corner). Shapes, freeforms and canvas backgrounds use it. - render: one `set_paint` for shapes, paths and WordArt; radial gradients; soft edges fade the real fill. - pdf: axial and radial shadings with per-stop opacity. - Review fixes: nested groups without a fill pass theirs on to `a:grpFill`, text-box contents don't take a group's fill, `phClr` transparency only on `phClr` colours, VML focus ends consistent and opacity clamped, `rotWithShape`/`scaled` written only when set, Shape Outline clears an outline gradient, shared stop sanitizing. - docs: file-format, layout parity, gaps #1, roadmap progress log. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * #1500: shape.fill sets transparency and gradients on every selected shape - shape.fill (now cmd/shape_fill.rs) takes {"color", "transparency"?} or {"gradient": {stops, type, angle, focus, rotateWithShape}}; a colour alone keeps the transparency; values are sanitized; applies to every selected shape, text box and canvas as one undo step. - Formatting (shape.fill, shape.effects) keeps a Shift+click multi-selection, as in Word. - view.formatShapePane toggles the Format Shape pane. - Session::undo_mark/merge_undo_since: a pane drag that applies as it goes undoes in one step. - Helpers for the pane: params(fill), stop add/remove/move, directions, variations and presets (variation and preset colours provisional until compared with Word). - Catalog: Shape Fill Gradient, Format Shape Pane. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * #1500: Shape Fill › Gradient and the Format Shape pane - Shape Fill › Gradient: No Gradient, Light and Dark Variations of the shape's colour, More Gradients… (opens the pane). - Format Shape pane (right): No fill / Solid fill (colour, transparency) / Gradient fill (presets, type, direction, angle, stops bar with add/remove/drag and arrow keys, per-stop colour, position, transparency, rotate with shape). Drags preview live, one undo step. - Right-click › Format Shape… on shapes, text boxes and canvases. - Gradient swatches have accessible names; new labels translated (own wording, clean room). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * #1500: preset gradients are our own design (clean room) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * #1500: Gradient variations, starting gradient and directions as Word 365 does them Observed in Word 365 (behaviour and values only): - Shape Fill › Gradient: a No Gradient swatch, then 13 Light and 13 Dark Variations in a grid five wide, in Word's order; light stops tinted 66/44.5/23.5% with saturation ×1.6, dark shaded 30/67.5/100% with ×1.15; linear ones scaled; corners with their tile rectangle. - Format Shape › Gradient fill starts from the theme's first accent (four stops, 0/74/83/100%). - Corner directions in the pane get the same tile rectangle; Add/Remove sit by the stops bar. - shape.fill carries "scaled" and "tile"; docx exports color_modified (lumMod, lumOff, tint, shade, satMod) so variations use the reader's own colour maths. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * #1500: review fixes for the shape fill ribbon controls - Gradient variations are built from the colour the shown variation came from, so picking variations in turn, or No Gradient, doesn't drift the colour. - A drag's undo mark is forgotten while the Format Shape pane is closed (a stale mark could merge later undo steps). - Dragging on the stops bar away from a handle no longer moves the selected stop; the arrow keys are taken by the bar (no focus moves or shape nudges). - Direction and Type changes keep a linear gradient's "scaled" and an imported tile rectangle; only a corner direction sets its tile. - Colour modifiers (lumMod, lumOff, tint, shade, satMod) live on Rgb in the doc crate, shared by the DOCX reader and the gradient builders. - Previews build their stop list once per swatch, not per vertex. - Translations for the pane's hint, direction and preset names. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * #1500: adapt to main: caret contrast samples gradient fills; upstream tests use Fill/Paint - Paint::at gives a paint's colour and opacity at a point; the caret's background sampling (#299) uses it for shape fills, now gradients or transparent, and the Format Shape previews use it too. - Upstream tests (#886 shapes survive saving, render) build fills as Fill/Paint. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * #1500: adapt to main after the rebase: VML presets, rounded corners, new catalogs - vml_fill takes the shape's shapetype: a VML preset shape (#1377) takes its fill, opacity and gradient from its shapetype when it states none of its own, as it already took its colour. - Rounded-rectangle corners (#1476) and the gradient paints meet in render, PDF and the caret's background sampling; ShapeExtra keeps both `corner` and `outline_fill`. - Tests that landed on main since build fills as Fill/Paint (VML presets, cover pages, VML freeforms, path arrowheads, PDF and render shapes). - The 40 Shape Fill / Format Shape strings in the hr, ko, sv and vi catalogs, which main gained since. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013kKPnx8r1vcYumjyL3kcuz * #1500: docs: shape gradients and transparency, Shape Fill › Gradient and the Format Shape pane Re-applied on main's docs after the rebase: progress log, file-format, layout and UI parity rows, gaps #1, status lines and revision history; parity checklist regenerated. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013kKPnx8r1vcYumjyL3kcuz * #1500: a theme picture fill falls back on the reference colour; VML freeform opacity note Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013kKPnx8r1vcYumjyL3kcuz * #1500: adapt to main after the second rebase: VML shapetype tests compare Fill; parity checklist regenerated Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013kKPnx8r1vcYumjyL3kcuz * #1500: one VML fraction parser; a preset takes its gradient from its shapetype Review notes: story.rs's watermark/freeform opacity reuses shape_style's vml_fraction (clamped at the callers) instead of a second copy; a test pins a VML preset whose v:fill gradient comes from its v:shapetype, and the 65536ths form of a fraction. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013kKPnx8r1vcYumjyL3kcuz * #1500: adapt to main after the third rebase: the shape duplicate preview test fills with Fill Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013kKPnx8r1vcYumjyL3kcuz * #1500: review — harness test: right-click a shape, Format Shape… opens the pane and keeps the shape selected Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WEhZXSxzS8orX7f9J3qpr * #1500: review — theme keeps every fill style a fillRef can reach (999 per list), still bounded Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WEhZXSxzS8orX7f9J3qpr --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: aiongg <aiongtaigi@gmail.com>
448 lines
20 KiB
Rust
448 lines
20 KiB
Rust
//! Shape Format › Shape Fill (#1500): a solid colour with its transparency, or a gradient, for
|
||
//! every selected shape, text box and drawing canvas.
|
||
|
||
use serde_json::Value;
|
||
use std::sync::Arc;
|
||
use wordcraft_doc::Pos;
|
||
use wordcraft_doc::fill::{Fill, Gradient, GradientFlip, GradientKind, PathShape};
|
||
use wordcraft_doc::para::InlineObject;
|
||
use wordcraft_doc::props::Rgb;
|
||
use wordcraft_doc::wordart::GradStop;
|
||
|
||
use super::objects::{edit_obj, picked_objects, selected_all};
|
||
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
|
||
|
||
/// Most shapes one command fills (a selection over a whole document).
|
||
const MAX_FILL_SHAPES: usize = 10_000;
|
||
/// Most stops read from a command (the fill keeps at most `MAX_STOPS` after sanitizing).
|
||
const MAX_PARAM_STOPS: usize = 64;
|
||
|
||
pub fn specs() -> Vec<CommandSpec> {
|
||
vec![
|
||
CommandSpec::new("shape.fill", "Shape Fill", "Shape Format › Shape Styles", shape_fill)
|
||
.params(
|
||
r#"{"color": "RRGGBB" | null, "transparency"?: %} | {"gradient": {"stops": [{"pos": %, "color": "RRGGBB", "transparency"?: %}], "type"?: "linear|radial|rectangular|path", "angle"?: deg, "focus"?: [l, t, r, b], "tile"?: [l, t, r, b], "scaled"?: bool, "rotateWithShape"?: bool}} (tile: percent insets of the gradient's area; scaled: a linear angle scaled with the shape; a colour alone keeps the shape's transparency; angle: linear only, 0 = left to right, 90 = top to bottom; focus: percent insets of where a radial, rectangular or path gradient starts; applies to every selected shape, text box and canvas background)"#,
|
||
)
|
||
.when(super::objects::has_shape_or_canvas),
|
||
]
|
||
}
|
||
|
||
/// What a `shape.fill` command sets.
|
||
enum Change {
|
||
None,
|
||
/// A colour, with its transparency or (`None`) keeping each shape's.
|
||
Solid(Rgb, Option<f32>),
|
||
Fill(Fill),
|
||
}
|
||
|
||
fn shape_fill(s: &mut Session, v: &Value) -> CmdResult {
|
||
let change = parse(v)?;
|
||
let targets = targets(s);
|
||
if targets.is_empty() {
|
||
return Err(CmdError::Disabled("no shape selected".into()));
|
||
}
|
||
let mut last = Value::Null;
|
||
for pos in &targets {
|
||
let o = edit_obj(s, pos, |o| {
|
||
let slot = match o {
|
||
InlineObject::Shape { fill, .. } => fill,
|
||
o => match super::canvas::style_mut(o) {
|
||
Some(style) => &mut style.fill,
|
||
None => return,
|
||
},
|
||
};
|
||
*slot = match &change {
|
||
Change::None => None,
|
||
Change::Fill(f) => Some(f.clone()),
|
||
Change::Solid(color, t) => {
|
||
let kept = match slot {
|
||
Some(Fill::Solid { transparency, .. }) => *transparency,
|
||
_ => 0.0,
|
||
};
|
||
Fill::Solid { color: *color, transparency: t.unwrap_or(kept) }.sanitized()
|
||
}
|
||
};
|
||
})?;
|
||
last = serde_json::to_value(o).unwrap_or(Value::Null);
|
||
}
|
||
Ok(serde_json::json!({"object": last, "count": targets.len()}))
|
||
}
|
||
|
||
/// The shapes and canvases in the selection and those added to it (Shift+click).
|
||
fn targets(s: &Session) -> Vec<Pos> {
|
||
let fillable = |o: &InlineObject| matches!(o, InlineObject::Shape { .. }) || o.is_canvas();
|
||
let mut out: Vec<Pos> = selected_all(s, MAX_FILL_SHAPES).into_iter().filter(|(_, o)| fillable(o)).map(|(pos, _)| pos).collect();
|
||
for pos in picked_objects(s) {
|
||
if !out.contains(&pos) && s.doc.para_at(&pos).and_then(|q| q.object_at(pos.off)).is_some_and(fillable) {
|
||
out.push(pos);
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
fn color(v: &Value) -> Result<Rgb, CmdError> {
|
||
p::str(v, "color").and_then(Rgb::parse).ok_or_else(|| CmdError::Params("`color`: \"RRGGBB\"".into()))
|
||
}
|
||
|
||
fn parse(v: &Value) -> Result<Change, CmdError> {
|
||
let Some(g) = v.get("gradient") else {
|
||
return match v.get("color") {
|
||
None | Some(Value::Null) => Ok(Change::None),
|
||
Some(_) => Ok(Change::Solid(color(v)?, p::f32(v, "transparency"))),
|
||
};
|
||
};
|
||
let bad = |m: &str| CmdError::Params(format!("`gradient`: {m}"));
|
||
if !g.is_object() {
|
||
return Err(bad("an object"));
|
||
}
|
||
let stops = g
|
||
.get("stops")
|
||
.and_then(Value::as_array)
|
||
.ok_or_else(|| bad("`stops` is a list"))?
|
||
.iter()
|
||
.take(MAX_PARAM_STOPS)
|
||
.map(|st| Ok(GradStop { pos: p::f32(st, "pos").unwrap_or(0.0), color: color(st)?, transparency: p::f32(st, "transparency").unwrap_or(0.0) }))
|
||
.collect::<Result<Vec<_>, CmdError>>()?;
|
||
if stops.len() < 2 {
|
||
return Err(bad("two or more stops"));
|
||
}
|
||
let rect = |key: &str, default: f32| -> Option<[f32; 4]> {
|
||
let a = g.get(key)?.as_array()?;
|
||
let mut r = [default; 4];
|
||
for (slot, x) in r.iter_mut().zip(a) {
|
||
*slot = x.as_f64().map_or(default, |x| x as f32);
|
||
}
|
||
Some(r)
|
||
};
|
||
let path = |shape| GradientKind::Path { shape, focus: rect("focus", 50.0).unwrap_or([50.0; 4]) };
|
||
let kind = match p::str(g, "type").unwrap_or("linear") {
|
||
"linear" => {
|
||
GradientKind::Linear { angle: p::f32(g, "angle").unwrap_or(90.0), scaled: g.get("scaled").and_then(Value::as_bool).unwrap_or(false) }
|
||
}
|
||
"radial" => path(PathShape::Circle),
|
||
"rectangular" => path(PathShape::Rect),
|
||
"path" => path(PathShape::Shape),
|
||
t => return Err(bad(&format!("unknown type `{t}`"))),
|
||
};
|
||
let rot_with_shape = g.get("rotateWithShape").and_then(Value::as_bool).unwrap_or(true);
|
||
let fill = Fill::Gradient(Arc::new(Gradient { stops, kind, tile: rect("tile", 0.0), rot_with_shape, flip: GradientFlip::None }));
|
||
fill.sanitized().map(Change::Fill).ok_or_else(|| bad("no stops"))
|
||
}
|
||
|
||
/// The `shape.fill` params that set `fill` (`None`: no fill).
|
||
pub fn params(fill: Option<&Fill>) -> Value {
|
||
use serde_json::json;
|
||
match fill {
|
||
None => json!({"color": null}),
|
||
Some(Fill::Solid { color, transparency }) => json!({"color": color.hex(), "transparency": transparency}),
|
||
Some(Fill::Gradient(g)) => {
|
||
let stops: Vec<Value> = g.stops.iter().map(|s| json!({"pos": s.pos, "color": s.color.hex(), "transparency": s.transparency})).collect();
|
||
let mut out = json!({"stops": stops, "rotateWithShape": g.rot_with_shape});
|
||
if let Some(t) = g.tile {
|
||
out["tile"] = json!(t);
|
||
}
|
||
match g.kind {
|
||
GradientKind::Linear { angle, scaled } => {
|
||
out["type"] = json!("linear");
|
||
out["angle"] = json!(angle);
|
||
out["scaled"] = json!(scaled);
|
||
}
|
||
GradientKind::Path { shape, focus } => {
|
||
out["type"] = json!(match shape {
|
||
PathShape::Circle => "radial",
|
||
PathShape::Rect => "rectangular",
|
||
PathShape::Shape => "path",
|
||
});
|
||
out["focus"] = json!(focus);
|
||
}
|
||
}
|
||
json!({"gradient": out})
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The directions Format Shape offers for a linear gradient, as angles (0 = left to right, 90 =
|
||
/// top to bottom).
|
||
pub const LINEAR_DIRECTIONS: [f32; 8] = [45.0, 90.0, 135.0, 0.0, 180.0, 315.0, 270.0, 225.0];
|
||
|
||
/// The directions Format Shape offers for radial and rectangular gradients, as where they start
|
||
/// (percent insets, left, top, right, bottom): each corner, then the centre.
|
||
pub const PATH_DIRECTIONS: [[f32; 4]; 5] =
|
||
[[100.0, 100.0, 0.0, 0.0], [0.0, 100.0, 100.0, 0.0], [50.0, 50.0, 50.0, 50.0], [100.0, 0.0, 0.0, 100.0], [0.0, 0.0, 100.0, 100.0]];
|
||
|
||
fn grad(stops: &[(f32, Rgb)], kind: GradientKind) -> Fill {
|
||
let stops = stops.iter().map(|&(pos, color)| GradStop { pos, color, transparency: 0.0 }).collect();
|
||
Fill::Gradient(Arc::new(Gradient { stops, kind, tile: None, rot_with_shape: true, flip: GradientFlip::None }))
|
||
}
|
||
|
||
/// The gradient Format Shape's Gradient fill starts from, from the theme's first accent
|
||
/// whatever the shape's colour (as Word 365 does): four stops, 0% at 95% lighter, 74% and 83%
|
||
/// at 55% lighter, 100% at 70% lighter, top to bottom, scaled with the shape.
|
||
pub fn default_gradient(accent1: Rgb) -> Fill {
|
||
let lighter = |off: f64| {
|
||
let c = accent1.modified("lumMod", 1.0 - off);
|
||
c.modified("lumOff", off)
|
||
};
|
||
let stops = [(0.0, 0.95), (74.0, 0.55), (83.0, 0.55), (100.0, 0.7)]
|
||
.into_iter()
|
||
.map(|(pos, off)| GradStop { pos, color: lighter(off), transparency: 0.0 })
|
||
.collect();
|
||
let g = Gradient { stops, kind: GradientKind::Linear { angle: 90.0, scaled: true }, tile: None, rot_with_shape: true, flip: GradientFlip::None };
|
||
Fill::Gradient(Arc::new(g))
|
||
}
|
||
|
||
/// Where each corner tile's gradient starts (percent insets) and the tile rectangle Word gives
|
||
/// it: from the bottom-right, bottom-left, top-right and top-left corners. Observed for the
|
||
/// bottom-right corner (`fillToRect l=t=100%`, `tileRect r=b=-100%`); the others mirror it.
|
||
const CORNERS: [([f32; 4], [f32; 4]); 4] = [
|
||
([100.0, 100.0, 0.0, 0.0], [0.0, 0.0, -100.0, -100.0]),
|
||
([0.0, 100.0, 100.0, 0.0], [-100.0, 0.0, 0.0, -100.0]),
|
||
([100.0, 0.0, 0.0, 100.0], [0.0, -100.0, -100.0, 0.0]),
|
||
([0.0, 0.0, 100.0, 100.0], [-100.0, -100.0, 0.0, 0.0]),
|
||
];
|
||
|
||
/// The tile rectangle Word gives a radial or rectangular gradient starting at `focus`: a corner's,
|
||
/// or none from the centre.
|
||
pub fn corner_tile(focus: [f32; 4]) -> Option<[f32; 4]> {
|
||
CORNERS.iter().find(|(f, _)| *f == focus).map(|(_, t)| *t)
|
||
}
|
||
|
||
/// Shape Fill › Gradient's Light and Dark Variations of `c`, each with its name, in Word's
|
||
/// order (a grid five wide): three stops at 0, 50 and 100%, light ones `c` tinted to 66, 44.5
|
||
/// and 23.5% with saturation ×1.6, dark ones shaded to 30, 67.5 and 100% with saturation
|
||
/// ×1.15 (as Word writes them); linear ones scaled with the shape, the rest circular.
|
||
pub fn variations(c: Rgb) -> [Vec<(Fill, String)>; 2] {
|
||
let tiles = |m: &str, steps: [f64; 3], sat: f64| -> Vec<(Fill, String)> {
|
||
let stop = |i: usize| {
|
||
let color = c.modified(m, steps[i]).modified("satMod", sat);
|
||
GradStop { pos: i as f32 * 50.0, color, transparency: 0.0 }
|
||
};
|
||
let fill = |kind, tile| {
|
||
let g = Gradient { stops: (0..3).map(stop).collect(), kind, tile, rot_with_shape: true, flip: GradientFlip::None };
|
||
Fill::Gradient(Arc::new(g))
|
||
};
|
||
let lin = |angle: f32| (fill(GradientKind::Linear { angle, scaled: true }, None), linear_direction_name(angle));
|
||
let corner = |i: usize| {
|
||
let (focus, tile) = CORNERS[i];
|
||
(fill(GradientKind::Path { shape: PathShape::Circle, focus }, Some(tile)), path_direction_name(focus))
|
||
};
|
||
let centre = (fill(GradientKind::Path { shape: PathShape::Circle, focus: [50.0; 4] }, None), path_direction_name([50.0; 4]));
|
||
vec![
|
||
lin(45.0),
|
||
lin(90.0),
|
||
lin(135.0),
|
||
corner(0),
|
||
corner(1),
|
||
lin(0.0),
|
||
centre,
|
||
lin(180.0),
|
||
corner(2),
|
||
corner(3),
|
||
lin(315.0),
|
||
lin(270.0),
|
||
lin(225.0),
|
||
]
|
||
};
|
||
[tiles("tint", [0.66, 0.445, 0.235], 1.6), tiles("shade", [0.3, 0.675, 1.0], 1.15)]
|
||
}
|
||
|
||
/// The 30 preset gradients from the theme's six accents: per accent, soft, lit from the top,
|
||
/// deep, lit from the bottom and centre glow. Our own design (the repo's clean-room rule: no
|
||
/// copying Word's presets), on the same accents-by-styles grid as Word's.
|
||
pub fn presets(theme: &[Rgb]) -> Vec<Fill> {
|
||
let accent = |i: usize| theme.get(4 + i).copied().unwrap_or(Rgb(68, 114, 196));
|
||
let down = GradientKind::Linear { angle: 90.0, scaled: false };
|
||
let mut out = Vec::with_capacity(30);
|
||
for style in 0..5 {
|
||
for i in 0..6 {
|
||
let c = accent(i);
|
||
out.push(match style {
|
||
0 => grad(&[(0.0, c.modified("tint", 0.2)), (100.0, c.modified("tint", 0.6))], down),
|
||
1 => grad(&[(0.0, c.modified("tint", 0.4)), (50.0, c), (100.0, c.modified("shade", 0.75))], down),
|
||
2 => grad(&[(0.0, c), (100.0, c.modified("shade", 0.5))], down),
|
||
3 => grad(&[(0.0, c.modified("shade", 0.75)), (50.0, c), (100.0, c.modified("tint", 0.4))], down),
|
||
_ => grad(
|
||
&[(0.0, c.modified("tint", 0.4)), (100.0, c.modified("shade", 0.75))],
|
||
GradientKind::Path { shape: PathShape::Circle, focus: [50.0; 4] },
|
||
),
|
||
});
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// The preset styles' names, in [`presets`]'s order (each for accents 1 to 6).
|
||
pub const PRESET_STYLES: [&str; 5] = ["Soft", "Lit from Top", "Deep", "Lit from Bottom", "Center Glow"];
|
||
|
||
/// The name of the linear direction at `angle`.
|
||
pub fn linear_direction_name(angle: f32) -> String {
|
||
match angle.round() as i32 {
|
||
0 => "Linear Right",
|
||
45 => "Linear Diagonal - Top Left to Bottom Right",
|
||
90 => "Linear Down",
|
||
135 => "Linear Diagonal - Top Right to Bottom Left",
|
||
180 => "Linear Left",
|
||
225 => "Linear Diagonal - Bottom Right to Top Left",
|
||
270 => "Linear Up",
|
||
315 => "Linear Diagonal - Bottom Left to Top Right",
|
||
_ => "Linear",
|
||
}
|
||
.into()
|
||
}
|
||
|
||
/// The name of the radial or rectangular direction starting at `focus`.
|
||
pub fn path_direction_name(focus: [f32; 4]) -> String {
|
||
match focus.map(|v| v.round() as i32) {
|
||
[0, 0, 100, 100] => "From Top Left Corner",
|
||
[100, 0, 0, 100] => "From Top Right Corner",
|
||
[0, 100, 100, 0] => "From Bottom Left Corner",
|
||
[100, 100, 0, 0] => "From Bottom Right Corner",
|
||
_ => "From Center",
|
||
}
|
||
.into()
|
||
}
|
||
|
||
/// Stop editing for the Format Shape pane's gradient stops bar. Stops stay in position order; a
|
||
/// gradient keeps 2 to `MAX_STOPS` of them.
|
||
pub mod stops {
|
||
use wordcraft_doc::props::Rgb;
|
||
use wordcraft_doc::wordart::{GradStop, MAX_STOPS};
|
||
|
||
/// The gradient's colour and transparency at `pos` (percent).
|
||
pub fn at(stops: &[GradStop], pos: f32) -> (Rgb, f32) {
|
||
let after = stops.iter().position(|s| s.pos >= pos);
|
||
let (a, b) = match after {
|
||
Some(0) | None => {
|
||
let s = if after.is_none() { stops.last() } else { stops.first() };
|
||
return s.map_or((Rgb::BLACK, 0.0), |s| (s.color, s.transparency));
|
||
}
|
||
Some(i) => (stops[i - 1], stops[i]),
|
||
};
|
||
let t = if b.pos > a.pos { (pos - a.pos) / (b.pos - a.pos) } else { 0.0 };
|
||
let mix = |x: u8, y: u8| (f32::from(x) + (f32::from(y) - f32::from(x)) * t).round().clamp(0.0, 255.0) as u8;
|
||
(Rgb(mix(a.color.0, b.color.0), mix(a.color.1, b.color.1), mix(a.color.2, b.color.2)), a.transparency + (b.transparency - a.transparency) * t)
|
||
}
|
||
|
||
/// Adds a stop at `pos` in the gradient's colour there; its index, or `None` when full.
|
||
pub fn add(stops: &mut Vec<GradStop>, pos: f32) -> Option<usize> {
|
||
if stops.len() >= MAX_STOPS {
|
||
return None;
|
||
}
|
||
let pos = if pos.is_finite() { pos.clamp(0.0, 100.0) } else { 50.0 };
|
||
let (color, transparency) = at(stops, pos);
|
||
let i = stops.iter().position(|s| s.pos > pos).unwrap_or(stops.len());
|
||
stops.insert(i, GradStop { pos, color, transparency });
|
||
Some(i)
|
||
}
|
||
|
||
/// Removes stop `i` unless only two are left; whether it did.
|
||
pub fn remove(stops: &mut Vec<GradStop>, i: usize) -> bool {
|
||
if stops.len() <= 2 || i >= stops.len() {
|
||
return false;
|
||
}
|
||
stops.remove(i);
|
||
true
|
||
}
|
||
|
||
/// Moves stop `i` to `pos`, keeping the stops in order; its new index.
|
||
pub fn move_to(stops: &mut Vec<GradStop>, i: usize, pos: f32) -> usize {
|
||
if i >= stops.len() || !pos.is_finite() {
|
||
return i;
|
||
}
|
||
let mut s = stops.remove(i);
|
||
s.pos = pos.clamp(0.0, 100.0);
|
||
let j = stops.iter().position(|o| o.pos > s.pos).unwrap_or(stops.len());
|
||
stops.insert(j, s);
|
||
j
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::{Change, params, parse, stops};
|
||
use std::sync::Arc;
|
||
use wordcraft_doc::fill::{Fill, Gradient, GradientFlip, GradientKind, PathShape};
|
||
|
||
/// The variations Word writes for accent 1 (`tint`/`shade` with `satMod`, observed in Word 365).
|
||
#[test]
|
||
fn variations_follow_words() {
|
||
use super::variations;
|
||
let c = Rgb(0x15, 0x60, 0x82);
|
||
let [light, dark] = variations(c);
|
||
assert_eq!((light.len(), dark.len()), (13, 13));
|
||
assert_eq!(light[1].1, "Linear Down");
|
||
assert_eq!(light[6].1, "From Center");
|
||
let Fill::Gradient(g) = &light[0].0 else { panic!() };
|
||
assert_eq!(g.kind, GradientKind::Linear { angle: 45.0, scaled: true });
|
||
assert_eq!(g.stops.iter().map(|s| s.pos).collect::<Vec<_>>(), [0.0, 50.0, 100.0]);
|
||
assert!(g.stops[0].color.0 > g.stops[2].color.0 || g.stops[0].color.2 < g.stops[2].color.2, "lighter towards the end: {:?}", g.stops);
|
||
let Fill::Gradient(g) = &light[3].0 else { panic!() };
|
||
assert_eq!(
|
||
(g.kind, g.tile),
|
||
(GradientKind::Path { shape: PathShape::Circle, focus: [100.0, 100.0, 0.0, 0.0] }, Some([0.0, 0.0, -100.0, -100.0]))
|
||
);
|
||
let Fill::Gradient(g) = super::default_gradient(c) else { panic!() };
|
||
assert_eq!(g.stops.iter().map(|s| s.pos).collect::<Vec<_>>(), [0.0, 74.0, 83.0, 100.0], "Word's starting gradient");
|
||
assert_eq!(g.stops[1].color, g.stops[2].color);
|
||
assert!(g.stops.iter().all(|s| s.color.0 > c.0 && s.color.1 > c.1), "lighter than the accent");
|
||
let Fill::Gradient(g) = &dark[1].0 else { panic!() };
|
||
assert_eq!(g.stops[2].color, c.modified("satMod", 1.15), "the last dark stop is the colour itself");
|
||
}
|
||
|
||
#[test]
|
||
fn params_set_the_fill_they_were_made_from() {
|
||
let g = |kind| {
|
||
Fill::Gradient(Arc::new(Gradient {
|
||
stops: vec![st(0.0, 10, 0.0), st(40.0, 90, 25.0), st(100.0, 250, 100.0)],
|
||
kind,
|
||
tile: None,
|
||
rot_with_shape: false,
|
||
flip: GradientFlip::None,
|
||
}))
|
||
};
|
||
let fills = [
|
||
Fill::Solid { color: Rgb(1, 2, 3), transparency: 35.0 },
|
||
g(GradientKind::Linear { angle: 135.0, scaled: false }),
|
||
g(GradientKind::Linear { angle: 45.0, scaled: true }),
|
||
g(GradientKind::Path { shape: PathShape::Circle, focus: [100.0, 0.0, 0.0, 100.0] }),
|
||
g(GradientKind::Path { shape: PathShape::Rect, focus: [50.0; 4] }),
|
||
g(GradientKind::Path { shape: PathShape::Shape, focus: [50.0; 4] }),
|
||
];
|
||
let tiled = super::variations(Rgb(9, 9, 9))[0][3].0.clone();
|
||
for f in fills.into_iter().chain([tiled]) {
|
||
let v = params(Some(&f));
|
||
let back = match parse(&v) {
|
||
Ok(Change::Fill(f)) => f,
|
||
Ok(Change::Solid(c, t)) => Fill::Solid { color: c, transparency: t.unwrap_or(0.0) },
|
||
_ => panic!("{v}"),
|
||
};
|
||
assert_eq!(back, f, "{v}");
|
||
}
|
||
assert!(matches!(parse(¶ms(None)), Ok(Change::None)));
|
||
}
|
||
use wordcraft_doc::props::Rgb;
|
||
use wordcraft_doc::wordart::{GradStop, MAX_STOPS};
|
||
|
||
fn st(pos: f32, c: u8, transparency: f32) -> GradStop {
|
||
GradStop { pos, color: Rgb(c, c, c), transparency }
|
||
}
|
||
|
||
#[test]
|
||
fn stops_are_added_in_the_gradients_colour_moved_in_order_and_kept_two_to_ten() {
|
||
let mut g = vec![st(0.0, 0, 0.0), st(100.0, 200, 50.0)];
|
||
assert_eq!(stops::add(&mut g, 25.0), Some(1));
|
||
assert_eq!((g[1].color, g[1].transparency), (Rgb(50, 50, 50), 12.5));
|
||
assert_eq!(stops::add(&mut g, f32::NAN), Some(2), "a junk position goes in the middle");
|
||
assert_eq!(stops::move_to(&mut g, 1, 300.0), 3, "past the end: last");
|
||
assert_eq!(g.iter().map(|s| s.pos).collect::<Vec<_>>(), [0.0, 50.0, 100.0, 100.0]);
|
||
assert_eq!(stops::move_to(&mut g, 3, -1.0), 1, "after the stop already at 0%");
|
||
assert!(stops::remove(&mut g, 0) && stops::remove(&mut g, 0));
|
||
assert!(!stops::remove(&mut g, 0), "two stops stay");
|
||
assert!(!stops::remove(&mut vec![st(0.0, 0, 0.0); 3], 7));
|
||
while stops::add(&mut g, 10.0).is_some() {}
|
||
assert_eq!(g.len(), MAX_STOPS);
|
||
assert_eq!(stops::at(&g, 200.0).0, g[g.len() - 1].color);
|
||
assert_eq!(stops::at(&[], 5.0), (Rgb::BLACK, 0.0));
|
||
}
|
||
}
|