diff --git a/ROADMAP.md b/ROADMAP.md index 14a4c9351..e4c0bb889 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -169,6 +169,7 @@ Newest first. - **2026-10-14** · Colour parameters: a bare `{c,m,y,k}` is always read as 0..100, so C 1 is a 1 % tint and no longer solid cyan; fractions take the colour's own `{model: "cmyk", …}` form (#910). No readiness percentage change. - **2026-10-14** · The Tools panel follows InDesign's: Gradient Swatch and Gradient Feather are tools of their own; in two columns Content Collector and Placer, Apply Color / Gradient / None and Normal / Preview each get a button; View Options holds Frame Edges, Rulers, Guides, Smart Guides, Baseline Grid and Hidden Characters; Apply Color (,) and Apply Gradient (.) reuse the last colour and gradient. No readiness percentage change. +- **2026-10-13** · Gradient swatches work on strokes: drawn across the path's box on screen and in PDF (they drew nothing), set with `object.gradient {target: stroke}` and in the Gradient panel when the Stroke box is in front; the stroke's gradient vector moves with the shape and round-trips through IDML. No readiness percentage change. - **2026-10-12** · Text threads by hand: click a selected text frame's in or out port to load the text cursor, then click a frame to thread it, click empty space for a column-wide frame or drag one (it snaps like frame drawing); Shift-click autoflows, Alt-click stays loaded on the diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index cccb4972c..25c4c7270 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -54,6 +54,21 @@ impl Fill { } } + /// A linear gradient's axis on an object whose bounds (in its own space) are `b`: the + /// Gradient Swatch tool's vector, or the box's extent along the gradient's angle through its + /// centre. A box with no extent along that angle (a straight line, across it) takes its longer + /// side. + pub fn linear_axis(&self, b: Rect) -> (Point, Point) { + if let Some([x0, y0, x1, y1]) = self.gradient_vector { + return (Point::new(x0, y0), Point::new(x1, y1)); + } + let a = self.gradient_angle.unwrap_or(0.0).to_radians(); + let half = (b.width() * a.cos().abs() + b.height() * a.sin().abs()) / 2.0; + let half = if half > 1e-9 { half } else { b.width().max(b.height()).max(1e-3) / 2.0 }; + let d = designcraft_geom::Vec2::new(a.cos(), -a.sin()) * half; + (b.center() - d, b.center() + d) + } + pub fn none() -> Self { Fill { swatch: designcraft_color::swatch::NONE.into(), tint: 1.0, gradient_angle: None, gradient_vector: None, overprint: false } } @@ -246,6 +261,12 @@ pub struct Stroke { /// Overprint Gap (Attributes panel). #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub gap_overprint: bool, + /// A gradient swatch's angle on the stroke, as [`Fill::gradient_angle`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub gradient_angle: Option, + /// The Gradient Swatch tool's vector on the stroke, as [`Fill::gradient_vector`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub gradient_vector: Option<[f64; 4]>, } impl Default for Stroke { @@ -266,11 +287,25 @@ impl Default for Stroke { gap_tint: 1.0, overprint: false, gap_overprint: false, + gradient_angle: None, + gradient_vector: None, } } } impl Stroke { + /// The stroke's paint (swatch, tint and gradient geometry) as a fill, to paint it the same way: + /// a gradient swatch on a stroke runs across the object's box as on its fill. + pub fn as_fill(&self) -> Fill { + Fill { + swatch: self.swatch.clone(), + tint: self.tint, + gradient_angle: self.gradient_angle, + gradient_vector: self.gradient_vector, + overprint: self.overprint, + } + } + /// How far the painted stroke can reach beyond the path (arrowheads reach further). pub fn extent(&self) -> f64 { if self.start == Arrowhead::None && self.end == Arrowhead::None { self.weight } else { self.weight.max(0.5) * 5.5 } diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index e6a0edcf8..df16627db 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -1140,12 +1140,15 @@ fn union_bounds(d: &Document, ids: &[ItemId]) -> Rect { ids.iter().filter_map(|i| d.item(*i)).map(Item::bounds).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO) } -/// Transform an item's geometry in its own space: the path and the gradient vector with it. +/// Transform an item's geometry in its own space: the path and the gradient vectors (fill and +/// stroke) with it. pub(crate) fn bake(it: &mut Item, inner: Affine) { it.path.transform(inner); - if let Some([x0, y0, x1, y1]) = it.fill.gradient_vector { - let (a, b) = (inner * Point::new(x0, y0), inner * Point::new(x1, y1)); - it.fill.gradient_vector = Some([a.x, a.y, b.x, b.y]); + for v in [&mut it.fill.gradient_vector, &mut it.stroke.gradient_vector] { + if let Some([x0, y0, x1, y1]) = *v { + let (a, b) = (inner * Point::new(x0, y0), inner * Point::new(x1, y1)); + *v = Some([a.x, a.y, b.x, b.y]); + } } } diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 2609af641..7916ccb9f 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -144,7 +144,7 @@ pub fn specs() -> Vec { "Gradient", [], None, - "{kind?: linear|radial, stops?: [{location: 0–100, color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b]|{model, …}(0..1), opacity?: 0–100, midpoint?: 13–87}], reverse?: bool, angle?: degrees, from?: [x,y], to?: [x,y] (spread coords: the Gradient Swatch tool's drag), ids?} → {swatch, kind, stops} — edits the fill's gradient (an unnamed gradient unless it equals a gradient swatch)", + "{kind?: linear|radial, stops?: [{location: 0–100, color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b]|{model, …}(0..1), opacity?: 0–100, midpoint?: 13–87}], reverse?: bool, angle?: degrees, from?: [x,y], to?: [x,y] (spread coords: the Gradient Swatch tool's drag), target?: fill|stroke, ids?} → {swatch, kind, stops} — edits the fill's (or the stroke's) gradient (an unnamed gradient unless it equals a gradient swatch)", super::has_selection, apply_gradient ), @@ -926,13 +926,18 @@ fn apply_gradient(s: &mut Session, p: &Value) -> Result { }; let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false); let angle = p.get("angle").and_then(Value::as_f64); + let stroke = match str_param(p, "target") { + None | Some("fill") => false, + Some("stroke") => true, + Some(t) => return Err(bad("object.gradient", format!("unknown target {t}"))), + }; let (from, to) = (super::point_param(p, "from"), super::point_param(p, "to")); let ids = super::targets(s, p)?; s.edit(|d, _| { let mut out = Value::Null; for id in &ids { let Some(it) = d.item(*id) else { continue }; - let cur_name = it.fill.swatch.clone(); + let cur_name = if stroke { it.stroke.swatch.clone() } else { it.fill.swatch.clone() }; let cur = designcraft_color::swatch::resolve_gradient(&d.swatches, &cur_name).cloned(); let mut g = cur.clone().unwrap_or_default(); if let Some(k) = kind { @@ -964,16 +969,22 @@ fn apply_gradient(s: &mut Session, p: &Value) -> Result { }; let inv = d.find(*id).map(|l| d.parent_xf(&l)).zip(d.item(*id).map(|i| i.xf)).map(|(pxf, xf)| (pxf * xf).inverse()); let Some(it) = d.item_mut(*id) else { continue }; - it.fill.swatch = name.clone(); - it.fill.tint = 1.0; + // The fill's or the stroke's paint: swatch, tint, angle, vector. + let (swatch, tint, g_angle, g_vector) = if stroke { + (&mut it.stroke.swatch, &mut it.stroke.tint, &mut it.stroke.gradient_angle, &mut it.stroke.gradient_vector) + } else { + (&mut it.fill.swatch, &mut it.fill.tint, &mut it.fill.gradient_angle, &mut it.fill.gradient_vector) + }; + *swatch = name.clone(); + *tint = 1.0; if let Some(a) = angle { - it.fill.gradient_angle = Some(a); - it.fill.gradient_vector = None; + *g_angle = Some(a); + *g_vector = None; } if let (Some(a), Some(b), Some(inv)) = (from, to, inv) { let (a, b) = (inv * a, inv * b); if (b - a).hypot() > 1e-6 { - it.fill.gradient_vector = Some([a.x, a.y, b.x, b.y]); + *g_vector = Some([a.x, a.y, b.x, b.y]); } } if out.is_null() { @@ -1997,6 +2008,97 @@ mod radial_gradient_tests { } } +#[cfg(test)] +mod gradient_stroke_tests { + use serde_json::json; + + /// Grey along a 20 pt line from (20, 60) to (180, 60): near its start, middle and end. + fn along(img: &designcraft_render::Rendered) -> (u8, u8, u8) { + (img.pixel(26, 60)[0], img.pixel(100, 60)[0], img.pixel(174, 60)[0]) + } + + #[test] + fn a_gradient_on_a_stroke_runs_across_the_path_on_screen_and_in_pdf() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 120, "facingPages": false})).unwrap(); + let id = s.execute("line.create", &json!({"a": [20, 60], "b": [180, 60]})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.stroke", &json!({"ids": [id], "weight": 20})).unwrap(); + let r = s + .execute( + "object.gradient", + &json!({"ids": [id], "target": "stroke", "stops": [{"location": 0, "color": "#000000"}, {"location": 100, "color": "#ffffff"}]}), + ) + .unwrap(); + let d = s.doc().unwrap().doc.clone(); + let it = d.item(designcraft_doc::ItemId(id)).unwrap(); + assert_eq!(it.stroke.swatch, r["swatch"].as_str().unwrap()); + assert_eq!(it.fill.swatch, designcraft_color::swatch::NONE, "the fill is left alone"); + let mut rr = designcraft_render::Renderer::new(); + let (a, m, b) = along(&rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap()); + assert!(a < 40 && (90..170).contains(&m) && b > 215, "screen: {a} {m} {b}"); + // The PDF, placed back and drawn: the same. + 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": 120, "facingPages": false})).unwrap(); + p.execute("file.place", &json!({"base64": crate::cmd::file::base64_encode(&pdf), "name": "stroke.pdf", "x": 0, "y": 0, "width": 200})) + .unwrap(); + let pd = p.doc().unwrap().doc.clone(); + let (a, m, b) = along(&rr.render_page(&pd, &p.cache, 0, 1.0, false, &Default::default()).unwrap()); + assert!(a < 40 && (90..170).contains(&m) && b > 215, "pdf: {a} {m} {b}"); + // The Gradient Swatch tool's drag on the stroke: reversed by dragging right to left. + s.execute("object.gradient", &json!({"ids": [id], "target": "stroke", "from": [180, 60], "to": [20, 60]})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let (a, _, b) = along(&rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap()); + assert!(a > 215 && b < 40, "dragged: {a} {b}"); + assert!(s.execute("object.gradient", &json!({"ids": [id], "target": "gap"})).is_err()); + } + + /// A 20 pt horizontal line with a gradient on its stroke, drawn on screen and from its PDF. + fn line_with_stroke_gradient(g: serde_json::Value) -> (designcraft_render::Rendered, designcraft_render::Rendered) { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 120, "facingPages": false})).unwrap(); + let id = s.execute("line.create", &json!({"a": [20, 60], "b": [180, 60]})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.stroke", &json!({"ids": [id], "weight": 20})).unwrap(); + let mut g = g; + g["ids"] = json!([id]); + g["target"] = json!("stroke"); + s.execute("object.gradient", &g).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let mut rr = designcraft_render::Renderer::new(); + let screen = rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap(); + 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": 120, "facingPages": false})).unwrap(); + p.execute("file.place", &json!({"base64": crate::cmd::file::base64_encode(&pdf), "name": "line.pdf", "x": 0, "y": 0, "width": 200})).unwrap(); + let pd = p.doc().unwrap().doc.clone(); + let placed = rr.render_page(&pd, &p.cache, 0, 1.0, false, &Default::default()).unwrap(); + (screen, placed) + } + + #[test] + fn a_radial_gradient_on_a_straight_lines_stroke_shows_all_its_stops() { + // The line's box has no height: the gradient is a circle across the line's length. + let (screen, pdf) = line_with_stroke_gradient( + json!({"kind": "radial", "stops": [{"location": 0, "color": "#000000"}, {"location": 100, "color": "#ffffff"}]}), + ); + for (what, img) in [("screen", &screen), ("pdf", &pdf)] { + let (a, m, b) = along(img); + assert!(m < 40 && a > 180 && b > 180, "{what}: {a} {m} {b}"); + } + } + + #[test] + fn a_linear_gradient_across_a_straight_line_paints_its_stroke_alike_on_screen_and_in_pdf() { + let (screen, pdf) = + line_with_stroke_gradient(json!({"angle": 90, "stops": [{"location": 0, "color": "#ff0000"}, {"location": 100, "color": "#0000ff"}]})); + let (s, p) = (screen.pixel(100, 60), pdf.pixel(100, 60)); + // The line's box has no height: the gradient runs across the line's length, so the stroke + // shows its middle. + assert!(s[1] < 60 && s[0] > 80 && s[2] > 80, "screen: the stroke is painted, {s:?}"); + assert!((0..3).all(|i| s[i].abs_diff(p[i]) < 40), "screen {s:?}, pdf {p:?}"); + } +} + #[cfg(test)] #[path = "text_color_tests.rs"] mod text_color_tests; diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 73bea978b..75f28a33e 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1776,6 +1776,14 @@ impl<'a> Ex<'a> { } else if let Some(a) = it.fill.gradient_angle { el.set("GradientFillAngle", num(a)); } + // The same on the stroke. + if let Some([x0, y0, x1, y1]) = it.stroke.gradient_vector { + el.set("GradientStrokeStart", format!("{} {}", num(x0), num(y0))); + el.set("GradientStrokeLength", num((x1 - x0).hypot(y1 - y0))); + el.set("GradientStrokeAngle", num((-(y1 - y0)).atan2(x1 - x0).to_degrees())); + } else if let Some(a) = it.stroke.gradient_angle { + el.set("GradientStrokeAngle", num(a)); + } self.stroke_attrs(&mut el, &it.stroke); // Corner options: map path-order corners to the named corners by geometry. if !it.corners.is_none() { diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 80669a49c..54126acf2 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -2347,6 +2347,19 @@ impl<'r> Importer<'r> { it.stroke.swatch = swatch::NONE.into(); it.stroke.weight = 1.0; } + // A gradient on the stroke: its angle, or the Gradient Swatch tool's vector. + if let Some(a) = e.num("GradientStrokeAngle") + && a != 0.0 + { + it.stroke.gradient_angle = Some(a); + } + let start: Option> = e.prop("GradientStrokeStart").map(|s| s.split_whitespace().filter_map(|v| v.parse().ok()).collect()); + if let (Some([x, y]), Some(len)) = (start.as_deref().and_then(|v| <[f64; 2]>::try_from(v).ok()), e.num("GradientStrokeLength")) + && len > 0.0 + { + let a = e.num("GradientStrokeAngle").unwrap_or(0.0).to_radians(); + it.stroke.gradient_vector = Some([x, y, x + len * a.cos(), y - len * a.sin()]); + } } // Corners: each corner's shape and radius come from the item, then its object style chain; // at each level a per-corner attribute wins over the legacy all-corners one. diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index d11a3db6b..1c0371982 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -2176,6 +2176,23 @@ fn type_on_a_path_keeps_its_start_and_end_brackets() { } } +#[test] +fn a_strokes_gradient_vector_round_trips() { + let mut d = designcraft_doc::Document::new(&designcraft_doc::build::NewDocument::default()); + let id = designcraft_doc::ItemId(d.alloc()); + let mut it = designcraft_doc::Item::new( + id, + d.default_layer(), + designcraft_doc::Shape::Path, + designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(10.0, 10.0, 110.0, 60.0)), + ); + it.stroke.gradient_vector = Some([10.0, 35.0, 110.0, 35.0]); + d.insert_item(designcraft_doc::SpreadRef::Doc(0), it, None).unwrap(); + let back = crate::import_idml(&crate::export_idml(&d)).unwrap(); + let v = back.spreads[0].items[0].stroke.gradient_vector.unwrap(); + assert!(v.iter().zip([10.0, 35.0, 110.0, 35.0]).all(|(a, b)| (a - b).abs() < 1e-6), "{v:?}"); +} + #[test] fn frame_preference_import_caps_column_count_without_composition() { for (raw, expected) in [("4000000000", 216), ("1e300", 216), ("-100", 1), ("0", 1), ("NaN", 1), ("inf", 1), ("-inf", 1)] { diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index cd802d22f..a6f05dce4 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -989,7 +989,7 @@ impl Exporter<'_> { /// A swatch fill (solid or gradient) in the item's inner space. `None` for [None]/unknown. fn fill_paint(&mut self, fill: &designcraft_doc::Fill, bounds: Rect) -> Option { - let (swatch, tint, angle) = (fill.swatch.as_str(), fill.tint, fill.gradient_angle); + let (swatch, tint) = (fill.swatch.as_str(), fill.tint); if let Some(g) = designcraft_color::swatch::resolve_gradient(&self.doc.swatches, swatch) { let kind = g.kind; let mut expanded = g.expanded_stops(); @@ -1030,8 +1030,6 @@ impl Exporter<'_> { if stops.is_empty() { return None; } - let c = bounds.center(); - let v = fill.gradient_vector; return Some(match kind { GradientKind::Radial => { // A circle stretched into the object's ellipse, as on screen. @@ -1054,13 +1052,7 @@ impl Exporter<'_> { .into() } _ => { - let a = angle.unwrap_or(0.0).to_radians(); - let half = ((bounds.width() * a.cos().abs() + bounds.height() * a.sin().abs()) / 2.0).max(1e-3); - let d = Vec2::new(a.cos(), -a.sin()) * half; - let (p0, p1) = match v { - Some([x0, y0, x1, y1]) => (designcraft_geom::Point::new(x0, y0), designcraft_geom::Point::new(x1, y1)), - None => (c - d, c + d), - }; + let (p0, p1) = fill.linear_axis(bounds); LinearGradient { x1: p0.x as f32, y1: p0.y as f32, @@ -1080,7 +1072,14 @@ impl Exporter<'_> { fn stroke(&mut self, s: &mut Surface, it: &Item, bp: &BezPath, path: &Path) { let st = &it.stroke; - let Some(c) = self.swatch_color(&st.swatch, st.tint) else { return }; + // A gradient swatch runs across the path's box, as on a fill. + let paint: krilla::paint::Paint = if designcraft_color::swatch::resolve_gradient(&self.doc.swatches, &st.swatch).is_some() { + let Some(p) = self.fill_paint(&st.as_fill(), bp.bounding_box()) else { return }; + p + } else { + let Some(c) = self.swatch_color(&st.swatch, st.tint) else { return }; + c.into() + }; let closed = it.path.is_closed(); let mut cap = match st.cap { designcraft_doc::Cap::Butt => krilla::paint::LineCap::Butt, @@ -1101,7 +1100,7 @@ impl Exporter<'_> { s.draw_path(path); s.set_stroke(None); } - s.set_fill(Some(Fill { paint: c.into(), opacity: NormalizedF32::ONE, rule: FillRule::NonZero })); + s.set_fill(Some(Fill { paint: paint.clone(), opacity: NormalizedF32::ONE, rule: FillRule::NonZero })); s.draw_path(&op); s.set_fill(None); return; @@ -1153,7 +1152,7 @@ impl Exporter<'_> { s.draw_path(path); } s.set_stroke(Some(Stroke { - paint: c.clone().into(), + paint: paint.clone(), width: width as f32, miter_limit: st.miter_limit.max(1.0) as f32, line_cap: cap, @@ -1174,12 +1173,12 @@ impl Exporter<'_> { let Some(p) = to_path(&h.path) else { continue }; match h.outline { Some(w) => { - s.set_stroke(Some(Stroke { paint: c.clone().into(), width: w as f32, opacity: NormalizedF32::ONE, ..Default::default() })); + s.set_stroke(Some(Stroke { paint: paint.clone(), width: w as f32, opacity: NormalizedF32::ONE, ..Default::default() })); s.draw_path(&p); s.set_stroke(None); } None => { - s.set_fill(Some(krilla::paint::Fill { paint: c.clone().into(), opacity: NormalizedF32::ONE, rule: FillRule::NonZero })); + s.set_fill(Some(krilla::paint::Fill { paint: paint.clone(), opacity: NormalizedF32::ONE, rule: FillRule::NonZero })); s.draw_path(&p); s.set_fill(None); } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 6b7735e7b..f3fb7c1bd 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -620,8 +620,17 @@ impl Renderer { } fn draw_stroke(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine) { + self.draw_stroke_painted(ctx, f, it, bp, xf); + ctx.reset_paint_transform(); + } + + /// The stroke, in its colour or gradient (a gradient runs across the path's box, as a fill's). + fn draw_stroke_painted(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine) { let st = &it.stroke; - let Some(c) = f.doc.resolve_color(&st.swatch, st.tint) else { return }; + let (paint, bounds) = (st.as_fill(), bp.bounding_box()); + if f.doc.resolve_color(&st.swatch, st.tint).is_none() && designcraft_color::swatch::resolve_gradient(&f.doc.swatches, &st.swatch).is_none() { + return; + } let mut stroke = kurbo::Stroke::new(st.weight) .with_caps(match st.cap { designcraft_doc::Cap::Butt => kurbo::Cap::Butt, @@ -641,7 +650,9 @@ impl Renderer { _ => {} } ctx.set_transform(f.view * xf); - ctx.set_paint(color_of(&c, 1.0)); + if !set_fill_paint(ctx, f.doc, &paint, bounds) { + return; + } let closed = it.path.is_closed(); // Arrowheads: the path is shortened under them; they're drawn after it. let arrows = designcraft_doc::arrow::apply(bp, st, closed); @@ -668,7 +679,7 @@ impl Renderer { if op { ctx.pop_layer(); } - ctx.set_paint(color_of(&c, 1.0)); + set_fill_paint(ctx, f.doc, &paint, bounds); } // Stripes, wavy and hash strokes are fills (along the path's centre line). if let Some(o) = kind.outline(bp, st.weight, 0.05 * f.px) { @@ -877,7 +888,7 @@ pub fn color_of(c: &designcraft_color::Color, alpha: f32) -> peniko::Color { /// Set a swatch fill (solid or gradient) as the paint. Returns false for [None]/unknown. fn set_fill_paint(ctx: &mut RenderContext, doc: &Document, fill: &designcraft_doc::Fill, bounds: Rect) -> bool { - let (swatch, tint, angle) = (fill.swatch.as_str(), fill.tint, fill.gradient_angle); + let (swatch, tint) = (fill.swatch.as_str(), fill.tint); if let Some(g) = designcraft_color::swatch::resolve_gradient(&doc.swatches, swatch) { // Sampled so process colours blend as inks, as InDesign shows them (and as the PDF's CMYK // shading prints). @@ -885,7 +896,6 @@ fn set_fill_paint(ctx: &mut RenderContext, doc: &Document, fill: &designcraft_do if stops.is_empty() { return false; } - let c = bounds.center(); let grad = match (g.kind, fill.gradient_vector) { // Radial: a circle stretched into the object's ellipse (the paint transform). (designcraft_color::GradientKind::Radial, _) => { @@ -894,12 +904,9 @@ fn set_fill_paint(ctx: &mut RenderContext, doc: &Document, fill: &designcraft_do 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()), _ => { - let a = angle.unwrap_or(0.0).to_radians(); - let half = (bounds.width() * a.cos().abs() + bounds.height() * a.sin().abs()) / 2.0; - let d = Vec2::new(a.cos(), -a.sin()) * half; - peniko::Gradient::new_linear(c - d, c + d).with_stops(stops.as_slice()) + let (a, b) = fill.linear_axis(bounds); + peniko::Gradient::new_linear(a, b).with_stops(stops.as_slice()) } }; ctx.set_paint(grad); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 68ee69351..bcaf86801 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -74,6 +74,16 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Frame Edges", ["Rahmenkanten", "Bords du bloc", "Bordes del marco", "フレーム枠", "框架边缘"]), ("Rulers", ["Lineale", "Règles", "Reglas", "定規", "标尺"]), ("Hidden Characters", ["Verborgene Zeichen", "Caractères masqués", "Caracteres ocultos", "制御文字", "隐藏字符"]), + ( + "The stroke isn't a gradient.", + [ + "Die Kontur ist kein Verlauf.", + "Le contour n’est pas un dégradé.", + "El trazo no es un degradado.", + "線はグラデーションではありません。", + "描边不是渐变。", + ], + ), ("Reverse Path", ["Pfadrichtung umkehren", "Inverser le tracé", "Invertir trazado", "パスの方向反転", "反转路径"]), ("Set Anchor", ["Ankerpunkt festlegen", "Définir le point d’ancrage", "Definir punto de ancla", "アンカーポイントを設定", "设置锚点"]), ("Clear", ["Löschen", "Effacer", "Borrar", "消去", "清除"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 66e70faf6..0821042e0 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -37,6 +37,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("View Options", "خيارات العرض"), ("Frame Edges", "حواف الإطار"), ("Hidden Characters", "الأحرف المخفية"), + ("The stroke isn't a gradient.", "الحد ليس تدرجًا."), ("Reverse Path", "عكس المسار"), ("Set Anchor", "تعيين نقطة الارتساء"), ("Clear", "مسح"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 440d64fce..240ea4777 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -35,6 +35,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("View Options", "Opzioni di visualizzazione"), ("Frame Edges", "Bordi cornice"), ("Hidden Characters", "Caratteri nascosti"), + ("The stroke isn't a gradient.", "La traccia non è una sfumatura."), ("Reverse Path", "Inverti tracciato"), ("Set Anchor", "Imposta ancoraggio"), ("Clear", "Cancella"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 9cb7a9370..b5b27bb5a 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -36,6 +36,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("View Options", "表示オプション"), ("Frame Edges", "フレーム枠"), ("Hidden Characters", "制御文字"), + ("The stroke isn't a gradient.", "線はグラデーションではありません。"), ("Reverse Path", "パスの方向反転"), ("Set Anchor", "アンカーポイントを設定"), ("Clear", "消去"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 085b2add5..159cf4824 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -41,6 +41,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("View Options", "Opções de visualização"), ("Frame Edges", "Bordas do quadro"), ("Hidden Characters", "Caracteres ocultos"), + ("The stroke isn't a gradient.", "O traçado não é um gradiente."), ("Reverse Path", "Inverter demarcador"), ("Set Anchor", "Definir ponto de ancoragem"), ("Clear", "Limpar"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 0d372cc75..91036383e 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -38,6 +38,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("View Options", "Параметри перегляду"), ("Frame Edges", "Краї фреймів"), ("Hidden Characters", "Приховані символи"), + ("The stroke isn't a gradient.", "Обведення не є градієнтом."), ("Reverse Path", "Розвернути контур"), ("Set Anchor", "Задати прив’язку"), ("Clear", "Очистити"), diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 7ea1d8a0a..a21ffdb93 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -520,11 +520,21 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) { )); return; }; - let fill = it.fill.clone(); + // The fill's gradient, or the stroke's when the toolbox's Stroke box is in front (as InDesign's + // Gradient panel follows the Fill/Stroke proxy). + let target = if app.ui.stroke_front { "stroke" } else { "fill" }; + let fill = if app.ui.stroke_front { it.stroke.as_fill() } else { it.fill.clone() }; let Some(g) = designcraft_color::swatch::resolve_gradient(&st.doc.swatches, &fill.swatch).cloned() else { - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "The fill isn't a gradient.")).color(t.text_dim)); + crate::rtl::label( + ui, + egui::RichText::new(crate::i18n::tr( + &app.ui.language, + if app.ui.stroke_front { "The stroke isn't a gradient." } else { "The fill isn't a gradient." }, + )) + .color(t.text_dim), + ); if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Apply Gradient"))).clicked() { - let _ = app.run("object.gradient", json!({})); + let _ = app.run("object.gradient", json!({"target": target, "target": target})); } return; }; @@ -537,7 +547,7 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) { .show_ui(ui, |ui| { for (k, v) in [("Linear", "linear"), ("Radial", "radial")] { if ui.selectable_label(g.kind.label() == k, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, k))).clicked() { - let _ = app.run("object.gradient", json!({"kind": v})); + let _ = app.run("object.gradient", json!({"target": target, "kind": v})); } } }); @@ -548,7 +558,7 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) { }) .clicked() { - let _ = app.run("object.gradient", json!({"reverse": true})); + let _ = app.run("object.gradient", json!({"target": target, "reverse": true})); } }); let sel_id = egui::Id::new("grad_sel"); @@ -560,13 +570,13 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) { if let Some(v) = crate::widgets::number(ui, "grad_loc", loc, " %", 54.0, 1) { let mut stops = stops_json(&g); stops[sel]["location"] = json!(v.clamp(0.0, 100.0)); - let _ = app.run("object.gradient", json!({"stops": stops})); + let _ = app.run("object.gradient", json!({"target": target, "stops": stops})); } if !radial { crate::widgets::caption(ui, crate::i18n::tr(&app.ui.language, "Angle:")); let angle = fill.gradient_vector.map(|[x0, y0, x1, y1]| (-(y1 - y0)).atan2(x1 - x0).to_degrees()).or(fill.gradient_angle).unwrap_or(0.0); if let Some(v) = crate::widgets::number(ui, "grad_angle", Some(angle), "°", 54.0, 1) { - let _ = app.run("object.gradient", json!({"angle": v})); + let _ = app.run("object.gradient", json!({"target": target, "angle": v})); } } }); @@ -616,7 +626,7 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) { } else { stops[i]["location"] = json!(o as f64 * 100.0); } - let _ = app.run("object.gradient", json!({"stops": stops})); + let _ = app.run("object.gradient", json!({"target": target, "stops": stops})); } if resp.clicked() && let Some(p) = resp.interact_pointer_pos() @@ -629,7 +639,7 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let (c, a) = g.sample(o); let mut stops = stops_json(&g); stops.push(json!({"location": o as f64 * 100.0, "color": c.to_hex(), "opacity": a as f64 * 100.0, "midpoint": 50.0})); - let _ = app.run("object.gradient", json!({"stops": stops})); + let _ = app.run("object.gradient", json!({"target": target, "stops": stops})); sel = g.stops.iter().filter(|s| s.offset <= o).count(); } None => {} @@ -678,7 +688,7 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) { if egui::color_picker::color_edit_button_srgb(ui, &mut rgb).changed() { let mut stops = stops_json(&g); stops[sel]["color"] = json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2])); - let _ = app.run("object.gradient", json!({"stops": stops})); + let _ = app.run("object.gradient", json!({"target": target, "stops": stops})); } }); } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 80b316c12..0a0cee649 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -424,7 +424,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. 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). +- 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` (`target: stroke` for a stroke's gradient, drawn across the path's box on screen and in PDF); the vector round-trips through IDML (`GradientFill…`/`GradientStroke…`). The Gradient panel follows the Fill/Stroke proxy. 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.