diff --git a/ROADMAP.md b/ROADMAP.md index 76d4bdbf..f55002d9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -139,6 +139,7 @@ Newest first. - **2026-10-12** · Underlines and strikethroughs are drawn as one bar per stretch of text on screen, as in PDF export: a bar per letter left faint seams between the letters. No readiness percentage change. - **2026-10-12** · The section marker character shows its section's marker text on document pages (nothing when the section has none) instead of the word "Section", which stays on parent pages; editing the marker recomposes (#556). No readiness percentage change. - **2026-10-12** · IDML import keeps a font family name exactly as written, trailing spaces included, and font lookup falls back to the name without surrounding spaces; `FontStyle="Nothing"` leaves the style to the paragraph (#558). No readiness percentage change. +- **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 (printing):** `file.printers` and `file.print` use craft-libs/craft-print for CUPS and Windows. DesignCraft retains PDF export, page selection, spreads, marks and bleed; dry runs create no temporary PDF or print job. Windows selects matching or default driver paper and shrinks oversized sheets without enlarging smaller ones; queue duplex defaults are preserved. Headless tests exercise exported bytes, job settings, dry runs and refusals; shared native Windows CI passes, including paused-queue submission and cleanup. PostScript, production print controls and physical output validation remain open. No readiness percentage change. - **2026-10-11** · The View menu follows InDesign's: proofing first, then zoom, Rotate Spread, Screen Mode, Display Performance, rulers, Extras, Grids & Guides and Structure; the view items that sat at the end of the Type menu moved there. No readiness percentage change. diff --git a/apps/designcraft-cli/src/perf.rs b/apps/designcraft-cli/src/perf.rs index 5139178e..aa259ce2 100644 --- a/apps/designcraft-cli/src/perf.rs +++ b/apps/designcraft-cli/src/perf.rs @@ -242,6 +242,7 @@ 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], diff --git a/crates/doc/src/assets.rs b/crates/doc/src/assets.rs index 3e4baf9f..fec7cbab 100644 --- a/crates/doc/src/assets.rs +++ b/crates/doc/src/assets.rs @@ -286,6 +286,7 @@ 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], diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index b83c6bac..56032291 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -526,6 +526,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). @@ -543,6 +546,92 @@ 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)] @@ -703,6 +792,24 @@ impl Graphic { (r.width() > 0.0 && r.height() > 0.0).then_some(r) } + /// 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 { @@ -1301,6 +1408,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)), + clip: None, + auto_fit: Fitting::None, + fit_align: 4, + crop: [0.0; 4], + }; + 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 c76b5a2f..bb5b15f0 100644 --- a/crates/engine/src/cmd/datamerge/fill.rs +++ b/crates/engine/src/cmd/datamerge/fill.rs @@ -389,6 +389,7 @@ fn place_image( fit_align: 0, crop: [0.0; 4], wrap: Default::default(), + 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 bccff305..9c151977 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -264,6 +264,7 @@ 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], @@ -431,6 +432,7 @@ 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], diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index c07aa8b6..bf40799d 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -474,6 +474,7 @@ fn file_place(s: &mut Session, p: &Value) -> Result { asset: aid, size: (bw, bh), 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], @@ -492,6 +493,7 @@ fn file_place(s: &mut Session, p: &Value) -> Result { asset: aid, size: (bw, bh), xf: Affine::translate((x, y)) * Affine::scale(k), + clip: None, auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], @@ -607,6 +609,7 @@ 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], @@ -630,6 +633,7 @@ 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], diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 40599257..d867a3d2 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -419,6 +419,7 @@ 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], diff --git a/crates/engine/src/cmd/mathexpr.rs b/crates/engine/src/cmd/mathexpr.rs index dc1b6ed7..ef840b21 100644 --- a/crates/engine/src/cmd/mathexpr.rs +++ b/crates/engine/src/cmd/mathexpr.rs @@ -26,6 +26,7 @@ fn math_item(d: &mut designcraft_doc::Document, latex: &str, size: f64, lid: des fit_align: 4, crop: [0.0; 4], wrap: Default::default(), + 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 4b3b5d5d..6f5f7244 100644 --- a/crates/engine/src/cmd/media.rs +++ b/crates/engine/src/cmd/media.rs @@ -35,6 +35,7 @@ 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], @@ -52,6 +53,7 @@ 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], diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index a9c9b75d..cc25b3d0 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -680,6 +680,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", @@ -2063,6 +2072,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 { @@ -2927,6 +2993,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 f9104849..fc2f6039 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -1640,6 +1640,7 @@ 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], diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs index 94a93c1a..7964a43f 100644 --- a/crates/engine/src/sample.rs +++ b/crates/engine/src/sample.rs @@ -82,6 +82,7 @@ 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], diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index d18c69a7..1d5890b8 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -846,6 +846,7 @@ 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], diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index e3302531..09deb605 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -308,6 +308,7 @@ 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], diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 172415cf..6e965888 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1959,6 +1959,9 @@ impl<'a> Ex<'a> { if !g.wrap.is_default() { el.push(text_wrap_pref(&g.wrap)); } + 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), @@ -2769,6 +2772,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 f51cdfa9..06acfc77 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -2651,6 +2651,7 @@ impl<'r> Importer<'r> { let wrap = if g.get("AppliedObjectStyle").is_some() { self.with_style(g, "TextWrapPreference") } else { g.find("TextWrapPreference").cloned() }; let wrap = wrap.map(|w| text_wrap(&w)).unwrap_or_default(); + 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 page = pdf_page_in(g); @@ -2661,6 +2662,7 @@ 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], @@ -3203,19 +3205,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 b0b2ce27..10bdc1b2 100644 --- a/crates/idml/src/names.rs +++ b/crates/idml/src/names.rs @@ -2,7 +2,7 @@ use designcraft_color::BlendMode; use designcraft_doc::{ - Align, Arrowhead, AutoSize, Cap, Capitalization, ContourType, FirstBaseline, Join, Leading, NumberStyle, Position, StartParagraph, StrokeAlign, + Align, Arrowhead, AutoSize, Cap, Capitalization, ClippingKind, ContourType, FirstBaseline, Join, Leading, NumberStyle, Position, StartParagraph, StrokeAlign, StrokeType, TabAlign, VerticalJustification, WrapMode, WrapSide, }; use designcraft_geom::Unit; @@ -465,6 +465,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 1aefad33..be6dd6fd 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; mod object_styles; mod rtl_binding; 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 2c000b1e..1f75871c 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -1303,7 +1303,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 48e1fe9a..204bb722 100644 --- a/crates/pdf/src/pdfx.rs +++ b/crates/pdf/src/pdfx.rs @@ -477,6 +477,7 @@ 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], diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 8607b9ff..d27d03d3 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -244,6 +244,7 @@ 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], @@ -536,3 +537,39 @@ fn small_caps_and_small_cap_figures_extract_as_typed() { }); assert!(uncompressed(&d, PdfOptions::default()).contains("/ActualText")); } + +/// 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 23e45721..4064404f 100644 --- a/crates/pdf/tests/support/image_content_cache_contract.rs +++ b/crates/pdf/tests/support/image_content_cache_contract.rs @@ -31,6 +31,7 @@ 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], diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 414326cc..d23b8dba 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -753,7 +753,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). @@ -1646,6 +1662,7 @@ 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], @@ -1663,6 +1680,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 9a29f4af..392671ad 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -569,6 +569,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 44a00d93..c6c25221 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-12 · **Change:** minor (gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; 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. @@ -179,7 +179,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 af1cda17..8e10cca5 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-12 · **Change:** minor (text threads by hand) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text threads by hand; 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 @@ -339,7 +339,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.