Files
wordcraft/crates/doc/src/wordart.rs
T
6b01aa967f Shape fills: gradients and transparency (#1500) (#1605)
* #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>
2026-10-21 19:12:26 -04:00

380 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! WordArt: text effects on runs and text warps on shapes.
//!
//! Text effects are the run properties of [MS-DOCX] §2.6 (`w14:textFill`, `w14:textOutline`,
//! `w14:shadow`, `w14:glow`, `w14:reflection`): how a run's letters are filled and outlined and
//! what is drawn around them. A warp ("Transform", DrawingML `a:prstTxWarp`, ECMA-376
//! §20.1.9.19) bends a shape's whole text along a preset geometry. Lengths are points, angles
//! degrees, transparencies percent (0 = opaque).
use serde::{Deserialize, Serialize};
use crate::effects::{Glow, Shadow};
use crate::props::Rgb;
/// Most gradient stops kept.
pub const MAX_STOPS: usize = 10;
/// Widest text outline, points.
pub const MAX_OUTLINE: f32 = 20.0;
/// Largest reflection gap, points.
pub const MAX_REFLECTION_GAP: f32 = 100.0;
/// Most adjust values a warp keeps.
pub const MAX_ADJ: usize = 8;
/// Largest adjust value magnitude (DrawingML angles are 60000ths of a degree, fractions 100000ths).
pub const MAX_ADJ_VALUE: i64 = 100_000_000;
/// The WordArt effects of a run. `None` fields draw the text normally (its colour, no outline).
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct TextEffects {
#[serde(skip_serializing_if = "Option::is_none")]
pub fill: Option<TextFill>,
#[serde(skip_serializing_if = "Option::is_none")]
pub outline: Option<TextOutline>,
#[serde(skip_serializing_if = "Option::is_none")]
pub shadow: Option<Shadow>,
#[serde(skip_serializing_if = "Option::is_none")]
pub glow: Option<Glow>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reflection: Option<Reflection>,
}
/// How letters are filled.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum TextFill {
/// Not filled (hollow letters: only their outline shows).
None,
Solid {
color: Rgb,
#[serde(default)]
transparency: f32,
},
/// A linear gradient across the run's letters, at `angle` degrees (0 = left to right, 90 = top
/// to bottom).
Gradient {
stops: Vec<GradStop>,
#[serde(default)]
angle: f32,
},
}
/// A gradient stop at `pos` percent along the gradient.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GradStop {
pub pos: f32,
pub color: Rgb,
#[serde(default)]
pub transparency: f32,
}
/// The line around letters. `color: None` is explicitly no line.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct TextOutline {
pub color: Option<Rgb>,
pub width: f32,
pub transparency: f32,
}
impl Default for TextOutline {
fn default() -> Self {
TextOutline { color: Some(Rgb::BLACK), width: 0.75, transparency: 0.0 }
}
}
/// A mirror image of the letters below them that fades out: it starts at `transparency` and
/// fades to nothing over `size` percent of the letters' height, `distance` points below them.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Reflection {
pub transparency: f32,
pub size: f32,
pub distance: f32,
pub blur: f32,
}
impl Default for Reflection {
fn default() -> Self {
Reflection { transparency: 50.0, size: 50.0, distance: 0.0, blur: 0.5 }
}
}
pub(crate) fn clamp(v: f32, lo: f32, hi: f32, dflt: f32) -> f32 {
if v.is_finite() { v.clamp(lo, hi) } else { dflt }
}
/// At most [`MAX_STOPS`] stops, positions and transparencies 0–100%, in order (text and shape
/// gradients).
pub(crate) fn sanitized_stops(stops: &[GradStop]) -> Vec<GradStop> {
let mut s: Vec<GradStop> = stops
.iter()
.take(MAX_STOPS)
.map(|g| GradStop { pos: clamp(g.pos, 0.0, 100.0, 0.0), color: g.color, transparency: clamp(g.transparency, 0.0, 100.0, 0.0) })
.collect();
s.sort_by(|a, b| a.pos.total_cmp(&b.pos));
s
}
impl TextFill {
/// Finite, in range: at most [`MAX_STOPS`] stops, in order; a gradient without stops is no
/// gradient (`None` back).
pub fn sanitized(&self) -> Option<TextFill> {
Some(match self {
TextFill::None => TextFill::None,
TextFill::Solid { color, transparency } => TextFill::Solid { color: *color, transparency: clamp(*transparency, 0.0, 100.0, 0.0) },
TextFill::Gradient { stops, angle } => {
let s = sanitized_stops(stops);
if s.is_empty() {
return None;
}
TextFill::Gradient { stops: s, angle: clamp(*angle, -1e6, 1e6, 0.0).rem_euclid(360.0) }
}
})
}
/// One colour standing for the fill (a gradient's middle stop): what plain text, the caret
/// and exports without gradients use.
pub fn main_color(&self) -> Option<Rgb> {
match self {
TextFill::None => None,
TextFill::Solid { color, .. } => Some(*color),
TextFill::Gradient { stops, .. } => stops.get(stops.len() / 2).map(|s| s.color),
}
}
}
impl TextEffects {
pub fn is_empty(&self) -> bool {
*self == TextEffects::default()
}
/// Finite, in range; zero-size glows dropped.
pub fn sanitized(&self) -> TextEffects {
TextEffects {
fill: self.fill.as_ref().and_then(TextFill::sanitized),
outline: self.outline.map(|o| TextOutline {
color: o.color,
width: clamp(o.width, 0.0, MAX_OUTLINE, 0.75),
transparency: clamp(o.transparency, 0.0, 100.0, 0.0),
}),
shadow: self.shadow.map(Shadow::sanitized),
glow: self.glow.map(Glow::sanitized).filter(|g| g.size > 0.0),
reflection: self.reflection.map(|r| Reflection {
transparency: clamp(r.transparency, 0.0, 100.0, 50.0),
size: clamp(r.size, 0.0, 100.0, 50.0),
distance: clamp(r.distance, 0.0, MAX_REFLECTION_GAP, 0.0),
blur: clamp(r.blur, 0.0, crate::effects::MAX_BLUR, 0.0),
}),
}
}
/// Apply every `Some` field of `patch` on top of `self` (style, then direct formatting).
pub fn overlay(&mut self, patch: &TextEffects) {
if patch.fill.is_some() {
self.fill.clone_from(&patch.fill);
}
if patch.outline.is_some() {
self.outline = patch.outline;
}
if patch.shadow.is_some() {
self.shadow = patch.shadow;
}
if patch.glow.is_some() {
self.glow = patch.glow;
}
if patch.reflection.is_some() {
self.reflection = patch.reflection;
}
}
}
/// Our WordArt styles (Shape Format › WordArt Styles, Insert › WordArt): id and label. Their
/// colours come from the document's theme ([`art_style`]).
pub const ART_STYLES: [(&str, &str); 8] = [
("solid", "Solid Accent"),
("outlined", "Light with Outline"),
("hollow", "Hollow"),
("gradient", "Accent Gradient"),
("shadowed", "Dark with Shadow"),
("glowing", "Soft Glow"),
("reflected", "Accent with Reflection"),
("sunset", "Warm Gradient with Outline"),
];
/// The text effects of WordArt style `id` (see [`ART_STYLES`]) in theme colours `theme` (dk1,
/// lt1, dk2, lt2, accent1…6 as in [`crate::THEME_COLORS`]). `None` for an unknown id.
pub fn art_style(id: &str, theme: &[Rgb]) -> Option<TextEffects> {
let slot = |i: usize| theme.get(i).or_else(|| crate::THEME_COLORS.get(i)).copied().unwrap_or(Rgb::BLACK);
let (dark, light, accent, accent2) = (slot(2), slot(1), slot(4), slot(5));
let solid = |c: Rgb| Some(TextFill::Solid { color: c, transparency: 0.0 });
let line = |c: Rgb, w: f32| Some(TextOutline { color: Some(c), width: w, transparency: 0.0 });
let grad = |a: Rgb, b: Rgb| {
Some(TextFill::Gradient {
stops: vec![GradStop { pos: 0.0, color: a, transparency: 0.0 }, GradStop { pos: 100.0, color: b, transparency: 0.0 }],
angle: 90.0,
})
};
let fx = match id {
"solid" => TextEffects { fill: solid(accent), ..Default::default() },
"outlined" => TextEffects { fill: solid(light), outline: line(accent, 1.0), ..Default::default() },
"hollow" => TextEffects { fill: Some(TextFill::None), outline: line(dark, 1.0), ..Default::default() },
"gradient" => TextEffects { fill: grad(accent, slot(7)), ..Default::default() },
"shadowed" => TextEffects {
fill: solid(dark),
shadow: Some(Shadow { color: Rgb::BLACK, transparency: 55.0, blur: 3.0, distance: 3.0, angle: 45.0, rot_with_shape: false }),
..Default::default()
},
"glowing" => TextEffects {
fill: solid(light),
outline: line(accent, 0.5),
glow: Some(Glow { color: accent, size: 6.0, transparency: 55.0 }),
..Default::default()
},
"reflected" => TextEffects { fill: solid(accent), reflection: Some(Reflection::default()), ..Default::default() },
"sunset" => TextEffects { fill: grad(accent2, slot(8)), outline: line(dark, 0.5), ..Default::default() },
_ => return None,
};
Some(fx)
}
/// Every DrawingML preset text warp (ECMA-376 §20.1.10.76 `ST_TextShapeType`). A warp read with
/// another name is dropped.
pub const WARP_PRESETS: [&str; 41] = [
"textNoShape",
"textPlain",
"textStop",
"textTriangle",
"textTriangleInverted",
"textChevron",
"textChevronInverted",
"textRingInside",
"textRingOutside",
"textArchUp",
"textArchDown",
"textCircle",
"textButton",
"textArchUpPour",
"textArchDownPour",
"textCirclePour",
"textButtonPour",
"textCurveUp",
"textCurveDown",
"textCanUp",
"textCanDown",
"textWave1",
"textWave2",
"textDoubleWave1",
"textWave4",
"textInflate",
"textDeflate",
"textInflateBottom",
"textDeflateBottom",
"textInflateTop",
"textDeflateTop",
"textDeflateInflate",
"textDeflateInflateDeflate",
"textFadeRight",
"textFadeLeft",
"textFadeUp",
"textFadeDown",
"textSlantUp",
"textSlantDown",
"textCascadeUp",
"textCascadeDown",
];
/// The warps WordCraft draws bent (the others round-trip and draw flat), with menu labels.
pub const DRAWN_WARPS: [(&str, &str); 12] = [
("textArchUp", "Arch Up"),
("textArchDown", "Arch Down"),
("textCircle", "Circle"),
("textWave1", "Wave"),
("textSlantUp", "Slant Up"),
("textSlantDown", "Slant Down"),
("textInflate", "Inflate"),
("textDeflate", "Deflate"),
("textChevron", "Chevron Up"),
("textChevronInverted", "Chevron Down"),
("textTriangle", "Triangle Up"),
("textTriangleInverted", "Triangle Down"),
];
/// A shape's text warp: a preset and its adjust values (`a:avLst/a:gd`, name and `val`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextWarp {
pub preset: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub adj: Vec<(String, i64)>,
}
impl TextWarp {
/// A warp with preset `preset` (one of [`WARP_PRESETS`]); `None` for any other name.
pub fn new(preset: &str) -> Option<TextWarp> {
WARP_PRESETS.iter().find(|p| **p == preset).map(|p| TextWarp { preset: (*p).to_string(), adj: Vec::new() })
}
/// Add an adjust value (capped: at most [`MAX_ADJ`], short ASCII names, values within
/// ±[`MAX_ADJ_VALUE`]); a repeated name replaces the earlier value.
pub fn set_adj(&mut self, name: &str, val: i64) {
if name.is_empty() || name.len() > 16 || !name.chars().all(|c| c.is_ascii_alphanumeric()) {
return;
}
let val = val.clamp(-MAX_ADJ_VALUE, MAX_ADJ_VALUE);
if let Some(slot) = self.adj.iter_mut().find(|(n, _)| n == name) {
slot.1 = val;
} else if self.adj.len() < MAX_ADJ {
self.adj.push((name.to_string(), val));
}
}
/// The adjust value `name`, if set.
pub fn adj_value(&self, name: &str) -> Option<i64> {
self.adj.iter().find(|(n, _)| n == name).map(|(_, v)| *v)
}
/// Valid: a known preset, capped adjust values (what files and commands are reduced to).
pub fn sanitized(&self) -> Option<TextWarp> {
let mut w = TextWarp::new(&self.preset)?;
for (n, v) in &self.adj {
w.set_adj(n, *v);
}
Some(w)
}
/// Does the warp leave the text as it is (`textNoShape`, `textPlain`)?
pub fn is_plain(&self) -> bool {
matches!(self.preset.as_str(), "textNoShape" | "textPlain")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hostile_values_are_reduced() {
let fx = TextEffects {
fill: Some(TextFill::Gradient {
stops: (0..50).map(|i| GradStop { pos: if i == 0 { f32::NAN } else { 1e9 }, color: Rgb::BLACK, transparency: -5.0 }).collect(),
angle: f32::INFINITY,
}),
outline: Some(TextOutline { color: None, width: 1e9, transparency: f32::NAN }),
..Default::default()
}
.sanitized();
let Some(TextFill::Gradient { stops, angle }) = fx.fill else { panic!("a gradient") };
assert_eq!(stops.len(), MAX_STOPS);
assert!(stops.iter().all(|s| (0.0..=100.0).contains(&s.pos) && s.transparency == 0.0));
assert_eq!(angle, 0.0);
assert_eq!(fx.outline.map(|o| o.width), Some(MAX_OUTLINE));
assert_eq!(TextFill::Gradient { stops: vec![], angle: 0.0 }.sanitized(), None);
assert!(TextWarp::new("textMadeUp").is_none());
let mut w = TextWarp::new("textArchUp").unwrap();
for i in 0..20 {
w.set_adj(&format!("adj{i}"), i64::MAX);
}
w.set_adj("bad name!", 1);
assert_eq!(w.adj.len(), MAX_ADJ);
assert!(w.adj.iter().all(|(_, v)| *v == MAX_ADJ_VALUE));
for (id, _) in ART_STYLES {
assert!(art_style(id, &[]).is_some(), "{id}");
}
}
}