fix(render): radial gradients stretch with the object's box, as in InDesign

InDesign draws a radial gradient in the object's bounding box: on a
non-square frame it's an ellipse reaching the frame's sides, and a
Gradient Swatch tool drag keeps those proportions. LYH Studio episode 6
(3:16) gives the left page's background a radial gradient: magenta in
the middle, cyan out to every edge. DesignCraft drew a circle reaching
only the longer side, so a portrait page stayed purple at its sides.

- doc: `Fill::radial_ellipse(bounds)`: centre and radii (default: the
  box's centre and half its width and height; with a tool vector, its
  start, and its length measured with the box made square).
- render: the radial paint is that circle, stretched by the paint
  transform; PDF: the shading's transform does the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
moppeeer
2026-10-13 15:58:05 +02:00
co-authored by Claude Opus 5.5
parent e862e0a613
commit 7a30e5427e
7 changed files with 122 additions and 15 deletions
+1
View File
@@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-10-13** · Radial gradients stretch with the object's box, as InDesign draws them: by default an ellipse reaching the frame's sides (it was a circle reaching only the longer side), and a Gradient Swatch tool drag keeps the box's proportions; on screen and in PDF. No readiness percentage change.
- **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change.
- **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change.
- **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change.
+16
View File
@@ -32,6 +32,22 @@ impl Default for Fill {
}
impl Fill {
/// A radial gradient's ellipse on an object whose bounds (in its own space) are `b`: centre and
/// horizontal and vertical radii. As in InDesign, a radial gradient stretches with the object's
/// box: by default it's centred and reaches the box's sides (an ellipse in a non-square box);
/// with a Gradient Swatch tool vector it's centred at the vector's start, and the vector's
/// length, measured with the box made square, sets its size.
pub fn radial_ellipse(&self, b: designcraft_geom::Rect) -> (designcraft_geom::Point, f64, f64) {
let k = if b.width() > 1e-9 && b.height() > 1e-9 { b.height() / b.width() } else { 1.0 };
match self.gradient_vector {
Some([x0, y0, x1, y1]) => {
let r = designcraft_geom::Vec2::new(x1 - x0, (y1 - y0) / k).hypot();
(designcraft_geom::Point::new(x0, y0), r, r * k)
}
None => (b.center(), b.width().max(1e-9) / 2.0, b.height().max(1e-9) / 2.0),
}
}
pub fn none() -> Self {
Fill { swatch: designcraft_color::swatch::NONE.into(), tint: 1.0, gradient_angle: None, gradient_vector: None, overprint: false }
}
+37
View File
@@ -1751,3 +1751,40 @@ mod sample_color_tests {
}
}
}
#[cfg(test)]
mod radial_gradient_tests {
use serde_json::json;
/// White at the side and the top of a 100×200 radial (black → white) rectangle at (50, 20) on
/// a 200×240 page, rendered at 1 px/pt.
fn sides(img: &designcraft_render::Rendered) -> (u8, u8, u8) {
(img.pixel(100, 120)[0], img.pixel(52, 120)[0], img.pixel(100, 22)[0])
}
#[test]
fn a_radial_gradient_is_the_objects_ellipse_on_screen_and_in_pdf() {
let mut s = crate::Session::new();
s.execute("file.new", &json!({"width": 200, "height": 240, "facingPages": false})).unwrap();
let id = s.execute("frame.create", &json!({"rect": [50, 20, 150, 220], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
s.execute("object.stroke", &json!({"ids": [id], "swatch": "[None]"})).unwrap();
s.execute(
"object.gradient",
&json!({"ids": [id], "kind": "radial", "stops": [{"location": 0, "color": "#000000"}, {"location": 100, "color": "#ffffff"}]}),
)
.unwrap();
let d = s.doc().unwrap().doc.clone();
let mut r = designcraft_render::Renderer::new();
let (c, side, top) = sides(&r.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap());
assert!(c < 30 && side > 220 && top > 220, "screen: centre {c}, side {side}, top {top}");
// The PDF, placed back and drawn: the same ellipse.
let pdf = designcraft_pdf::export_pdf(&d, &s.cache, &Default::default()).unwrap();
let mut p = crate::Session::new();
p.execute("file.new", &json!({"width": 200, "height": 240, "facingPages": false})).unwrap();
p.execute("file.place", &json!({"base64": crate::cmd::file::base64_encode(&pdf), "name": "radial.pdf", "x": 0, "y": 0, "width": 200}))
.unwrap();
let d = p.doc().unwrap().doc.clone();
let (c, side, top) = sides(&r.render_page(&d, &p.cache, 0, 1.0, false, &Default::default()).unwrap());
assert!(c < 30 && side > 220 && top > 220, "pdf: centre {c}, side {side}, top {top}");
}
}
+6 -7
View File
@@ -1015,11 +1015,10 @@ impl Exporter<'_> {
let v = fill.gradient_vector;
return Some(match kind {
GradientKind::Radial => {
let (c, r) = match v {
Some([x0, y0, x1, y1]) => (designcraft_geom::Point::new(x0, y0), Vec2::new(x1 - x0, y1 - y0).hypot()),
None => (c, bounds.width().max(bounds.height()) / 2.0),
};
let r = r.max(1e-3) as f32;
// A circle stretched into the object's ellipse, as on screen.
let (c, rx, ry) = fill.radial_ellipse(bounds);
let rx = rx.max(1e-3);
let stretch = Affine::translate(c.to_vec2()) * Affine::scale_non_uniform(1.0, ry / rx) * Affine::translate(-c.to_vec2());
let (cx, cy) = (c.x as f32, c.y as f32);
RadialGradient {
fx: cx,
@@ -1027,8 +1026,8 @@ impl Exporter<'_> {
fr: 0.0,
cx,
cy,
cr: r,
transform: Transform::identity(),
cr: rx as f32,
transform: tf(stretch),
spread_method: SpreadMethod::Pad,
stops,
anti_alias: false,
+6 -7
View File
@@ -886,15 +886,14 @@ fn set_fill_paint(ctx: &mut RenderContext, doc: &Document, fill: &designcraft_do
}
let c = bounds.center();
let grad = match (g.kind, fill.gradient_vector) {
(designcraft_color::GradientKind::Radial, Some([x0, y0, x1, y1])) => {
let r = Vec2::new(x1 - x0, y1 - y0).hypot().max(1e-3);
peniko::Gradient::new_radial((x0, y0), r as f32).with_stops(stops.as_slice())
// Radial: a circle stretched into the object's ellipse (the paint transform).
(designcraft_color::GradientKind::Radial, _) => {
let (c, rx, ry) = fill.radial_ellipse(bounds);
let rx = rx.max(1e-3);
ctx.set_paint_transform(Affine::translate(c.to_vec2()) * Affine::scale_non_uniform(1.0, ry / rx) * Affine::translate(-c.to_vec2()));
peniko::Gradient::new_radial(c, rx as f32).with_stops(stops.as_slice())
}
(_, Some([x0, y0, x1, y1])) => peniko::Gradient::new_linear((x0, y0), (x1, y1)).with_stops(stops.as_slice()),
(designcraft_color::GradientKind::Radial, None) => {
let r = bounds.width().max(bounds.height()) / 2.0;
peniko::Gradient::new_radial(c, r as f32).with_stops(stops.as_slice())
}
_ => {
let a = angle.unwrap_or(0.0).to_radians();
let half = (bounds.width() * a.cos().abs() + bounds.height() * a.sin().abs()) / 2.0;
+55
View File
@@ -0,0 +1,55 @@
//! Gradient fills: InDesign stretches a radial gradient with the object's box.
use designcraft_color::swatch::{Swatch, SwatchValue};
use designcraft_color::{Color, Gradient, GradientKind, GradientStop};
use designcraft_compose::Cache;
use designcraft_doc::build::NewDocument;
use designcraft_doc::{Document, Fill, Item, ItemId, Shape, SpreadRef};
use designcraft_geom::{Affine, Rect, Vec2, shapes};
use designcraft_render::{Placed, RenderOptions, Rendered, Renderer};
/// A 100×200 rectangle at (50, 20) filled with a black-to-white radial gradient.
fn portrait(vector: Option<[f64; 4]>) -> Document {
let mut d = Document::new(&NewDocument { width: 200.0, height: 240.0, facing_pages: false, ..Default::default() });
let stop = |offset: f32, color: Color| GradientStop { offset, color, opacity: 1.0, midpoint: 0.5 };
d.swatches.push(Swatch {
name: "Radial".into(),
value: SwatchValue::Gradient {
gradient: Gradient { kind: GradientKind::Radial, stops: vec![stop(0.0, Color::BLACK), stop(1.0, Color::WHITE)] },
},
locked: false,
named: true,
hidden: false,
});
let id = ItemId(d.alloc());
let mut it = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(50.0, 20.0, 150.0, 220.0)));
it.fill = Fill { swatch: "Radial".into(), gradient_vector: vector, ..Fill::none() };
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
d
}
fn render(d: &Document) -> Rendered {
let mut r = Renderer::new();
r.threads = 0;
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
r.render(d, &Cache::new(), &[Placed { spread: SpreadRef::Doc(0), xf: Affine::translate(Vec2::ZERO) }], 200, 240, Affine::IDENTITY, &opts)
}
fn grey(img: &Rendered, x: u32, y: u32) -> u8 {
img.pixel(x, y)[0]
}
#[test]
fn a_radial_gradient_stretches_with_its_object() {
// Centred, it reaches white at the sides and at the top and bottom alike (an ellipse).
let img = render(&portrait(None));
assert!(grey(&img, 100, 120) < 20, "black at the centre: {}", grey(&img, 100, 120));
let (side, top) = (grey(&img, 52, 120), grey(&img, 100, 22));
assert!(side > 225 && top > 225, "white at the side ({side}) and the top ({top})");
// Half-way to the side and half-way to the top: the same grey.
let (hs, ht) = (grey(&img, 75, 120), grey(&img, 100, 70));
assert!(hs.abs_diff(ht) < 12, "half-way: side {hs}, top {ht}");
// A Gradient Swatch tool vector to the right side: the same ellipse.
let dragged = render(&portrait(Some([100.0, 120.0, 150.0, 120.0])));
assert!(grey(&dragged, 100, 70).abs_diff(ht) < 12 && grey(&dragged, 75, 120).abs_diff(hs) < 12);
}
+1 -1
View File
@@ -421,7 +421,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
- View › Display Performance: Fast (grey boxes, no effects, ⌥⇧⌘Z), Typical (72 ppi proxies, ⌥⌘Z), High Quality (⌥⌘H, our default — the renderer is fast enough).
- OpenType menu (Character panel): discretionary ligatures, fractions, ordinal, swash, titling, contextual alternates, slashed zero, the four figure styles and stylistic sets 1–20; `type.openType`; mapped to IDML's OTF attributes.
- Add Anchor Point (=), Delete Anchor Point (-) and Convert Direction Point (⇧C) tools (click toggles smooth/corner, drag pulls out handles), with `path.addAnchor`, `path.deleteAnchor`, `path.convertAnchor`.
- Gradients: the Gradient Swatch tool (G) drags a gradient's start and end across objects (Shift: 45°); the Gradient panel sets type, angle, reverse and edits stops on a ramp (drag, add, drag off to remove, location, colour). Edited gradients are unnamed swatches; `object.gradient`; the vector round-trips through IDML.
- Gradients: the Gradient Swatch tool (G) drags a gradient's start and end across objects (Shift: 45°); the Gradient panel sets type, angle, reverse and edits stops on a ramp (drag, add, drag off to remove, location, colour). Edited gradients are unnamed swatches; `object.gradient`; the vector round-trips through IDML. A radial gradient stretches with the object's box as InDesign's does (an ellipse reaching the sides of a non-square frame, on screen and in PDF).
- Document Setup (⌥⌘P): intent, number of pages, start page # (an even start begins with a left page), facing pages, size presets and orientation, bleed and slug per edge; `layout.documentSetup {}` reports the setup.
- Stroke panel: cap, join, miter limit, alignment, type, Start/End arrowheads (12 kinds, drawn on screen and in PDF; the path is shortened under pointed heads) and gap colour under dashes and dots.
- Preferences (⌘K: General, Type, Units & Increments, Grids, Guides & Pasteboard, Display Performance) backed by `prefs.set` (application) and `document.preferences` (units, increments, grids — undoable, saved with the document; right-click a ruler to pick its units, the horizontal and vertical rulers each have their own); scaling applies to content and scales stroke weights (Include Stroke Weight), and X/Y/W/H measure the stroke's outer edge (Dimensions Include Stroke Weight). Quick Apply (⌘Return) finds paragraph, character and object styles as well as commands.