From 370dca2ca24fce5545a87280cfa0b7ef2277b742 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 13 Oct 2026 19:11:58 +0200 Subject: [PATCH 1/2] feat(render): gradient strokes, on screen, in PDF and in the Gradient panel A gradient swatch on a stroke drew nothing: strokes were painted with a single resolved colour. LYH Studio episode 6 gives the loop on the right page (9:13) and, at first by accident, the circle (1:19, "right now it is on the stroke") a gradient stroke. - doc: Stroke::gradient_angle / gradient_vector (as Fill's) and Stroke::as_fill(), the stroke's paint as a fill. - render and PDF: a gradient stroke is painted like a gradient fill, across the path's box (dash gaps and arrowheads keep their paints). - engine: object.gradient {target: stroke} edits the stroke's gradient (kind, stops, angle, the Gradient Swatch tool's from/to); scaling moves the stroke's vector with the shape. - IDML: GradientStrokeStart/Length/Angle round-trip. - Gradient panel: acts on the stroke when the toolbox's Stroke box is in front, as InDesign's follows the Fill/Stroke proxy. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/doc/src/item.rs | 20 ++++++++ crates/engine/src/cmd/object.rs | 11 +++-- crates/engine/src/cmd/style.rs | 71 ++++++++++++++++++++++++--- crates/idml/src/export.rs | 8 +++ crates/idml/src/import.rs | 13 +++++ crates/idml/src/tests.rs | 17 +++++++ crates/pdf/src/export.rs | 17 +++++-- crates/render/src/lib.rs | 17 +++++-- crates/ui-egui/src/i18n.rs | 10 ++++ crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/i18n/ja.rs | 1 + crates/ui-egui/src/i18n/pt_br.rs | 1 + crates/ui-egui/src/i18n/uk.rs | 1 + crates/ui-egui/src/panels/swatches.rs | 30 +++++++---- docs/target-app-parity.md | 2 +- 17 files changed, 192 insertions(+), 30 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..ac9e48805 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** · 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** · 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 0a701998e..fe4ff27ef 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -224,6 +224,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 { @@ -244,11 +250,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 aa5a2cc01..50b4d577c 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -1103,12 +1103,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 c4d20f099..8562ca74d 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}|[r,g,b], 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}|[r,g,b], 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 ), @@ -922,13 +922,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 { @@ -960,16 +965,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() { @@ -1751,3 +1762,49 @@ mod sample_color_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()); + } +} diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index d01abe9e7..cfdbae52d 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1673,6 +1673,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 59ca44be7..c0959d7c9 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -2129,6 +2129,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. let cn = crate::export::corner_names(&it.path); diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index a2ee8f2d5..8eacd1142 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -1479,3 +1479,20 @@ fn type_on_a_path_keeps_its_start_and_end_brackets() { assert_eq!(pt.end.map(|e| e.round()), end, "an end bracket at the path's end is no end bracket"); } } + +#[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:?}"); +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index dcf8abb83..64434732a 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -1062,7 +1062,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, @@ -1083,7 +1090,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; @@ -1135,7 +1142,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, @@ -1156,12 +1163,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 2cc5c548f..c8b8d642a 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -621,8 +621,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, @@ -642,7 +651,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); @@ -669,7 +680,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) { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 5252c3881..dd81bca66 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -55,6 +55,16 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), + ( + "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 a182d09ec..4b195386e 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("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 75ece03f6..d9ea6eb63 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("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 642b4a4c0..988b0ccd0 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,6 +30,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("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 982dfb20d..f592575ef 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("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 f3bc8fb35..5d0d613bc 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("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 7544347a9..06b035dc2 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -407,11 +407,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; }; @@ -424,7 +434,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})); } } }); @@ -435,7 +445,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"); @@ -447,13 +457,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})); } } }); @@ -503,7 +513,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() @@ -516,7 +526,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 => {} @@ -565,7 +575,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 eb7d7e5c1..b9f1e6e6e 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` (`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. - 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. From f69601b0ffd09da60cc90aabb2b713cf78bbc584 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 10:36:45 +0200 Subject: [PATCH 2/2] A linear gradient across a straight line runs along its length, on screen and in PDF alike Review fix from aiongg (72d7ea2e, with the line tests of 6db40bdb), ported to this branch: Fill::linear_axis gives screen and PDF one gradient axis, and a box with no extent along the gradient's angle takes its longer side. Co-authored-by: aiongg Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/item.rs | 15 ++++++++++++ crates/engine/src/cmd/style.rs | 45 ++++++++++++++++++++++++++++++++++ crates/pdf/src/export.rs | 10 ++------ crates/render/src/lib.rs | 9 +++---- 4 files changed, 65 insertions(+), 14 deletions(-) diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 1dc045e08..593edd170 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -32,6 +32,21 @@ impl Default for Fill { } 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 } } diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 08cb736a9..fdc11e805 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -1900,6 +1900,51 @@ mod gradient_stroke_tests { 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)] diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 64434732a..8da90186e 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -978,7 +978,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 expanded = g.expanded_stops(); @@ -1036,13 +1036,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, diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index f4085b270..07b08a812 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -889,7 +889,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) { let stops: Vec = g.expanded_stops().iter().map(|(o, c, a)| peniko::ColorStop::from((*o, color_of(c, *a)))).collect(); if stops.is_empty() { @@ -901,16 +901,13 @@ fn set_fill_paint(ctx: &mut RenderContext, doc: &Document, fill: &designcraft_do 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()) } - (_, 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; - 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);