Gradients: read, write and draw a:tileRect

Gradient tile rectangles were dropped on import. PowerPoint's "from
corner" radial presets depend on them: fillToRect puts the focus on a
corner and tileRect stretches the gradient's box a whole size past it, so
the circle is centred on that corner and reaches the far one. Without it
(after the two-point radial change) such fills washed out. A linear
gradient now spans its tile rectangle; a radial one ends on the circle
around it, with the focus still placed in the shape's own box (fitted to
PowerPoint's export of the corpus deck tdf114848: mean diff over the
filled box 0.77). Round-trips through pptx.

Wild synthetic set: tile-rect gradients 16.74 -> 2.48.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-13 05:33:27 -04:00
parent 9a7e7e133e
commit 0c5ddb5dea
12 changed files with 137 additions and 13 deletions

View File

@ -261,7 +261,7 @@ pub(crate) fn gradient_param(v: &Value) -> Option<Gradient> {
scaled: false,
},
};
Some(Gradient { stops, shape, rotate_with_shape: v.get("rotateWithShape").and_then(Value::as_bool).unwrap_or(true) })
Some(Gradient { tile: [0.0; 4], stops, shape, rotate_with_shape: v.get("rotateWithShape").and_then(Value::as_bool).unwrap_or(true) })
}
fn fill(s: &mut Session, p: &Value) -> Result<Value> {

View File

@ -92,6 +92,15 @@ pub struct Gradient {
pub shape: GradientShape,
#[serde(default = "crate::yes")]
pub rotate_with_shape: bool,
/// `a:tileRect`: the box the gradient is laid out in, as insets from the shape's box
/// (fractions; negative reaches outside it). PowerPoint's "from corner" radial presets use it
/// to centre the gradient on the corner.
#[serde(default, skip_serializing_if = "is_zero4")]
pub tile: [f64; 4],
}
fn is_zero4(v: &[f64; 4]) -> bool {
v.iter().all(|x| *x == 0.0)
}
/// How a picture fills its box: stretched into a (fractional) inset rect, or tiled.

View File

@ -69,7 +69,8 @@ fn a_gradient_without_stops_takes_them_from_its_style() {
let p = Presentation::default();
let s = &p.slides[0];
let ctx = Ctx::for_slide(&p, s).unwrap();
let own = style::Gradient { stops: vec![], shape: style::GradientShape::Linear { angle: 45.0, scaled: true }, rotate_with_shape: true };
let own =
style::Gradient { tile: [0.0; 4], stops: vec![], shape: style::GradientShape::Linear { angle: 45.0, scaled: true }, rotate_with_shape: true };
let mut st = ShapeStyle::accent(SchemeSlot::Accent2);
st.fill_ref.0 = 3;
let sh = Shape { style: Some(st.clone()), fill: Some(Fill::Gradient(own.clone())), ..Default::default() };

View File

@ -108,6 +108,7 @@ fn ph() -> ColorRef {
pub fn default_format() -> FormatScheme {
let grad = |a: i32, b: i32| {
Fill::Gradient(Gradient {
tile: [0.0; 4],
stops: vec![
GradientStop { pos: 0.0, color: ph().with(ColorTransform::LumMod(a)).with(ColorTransform::SatMod(103000)) },
GradientStop { pos: 1.0, color: ph().with(ColorTransform::LumMod(b)) },

View File

@ -116,7 +116,8 @@ pub fn gradient(f: &El) -> Gradient {
} else {
GradientShape::Linear { angle: 90.0, scaled: false }
};
Gradient { stops, shape, rotate_with_shape: f.bool("rotWithShape").unwrap_or(true) }
let tile = f.child("tileRect").map(|r| [pct(r, "l"), pct(r, "t"), pct(r, "r"), pct(r, "b")].map(|v| v.unwrap_or(0.0).clamp(-10.0, 10.0)));
Gradient { tile: tile.unwrap_or_default(), stops, shape, rotate_with_shape: f.bool("rotWithShape").unwrap_or(true) }
}
/// `blipFill` (either `a:` or `p:`) → picture fill, registering the image as media.

View File

@ -272,6 +272,10 @@ fn gradient(w: &mut W, g: &deckcraft_model::style::Gradient) {
w.close("a:path");
}
}
if g.tile.iter().any(|v| *v != 0.0) {
let [l, t, r, b] = g.tile;
w.empty("a:tileRect", A::new().a("l", pct(l)).a("t", pct(t)).a("r", pct(r)).a("b", pct(b)));
}
w.close("a:gradFill");
}

View File

@ -88,7 +88,7 @@ fn a_gradient_without_stops_is_saved_without_stops() {
let mut p = defaults::new_presentation(None);
let id = ShapeId(p.alloc_id());
let first = p.slides[0].id;
let own = Gradient { stops: vec![], shape: GradientShape::Linear { angle: 45.0, scaled: true }, rotate_with_shape: true };
let own = Gradient { tile: [0.0; 4], stops: vec![], shape: GradientShape::Linear { angle: 45.0, scaled: true }, rotate_with_shape: true };
p.slide_mut(first).expect("slide").shapes.push(Shape {
id,
name: "Styled".into(),
@ -112,6 +112,7 @@ fn a_fill_overlay_round_trips() {
let overlay = FillOverlay {
blend: "mult".into(),
fill: Fill::Gradient(Gradient {
tile: [0.0; 4],
stops: vec![
GradientStop { pos: 0.0, color: deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1) },
GradientStop { pos: 1.0, color: deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(10, 20, 30)) },
@ -133,3 +134,39 @@ fn a_fill_overlay_round_trips() {
let s = q.slides[0].shapes.iter().find(|s| s.name == "Overlaid").expect("shape");
assert_eq!(s.effects.as_ref().and_then(|e| e.fill_overlay.clone()), Some(overlay));
}
#[test]
fn a_gradient_tile_rect_is_read_and_round_trips() {
use deckcraft_model::style::{Gradient, GradientShape, GradientStop};
// PowerPoint's "from top-left corner" radial: focus on the corner, tile reaching a box up and
// to the left so it centres on that corner.
let g = Gradient {
stops: vec![
GradientStop { pos: 0.0, color: deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 128, 255)) },
GradientStop { pos: 1.0, color: deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::WHITE) },
],
shape: GradientShape::Path { path: "circle".into(), focus: [0.0, 0.0, 1.0, 1.0] },
rotate_with_shape: true,
tile: [-1.0, -1.0, 0.0, 0.0],
};
let mut p = defaults::new_presentation(None);
let id = ShapeId(p.alloc_id());
let first = p.slides[0].id;
p.slide_mut(first).expect("slide").shapes.push(Shape {
id,
name: "Corner".into(),
xfrm: Some(Xfrm::new(10.0, 10.0, 200.0, 100.0)),
fill: Some(Fill::Gradient(g.clone())),
..Default::default()
});
let bytes = deckcraft_pptx::export(&p).expect("export");
let q = deckcraft_pptx::import(&bytes).expect("import");
let s = q.slides[0].shapes.iter().find(|s| s.name == "Corner").expect("shape");
assert_eq!(s.fill, Some(Fill::Gradient(g)));
// As written by PowerPoint (attributes it leaves out are 0).
let edited =
edit_slides(&bytes, |x| x.replace("<a:tileRect l=\"-100000\" t=\"-100000\" r=\"0\" b=\"0\"/>", "<a:tileRect l=\"-100000\" t=\"-100000\"/>"));
let q = deckcraft_pptx::import(&edited).expect("import");
let s = q.slides[0].shapes.iter().find(|s| s.name == "Corner").expect("shape");
assert!(matches!(&s.fill, Some(Fill::Gradient(g)) if g.tile == [-1.0, -1.0, 0.0, 0.0]), "{:?}", s.fill);
}

View File

@ -105,6 +105,7 @@ pub fn rich_deck() -> Presentation {
let mut a = shape(&mut p, "Gradient Star", 100.0, 120.0, 150.0, 150.0);
a.geom = Geom::Preset { name: "star5".into(), adj: vec![20000.0] };
a.fill = Some(Fill::Gradient(Gradient {
tile: [0.0; 4],
stops: vec![
GradientStop { pos: 0.0, color: ColorRef::scheme(SchemeSlot::Accent1) },
GradientStop { pos: 1.0, color: ColorRef::scheme(SchemeSlot::Accent2).with(ColorTransform::LumMod(75000)) },

View File

@ -35,6 +35,10 @@ pub fn fill_path(
if g.stops.is_empty() {
return;
}
// A linear gradient spans its tile rectangle (the shape's box unless `tileRect` moves
// its edges); a radial one ends on the circle around it, from a focus placed in the
// shape's own box (PowerPoint's "from corner" presets centre the tile on the corner).
let tile = tile_box(bounds, g.tile);
let stops: Vec<(f64, Rgba)> = g.stops.iter().map(|s| (s.pos, shade(rctx.color(&s.color, ph), mode))).collect();
ctx.set_paint(gradient(&g.shape, stops, bounds, alpha));
@ -55,12 +59,12 @@ pub fn fill_path(
// Angle measured clockwise from left→right; span the bounds along that direction.
let a = angle.to_radians();
let (dx, dy) = (a.cos(), a.sin());
let c = bounds.center();
let half = (bounds.width() * dx.abs() + bounds.height() * dy.abs()) / 2.0;
let c = tile.center();
let half = (tile.width() * dx.abs() + tile.height() * dy.abs()) / 2.0;
peniko::Gradient::new_linear(Point::new(c.x - dx * half, c.y - dy * half), Point::new(c.x + dx * half, c.y + dy * half))
.with_stops(stops.as_slice())
}
GradientShape::Path { focus, .. } => circle_gradient(*focus, bounds).with_stops(stops.as_slice()),
GradientShape::Path { focus, .. } => circle_gradient(*focus, bounds, tile).with_stops(stops.as_slice()),
};
ctx.set_paint(grad);
ctx.fill_path(path);
@ -88,6 +92,22 @@ pub fn resolved<'a>(rctx: &Ctx, pf: &'a PictureFill, ph: Option<Rgba>) -> std::b
std::borrow::Cow::Owned(out)
}
/// `bounds` with its edges moved in by `tile` (left, top, right, bottom insets as fractions of its
/// size; negative moves them out), as `a:tileRect` and `a:fillRect` lay out a fill.
pub fn tile_box(bounds: Rect, tile: [f64; 4]) -> Rect {
if tile.iter().all(|v| *v == 0.0) || tile.iter().any(|v| !v.is_finite()) {
return bounds;
}
let (w, h) = (bounds.width(), bounds.height());
let [l, t, r, b] = tile;
let x0 = bounds.x0 + l * w;
let x1 = bounds.x1 - r * w;
let y0 = bounds.y0 + t * h;
let y1 = bounds.y1 - b * h;
// An inverted tile (insets past each other) lays out nothing sensible: keep the box.
if x1 - x0 <= 1e-9 || y1 - y0 <= 1e-9 { bounds } else { Rect::new(x0, y0, x1, y1) }
}
/// The paint of a gradient of `shape` with `stops` (position, colour) spanning `bounds`, faded
/// by `alpha`, ramping between stops as PowerPoint does (see `ramp`).
pub fn gradient(shape: &GradientShape, mut stops: Vec<(f64, Rgba)>, bounds: Rect, alpha: f64) -> peniko::Gradient {
@ -107,7 +127,7 @@ pub fn gradient(shape: &GradientShape, mut stops: Vec<(f64, Rgba)>, bounds: Rect
peniko::Gradient::new_linear(Point::new(c.x - dx * half, c.y - dy * half), Point::new(c.x + dx * half, c.y + dy * half))
.with_stops(stops.as_slice())
}
GradientShape::Path { focus, .. } => circle_gradient(*focus, bounds).with_stops(stops.as_slice()),
GradientShape::Path { focus, .. } => circle_gradient(*focus, bounds, bounds).with_stops(stops.as_slice()),
}
}
@ -117,11 +137,14 @@ pub fn gradient(shape: &GradientShape, mut stops: Vec<(f64, Rgba)>, bounds: Rect
/// centre, half its diagonal), with circles in between moving from one to the other. A focus off
/// the centre so bunches the colours on its near side; a gradient "from a corner" reaches its
/// last colour at that corner too, not only at the far one.
pub fn circle_gradient(focus: [f64; 4], bounds: Rect) -> peniko::Gradient {
///
/// With a tile rectangle (`tile`, otherwise `bounds` itself) the end circle is the one around
/// the tile, while the focus stays placed in `bounds`.
pub fn circle_gradient(focus: [f64; 4], bounds: Rect, tile: Rect) -> peniko::Gradient {
let fx = bounds.x0 + bounds.width() * (focus[0] + (1.0 - focus[2])) / 2.0;
let fy = bounds.y0 + bounds.height() * (focus[1] + (1.0 - focus[3])) / 2.0;
let c = bounds.center();
let r = (bounds.width().hypot(bounds.height()) / 2.0).max(1e-6);
let c = tile.center();
let r = (tile.width().hypot(tile.height()) / 2.0).max(1e-6);
let f = Point::new(fx, fy);
if !(f.is_finite() && c.is_finite() && r.is_finite()) {
return peniko::Gradient::new_radial(c, r as f32);

View File

@ -565,6 +565,7 @@ fn a_linear_gradient_ramps_like_powerpoint() {
id: ShapeId(700),
xfrm: Some(Xfrm::new(0.0, 0.0, 800.0, 100.0)),
fill: Some(Fill::Gradient(Gradient {
tile: [0.0; 4],
stops: vec![stop(0.0, deckcraft_color::Rgba::rgb(0, 0, 0)), stop(1.0, deckcraft_color::Rgba::rgb(255, 255, 255))],
shape: GradientShape::Linear { angle: 0.0, scaled: false },
rotate_with_shape: true,
@ -589,6 +590,7 @@ fn a_gradient_with_a_stop_off_the_end_stays_linear() {
id: ShapeId(701),
xfrm: Some(Xfrm::new(0.0, 0.0, 800.0, 100.0)),
fill: Some(Fill::Gradient(Gradient {
tile: [0.0; 4],
stops: vec![stop(0.2, deckcraft_color::Rgba::rgb(0, 0, 0)), stop(1.0, deckcraft_color::Rgba::rgb(255, 255, 255))],
shape: GradientShape::Linear { angle: 0.0, scaled: false },
rotate_with_shape: true,
@ -629,6 +631,7 @@ fn a_shape_filled_with_the_background_shows_the_slide_background() {
let stop = |pos, v| GradientStop { pos, color: ColorRef::rgb(deckcraft_color::Rgba::rgb(v, v, v)) };
std::sync::Arc::make_mut(&mut p.slides[0]).background = Some(deckcraft_model::Background::Fill {
fill: Fill::Gradient(Gradient {
tile: [0.0; 4],
stops: vec![stop(0.0, 0), stop(0.5, 0), stop(1.0, 200)],
shape: GradientShape::Linear { angle: 0.0, scaled: false },
rotate_with_shape: true,
@ -702,6 +705,7 @@ fn a_text_gradient_starts_over_on_every_line() {
let props = &mut p.runs[0].props;
props.size = Some(80.0);
props.fill = Some(Fill::Gradient(Gradient {
tile: [0.0; 4],
stops: vec![stop(0.0, 0), stop(1.0, 255)],
shape: GradientShape::Linear { angle: 90.0, scaled: false },
rotate_with_shape: true,
@ -862,6 +866,7 @@ fn rect_path_gradients_draw_x_shaped_contours() {
id: ShapeId(770),
xfrm: Some(Xfrm::new(0.0, 0.0, 280.0, 200.0)),
fill: Some(Fill::Gradient(Gradient {
tile: [0.0; 4],
stops: vec![stop(0.0, 0), stop(0.5, 128), stop(1.0, 255)],
shape: GradientShape::Path { path: "rect".into(), focus: [0.5, 0.5, 0.5, 0.5] },
rotate_with_shape: true,

View File

@ -168,7 +168,12 @@ fn radial(focus: [f64; 4]) -> Shape {
Shape {
id: ShapeId(13),
xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)),
fill: Some(Fill::Gradient(Gradient { stops, shape: GradientShape::Path { path: "circle".into(), focus }, rotate_with_shape: true })),
fill: Some(Fill::Gradient(Gradient {
tile: [0.0; 4],
stops,
shape: GradientShape::Path { path: "circle".into(), focus },
rotate_with_shape: true,
})),
line: no_line(),
..Default::default()
}
@ -292,7 +297,8 @@ fn a_pictures_border_lies_outside_the_picture() {
fn children_filled_with_the_group_fill_show_one_fill_across_the_group() {
use deckcraft_model::style::{Gradient, GradientShape, GradientStop};
let stops = vec![GradientStop { pos: 0.0, color: ColorRef::rgb(RED) }, GradientStop { pos: 1.0, color: ColorRef::rgb(Rgba::rgb(0, 0, 255)) }];
let grad = Fill::Gradient(Gradient { stops, shape: GradientShape::Linear { angle: 0.0, scaled: false }, rotate_with_shape: true });
let grad =
Fill::Gradient(Gradient { tile: [0.0; 4], stops, shape: GradientShape::Linear { angle: 0.0, scaled: false }, rotate_with_shape: true });
let child = |id: u32, x: f64| Shape {
id: ShapeId(id),
xfrm: Some(Xfrm::new(x, 0.0, 90.0, 100.0)),
@ -331,3 +337,37 @@ fn open_freeforms_take_arrowheads_even_when_filled() {
// (295.7, 187.3), 3.5 pt off the line's axis: outside the 4 pt line and outside the fill.
assert!(close(img.pixel(294, 189), RED, 60), "arrowhead drawn {:?}", img.pixel(294, 189));
}
#[test]
fn a_corner_radial_centres_on_the_corner_through_its_tile() {
// fillToRect on the top-left corner, tileRect reaching a whole box up and left: the circle is
// centred on the corner and reaches the far corner (as PowerPoint's "from corner" presets).
let mut sh = radial([0.0, 0.0, 1.0, 1.0]);
if let Some(Fill::Gradient(g)) = &mut sh.fill {
g.tile = [-1.0, -1.0, 0.0, 0.0];
}
let img = render(vec![sh]);
assert!(img.pixel(102, 102)[0] > 240, "white at the corner {:?}", img.pixel(102, 102));
let (top_right, bottom_left) = (img.pixel(498, 101)[0], img.pixel(101, 298)[0]);
assert!(top_right < bottom_left, "farther is darker {top_right} {bottom_left}");
assert!(bottom_left > 120, "the near edge stays light {bottom_left}");
assert!(img.pixel(497, 297)[0] < 40, "dark at the far corner {:?}", img.pixel(497, 297));
}
#[test]
fn a_linear_gradient_spans_its_tile_rect() {
use deckcraft_model::style::{Gradient, GradientShape, GradientStop};
// Tile rect the left half of the box: the ramp ends half way and stays black after.
let stops = vec![GradientStop { pos: 0.0, color: ColorRef::rgb(WHITE) }, GradientStop { pos: 1.0, color: ColorRef::rgb(Rgba::BLACK) }];
let g = Gradient { stops, shape: GradientShape::Linear { angle: 0.0, scaled: false }, rotate_with_shape: true, tile: [0.0, 0.0, 0.5, 0.0] };
let sh = Shape {
id: ShapeId(20),
xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 100.0)),
fill: Some(Fill::Gradient(g)),
line: no_line(),
..Default::default()
};
let img = render(vec![sh]);
assert!(img.pixel(102, 150)[0] > 240);
assert!(img.pixel(305, 150)[0] < 15 && img.pixel(495, 150)[0] < 15, "{:?}", img.pixel(305, 150));
}

View File

@ -16,6 +16,7 @@ pub fn default_gradient(base: ColorRef, angle: f64) -> Gradient {
let mut dark = base.clone();
dark.mods.push(ColorTransform::LumMod(60_000));
Gradient {
tile: [0.0; 4],
stops: vec![GradientStop { pos: 0.0, color: base }, GradientStop { pos: 1.0, color: dark }],
shape: GradientShape::Linear { angle, scaled: false },
rotate_with_shape: true,
@ -136,6 +137,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) ->
..default_gradient(base.clone(), 45.0)
},
Gradient {
tile: [0.0; 4],
stops: vec![GradientStop { pos: 0.0, color: light }, GradientStop { pos: 1.0, color: dark }],
shape: GradientShape::Path { path: "circle".into(), focus: [0.5, 0.5, 0.5, 0.5] },
rotate_with_shape: true,