mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-31 13:46:52 -06:00
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:
co-authored by
Claude Opus 5.5
parent
faa2897ace
commit
aa54531472
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user