diff --git a/ROADMAP.md b/ROADMAP.md index 7038164ec..9ca6640ab 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -106,6 +106,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Vertical type (Vertical Type Tool, Type ▸ Story Direction, `object.textFrameOptions {vertical}`, IDML StoryOrientation): lines run top to bottom and follow each other right to left; CJK characters stay upright and other text turns, on screen and in PDF; CJK line breaking with basic kinsoku (also in horizontal text). Vertical punctuation forms (`vert`), tate-chu-yoko and vertical metrics aren't there yet. - 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). - 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 e8af7d666..904d3ed5f 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -712,6 +712,84 @@ pub struct Effects { pub outer_glow: OuterGlow, #[serde(skip_serializing_if = "is_default")] pub gradient_feather: GradientFeather, + #[serde(skip_serializing_if = "is_default")] + pub inner_glow: InnerGlow, + #[serde(skip_serializing_if = "is_default")] + pub bevel: Bevel, + #[serde(skip_serializing_if = "is_default")] + pub satin: Satin, +} + +/// Inner Glow: a glow inside the edge (or from the centre). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct InnerGlow { + pub on: bool, + pub color: String, + pub opacity: f32, + pub size: f64, + /// Percentage (0–100) of `size` that hardens the glow. + pub choke: f64, + /// Glow from the centre instead of the edges. + pub center: bool, +} + +impl Default for InnerGlow { + fn default() -> Self { + InnerGlow { on: false, color: designcraft_color::swatch::PAPER.into(), opacity: 0.75, size: 7.0, choke: 0.0, center: false } + } +} + +/// Bevel and Emboss (inner bevel): a highlight on the lit edges, a shadow on the others. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Bevel { + pub on: bool, + pub size: f64, + /// Depth in percent (scales the highlight and shadow offset). + pub depth: f64, + pub angle: f64, + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub global_light: bool, + pub highlight: String, + pub highlight_opacity: f32, + pub shadow: String, + pub shadow_opacity: f32, +} + +impl Default for Bevel { + fn default() -> Self { + Bevel { + on: false, + size: 7.0, + depth: 100.0, + angle: 120.0, + global_light: true, + highlight: designcraft_color::swatch::PAPER.into(), + highlight_opacity: 0.75, + shadow: designcraft_color::swatch::BLACK.into(), + shadow_opacity: 0.75, + } + } +} + +/// Satin: soft interior shading from two offset copies of the shape. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Satin { + pub on: bool, + pub color: String, + pub opacity: f32, + pub angle: f64, + pub distance: f64, + pub size: f64, + pub invert: bool, +} + +impl Default for Satin { + fn default() -> Self { + Satin { on: false, color: designcraft_color::swatch::BLACK.into(), opacity: 0.5, angle: 120.0, distance: 7.0, size: 7.0, invert: false } + } } /// Gradient Feather: the object fades from `start` to `end` opacity along a gradient. @@ -757,7 +835,14 @@ impl GradientFeather { 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.gradient_feather.on + self.drop_shadow.on + || self.feather > 0.0 + || self.inner_shadow.on + || self.outer_glow.on + || self.gradient_feather.on + || self.inner_glow.on + || self.bevel.on + || self.satin.on } } diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 0d5e2354d..2bf58682c 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -229,6 +229,97 @@ fn flatten(d: &designcraft_doc::Document, cache: &designcraft_compose::Cache, pp (out, n) } +/// A text frame in a story that runs through other frames (replacing it would reflow the story). +fn threaded(d: &designcraft_doc::Document, it: &designcraft_doc::Item) -> bool { + it.text_frame().and_then(|t| d.story(t.story)).is_some_and(|st| st.frames.len() > 1) +} + +/// 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 +} + +/// A copy of `d` where spread-level objects with soft effects are 300 ppi images of their whole +/// appearance (transparent around them, so they composite over what's behind). +fn rasterize_effects(d: &designcraft_doc::Document, cache: &designcraft_compose::Cache) -> (Option, usize) { + use designcraft_doc::{Asset, AssetId, Content, Graphic, Item, ItemId, Shape, SpreadRef}; + let todo: Vec<(usize, ItemId)> = d + .spreads + .iter() + .enumerate() + .flat_map(|(si, sp)| sp.items.iter().filter(|it| !it.hidden && raster_effects(it) && !threaded(d, it)).map(move |it| (si, it.id))) + .collect(); + if todo.is_empty() { + return (None, 0); + } + let mut out = d.clone(); + let mut rr = designcraft_render::Renderer::new(); + rr.threads = designcraft_render::default_threads(); + let k = 300.0 / 72.0; + let mut n = 0; + for (si, id) in todo { + let Some(it) = d.item(id) else { continue }; + let reach = it.bounds().inflate( + designcraft_render::effect_outset(it) + it.stroke.extent() + 2.0, + designcraft_render::effect_outset(it) + it.stroke.extent() + 2.0, + ); + // Just this object on its spread, no paper. + let mut solo = d.clone(); + let sp = std::sync::Arc::make_mut(&mut solo.spreads[si]); + sp.items.retain(|x| x.id == id); + for pg in &mut sp.pages { + pg.show_parent_items = false; + } + let (w, h) = ((reach.width() * k).ceil().max(1.0) as u32, (reach.height() * k).ceil().max(1.0) as u32); + if w as u64 * h as u64 > 60_000_000 { + continue; + } + let view = designcraft_geom::Affine::scale(k) * designcraft_geom::Affine::translate((-reach.x0, -reach.y0)); + let opts = designcraft_render::RenderOptions { paper: false, background: None, printing_only: true, ..Default::default() }; + let img = rr.render( + &solo, + cache, + &[designcraft_render::Placed { spread: SpreadRef::Doc(si), offset: designcraft_geom::Vec2::ZERO }], + w, + h, + view, + &opts, + ); + let aid = AssetId(out.alloc()); + out.assets.insert( + aid, + std::sync::Arc::new(Asset { + page: 0, + id: aid, + name: format!("effects-{}.png", id.0), + mime: "image/png".into(), + link: None, + data: std::sync::Arc::new(img.to_png()), + pixels: Some((w, h)), + }), + ); + let mut img_item = Item::new(ItemId(out.alloc()), it.layer, Shape::Rectangle, designcraft_geom::shapes::rectangle(reach)); + img_item.content = Content::Graphic(Graphic { + asset: aid, + size: (w as f64, h as f64), + xf: designcraft_geom::Affine::translate((reach.x0, reach.y0)) * designcraft_geom::Affine::scale(1.0 / k), + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + }); + img_item.stroke.weight = 0.0; + img_item.alt_text = it.alt_text.clone(); + // Same place in the stacking order. + let osp = std::sync::Arc::make_mut(&mut out.spreads[si]); + if let Some(pos) = osp.items.iter().position(|x| x.id == id) { + osp.items[pos] = std::sync::Arc::new(img_item); + n += 1; + } + } + (Some(out), n) +} + /// A copy of `d` where placed PDFs with Object Layer Options hidden layers are 300 ppi images. fn rasterize_layered(d: &designcraft_doc::Document) -> (Option, usize) { let ids: Vec = @@ -286,7 +377,9 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result { // Placed PDFs with hidden layers go out as images showing just their visible layers. let base: &designcraft_doc::Document = flattened.as_ref().map_or(&st.doc, |f| &f.0); let (layered, layer_count) = rasterize_layered(base); - let doc: &designcraft_doc::Document = layered.as_ref().unwrap_or(base); + let base2: &designcraft_doc::Document = layered.as_ref().unwrap_or(base); + let (fx_doc, fx_count) = rasterize_effects(base2, &s.cache); + let doc: &designcraft_doc::Document = fx_doc.as_ref().unwrap_or(base2); let r = designcraft_pdf::export_pdf_with_report(doc, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?; let mut r = r; // Transparency Blend Space: the page group of pages with transparency. @@ -307,6 +400,9 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result { None => r.warnings.push("page transitions couldn't be added to this PDF".into()), } } + if fx_count > 0 { + r.warnings.push(format!("{fx_count} object(s) with soft effects (shadows, glows, feathers, bevels) exported as images")); + } if layer_count > 0 { r.warnings.push(format!("{layer_count} placed PDF(s) with hidden layers exported as images")); } diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 6f92a4eec..d154e76a5 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -316,6 +316,93 @@ pub fn specs() -> Vec { ) } ), + cmd!( + "object.innerGlow", + "Inner Glow", + ["Object", "Effects"], + None, + "{on?: bool, opacity?, size?, choke? (%), center?: bool (glow from the centre), color?, ids?}", + has_selection, + |s, p| { + let p2 = p.clone(); + set_flag( + s, + p, + move |i| { + let e = &mut i.effects.inner_glow; + e.on = p2.get("on").and_then(Value::as_bool).unwrap_or(!e.on); + e.opacity = f64_or(&p2, "opacity", e.opacity as f64) as f32; + e.size = f64_or(&p2, "size", e.size); + e.choke = f64_or(&p2, "choke", e.choke); + e.center = p2.get("center").and_then(Value::as_bool).unwrap_or(e.center); + if let Some(c) = str_param(&p2, "color") { + e.color = c.into(); + } + }, + false, + ) + } + ), + cmd!( + "object.bevel", + "Bevel and Emboss", + ["Object", "Effects"], + None, + "{on?: bool, size?, depth? (%), angle?, globalLight?: bool, highlight?, highlightOpacity?, shadow?, shadowOpacity?, ids?}", + has_selection, + |s, p| { + let p2 = p.clone(); + set_flag( + s, + p, + move |i| { + let e = &mut i.effects.bevel; + e.on = p2.get("on").and_then(Value::as_bool).unwrap_or(!e.on); + e.size = f64_or(&p2, "size", e.size); + e.depth = f64_or(&p2, "depth", e.depth); + e.angle = f64_or(&p2, "angle", e.angle); + e.global_light = p2.get("globalLight").and_then(Value::as_bool).unwrap_or(e.global_light); + e.highlight_opacity = f64_or(&p2, "highlightOpacity", e.highlight_opacity as f64) as f32; + e.shadow_opacity = f64_or(&p2, "shadowOpacity", e.shadow_opacity as f64) as f32; + if let Some(c) = str_param(&p2, "highlight") { + e.highlight = c.into(); + } + if let Some(c) = str_param(&p2, "shadow") { + e.shadow = c.into(); + } + }, + false, + ) + } + ), + cmd!( + "object.satin", + "Satin", + ["Object", "Effects"], + None, + "{on?: bool, opacity?, angle?, distance?, size?, invert?: bool, color?, ids?}", + has_selection, + |s, p| { + let p2 = p.clone(); + set_flag( + s, + p, + move |i| { + let e = &mut i.effects.satin; + e.on = p2.get("on").and_then(Value::as_bool).unwrap_or(!e.on); + e.opacity = f64_or(&p2, "opacity", e.opacity as f64) as f32; + e.angle = f64_or(&p2, "angle", e.angle); + e.distance = f64_or(&p2, "distance", e.distance); + e.size = f64_or(&p2, "size", e.size); + e.invert = p2.get("invert").and_then(Value::as_bool).unwrap_or(e.invert); + if let Some(c) = str_param(&p2, "color") { + e.color = c.into(); + } + }, + 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) @@ -2030,6 +2117,46 @@ mod attributes_tests { mod fitting_tests { use super::*; + #[test] + fn inner_glow_bevel_and_satin_change_the_inside() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300]})).unwrap()["id"].clone(); + s.execute("object.fill", &json!({"swatch": "C=100 M=0 Y=0 K=0", "ids": [id]})).unwrap(); + let shot = |s: &Session| { + let d = s.doc().unwrap().doc.clone(); + let mut rr = designcraft_render::Renderer::new(); + rr.threads = 0; + rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap() + }; + let plain = shot(&s); + // Inner glow (paper colour, screened): the edge lightens, the centre doesn't. + s.execute("object.innerGlow", &json!({"ids": [id], "on": true, "size": 20})).unwrap(); + let g = shot(&s); + assert!(g.pixel(103, 200)[0] > plain.pixel(103, 200)[0] + 30, "edge lighter"); + assert_eq!(g.pixel(200, 200), plain.pixel(200, 200)); + s.execute("object.innerGlow", &json!({"ids": [id], "on": false})).unwrap(); + // Bevel: lit and shaded sides differ. + s.execute("object.bevel", &json!({"ids": [id], "on": true, "size": 20, "angle": 120, "globalLight": false})).unwrap(); + let b = shot(&s); + let (top_left, bottom_right) = (b.pixel(104, 104), b.pixel(296, 296)); + assert!(top_left[1] as i32 > bottom_right[1] as i32 + 30, "{top_left:?} {bottom_right:?}"); + s.execute("object.bevel", &json!({"ids": [id], "on": false})).unwrap(); + // Satin darkens somewhere inside. + s.execute("object.satin", &json!({"ids": [id], "on": true, "distance": 40, "size": 10})).unwrap(); + let st = shot(&s); + let darker = (110..290) + .step_by(10) + .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); + // 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}"); + let bytes = super::super::file::base64_decode(r["base64"].as_str().unwrap()); + assert!(String::from_utf8_lossy(&bytes).contains("/Subtype/Image")); + } + #[test] fn live_distribute_spreads_objects_without_resizing() { let mut s = Session::new(); diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 0dc318491..2589b43e3 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -11,6 +11,8 @@ //! `spread`/`choke` (percent of size) grow the silhouette instead of blurring it. The shadow's //! opacity rides on the filter colour's alpha. //! - Inner shadow: the inverse of the offset shape, blurred, clipped to the shape, on top. +//! - Inner glow, bevel and emboss, satin: built like the inner shadow (an offset inverse of the +//! shape, blurred and clipped) with screen / multiply blending; satin XORs two offset copies. //! - Feather: the object is masked by its shape inset by half the width and blurred, so the //! edge fades from ~0 at the path to full strength `width` inside. @@ -157,39 +159,150 @@ impl Renderer { ctx.pop_layer(); ctx.pop_layer(); } + if !it.path.is_closed() { + return; + } // Inner shadow, clipped to the shape. if e.inner_shadow.on - && it.path.is_closed() && let Some(c) = doc.resolve_color(&e.inner_shadow.color, 1.0) { let s = &e.inner_shadow; let (sigma, choke) = split(s.size, s.choke); let off = offset(doc.light_angle(s.angle, s.global_light), s.distance); - let paint = color_of(&c, s.opacity.clamp(0.0, 1.0)); + self.inner_edge(ctx, f, reach, bp, xf, off, sigma, choke, color_of(&c, s.opacity.clamp(0.0, 1.0)), Mix::Normal); + } + // Inner glow: from the edges inwards (or out from the centre), screened over the object. + if e.inner_glow.on + && let Some(c) = doc.resolve_color(&e.inner_glow.color, 1.0) + { + let g = &e.inner_glow; + let (sigma, choke) = split(g.size, g.choke); + let paint = color_of(&c, g.opacity.clamp(0.0, 1.0)); + if g.center { + self.with_filters(ctx, f, reach, sigma, |_, c, fr| { + c.set_transform(fr.view * xf); + c.push_clip_layer(bp); + c.set_transform(Affine::IDENTITY); + c.push_layer(None, Some(BlendMode::new(Mix::Screen, Compose::SrcOver)), None, None, None); + c.set_transform(fr.view); + c.push_layer(None, None, None, None, Some(blur(sigma))); + c.set_transform(fr.view * xf); + c.set_paint(paint); + c.fill_path(bp); + // Fade towards the edge: cut a band of the glow's size away. + c.set_transform(Affine::IDENTITY); + c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestOut)), None, None, None); + c.set_transform(fr.view * xf); + c.set_paint(peniko::Color::BLACK); + c.set_stroke(kurbo::Stroke::new((g.size - choke).max(0.5) * 2.0).with_join(kurbo::Join::Round)); + c.stroke_path(bp); + c.pop_layer(); + c.pop_layer(); + c.pop_layer(); + c.pop_layer(); + }); + } else { + self.inner_edge(ctx, f, reach, bp, xf, Vec2::ZERO, sigma, choke, paint, Mix::Screen); + } + } + // Satin: the shape shifted both ways along the angle, blurred, where exactly one covers. + if e.satin.on + && let Some(c) = doc.resolve_color(&e.satin.color, 1.0) + { + let st = &e.satin; + let off = offset(st.angle, st.distance / 2.0); + let sigma = st.size.max(0.0) / 2.0; + let paint = color_of(&c, st.opacity.clamp(0.0, 1.0)); + let invert = st.invert; self.with_filters(ctx, f, reach, sigma, |_, c, fr| { c.set_transform(fr.view * xf); c.push_clip_layer(bp); + c.set_transform(Affine::IDENTITY); + c.push_layer(None, Some(BlendMode::new(Mix::Multiply, Compose::SrcOver)), None, None, None); c.set_transform(fr.view); c.push_layer(None, None, None, None, Some(blur(sigma))); - // Everything outside the offset shape, so the shadow bleeds in from the edges. - let m = Affine::translate(off) * xf; - let pad = sigma * 4.0 + choke + off.hypot() + 4.0 * fr.px; - let mut inv = m.transform_rect_bbox(bp.bounding_box()).inflate(pad, pad).to_path(0.1); - let mut shape = bp.clone(); - shape.apply_affine(m); - inv.extend(shape.iter()); - c.set_paint(paint); - c.set_fill_rule(peniko::Fill::EvenOdd); - c.fill_path(&inv); - c.set_fill_rule(peniko::Fill::NonZero); - if choke > 0.0 { - c.set_stroke(kurbo::Stroke::new(choke * 2.0).with_join(kurbo::Join::Round)); - c.stroke_path(&shape); + if invert { + c.set_transform(fr.view * xf); + c.set_paint(paint); + c.fill_path(bp); } + c.set_transform(Affine::IDENTITY); + c.push_layer(None, Some(BlendMode::new(Mix::Normal, if invert { Compose::DestOut } else { Compose::SrcOver })), None, None, None); + c.set_transform(fr.view * Affine::translate(off) * xf); + c.set_paint(paint); + c.fill_path(bp); + c.set_transform(Affine::IDENTITY); + c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::Xor)), None, None, None); + c.set_transform(fr.view * Affine::translate(-off) * xf); + c.fill_path(bp); + c.pop_layer(); + c.pop_layer(); + c.pop_layer(); c.pop_layer(); c.pop_layer(); }); } + // Bevel and Emboss (inner bevel): highlight from the lit side, shadow from the other. + if e.bevel.on { + let b = &e.bevel; + let off = offset(doc.light_angle(b.angle, b.global_light), b.size * (b.depth / 100.0).clamp(0.01, 10.0) * 0.5); + let sigma = b.size.max(0.0) / 2.0; + if let Some(c) = doc.resolve_color(&b.highlight, 1.0) { + // The inverse shape moved away from the light lights the edges facing it. + self.inner_edge(ctx, f, reach, bp, xf, off, sigma, 0.0, color_of(&c, b.highlight_opacity.clamp(0.0, 1.0)), Mix::Screen); + } + if let Some(c) = doc.resolve_color(&b.shadow, 1.0) { + self.inner_edge(ctx, f, reach, bp, xf, -off, sigma, 0.0, color_of(&c, b.shadow_opacity.clamp(0.0, 1.0)), Mix::Multiply); + } + } + } + + /// An inner shadow-like edge: everything outside the shape moved by `off`, blurred by + /// `sigma`, clipped to the shape and blended with `mix`. + #[allow(clippy::too_many_arguments)] + fn inner_edge( + &mut self, + ctx: &mut RenderContext, + f: &Frame, + reach: Rect, + bp: &BezPath, + xf: Affine, + off: Vec2, + sigma: f64, + choke: f64, + paint: peniko::Color, + mix: Mix, + ) { + self.with_filters(ctx, f, reach, sigma, |_, c, fr| { + c.set_transform(fr.view * xf); + c.push_clip_layer(bp); + if mix != Mix::Normal { + c.set_transform(Affine::IDENTITY); + c.push_layer(None, Some(BlendMode::new(mix, Compose::SrcOver)), None, None, None); + } + c.set_transform(fr.view); + c.push_layer(None, None, None, None, Some(blur(sigma))); + // Everything outside the offset shape, so the colour bleeds in from the edges. + let m = Affine::translate(off) * xf; + let pad = sigma * 4.0 + choke + off.hypot() + 4.0 * fr.px; + let mut inv = m.transform_rect_bbox(bp.bounding_box()).inflate(pad, pad).to_path(0.1); + let mut shape = bp.clone(); + shape.apply_affine(m); + inv.extend(shape.iter()); + c.set_paint(paint); + c.set_fill_rule(peniko::Fill::EvenOdd); + c.fill_path(&inv); + c.set_fill_rule(peniko::Fill::NonZero); + if choke > 0.0 { + c.set_stroke(kurbo::Stroke::new(choke * 2.0).with_join(kurbo::Join::Round)); + c.stroke_path(&shape); + } + c.pop_layer(); + if mix != Mix::Normal { + c.pop_layer(); + } + c.pop_layer(); + }); } /// Run `draw`, which pushes filter layers. Single-threaded contexts draw directly. On a diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 09dc1be67..03df7c417 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -24,6 +24,11 @@ pub use vello_cpu; pub mod damage; mod fx; + +/// How far (points) an object's soft effects reach outside its geometry. +pub fn effect_outset(it: &Item) -> f64 { + fx::outset(it) +} pub mod glyphs; mod pdf_layers; pub use pdf_layers::{pdf_hide_layers, pdf_layers}; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index aa5b2c2af..04415ed9c 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -442,6 +442,9 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:object.dropShadow", "cmd:object.innerShadow", "cmd:object.outerGlow", + "cmd:object.innerGlow", + "cmd:object.bevel", + "cmd:object.satin", "cmd:object.feather", "<", ">Select", diff --git a/docs/parity.md b/docs/parity.md index 3b3a47959..958247801 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 | 45 | 31 | 0 | 80% | +| P2 | 76 | 46 | 30 | 0 | 80% | | 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 | 75% | +| Effects & transparency | 79% | | Placing & links | 91% | | Type & text frames | 99% | | Typography | 98% | @@ -161,7 +161,7 @@ Open P0 items: Vector/page formats (PDF/AI/SVG/EPS place), Export to PDF (print) | Effects & transparency | Opacity & blend modes | P0 | D | | 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 | P | +| Effects & transparency | Inner Shadow, Outer Glow, Inner Glow, Bevel, Satin | P2 | D | | Effects & transparency | Basic/Directional/Gradient Feather | P2 | P | | Effects & transparency | Global Light | P2 | D | | Effects & transparency | Transparency blend space | P1 | P |