From 7a30e5427e754e49e2f8a4f148db7ef96853c56f Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 13 Oct 2026 15:58:05 +0200 Subject: [PATCH] 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 --- ROADMAP.md | 1 + crates/doc/src/item.rs | 16 ++++++++++ crates/engine/src/cmd/style.rs | 37 +++++++++++++++++++++ crates/pdf/src/export.rs | 13 ++++---- crates/render/src/lib.rs | 13 ++++---- crates/render/tests/gradients.rs | 55 ++++++++++++++++++++++++++++++++ docs/target-app-parity.md | 2 +- 7 files changed, 122 insertions(+), 15 deletions(-) create mode 100644 crates/render/tests/gradients.rs diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f8207..6d205978 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 0a701998..2962d374 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -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 } } diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index c4d20f09..39025c2a 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -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}"); + } +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index dcf8abb8..f75c652d 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -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, diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 2cc5c548..6746e70d 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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; diff --git a/crates/render/tests/gradients.rs b/crates/render/tests/gradients.rs new file mode 100644 index 00000000..619d3103 --- /dev/null +++ b/crates/render/tests/gradients.rs @@ -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); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index eb7d7e5c..1a464e7d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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.