diff --git a/ROADMAP.md b/ROADMAP.md index cfd3efc63..55531dbde 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -30,6 +30,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool). - Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`. - Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow adds pages and threaded frames until the text fits; Remove Styles option (`designcraft-textimport`). +- Gradient Feather (⇧G tool, `object.gradientFeather`): objects fade along a linear or radial opacity gradient, on screen and as a soft mask in PDF. - Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`). - Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export. - Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override. diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index de177a2b7..00786122d 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -498,12 +498,54 @@ pub struct Effects { pub inner_shadow: InnerShadow, #[serde(skip_serializing_if = "is_default")] pub outer_glow: OuterGlow, + #[serde(skip_serializing_if = "is_default")] + pub gradient_feather: GradientFeather, +} + +/// Gradient Feather: the object fades from `start` to `end` opacity along a gradient. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct GradientFeather { + pub on: bool, + pub radial: bool, + /// Degrees (linear), when no vector was dragged. + pub angle: f64, + pub start: f32, + pub end: f32, + /// Gradient Feather tool vector in the item's space: start and end (linear), centre and + /// radius point (radial). + #[serde(skip_serializing_if = "Option::is_none")] + pub vector: Option<[f64; 4]>, +} + +impl Default for GradientFeather { + fn default() -> Self { + GradientFeather { on: false, radial: false, angle: 0.0, start: 1.0, end: 0.0, vector: None } + } +} + +impl GradientFeather { + /// The gradient's two points in item space for an object with inner bounds `b`. + pub fn points(&self, b: designcraft_geom::Rect) -> (designcraft_geom::Point, designcraft_geom::Point) { + use designcraft_geom::{Point, Vec2}; + if let Some([x0, y0, x1, y1]) = self.vector { + return (Point::new(x0, y0), Point::new(x1, y1)); + } + let c = b.center(); + if self.radial { + return (c, c + Vec2::new(b.width().max(b.height()) / 2.0, 0.0)); + } + let a = self.angle.to_radians(); + let d = Vec2::new(a.cos(), -a.sin()); + let half = (b.width() * d.x.abs() + b.height() * d.y.abs()) / 2.0; + (c - d * half, c + d * half) + } } impl Effects { /// Any effect drawn with soft (raster) filters? pub fn any(&self) -> bool { - self.drop_shadow.on || self.feather > 0.0 || self.inner_shadow.on || self.outer_glow.on + self.drop_shadow.on || self.feather > 0.0 || self.inner_shadow.on || self.outer_glow.on || self.gradient_feather.on } } diff --git a/crates/engine/src/cmd/paths.rs b/crates/engine/src/cmd/paths.rs index dff98de7d..935750f42 100644 --- a/crates/engine/src/cmd/paths.rs +++ b/crates/engine/src/cmd/paths.rs @@ -48,6 +48,15 @@ pub fn specs() -> Vec { has_selection, pathfinder ), + cmd!( + "object.gradientFeather", + "Gradient Feather", + ["Object", "Effects"], + None, + "{on?: true, radial?, angle?, start?: opacity 0–100 (100), end?: (0), from?: [x,y], to?: [x,y] (spread coords: the Gradient Feather tool's drag), ids?}", + has_selection, + gradient_feather + ), cmd!( "path.split", "Split Path", @@ -156,6 +165,40 @@ fn pathfinder(s: &mut Session, p: &Value) -> Result { }) } +fn gradient_feather(s: &mut Session, p: &Value) -> Result { + let ids = targets(s, p)?; + let p = p.clone(); + s.edit(|d, _| { + for id in &ids { + let Some((_, xf)) = spread_xf(d, *id) else { continue }; + let Some(it) = d.item_mut(*id) else { continue }; + let gf = &mut it.effects.gradient_feather; + gf.on = p.get("on").and_then(Value::as_bool).unwrap_or(true); + if let Some(v) = p.get("radial").and_then(Value::as_bool) { + gf.radial = v; + } + if let Some(v) = p.get("angle").and_then(Value::as_f64) { + gf.angle = v; + gf.vector = None; + } + if let Some(v) = p.get("start").and_then(Value::as_f64) { + gf.start = (v / 100.0).clamp(0.0, 1.0) as f32; + } + if let Some(v) = p.get("end").and_then(Value::as_f64) { + gf.end = (v / 100.0).clamp(0.0, 1.0) as f32; + } + if let (Some(a), Some(b)) = (point_param(&p, "from"), point_param(&p, "to")) { + let inv = xf.inverse(); + let (a, b) = (inv * a, inv * b); + if (b - a).hypot() > 1e-6 { + gf.vector = Some([a.x, a.y, b.x, b.y]); + } + } + } + Ok(json!({"changed": ids.len()})) + }) +} + fn release_compound(s: &mut Session, p: &Value) -> Result { let ids = targets(s, p)?; s.edit(|d, sel| { @@ -370,6 +413,24 @@ mod tests { assert!(s.execute("object.pathfinder", &json!({"op": "nope"})).is_err()); } + #[test] + fn gradient_feather_fades_on_screen_and_in_pdf() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [100, 100, 300, 200]})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.fill", &json!({"ids": [id], "swatch": "[Black]"})).unwrap(); + s.execute("object.gradientFeather", &json!({"ids": [id], "from": [100, 150], "to": [300, 150]})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let mut rr = designcraft_render::Renderer::new(); + rr.threads = 0; + let img = rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap(); + let (l, m, r) = (img.pixel(105, 150)[0], img.pixel(200, 150)[0], img.pixel(295, 150)[0]); + assert!(l < 60 && m > l + 40 && r > 220, "fades left to right: {l} {m} {r}"); + let pdf = designcraft_pdf::export_pdf(&d, &s.cache, &Default::default()).unwrap(); + let t = String::from_utf8_lossy(&pdf); + assert!(t.contains("/SMask") || t.contains("/Luminosity"), "a soft mask"); + } + #[test] fn create_outlines_turns_text_into_paths() { let mut s = Session::new(); diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index da79a6c2e..ebb9889bd 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -410,6 +410,63 @@ impl Exporter<'_> { } fn item(&mut self, s: &mut Surface, it: &Item, parent: Affine, page_name: Option<&str>) { + let gf = &it.effects.gradient_feather; + if !gf.on || it.hidden || it.nonprinting { + return self.item_inner(s, it, parent, page_name); + } + // Gradient feather: a luminosity mask (white = opaque) over the object. + let xf = parent * it.xf; + let (p0, p1) = gf.points(it.inner_bounds()); + let gray = |a: f32| -> krilla::color::Color { luma::Color::new((a.clamp(0.0, 1.0) * 255.0).round() as u8).into() }; + let stops = vec![ + Stop { offset: NormalizedF32::ZERO, color: gray(gf.start), opacity: NormalizedF32::ONE }, + Stop { offset: NormalizedF32::ONE, color: gray(gf.end), opacity: NormalizedF32::ONE }, + ]; + let paint: krilla::paint::Paint = if gf.radial { + let r = (p1 - p0).hypot().max(1e-3) as f32; + RadialGradient { + fx: p0.x as f32, + fy: p0.y as f32, + fr: 0.0, + cx: p0.x as f32, + cy: p0.y as f32, + cr: r, + transform: Transform::identity(), + spread_method: SpreadMethod::Pad, + stops, + anti_alias: false, + } + .into() + } else { + LinearGradient { + x1: p0.x as f32, + y1: p0.y as f32, + x2: p1.x as f32, + y2: p1.y as f32, + transform: Transform::identity(), + spread_method: SpreadMethod::Pad, + stops, + anti_alias: false, + } + .into() + }; + let area = it.inner_bounds().inflate(it.stroke.extent() + 4.0, it.stroke.extent() + 4.0); + let mut sb = s.stream_builder(); + let mut ms = sb.surface(); + ms.push_transform(&tf(xf)); + if let Some(p) = to_path(&area.to_path(0.1)) { + ms.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: FillRule::NonZero })); + ms.draw_path(&p); + } + ms.pop(); + ms.finish(); + let stream = sb.finish(); + s.push_mask(krilla::mask::Mask::new(stream, krilla::mask::MaskType::Luminosity)); + self.item_inner(s, it, parent, page_name); + s.pop(); + } + + fn item_inner(&mut self, s: &mut Surface, it: &Item, parent: Affine, page_name: Option<&str>) { if it.hidden || it.nonprinting { return; } diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 0a9a2315c..8748e3e8e 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -103,6 +103,13 @@ impl Renderer { c.pop_layer(); }); } + // Gradient feather: the object (feathered or not) drawn in its own layer, then kept as + // much as the opacity gradient says. + let gf = &e.gradient_feather; + if gf.on { + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, None, None, None, None); + } // The object, feathered or not. if e.feather > 0.0 && it.path.is_closed() { let w = e.feather; @@ -131,6 +138,25 @@ impl Renderer { } else { self.draw_body(ctx, f, it, bp, xf, page_name); } + if gf.on { + let (p0, p1) = gf.points(it.inner_bounds()); + let stop = |o: f32, a: f32| peniko::ColorStop::from((o, peniko::Color::from_rgba8(0, 0, 0, (a.clamp(0.0, 1.0) * 255.0).round() as u8))); + let stops = [stop(0.0, gf.start), stop(1.0, gf.end)]; + let grad = if gf.radial { + peniko::Gradient::new_radial(p0, (p1 - p0).hypot().max(1e-3) as f32).with_stops(stops.as_slice()) + } else { + peniko::Gradient::new_linear(p0, p1).with_stops(stops.as_slice()) + }; + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None); + ctx.set_transform(f.view * xf); + ctx.set_paint(grad); + let area = if xf.determinant().abs() > 1e-12 { xf.inverse().transform_rect_bbox(reach) } else { it.inner_bounds() }; + ctx.fill_rect(&area.inflate(4.0, 4.0)); + ctx.reset_paint_transform(); + ctx.pop_layer(); + ctx.pop_layer(); + } // Inner shadow, clipped to the shape. if e.inner_shadow.on && it.path.is_closed() diff --git a/crates/tools/src/gradient.rs b/crates/tools/src/gradient.rs index bf0e2a880..b35b90b11 100644 --- a/crates/tools/src/gradient.rs +++ b/crates/tools/src/gradient.rs @@ -8,6 +8,8 @@ use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, Tool #[derive(Default)] pub struct GradientTool { + /// Gradient Feather tool (Shift+G): the drag sets the fade instead of the fill gradient. + pub feather: bool, /// Canvas start point, spread-space start point and the target ids. drag: Option<(Point, Point, Vec)>, end: Option, @@ -23,9 +25,15 @@ fn constrain(a: Point, b: Point, m: Mods) -> Point { a + Vec2::new(ang.cos(), ang.sin()) * v.hypot() } +impl GradientTool { + pub fn feather() -> Self { + GradientTool { feather: true, ..Default::default() } + } +} + impl Tool for GradientTool { fn id(&self) -> &'static str { - "gradientSwatch" + if self.feather { "gradientFeather" } else { "gradientSwatch" } } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { @@ -42,7 +50,7 @@ impl Tool for GradientTool { } self.drag = Some((ev.pos, sp, ids)); self.end = None; - vec![Action::Begin("Gradient".into())] + vec![Action::Begin(if self.feather { "Gradient Feather".into() } else { "Gradient".into() })] } PointerKind::Drag => { let Some((start, sp, ids)) = &self.drag else { return vec![] }; @@ -52,7 +60,8 @@ impl Tool for GradientTool { return vec![]; } let to = *sp + (end - *start); - vec![Action::Preview("object.gradient".into(), json!({"ids": ids, "from": [sp.x, sp.y], "to": [to.x, to.y]}))] + let cmd = if self.feather { "object.gradientFeather" } else { "object.gradient" }; + vec![Action::Preview(cmd.into(), json!({"ids": ids, "from": [sp.x, sp.y], "to": [to.x, to.y]}))] } PointerKind::Up => { let moved = self.end.zip(self.drag.as_ref()).is_some_and(|(e, (s, _, _))| (e - *s).hypot() >= cx.tol(2.0)); diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index f26b21e1d..2c54b6047 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -261,6 +261,7 @@ pub fn create(id: &str) -> Box { "type" => Box::new(text::TypeTool::default()), "pen" => Box::new(pen::PenTool::default()), "gradientSwatch" => Box::new(gradient::GradientTool::default()), + "gradientFeather" => Box::new(gradient::GradientTool::feather()), "pencil" => Box::new(pencil::PencilTool::default()), "smooth" => Box::new(pencil::PathDragTool::new(false)), "erase" => Box::new(pencil::PathDragTool::new(true)), diff --git a/docs/parity.md b/docs/parity.md index d15795104..83821f37c 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -10,7 +10,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| | P0 | 102 | 99 | 3 | 0 | 99% | -| P1 | 97 | 66 | 26 | 5 | 81% | +| P1 | 97 | 67 | 26 | 4 | 82% | | P2 | 76 | 8 | 7 | 61 | 15% | | Area | Parity | @@ -20,7 +20,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Layers | 95% | | Frames, shapes & paths | 88% | | Transform | 93% | -| Fill, stroke, colour | 81% | +| Fill, stroke, colour | 86% | | Effects & transparency | 50% | | Placing & links | 81% | | Type & text frames | 88% | @@ -143,7 +143,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t | Fill, stroke, colour | Color panel | P0 | D | | Fill, stroke, colour | Color Picker dialog | P0 | D | | Fill, stroke, colour | Gradient panel & tool | P0 | D | -| Fill, stroke, colour | Gradient Feather tool | P1 | M | +| Fill, stroke, colour | Gradient Feather tool | P1 | D | | Fill, stroke, colour | Tints | P0 | D | | Fill, stroke, colour | Special swatches | P0 | D | | Fill, stroke, colour | Spot colours | P1 | D |