From 8125f2490b960bf9179c791c102eca65912c681b Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 12 Oct 2026 05:31:03 +0000 Subject: [PATCH 1/2] Images: clipping paths from IDML clip the image on screen and in PDF A placed graphic keeps its clipping path (Detect Edges, Alpha Channel, Photoshop Path or User-Modified Path) in graphic space, with the options IDML carries. IDML import reads ClippingPathSettings and its saved ClippingPathGeometry (anchors capped, non-finite subpaths dropped); export writes them back. The canvas and PDF export clip the image to the path inside the frame clip; an inverted path shows the image outside it. Without saved geometry nothing changes. object.clippingPathOptions reports a graphic's clipping path, inverts it or removes it (Object > Clipping Path > None). Fixes #450. --- ROADMAP.md | 1 + apps/designcraft-cli/src/perf.rs | 1 + crates/doc/src/assets.rs | 1 + crates/doc/src/item.rs | 128 ++++++++++++++++++ crates/engine/src/cmd/datamerge/fill.rs | 3 +- crates/engine/src/cmd/export.rs | 2 + crates/engine/src/cmd/file.rs | 4 + crates/engine/src/cmd/links.rs | 1 + crates/engine/src/cmd/mathexpr.rs | 11 +- crates/engine/src/cmd/media.rs | 2 + crates/engine/src/cmd/object.rs | 99 ++++++++++++++ crates/engine/src/cmd/table.rs | 1 + crates/engine/src/sample.rs | 1 + crates/epub/src/lib.rs | 1 + crates/format/src/lib.rs | 1 + crates/idml/src/export.rs | 22 +++ crates/idml/src/import.rs | 38 +++++- crates/idml/src/names.rs | 18 ++- crates/idml/src/tests.rs | 1 + crates/idml/src/tests/clipping.rs | 103 ++++++++++++++ crates/pdf/src/export.rs | 18 +++ crates/pdf/src/pdfx.rs | 1 + crates/pdf/src/tests.rs | 37 +++++ .../support/image_content_cache_contract.rs | 1 + crates/render/src/lib.rs | 74 ++++++++++ crates/ui-egui/src/menus.rs | 1 + docs/gaps.md | 6 +- docs/target-app-parity.md | 4 +- 28 files changed, 571 insertions(+), 10 deletions(-) create mode 100644 crates/idml/src/tests/clipping.rs diff --git a/ROADMAP.md b/ROADMAP.md index d33353caa..6c17f443e 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 7749f6443..af93a125f 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 24ba5c243..a118bbc08 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 5cfe05fe2..2c98ba44c 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 89dd0c74e..643826173 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 f47863851..2b842f78b 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 ed126eee5..bc1444336 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 73de3ea7e..2e465f3be 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 ec90d52e3..49edc6e35 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 b6e4cd4b8..b90a92977 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 cd13be117..be732782d 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 c2313170a..b6038bf28 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 15a05d084..ae47b32af 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 643f59341..d8f1de2bd 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 408bb3ccc..5e43a5aa1 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 9d73562b9..0512f0917 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 46c3253b6..9a6aad9c8 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 1df0f56d5..4ebe29baf 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 23ec99bdf..3d18dbc80 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 000000000..02f024377 --- /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 dcf8abb83..a8b3fac0d 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 3218ffc76..2db22df89 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 9ac53d057..6c304e5c6 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 6a4ac91f1..3438380f6 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 2cc5c548f..4d038af5b 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 d123ec4eb..35b1d1684 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 6f92c5500..3dae7ac84 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 e9c27a9f8..b196a7349 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. From e111bb74f76a438f7cf3fa488c4e1cc8421bf599 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 12 Oct 2026 05:32:11 +0000 Subject: [PATCH 2/2] Images: declare a graphic's clipping path beside its transform --- apps/designcraft-cli/src/perf.rs | 2 +- crates/doc/src/assets.rs | 2 +- crates/doc/src/item.rs | 8 ++++---- crates/engine/src/cmd/export.rs | 4 ++-- crates/engine/src/cmd/file.rs | 8 ++++---- crates/engine/src/cmd/links.rs | 2 +- crates/engine/src/cmd/mathexpr.rs | 2 +- crates/engine/src/cmd/media.rs | 4 ++-- crates/engine/src/cmd/table.rs | 2 +- crates/engine/src/sample.rs | 2 +- crates/epub/src/lib.rs | 2 +- crates/format/src/lib.rs | 2 +- crates/idml/src/import.rs | 2 +- crates/pdf/src/pdfx.rs | 2 +- crates/pdf/src/tests.rs | 2 +- crates/pdf/tests/support/image_content_cache_contract.rs | 2 +- crates/render/src/lib.rs | 2 +- 17 files changed, 25 insertions(+), 25 deletions(-) diff --git a/apps/designcraft-cli/src/perf.rs b/apps/designcraft-cli/src/perf.rs index af93a125f..3c0846247 100644 --- a/apps/designcraft-cli/src/perf.rs +++ b/apps/designcraft-cli/src/perf.rs @@ -243,10 +243,10 @@ pub fn synthetic(spec: &Spec) -> Result { asset, size: (nw, nh), xf: Affine::translate((r.x0 + (r.width() - nw * s) / 2.0, r.y0 + (r.height() - nh * s) / 2.0)) * Affine::scale(s), + clip: None, 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 a118bbc08..c511defa9 100644 --- a/crates/doc/src/assets.rs +++ b/crates/doc/src/assets.rs @@ -286,10 +286,10 @@ mod tests { asset: AssetId(asset), size: (10.0, 10.0), xf: Affine::translate((crop, 0.0)), + clip: None, 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 2c98ba44c..b579280e9 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -447,6 +447,9 @@ pub struct Graphic { pub size: (f64, f64), /// Graphic space (0,0)-(w,h) → frame inner space. pub xf: Affine, + /// Clipping path: only the part of the graphic inside it shows. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub clip: Option>, #[serde(default)] pub auto_fit: Fitting, /// Frame Fitting Options › Align From: the reference point (0–8, row-major; 4 = centre). @@ -455,9 +458,6 @@ 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 { @@ -1305,10 +1305,10 @@ mod tests { asset: AssetId(3), size: (40.0, 30.0), xf: Affine::translate((5.0, 5.0)), + clip: None, 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}"); diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 2b842f78b..f976f0de3 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -264,10 +264,10 @@ fn flatten(d: &designcraft_doc::Document, cache: &designcraft_compose::Cache, pp size: (w as f64, h as f64), xf: designcraft_geom::Affine::translate((r.x0, r.y0)) * designcraft_geom::Affine::scale_non_uniform(r.width() / w as f64, r.height() / h as f64), + clip: None, auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], - clip: None, }); it.stroke.weight = 0.0; it @@ -430,10 +430,10 @@ fn rasterize_where( 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), + clip: None, 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 bc1444336..df85549f2 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -358,10 +358,10 @@ fn file_place(s: &mut Session, p: &Value) -> Result { asset: aid, size: (nw, nh), xf: Affine::translate((r.x0 + (r.width() - gw) / 2.0, r.y0 + (r.height() - gh) / 2.0)) * Affine::scale(k), + clip: None, auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], - clip: None, }); fid } else { @@ -377,10 +377,10 @@ fn file_place(s: &mut Session, p: &Value) -> Result { asset: aid, size: (nw, nh), xf: Affine::translate((x - bx * k, y - by * k)) * Affine::scale(k), + clip: None, auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], - clip: None, }); d.insert_item(spread, it, None)?; id @@ -471,10 +471,10 @@ fn place_drop(s: &mut Session, p: &Value) -> Result { asset: aid, size: (nw, nh), xf: Affine::translate((r.x0 + (r.width() - nw * k) / 2.0, r.y0 + (r.height() - nh * k) / 2.0)) * Affine::scale(k), + clip: None, auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], - clip: None, }); fid } @@ -494,10 +494,10 @@ fn place_drop(s: &mut Session, p: &Value) -> Result { asset: aid, size: (nw, nh), xf: Affine::translate((frame_r.x0, frame_r.y0)) * Affine::scale(k), + clip: None, 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 2e465f3be..847f38385 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -417,10 +417,10 @@ mod shared_asset_tests { asset: sid, size: (4.0, 4.0), xf: designcraft_geom::Affine::IDENTITY, + clip: None, 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 49edc6e35..26f87fc86 100644 --- a/crates/engine/src/cmd/mathexpr.rs +++ b/crates/engine/src/cmd/mathexpr.rs @@ -30,10 +30,10 @@ fn math_item(d: &mut designcraft_doc::Document, latex: &str, size: f64, lid: des asset: aid, size: (w, h), xf: Affine::translate(at), + clip: None, auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], - clip: None, }); it.alt_text = latex.to_string(); it.name = "Math".into(); diff --git a/crates/engine/src/cmd/media.rs b/crates/engine/src/cmd/media.rs index b90a92977..464de71de 100644 --- a/crates/engine/src/cmd/media.rs +++ b/crates/engine/src/cmd/media.rs @@ -35,10 +35,10 @@ pub(crate) fn place_media(s: &mut Session, p: &Value, name: String, mime: &str, asset: aid, size: (r.width(), r.height()), xf: Affine::translate((r.x0, r.y0)), + clip: None, auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], - clip: None, }); it.media = Some(MediaOptions { controls: true, ..Default::default() }); fid @@ -52,10 +52,10 @@ pub(crate) fn place_media(s: &mut Session, p: &Value, name: String, mime: &str, asset: aid, size: (w, h), xf: Affine::translate((x, y)), + clip: None, 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/table.rs b/crates/engine/src/cmd/table.rs index b6038bf28..eca941fa1 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -1423,10 +1423,10 @@ fn place_graphic(s: &mut Session, p: &Value) -> Result { asset: aid, size: (nw, nh), xf, + clip: None, 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 ae47b32af..29d439ebb 100644 --- a/crates/engine/src/sample.rs +++ b/crates/engine/src/sample.rs @@ -82,10 +82,10 @@ fn graphic_frame(d: &mut Document, sr: SpreadRef, r: Rect, asset: AssetId, px: ( asset, size: (nw, nh), xf: Affine::translate((r.x0 + (r.width() - nw * k) / 2.0, r.y0 + (r.height() - nh * k) / 2.0)) * Affine::scale(k), + clip: None, 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 d8f1de2bd..d5a496e2c 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -847,10 +847,10 @@ mod tests { asset: aid, size: (1.0, 1.0), xf: Affine::IDENTITY, + clip: None, 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 5e43a5aa1..ee7d1dca0 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -248,10 +248,10 @@ mod tests { asset, size: (1.0, 1.0), xf: Affine::translate((crop, crop)), + clip: None, 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/import.rs b/crates/idml/src/import.rs index 9a6aad9c8..f7e65cb36 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -2276,10 +2276,10 @@ impl<'r> Importer<'r> { asset, size, xf: gxf * Affine::translate((l, t)), + clip: clip.clone(), 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(); diff --git a/crates/pdf/src/pdfx.rs b/crates/pdf/src/pdfx.rs index 2db22df89..35befbcf1 100644 --- a/crates/pdf/src/pdfx.rs +++ b/crates/pdf/src/pdfx.rs @@ -477,10 +477,10 @@ mod tests { asset, size, xf: Affine::translate(at) * Affine::scale(100.0 / size.0), + clip: None, 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 6c304e5c6..d16884a96 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -233,10 +233,10 @@ fn place(d: &mut Document, data: Vec, px: (u32, u32)) { asset, size: (px.0 as f64, px.1 as f64), xf: designcraft_geom::Affine::translate((72.0, 72.0)) * designcraft_geom::Affine::scale(50.0), + clip: None, auto_fit: designcraft_doc::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/tests/support/image_content_cache_contract.rs b/crates/pdf/tests/support/image_content_cache_contract.rs index 3438380f6..98531b7d1 100644 --- a/crates/pdf/tests/support/image_content_cache_contract.rs +++ b/crates/pdf/tests/support/image_content_cache_contract.rs @@ -31,10 +31,10 @@ fn place_named(d: &mut Document, data: Vec, x: f64, name: &str, mime: &str, asset: aid, size: (20.0, 20.0), xf: Affine::translate((x, 36.0)), + clip: None, 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 4d038af5b..6f1f57043 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1469,10 +1469,10 @@ mod tests { asset: aid, size: (100.0, 100.0), xf: Affine::translate((100.0, 100.0)), + clip: None, 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();