From 329806477e0bd05f7bf05f16e44d06fdb9cfb5f8 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 07:45:10 -0800 Subject: [PATCH] Data merge: an explicit QR role in text is refused, and the caret follows each field data.placeholder.add with role qr and the insertion point in text bound the QR code to the first selected frame, which the caret was not in. It now returns "qr placeholders bind to a frame", as a QR field there does. A field inserted at the insertion point left the caret before its marker, so a second field went in front of the first. The caret now moves past the marker, as after typing. Docs: docs/agents.md. --- crates/engine/src/cmd/datamerge.rs | 9 +++++- crates/engine/src/cmd/datamerge/tests.rs | 38 ++++++++++++++++++++++++ docs/agents.md | 5 ++-- 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/datamerge.rs b/crates/engine/src/cmd/datamerge.rs index e03da2d05..c57d507fb 100644 --- a/crates/engine/src/cmd/datamerge.rs +++ b/crates/engine/src/cmd/datamerge.rs @@ -485,7 +485,9 @@ fn add_placeholder(d: &mut Document, sel: &mut Selection, p: &Value, name: &str) } else if explicit_story || role_param == Some("text") { false } else if role_param == Some("qr") { - true + // A QR role with the insertion point in text is refused below rather than bound to a + // selected frame the caret is not in. + sel.text.is_none() } else if role_param == Some("image") { // An image role with the insertion point in text places an inline image there. sel.text.is_none() @@ -585,6 +587,11 @@ fn add_placeholder(d: &mut Document, sel: &mut Selection, p: &Value, name: &str) st.replace(at..at, &marker); } d.adjust_text_ranges(story, at..at, marker.len()); + // The caret moves past the field, as after typing, so the next field goes after it. + if let Some(t) = sel.text.as_mut().filter(|t| t.story == story && t.cell.is_none()) { + let shift = |pos: usize| if pos >= at { pos + marker.len() } else { pos }; + (t.anchor, t.focus) = (shift(t.anchor), shift(t.focus)); + } PlaceholderAnchor::Text { story, start: at, end: at.saturating_add(marker.len()) } } else { let text = d.story(story).map(|st| st.text.clone()).unwrap_or_default(); diff --git a/crates/engine/src/cmd/datamerge/tests.rs b/crates/engine/src/cmd/datamerge/tests.rs index 5f5c4a118..9180964c2 100644 --- a/crates/engine/src/cmd/datamerge/tests.rs +++ b/crates/engine/src/cmd/datamerge/tests.rs @@ -831,6 +831,44 @@ fn a_qr_field_at_the_insertion_point_is_refused() { assert!(s.documents()[0].doc.data_merge.placeholders.is_empty()); } +/// An explicit QR role at a text insertion point is refused too, rather than binding the QR code +/// to a selected frame the caret is not in. +#[test] +fn an_explicit_qr_role_at_the_insertion_point_is_refused() { + let mut s = Session::new(); + new_doc(&mut s, 1); + select_csv(&mut s, "Name,#Code\nAda,hello\n"); + let made = text_frame(&mut s, [36.0, 36.0, 300.0, 300.0], "Hi ", None); + let other = text_frame(&mut s, [36.0, 400.0, 300.0, 600.0], "", None); + let other_id = designcraft_doc::ItemId(other["id"].as_u64().unwrap()); + let sid = StoryId(made["story"].as_u64().unwrap()); + let doc = s.doc_mut().unwrap(); + doc.selection = Selection::text(TextSel { story: sid, anchor: 3, focus: 3, frame: None, cell: None }); + doc.selection.items = vec![other_id]; + let msg = err(&mut s, "data.placeholder.add", json!({"field": "Code", "role": "qr"})); + assert!(msg.contains("bind to a frame"), "{msg}"); + let doc = &s.documents()[0].doc; + assert!(doc.data_merge.placeholders.is_empty()); + assert_eq!(doc.item(other_id).unwrap().label, "", "the other frame is not bound"); +} + +/// Fields inserted one after another at the insertion point keep their order: the caret moves +/// past each marker, as after typing. +#[test] +fn fields_inserted_at_the_insertion_point_keep_their_order() { + let mut s = Session::new(); + new_doc(&mut s, 1); + select_csv(&mut s, "Name,City,@Photo\nAda,Paris,/x.png\n"); + let made = text_frame(&mut s, [36.0, 36.0, 300.0, 300.0], "Hi ", None); + exec(&mut s, "text.select", json!({"story": made["story"], "anchor": 3, "focus": 3})); + exec(&mut s, "data.placeholder.add", json!({"field": "Name", "role": "text"})); + exec(&mut s, "data.placeholder.add", json!({"field": "City", "role": "text"})); + exec(&mut s, "data.placeholder.add", json!({"field": "Photo"})); + assert_eq!(texts(&s), vec!["Hi <><><>".to_string()]); + let t = s.documents()[0].selection.text.unwrap(); + assert_eq!((t.anchor, t.focus), (28, 28)); +} + /// A typed `<>` marker for an image column places the image too, from an absolute path; /// an explicit text role still writes the path as text. #[test] diff --git a/docs/agents.md b/docs/agents.md index b2ec4c156..8058510e6 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -91,8 +91,9 @@ external PDF/X certification. marks text or a frame as a field (`role`: text, image, qr, hyperlink). An image field (a `@` column) inserted at a text insertion point, or typed there as `<>`, merges as an inline image at its natural size, scaled down to fit the frame. The merge writes only text into a table cell or a note: an image field inserted there is refused unless `role` -is `text`, and its `<>` marker, inserted or typed, merges as the path. A QR field (a `#` column) inserted at a -text insertion point is refused; bind it to a frame instead. An image +is `text`, and its `<>` marker, inserted or typed, merges as the path. A QR field (a `#` column) or `role: "qr"` +at a text insertion point is refused; bind it to a frame with `item` instead. A field inserted at the insertion point +puts the caret after its marker. An image path is absolute or relative to the data file's folder, then the document's; a path that starts with one separator (`\Photos\a.jpg`) is tried from the drive's root first. `data.options` sets records, tiling and image fitting; `data.preview {record}` / `data.preview.stop` show one record without saving it.