diff --git a/ROADMAP.md b/ROADMAP.md index 9ca6640..d08c220 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -107,6 +107,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Math expressions (Type ▸ Insert Math Expression…, `math.insert`, `math.svg`): a LaTeX subset (fractions, scripts, roots, big operators with limits, Greek, relations and operators with math spacing, `\left…\right`, `\text`) typeset by our own engine into SVG, placed inline at the cursor or as a frame; the LaTeX is kept as alt text and can be edited. - Object Layer Options for placed PDFs (`object.pdfLayers`, `object.layerOptions`, Object ▸ Object Layer Options…): the file's optional-content layers listed and shown or hidden per frame on screen; in exported PDFs such graphics go out as 300 ppi images of their visible layers. - Effects: Inner Glow (edge or centre), Bevel and Emboss (inner bevel lit from the light angle), Satin (`object.innerGlow`, `object.bevel`, `object.satin`); PDF export now keeps soft effects: objects with shadows, glows, feathers, bevels or satin go out as 300 ppi transparent images of their appearance (threaded text frames excepted). +- Directional Feather (`object.directionalFeather {widths: [top, left, bottom, right]}`): each side fades over its own width. - 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 904d3ed..369e2d5 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -718,6 +718,17 @@ pub struct Effects { pub bevel: Bevel, #[serde(skip_serializing_if = "is_default")] pub satin: Satin, + #[serde(skip_serializing_if = "is_default")] + pub directional_feather: DirectionalFeather, +} + +/// Directional Feather: each side fades over its own width (item space, before rotation). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct DirectionalFeather { + pub on: bool, + /// Top, left, bottom, right widths in points. + pub widths: [f64; 4], } /// Inner Glow: a glow inside the edge (or from the centre). @@ -843,6 +854,7 @@ impl Effects { || self.inner_glow.on || self.bevel.on || self.satin.on + || self.directional_feather.on } } diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 2bf5868..db33a7c 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -237,7 +237,14 @@ fn threaded(d: &designcraft_doc::Document, it: &designcraft_doc::Item) -> bool { /// Soft effects other than the gradient feather (which PDF export draws itself). fn raster_effects(it: &designcraft_doc::Item) -> bool { let e = &it.effects; - e.drop_shadow.on || e.feather > 0.0 || e.inner_shadow.on || e.outer_glow.on || e.inner_glow.on || e.bevel.on || e.satin.on + e.drop_shadow.on + || e.feather > 0.0 + || e.inner_shadow.on + || e.outer_glow.on + || e.inner_glow.on + || e.bevel.on + || e.satin.on + || e.directional_feather.on } /// A copy of `d` where spread-level objects with soft effects are 300 ppi images of their whole diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index d154e76..95c297b 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -403,6 +403,38 @@ pub fn specs() -> Vec { ) } ), + cmd!( + "object.directionalFeather", + "Directional Feather", + ["Object", "Effects"], + None, + "{on?: bool, widths?: [top, left, bottom, right] | number, ids?}", + has_selection, + |s, p| { + let p2 = p.clone(); + set_flag( + s, + p, + move |i| { + let e = &mut i.effects.directional_feather; + e.on = p2.get("on").and_then(Value::as_bool).unwrap_or(!e.on); + match p2.get("widths") { + Some(Value::Number(n)) => e.widths = [n.as_f64().unwrap_or(0.0).max(0.0); 4], + Some(v @ Value::Array(_)) => { + if let Ok(w) = serde_json::from_value::<[f64; 4]>(v.clone()) { + e.widths = w.map(|x| x.max(0.0)); + } + } + _ => {} + } + if e.on && e.widths.iter().all(|w| *w <= 0.0) { + e.widths = [9.0; 4]; + } + }, + false, + ) + } + ), cmd!("object.feather", "Basic Feather", ["Object", "Effects"], None, "{width (0 = off), ids?}", has_selection, |s, p| { let w = f64_or(p, "width", 9.0).max(0.0); set_flag(s, p, move |i| i.effects.feather = w, false) @@ -2150,6 +2182,12 @@ mod fitting_tests { .flat_map(|x| (110..290).step_by(10).map(move |y| (x, y))) .any(|(x, y)| (st.pixel(x, y)[1] as i32) < plain.pixel(x, y)[1] as i32 - 30); assert!(darker); + s.execute("object.satin", &json!({"ids": [id], "on": false})).unwrap(); + // Directional feather: only the left edge fades. + s.execute("object.directionalFeather", &json!({"ids": [id], "on": true, "widths": [0, 40, 0, 0]})).unwrap(); + let df = shot(&s); + assert!(df.pixel(102, 200)[0] > 200, "left edge faded to paper: {:?}", df.pixel(102, 200)); + assert_eq!(df.pixel(298, 200), plain.pixel(298, 200), "right edge untouched"); // PDF export carries effects (as an image of the object). let r = s.execute("file.exportPdf", &json!({})).unwrap(); assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("soft effects")), "{r}"); diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 2589b43..2818fea 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -108,7 +108,8 @@ impl Renderer { // 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 { + let directional = e.directional_feather.on && e.directional_feather.widths.iter().any(|w| *w > 0.0); + if gf.on || directional { ctx.set_transform(Affine::IDENTITY); ctx.push_layer(None, None, None, None, None); } @@ -140,6 +141,34 @@ impl Renderer { } else { self.draw_body(ctx, f, it, bp, xf, page_name); } + // Directional feather: one fading ramp per side, multiplied in. + let df = &e.directional_feather; + if df.on && df.widths.iter().any(|w| *w > 0.0) { + let b = it.inner_bounds(); + let area = if xf.determinant().abs() > 1e-12 { xf.inverse().transform_rect_bbox(reach) } else { b }; + let ramps = [ + (df.widths[0], kurbo::Point::new(b.x0, b.y0), kurbo::Point::new(b.x0, b.y0 + df.widths[0])), + (df.widths[1], kurbo::Point::new(b.x0, b.y0), kurbo::Point::new(b.x0 + df.widths[1], b.y0)), + (df.widths[2], kurbo::Point::new(b.x0, b.y1), kurbo::Point::new(b.x0, b.y1 - df.widths[2])), + (df.widths[3], kurbo::Point::new(b.x1, b.y0), kurbo::Point::new(b.x1 - df.widths[3], b.y0)), + ]; + for (w, p0, p1) in ramps { + if w <= 0.0 { + continue; + } + let stops = [ + peniko::ColorStop::from((0.0, peniko::Color::from_rgba8(0, 0, 0, 0))), + peniko::ColorStop::from((1.0, peniko::Color::from_rgba8(0, 0, 0, 255))), + ]; + 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(peniko::Gradient::new_linear(p0, p1).with_stops(stops.as_slice())); + ctx.fill_rect(&area.inflate(4.0, 4.0)); + ctx.reset_paint_transform(); + ctx.pop_layer(); + } + } 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))); @@ -157,6 +186,8 @@ impl Renderer { ctx.fill_rect(&area.inflate(4.0, 4.0)); ctx.reset_paint_transform(); ctx.pop_layer(); + } + if gf.on || directional { ctx.pop_layer(); } if !it.path.is_closed() { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 04415ed..bf5ff48 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -446,6 +446,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:object.bevel", "cmd:object.satin", "cmd:object.feather", + "cmd:object.directionalFeather", "<", ">Select", "cmd:select.firstAbove", diff --git a/docs/parity.md b/docs/parity.md index 9582478..c50221b 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -11,7 +11,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia |---|---|---|---|---|---| | P0 | 102 | 100 | 2 | 0 | 99% | | P1 | 97 | 94 | 3 | 0 | 98% | -| P2 | 76 | 46 | 30 | 0 | 80% | +| P2 | 76 | 47 | 29 | 0 | 81% | | Area | Parity | |---|---| @@ -21,7 +21,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Frames, shapes & paths | 100% | | Transform | 100% | | Fill, stroke, colour | 99% | -| Effects & transparency | 79% | +| Effects & transparency | 83% | | Placing & links | 91% | | Type & text frames | 99% | | Typography | 98% | @@ -162,7 +162,7 @@ Open P0 items: Vector/page formats (PDF/AI/SVG/EPS place), Export to PDF (print) | Effects & transparency | Isolate Blending / Knockout Group | P2 | P | | Effects & transparency | Drop Shadow | P1 | D | | Effects & transparency | Inner Shadow, Outer Glow, Inner Glow, Bevel, Satin | P2 | D | -| Effects & transparency | Basic/Directional/Gradient Feather | P2 | P | +| Effects & transparency | Basic/Directional/Gradient Feather | P2 | D | | Effects & transparency | Global Light | P2 | D | | Effects & transparency | Transparency blend space | P1 | P | | Effects & transparency | Transparency flattener | P2 | P |