Files
wordcraft/crates/doc/src/fill.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

238 lines
9.2 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.
//! Shape fills: what paints a shape's, text box's or canvas's interior, and a shape outline's
//! gradient (DrawingML `a:solidFill` / `a:gradFill`, ECMA-376 §20.1.8.54 and §20.1.8.33; VML
//! `v:fill`).
//!
//! A gradient is kept as the file states it (stops, linear angle or path focus, tile rectangle,
//! `rotWithShape`, flip) so saving writes it back; drawing approximates rectangular and
//! shape-following paths as circular. Transparencies are percent (0 = opaque); rectangles are
//! percent insets (left, top, right, bottom) from the shape's edges.
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::sync::Arc;
use crate::props::Rgb;
#[cfg(test)]
use crate::wordart::MAX_STOPS;
use crate::wordart::{GradStop, clamp, sanitized_stops};
/// A shape's fill. In `.json` documents an opaque solid fill is written `[r, g, b]` as before
/// fills had transparency or gradients; the rest are tagged (`{"type": "gradient", ...}`).
#[derive(Clone, Debug, PartialEq)]
pub enum Fill {
Solid { color: Rgb, transparency: f32 },
Gradient(Arc<Gradient>),
}
/// A gradient fill.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Gradient {
pub stops: Vec<GradStop>,
pub kind: GradientKind,
/// `a:tileRect`: where the gradient's area sits relative to the shape (percent insets).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tile: Option<[f32; 4]>,
/// Turns with the shape (`rotWithShape`).
#[serde(default)]
pub rot_with_shape: bool,
#[serde(default, skip_serializing_if = "GradientFlip::is_none")]
pub flip: GradientFlip,
}
/// How a gradient runs.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum GradientKind {
/// Along a line at `angle` degrees (0 = left to right, 90 = top to bottom). `scaled`: the
/// angle is scaled with the shape's aspect ratio.
Linear {
angle: f32,
#[serde(default)]
scaled: bool,
},
/// Outwards from `focus` (percent insets from the shape's edges, `a:fillToRect`).
Path { shape: PathShape, focus: [f32; 4] },
}
/// A path gradient's outline (`a:path/@path`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PathShape {
Circle,
Rect,
Shape,
}
/// How the gradient's tile is mirrored when tiled (`a:gradFill/@flip`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum GradientFlip {
#[default]
None,
X,
Y,
Xy,
}
impl GradientFlip {
pub fn is_none(&self) -> bool {
*self == GradientFlip::None
}
}
/// Largest rectangle inset kept, percent (hostile files).
const MAX_INSET: f32 = 1000.0;
fn rect(r: [f32; 4]) -> [f32; 4] {
r.map(|v| clamp(v, -MAX_INSET, MAX_INSET, 0.0))
}
impl Fill {
/// An opaque solid fill.
pub fn solid(color: Rgb) -> Fill {
Fill::Solid { color, transparency: 0.0 }
}
/// The one colour standing for the fill where only one fits: the solid colour, or a
/// gradient's middle stop.
pub fn main_color(&self) -> Rgb {
match self {
Fill::Solid { color, .. } => *color,
Fill::Gradient(g) => g.stops.get(g.stops.len() / 2).map_or(Rgb::BLACK, |s| s.color),
}
}
/// Finite, in range: at most [`MAX_STOPS`] stops, in order, positions and transparencies
/// 0–100%, angles 0–360°. A gradient with one stop is that stop's solid colour; with none,
/// no fill (`None`).
pub fn sanitized(&self) -> Option<Fill> {
Some(match self {
Fill::Solid { color, transparency } => Fill::Solid { color: *color, transparency: clamp(*transparency, 0.0, 100.0, 0.0) },
Fill::Gradient(g) => {
let stops = sanitized_stops(&g.stops);
match stops[..] {
[] => return None,
[s] => return Some(Fill::Solid { color: s.color, transparency: s.transparency }),
_ => {}
}
let kind = match g.kind {
GradientKind::Linear { angle, scaled } => {
GradientKind::Linear { angle: clamp(angle, -1e6, 1e6, 0.0).rem_euclid(360.0) % 360.0, scaled }
}
GradientKind::Path { shape, focus } => GradientKind::Path { shape, focus: rect(focus) },
};
Fill::Gradient(Arc::new(Gradient { stops, kind, tile: g.tile.map(rect), rot_with_shape: g.rot_with_shape, flip: g.flip }))
}
})
}
}
/// The tagged `.json` form of fills other than opaque solid ones.
#[derive(Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
enum Tagged {
Solid {
color: Rgb,
#[serde(default)]
transparency: f32,
},
Gradient(Gradient),
}
#[derive(Deserialize)]
#[serde(untagged)]
enum AnyForm {
Plain(Rgb),
Tagged(Tagged),
}
impl Serialize for Fill {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
match self {
Fill::Solid { color, transparency: 0.0 } => color.serialize(s),
Fill::Solid { color, transparency } => Tagged::Solid { color: *color, transparency: *transparency }.serialize(s),
Fill::Gradient(g) => Tagged::Gradient(Gradient::clone(g)).serialize(s),
}
}
}
impl<'de> Deserialize<'de> for Fill {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Fill, D::Error> {
Ok(match AnyForm::deserialize(d)? {
AnyForm::Plain(color) => Fill::solid(color),
AnyForm::Tagged(Tagged::Solid { color, transparency }) => Fill::Solid { color, transparency },
AnyForm::Tagged(Tagged::Gradient(g)) => Fill::Gradient(Arc::new(g)),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn stop(pos: f32, color: Rgb, transparency: f32) -> GradStop {
GradStop { pos, color, transparency }
}
fn grad(stops: Vec<GradStop>, kind: GradientKind) -> Fill {
Fill::Gradient(Arc::new(Gradient { stops, kind, tile: None, rot_with_shape: true, flip: GradientFlip::None }))
}
#[test]
fn opaque_solid_fills_are_written_as_before_and_old_documents_load() {
let f = Fill::solid(Rgb(1, 2, 3));
assert_eq!(serde_json::to_string(&f).ok().as_deref(), Some("[1,2,3]"));
assert_eq!(serde_json::from_str::<Fill>("[1,2,3]").ok(), Some(f));
}
#[test]
fn transparent_and_gradient_fills_round_trip_tagged() {
let fills = [
Fill::Solid { color: Rgb::WHITE, transparency: 70.0 },
grad(vec![stop(0.0, Rgb(9, 9, 9), 0.0), stop(100.0, Rgb::WHITE, 50.0)], GradientKind::Linear { angle: 90.0, scaled: true }),
Fill::Gradient(Arc::new(Gradient {
stops: vec![stop(0.0, Rgb::BLACK, 0.0), stop(100.0, Rgb::WHITE, 0.0)],
kind: GradientKind::Path { shape: PathShape::Rect, focus: [50.0, 50.0, 50.0, 50.0] },
tile: Some([-10.0, 0.0, 0.0, 0.0]),
rot_with_shape: false,
flip: GradientFlip::Xy,
})),
];
for f in fills {
let json = serde_json::to_string(&f).unwrap_or_default();
assert!(json.contains("\"type\""), "{json}");
assert_eq!(serde_json::from_str::<Fill>(&json).ok(), Some(f), "{json}");
}
}
#[test]
fn hostile_fills_are_sanitized() {
let many: Vec<GradStop> = (0..30).map(|i| stop(100.0 - i as f32, Rgb::BLACK, f32::NAN)).collect();
let Some(Fill::Gradient(g)) = grad(many, GradientKind::Linear { angle: -450.0, scaled: false }).sanitized() else { panic!() };
assert_eq!(g.stops.len(), MAX_STOPS);
assert!(g.stops.windows(2).all(|w| w[0].pos <= w[1].pos) && g.stops.iter().all(|s| s.transparency == 0.0));
assert_eq!(g.kind, GradientKind::Linear { angle: 270.0, scaled: false });
let path = grad(
vec![stop(150.0, Rgb::BLACK, 200.0), stop(-5.0, Rgb::WHITE, 0.0)],
GradientKind::Path { shape: PathShape::Circle, focus: [f32::NAN, 1e9, -1e9, 5.0] },
);
let Some(Fill::Gradient(g)) = path.sanitized() else { panic!() };
assert_eq!((g.stops[0].pos, g.stops[1].pos, g.stops[1].transparency), (0.0, 100.0, 100.0));
assert_eq!(g.kind, GradientKind::Path { shape: PathShape::Circle, focus: [0.0, MAX_INSET, -MAX_INSET, 5.0] });
assert_eq!(
grad(vec![stop(0.0, Rgb::WHITE, 30.0)], GradientKind::Linear { angle: 0.0, scaled: false }).sanitized(),
Some(Fill::Solid { color: Rgb::WHITE, transparency: 30.0 })
);
assert_eq!(grad(vec![], GradientKind::Linear { angle: 0.0, scaled: false }).sanitized(), None);
assert_eq!(Fill::Solid { color: Rgb::BLACK, transparency: f32::NAN }.sanitized(), Some(Fill::solid(Rgb::BLACK)));
}
#[test]
fn main_colour_is_the_middle_stop() {
let g = grad(
vec![stop(0.0, Rgb(1, 0, 0), 0.0), stop(50.0, Rgb(2, 0, 0), 0.0), stop(100.0, Rgb(3, 0, 0), 0.0)],
GradientKind::Linear { angle: 0.0, scaled: false },
);
assert_eq!(g.main_color(), Rgb(2, 0, 0));
assert_eq!(Fill::solid(Rgb::WHITE).main_color(), Rgb::WHITE);
}
}