collective

Browse / github.com/storytold/designcraft / patch

Copy: copied text gets text anchors and tables of its own

Copy: copied text gets text anchors and tables of its own Edit › Duplicate, Paste and Duplicate Spread copy a text frame's story through `object::renumber`, which gave the copy a new story id but kept its text anchor ids and table ids. The copy and the original shared them, so a cross-reference in the copy resolved to the original's paragraph (`Document::find_anchor` looks in the story the id was made in first), and `table.*` commands given `{table}` alone changed whichever story `find_table` found first. A cross-reference inserts a text anchor at the start of its destination paragraph ([Cross-references](https://web.archive.org/web/20250101165015/ht

Status
open
Score
0.00
Adopters
0 other operators
Author
dotCooCoo (imported from pull request #1843, open)
Base
18246dddd2c9af3bd062b0e69781a040b34ff9b2
Size
+272 −9 in 2 files
Review
pending
Shared
2026-10-28
Id
b24ab25bec643ce32dd0b149

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/cmd/edit.rs crates/engine/src/cmd/object.rs

diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs--- a/crates/engine/src/cmd/edit.rs+++ b/crates/engine/src/cmd/edit.rs@@ -91,15 +91,17 @@ fn paste_text(s: &mut Session, text: Option<String>, formatted: bool) -> Result<                 let mut stories = std::collections::HashMap::new();                 let fresh: Vec<designcraft_doc::Item> = list                     .iter()-                    .map(|i| {-                        let mut i = i.clone();-                        super::object::renumber(d, &items, &mut i, &mut stories);+                    .map(|orig| {+                        let mut i = orig.clone();+                        if !super::object::renumber(d, &items, &mut i, &mut stories) {+                            return Err(designcraft_doc::DocError::Invalid(format!("{} nests groups too deeply to copy", orig.id)));+                        }                         if d.layer(i.layer).is_none() {                             i.layer = d.default_layer();                         }-                        i+                        Ok(i)                     })-                    .collect();+                    .collect::<std::result::Result<_, _>>()?;                 let end = super::anchored::anchor_items(d, t.story, r.start, fresh, &Default::default())?;                 sel.text = Some(designcraft_doc::TextSel { anchor: end, focus: end, ..t });                 Ok(json!({"pos": end, "anchored": list.len()}))diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs--- a/crates/engine/src/cmd/object.rs+++ b/crates/engine/src/cmd/object.rs@@ -1056,7 +1056,9 @@ pub fn duplicate_from(dst: &mut Document, src: &Document, ids: &[ItemId], to: Sp         let Some(it) = src.item(*id) else { continue };         let mut copy = it.clone();         copy.xf = Affine::translate(off) * copy.xf;-        renumber(dst, src, &mut copy, &mut story_map);+        if !renumber(dst, src, &mut copy, &mut story_map) {+            return Err(designcraft_doc::DocError::Invalid(format!("{id} nests groups too deeply to copy")).into());+        }         if dst.layer(copy.layer).is_none() {             copy.layer = dst.default_layer();         }@@ -1067,6 +1069,9 @@ pub fn duplicate_from(dst: &mut Document, src: &Document, ids: &[ItemId], to: Sp     Ok(out) } +/// Groups nesting deeper than this are not copied: [`renumber`] reports them.+const MAX_COPY_DEPTH: usize = 64;+ /// The id to reference in `dst` for asset `id` of `src`. When `src` lacks the asset (a missing graphic), the reference /// gets a fresh id from `dst`, so it stays missing: `src`'s id may already name, or later be allocated to, another /// image in `dst`.@@ -1074,8 +1079,15 @@ fn adopt_or_dangle(dst: &mut Document, src: &Document, id: AssetId) -> AssetId {     dst.adopt_asset(src, id).unwrap_or_else(|| AssetId(dst.alloc())) } -/// Give `it` (an item of `src`) fresh ids from `dst`, with copies of its stories and the assets it uses.-pub(crate) fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut HashMap<StoryId, StoryId>) {+/// Give `it` (an item of `src`) fresh ids from `dst`, with copies of its stories and the assets it uses. `stories` maps+/// the stories of `src` copied so far to their copies. False when its groups nest deeper than [`MAX_COPY_DEPTH`]: the+/// items below that keep their ids, and the copy must not be used.+pub(crate) fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut HashMap<StoryId, StoryId>) -> bool {+    renumber_in(dst, src, it, stories, 0)+}++/// [`renumber`] for an item `depth` groups deep.+fn renumber_in(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut HashMap<StoryId, StoryId>, depth: usize) -> bool {     it.id = ItemId(dst.alloc());     let my_id = it.id;     if let Some(m) = it.media.as_mut()@@ -1091,6 +1103,7 @@ pub(crate) fn renumber(dst: &mut Document, src: &Document, it: &mut Item, storie                     let n = StoryId(dst.alloc());                     let mut st = src.story(tf.story).cloned().unwrap_or_else(|| Story::new(n));                     st.id = n;+                    renumber_marks(dst, src, stories, &mut st);                     let mut assets = HashMap::new();                     for a in designcraft_doc::story_asset_refs(&st) {                         let na = adopt_or_dangle(dst, src, a);@@ -1114,12 +1127,99 @@ pub(crate) fn renumber(dst: &mut Document, src: &Document, it: &mut Item, storie             g.asset = adopt_or_dangle(dst, src, g.asset);         }         Content::Group { items } => {+            if depth >= MAX_COPY_DEPTH {+                return false;+            }+            let mut complete = true;             for c in items.iter_mut() {-                renumber(dst, src, Arc::make_mut(c), stories);+                complete &= renumber_in(dst, src, Arc::make_mut(c), stories, depth + 1);             }+            return complete;         }         Content::Unassigned => {}     }+    true+}++/// Give the text anchors and tables of `st`, a new copy of a story of `src`, ids of their own (anchors from the story's+/// id, tables from `dst`). Cross-references in `st` and in the copies `stories` already lists point at the copies of+/// their destinations where those are copied, and the copy's paragraphs follow its tables.+fn renumber_marks(dst: &mut Document, src: &Document, stories: &HashMap<StoryId, StoryId>, st: &mut Story) {+    let base = st.id.0 << 24;+    let mut anchors = HashMap::new();+    for (k, a) in (1..).zip(st.anchors.iter_mut()) {+        anchors.insert(a.id, base + k);+        Arc::make_mut(a).id = base + k;+    }+    for (old, new) in stories {+        if let (Some(o), Some(n)) = (src.story(*old), dst.story(*new)) {+            anchors.extend(o.anchors.iter().zip(&n.anchors).map(|(a, b)| (a.id, b.id)));+        }+    }+    // An earlier copy's targets are found again from its original: a new anchor id can be another original's.+    for (old, new) in stories {+        if let (Some(o), Some(copy)) = (src.story(*old), dst.story_mut(*new)) {+            retarget_xrefs(copy, o, &anchors, 0);+        }+    }+    remap_xrefs(st, &anchors, 0);+    let mut tables = HashMap::new();+    for (old, mut table) in std::mem::take(&mut st.tables) {+        let id = dst.alloc();+        Arc::make_mut(&mut table).id = id;+        tables.insert(old, id);+        st.tables.insert(id, table);+    }+    for pf in &mut st.paras {+        pf.table = pf.table.map(|t| tables.get(&t).copied().unwrap_or(t));+    }+}++/// Point the cross-references in `st`, and in the stories of its table cells and notes down to [`MAX_COPY_DEPTH`]+/// levels, at the anchors `map` gives for their targets.+fn remap_xrefs(st: &mut Story, map: &HashMap<u64, u64>, depth: usize) {+    if map.is_empty() {+        return;+    }+    for x in &mut st.xrefs {+        if let Some(n) = map.get(&x.target) {+            Arc::make_mut(x).target = *n;+        }+    }+    if depth >= MAX_COPY_DEPTH {+        return;+    }+    for t in st.tables.values_mut() {+        for c in &mut Arc::make_mut(t).cells {+            remap_xrefs(&mut c.text, map, depth + 1);+        }+    }+    for n in st.notes.iter_mut().chain(st.endnotes.iter_mut()) {+        remap_xrefs(&mut Arc::make_mut(n).text, map, depth + 1);+    }+}++/// Point the cross-references in `copy`, a copy of `orig`, and in the stories of its table cells and notes down to+/// [`MAX_COPY_DEPTH`] levels, at the anchors `map` gives for the targets of the matching references in `orig`.+fn retarget_xrefs(copy: &mut Story, orig: &Story, map: &HashMap<u64, u64>, depth: usize) {+    for (x, o) in copy.xrefs.iter_mut().zip(&orig.xrefs) {+        let target = map.get(&o.target).copied().unwrap_or(o.target);+        if x.target != target {+            Arc::make_mut(x).target = target;+        }+    }+    if depth >= MAX_COPY_DEPTH {+        return;+    }+    // Copied tables have new ids, given in the order of the old ones, so the two maps list them in the same order.+    for (t, o) in copy.tables.values_mut().zip(orig.tables.values()) {+        for (c, oc) in Arc::make_mut(t).cells.iter_mut().zip(&o.cells) {+            retarget_xrefs(&mut c.text, &oc.text, map, depth + 1);+        }+    }+    for (n, o) in copy.notes.iter_mut().zip(&orig.notes).chain(copy.endnotes.iter_mut().zip(&orig.endnotes)) {+        retarget_xrefs(&mut Arc::make_mut(n).text, &o.text, map, depth + 1);+    } }  fn transform_move(s: &mut Session, p: &Value) -> Result<Value> {@@ -5078,6 +5178,167 @@ mod copy_ids_tests {         assert_eq!((r["created"].as_u64(), ids(&r).len()), (Some(3), 3), "{r}");         assert!(ids(&r).iter().all(|i| s.doc().unwrap().doc.item(designcraft_doc::ItemId(*i)).is_some()));     }++    /// A selected text frame whose story holds a paragraph anchor, a cross-reference to it and a table.+    fn marked_frame(s: &mut crate::Session) -> (u64, designcraft_doc::StoryId) {+        s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap();+        let text = json!({"rect": [72, 72, 400, 400], "content": "text", "text": "Results\nSee "});+        let f = s.execute("frame.create", &text).unwrap()["id"].as_u64().unwrap();+        let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().text_frame().unwrap().story;+        s.execute("text.select", &json!({"story": sid.0, "anchor": 12, "focus": 12})).unwrap();+        s.execute("xref.insert", &json!({"paragraph": "Results"})).unwrap();+        s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap();+        s.execute("selection.set", &json!({"ids": [f]})).unwrap();+        (f, sid)+    }++    /// The copy of story `orig` made for item `copy` has a text anchor and a table of its own, and its cross-reference+    /// points at its own anchor. A table command naming the copy's table changes the copy alone.+    fn assert_own_marks(s: &mut crate::Session, orig: designcraft_doc::StoryId, copy: u64) {+        let d = s.doc().unwrap().doc.clone();+        let sid = d.item(designcraft_doc::ItemId(copy)).unwrap().text_frame().unwrap().story;+        assert_ne!(sid, orig);+        let (o, c) = (d.story(orig).unwrap(), d.story(sid).unwrap());+        let (oa, ca) = (o.anchors[0].id, c.anchors[0].id);+        assert_ne!(oa, ca, "the copy's anchor has an id of its own");+        assert_eq!(d.find_anchor(ca).map(|(at, _)| at), Some(sid));+        assert_eq!((o.xrefs[0].target, c.xrefs[0].target), (oa, ca), "each cross-reference points at its own story's anchor");+        let (ot, ct) = (*o.tables.keys().next().unwrap(), *c.tables.keys().next().unwrap());+        assert_ne!(ot, ct, "the copy's table has an id of its own");+        assert_eq!(c.tables[&ct].id, ct);+        assert_eq!(c.paras.iter().filter_map(|pf| pf.table).collect::<Vec<_>>(), [ct], "the copied paragraph holds the copied table");+        d.check().unwrap();+        // The table commands are enabled with the insertion point in a table: here the original's.+        let cell = designcraft_doc::CellAddr { table: ot, row: 0, col: 0 };+        s.edit(|_, sel| {+            sel.text = Some(designcraft_doc::TextSel { story: orig, anchor: 0, focus: 0, frame: None, cell: Some(cell) });+            Ok(())+        })+        .unwrap();+        s.execute("table.insertRow", &json!({"table": ct})).unwrap();+        let d = &s.doc().unwrap().doc;+        assert_eq!(d.story(orig).unwrap().tables[&ot].nrows(), 1, "the original table is unchanged");+        assert_eq!(d.story(sid).unwrap().tables[&ct].nrows(), 2);+    }++    #[test]+    fn duplicated_text_gets_anchors_and_tables_of_its_own() {+        let mut s = crate::Session::new();+        let (_, sid) = marked_frame(&mut s);+        let copy = s.execute("edit.duplicate", &json!({})).unwrap()["ids"][0].as_u64().unwrap();+        assert_own_marks(&mut s, sid, copy);+    }++    #[test]+    fn pasted_text_gets_anchors_and_tables_of_its_own() {+        let mut s = crate::Session::new();+        let (_, sid) = marked_frame(&mut s);+        s.execute("edit.copy", &json!({})).unwrap();+        let copy = s.execute("edit.paste", &json!({})).unwrap()["ids"][0].as_u64().unwrap();+        assert_own_marks(&mut s, sid, copy);+    }++    #[test]+    fn duplicated_spreads_get_anchors_and_tables_of_their_own() {+        let mut s = crate::Session::new();+        let (f, sid) = marked_frame(&mut s);+        s.execute("layout.pages.duplicateSpread", &json!({"spread": 0})).unwrap();+        let d = &s.doc().unwrap().doc;+        let copy = d.spreads[1].items.iter().find(|i| i.text_frame().is_some()).unwrap().id.0;+        assert_ne!(copy, f);+        assert_own_marks(&mut s, sid, copy);+    }++    #[test]+    fn groups_nested_too_deeply_are_not_copied() {+        let mut s = crate::Session::new();+        s.execute("file.new", &json!({})).unwrap();+        let frame = |s: &mut crate::Session| s.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap()["id"].as_u64().unwrap();+        let mut g = frame(&mut s);+        for _ in 0..=super::MAX_COPY_DEPTH {+            let other = frame(&mut s);+            g = s.execute("object.group", &json!({"ids": [g, other]})).unwrap()["id"].as_u64().unwrap();+        }+        let text = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300], "content": "text", "text": "Host"})).unwrap()["id"].as_u64();+        let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(text.unwrap())).unwrap().text_frame().unwrap().story;+        let before = s.doc().unwrap().doc.clone();+        assert!(s.execute("transform.move", &json!({"dx": 10, "dy": 0, "copy": true, "ids": [g]})).is_err());+        assert_eq!(s.doc().unwrap().doc, before, "nothing is copied");+        // Nor pasted into text as an anchored object.+        s.execute("selection.set", &json!({"ids": [g]})).unwrap();+        s.execute("edit.copy", &json!({})).unwrap();+        s.execute("text.select", &json!({"story": sid.0, "anchor": 4, "focus": 4})).unwrap();+        assert!(s.execute("edit.paste", &json!({})).is_err());+        assert_eq!(s.doc().unwrap().doc, before, "nothing is pasted");+    }++    #[test]+    fn pasted_cross_references_follow_the_pasted_anchors_of_other_stories() {+        let mut s = crate::Session::new();+        s.execute("file.new", &json!({})).unwrap();+        let frame = |s: &mut crate::Session, rect: [f64; 4], text: &str| {+            let id = s.execute("frame.create", &json!({"rect": rect, "content": "text", "text": text})).unwrap()["id"].as_u64().unwrap();+            (id, s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().text_frame().unwrap().story)+        };+        // Each story refers to the other, so one of the two is pasted before its destination.+        let (a, first) = frame(&mut s, [72.0, 72.0, 300.0, 200.0], "Methods, see ");+        let (b, second) = frame(&mut s, [72.0, 300.0, 300.0, 400.0], "Results, see ");+        s.execute("text.select", &json!({"story": first.0, "anchor": 13, "focus": 13})).unwrap();+        s.execute("xref.insert", &json!({"paragraph": "Results"})).unwrap();+        s.execute("text.select", &json!({"story": second.0, "anchor": 13, "focus": 13})).unwrap();+        s.execute("xref.insert", &json!({"paragraph": "Methods"})).unwrap();+        s.execute("selection.set", &json!({"ids": [a, b]})).unwrap();+        s.execute("edit.copy", &json!({})).unwrap();+        // Into another document, which holds no anchor of the original's: its stories get other ids.+        s.execute("file.new", &json!({})).unwrap();+        for _ in 0..3 {+            frame(&mut s, [0.0, 0.0, 50.0, 50.0], "");+        }+        s.execute("selection.set", &json!({"ids": []})).unwrap();+        let ids = s.execute("edit.paste", &json!({})).unwrap()["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect::<Vec<_>>();+        let d = &s.doc().unwrap().doc;+        let story = |id: u64| d.story(d.item(designcraft_doc::ItemId(id)).unwrap().text_frame().unwrap().story).unwrap();+        for (refers, dest) in [(story(ids[0]), story(ids[1])), (story(ids[1]), story(ids[0]))] {+            assert_eq!(refers.xrefs[0].target, dest.anchors[0].id, "the cross-reference points at the pasted destination");+            assert_eq!(d.find_anchor(refers.xrefs[0].target).map(|(at, _)| at), Some(dest.id));+        }+    }++    /// The first copy's new anchor id is the second original's anchor id, as can happen between two documents.+    #[test]+    fn copied_cross_references_keep_their_destination_when_new_ids_repeat_old_ones() {+        let mut s = crate::Session::new();+        s.execute("file.new", &json!({})).unwrap();+        s.edit(|d, _| {+            d.next_id += 1000;+            Ok(())+        })+        .unwrap();+        let frame = |s: &mut crate::Session, rect: [f64; 4], text: &str| {+            let id = s.execute("frame.create", &json!({"rect": rect, "content": "text", "text": text})).unwrap()["id"].as_u64().unwrap();+            (designcraft_doc::ItemId(id), s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().text_frame().unwrap().story)+        };+        let (a, first) = frame(&mut s, [72.0, 72.0, 300.0, 200.0], "Methods, see ");+        let (b, second) = frame(&mut s, [72.0, 300.0, 300.0, 400.0], "Results");+        s.execute("text.select", &json!({"story": first.0, "anchor": 13, "focus": 13})).unwrap();+        s.execute("xref.insert", &json!({"paragraph": "Methods"})).unwrap();+        s.execute("text.select", &json!({"story": second.0, "anchor": 0, "focus": 0})).unwrap();+        s.execute("anchor.create", &json!({"name": "Results"})).unwrap();+        let src = s.doc().unwrap().doc.as_ref().clone();+        assert_eq!(src.story(second).unwrap().anchors[0].id, (second.0 << 24) + 1);+        // The copy of `first` gets story id `second`: one id goes to the frame, the next to its story.+        let mut t = crate::Session::new();+        t.execute("file.new", &json!({})).unwrap();+        let mut dst = t.doc().unwrap().doc.as_ref().clone();+        assert!(dst.next_id < second.0 - 2);+        dst.next_id = second.0 - 2;+        let ids = super::duplicate_from(&mut dst, &src, &[a, b], designcraft_doc::SpreadRef::Doc(0), designcraft_geom::Vec2::ZERO).unwrap();+        let story = |id: designcraft_doc::ItemId| dst.story(dst.item(id).unwrap().text_frame().unwrap().story).unwrap();+        let (one, two) = (story(ids[0]), story(ids[1]));+        assert_eq!(one.id, second);+        assert_eq!(one.xrefs[0].target, one.anchors[0].id, "the copy still refers to its own paragraph");+        assert_ne!(two.anchors[0].id, one.anchors[0].id);+    } }  #[cfg(test)]