Merge pr/691: Copy a selected placed graphic on its own, so Paste Into puts the whole image in place (#691)

This commit is contained in:
aiongg
2026-10-13 12:27:29 +00:00
2 changed files with 75 additions and 6 deletions
+1
View File
@@ -128,6 +128,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-10-13** · Copy with a placed graphic selected (Direct Selection) copies just the graphic, uncropped, where it sits on the page and without the frame's stroke, so Paste Into puts the whole image in place; Cut and Delete then remove only the graphic and leave its frame empty. Nested selections copy in their spread coordinates. No readiness percentage change.
- **2026-10-13** · Object effects (Basic Feather, Inner Glow, shadows, glows, bevel, satin, directional and gradient feathers) apply to frames with pasted-in content, on screen and in image exports: the frame, its clipped content and its stroke are one object to the effect. PDF export still has no soft effects for any object. No readiness percentage change.
- **2026-10-13** · Backspace between two consecutive tables, pasting a table next to a table anchor, and pasting several paragraphs right before one keep every table on its own anchor paragraph. Previously a table was dropped or left without its anchor, and the saved file failed to reopen. No readiness percentage change.
- **2026-10-12** · The engine's atomic writer, used by saves and exports (documents, snippets, books, libraries, swatches, XML, interchange, MCP, desktop and CLI writes), delegates to craft-libs/craft-atomic. Failed writes retain the original; new files follow the umask and replaced files keep their permissions. Recovery checkpoints and package output keep their own no-follow writers. No readiness percentage change.
+74 -6
View File
@@ -258,11 +258,20 @@ pub fn specs() -> Vec<CommandSpec> {
return super::text::delete_selection(s);
}
let ids = super::targets(s, p)?;
// With a placed graphic itself selected (Direct Selection), only the graphic goes: its
// frame stays, empty and selected (as in InDesign). Other items are deleted.
let content = s.doc()?.selection.content && p.get("ids").is_none();
s.edit(|d, sel| {
let mut kept = Vec::new();
for id in &ids {
let _ = d.remove_item(*id);
if let Some(it) = d.item_mut(*id).filter(|it| content && matches!(it.content, designcraft_doc::Content::Graphic(_))) {
it.content = designcraft_doc::Content::Unassigned;
kept.push(*id);
} else {
let _ = d.remove_item(*id);
}
}
*sel = Selection::default();
*sel = Selection::items(kept);
Ok(json!({"deleted": ids.len()}))
})
}),
@@ -362,12 +371,15 @@ pub(crate) fn clip_doc(s: &Session) -> Result<designcraft_doc::Document> {
let st = s.doc()?;
let mut d = (*st.doc).clone();
let keep: Vec<ItemId> = st.selection.items.clone();
// Gather the selected items (keep their spread coordinates) into spread 0.
// Gather the selected items (in their spread coordinates, nested ones too) into spread 0.
let mut items = Vec::new();
for id in &keep {
if let Some(it) = st.doc.item(*id) {
items.push(Arc::new(it.clone()));
}
let (Some(it), Some(loc)) = (st.doc.item(*id), st.doc.find(*id)) else { continue };
let parent = st.doc.parent_xf(&loc);
items.push(Arc::new(match &it.content {
designcraft_doc::Content::Graphic(g) if st.selection.content => graphic_alone(it, g, parent),
_ => designcraft_doc::Item { xf: parent * it.xf, ..it.clone() },
}));
}
for sp in d.spreads.iter_mut() {
Arc::make_mut(sp).items.clear();
@@ -400,6 +412,22 @@ pub(crate) fn clip_doc(s: &Session) -> Result<designcraft_doc::Document> {
Ok(d)
}
/// A placed graphic on its own, as InDesign copies one selected with Direct Selection: a frame of
/// the whole graphic's bounds (uncropped), where the graphic sits on the spread (`parent` is the
/// frame's parent transform), without the frame's fill, stroke or effects.
fn graphic_alone(frame: &designcraft_doc::Item, g: &designcraft_doc::Graphic, parent: designcraft_geom::Affine) -> designcraft_doc::Item {
let r = designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1);
let mut it = designcraft_doc::Item::new(frame.id, frame.layer, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(r));
it.xf = parent * frame.xf * g.xf;
it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {
xf: designcraft_geom::Affine::IDENTITY,
auto_fit: designcraft_doc::Fitting::None,
crop: [0.0; 4],
..g.clone()
});
it
}
#[cfg(test)]
mod text_clipboard_tests {
use super::*;
@@ -628,6 +656,46 @@ mod paste_into_tests {
assert!(img.pixel(120, 120)[0] > 200, "{:?}", img.pixel(120, 120));
s.doc().unwrap().doc.check().unwrap();
}
#[test]
fn a_selected_graphic_copies_and_clears_on_its_own() {
let dir = std::env::temp_dir().join(format!("dc-graphic-alone-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let png = dir.join("photo.png");
std::fs::write(&png, designcraft_render::Rendered { width: 200, height: 100, pixels: vec![40u8; 200 * 100 * 4] }.to_png()).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
// A square frame showing the middle of a wide photo (cropped left and right).
let frame = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap();
s.execute("file.place", &json!({"path": png.to_string_lossy(), "frame": frame})).unwrap();
s.execute("object.fit", &json!({"mode": "fillProportionally", "ids": [frame]})).unwrap();
s.execute("object.stroke", &json!({"ids": [frame], "swatch": "[Black]", "weight": 4})).unwrap();
// Direct Selection on the photo, Copy: the whole photo, where it is, without the frame's stroke.
s.execute("selection.set", &json!({"ids": [frame], "content": true})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
let clip = s.clipboard.clone().unwrap();
let it = &clip.spreads[0].items[0];
assert!(matches!(&it.content, designcraft_doc::Content::Graphic(g) if g.crop == [0.0; 4]));
assert_eq!(it.bounds(), designcraft_geom::Rect::new(50.0, 100.0, 250.0, 200.0));
assert!(it.stroke.weight == 0.0 || it.stroke.swatch == "[None]", "{:?}", it.stroke);
// Paste Into a shape over it: the photo lands in place, inside the shape.
let shape = s.execute("frame.create", &json!({"rect": [60, 90, 240, 210], "shape": "ellipse"})).unwrap()["id"].as_u64().unwrap();
s.execute("selection.set", &json!({"ids": [shape]})).unwrap();
s.execute("edit.pasteInto", &json!({})).unwrap();
let d = s.doc().unwrap().doc.clone();
let kid = d.item(ItemId(shape)).unwrap().children()[0].clone();
let at = d.parent_xf(&d.find(kid.id).unwrap()) * kid.xf;
assert!((at * designcraft_geom::Point::new(0.0, 0.0) - designcraft_geom::Point::new(50.0, 100.0)).hypot() < 1e-6);
assert!((at * designcraft_geom::Point::new(200.0, 100.0) - designcraft_geom::Point::new(250.0, 200.0)).hypot() < 1e-6);
// Delete with the photo selected: the frame stays, empty.
s.execute("selection.set", &json!({"ids": [frame], "content": true})).unwrap();
assert_eq!(s.execute("edit.clear", &json!({})).unwrap()["deleted"], 1);
let d = s.doc().unwrap().doc.clone();
assert!(matches!(d.item(ItemId(frame)).unwrap().content, designcraft_doc::Content::Unassigned));
assert_eq!(s.doc().unwrap().selection, Selection::items(vec![ItemId(frame)]));
d.check().unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(test)]