diff --git a/crates/engine/src/cmd/file/place.rs b/crates/engine/src/cmd/file/place.rs index 81126030f..c412818b6 100644 --- a/crates/engine/src/cmd/file/place.rs +++ b/crates/engine/src/cmd/file/place.rs @@ -126,10 +126,12 @@ pub(super) fn file_place(s: &mut Session, p: &Value) -> Result { let id = if let Some(fid) = target_frame { let it = d.item_mut(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?; let r = it.inner_bounds(); - // Fill the frame proportionally once. Auto-Fit stays off, so resizing the frame crops. + // Over the graphic it replaces, so the frame's crop and zoom carry over; else fill the + // frame proportionally once. Auto-Fit stays off, so resizing the frame crops. let k = (r.width() / bw).max(r.height() / bh); let (gw, gh) = (bw * k, bh * k); - let xf = Affine::translate((r.x0 + (r.width() - gw) / 2.0, r.y0 + (r.height() - gh) / 2.0)) * Affine::scale(k); + let fill = Affine::translate((r.x0 + (r.width() - gw) / 2.0, r.y0 + (r.height() - gh) / 2.0)) * Affine::scale(k); + let xf = in_place_of(it, (bw, bh)).unwrap_or(fill); validate_placement((bw, bh), r, xf, "file.place")?; it.content = Content::Graphic(Graphic { layer_options: None, @@ -214,6 +216,32 @@ fn eps_size(bytes: &[u8], command: &str) -> Result> { } /// Fitting can overflow even with finite inputs. Never persist NaN/Inf as JSON nulls. +/// Where a graphic of natural `size` goes when it replaces the one `frame` shows (in the frame, or +/// as the one graphic pasted into it): over the same area, scaled to cover it and centred on it, +/// so the frame's crop and zoom carry over. An edited copy of the same picture lands exactly where +/// the old one was. `None` when the frame shows no graphic, or the result isn't finite. +fn in_place_of(frame: &Item, size: (f64, f64)) -> Option { + let (old, outer) = match &frame.content { + Content::Graphic(g) => (g, Affine::IDENTITY), + Content::Group { items } if frame.has_nested_items() => match items.as_slice() { + [c] => match &c.content { + Content::Graphic(g) => (g, c.xf), + _ => return None, + }, + _ => return None, + }, + _ => return None, + }; + let ((ow, oh), (nw, nh)) = (old.size, size); + // Written so a NaN size fails too. + if !(ow > 0.0 && oh > 0.0 && nw > 0.0 && nh > 0.0) { + return None; + } + let k = (ow / nw).max(oh / nh); + let xf = outer * old.xf * Affine::translate(((ow - nw * k) / 2.0, (oh - nh * k) / 2.0)) * Affine::scale(k); + xf.as_coeffs().iter().all(|n| n.is_finite()).then_some(xf) +} + pub(crate) fn validate_placement(size: (f64, f64), frame: Rect, xf: Affine, command: &str) -> Result<()> { let valid_rect = |r: Rect| [r.x0, r.y0, r.x1, r.y1, r.width(), r.height()].iter().all(|n| n.is_finite()) && r.width() >= 0.0 && r.height() >= 0.0; if !size.0.is_finite() @@ -265,11 +293,13 @@ pub(super) fn place_drop(s: &mut Session, p: &Value) -> Result { let (x, y) = (crate::cmd::f64_or(p, "x", 0.0), crate::cmd::f64_or(p, "y", 0.0)); let d = &s.doc()?.doc; let (frame_rect, xf, auto_fit) = if let Some(fid) = frame { - let r = d.item(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?.inner_bounds(); + let it = d.item(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?; + let r = it.inner_bounds(); let k = (r.width() / nw).max(r.height() / nh); - let xf = Affine::translate((r.x0 + (r.width() - nw * k) / 2.0, r.y0 + (r.height() - nh * k) / 2.0)) * Affine::scale(k); - // Fill the frame proportionally once. Auto-Fit stays off, so resizing the frame crops. - (r, xf, Default::default()) + let fill = Affine::translate((r.x0 + (r.width() - nw * k) / 2.0, r.y0 + (r.height() - nh * k) / 2.0)) * Affine::scale(k); + // Over the graphic it replaces (the frame's crop and zoom carry over), else fill the frame + // proportionally once. Auto-Fit stays off, so resizing the frame crops. + (r, in_place_of(it, (nw, nh)).unwrap_or(fill), Default::default()) } else { if d.spread(sr).is_none() { return Err(bad("place.drop", "no such spread")); @@ -803,6 +833,70 @@ mod nested_frame_tests { assert_eq!(d.spreads[0].items.len(), 1, "no new object"); } + /// A graphic replacing the one a frame shows keeps the frame's crop and zoom: one of the same + /// proportions lands exactly over the old one (in the frame or pasted into it), another covers + /// the same area, centred on it. + #[test] + fn a_replaced_graphic_keeps_the_crop_and_zoom() { + let png_of = |w: u32, h: u32, red: u8| { + let png = designcraft_render::Rendered { width: w, height: h, pixels: [red, 90, 30, 255].repeat((w * h) as usize) }.to_png(); + super::super::base64_encode(&png) + }; + let graphic = |s: &Session, id: ItemId| match &s.doc().unwrap().doc.item(id).unwrap().content { + Content::Graphic(g) => g.clone(), + other => panic!("a graphic: {other:?}"), + }; + let close = |a: Affine, b: Affine| a.as_coeffs().iter().zip(b.as_coeffs()).all(|(x, y)| (x - y).abs() < 1e-9); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let frame = s.execute("frame.create", &json!({"rect": [100, 100, 200, 160]})).unwrap()["id"].as_u64().unwrap(); + let frame = ItemId(frame); + s.execute("file.place", &json!({"base64": png_of(40, 20, 200), "name": "a.png", "frame": frame.0})).unwrap(); + // Zoomed in and moved within the frame. + let zoomed = Affine::translate((90.0, 95.0)) * Affine::scale(5.0); + s.edit(|d, _| { + if let Content::Graphic(g) = &mut d.item_mut(frame).unwrap().content { + g.xf = zoomed; + } + Ok(()) + }) + .unwrap(); + // Same proportions: exactly where the old one was. + s.execute("file.place", &json!({"base64": png_of(80, 40, 20), "name": "b.png", "frame": frame.0})).unwrap(); + let g = graphic(&s, frame); + let shown = |g: &designcraft_doc::Graphic| g.xf.transform_rect_bbox(Rect::new(0.0, 0.0, g.size.0, g.size.1)); + assert!(close(g.xf * Affine::scale(g.size.0 / 40.0), zoomed), "{:?}", g.xf); + assert_eq!(shown(&g), Rect::new(90.0, 95.0, 290.0, 195.0)); + // Other proportions: covers the same area, centred. + s.execute("file.place", &json!({"base64": png_of(20, 20, 60), "name": "c.png", "frame": frame.0})).unwrap(); + let c = shown(&graphic(&s, frame)); + assert!((c.width() - 200.0).abs() < 1e-9 && (c.height() - 200.0).abs() < 1e-9, "{c:?}"); + assert!((c.center().x - 190.0).abs() < 1e-9 && (c.center().y - 145.0).abs() < 1e-9, "{c:?}"); + // Pasted into the frame: the nested image's own transform carries over too. + s.edit(|d, _| { + let it = d.item_mut(frame).unwrap(); + let mut image = it.clone(); + image.id = ItemId(d.alloc()); + image.xf = Affine::translate((-7.0, 3.0)); + let it = d.item_mut(frame).unwrap(); + it.content = Content::Group { items: vec![Arc::new(image)] }; + Ok(()) + }) + .unwrap(); + let before = match &s.doc().unwrap().doc.item(frame).unwrap().content { + Content::Group { items } => match &items[0].content { + Content::Graphic(g) => items[0].xf.transform_rect_bbox(shown(g)), + other => panic!("a nested graphic: {other:?}"), + }, + other => panic!("a frame with a nested image: {other:?}"), + }; + s.execute("file.place", &json!({"base64": png_of(30, 30, 90), "name": "d.png", "frame": frame.0})).unwrap(); + let after = shown(&graphic(&s, frame)); + for (a, b) in [(after.x0, before.x0), (after.y0, before.y0), (after.x1, before.x1), (after.y1, before.y1)] { + assert!((a - b).abs() < 1e-9, "{after:?} is where the nested image was, {before:?}"); + } + } + /// A document whose top layer is hidden and locked (a dieline) opens with new objects going to /// the topmost layer that is shown and unlocked, not where they couldn't be seen or selected. #[test]