mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-10 20:51:50 +00: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>
238 lines
9.2 KiB
Rust
238 lines
9.2 KiB
Rust
//! 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);
|
||
}
|
||
}
|