diff --git a/ROADMAP.md b/ROADMAP.md index d33353ca..6c17f443 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12** · Clipping paths saved with images in IDML (Detect Edges, Alpha Channel, Photoshop Path, User-Modified Path, inverted or not) clip the image on screen and in PDF and survive IDML export; `object.clippingPathOptions` reports, inverts or removes one (#450). No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/apps/designcraft-cli/src/perf.rs b/apps/designcraft-cli/src/perf.rs index 7749f644..af93a125 100644 --- a/apps/designcraft-cli/src/perf.rs +++ b/apps/designcraft-cli/src/perf.rs @@ -246,6 +246,7 @@ pub fn synthetic(spec: &Spec) -> Result { auto_fit: designcraft_doc::Fitting::FillProportionally, fit_align: 4, crop: [0.0; 4], + clip: None, }); if k == 0 && pi == 0 { it.effects.drop_shadow.on = true; diff --git a/crates/doc/src/assets.rs b/crates/doc/src/assets.rs index 24ba5c24..a118bbc0 100644 --- a/crates/doc/src/assets.rs +++ b/crates/doc/src/assets.rs @@ -289,6 +289,7 @@ mod tests { auto_fit: Default::default(), fit_align: 4, crop: [crop; 4], + clip: None, }); it } diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 5cfe05fe..2c98ba44 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -455,12 +455,101 @@ pub struct Graphic { /// Frame Fitting Options › Crop Amount: top, left, bottom, right (negative adds space). #[serde(default)] pub crop: [f64; 4], + /// Clipping path: only the part of the graphic inside it shows. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub clip: Option>, } fn center_ref() -> u8 { 4 } +/// Most anchors a clipping path read from a file keeps (detected edges can be dense). +pub const MAX_CLIP_ANCHORS: usize = 100_000; + +/// Clipping Path › Type: where the clipping path came from. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ClippingKind { + /// Traced from the image's edges against a light background. + DetectEdges, + /// Traced from one of the image's alpha channels. + AlphaChannel, + /// A path saved in the image file. + PhotoshopPath, + /// One of the above, edited by hand. + UserModifiedPath, +} + +/// A placed graphic's clipping path. The tracing options are kept as the file had them; the +/// outline itself is `path`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClippingPath { + pub kind: ClippingKind, + /// The outline in graphic space ((0,0)-(w,h) is the graphic's natural rectangle). + pub path: PathData, + /// Show the graphic outside the path instead of inside it. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub invert: bool, + /// Holes inside the traced shape are part of the path. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub include_inside_edges: bool, + /// Detect Edges: trace only the part of the image the frame shows. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub restrict_to_frame: bool, + /// Trace the full-resolution image rather than the screen proxy. + #[serde(default = "yes")] + pub use_high_resolution: bool, + /// Detect Edges / Alpha Channel: the darkness or opacity level that counts as inside (0–255). + #[serde(default = "clip_threshold")] + pub threshold: f64, + /// How closely the traced path follows the pixels, in pixels. + #[serde(default = "clip_tolerance")] + pub tolerance: f64, + /// Inset Frame: how far the path is pulled in (points; negative pushes it out). + #[serde(default)] + pub inset: f64, + /// The alpha channel or image path the clip came from. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub source_name: String, + /// Index of the alpha channel (-1 = none). + #[serde(default = "no_index")] + pub source_index: i32, +} + +fn yes() -> bool { + true +} +fn clip_threshold() -> f64 { + 25.0 +} +fn clip_tolerance() -> f64 { + 2.0 +} +fn no_index() -> i32 { + -1 +} + +impl ClippingPath { + /// A clipping path of `kind` with the default options. + pub fn new(kind: ClippingKind, path: PathData) -> Self { + Self { + kind, + path, + invert: false, + include_inside_edges: false, + restrict_to_frame: false, + use_high_resolution: true, + threshold: clip_threshold(), + tolerance: clip_tolerance(), + inset: 0.0, + source_name: String::new(), + source_index: no_index(), + } + } +} + /// Liquid Layout object rules: which edges stay at their distance from the page edge, and /// whether the object may stretch. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -614,6 +703,24 @@ fn is_zero_usize(v: &usize) -> bool { } impl Graphic { + /// The region the clipping path lets through, in frame inner space, and whether it is filled + /// even-odd (an inverted clip: the graphic's rectangle minus the path). `None` when the graphic + /// has no clipping path or the path is empty. + pub fn clip_region(&self) -> Option<(designcraft_geom::BezPath, bool)> { + use designcraft_geom::Shape; + let c = self.clip.as_ref().filter(|c| !c.path.is_empty())?; + let mut path = c.path.to_bezpath(); + if !c.invert { + path.apply_affine(self.xf); + return Some((path, false)); + } + let outer = Rect::new(0.0, 0.0, self.size.0, self.size.1).union(path.bounding_box()).inflate(1.0, 1.0); + let mut region = outer.to_path(0.1); + region.extend(path.iter()); + region.apply_affine(self.xf); + Some((region, true)) + } + /// The graphic transform that fits it into `frame` (the frame's inner rect) with `mode`, /// honouring the crop amounts and the reference point. pub fn fitted(&self, frame: Rect, mode: Fitting) -> Option { @@ -1192,6 +1299,27 @@ mod tests { use super::*; use designcraft_geom::shapes; + #[test] + fn graphics_without_a_clipping_path_load_and_save_as_before() { + let g = Graphic { + asset: AssetId(3), + size: (40.0, 30.0), + xf: Affine::translate((5.0, 5.0)), + auto_fit: Fitting::None, + fit_align: 4, + crop: [0.0; 4], + clip: None, + }; + let v = serde_json::to_value(&g).unwrap(); + assert!(v.get("clip").is_none(), "{v}"); + assert_eq!(serde_json::from_value::(v.clone()).unwrap(), g); + // A clip written with only its type and path gets the default options. + let mut v = v; + v["clip"] = serde_json::json!({"kind": "alphaChannel", "path": {"subpaths": []}}); + let c = serde_json::from_value::(v).unwrap().clip.unwrap(); + assert_eq!(*c, ClippingPath::new(ClippingKind::AlphaChannel, PathData::default())); + } + #[test] fn bounds_and_text_area() { let mut it = Item::new(ItemId(1), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0))); diff --git a/crates/engine/src/cmd/datamerge/fill.rs b/crates/engine/src/cmd/datamerge/fill.rs index 89dd0c74..64382617 100644 --- a/crates/engine/src/cmd/datamerge/fill.rs +++ b/crates/engine/src/cmd/datamerge/fill.rs @@ -381,7 +381,8 @@ fn place_image( pixels: Some((pw, ph)), page: 0, }); - let mut g = Graphic { asset: aid, size: (nw, nh), xf: Affine::translate((inner.x0, inner.y0)), auto_fit: mode, fit_align: 0, crop: [0.0; 4] }; + let mut g = + Graphic { asset: aid, size: (nw, nh), xf: Affine::translate((inner.x0, inner.y0)), auto_fit: mode, fit_align: 0, crop: [0.0; 4], clip: None }; if let Some(xf) = g.fitted(inner, mode) { g.xf = xf; } diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index f4786385..2b842f78 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -267,6 +267,7 @@ fn flatten(d: &designcraft_doc::Document, cache: &designcraft_compose::Cache, pp auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], + clip: None, }); it.stroke.weight = 0.0; it @@ -432,6 +433,7 @@ fn rasterize_where( auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], + clip: None, }); img_item.stroke.weight = 0.0; img_item.alt_text = it.alt_text.clone(); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index ed126eee..bc144433 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -361,6 +361,7 @@ fn file_place(s: &mut Session, p: &Value) -> Result { auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], + clip: None, }); fid } else { @@ -379,6 +380,7 @@ fn file_place(s: &mut Session, p: &Value) -> Result { auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], + clip: None, }); d.insert_item(spread, it, None)?; id @@ -472,6 +474,7 @@ fn place_drop(s: &mut Session, p: &Value) -> Result { auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], + clip: None, }); fid } @@ -494,6 +497,7 @@ fn place_drop(s: &mut Session, p: &Value) -> Result { auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], + clip: None, }); d.insert_item(sr, it, None)?; id diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 73de3ea7..2e465f3b 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -420,6 +420,7 @@ mod shared_asset_tests { auto_fit: Default::default(), fit_align: 4, crop: [1.0; 4], + clip: None, }); src.insert_item(SpreadRef::Doc(0), it, None).unwrap(); let ids = super::super::object::duplicate_from(&mut dst, &src, &[iid], SpreadRef::Doc(0), designcraft_geom::Vec2::ZERO).unwrap(); diff --git a/crates/engine/src/cmd/mathexpr.rs b/crates/engine/src/cmd/mathexpr.rs index ec90d52e..49edc6e3 100644 --- a/crates/engine/src/cmd/mathexpr.rs +++ b/crates/engine/src/cmd/mathexpr.rs @@ -26,8 +26,15 @@ fn math_item(d: &mut designcraft_doc::Document, latex: &str, size: f64, lid: des let id = ItemId(d.alloc()); let r = Rect::new(at.0, at.1, at.0 + w, at.1 + h); let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r)); - it.content = - Content::Graphic(Graphic { asset: aid, size: (w, h), xf: Affine::translate(at), auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4] }); + it.content = Content::Graphic(Graphic { + asset: aid, + size: (w, h), + xf: Affine::translate(at), + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + clip: None, + }); it.alt_text = latex.to_string(); it.name = "Math".into(); it diff --git a/crates/engine/src/cmd/media.rs b/crates/engine/src/cmd/media.rs index b6e4cd4b..b90a9297 100644 --- a/crates/engine/src/cmd/media.rs +++ b/crates/engine/src/cmd/media.rs @@ -38,6 +38,7 @@ pub(crate) fn place_media(s: &mut Session, p: &Value, name: String, mime: &str, auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], + clip: None, }); it.media = Some(MediaOptions { controls: true, ..Default::default() }); fid @@ -54,6 +55,7 @@ pub(crate) fn place_media(s: &mut Session, p: &Value, name: String, mime: &str, auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], + clip: None, }); it.media = Some(MediaOptions { controls: true, ..Default::default() }); d.insert_item(spread, it, None)?; diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index cd13be11..be732782 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -679,6 +679,15 @@ pub fn specs() -> Vec { has_selection, clipping_path ), + cmd!( + "object.clippingPathOptions", + "Clipping Path Options…", + ["Object", "Clipping Path"], + None, + "{type?: none, invert?: bool, includeInsideEdges?: bool, ids?} — without type, invert or includeInsideEdges, reports each selected graphic's clipping path ({items: [{id, type: none|detectEdges|alphaChannel|photoshopPath|userModifiedPath, anchors, invert, includeInsideEdges, threshold, tolerance, inset, sourceName}]}); type none removes it, invert shows the graphic outside it instead of inside", + has_selection, + clipping_path_options + ), cmd!( "object.primaryTextFrame", "Primary Text Frame", @@ -1701,6 +1710,63 @@ fn clipping_path(s: &mut Session, p: &Value) -> Result { }) } +/// Clipping Path Options: report a graphic's clipping path, remove it, or change its options. +fn clipping_path_options(s: &mut Session, p: &Value) -> Result { + const ID: &str = "object.clippingPathOptions"; + let ids = targets(s, p)?; + let remove = match str_param(p, "type") { + None => false, + Some("none") => true, + Some(t) => return Err(bad(ID, format!("type `{t}` can't be set here (only `none`); object.clippingPath traces an outline"))), + }; + let flag = |key: &str| p.get(key).map(|v| v.as_bool().ok_or_else(|| bad(ID, format!("{key} must be true or false")))).transpose(); + let (invert, inside) = (flag("invert")?, flag("includeInsideEdges")?); + let graphics: Vec = { + let d = &s.doc()?.doc; + ids.into_iter().filter(|id| d.item(*id).and_then(Item::graphic).is_some()).collect() + }; + if graphics.is_empty() { + return Err(bad(ID, "select a frame with a placed graphic")); + } + if !remove && invert.is_none() && inside.is_none() { + let d = &s.doc()?.doc; + let items: Vec = graphics + .iter() + .filter_map(|id| d.item(*id).and_then(Item::graphic).map(|g| (id, g))) + .map(|(id, g)| match &g.clip { + None => json!({"id": id.0, "type": "none"}), + Some(c) => json!({ + "id": id.0, + "type": serde_json::to_value(c.kind).ok(), + "anchors": c.path.anchor_count(), + "invert": c.invert, + "includeInsideEdges": c.include_inside_edges, + "threshold": c.threshold, + "tolerance": c.tolerance, + "inset": c.inset, + "sourceName": c.source_name, + }), + }) + .collect(); + return Ok(json!({"items": items})); + } + s.edit(|d, _| { + let mut n = 0; + for id in &graphics { + let Some(Content::Graphic(g)) = d.item_mut(*id).map(|it| &mut it.content) else { continue }; + if remove { + n += usize::from(g.clip.take().is_some()); + } else if let Some(c) = &mut g.clip { + let c = Arc::make_mut(c); + c.invert = invert.unwrap_or(c.invert); + c.include_inside_edges = inside.unwrap_or(c.include_inside_edges); + n += 1; + } + } + Ok(json!({"changed": n})) + }) +} + fn fitting_mode(s: &str) -> Option { use designcraft_doc::Fitting as F; Some(match s { @@ -2565,6 +2631,39 @@ mod clipping_tests { assert_eq!(it.shape, Shape::Path); assert!(s.execute("object.clippingPath", &json!({"ids": [f], "threshold": 255})).is_err()); } + + #[test] + fn clipping_path_options_report_invert_and_remove_the_clip() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let png = designcraft_render::Rendered { width: 4, height: 4, pixels: vec![255; 64] }.to_png(); + let f = s.execute("frame.create", &json!({"rect": [100, 100, 140, 140]})).unwrap()["id"].as_u64().unwrap(); + s.execute("file.place", &json!({"base64": super::super::file::base64_encode(&png), "name": "white.png", "frame": f})).unwrap(); + let get = |s: &mut Session| s.execute("object.clippingPathOptions", &json!({"ids": [f]})).unwrap()["items"][0].clone(); + assert_eq!(get(&mut s)["type"], "none"); + // As an imported file would carry it. + let triangle = designcraft_geom::PathData::single(designcraft_geom::path::SubPath::polyline( + &[Point::new(0.0, 4.0), Point::new(2.0, 0.0), Point::new(4.0, 4.0)], + true, + )); + s.edit(|d, _| { + if let Some(Content::Graphic(g)) = d.item_mut(ItemId(f)).map(|it| &mut it.content) { + g.clip = Some(Arc::new(designcraft_doc::ClippingPath::new(designcraft_doc::ClippingKind::PhotoshopPath, triangle))); + } + Ok(json!({})) + }) + .unwrap(); + let c = get(&mut s); + assert_eq!((c["type"].as_str(), c["anchors"].as_u64(), c["invert"].as_bool()), (Some("photoshopPath"), Some(3), Some(false))); + s.execute("object.clippingPathOptions", &json!({"ids": [f], "invert": true})).unwrap(); + assert_eq!(get(&mut s)["invert"], true); + assert_eq!(s.execute("object.clippingPathOptions", &json!({"ids": [f], "type": "none"})).unwrap()["changed"], 1); + assert_eq!(get(&mut s)["type"], "none"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(get(&mut s)["type"], "photoshopPath"); + assert!(s.execute("object.clippingPathOptions", &json!({"ids": [f], "type": "alphaChannel"})).is_err()); + assert!(s.execute("object.clippingPathOptions", &json!({"ids": [f], "invert": "yes"})).is_err()); + } } #[cfg(test)] diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index c2313170..b6038bf2 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -1426,6 +1426,7 @@ fn place_graphic(s: &mut Session, p: &Value) -> Result { auto_fit: if fill { designcraft_doc::Fitting::FillProportionally } else { designcraft_doc::Fitting::FitProportionally }, fit_align: 4, crop: [0.0; 4], + clip: None, }); clamp_selection(d, sel); Ok(json!({"asset": aid.0, "row": r, "col": c})) diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs index 15a05d08..ae47b32a 100644 --- a/crates/engine/src/sample.rs +++ b/crates/engine/src/sample.rs @@ -85,6 +85,7 @@ fn graphic_frame(d: &mut Document, sr: SpreadRef, r: Rect, asset: AssetId, px: ( auto_fit: designcraft_doc::Fitting::FillProportionally, fit_align: 4, crop: [0.0; 4], + clip: None, }); it.object_style = designcraft_doc::BASIC_GRAPHICS_FRAME.into(); d.insert_item(sr, it, None)?; diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index 643f5934..d8f1de2b 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -850,6 +850,7 @@ mod tests { auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], + clip: None, }); d.insert_item(SpreadRef::Doc(0), item, None).unwrap(); diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 408bb3cc..5e43a5aa 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -251,6 +251,7 @@ mod tests { auto_fit: Default::default(), fit_align: 4, crop: [crop; 4], + clip: None, }); d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); id diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 9d73562b..0512f091 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1779,6 +1779,9 @@ impl<'a> Ex<'a> { } props.push(El::new("GraphicBounds").attr("Left", "0").attr("Top", "0").attr("Right", num(g.size.0)).attr("Bottom", num(g.size.1))); let mut el = with_props(el, props); + if let Some(c) = &g.clip { + el.push(clipping_settings(c)); + } let uri = match &asset.link { Some(l) => path_to_uri(l), None => format!("file:{}", asset.name), @@ -2510,6 +2513,25 @@ fn text_frame_pref(o: &TextFrameOptions, rule_color: &str) -> El { with_props(el, vec![inset]) } +/// `` with the outline (graphic space is the image's inner space, whose +/// `GraphicBounds` start at 0,0). +fn clipping_settings(c: &designcraft_doc::ClippingPath) -> El { + let mut geometry = path_geometry(&c.path); + geometry.name = "ClippingPathGeometry".into(); + let el = El::new("ClippingPathSettings") + .attr("ClippingType", names::clipping_kind_out(c.kind)) + .attr("InvertPath", bool_s(c.invert)) + .attr("IncludeInsideEdges", bool_s(c.include_inside_edges)) + .attr("RestrictToFrame", bool_s(c.restrict_to_frame)) + .attr("UseHighResolutionImage", bool_s(c.use_high_resolution)) + .attr("Threshold", num(c.threshold)) + .attr("Tolerance", num(c.tolerance)) + .attr("InsetFrame", num(c.inset)) + .attr("AppliedPathName", if c.source_name.is_empty() { "$ID/" } else { c.source_name.as_str() }) + .attr("Index", c.source_index.to_string()); + with_props(el, vec![geometry]) +} + fn path_geometry(path: &PathData) -> El { let mut pg = El::new("PathGeometry"); for sp in &path.subpaths { diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 46c3253b..9a6aad9c 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -2268,6 +2268,7 @@ impl<'r> Importer<'r> { None => (0.0, 0.0, 0.0, 0.0), }; let size = ((r - l).abs(), (b - t).abs()); + let clip = clipping_path(g, Affine::translate((-l, -t))); let link = g.find("Link"); let uri = link.and_then(|k| k.get("LinkResourceURI")).map(uri_to_path); let graphic = |asset| { @@ -2278,6 +2279,7 @@ impl<'r> Importer<'r> { auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], + clip: clip.clone(), }) }; let mut data = g.prop_el("Contents").map(|c| base64_decode(&c.text_content())).unwrap_or_default(); @@ -2598,19 +2600,53 @@ fn inset_spacing(e: &El) -> Option<[f64; 4]> { } } +/// A graphic's `ClippingPathSettings` with its saved geometry, moved into graphic space by +/// `to_graphic`. `None` for type None, an unknown type, or no geometry (the outline is never +/// traced here). +fn clipping_path(g: &El, to_graphic: Affine) -> Option> { + let cs = g.find("ClippingPathSettings")?; + let kind = names::clipping_kind_in(cs.get("ClippingType").unwrap_or("None"))?; + let geometry = cs.prop_el("ClippingPathGeometry").or_else(|| cs.find("ClippingPathGeometry")).or_else(|| g.prop_el("ClippingPathGeometry"))?; + let mut path = path_capped(geometry, designcraft_doc::MAX_CLIP_ANCHORS); + path.subpaths.retain(|sp| sp.anchors.len() > 1 && sp.anchors.iter().all(|a| a.p.is_finite() && a.h_in.is_finite() && a.h_out.is_finite())); + if path.is_empty() { + return None; + } + path.transform(to_graphic); + let finite = |v: Option, def: f64| v.filter(|v| v.is_finite()).unwrap_or(def); + let mut c = designcraft_doc::ClippingPath::new(kind, path); + c.invert = cs.boolean("InvertPath").unwrap_or(false); + c.include_inside_edges = cs.boolean("IncludeInsideEdges").unwrap_or(false); + c.restrict_to_frame = cs.boolean("RestrictToFrame").unwrap_or(false); + c.use_high_resolution = cs.boolean("UseHighResolutionImage").unwrap_or(true); + c.threshold = finite(cs.num("Threshold"), c.threshold).clamp(0.0, 255.0); + c.tolerance = finite(cs.num("Tolerance"), c.tolerance).clamp(0.0, 10.0); + c.inset = finite(cs.num("InsetFrame"), 0.0); + c.source_name = cs.get("AppliedPathName").filter(|n| *n != "$ID/").unwrap_or("").to_string(); + c.source_index = cs.num("Index").filter(|v| v.is_finite()).map_or(-1, |v| v.clamp(-1.0, i32::MAX as f64) as i32); + Some(Arc::new(c)) +} + fn path_of(pg: &El) -> PathData { + path_capped(pg, usize::MAX) +} + +/// The path of a `…Geometry` element, keeping at most `max` anchors. +fn path_capped(pg: &El, max: usize) -> PathData { let mut subs = Vec::new(); + let mut left = max; for g in pg.find_all("GeometryPathType") { let closed = g.get("PathOpen") != Some("true"); let mut anchors = Vec::new(); if let Some(arr) = g.find("PathPointArray") { - for p in arr.find_all("PathPointType") { + for p in arr.find_all("PathPointType").take(left) { let Some(a) = parse_point(p.get("Anchor")) else { continue }; let hin = parse_point(p.get("LeftDirection")).unwrap_or(a); let hout = parse_point(p.get("RightDirection")).unwrap_or(a); anchors.push(Anchor::with_handles(a, hin, hout)); } } + left = left.saturating_sub(anchors.len()); if !anchors.is_empty() { subs.push(SubPath::new(anchors, closed)); } diff --git a/crates/idml/src/names.rs b/crates/idml/src/names.rs index 1df0f56d..4ebe29ba 100644 --- a/crates/idml/src/names.rs +++ b/crates/idml/src/names.rs @@ -2,8 +2,8 @@ use designcraft_color::BlendMode; use designcraft_doc::{ - Align, Arrowhead, AutoSize, Cap, Capitalization, FirstBaseline, Join, Leading, NumberStyle, Position, StartParagraph, StrokeAlign, StrokeType, - TabAlign, VerticalJustification, WrapMode, WrapSide, + Align, Arrowhead, AutoSize, Cap, Capitalization, ClippingKind, FirstBaseline, Join, Leading, NumberStyle, Position, StartParagraph, StrokeAlign, + StrokeType, TabAlign, VerticalJustification, WrapMode, WrapSide, }; use designcraft_geom::Unit; use designcraft_geom::corners::CornerShape; @@ -378,6 +378,20 @@ pub fn wrap_mode_out(m: WrapMode) -> &'static str { WrapMode::JumpToNextColumn => "NextColumnTextWrap", } } +pub fn clipping_kind_out(k: ClippingKind) -> &'static str { + match k { + ClippingKind::DetectEdges => "DetectEdges", + ClippingKind::AlphaChannel => "AlphaChannel", + ClippingKind::PhotoshopPath => "PhotoshopPath", + ClippingKind::UserModifiedPath => "UserModifiedPath", + } +} +/// `None` for `ClippingType="None"` and unknown types. +pub fn clipping_kind_in(s: &str) -> Option { + [ClippingKind::DetectEdges, ClippingKind::AlphaChannel, ClippingKind::PhotoshopPath, ClippingKind::UserModifiedPath] + .into_iter() + .find(|k| clipping_kind_out(*k) == s) +} pub fn wrap_mode_in(s: &str) -> WrapMode { [WrapMode::BoundingBox, WrapMode::Contour, WrapMode::JumpObject, WrapMode::JumpToNextColumn] .into_iter() diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 23ec99bd..3d18dbc8 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -8,6 +8,7 @@ use designcraft_geom::{Rect, shapes}; use super::*; mod assets; +mod clipping; mod decorations; fn zip_files(files: &[(&str, &str)]) -> Vec { diff --git a/crates/idml/src/tests/clipping.rs b/crates/idml/src/tests/clipping.rs new file mode 100644 index 00000000..02f02437 --- /dev/null +++ b/crates/idml/src/tests/clipping.rs @@ -0,0 +1,103 @@ +//! Image clipping paths: `ClippingPathSettings` with saved geometry. + +use std::sync::Arc; + +use designcraft_doc::{ClippingKind, ClippingPath, Content, Graphic}; +use designcraft_geom::Point; + +use super::*; + +/// A 40×30 frame showing an image whose inner space is centred on its origin (GraphicBounds +/// -20,-15 … 20,15), clipped by a triangle with its apex at the top middle. +fn package(settings: &str) -> Vec { + let spread = format!( + r#" + + + + + + + + + + {settings} + + + + +"# + ); + zip_files(&[ + ("designmap.xml", DESIGNMAP), + ("Resources/Graphic.xml", GRAPHIC), + ("Resources/Styles.xml", STYLES), + ("Resources/Preferences.xml", PREFS), + ("MasterSpreads/MasterSpread_m1.xml", MASTER), + ("Spreads/Spread_sp1.xml", &spread), + ("Stories/Story_s1.xml", STORY), + ]) +} + +const TRIANGLE: &str = r#" + + + + + + + + + + + "#; + +fn graphic(d: &Document) -> Graphic { + d.spreads[0].items.iter().find_map(|i| i.graphic()).unwrap().clone() +} + +fn anchors(c: &ClippingPath) -> Vec { + c.path.subpaths.iter().flat_map(|s| s.anchors.iter().map(|a| a.p)).collect() +} + +#[test] +fn imports_the_saved_clipping_path_in_graphic_space() { + let d = import_idml(&package(TRIANGLE)).unwrap(); + let g = graphic(&d); + let c = g.clip.as_ref().unwrap(); + assert_eq!(c.kind, ClippingKind::PhotoshopPath); + assert_eq!(c.source_name, "Path 1"); + // The subpath with a NaN anchor is dropped; the triangle moves by -GraphicBounds' top left. + assert_eq!(anchors(c), [Point::new(0.0, 30.0), Point::new(20.0, 0.0), Point::new(40.0, 30.0)]); + // In the frame: the image fills it, so the apex is at the top middle. + let (region, even_odd) = g.clip_region().unwrap(); + assert!(!even_odd); + let b = designcraft_geom::Shape::bounding_box(®ion); + assert!((b.x0 - 0.0).abs() < 1e-9 && (b.x1 - 40.0).abs() < 1e-9 && b.y0.abs() < 1e-9 && (b.y1 - 30.0).abs() < 1e-9, "{b:?}"); + + // Type None, or no geometry: no clipping path. + let none = TRIANGLE.replace("PhotoshopPath", "None"); + assert!(graphic(&import_idml(&package(&none)).unwrap()).clip.is_none()); + assert!(graphic(&import_idml(&package(r#""#)).unwrap()).clip.is_none()); +} + +#[test] +fn clipping_path_round_trips() { + let mut d = import_idml(&package(&TRIANGLE.replace(r#"InvertPath="false""#, r#"InvertPath="true""#))).unwrap(); + // A user-modified path with a threshold survives too. + let id = d.spreads[0].items.iter().find(|i| i.graphic().is_some()).unwrap().id; + if let Content::Graphic(g) = &mut d.item_mut(id).unwrap().content { + let c = Arc::make_mut(g.clip.as_mut().unwrap()); + c.kind = ClippingKind::UserModifiedPath; + c.threshold = 40.0; + } + let before = graphic(&d); + let back = graphic(&import_idml(&export_idml(&d)).unwrap()); + let (a, b) = (before.clip.clone().unwrap(), back.clip.clone().unwrap()); + assert!(b.invert); + assert_eq!(b.kind, ClippingKind::UserModifiedPath); + assert_eq!(b.threshold, 40.0); + assert_eq!(anchors(&a), anchors(&b)); + assert!(back.clip_region().unwrap().1, "an inverted clip is filled even-odd"); +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index dcf8abb8..a8b3fac0 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -1275,7 +1275,25 @@ impl Exporter<'_> { img } + /// Draw a placed graphic, inside its clipping path when it has one. pub(crate) fn graphic(&mut self, s: &mut Surface, g: &designcraft_doc::Graphic) { + match g.clip_region().map(|(region, even_odd)| (to_path(®ion), even_odd)) { + Some((Some(clip), even_odd)) => { + s.push_clip_path(&clip, if even_odd { &FillRule::EvenOdd } else { &FillRule::NonZero }); + self.graphic_unclipped(s, g); + s.pop(); + } + // A degenerate outline shows nothing, or everything when inverted. + Some((None, even_odd)) => { + if even_odd { + self.graphic_unclipped(s, g); + } + } + None => self.graphic_unclipped(s, g), + } + } + + fn graphic_unclipped(&mut self, s: &mut Surface, g: &designcraft_doc::Graphic) { // Video / sound without a poster: a dark frame with a play mark. if let Some(asset) = self.doc.assets.get(&g.asset) && designcraft_doc::media_kind(&asset.mime).is_some() diff --git a/crates/pdf/src/pdfx.rs b/crates/pdf/src/pdfx.rs index 3218ffc7..2db22df8 100644 --- a/crates/pdf/src/pdfx.rs +++ b/crates/pdf/src/pdfx.rs @@ -480,6 +480,7 @@ mod tests { auto_fit: Fitting::FillProportionally, fit_align: 4, crop: [0.0; 4], + clip: None, }); d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); } diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 9ac53d05..6c304e5c 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -236,6 +236,7 @@ fn place(d: &mut Document, data: Vec, px: (u32, u32)) { auto_fit: designcraft_doc::Fitting::FillProportionally, fit_align: 4, crop: [0.0; 4], + clip: None, }); d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); } @@ -446,3 +447,39 @@ fn column_rules_are_drawn() { assert!((xs[0] - 214.0).abs() < 0.01 && (xs[1] - 398.0).abs() < 0.01, "{xs:?}"); assert!(rules(false).is_empty()); } + +/// A clipping path on a placed image becomes a clip in the page content, inside the frame's. +#[test] +fn clipping_path_clips_the_image() { + let png = |d: &mut Document| { + let mut b = Vec::new(); + image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255])) + .write_to(&mut std::io::Cursor::new(&mut b), image::ImageFormat::Png) + .unwrap(); + place(d, b, (4, 4)); + }; + let clips = |s: &str| s.lines().filter(|l| matches!(l.trim(), "W" | "W*")).count(); + let mut d = doc_with_text("x"); + png(&mut d); + let plain = uncompressed(&d, PdfOptions::default()); + let id = d.spreads[0].items.iter().find(|i| i.graphic().is_some()).unwrap().id; + let triangle = designcraft_geom::PathData::single(designcraft_geom::path::SubPath::polyline( + &[designcraft_geom::Point::new(0.0, 4.0), designcraft_geom::Point::new(2.0, 0.0), designcraft_geom::Point::new(4.0, 4.0)], + true, + )); + let mut clip = designcraft_doc::ClippingPath::new(designcraft_doc::ClippingKind::DetectEdges, triangle); + let set = |d: &mut Document, c: &designcraft_doc::ClippingPath| { + if let Some(designcraft_doc::Content::Graphic(g)) = d.item_mut(id).map(|it| &mut it.content) { + g.clip = Some(std::sync::Arc::new(c.clone())); + } + }; + set(&mut d, &clip); + let clipped = uncompressed(&d, PdfOptions::default()); + assert_eq!(clips(&clipped), clips(&plain) + 1, "{clipped}"); + // The triangle's apex: graphic (2, 0) → page (172, 72) → PDF y = page height − 72. + let apex_y = d.spreads[0].pages[0].height - 72.0; + assert!(clipped.contains(&format!("172 {apex_y} l")), "{clipped}"); + clip.invert = true; + set(&mut d, &clip); + assert!(uncompressed(&d, PdfOptions::default()).lines().any(|l| l.trim() == "W*"), "an inverted clip is even-odd"); +} diff --git a/crates/pdf/tests/support/image_content_cache_contract.rs b/crates/pdf/tests/support/image_content_cache_contract.rs index 6a4ac91f..3438380f 100644 --- a/crates/pdf/tests/support/image_content_cache_contract.rs +++ b/crates/pdf/tests/support/image_content_cache_contract.rs @@ -34,6 +34,7 @@ fn place_named(d: &mut Document, data: Vec, x: f64, name: &str, mime: &str, auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], + clip: None, }); d.insert_item(SpreadRef::Doc(0), item, None).unwrap(); } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 2cc5c548..4d038af5 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -722,7 +722,23 @@ impl Renderer { } } + /// Draw a placed graphic, inside its clipping path when it has one. pub(crate) fn draw_graphic(&mut self, ctx: &mut RenderContext, f: &Frame, g: &designcraft_doc::Graphic, xf: Affine) { + let Some((region, even_odd)) = g.clip_region() else { + self.draw_graphic_unclipped(ctx, f, g, xf); + return; + }; + ctx.set_transform(f.view * xf); + if even_odd { + ctx.set_fill_rule(peniko::Fill::EvenOdd); + } + ctx.push_clip_layer(®ion); + ctx.set_fill_rule(peniko::Fill::NonZero); + self.draw_graphic_unclipped(ctx, f, g, xf); + ctx.pop_layer(); + } + + fn draw_graphic_unclipped(&mut self, ctx: &mut RenderContext, f: &Frame, g: &designcraft_doc::Graphic, xf: Affine) { let Some(asset) = f.doc.assets.get(&g.asset) else { return }; if designcraft_doc::media_kind(&asset.mime).is_some() { // Video / sound: a dark frame with a play mark (the poster, when set, is drawn by the item). @@ -1456,6 +1472,7 @@ mod tests { auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], + clip: None, }); d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); let cache = Cache::new(); @@ -1469,6 +1486,63 @@ mod tests { assert_eq!(px(DisplayQuality::Fast, &mut r)[..3], [178, 178, 178]); } + #[test] + fn clipping_path_hides_the_image_outside_it() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let mut png = Vec::new(); + image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + let aid = designcraft_doc::AssetId(d.alloc()); + d.assets.insert( + aid, + Arc::new(designcraft_doc::Asset { + page: 0, + id: aid, + name: "red.png".into(), + mime: "image/png".into(), + link: None, + data: Arc::new(png), + pixels: Some((4, 4)), + }), + ); + // A 100 pt image at 100,100 clipped by a triangle with its apex at the top middle. + let triangle = designcraft_geom::PathData::single(designcraft_geom::path::SubPath::polyline( + &[designcraft_geom::Point::new(0.0, 100.0), designcraft_geom::Point::new(50.0, 0.0), designcraft_geom::Point::new(100.0, 100.0)], + true, + )); + let mut clip = designcraft_doc::ClippingPath::new(designcraft_doc::ClippingKind::PhotoshopPath, triangle); + let id = designcraft_doc::ItemId(d.alloc()); + let mut it = + Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0))); + it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + asset: aid, + size: (100.0, 100.0), + xf: Affine::translate((100.0, 100.0)), + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + clip: Some(Arc::new(clip.clone())), + }); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let cache = Cache::new(); + let mut r = Renderer::new(); + r.threads = 0; + let page = r.render_page(&d, &cache, 0, 1.0, false, &RenderOptions::default()).unwrap(); + assert_eq!(page.pixel(150, 170)[..3], [255, 0, 0], "inside the triangle"); + assert_eq!(page.pixel(110, 110)[..3], [255, 255, 255], "inside the frame, outside the triangle: paper"); + // Inverted: the corners show and the triangle is paper. + clip.invert = true; + if let Some(designcraft_doc::Content::Graphic(g)) = d.item_mut(id).map(|it| &mut it.content) { + g.clip = Some(Arc::new(clip)); + } + let cache = Cache::new(); + let page = r.render_page(&d, &cache, 0, 1.0, false, &RenderOptions::default()).unwrap(); + assert_eq!(page.pixel(150, 170)[..3], [255, 255, 255]); + assert_eq!(page.pixel(110, 110)[..3], [255, 0, 0]); + } + #[test] fn missing_fonts_are_highlighted_on_screen_only() { let mut d = Document::new(&NewDocument::default()); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d123ec4e..35b1d168 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -551,6 +551,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "<", "ui:app.qrCode", ">Clipping Path", + "cmd:object.clippingPathOptions|None|{\"type\": \"none\"}", "cmd:object.clippingPath|Alpha Channel|{\"type\": \"alpha\"}", "cmd:object.clippingPath|Detect Edges|{\"type\": \"edges\"}", "<", diff --git a/docs/gaps.md b/docs/gaps.md index 6f92c550..3dae7ac8 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DesignCraft falls short of InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; Windows CI passes) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (image clipping paths from IDML) · **Target:** Adobe InDesign 2026 (21.6) Every known shortfall against InDesign 2026, one entry each, ranked by how much it stops an InDesign professional from doing real work. This is the work list: agents pick from the top. @@ -178,7 +178,9 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · ### 19. Placed graphics depth (FF) - **Missing:** EPS is shown through its preview only (no PostScript interpreter); PSD layer - comps beyond visibility; Edit Original / Edit With; PDF placed as editable vectors (#227, #24); + comps beyond visibility; the Clipping Path Options dialog (tracing into a clip that keeps the + frame, Threshold/Tolerance/Inset Frame preview, picking an alpha channel or a path saved in a + TIFF/PSD); Text Wrap › Same As Clipping following an imported clipping path; Edit Original / Edit With; PDF placed as editable vectors (#227, #24); QuarkXPress import (#73). - **Estimate:** 30–60 h (PostScript interpreter is a large separate project: +80–150 h). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e9c27a9f..b196a734 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DesignCraft parity with Adobe InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; Windows CI passes) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (image clipping paths from IDML) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature @@ -336,7 +336,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Column rules (Text Frame Options › Column Rules, `object.textFrameOptions {columnRule, columnRuleWeight, columnRuleColor, columnRuleTint, columnRuleOffset, columnRuleTopInset, columnRuleBottomInset}`, reported by `document.inspect`): a bar centred in each gutter of a multi-column frame, inside the insets and between the columns of right-to-left and vertical frames too, broken around paragraphs that span columns, with weight, colour, tint, horizontal offset and top/bottom insets; on screen, in PDF (tagged as an artifact) and in IDML (`ColumnRule…` attributes of `TextFramePreference`, also in object styles). The dialog shows the frame's options and changes only the fields edited. Rule stroke types and overprint aren't there yet. - Place a page of an IDML or DesignCraft document (`file.place {layoutPage}`): its objects, stories, styles and swatches as one group (INDD files are not readable). - Articles panel (`article.new/add/remove/options/delete/list`): reading order and content of EPUB and HTML exports. -- Object ▸ Clipping Path (`object.clippingPath {type: alpha|edges, threshold, tolerance}`): traces the placed image and makes the outline the frame. +- Object ▸ Clipping Path (`object.clippingPath {type: alpha|edges, threshold, tolerance}`): traces the placed image and makes the outline the frame. Clipping paths saved in IDML (Detect Edges, Alpha Channel, Photoshop Path, User-Modified Path; Invert) hide the image outside the path on screen and in PDF and survive IDML export; `object.clippingPathOptions` reports, inverts or removes one (Object ▸ Clipping Path ▸ None). - Gridify: arrow keys while dragging a frame or shape tool add columns/rows (`frame.grid`), one undo step. - Gap tool (U, `gap.move`): drag the space between objects (or an object and the page edge) and both sides resize. - XML basics (`xml.newTag/deleteTag/tag/mapStyle/structure`, `file.exportXml`, `file.importXml`; Tags panel with structure; View ▸ Show Tagged Frames): tagged frames export in reading order, mapped paragraph styles become elements, imported elements flow into frames with matching tags. Inline text tagging (`xml.tagText`, an `xmlTag` character attribute, exported as nested elements) and tag markers (View ▸ Structure ▸ Show Tag Markers) too. DTDs (`xml.loadDtd`, `xml.validate`, `xml.deleteDtd`; Tags panel): loading one adds its elements as tags and names the root; validation checks content models (sequences, choices, `?*+`, mixed content, EMPTY/ANY, parameter entities) and required attributes, and lists each problem with its element path.