Place: Placement Options clip a DCS image inside its file's clipping path

When links.update or links.relink re-read a DCS file and Placement
Options also clipped the image (Fill with Clip, or an aspect change
that overflows), the placement rectangle replaced the file's clipping
path, so plate art outside that path showed and printed.

The file's clip group now stays around the image and the placement's
clip goes in a clip group around it. The clip group holding just the
file's group takes that clip, as one holding just an image does today,
else a new one does, so later updates reuse the same two groups. A
file that loses its clipping path leaves its group as a plain group
inside the placement's clip. The same applies in symbols, graph
designs, patterns and opacity masks.
This commit is contained in:
dotCooCoo
2026-11-13 20:18:35 -08:00
parent 1e4e3534f5
commit 155c2f722b
3 changed files with 217 additions and 28 deletions
+104 -27
View File
@@ -667,6 +667,16 @@ enum ClipTo {
File(PathData),
/// It goes into a new clip group of this path, inside the clip group that holds it.
Nest(PathData),
/// Placement Options clip it to `placement` while its DCS file draws it through `file`: it is
/// clipped to `file` as [`ClipTo::File`] clips it, and the clip group holding it is clipped to
/// `placement` as [`ClipTo::Path`] clips an object. With no `file` (the new file lacks the
/// clipping path of the clip group holding it), that group stays as [`ClipTo::Off`] leaves it,
/// clipped to `placement`.
Both { file: Option<PathData>, placement: PathData },
/// Placement Options clip it to `placement` while its DCS file draws it through `file`, and the
/// clip group holding just it is not its old file's: that clip group's clipping path becomes
/// `placement`, and it goes into a new clip group of `file` inside it.
BothInside { file: PathData, placement: PathData },
/// The clipping path of the clip group holding just it goes (a DCS file's clipping path that
/// its new file lacks): the group stays as a plain group of it, with its id, name, visibility
/// and transparency.
@@ -702,10 +712,10 @@ fn same_path(a: &PathData, b: &PathData) -> bool {
}
/// The clip an image placed with `old_xf` takes when it shows `img`, read from a file, placed with
/// `new_xf`: Placement Options' clip `clip` when they clip it, else the clipping path a DCS file
/// draws `img` through. When neither clips it, a clip group holding just it whose clipping path
/// `held` is its old DCS file's (`old_clip`, in the old pixels, under the nonzero rule its clip
/// groups get) goes.
/// `new_xf`: Placement Options' clip `clip` when they clip it, and the clipping path a DCS file
/// draws `img` through, the file's clip group inside the placement's. When neither clips it, a clip
/// group holding just it whose clipping path `held` is its old DCS file's (`old_clip`, in the old
/// pixels, under the nonzero rule its clip groups get) goes.
fn clip_to(
old_xf: Affine,
old_clip: Option<&PathData>,
@@ -714,16 +724,22 @@ fn clip_to(
new_xf: Affine,
held: Option<(&PathData, FillRule)>,
) -> ClipTo {
if let Some(clip) = clip {
return ClipTo::Path(clip_shape(clip));
}
// Whether the clip group holding just the image is the old DCS file's: a clipping path whose
// fill rule was changed in the document is not.
let file_held = match (old_clip, held) {
(Some(old), Some((held, rule))) => rule == FillRule::NonZero && same_path(&old.transformed(old_xf), held),
_ => false,
};
match img.clip.as_ref().map(|path| path.transformed(new_xf)) {
let file = img.clip.as_ref().map(|path| path.transformed(new_xf));
if let Some(clip) = clip {
let placement = clip_shape(clip);
return match file {
Some(file) if held.is_some() && !file_held => ClipTo::BothInside { file, placement },
file if file.is_some() || file_held => ClipTo::Both { file, placement },
_ => ClipTo::Path(placement),
};
}
match file {
// A clipping mask made in the document stays, with the file's clip group inside it.
Some(path) if held.is_some() && !file_held => ClipTo::Nest(path),
Some(path) => ClipTo::File(path),
@@ -752,7 +768,8 @@ impl Nested<'_> {
let first = d.peek_next_id();
self.next = first;
for l in d.layers.iter_mut() {
self.update(l, false);
// Nothing is picked in the layers themselves, so no layer is clipped.
let _ = self.update(l, false);
}
for root in d.symbols.iter_mut().map(|s| &mut s.art).chain(d.graph_designs.iter_mut().map(|g| &mut g.art)) {
self.root(root);
@@ -769,20 +786,30 @@ impl Nested<'_> {
/// mask art).
fn root(&mut self, n: &mut Arc<Node>) {
if !(self.pick)(n) {
return self.update(n, true);
if let Some(placement) = self.update(n, true) {
*n = Arc::new(self.clip_group(Arc::clone(n), placement));
}
return;
}
let (new, clip) = (self.make)(n, None);
let mut new = Arc::new(new);
self.update(&mut new, true);
let _ = self.update(&mut new, true);
*n = match clip {
ClipTo::Path(path) | ClipTo::File(path) | ClipTo::Nest(path) => Arc::new(self.clip_group(new, path)),
ClipTo::Both { file, placement } => self.clip_both(new, file, placement),
ClipTo::BothInside { file, placement } => self.clip_both(new, Some(file), placement),
ClipTo::Keep | ClipTo::Off => new,
};
}
/// `n` (outside the layer tree when `nested`) with the picked objects under it remade; it stays
/// the same node when none is there, and only the nodes on the way to one are copied.
fn update(&mut self, n: &mut Arc<Node>, nested: bool) {
/// the same node when none is there, and only the nodes on the way to one are copied. The
/// result is Placement Options' clip that `n` itself, now the clip group of a DCS file's
/// clipping path holding a picked object (or the plain group [`ClipTo::Off`] leaves), takes:
/// the clipping path of the clip group holding just `n` becomes it, else `n` goes into a new
/// clip group of it.
#[must_use]
fn update(&mut self, n: &mut Arc<Node>, nested: bool) -> Option<PathData> {
let mask = n.extras().mask.as_ref().and_then(|m| {
let mut art = Arc::clone(&m.art);
self.root(&mut art);
@@ -794,6 +821,8 @@ impl Nested<'_> {
// The clipping path and object of a plain group holding just the object, which becomes a
// clip group again.
let mut reclipped = None;
// Placement Options' clip for `n` itself.
let mut around = None;
if let Some(children) = n.children() {
// A clip group holding just the object: its clipping path changes.
let clip_path = match (&n.kind, &children[..]) {
@@ -804,9 +833,11 @@ impl Nested<'_> {
for (i, c) in children.iter().enumerate() {
if !(nested && (self.pick)(c)) {
let mut new = Arc::clone(c);
self.update(&mut new, nested);
if !Arc::ptr_eq(&new, c) {
slots.push((i, new));
match (self.update(&mut new, nested), clip_path) {
(None, _) if Arc::ptr_eq(&new, c) => {}
(None, _) => slots.push((i, new)),
(Some(placement), Some(path)) if i == 1 => slots.extend([(0, with_clip(path, placement)), (1, new)]),
(Some(placement), _) => slots.push((i, Arc::new(self.clip_group(new, placement)))),
}
continue;
}
@@ -816,16 +847,10 @@ impl Nested<'_> {
});
let (new, clip) = (self.make)(c, held);
let mut new = Arc::new(new);
self.update(&mut new, true);
let _ = self.update(&mut new, true);
match (clip, clip_path) {
(ClipTo::Keep, _) => slots.push((i, new)),
(ClipTo::Path(clip) | ClipTo::File(clip), Some(path)) if i == 1 => {
let mut path = Node::clone(path);
if let NodeKind::Path { path: p, .. } = &mut path.kind {
*p = clip;
}
slots.extend([(0, Arc::new(path)), (1, new)]);
}
(ClipTo::Path(clip) | ClipTo::File(clip), Some(path)) if i == 1 => slots.extend([(0, with_clip(path, clip)), (1, new)]),
(ClipTo::File(clip), None) if plain => reclipped = Some((clip, new)),
(ClipTo::Path(clip) | ClipTo::File(clip) | ClipTo::Nest(clip), _) => {
let group = self.clip_group(new, clip);
@@ -833,11 +858,30 @@ impl Nested<'_> {
}
(ClipTo::Off, Some(_)) if i == 1 => declipped = Some(new),
(ClipTo::Off, _) => slots.push((i, new)),
// The file's clip group stays `n`, and the placement's clip goes around it.
(ClipTo::Both { file: Some(file), placement }, Some(path)) if i == 1 => {
slots.extend([(0, with_clip(path, file)), (1, new)]);
around = Some(placement);
}
(ClipTo::Both { file: Some(file), placement }, None) if plain => {
reclipped = Some((file, new));
around = Some(placement);
}
(ClipTo::Both { file: None, placement }, Some(_)) if i == 1 => {
declipped = Some(new);
around = Some(placement);
}
(ClipTo::BothInside { file, placement }, Some(path)) if i == 1 => {
let group = self.clip_group(new, file);
slots.extend([(0, with_clip(path, placement)), (1, Arc::new(group))]);
}
(ClipTo::Both { file, placement }, _) => slots.push((i, self.clip_both(new, file, placement))),
(ClipTo::BothInside { file, placement }, _) => slots.push((i, self.clip_both(new, Some(file), placement))),
}
}
}
if mask.is_none() && slots.is_empty() && declipped.is_none() && reclipped.is_none() {
return;
return None;
}
let reclipped = reclipped.map(|(clip, object)| (Arc::new(self.clipping_path(clip)), object));
let n = Arc::make_mut(n);
@@ -849,14 +893,14 @@ impl Nested<'_> {
if let NodeKind::Group { children, clip } = &mut n.kind {
(*children, *clip) = (vec![path, object], true);
}
return;
return around;
}
// A clip group that stays as a plain group of just the object.
if let Some(object) = declipped {
if let NodeKind::Group { children, clip } = &mut n.kind {
(*children, *clip) = (vec![object], false);
}
return;
return around;
}
if let Some(children) = n.children_mut() {
for (i, c) in slots {
@@ -865,6 +909,17 @@ impl Nested<'_> {
}
}
}
around
}
/// A clip group of `placement` holding `object`, inside a clip group of `file` when there is a
/// `file` ([`ClipTo::Both`] for an object no clip group holds yet).
fn clip_both(&mut self, object: Arc<Node>, file: Option<PathData>, placement: PathData) -> Arc<Node> {
let object = match file {
Some(file) => Arc::new(self.clip_group(object, file)),
None => object,
};
Arc::new(self.clip_group(object, placement))
}
/// A clip group of `object` clipped to `clip`, as [`clip_image_to`] makes one in the layers.
@@ -1035,6 +1090,15 @@ fn clip_file_to(d: &mut Document, id: NodeId, path: PathData) -> Result<()> {
Ok(())
}
/// Clipping path `path` (a node) with its path changed to `clip`.
fn with_clip(path: &Node, clip: PathData) -> Arc<Node> {
let mut path = Node::clone(path);
if let NodeKind::Path { path: p, .. } = &mut path.kind {
*p = clip;
}
Arc::new(path)
}
/// A clipping path `id` of `path`, unpainted.
fn clipping_path(id: NodeId, path: PathData) -> Node {
let none = vectorcraft_color::Paint::None;
@@ -1130,6 +1194,19 @@ fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, force: Optio
ClipTo::File(path) => clip_file_to(d, id, path),
ClipTo::Nest(path) => new_clip_group(d, id, path),
ClipTo::Off => unclip(d, id),
ClipTo::Both { file, placement } => {
match file {
Some(file) => clip_file_to(d, id, file)?,
None => unclip(d, id)?,
}
// The file's clip group (or the plain group left of it) takes the placement's clip.
let group = d.parent_of(id).ok_or(EngineError::NoNode(id))?;
clip_image_to(d, group, placement)
}
ClipTo::BothInside { file, placement } => {
clip_image_to(d, id, placement)?;
new_clip_group(d, id, file)
}
})
}
+112
View File
@@ -490,6 +490,118 @@ fn a_clipping_mask_of_ones_own_stays_when_the_link_is_updated() {
assert_eq!(mask_width(&s), Some(4.0), "the mask's path is kept");
}
/// [`two_plates`] on a 20 × 20 pt page, its composite drawn through the left half of it (same
/// length, so the plates stay where the header says).
fn two_plates_square() -> Vec<u8> {
let text = String::from_utf8(two_plates()).unwrap();
let text = text.replace("0 0 20 10", "0 0 20 20").replace("20 10 scale", "20 20 scale");
text.replacen("10 10 lineto 0 10 lineto", "10 20 lineto 0 20 lineto", 1).into_bytes()
}
/// The bounds (width, height) of the clipping path of clip group `g`.
fn clip_size(doc: &Document, g: NodeId) -> (f64, f64) {
let NodeKind::Group { children, clip: true } = &doc.node(g).unwrap().kind else { panic!("a clip group: {:?}", doc.node(g).unwrap().kind) };
let b = children[0].geometric_bounds().expect("a clipping path");
(b.width(), b.height())
}
#[test]
fn placement_options_clip_a_dcs_image_inside_its_files_clipping_path() {
let dir = temp_dir("placement-clip");
let path = write(&dir, "pic.eps", &two_plates());
let square = write(&dir, "square.eps", &two_plates_square());
let mut s = session();
let r = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap();
let id = image_of(&s, &r["ids"][0]).id;
s.execute("links.placementOptions", &json!({"ids": [id.0], "preserve": "fill", "clip": true})).unwrap();
let depth = |doc: &Document| std::iter::successors(Some(id), |n| doc.parent_of(*n)).count();
let mut first = None;
// The square file fills the 20 × 10 pt placement and is cut to it; the file's own clipping
// path, the left half of its page, still clips the image inside that, on every relink.
for _ in 0..2 {
s.execute("links.relink", &json!({ "ids": [id.0], "path": square })).unwrap();
let doc = &s.doc().unwrap().doc;
let file = doc.parent_of(id).expect("the file's clip group");
let (w, h) = clip_size(doc, file);
assert!((w - 10.0).abs() < 1e-6 && (h - 20.0).abs() < 1e-6, "the file's clipping path: {w} × {h}");
let placement = doc.parent_of(file).expect("the placement's clip group");
let (w, h) = clip_size(doc, placement);
assert!((w - 20.0).abs() < 1e-6 && (h - 10.0).abs() < 1e-6, "the placement's clip: {w} × {h}");
assert_eq!(*first.get_or_insert(depth(doc)), depth(doc), "no group is added on a later relink");
}
// An update of the file keeps both.
s.execute("links.update", &json!({ "ids": [id.0] })).unwrap();
let doc = &s.doc().unwrap().doc;
assert_eq!(first, Some(depth(doc)));
let file = doc.parent_of(id).unwrap();
assert!((clip_size(doc, file).0 - 10.0).abs() < 1e-6, "the file's clipping path");
assert!((clip_size(doc, doc.parent_of(file).unwrap()).1 - 10.0).abs() < 1e-6, "the placement's clip");
// The file loses its clipping path and gains it again: the same two groups, no more.
let unclipped = String::from_utf8(two_plates_square()).unwrap().replacen("closepath clip newpath", "closepath newpath ", 1);
for _ in 0..2 {
std::fs::write(&square, unclipped.as_bytes()).unwrap();
s.execute("links.update", &json!({ "ids": [id.0] })).unwrap();
let doc = &s.doc().unwrap().doc;
assert_eq!(first, Some(depth(doc)), "the file's group stays, without its clip");
let plain = doc.parent_of(id).unwrap();
assert!(!matches!(doc.node(plain).unwrap().kind, NodeKind::Group { clip: true, .. }), "the file's group is not clipped");
assert!((clip_size(doc, doc.parent_of(plain).unwrap()).1 - 10.0).abs() < 1e-6, "the placement's clip stays");
std::fs::write(&square, two_plates_square()).unwrap();
s.execute("links.update", &json!({ "ids": [id.0] })).unwrap();
let doc = &s.doc().unwrap().doc;
assert_eq!(first, Some(depth(doc)), "the file's clip group again, at the same depth");
let file = doc.parent_of(id).unwrap();
assert!((clip_size(doc, file).0 - 10.0).abs() < 1e-6, "the file's clipping path again");
assert!((clip_size(doc, doc.parent_of(file).unwrap()).1 - 10.0).abs() < 1e-6, "the placement's clip");
}
}
#[test]
fn placement_options_clip_a_dcs_image_in_a_symbol_inside_its_files_clipping_path() {
let dir = temp_dir("placement-clip-symbol");
let path = write(&dir, "pic.eps", &two_plates());
let square = write(&dir, "square.eps", &two_plates_square());
let mut s = session();
let r = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap();
s.execute("symbol.new", &json!({ "name": "Pic", "ids": [r["ids"][0]] })).unwrap();
let list = s.execute("links.list", &json!({})).unwrap();
let row = list["links"].as_array().unwrap().iter().find(|r| r["owner"]["kind"] == "symbol").cloned().expect("the symbol's image");
let id = row["id"].as_u64().unwrap();
s.execute("links.placementOptions", &json!({"ids": [id], "preserve": "fill", "clip": true})).unwrap();
// The clip groups around the symbol's image, innermost first.
let clips = |s: &Session| {
fn find(n: &Node, id: u64, around: &mut Vec<(f64, f64)>) -> bool {
if n.id.0 == id {
return true;
}
let Some(children) = n.children() else { return false };
if !children.iter().any(|c| find(c, id, around)) {
return false;
}
if let NodeKind::Group { clip: true, .. } = n.kind
&& let Some(b) = children[0].geometric_bounds()
{
around.push((b.width(), b.height()));
}
true
}
let mut around = vec![];
let doc = &s.doc().unwrap().doc;
assert!(find(&doc.symbols.iter().find(|w| w.name == "Pic").expect("the symbol").art, id, &mut around));
around
};
for _ in 0..2 {
s.execute("links.relink", &json!({ "ids": [id], "path": square })).unwrap();
let around = clips(&s);
assert_eq!(around.len(), 2, "the file's clip group inside the placement's: {around:?}");
// In the symbol's own units: the file's path as wide as the placement is tall, half as
// wide as it is tall, and the placement twice as wide as it is tall.
let ((fw, fh), (pw, ph)) = (around[0], around[1]);
assert!((fh - 2.0 * fw).abs() < 1e-6 && (fw - ph).abs() < 1e-6, "the file's clipping path: {around:?}");
assert!((pw - 2.0 * ph).abs() < 1e-6, "the placement's clip: {around:?}");
}
}
#[test]
fn a_dcs_file_takes_the_place_parameters_an_eps_file_takes() {
let dir = temp_dir("params");
+1 -1
View File
@@ -6512,7 +6512,7 @@ file's composite is drawn through clips the image (a clipping group, its path un
clipping path kept as the same area), and `file.place` answers the image's id as
`image`, since the Links commands take the image and not the group. `links.update` and `links.relink` clip the image
by the file's clipping path again (a plain group holding just the image becomes its clipping group, a new clipping group holds it when no group does, and it goes inside a clipping mask made in the
document, whose path is kept), unless Placement Options clip the image, the same way for the images in symbols, graph designs, patterns and opacity masks; when the new file has no
document, whose path is kept), the same way for the images in symbols, graph designs, patterns and opacity masks. When Placement Options also clip the image, the file's clipping group goes inside a clipping group of the placement's clip (the clipping group holding just the file's one, or a clipping group holding just the image that is not the file's, takes that clip, else a new one does), and a later update or relink changes the same two groups; when the new file has no
clipping path, a clipping group holding just the image whose path is the old file's clipping path loses that path and
stays as a plain group of the image, with its id, name, visibility and transparency (one whose path was given the
even-odd fill rule stays as it is). The link shows as modified when a plate file of its own changes