From 1ee36c2c0701b670714dff85bf2f8d56fa52e05d Mon Sep 17 00:00:00 2001 From: Diego Gomez Garcia Date: Fri, 9 Oct 2026 01:05:11 +0200 Subject: [PATCH] Fix pasted type losing its layout bounds cache Type pasted or imported from another app (Illustrator, PDF, SVG) arrives without cached_bounds, which is not serialized. TextObject::bounds() then falls back to estimate_bounds(), a rough 0.55 em per character, so the selection box came up short of the last character and alignment looked off until the file was saved and reopened (Session::add_document refreshes it). Refresh the bounds of pasted text in paste_clip, covering paste and paste in front/back/in place and every clipboard flavour, with a regression test. --- crates/engine/src/cmd/clipboard.rs | 20 ++++++++++++++++++++ crates/engine/src/cmd/edit.rs | 3 +++ crates/engine/src/cmd/typecmd.rs | 14 ++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index 50b81bfe..63f8af70 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -237,6 +237,26 @@ mod tests { assert!((b.width() - 30.0).abs() < 1e-6 && (b.height() - 40.0).abs() < 1e-6); } + #[test] + fn pasted_type_gets_its_layout_bounds_cache() { + // Reported from Illustrator: pasted type kept no layout bounds, so its box (and alignment) + // fell back to the rough estimate until the file was saved and reopened. + let mut s = session(); + let svg = r#"WWWWWWWW"#; + s.execute("clipboard.importSvg", &json!({"svg": svg})).unwrap(); + s.execute("edit.paste", &json!({})).unwrap(); + let mut checked = 0; + s.doc().unwrap().doc.walk(|n| { + let vectorcraft_doc::NodeKind::Text(t) = &n.kind else { return }; + let cached = t.cached_bounds.expect("pasted type computed its bounds"); + let real = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).bounds; + assert!((cached.width() - real.width()).abs() < 1e-6, "{cached:?} {real:?}"); + assert!(cached.width() > t.estimate_bounds().width() + 1.0, "{cached:?} is just the estimate"); + checked += 1; + }); + assert_eq!(checked, 1, "one text object pasted"); + } + #[test] fn paste_never_lands_in_an_object_that_reused_an_undone_layers_id() { // Found by the model-based test: undo restores the id counter, so a compound path made diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index e601717a..8f2b5cec 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -364,6 +364,9 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho } } sel.set(new_ids.iter().copied()); + // Pasted type arrives without the layout bounds cache: fill it so boxes and alignment are + // right away (opening the file later would, but paste must not depend on a reopen). + super::typecmd::refresh_bounds_of(d, &new_ids); Ok((new_ids, imported)) })?; Ok(json!({ diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index f0cb2ff3..8935b325 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -103,6 +103,20 @@ pub(crate) fn refresh_bounds(t: &mut TextObject) { t.cached_bounds = Some(lay.bounds); } +/// Recompute the layout bounds cache for the text among `ids` (and their descendants) that lacks +/// it. Pasted or imported type arrives without the cache (not serialized), so selection boxes, the +/// Transform panel and hit testing would fall back to [`TextObject::estimate_bounds`] (a rough +/// 0.55 em per character) until the text is edited — the box comes up short and alignment looks off. +pub(crate) fn refresh_bounds_of(d: &mut Document, ids: &[NodeId]) { + for id in text_ids(d, ids) { + if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) + && t.cached_bounds.is_none() + { + refresh_bounds(t); + } + } +} + /// Text objects among `ids` and their descendants. fn text_ids(d: &Document, ids: &[NodeId]) -> Vec { let mut out = vec![];