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)]
