F: Gradient Feather — tool, effect on screen, soft mask in PDF

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-02 21:19:57 -04:00
co-authored by Claude Opus 5.5
parent faa2897ace
commit aa54531472
8 changed files with 204 additions and 7 deletions
+1
View File
@@ -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.
+43 -1
View File
@@ -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
}
}
+61
View File
@@ -48,6 +48,15 @@ pub fn specs() -> Vec<CommandSpec> {
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<Value> {
})
}
fn gradient_feather(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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();
+57
View File
@@ -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;
}
+26
View File
@@ -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()
+12 -3
View File
@@ -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<u64>)>,
end: Option<Point>,
@@ -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<Action> {
@@ -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));
+1
View File
@@ -261,6 +261,7 @@ pub fn create(id: &str) -> Box<dyn Tool> {
"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)),
+3 -3
View File
@@ -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 |