From 20ab8b39e7a0018e3f6cb25f551c300fd2751626 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 25 Oct 2026 08:48:22 -0700 Subject: [PATCH 01/18] Layers: Duplicate Layer copies a layer's settings and objects above it layer.duplicate adds " copy" above a layer, with the layer's settings, a copy of every object on it and copies of its ruler guides, on document and parent pages, in one undo step. Copied text frames stay threaded to the copies of frames on the same layer and are not threaded to frames on other layers. The anchored objects in a copied story get new ids, and so do its tables and its text anchors outside table cells and notes; its cross-references point at the copied anchors. A group goes on the new layer with all its members, and a copied live caption follows the copied object. A copied override of a parent item overrides that item's copy, and is detached when the parent item is on another layer. Pages hide a parent item's copy where they hide the parent item. A layer whose objects nest more than 64 levels deep is refused. The Layers panel runs it from a layer's context menu (Duplicate Layer "") and when a layer is dropped on Create New Layer. --- ROADMAP.md | 1 + crates/engine/src/cmd/layout.rs | 626 +++++++++++++++++++++++++++- crates/ui-egui/src/panels/layers.rs | 32 +- docs/target-app-parity.md | 2 +- 4 files changed, 658 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 6a3696361..b5e8e41e5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -381,6 +381,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-25** · Duplicate Layer (`layer.duplicate`; the layer's context menu in the Layers panel, or a layer dropped on Create New Layer) adds a copy named after the layer with " copy" added ("Art copy" for Art) above the layer, with its settings, a copy of every object on it and copies of its ruler guides, on document and parent pages, in one undo step. Copied frames stay threaded to the copies of frames on the same layer and are not threaded to frames on other layers. A copied override of a parent item overrides that item's copy, and pages hide the copy where they hide the parent item. No readiness percentage change. - **2026-10-24** · Panels dock through the suite's shared docking (craft-libs), and on desktop a floating panel opens in its own window that can leave the main window: keys pressed there run the main window's shortcuts, clipboard and tool keys, and the canvas paints no tool cursor while another of the app's windows has the pointer. The web build keeps floating panels in the page. No readiness percentage change. - **2026-10-24** · Story Options rejects malformed or nonexistent explicit story/frame IDs without changing the selected story, including an invalid frame alongside a valid story. Null targets remain absent; omitted targets still use the selection. No readiness percentage change. - **2026-10-24** · Text Frame Options explains that a custom baseline-grid increment must be a measure greater than zero when zero, a negative value or a non-measure is entered. Validation is unchanged. No readiness percentage change. diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index d67f469f0..9e6c2d017 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -1,6 +1,9 @@ //! Layout menu and Pages/Layers panels: pages, spreads, parents, margins & columns, layers. -use designcraft_doc::{LAYER_COLORS, Layer, LayerId, Margins, SpreadId}; +use std::collections::HashMap; +use std::sync::Arc; + +use designcraft_doc::{Content, Guide, Item, ItemId, LAYER_COLORS, Layer, LayerId, Margins, SpreadId, SpreadRef, Story, StoryId}; use serde_json::{Value, json}; use super::{CommandSpec, bad, cmd, has_doc, ok, str_param}; @@ -770,6 +773,15 @@ pub fn specs() -> Vec { s.doc_mut()?.active_layer = r; Ok(json!({"id": r.0})) }), + cmd!( + "layer.duplicate", + "Duplicate Layer", + [], + None, + "{id} → {id}. Adds a copy of the layer's settings and objects (document and parent spreads) above it, named \" copy\"", + has_doc, + duplicate_layer + ), cmd!( "layer.set", "Layer Options…", @@ -1359,6 +1371,242 @@ fn set_layer(s: &mut Session, p: &Value) -> Result { }) } +/// Duplicate Layer: a new layer above `id` with its settings and a copy of every object on it, on document and parent +/// spreads, in one undo step. Copied text frames stay threaded to the other copied frames of their story, and each copy +/// of a story holds all of its text. A copied override of a parent item overrides that item's copy, or is detached when +/// the parent item is on another layer. Pages hide a parent item's copy where they hide the parent item. +fn duplicate_layer(s: &mut Session, p: &Value) -> Result { + const ID: &str = "layer.duplicate"; + let id = LayerId(p.get("id").and_then(Value::as_u64).ok_or_else(|| bad(ID, "missing id"))?); + let r = s.edit(|d, _| { + let i = d.layers.iter().position(|l| l.id == id).ok_or_else(|| bad(ID, "no such layer"))?; + let mut seen = std::collections::HashSet::new(); + if d.spreads.iter().chain(&d.parents).flat_map(|sp| sp.items.iter()).any(|it| it.layer == id && too_deep(it, d, &mut seen)) { + return Err(bad(ID, "the layer's objects are nested too deeply to copy")); + } + let new_id = LayerId(d.alloc()); + let mut layer = d.layers[i].clone(); + layer.id = new_id; + layer.name = copy_name(&d.layers, &layer.name); + d.layers.insert(i, layer); + let src = d.clone(); + let mut stories = HashMap::new(); + let mut ids = HashMap::new(); + let refs = (0..src.parents.len()).map(SpreadRef::Parent).chain((0..src.spreads.len()).map(SpreadRef::Doc)); + let mut copies = Vec::new(); + for r in refs { + let Some(sp) = src.spread(r) else { continue }; + for it in sp.items.iter().filter(|it| it.layer == id) { + let mut copy = Item::clone(it); + super::object::renumber(d, &src, &mut copy, &mut stories); + map_ids(it, ©, &mut ids); + copies.push((r, copy)); + } + } + // Every copy has its id before overrides are pointed at the copied parent items. + for (r, mut copy) in copies { + move_copy(&mut copy, new_id, &ids); + d.insert_item(r, copy, None)?; + } + // A story copy holds the same anchored objects as the original: they get ids of their own, and the stories of + // anchored text frames get copies in turn. + let mut pending: Vec = stories.values().copied().collect(); + while let Some(new) = pending.pop() { + let Some(mut st) = d.story(new).cloned() else { continue }; + renumber_anchored(d, &mut st, new_id, &mut stories, &mut ids, &mut pending); + d.stories.insert(new, Arc::new(st)); + } + // Each story copy gets its frames in the original order, text anchors of its own, which its cross-references + // and those of the other copies point at, and tables with ids of their own. + let mut anchors = HashMap::new(); + for (old, new) in &stories { + let Some(mut st) = d.story(*new).cloned() else { continue }; + st.frames = src.story(*old).map(|o| o.frames.iter().filter_map(|f| ids.get(f).copied()).collect()).unwrap_or_default(); + for (k, a) in (1..).zip(st.anchors.iter_mut()) { + let id = (new.0 << 24) + k; + anchors.insert(a.id, id); + Arc::make_mut(a).id = id; + } + let mut tables = HashMap::new(); + for (t, mut table) in std::mem::take(&mut st.tables) { + let n = d.alloc(); + Arc::make_mut(&mut table).id = n; + tables.insert(t, n); + st.tables.insert(n, table); + } + for pf in &mut st.paras { + pf.table = pf.table.and_then(|t| tables.get(&t).copied()); + } + d.stories.insert(*new, Arc::new(st)); + } + for new in stories.values() { + if let Some(st) = d.story_mut(*new) { + remap_xrefs(st, &anchors); + } + } + // A page, document or parent, hides a parent item's copy where it hides the parent item, and its ruler guides on + // the layer get copies on the new layer. + let hidden = |pg: &designcraft_doc::Page| -> Vec { pg.overridden.iter().filter_map(|o| ids.get(o).copied()).collect() }; + for sp in d.spreads.iter_mut().chain(d.parents.iter_mut()) { + if sp.pages.iter().all(|pg| hidden(pg).is_empty() && pg.guides.iter().all(|g| g.layer != Some(id))) { + continue; + } + for pg in &mut Arc::make_mut(sp).pages { + let copies = hidden(pg); + pg.overridden.extend(copies); + let guides: Vec<_> = pg.guides.iter().filter(|g| g.layer == Some(id)).map(|g| Guide { layer: Some(new_id), ..g.clone() }).collect(); + pg.guides.extend(guides); + } + } + Ok(new_id) + })?; + s.doc_mut()?.active_layer = r; + Ok(json!({"id": r.0})) +} + +/// The name of a copy of layer `name`: " copy", then " copy 2" and up while a layer has the name. +fn copy_name(layers: &[Layer], name: &str) -> String { + let base = format!("{name} copy"); + let taken = |n: &str| layers.iter().any(|l| l.name == n); + if !taken(&base) { + return base; + } + (2..).map(|k| format!("{base} {k}")).find(|n| !taken(n)).unwrap_or(base) +} + +/// Record each id of `orig` and its descendants against the matching id in `copy`. +fn map_ids(orig: &Item, copy: &Item, ids: &mut HashMap) { + ids.insert(orig.id, copy.id); + for (o, c) in orig.children().iter().zip(copy.children()) { + map_ids(o, c, ids); + } +} + +/// A layer whose groups, anchored objects, table cells and notes nest deeper than this within one story or object is +/// not duplicated. +const MAX_COPY_DEPTH: usize = 64; + +/// Whether `it`, or a story its text frames reach directly or through anchored text frames, nests groups, anchored +/// objects, table cells and notes deeper than [`MAX_COPY_DEPTH`]. Stories in `seen` were checked already and are skipped. +fn too_deep(it: &Item, d: &designcraft_doc::Document, seen: &mut std::collections::HashSet) -> bool { + fn item(it: &Item, depth: usize, next: &mut Vec) -> bool { + if let Some(t) = it.text_frame() { + next.push(t.story); + } + depth > MAX_COPY_DEPTH || it.children().iter().any(|c| item(c, depth + 1, next)) + } + fn story(st: &Story, depth: usize, next: &mut Vec) -> bool { + depth > MAX_COPY_DEPTH + || st.objects.iter().any(|o| item(&o.item, depth + 1, next)) + || st.tables.values().flat_map(|t| &t.cells).any(|c| story(&c.text, depth + 1, next)) + || st.notes.iter().chain(&st.endnotes).any(|n| story(&n.text, depth + 1, next)) + } + let mut next = Vec::new(); + if item(it, 0, &mut next) { + return true; + } + while let Some(sid) = next.pop() { + if seen.insert(sid) + && let Some(st) = d.story(sid) + && story(st, 0, &mut next) + { + return true; + } + } + false +} + +/// Give the items anchored in `st`, and in the stories of its table cells and notes, new ids from `d` (recorded in `ids`) +/// and layer `to`. An anchored text frame's story gets one copy, recorded in `stories`, that every copied frame of the +/// story shares; a new copy goes on `pending`, and its frame list is filled in afterwards. [`too_deep`] bounds the nesting. +fn renumber_anchored( + d: &mut designcraft_doc::Document, + st: &mut Story, + to: LayerId, + stories: &mut HashMap, + ids: &mut HashMap, + pending: &mut Vec, +) { + for o in &mut st.objects { + renumber_anchored_item(d, &mut Arc::make_mut(o).item, to, stories, ids, pending); + } + for t in st.tables.values_mut() { + for c in &mut Arc::make_mut(t).cells { + renumber_anchored(d, &mut c.text, to, stories, ids, pending); + } + } + for n in st.notes.iter_mut().chain(st.endnotes.iter_mut()) { + renumber_anchored(d, &mut Arc::make_mut(n).text, to, stories, ids, pending); + } +} + +/// Point the cross-references in `st`, and in the stories of its table cells and notes, at the anchors `map` gives for +/// their targets. [`too_deep`] bounds the nesting. +fn remap_xrefs(st: &mut Story, map: &HashMap) { + for x in &mut st.xrefs { + if let Some(n) = map.get(&x.target) { + Arc::make_mut(x).target = *n; + } + } + for t in st.tables.values_mut() { + for c in &mut Arc::make_mut(t).cells { + remap_xrefs(&mut c.text, map); + } + } + for n in st.notes.iter_mut().chain(st.endnotes.iter_mut()) { + remap_xrefs(&mut Arc::make_mut(n).text, map); + } +} + +/// [`renumber_anchored`] for one anchored item and its descendants. +fn renumber_anchored_item( + d: &mut designcraft_doc::Document, + it: &mut Item, + to: LayerId, + stories: &mut HashMap, + ids: &mut HashMap, + pending: &mut Vec, +) { + let id = ItemId(d.alloc()); + ids.insert(it.id, id); + it.id = id; + it.layer = to; + if let Content::Text(tf) = &mut it.content { + tf.story = match stories.get(&tf.story) { + Some(n) => *n, + None => { + let n = StoryId(d.alloc()); + let mut copy = d.story(tf.story).cloned().unwrap_or_else(|| Story::new(n)); + copy.id = n; + copy.frames.clear(); + d.stories.insert(n, Arc::new(copy)); + stories.insert(tf.story, n); + pending.push(n); + n + } + }; + } + for c in it.children_mut().into_iter().flatten() { + renumber_anchored_item(d, Arc::make_mut(c), to, stories, ids, pending); + } +} + +/// Put `it` and its descendants on layer `to`, and point overrides at the copied parent items and live captions at the +/// copied objects. A copy whose parent item was not copied is detached from it, so the original override stays the only +/// one of that item. +fn move_copy(it: &mut Item, to: LayerId, ids: &HashMap) { + it.layer = to; + it.overrides = it.overrides.and_then(|o| ids.get(&o).copied()); + if let Some(c) = &mut it.live_caption + && let Some(n) = ids.get(&c.source) + { + c.source = *n; + } + for c in it.children_mut().into_iter().flatten() { + move_copy(Arc::make_mut(c), to, ids); + } +} + #[cfg(test)] mod setup_tests { use serde_json::json; @@ -1570,6 +1818,382 @@ mod layer_tests { assert_eq!(d.layers.len(), 1); assert_eq!(d.item(designcraft_doc::ItemId(f)).unwrap().layer.0, l4); } + + fn on_layer(d: &designcraft_doc::Document, layer: u64) -> Vec> { + d.spreads.iter().chain(&d.parents).flat_map(|sp| sp.items.iter().filter(|i| i.layer.0 == layer).cloned()).collect() + } + + #[test] + fn duplicate_layer_copies_settings_and_objects_above_the_original() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"pages": 2, "facingPages": false})).unwrap(); + let base = s.doc().unwrap().doc.layers[0].id.0; + let art = s.execute("layer.new", &json!({"name": "Art"})).unwrap()["id"].as_u64().unwrap(); + s.execute("layer.set", &json!({"id": art, "color": "Red", "printable": false, "showGuides": false})).unwrap(); + let rect = s.execute("frame.create", &json!({"rect": [10, 10, 50, 50]})).unwrap()["id"].as_u64().unwrap(); + s.execute("frame.create", &json!({"spread": 1, "rect": [10, 10, 60, 60]})).unwrap(); + s.execute("guide.add", &json!({"orientation": "horizontal", "position": 200, "page": 0, "liquid": true})).unwrap(); + s.execute("layer.activate", &json!({"id": base})).unwrap(); + s.execute("frame.create", &json!({"rect": [100, 100, 150, 150]})).unwrap(); + s.execute("guide.add", &json!({"orientation": "vertical", "position": 300, "page": 0})).unwrap(); + s.execute("layer.set", &json!({"id": art, "locked": true})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let copy = s.execute("layer.duplicate", &json!({"id": art})).unwrap()["id"].as_u64().unwrap(); + let d = s.doc().unwrap().doc.clone(); + assert_eq!(d.layers.iter().map(|l| l.id.0).collect::>(), [copy, art, base], "the copy sits right above the original"); + let (l, o) = (d.layer(designcraft_doc::LayerId(copy)).unwrap(), d.layer(designcraft_doc::LayerId(art)).unwrap()); + assert_eq!(l.name, "Art copy"); + assert_eq!((l.color, l.visible, l.locked, l.printable, l.show_guides), (o.color, o.visible, o.locked, o.printable, o.show_guides)); + assert_eq!(s.doc().unwrap().active_layer.0, copy); + let (orig, copies) = (on_layer(&d, art), on_layer(&d, copy)); + assert_eq!(copies.len(), 2, "both spreads' objects"); + for (a, b) in orig.iter().zip(&copies) { + assert_ne!(a.id, b.id); + assert_eq!((a.xf, a.path.clone()), (b.xf, b.path.clone())); + assert_eq!(d.find(a.id).unwrap().spread, d.find(b.id).unwrap().spread); + } + assert_eq!(on_layer(&d, base).len(), 1, "other layers' objects stay as they were"); + let guides = &d.spreads[0].pages[0].guides; + assert_eq!(guides.len(), 3, "Art's guide gets a copy; the base layer's does not"); + let (g, c) = (&guides[0], &guides[2]); + assert_eq!((g.layer, c.layer), (Some(designcraft_doc::LayerId(art)), Some(designcraft_doc::LayerId(copy)))); + assert_eq!((g.orientation, g.position, g.liquid), (c.orientation, c.position, c.liquid)); + assert!(d.item(designcraft_doc::ItemId(rect)).is_some()); + // A second copy gets a name of its own; bad ids change nothing. + s.execute("layer.duplicate", &json!({"id": art})).unwrap(); + assert_eq!(s.doc().unwrap().doc.layers[1].name, "Art copy 2"); + s.execute("edit.undo", &json!({})).unwrap(); + for bad in [json!({"id": 999}), json!({})] { + assert!(s.execute("layer.duplicate", &bad).is_err(), "{bad}"); + } + // One undo step. + s.execute("edit.undo", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.layers, before.layers); + assert_eq!(d.all_items(), before.all_items()); + } + + #[test] + fn duplicate_layer_keeps_threads_on_the_layer_only() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let base = s.doc().unwrap().doc.layers[0].id.0; + let text = |s: &mut crate::Session, rect: [f64; 4], t: &str| { + s.execute("frame.create", &json!({"rect": rect, "content": "text", "text": t})).unwrap()["id"].as_u64().unwrap() + }; + // a → b on Base, c on Art at the end of the same thread. + let b = text(&mut s, [10.0, 200.0, 100.0, 240.0], ""); + let a = text(&mut s, [10.0, 10.0, 100.0, 50.0], "One two three four five six seven eight nine ten eleven twelve"); + s.execute("text.thread", &json!({"from": a, "to": b})).unwrap(); + let art = s.execute("layer.new", &json!({"name": "Art"})).unwrap()["id"].as_u64().unwrap(); + let c = text(&mut s, [300.0, 10.0, 400.0, 50.0], ""); + s.execute("text.thread", &json!({"from": b, "to": c})).unwrap(); + let _ = art; + let copy = s.execute("layer.duplicate", &json!({"id": base})).unwrap()["id"].as_u64().unwrap(); + let d = s.doc().unwrap().doc.clone(); + let story = |id: u64| d.item(designcraft_doc::ItemId(id)).and_then(|i| i.text_frame()).map(|t| t.story).unwrap(); + let orig = d.story(story(a)).unwrap(); + assert_eq!(orig.frames.iter().map(|f| f.0).collect::>(), [a, b, c], "the original thread is unchanged"); + let copies = on_layer(&d, copy); + assert_eq!(copies.len(), 2); + let sid = copies[0].text_frame().unwrap().story; + assert_eq!(copies[1].text_frame().unwrap().story, sid, "the copies share one story"); + let st = d.story(sid).unwrap(); + assert_ne!(sid, orig.id); + assert_eq!(st.text, orig.text, "the copy holds the whole text"); + // Thread order as in the original (a's copy first, though b's copy was made first), without c. + let pos = |id: designcraft_doc::ItemId| d.find(id).map(|l| l.path.clone()); + let order: Vec<_> = st.frames.iter().map(|f| d.item(*f).unwrap().xf).collect(); + let want: Vec<_> = [a, b].iter().map(|f| d.item(designcraft_doc::ItemId(*f)).unwrap().xf).collect(); + assert_eq!(order, want); + assert!(st.frames.iter().all(|f| pos(*f).is_some())); + } + + #[test] + fn duplicate_layer_copies_parent_items_and_their_overrides() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap(); + let base = s.doc().unwrap().doc.layers[0].id.0; + let folio = json!({"spread": {"kind": "parent", "index": 0}, "rect": [72, 720, 300, 750], "content": "text", "text": "Folio"}); + let parent = s.execute("frame.create", &folio).unwrap()["id"].as_u64().unwrap(); + let o = s.execute("layout.overrideParentItems", &json!({"page": 1})).unwrap()["ids"][0].as_u64().unwrap(); + // Page 3's override is deleted, which keeps the parent item hidden there. + let gone = s.execute("layout.overrideParentItems", &json!({"page": 2})).unwrap()["ids"][0].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [gone]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + // Parent B, based on A, hides the folio on its page. + s.execute("layout.parents.new", &json!({"prefix": "B"})).unwrap(); + s.edit(|d, _| { + let a = d.parents[0].id; + let bp = std::sync::Arc::make_mut(&mut d.parents[1]); + bp.parent.as_mut().unwrap().based_on = Some(a); + bp.pages[0].parent = Some(a); + bp.pages[0].overridden.push(designcraft_doc::ItemId(parent)); + Ok(()) + }) + .unwrap(); + let copy = s.execute("layer.duplicate", &json!({"id": base})).unwrap()["id"].as_u64().unwrap(); + let d = s.doc().unwrap().doc.clone(); + let copies = on_layer(&d, copy); + assert_eq!(copies.len(), 2, "the parent item and page 2's override"); + let p2 = copies.iter().find(|i| matches!(d.find(i.id).unwrap().spread, designcraft_doc::SpreadRef::Parent(0))).unwrap(); + let o2 = copies.iter().find(|i| i.overrides.is_some()).unwrap(); + assert_ne!((p2.id.0, o2.id.0), (parent, o)); + assert_eq!(o2.overrides, Some(p2.id), "the override's copy overrides the parent item's copy"); + assert_eq!(d.page_of_item(o2.id), Some(1)); + let overridden = |page: usize| { + let (si, pi) = d.page_loc(page).unwrap(); + d.spreads[si].pages[pi].overridden.clone() + }; + assert_eq!(overridden(1), [designcraft_doc::ItemId(parent), p2.id], "page 2 hides both parent items"); + assert_eq!(overridden(2), [designcraft_doc::ItemId(parent), p2.id], "page 3 too, without an override"); + assert!(overridden(0).is_empty(), "page 1 shows both"); + assert_eq!(d.parents[1].pages[0].overridden, [designcraft_doc::ItemId(parent), p2.id], "parent B hides both"); + } + + #[test] + fn duplicate_layer_moves_whole_groups_and_finds_later_parent_items() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let base = s.doc().unwrap().doc.layers[0].id.0; + let a = s.execute("frame.create", &json!({"rect": [0, 0, 10, 10]})).unwrap()["id"].as_u64().unwrap(); + s.execute("layer.new", &json!({"name": "Art"})).unwrap(); + let b = s.execute("frame.create", &json!({"rect": [20, 0, 30, 10]})).unwrap()["id"].as_u64().unwrap(); + // The group takes the base layer from its first member; b stays on Art inside it. + let g = s.execute("object.group", &json!({"ids": [a, b]})).unwrap()["id"].as_u64().unwrap(); + s.execute("layer.activate", &json!({"id": base})).unwrap(); + // Parent A's item overrides one on parent B, which comes after it in the document. + s.execute("layout.parents.new", &json!({"prefix": "B"})).unwrap(); + let on_b = + s.execute("frame.create", &json!({"spread": {"kind": "parent", "index": 1}, "rect": [0, 0, 50, 50]})).unwrap()["id"].as_u64().unwrap(); + let on_a = + s.execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [0, 0, 50, 50]})).unwrap()["id"].as_u64().unwrap(); + s.edit(|d, _| { + d.item_mut(designcraft_doc::ItemId(on_a)).unwrap().overrides = Some(designcraft_doc::ItemId(on_b)); + Ok(()) + }) + .unwrap(); + let d = s.doc().unwrap().doc.clone(); + let layer_of = |id: u64| d.item(designcraft_doc::ItemId(id)).unwrap().layer.0; + assert_eq!((layer_of(g), layer_of(b)), (base, d.layers.iter().find(|l| l.name == "Art").unwrap().id.0)); + let copy = s.execute("layer.duplicate", &json!({"id": base})).unwrap()["id"].as_u64().unwrap(); + let d = s.doc().unwrap().doc.clone(); + let copies = on_layer(&d, copy); + let group = copies.iter().find(|i| !i.children().is_empty()).unwrap(); + assert!(group.children().iter().all(|c| c.layer.0 == copy), "the copied group is on the new layer as a whole"); + let in_parent = + |i: usize| copies.iter().find(|c| matches!(d.find(c.id).unwrap().spread, designcraft_doc::SpreadRef::Parent(p) if p == i)).unwrap(); + assert_eq!(in_parent(0).overrides, Some(in_parent(1).id), "A's copy overrides B's copy"); + } + + #[test] + fn duplicate_layer_gives_anchored_objects_their_own_ids() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let base = s.doc().unwrap().doc.layers[0].id.0; + let text = json!({"rect": [72, 72, 400, 400], "content": "text", "text": "Before after."}); + 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; + let b = s.execute("frame.create", &json!({"rect": [450, 450, 474, 490]})).unwrap()["id"].as_u64().unwrap(); + s.execute("anchored.insert", &json!({"ids": [b], "story": sid.0, "pos": 7})).unwrap(); + // Two anchored text frames threaded in one story, as IDML import makes them. + let threaded = s + .edit(|d, _| { + use designcraft_doc::{AnchoredObject, ParaFormat}; + let (r, r2) = (designcraft_geom::Rect::new(0.0, 0.0, 60.0, 30.0), designcraft_geom::Rect::new(0.0, 0.0, 80.0, 40.0)); + let (first, story) = d.add_text_frame(designcraft_doc::SpreadRef::Doc(0), r, d.default_layer(), "Symbol", ParaFormat::default())?; + let (second, spare) = d.add_text_frame(designcraft_doc::SpreadRef::Doc(0), r2, d.default_layer(), "", ParaFormat::default())?; + let mut items = Vec::new(); + for f in [first, second] { + let mut it = d.item(f).cloned().ok_or(designcraft_doc::DocError::NoItem(f))?; + if let designcraft_doc::Content::Text(tf) = &mut it.content { + tf.story = story; + } + items.push(it); + } + d.stories.remove(&spare); + d.story_mut(story).ok_or(designcraft_doc::DocError::NoStory(story))?.frames = vec![first, second]; + std::sync::Arc::make_mut(&mut d.spreads[0]).items.retain(|i| i.id != first && i.id != second); + let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; + for it in items.into_iter().rev() { + st.insert_object(0, AnchoredObject::new(it, Default::default())); + } + Ok(story) + }) + .unwrap(); + s.doc().unwrap().doc.check().unwrap(); + let copy = s.execute("layer.duplicate", &json!({"id": base})).unwrap()["id"].as_u64().unwrap(); + let d = &s.doc().unwrap().doc; + d.check().unwrap(); + let symbols: Vec<_> = d.anchored_items().into_iter().filter_map(|(_, i)| i.text_frame().map(|t| (i.id, t.story))).collect(); + assert_eq!(symbols.len(), 4); + let copied = symbols.iter().map(|s| s.1).find(|s| *s != threaded).unwrap(); + let st = d.story(copied).unwrap(); + assert_eq!(st.text, "Symbol"); + assert_eq!(st.frames.len(), 2, "the copies share one story"); + let order = |story: &designcraft_doc::Story| story.frames.iter().map(|f| d.anchored_item(*f).unwrap().path.clone()).collect::>(); + assert_eq!(order(st), order(d.story(threaded).unwrap()), "in the original thread order"); + let anchored: Vec<_> = d.anchored_items().into_iter().filter(|(_, i)| i.text_frame().is_none()).map(|(_, i)| (i.id, i.layer.0)).collect(); + assert_eq!(anchored.len(), 2); + assert_ne!(anchored[0].0, anchored[1].0, "the copy's anchored object has an id of its own"); + let mut layers: Vec<_> = anchored.iter().map(|a| a.1).collect(); + layers.sort_unstable(); + let mut want = vec![base, copy]; + want.sort_unstable(); + assert_eq!(layers, want, "the copy's anchored object is on the new layer"); + } + + #[test] + fn duplicate_layer_points_copied_live_captions_at_the_copied_objects() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let base = s.doc().unwrap().doc.layers[0].id.0; + let a = s.execute("frame.create", &json!({"rect": [72, 72, 272, 172]})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.altText", &json!({"text": "First", "ids": [a]})).unwrap(); + s.execute("object.caption", &json!({"ids": [a], "text": "Photo: {altText}", "live": true})).unwrap(); + let copy = s.execute("layer.duplicate", &json!({"id": base})).unwrap()["id"].as_u64().unwrap(); + let d = &s.doc().unwrap().doc; + let copies = on_layer(d, copy); + let frame = copies.iter().find(|i| i.live_caption.is_none()).unwrap(); + let caption = copies.iter().find_map(|i| i.live_caption.as_ref()).unwrap(); + assert_eq!(caption.source, frame.id, "the caption's copy follows the frame's copy"); + assert_ne!(frame.id.0, a); + } + + #[test] + fn duplicate_layer_gives_copied_text_anchors_their_own_ids() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let base = s.doc().unwrap().doc.layers[0].id.0; + let text = json!({"rect": [72, 72, 400, 400], "content": "text", "text": "Heading\rSee "}); + 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; + let anchor = s + .edit(|d, _| { + let a = d.paragraph_anchor(sid, 0).ok_or(designcraft_doc::DocError::NoStory(sid))?; + let format = d.xref_formats[0].name.clone(); + let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; + let end = st.len(); + st.insert_xref(end, designcraft_doc::CrossRef { target: a, format }); + Ok(a) + }) + .unwrap(); + let copy = s.execute("layer.duplicate", &json!({"id": base})).unwrap()["id"].as_u64().unwrap(); + let d = &s.doc().unwrap().doc; + let copied = on_layer(d, copy)[0].text_frame().unwrap().story; + let (orig, st) = (d.story(sid).unwrap(), d.story(copied).unwrap()); + assert_eq!((orig.anchors[0].id, orig.xrefs[0].target), (anchor, anchor), "the original is unchanged"); + assert_ne!(st.anchors[0].id, anchor, "the copy's anchor has an id of its own"); + assert_eq!(st.anchors[0].name, orig.anchors[0].name); + assert_eq!(st.xrefs[0].target, st.anchors[0].id, "the copied cross-reference points at the copied paragraph"); + assert_eq!(d.find_anchor(st.anchors[0].id).map(|a| a.0), Some(copied)); + } + + #[test] + fn duplicate_layer_gives_copied_tables_their_own_ids() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let base = s.doc().unwrap().doc.layers[0].id.0; + let text = json!({"rect": [72, 72, 400, 400], "content": "text", "text": "Rows"}); + 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": 4, "focus": 4})).unwrap(); + let table = s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + let copy = s.execute("layer.duplicate", &json!({"id": base})).unwrap()["id"].as_u64().unwrap(); + let d = &s.doc().unwrap().doc; + let copied = on_layer(d, copy)[0].text_frame().unwrap().story; + let st = d.story(copied).unwrap(); + let tables: Vec = st.tables.keys().copied().collect(); + assert_eq!(tables.len(), 1); + let new = tables[0]; + assert_ne!(new, table, "the copy's table has an id of its own"); + assert_eq!(st.tables[&new].id, new); + assert_eq!(st.paras.iter().filter_map(|pf| pf.table).collect::>(), [new], "the copied paragraph holds the copied table"); + d.check().unwrap(); + s.execute("table.insertRow", &json!({"table": new})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.story(sid).unwrap().tables[&table].nrows(), 1, "the original table is unchanged"); + assert_eq!(d.story(copied).unwrap().tables[&new].nrows(), 2, "a command naming the copy's table changes the copy"); + } + + /// 40 stories, each holding two anchored text frames threaded in the next: 2^40 paths, 40 stories. + #[test] + fn duplicate_layer_checks_each_shared_story_once() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let base = s.doc().unwrap().doc.layers[0].id.0; + let f = s.execute("frame.create", &json!({"rect": [72, 72, 400, 400], "content": "text", "text": "Top"})).unwrap()["id"].as_u64().unwrap(); + let top = s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().text_frame().unwrap().story; + s.edit(|d, _| { + use designcraft_doc::{AnchoredObject, ParaFormat}; + let r = designcraft_geom::Rect::new(0.0, 0.0, 60.0, 30.0); + let mut outer = top; + for _ in 0..40 { + let (a, inner) = d.add_text_frame(designcraft_doc::SpreadRef::Doc(0), r, d.default_layer(), "Link", ParaFormat::default())?; + let (b, spare) = d.add_text_frame(designcraft_doc::SpreadRef::Doc(0), r, d.default_layer(), "", ParaFormat::default())?; + let mut items = Vec::new(); + for f in [a, b] { + let mut it = d.item(f).cloned().ok_or(designcraft_doc::DocError::NoItem(f))?; + if let designcraft_doc::Content::Text(tf) = &mut it.content { + tf.story = inner; + } + items.push(it); + } + d.stories.remove(&spare); + d.story_mut(inner).ok_or(designcraft_doc::DocError::NoStory(inner))?.frames = vec![a, b]; + std::sync::Arc::make_mut(&mut d.spreads[0]).items.retain(|i| i.id != a && i.id != b); + let st = d.story_mut(outer).ok_or(designcraft_doc::DocError::NoStory(outer))?; + for it in items.into_iter().rev() { + st.insert_object(0, AnchoredObject::new(it, Default::default())); + } + outer = inner; + } + Ok(()) + }) + .unwrap(); + s.doc().unwrap().doc.check().unwrap(); + let before = s.doc().unwrap().doc.stories.len(); + s.execute("layer.duplicate", &json!({"id": base})).unwrap(); + let d = &s.doc().unwrap().doc; + d.check().unwrap(); + assert_eq!(d.stories.len(), 2 * before, "one copy of each story"); + } + + #[test] + fn duplicate_layer_refuses_objects_nested_too_deeply() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let base = s.doc().unwrap().doc.layers[0].id.0; + let mut g = s.execute("frame.create", &json!({"rect": [0, 0, 10, 10]})).unwrap()["id"].as_u64().unwrap(); + for _ in 0..super::MAX_COPY_DEPTH + 1 { + let f = s.execute("frame.create", &json!({"rect": [0, 0, 10, 10]})).unwrap()["id"].as_u64().unwrap(); + g = s.execute("object.group", &json!({"ids": [g, f]})).unwrap()["id"].as_u64().unwrap(); + } + let before = s.doc().unwrap().doc.layers.clone(); + assert!(s.execute("layer.duplicate", &json!({"id": base})).is_err()); + assert_eq!(s.doc().unwrap().doc.layers, before, "nothing changes"); + } + + #[test] + fn duplicate_layer_detaches_overrides_of_parent_items_on_other_layers() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"pages": 2, "facingPages": false})).unwrap(); + let base = s.doc().unwrap().doc.layers[0].id.0; + s.execute("layer.new", &json!({"name": "Art"})).unwrap(); + let logo = json!({"spread": {"kind": "parent", "index": 0}, "rect": [72, 72, 144, 144]}); + let parent = s.execute("frame.create", &logo).unwrap()["id"].as_u64().unwrap(); + let o = s.execute("layout.overrideParentItems", &json!({"page": 1})).unwrap()["ids"][0].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [o]})).unwrap(); + s.execute("object.setLayer", &json!({"layer": base})).unwrap(); + let copy = s.execute("layer.duplicate", &json!({"id": base})).unwrap()["id"].as_u64().unwrap(); + let d = &s.doc().unwrap().doc; + let copies = on_layer(d, copy); + assert_eq!(copies.len(), 1, "the override only: the parent item is on Art"); + assert_eq!(copies[0].overrides, None, "a plain object, so the original override stays the only one"); + assert_eq!(d.item(designcraft_doc::ItemId(o)).unwrap().overrides, Some(designcraft_doc::ItemId(parent))); + let (si, pi) = d.page_loc(1).unwrap(); + assert_eq!(d.spreads[si].pages[pi].overridden, [designcraft_doc::ItemId(parent)]); + } } #[cfg(test)] diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 1f5382ac2..34a503158 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -453,6 +453,11 @@ pub fn show(app: &mut DesignApp, ui: &mut Ui) { ui.close(); } ui.separator(); + let duplicate = crate::i18n::tr(&language, "Duplicate Layer \"{name}\"").replace("{name}", &l.name); + if ui.button(crate::rtl::widget(ui, duplicate)).clicked() { + cmds.push(("layer.duplicate", json!({"id": l.id.0}))); + ui.close(); + } if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&language, "Delete Layer"))).clicked() { cmds.push(("layer.delete", json!({"id": l.id.0}))); ui.close(); @@ -550,7 +555,14 @@ pub fn show(app: &mut DesignApp, ui: &mut Ui) { if icons::button(ui, "trash", 20.0, false, crate::i18n::tr(&app.ui.language, "Delete Layer")).clicked() { let _ = app.run("layer.delete", json!({"id": active.0})); } - if icons::button(ui, "plus", 20.0, false, crate::i18n::tr(&app.ui.language, "Create New Layer")).clicked() { + // Create New Layer; a layer dropped on it is duplicated. + let dragging = matches!(egui::DragAndDrop::payload::(ui.ctx()).as_deref(), Some(LayersDrag::Layer(_))); + let plus = icons::button(ui, "plus", 20.0, dragging, crate::i18n::tr(&app.ui.language, "Create New Layer")); + if let Some(payload) = plus.dnd_release_payload::() { + if let LayersDrag::Layer(id) = *payload { + let _ = app.run("layer.duplicate", json!({"id": id.0})); + } + } else if plus.clicked() { let _ = app.run("layer.new", json!({})); } }); @@ -670,6 +682,24 @@ mod tests { assert_eq!(layers(h.state()), ["Art", "Layer 1"], "one undo step"); } + #[test] + fn a_layer_dropped_on_create_new_layer_is_duplicated() { + use egui_kittest::kittest::Queryable; + let mut app = app(); + app.run("layer.new", json!({"name": "Art"})).unwrap(); + app.run("frame.create", json!({"rect": [0, 0, 10, 10]})).unwrap(); + app.run("selection.set", json!({"ids": []})).unwrap(); + let mut h = panel(app); + let plus = h.get_by_label("Create New Layer").rect().center(); + // Rows: Art, its rectangle, Layer 1. Drop Layer 1 on the button. + drag(&mut h, row(2, 0.5, false), plus); + assert_eq!(layers(h.state()), ["Art", "Layer 1 copy", "Layer 1"]); + drag(&mut h, row(0, 0.5, false), plus); + assert_eq!(layers(h.state()), ["Art copy", "Art", "Layer 1 copy", "Layer 1"]); + let d = &h.state().session.active().unwrap().doc; + assert_eq!(d.spreads[0].items.iter().filter(|i| i.layer == d.layers[0].id).count(), 1, "the copy holds the rectangle"); + } + #[test] fn dragging_objects_and_the_selection_square_moves_them_between_layers() { let mut app = app(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 428ed9bf7..51073d762 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -441,7 +441,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Pathfinder (Object › Pathfinder and a Pathfinder panel): Add, Subtract, Intersect, Exclude Overlap, Minus Back; curves stay curves (flo_curves); the result keeps the frontmost object's look and content, so a photo intersected with shapes behind it shows through them (Subtract keeps the backmost); `object.pathfinder`. - Pathfinder panel as InDesign's: Paths (Join Path `path.join`, Open/Close Path, Reverse Path), Pathfinder, Convert Shape, Convert Point (`path.convertPoints`: plain, corner, smooth, symmetrical). - Make / Release Compound Path (⌘8; nested paths become holes), Create Outlines (⇧⌘O; one path per text colour), Scissors tool (C) with `path.split`. -- Transform Again / Individually / Sequence Again (⌥⌘3; a moved copy repeats like Step and Repeat), Clear Transformations; Layers: Merge, Delete Unused, Hide/Lock Others, Show/Unlock All; Break Link to Style (paragraph and character). +- Transform Again / Individually / Sequence Again (⌥⌘3; a moved copy repeats like Step and Repeat), Clear Transformations; Layers: Duplicate, Merge, Delete Unused, Hide/Lock Others, Show/Unlock All; Break Link to Style (paragraph and character). - Floating panels: drag a panel's tab, or a group by its drag bar, off the dock (or choose Float Panel) into a movable floating window that resizes from every edge; Dock Panel (in its tab menu and ≡ menu) puts it back where it was; positions persist; `window.panel.float` / `window.panel.dock` (aliases `window.floatPanel` / `window.dockPanel`). - Place SVG (vector in PDF export via krilla-svg, resvg on screen, text in the bundled fonts), Photoshop (.psd/.psb composite in its colour mode — Bitmap, Grayscale, Duotone, Indexed, RGB, CMYK, Lab, 8/16-bit — with merged transparency; extra alpha and spot channels don't change the image), BMP, and Illustrator files saved with PDF compatibility (.ai). A swatch on a placed greyscale or bitmap image (IDML ``) colours it on screen and in PDF. New `designcraft-images` crate (L2) for sniffing, sizes and decoding. EPS is still missing. - View › Display Performance: Fast (grey boxes, no effects, ⌥⇧⌘Z), Typical (72 ppi proxies, ⌥⌘Z), High Quality (⌥⌘H, our default — the renderer is fast enough). From d11a050a67166e738c1c48856f3d12cc1fccb0d5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 25 Oct 2026 15:26:23 -0700 Subject: [PATCH 02/18] Layers: Duplicate Layer numbers copies in story order and is translated Duplicate Layer gave the copied stories' tables and anchored objects their new ids in hash-map order, so the ids differed from run to run. It now takes the stories in order. The Layers panel's "Duplicate Layer" menu item has a row in every interface language. --- crates/engine/src/cmd/layout.rs | 37 +++++++++++++++++++++++++++++--- crates/ui-egui/src/i18n.rs | 18 ++++++++++++++++ crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/cs.rs | 1 + crates/ui-egui/src/i18n/de.rs | 1 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/i18n/ja.rs | 1 + crates/ui-egui/src/i18n/ka.rs | 1 + crates/ui-egui/src/i18n/pl.rs | 1 + crates/ui-egui/src/i18n/pt_br.rs | 1 + crates/ui-egui/src/i18n/ru.rs | 1 + crates/ui-egui/src/i18n/uk.rs | 1 + 12 files changed, 62 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 9e6c2d017..361e2f40b 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -1409,17 +1409,21 @@ fn duplicate_layer(s: &mut Session, p: &Value) -> Result { d.insert_item(r, copy, None)?; } // A story copy holds the same anchored objects as the original: they get ids of their own, and the stories of - // anchored text frames get copies in turn. + // anchored text frames get copies in turn. The story copies are taken in story order. let mut pending: Vec = stories.values().copied().collect(); + pending.sort_unstable(); + pending.reverse(); while let Some(new) = pending.pop() { let Some(mut st) = d.story(new).cloned() else { continue }; renumber_anchored(d, &mut st, new_id, &mut stories, &mut ids, &mut pending); d.stories.insert(new, Arc::new(st)); } // Each story copy gets its frames in the original order, text anchors of its own, which its cross-references - // and those of the other copies point at, and tables with ids of their own. + // and those of the other copies point at, and tables with ids of their own, allocated in story order. let mut anchors = HashMap::new(); - for (old, new) in &stories { + let mut order: Vec<(StoryId, StoryId)> = stories.iter().map(|(old, new)| (*old, *new)).collect(); + order.sort_unstable_by_key(|(_, new)| *new); + for (old, new) in &order { let Some(mut st) = d.story(*new).cloned() else { continue }; st.frames = src.story(*old).map(|o| o.frames.iter().filter_map(|f| ids.get(f).copied()).collect()).unwrap_or_default(); for (k, a) in (1..).zip(st.anchors.iter_mut()) { @@ -2116,6 +2120,33 @@ mod layer_tests { assert_eq!(d.story(copied).unwrap().tables[&new].nrows(), 2, "a command naming the copy's table changes the copy"); } + #[test] + fn duplicate_layer_numbers_copied_tables_and_anchored_objects_in_story_order() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let base = s.doc().unwrap().doc.layers[0].id.0; + for k in 0..8 { + let y = 72 + 80 * k; + let text = json!({"rect": [72, y, 400, y + 60], "content": "text", "text": "Rows"}); + 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": 4, "focus": 4})).unwrap(); + s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap(); + let b = s.execute("frame.create", &json!({"rect": [450, y, 474, y + 40]})).unwrap()["id"].as_u64().unwrap(); + s.execute("anchored.insert", &json!({"ids": [b], "story": sid.0, "pos": 0})).unwrap(); + } + let copy = s.execute("layer.duplicate", &json!({"id": base})).unwrap()["id"].as_u64().unwrap(); + let d = &s.doc().unwrap().doc; + d.check().unwrap(); + let mut stories: Vec<_> = on_layer(d, copy).iter().filter_map(|i| i.text_frame().map(|t| t.story)).collect(); + stories.sort_unstable(); + assert_eq!(stories.len(), 8); + let tables: Vec = stories.iter().map(|sid| *d.story(*sid).unwrap().tables.keys().next().unwrap()).collect(); + let objects: Vec = stories.iter().map(|sid| d.story(*sid).unwrap().objects[0].item.id.0).collect(); + assert!(tables.is_sorted(), "the copied tables take ids in story order: {tables:?}"); + assert!(objects.is_sorted(), "the copied anchored objects take ids in story order: {objects:?}"); + } + /// 40 stories, each holding two anchored text frames threaded in the next: 2^40 paths, 40 stories. #[test] fn duplicate_layer_checks_each_shared_story_once() { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index d1b462ab5..27f4df308 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -4562,6 +4562,10 @@ const TABLE: &[(&str, [&str; 5])] = &[ "“{name}”的图层选项…", ], ), + ( + "Duplicate Layer \"{name}\"", + ["Ebene „{name}“ duplizieren", "Dupliquer le calque « {name} »", "Duplicar capa «{name}»", "レイヤー「{name}」を複製", "复制图层“{name}”"], + ), ("Swatch Name:", ["Farbfeldname:", "Nom de la nuance :", "Nombre de muestra:", "スウォッチ名:", "色板名称:"]), ( "Name with Color Value", @@ -4968,6 +4972,20 @@ mod tests { } } + #[test] + fn duplicate_layer_menu_item_is_localized_in_every_interface_language() { + let key = "Duplicate Layer \"{name}\""; + for (code, name) in LANGUAGES { + let label = tr(code, key); + if code.is_empty() { + assert_eq!(label, key, "{name}"); + } else { + assert_ne!(label, key, "{name} shows the Layers panel's Duplicate Layer item in English"); + assert!(label.contains("{name}"), "{name}"); + } + } + } + #[test] fn context_captions_come_from_their_context_or_the_plain_translation() { for (lang, table) in CONTEXT_TABLES { diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index f1dbe0acd..6aecde83c 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -1813,6 +1813,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Color Group Options…", "خيارات مجموعة الألوان…"), ("Layer Options", "خيارات الطبقة"), ("Layer Options for \"{name}\"…", "خيارات الطبقة \"{name}\"…"), + ("Duplicate Layer \"{name}\"", "تكرار الطبقة \"{name}\""), ("Swatch Name:", "اسم حامل اللون:"), ("Name with Color Value", "التسمية بقيمة اللون"), ("Color Type:", "نوع اللون:"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 2969d4cff..49b9ae716 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -210,6 +210,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Delete Column", "Odstranit sloupec"), ("Delete Condition", "Odstranit podmínku"), ("Delete Layer", "Odstranit vrstvu"), + ("Duplicate Layer \"{name}\"", "Duplikovat vrstvu „{name}“"), ("Delete Page", "Odstranit stránku"), ("Delete Row", "Odstranit řádek"), ("Delete Swatch", "Odstranit vzorek"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index aaf49bdb3..3ab1df80b 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -2040,6 +2040,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Color Group Options…", "Farbgruppenoptionen …"), ("Layer Options", "Ebenenoptionen"), ("Layer Options for \"{name}\"…", "Ebenenoptionen für „{name}“ …"), + ("Duplicate Layer \"{name}\"", "Ebene „{name}“ duplizieren"), ("Swatch Name:", "Farbfeldname:"), ("Name with Color Value", "Name mit Farbwert"), ("Color Type:", "Farbtyp:"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index db671bc83..cd68c8a53 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1865,6 +1865,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Color Group Options…", "Opzioni gruppo colori…"), ("Layer Options", "Opzioni livello"), ("Layer Options for \"{name}\"…", "Opzioni livello \"{name}\"…"), + ("Duplicate Layer \"{name}\"", "Duplica livello \"{name}\""), ("Swatch Name:", "Nome campione:"), ("Name with Color Value", "Nome con valore colore"), ("Color Type:", "Tipo colore:"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 01814f271..abeca545d 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -2123,6 +2123,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Color Group Options…", "カラーグループオプション…"), ("Layer Options", "レイヤーオプション"), ("Layer Options for \"{name}\"…", "「{name}」のレイヤーオプション…"), + ("Duplicate Layer \"{name}\"", "レイヤー「{name}」を複製"), ("Swatch Name:", "スウォッチ名:"), ("Name with Color Value", "カラー値を名前にする"), ("Color Type:", "カラータイプ:"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index e4136880b..ebd613805 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -2155,6 +2155,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Last commit", "ბოლო კომიტი"), ("Layer Options", "შრის პარამეტრები"), ("Layer Options for \"{name}\"…", "შრის პარამეტრები: „{name}“…"), + ("Duplicate Layer \"{name}\"", "შრის დუბლირება: „{name}“"), ("Leader:", "მიმთითებელი:"), ("Leading Model:", "სტრიქონთაშორისი ინტერვალის მოდელი:"), ("Left-aligned tab", "მარცხნივ სწორებული ტაბულაცია"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index bd3d96b9a..dfae05867 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -211,6 +211,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Delete Column", "Usuń kolumnę"), ("Delete Condition", "Usuń warunek"), ("Delete Layer", "Usuń warstwę"), + ("Duplicate Layer \"{name}\"", "Powiel warstwę „{name}”"), ("Delete Page", "Usuń stronę"), ("Delete Row", "Usuń wiersz"), ("Delete Swatch", "Usuń próbkę"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index d8125cfe4..84f61b9ea 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -1417,6 +1417,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Color Group Options…", "Opções do grupo de cores…"), ("Layer Options", "Opções de camada"), ("Layer Options for \"{name}\"…", "Opções da camada \"{name}\"…"), + ("Duplicate Layer \"{name}\"", "Duplicar camada \"{name}\""), ("Swatch Name:", "Nome da amostra:"), ("Name with Color Value", "Nome com valor da cor"), ("Color Type:", "Tipo de cor:"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 5e8b58806..ffcb2d8c8 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -210,6 +210,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Delete Column", "Удалить столбец"), ("Delete Condition", "Удалить условие"), ("Delete Layer", "Удалить слой"), + ("Duplicate Layer \"{name}\"", "Дублировать слой «{name}»"), ("Delete Page", "Удалить страницу"), ("Delete Row", "Удалить строку"), ("Delete Swatch", "Удалить образец"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index f0cf41017..8725858ba 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -1833,6 +1833,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Color Group Options…", "Параметри групи кольорів…"), ("Layer Options", "Параметри шару"), ("Layer Options for \"{name}\"…", "Параметри шару «{name}»…"), + ("Duplicate Layer \"{name}\"", "Дублювати шар «{name}»"), ("Swatch Name:", "Назва зразка:"), ("Name with Color Value", "Назва за значенням кольору"), ("Color Type:", "Тип кольору:"), From 99083a76d42f4690fc418b55d1ec6bbf169e9ceb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 26 Oct 2026 01:18:05 -0700 Subject: [PATCH 03/18] Layers: Duplicate Layer copies hyperlinks on the copied objects and text Duplicate Layer copied a layer's objects and stories but not the hyperlinks on them, so the copies had no links. Each hyperlink whose source is a copied object or a copied story now gets a copy with an id of its own, pointing at the copy and going to the same destination. --- crates/engine/src/cmd/layout.rs | 56 +++++++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 361e2f40b..3d098a674 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -3,7 +3,9 @@ use std::collections::HashMap; use std::sync::Arc; -use designcraft_doc::{Content, Guide, Item, ItemId, LAYER_COLORS, Layer, LayerId, Margins, SpreadId, SpreadRef, Story, StoryId}; +use designcraft_doc::{ + Content, Guide, Hyperlink, HyperlinkSource, Item, ItemId, LAYER_COLORS, Layer, LayerId, Margins, SpreadId, SpreadRef, Story, StoryId, +}; use serde_json::{Value, json}; use super::{CommandSpec, bad, cmd, has_doc, ok, str_param}; @@ -1374,7 +1376,8 @@ fn set_layer(s: &mut Session, p: &Value) -> Result { /// Duplicate Layer: a new layer above `id` with its settings and a copy of every object on it, on document and parent /// spreads, in one undo step. Copied text frames stay threaded to the other copied frames of their story, and each copy /// of a story holds all of its text. A copied override of a parent item overrides that item's copy, or is detached when -/// the parent item is on another layer. Pages hide a parent item's copy where they hide the parent item. +/// the parent item is on another layer. Pages hide a parent item's copy where they hide the parent item. Hyperlinks on +/// copied objects and text get copies that go to the same destinations. fn duplicate_layer(s: &mut Session, p: &Value) -> Result { const ID: &str = "layer.duplicate"; let id = LayerId(p.get("id").and_then(Value::as_u64).ok_or_else(|| bad(ID, "missing id"))?); @@ -1448,6 +1451,22 @@ fn duplicate_layer(s: &mut Session, p: &Value) -> Result { remap_xrefs(st, &anchors); } } + // The hyperlinks of copied objects and copied text get copies with ids of their own and the same destinations. + let links: Vec<_> = src + .hyperlinks + .iter() + .filter_map(|h| { + let source = match &h.source { + HyperlinkSource::Item { id } => HyperlinkSource::Item { id: *ids.get(id)? }, + HyperlinkSource::Text { story, start, end } => HyperlinkSource::Text { story: *stories.get(story)?, start: *start, end: *end }, + }; + Some((h, source)) + }) + .collect(); + for (h, source) in links { + let id = d.alloc(); + d.hyperlinks.push(Hyperlink { id, source, ..h.clone() }); + } // A page, document or parent, hides a parent item's copy where it hides the parent item, and its ruler guides on // the layer get copies on the new layer. let hidden = |pg: &designcraft_doc::Page| -> Vec { pg.overridden.iter().filter_map(|o| ids.get(o).copied()).collect() }; @@ -2064,6 +2083,39 @@ mod layer_tests { assert_ne!(frame.id.0, a); } + #[test] + fn duplicate_layer_copies_the_hyperlinks_of_its_objects_and_text() { + use designcraft_doc::{HyperlinkDest, HyperlinkSource}; + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"pages": 2})).unwrap(); + let base = s.doc().unwrap().doc.layers[0].id.0; + let a = s.execute("frame.create", &json!({"rect": [72, 72, 172, 172]})).unwrap()["id"].as_u64().unwrap(); + s.execute("hyperlink.create", &json!({"ids": [a], "url": "https://example.com/a", "name": "Logo"})).unwrap(); + let t = s.execute("frame.create", &json!({"rect": [72, 200, 400, 300], "content": "text", "text": "Read more here"})).unwrap(); + let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(t["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + s.execute("text.select", &json!({"story": sid.0, "anchor": 5, "focus": 9})).unwrap(); + s.execute("hyperlink.create", &json!({"page": 2})).unwrap(); + s.execute("layer.new", &json!({"name": "Art"})).unwrap(); + let other = s.execute("frame.create", &json!({"rect": [300, 72, 400, 172]})).unwrap()["id"].as_u64().unwrap(); + s.execute("hyperlink.create", &json!({"ids": [other], "email": "info@example.com"})).unwrap(); + let before = s.doc().unwrap().doc.hyperlinks.clone(); + let copy = s.execute("layer.duplicate", &json!({"id": base})).unwrap()["id"].as_u64().unwrap(); + let d = s.doc().unwrap().doc.clone(); + assert_eq!(d.hyperlinks[..3], before[..], "the original hyperlinks are unchanged"); + let copies = on_layer(&d, copy); + let frame = copies.iter().find(|i| i.text_frame().is_none()).unwrap().id; + let story = copies.iter().find_map(|i| i.text_frame()).unwrap().story; + let added = &d.hyperlinks[3..]; + assert_eq!(added.len(), 2, "the other layer's hyperlink gets no copy"); + assert_eq!((&added[0].name, &added[0].source, &added[0].dest), (&before[0].name, &HyperlinkSource::Item { id: frame }, &before[0].dest)); + assert_eq!(added[1].source, HyperlinkSource::Text { story, start: 5, end: 9 }); + assert_eq!((&added[1].name, &added[1].dest), (&before[1].name, &HyperlinkDest::Page(1))); + let ids: std::collections::HashSet = d.hyperlinks.iter().map(|h| h.id).collect(); + assert_eq!(ids.len(), 5, "each copy has an id of its own"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.hyperlinks, before, "one undo step removes the copies"); + } + #[test] fn duplicate_layer_gives_copied_text_anchors_their_own_ids() { let mut s = crate::Session::new(); From abc794396b8ad8a2842e43e482d6aed355288cbb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 26 Oct 2026 02:15:28 -0700 Subject: [PATCH 04/18] Layers: Duplicate Layer gives tables in copied cells and notes ids of their own Tables inside a copied table cell or note kept the original table's id, so two tables in the document shared one id. Duplicate Layer now renumbers them at every depth, with the paragraphs that hold them. --- crates/engine/src/cmd/layout.rs | 71 ++++++++++++++++++++++++++++----- 1 file changed, 60 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 3d098a674..31661848f 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -1422,7 +1422,8 @@ fn duplicate_layer(s: &mut Session, p: &Value) -> Result { d.stories.insert(new, Arc::new(st)); } // Each story copy gets its frames in the original order, text anchors of its own, which its cross-references - // and those of the other copies point at, and tables with ids of their own, allocated in story order. + // and those of the other copies point at, and tables with ids of their own, the tables in its cells and notes + // included, allocated in story order. let mut anchors = HashMap::new(); let mut order: Vec<(StoryId, StoryId)> = stories.iter().map(|(old, new)| (*old, *new)).collect(); order.sort_unstable_by_key(|(_, new)| *new); @@ -1434,16 +1435,7 @@ fn duplicate_layer(s: &mut Session, p: &Value) -> Result { anchors.insert(a.id, id); Arc::make_mut(a).id = id; } - let mut tables = HashMap::new(); - for (t, mut table) in std::mem::take(&mut st.tables) { - let n = d.alloc(); - Arc::make_mut(&mut table).id = n; - tables.insert(t, n); - st.tables.insert(n, table); - } - for pf in &mut st.paras { - pf.table = pf.table.and_then(|t| tables.get(&t).copied()); - } + renumber_tables(d, &mut st); d.stories.insert(*new, Arc::new(st)); } for new in stories.values() { @@ -1563,6 +1555,28 @@ fn renumber_anchored( } } +/// Give the tables of `st`, and the tables in the stories of its table cells and notes, new ids from `d`, and point the +/// paragraphs that hold them at the new ids. [`too_deep`] bounds the nesting. +fn renumber_tables(d: &mut designcraft_doc::Document, st: &mut Story) { + let mut tables = HashMap::new(); + for (t, mut table) in std::mem::take(&mut st.tables) { + let n = d.alloc(); + let table_mut = Arc::make_mut(&mut table); + table_mut.id = n; + for c in &mut table_mut.cells { + renumber_tables(d, &mut c.text); + } + tables.insert(t, n); + st.tables.insert(n, table); + } + for pf in &mut st.paras { + pf.table = pf.table.and_then(|t| tables.get(&t).copied()); + } + for n in st.notes.iter_mut().chain(st.endnotes.iter_mut()) { + renumber_tables(d, &mut Arc::make_mut(n).text); + } +} + /// Point the cross-references in `st`, and in the stories of its table cells and notes, at the anchors `map` gives for /// their targets. [`too_deep`] bounds the nesting. fn remap_xrefs(st: &mut Story, map: &HashMap) { @@ -2172,6 +2186,41 @@ mod layer_tests { assert_eq!(d.story(copied).unwrap().tables[&new].nrows(), 2, "a command naming the copy's table changes the copy"); } + #[test] + fn duplicate_layer_gives_tables_in_copied_cells_their_own_ids() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let base = s.doc().unwrap().doc.layers[0].id.0; + let text = json!({"rect": [72, 72, 400, 400], "content": "text", "text": "Rows"}); + 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": 4, "focus": 4})).unwrap(); + let table = s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let inner = d.alloc(); + let deepest = d.alloc(); + let mut nested = designcraft_doc::Table::new(inner, 1, 1, 0, 0, 40.0); + nested.cells[0].text.insert_table(0, designcraft_doc::Table::new(deepest, 1, 1, 0, 0, 20.0)); + d.story_mut(sid).unwrap().table_mut(table).unwrap().cells[0].text.insert_table(0, nested); + let copy = s.execute("layer.duplicate", &json!({"id": base})).unwrap()["id"].as_u64().unwrap(); + let d = &s.doc().unwrap().doc; + let copied = on_layer(d, copy)[0].text_frame().unwrap().story; + let outer = d.story(copied).unwrap().tables.values().next().unwrap(); + let cell = &outer.cells[0].text; + let new_inner = *cell.tables.keys().next().unwrap(); + assert_ne!(new_inner, inner, "a table in a copied cell has an id of its own"); + assert_eq!(cell.tables[&new_inner].id, new_inner); + assert_eq!(cell.paras.iter().filter_map(|pf| pf.table).collect::>(), [new_inner], "the copied cell holds the copied table"); + let inner_cell = &cell.tables[&new_inner].cells[0].text; + let new_deepest = *inner_cell.tables.keys().next().unwrap(); + assert_ne!(new_deepest, deepest, "a table nested deeper has an id of its own"); + assert_eq!(inner_cell.paras.iter().filter_map(|pf| pf.table).collect::>(), [new_deepest]); + let ids = [outer.id, new_inner, new_deepest, table, inner, deepest]; + assert_eq!(ids.iter().collect::>().len(), ids.len(), "every table id is unique: {ids:?}"); + let orig = d.story(sid).unwrap().tables[&table].cells[0].text.tables.keys().copied().collect::>(); + assert_eq!(orig, [inner], "the original's nested table keeps its id"); + } + #[test] fn duplicate_layer_numbers_copied_tables_and_anchored_objects_in_story_order() { let mut s = crate::Session::new(); From 1c424288a931c0dee22eca5c8505a26ab6ef87b1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 27 Oct 2026 08:54:35 -0700 Subject: [PATCH 05/18] Remap text anchors in duplicated cells and notes --- crates/engine/src/cmd/layout/layers.rs | 88 ++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/crates/engine/src/cmd/layout/layers.rs b/crates/engine/src/cmd/layout/layers.rs index f9943007a..fc8c7ecf2 100644 --- a/crates/engine/src/cmd/layout/layers.rs +++ b/crates/engine/src/cmd/layout/layers.rs @@ -119,6 +119,7 @@ pub(super) fn duplicate_layer(s: &mut Session, p: &Value) -> Result { Arc::make_mut(a).id = id; } renumber_tables(d, &mut st); + renumber_nested_anchors(d, &mut st, &mut anchors); d.stories.insert(*new, Arc::new(st)); } for new in stories.values() { @@ -260,6 +261,25 @@ fn renumber_tables(d: &mut designcraft_doc::Document, st: &mut Story) { } } +fn renumber_nested_anchors(d: &mut designcraft_doc::Document, st: &mut Story, anchors: &mut HashMap) { + fn child(d: &mut designcraft_doc::Document, st: &mut Story, anchors: &mut HashMap) { + for anchor in &mut st.anchors { + let id = d.alloc(); + anchors.insert(anchor.id, id); + Arc::make_mut(anchor).id = id; + } + renumber_nested_anchors(d, st, anchors); + } + for table in st.tables.values_mut() { + for cell in &mut Arc::make_mut(table).cells { + child(d, &mut cell.text, anchors); + } + } + for note in st.notes.iter_mut().chain(st.endnotes.iter_mut()) { + child(d, &mut Arc::make_mut(note).text, anchors); + } +} + /// Point the cross-references in `st`, and in the stories of its table cells and notes, at the anchors `map` gives for /// their targets. [`too_deep`] bounds the nesting. fn remap_xrefs(st: &mut Story, map: &HashMap) { @@ -709,6 +729,74 @@ mod layer_tests { assert_eq!(d.find_anchor(st.anchors[0].id).map(|a| a.0), Some(copied)); } + #[test] + fn duplicate_layer_remaps_anchors_in_cells_and_notes() { + use designcraft_doc::Story; + use std::sync::Arc; + + fn add_anchor(st: &mut Story, id: u64) { + st.insert_anchor(0, "Nested heading"); + Arc::make_mut(&mut st.anchors[0]).id = id; + st.insert_xref(st.len(), designcraft_doc::CrossRef { target: id, format: String::new() }); + } + fn collect(st: &Story, values: &mut Vec<(u64, u64)>) { + for (a, x) in st.anchors.iter().zip(st.xrefs.iter()) { + values.push((a.id, x.target)); + } + for table in st.tables.values() { + for cell in &table.cells { + collect(&cell.text, values); + } + } + for note in st.notes.iter().chain(st.endnotes.iter()) { + collect(¬e.text, values); + } + } + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let base = s.doc().unwrap().doc.layers[0].id.0; + let f = s.execute("frame.create", &json!({"rect": [72, 72, 400, 400], "content": "text", "text": "Rows"})).unwrap()["id"].as_u64().unwrap(); + let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().text_frame().unwrap().story; + let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let table_id = d.alloc(); + let nested_id = d.alloc(); + let anchor_ids = [d.alloc(), d.alloc(), d.alloc(), d.alloc()]; + let mut table = designcraft_doc::Table::new(table_id, 1, 1, 0, 0, 40.0); + let cell = &mut table.cells[0].text; + add_anchor(cell, anchor_ids[0]); + let mut nested = designcraft_doc::Table::new(nested_id, 1, 1, 0, 0, 20.0); + add_anchor(&mut nested.cells[0].text, anchor_ids[1]); + cell.insert_table(cell.len(), nested); + cell.insert_note(cell.len(), "Footnote", Default::default()).unwrap(); + add_anchor(&mut Arc::make_mut(&mut cell.notes[0]).text, anchor_ids[2]); + cell.insert_endnote(cell.len(), "Endnote", Default::default()).unwrap(); + add_anchor(&mut Arc::make_mut(&mut cell.endnotes[0]).text, anchor_ids[3]); + d.story_mut(sid).unwrap().insert_table(0, table); + let mut original = Vec::new(); + collect(d.story(sid).unwrap(), &mut original); + assert_eq!(original.len(), 4); + let copy = s.execute("layer.duplicate", &json!({"id": base})).unwrap()["id"].as_u64().unwrap(); + let d = &s.doc().unwrap().doc; + let copied = on_layer(d, copy)[0].text_frame().unwrap().story; + let mut after = Vec::new(); + collect(d.story(copied).unwrap(), &mut after); + assert_eq!(after.len(), original.len()); + let unique: std::collections::HashSet<_> = after.iter().map(|(id, _)| *id).collect(); + assert_eq!(unique.len(), after.len()); + for (id, target) in &after { + assert!(!anchor_ids.contains(id), "copied nested anchor retains original id {id}"); + assert_eq!(id, target, "copied reference points to its copied anchor"); + } + let mut unchanged = Vec::new(); + collect(d.story(sid).unwrap(), &mut unchanged); + assert_eq!(unchanged, original); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.redo", &json!({})).unwrap(); + let mut redone = Vec::new(); + collect(s.doc().unwrap().doc.story(copied).unwrap(), &mut redone); + assert_eq!(redone, after); + } + #[test] fn duplicate_layer_gives_copied_tables_their_own_ids() { let mut s = crate::Session::new(); From b11913eb9cb21a654c2d0b457152364bfa4d6736 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 14:33:20 -0700 Subject: [PATCH 06/18] Layers: copied anchors in cells and notes get ids no other anchor has Duplicate Layer gave the text anchors in copied table cells and notes the document's next ids, which can equal the ids of anchors that cell and note stories number on their own. Each copy now skips every anchor id already in the document. The layer commands also import what they use since main moved them into their own module. --- crates/engine/src/cmd/layout.rs | 7 +-- crates/engine/src/cmd/layout/layers.rs | 86 +++++++++++++++++++++++--- 2 files changed, 79 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index fbb8b2a52..5ec6abb4d 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -1,11 +1,6 @@ //! Layout menu and Pages/Layers panels: pages, spreads, parents, margins & columns, layers. -use std::collections::HashMap; -use std::sync::Arc; - -use designcraft_doc::{ - Content, Guide, Hyperlink, HyperlinkSource, Item, ItemId, LAYER_COLORS, Layer, LayerId, Margins, SpreadId, SpreadRef, Story, StoryId, -}; +use designcraft_doc::{LAYER_COLORS, Layer, LayerId, Margins, SpreadId}; use serde_json::{Value, json}; use super::{CommandSpec, bad, cmd, has_doc, ok, str_param}; diff --git a/crates/engine/src/cmd/layout/layers.rs b/crates/engine/src/cmd/layout/layers.rs index 05bfa4c66..a527df660 100644 --- a/crates/engine/src/cmd/layout/layers.rs +++ b/crates/engine/src/cmd/layout/layers.rs @@ -1,4 +1,9 @@ -use super::{LAYER_COLORS, LayerId, Result, Session, Value, bad, ok}; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use designcraft_doc::{Content, Guide, Hyperlink, HyperlinkSource, Item, ItemId, SpreadRef, Story, StoryId}; + +use super::{LAYER_COLORS, Layer, LayerId, Result, Session, Value, bad, json, ok}; /// Layer Options: every field in one undo step; a bad name or colour changes nothing. pub(super) fn set_layer(s: &mut Session, p: &Value) -> Result { @@ -105,6 +110,10 @@ pub(super) fn duplicate_layer(s: &mut Session, p: &Value) -> Result { // and those of the other copies point at, and tables with ids of their own, the tables in its cells and notes // included, allocated in story order. let mut anchors = HashMap::new(); + let mut used = HashSet::new(); + for st in d.stories.values() { + anchor_ids(st, &mut used); + } let mut order: Vec<(StoryId, StoryId)> = stories.iter().map(|(old, new)| (*old, *new)).collect(); order.sort_unstable_by_key(|(_, new)| *new); for (old, new) in &order { @@ -116,10 +125,11 @@ pub(super) fn duplicate_layer(s: &mut Session, p: &Value) -> Result { for (k, a) in (1..).zip(st.anchors.iter_mut()) { let id = (new.0 << 24) + k; anchors.insert(a.id, id); + used.insert(id); Arc::make_mut(a).id = id; } renumber_tables(d, &mut st); - renumber_nested_anchors(d, &mut st, &mut anchors); + renumber_nested_anchors(d, &mut st, &mut anchors, &mut used); d.stories.insert(*new, Arc::new(st)); } for new in stories.values() { @@ -261,22 +271,40 @@ fn renumber_tables(d: &mut designcraft_doc::Document, st: &mut Story) { } } -fn renumber_nested_anchors(d: &mut designcraft_doc::Document, st: &mut Story, anchors: &mut HashMap) { - fn child(d: &mut designcraft_doc::Document, st: &mut Story, anchors: &mut HashMap) { +/// Add the ids of the text anchors in `st`, and in the stories of its table cells and notes at any depth, to `used`. +/// The walk keeps its own stack, so a story on another layer that nests deeper than [`too_deep`] allows is read too. +fn anchor_ids(st: &Story, used: &mut HashSet) { + let mut stack = vec![st]; + while let Some(st) = stack.pop() { + used.extend(st.anchors.iter().map(|a| a.id)); + stack.extend(st.tables.values().flat_map(|t| t.cells.iter().map(|c| &c.text))); + stack.extend(st.notes.iter().chain(st.endnotes.iter()).map(|n| &n.text)); + } +} + +/// Give the text anchors in the stories of the table cells and notes of `st` new ids from `d` that are not in `used` +/// (recorded in `anchors` and `used`). [`too_deep`] bounds the nesting. +fn renumber_nested_anchors(d: &mut designcraft_doc::Document, st: &mut Story, anchors: &mut HashMap, used: &mut HashSet) { + fn child(d: &mut designcraft_doc::Document, st: &mut Story, anchors: &mut HashMap, used: &mut HashSet) { for anchor in &mut st.anchors { - let id = d.alloc(); + let id = loop { + let id = d.alloc(); + if used.insert(id) { + break id; + } + }; anchors.insert(anchor.id, id); Arc::make_mut(anchor).id = id; } - renumber_nested_anchors(d, st, anchors); + renumber_nested_anchors(d, st, anchors, used); } for table in st.tables.values_mut() { for cell in &mut Arc::make_mut(table).cells { - child(d, &mut cell.text, anchors); + child(d, &mut cell.text, anchors, used); } } for note in st.notes.iter_mut().chain(st.endnotes.iter_mut()) { - child(d, &mut Arc::make_mut(note).text, anchors); + child(d, &mut Arc::make_mut(note).text, anchors, used); } } @@ -797,6 +825,48 @@ mod layer_tests { assert_eq!(redone, after); } + #[test] + fn duplicate_layer_gives_copied_cell_anchors_ids_no_other_anchor_has() { + use std::sync::Arc; + + fn ids(st: &designcraft_doc::Story, out: &mut Vec) { + out.extend(st.anchors.iter().map(|a| a.id)); + for table in st.tables.values() { + for cell in &table.cells { + ids(&cell.text, out); + } + } + } + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let base = s.doc().unwrap().doc.layers[0].id.0; + let f = s.execute("frame.create", &json!({"rect": [72, 72, 400, 400], "content": "text", "text": "Rows"})).unwrap()["id"].as_u64().unwrap(); + let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().text_frame().unwrap().story; + let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let table_id = d.alloc(); + // The cell's anchors take the ids the document hands out next, as anchors made in a cell story can. + let start = d.alloc(); + let mut table = designcraft_doc::Table::new(table_id, 1, 1, 0, 0, 40.0); + let cell = &mut table.cells[0].text; + for k in 1..=64 { + cell.insert_anchor(0, "Heading"); + Arc::make_mut(&mut cell.anchors[0]).id = start + k; + } + d.story_mut(sid).unwrap().insert_table(0, table); + let mut original = Vec::new(); + ids(d.story(sid).unwrap(), &mut original); + assert_eq!(original.len(), 64); + let copy = s.execute("layer.duplicate", &json!({"id": base})).unwrap()["id"].as_u64().unwrap(); + let d = &s.doc().unwrap().doc; + let copied = on_layer(d, copy)[0].text_frame().unwrap().story; + let mut after = Vec::new(); + ids(d.story(copied).unwrap(), &mut after); + assert_eq!(after.len(), original.len()); + for id in &after { + assert!(!original.contains(id), "copied cell anchor {id} has the id of an original anchor"); + } + } + #[test] fn duplicate_layer_gives_copied_tables_their_own_ids() { let mut s = crate::Session::new(); From 3ff65a381a308267eecc14a8f7ba36c476f2c522 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 07:42:44 -0800 Subject: [PATCH 07/18] Layers: compare frame paths in the duplicate thread-order test Both text frames in the test start with an identity transform, so comparing transforms passed even when the copied thread was reversed. The assertion now compares the frames' paths, which differ. --- crates/engine/src/cmd/layout/layers.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/layout/layers.rs b/crates/engine/src/cmd/layout/layers.rs index a527df660..b696653b4 100644 --- a/crates/engine/src/cmd/layout/layers.rs +++ b/crates/engine/src/cmd/layout/layers.rs @@ -538,8 +538,8 @@ mod layer_tests { assert_eq!(st.text, orig.text, "the copy holds the whole text"); // Thread order as in the original (a's copy first, though b's copy was made first), without c. let pos = |id: designcraft_doc::ItemId| d.find(id).map(|l| l.path.clone()); - let order: Vec<_> = st.frames.iter().map(|f| d.item(*f).unwrap().xf).collect(); - let want: Vec<_> = [a, b].iter().map(|f| d.item(designcraft_doc::ItemId(*f)).unwrap().xf).collect(); + let order: Vec<_> = st.frames.iter().map(|f| d.item(*f).unwrap().path.clone()).collect(); + let want: Vec<_> = [a, b].iter().map(|f| d.item(designcraft_doc::ItemId(*f)).unwrap().path.clone()).collect(); assert_eq!(order, want); assert!(st.frames.iter().all(|f| pos(*f).is_some())); } From 72475633c20751b433a705268d4c9a6e87f3c6fd Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 07:49:45 -0800 Subject: [PATCH 08/18] Layers: point copied live captions at copied anchored objects Duplicate Layer pointed copied live captions at the copied objects before anchored objects in the copied stories had their ids, so a caption whose source is anchored kept the original object as its source. Anchored objects in copied stories also kept their captions and parent-item overrides unchanged. The copies now take their links after every anchored object has its id, and anchored objects get the same remapping as the other copies. --- crates/engine/src/cmd/layout/layers.rs | 53 +++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/layout/layers.rs b/crates/engine/src/cmd/layout/layers.rs index b696653b4..26e3545d1 100644 --- a/crates/engine/src/cmd/layout/layers.rs +++ b/crates/engine/src/cmd/layout/layers.rs @@ -91,11 +91,6 @@ pub(super) fn duplicate_layer(s: &mut Session, p: &Value) -> Result { copies.push((r, copy)); } } - // Every copy has its id before overrides are pointed at the copied parent items. - for (r, mut copy) in copies { - move_copy(&mut copy, new_id, &ids); - d.insert_item(r, copy, None)?; - } // A story copy holds the same anchored objects as the original: they get ids of their own, and the stories of // anchored text frames get copies in turn. The story copies are taken in story order. let mut pending: Vec = stories.values().copied().collect(); @@ -106,6 +101,17 @@ pub(super) fn duplicate_layer(s: &mut Session, p: &Value) -> Result { renumber_anchored(d, &mut st, new_id, &mut stories, &mut ids, &mut pending); d.stories.insert(new, Arc::new(st)); } + // Every copy, anchored objects included, has its id before overrides are pointed at the copied parent items and + // live captions at the copied objects. + for (r, mut copy) in copies { + move_copy(&mut copy, new_id, &ids); + d.insert_item(r, copy, None)?; + } + for new in stories.values() { + if let Some(st) = d.story_mut(*new) { + move_anchored(st, new_id, &ids); + } + } // Each story copy gets its frames in the original order, text anchors of its own, which its cross-references // and those of the other copies point at, and tables with ids of their own, the tables in its cells and notes // included, allocated in story order. @@ -375,6 +381,22 @@ fn move_copy(it: &mut Item, to: LayerId, ids: &HashMap) { } } +/// Run [`move_copy`] on the anchored objects of `st` and of the stories of its table cells and notes. [`too_deep`] bounds +/// the nesting. +fn move_anchored(st: &mut Story, to: LayerId, ids: &HashMap) { + for o in &mut st.objects { + move_copy(&mut Arc::make_mut(o).item, to, ids); + } + for t in st.tables.values_mut() { + for c in &mut Arc::make_mut(t).cells { + move_anchored(&mut c.text, to, ids); + } + } + for n in st.notes.iter_mut().chain(st.endnotes.iter_mut()) { + move_anchored(&mut Arc::make_mut(n).text, to, ids); + } +} + #[cfg(test)] mod layer_tests { use serde_json::json; @@ -695,6 +717,27 @@ mod layer_tests { assert_ne!(frame.id.0, a); } + #[test] + fn duplicate_layer_points_copied_live_captions_at_copied_anchored_objects() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let base = s.doc().unwrap().doc.layers[0].id.0; + let a = s.execute("frame.create", &json!({"rect": [450, 450, 474, 490]})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.caption", &json!({"ids": [a], "text": "{name}", "live": true})).unwrap(); + let text = json!({"rect": [72, 72, 400, 400], "content": "text", "text": "Before after."}); + 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; + // The caption's source moves into the story and keeps its id. + s.execute("anchored.insert", &json!({"ids": [a], "story": sid.0, "pos": 7})).unwrap(); + let copy = s.execute("layer.duplicate", &json!({"id": base})).unwrap()["id"].as_u64().unwrap(); + let d = &s.doc().unwrap().doc; + let anchored = d.anchored_items().into_iter().map(|(_, i)| i).find(|i| i.layer.0 == copy).unwrap(); + assert_ne!(anchored.id.0, a); + let copies = on_layer(d, copy); + let caption = copies.iter().find_map(|i| i.live_caption.as_ref()).unwrap(); + assert_eq!(caption.source, anchored.id, "the caption's copy follows the anchored object's copy"); + } + #[test] fn duplicate_layer_copies_the_hyperlinks_of_its_objects_and_text() { use designcraft_doc::{HyperlinkDest, HyperlinkSource}; From 812c7c4fd8319203906b8c6d5f2fe30e95127c21 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 08:01:00 -0800 Subject: [PATCH 09/18] Layers: give copied text anchors ids no other anchor has Duplicate Layer gave each copied text anchor the id story id << 24 | n without checking it, so a document holding an anchor with that id, such as one loaded from a file, got two anchors with one id, and cross-references could find the wrong one. A copied anchor now takes a new id when another anchor has that one or when the story id does not fit in the high bits. --- crates/engine/src/cmd/layout/layers.rs | 42 +++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/layout/layers.rs b/crates/engine/src/cmd/layout/layers.rs index 26e3545d1..2f423e676 100644 --- a/crates/engine/src/cmd/layout/layers.rs +++ b/crates/engine/src/cmd/layout/layers.rs @@ -129,7 +129,18 @@ pub(super) fn duplicate_layer(s: &mut Session, p: &Value) -> Result { } st.frames = src.story(*old).map(|o| o.frames.iter().filter_map(|f| ids.get(f).copied()).collect()).unwrap_or_default(); for (k, a) in (1..).zip(st.anchors.iter_mut()) { - let id = (new.0 << 24) + k; + // `story id << 24 | n`, as anchors are made, or a new id when another anchor has that one or the story id + // does not fit. + let made = new.0.checked_mul(1 << 24).and_then(|base| base.checked_add(k)).filter(|id| !used.contains(id)); + let id = match made { + Some(id) => id, + None => loop { + let id = d.alloc(); + if !used.contains(&id) { + break id; + } + }, + }; anchors.insert(a.id, id); used.insert(id); Arc::make_mut(a).id = id; @@ -800,6 +811,35 @@ mod layer_tests { assert_eq!(d.find_anchor(st.anchors[0].id).map(|a| a.0), Some(copied)); } + #[test] + fn duplicate_layer_gives_copied_text_anchors_ids_no_other_anchor_has() { + use std::sync::Arc; + + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let base = s.doc().unwrap().doc.layers[0].id.0; + let f = s.execute("frame.create", &json!({"rect": [72, 72, 400, 400], "content": "text", "text": "Head"})).unwrap()["id"].as_u64().unwrap(); + let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().text_frame().unwrap().story; + s.execute("layer.new", &json!({"name": "Art"})).unwrap(); + let g = s.execute("frame.create", &json!({"rect": [72, 450, 400, 600], "content": "text", "text": "Other"})).unwrap()["id"].as_u64().unwrap(); + let other = s.doc().unwrap().doc.item(designcraft_doc::ItemId(g)).unwrap().text_frame().unwrap().story; + let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc); + d.story_mut(sid).unwrap().insert_anchor(0, "Heading"); + // A story on another layer holds the ids the copy's first anchor would take, as anchors loaded from a file can. + let next = d.next_id; + let st = d.story_mut(other).unwrap(); + for k in 1..=64 { + st.insert_anchor(0, "Taken"); + Arc::make_mut(&mut st.anchors[0]).id = ((next + k) << 24) + 1; + } + s.execute("layer.duplicate", &json!({"id": base})).unwrap(); + let d = &s.doc().unwrap().doc; + let all: Vec = d.stories.values().flat_map(|st| st.anchors.iter().map(|a| a.id)).collect(); + assert_eq!(all.len(), 66); + let unique: std::collections::HashSet<_> = all.iter().collect(); + assert_eq!(unique.len(), all.len(), "the copied anchor has an id no other anchor has"); + } + #[test] fn duplicate_layer_remaps_anchors_in_cells_and_notes() { use designcraft_doc::Story; From 8d39765e936f388bc7fa8675ca4fe598acab5088 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 09:15:31 -0800 Subject: [PATCH 10/18] Layers: use the anchored copies object copying now makes Copying a frame now gives the objects anchored in its story copy ids of their own and copies of the stories of anchored text frames. Duplicate Layer renumbered them a second time, so live captions kept pointing at the originals and shared stories were copied again. It now records the ids of those copies instead. Copying a frame also renumbers objects anchored in endnotes inside table cells and footnotes, which kept their original ids before. --- crates/engine/src/cmd/layout/layers.rs | 113 ++++++++++++------------- crates/engine/src/cmd/object.rs | 19 +++-- 2 files changed, 63 insertions(+), 69 deletions(-) diff --git a/crates/engine/src/cmd/layout/layers.rs b/crates/engine/src/cmd/layout/layers.rs index b2a2ec8ac..f93a3c09c 100644 --- a/crates/engine/src/cmd/layout/layers.rs +++ b/crates/engine/src/cmd/layout/layers.rs @@ -1,7 +1,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; -use designcraft_doc::{Content, Guide, Hyperlink, HyperlinkSource, Item, ItemId, SpreadRef, Story, StoryId}; +use designcraft_doc::{Guide, Hyperlink, HyperlinkSource, Item, ItemId, SpreadRef, Story, StoryId}; use super::{LAYER_COLORS, Layer, LayerId, Result, Session, Value, bad, json, ok}; @@ -91,15 +91,12 @@ pub(super) fn duplicate_layer(s: &mut Session, p: &Value) -> Result { copies.push((r, copy)); } } - // A story copy holds the same anchored objects as the original: they get ids of their own, and the stories of - // anchored text frames get copies in turn. The story copies are taken in story order. - let mut pending: Vec = stories.values().copied().collect(); - pending.sort_unstable(); - pending.reverse(); - while let Some(new) = pending.pop() { - let Some(mut st) = d.story(new).cloned() else { continue }; - renumber_anchored(d, &mut st, new_id, &mut stories, &mut ids, &mut pending); - d.stories.insert(new, Arc::new(st)); + // The story copies hold copies of the original's anchored objects with ids of their own (and copies of the + // stories of anchored text frames): record each copy's id against the original's. + for (old, new) in &stories { + if let (Some(orig), Some(copy)) = (src.story(*old), d.story(*new)) { + map_anchored(orig, copy, &mut ids); + } } // Every copy, anchored objects included, has its id before overrides are pointed at the copied parent items and // live captions at the copied objects. @@ -242,27 +239,21 @@ fn too_deep(it: &Item, d: &designcraft_doc::Document, seen: &mut std::collection false } -/// Give the items anchored in `st`, and in the stories of its table cells and notes, new ids from `d` (recorded in `ids`) -/// and layer `to`. An anchored text frame's story gets one copy, recorded in `stories`, that every copied frame of the -/// story shares; a new copy goes on `pending`, and its frame list is filled in afterwards. [`too_deep`] bounds the nesting. -fn renumber_anchored( - d: &mut designcraft_doc::Document, - st: &mut Story, - to: LayerId, - stories: &mut HashMap, - ids: &mut HashMap, - pending: &mut Vec, -) { - for o in &mut st.objects { - renumber_anchored_item(d, &mut Arc::make_mut(o).item, to, stories, ids, pending); +/// Run [`map_ids`] on the items anchored in story `orig` and in its copy `copy`, and in the stories of their table cells +/// and notes. [`too_deep`] bounds the nesting. +fn map_anchored(orig: &Story, copy: &Story, ids: &mut HashMap) { + for (o, c) in orig.objects.iter().zip(©.objects) { + map_ids(&o.item, &c.item, ids); } - for t in st.tables.values_mut() { - for c in &mut Arc::make_mut(t).cells { - renumber_anchored(d, &mut c.text, to, stories, ids, pending); + for (k, t) in &orig.tables { + if let Some(ct) = copy.tables.get(k) { + for (o, c) in t.cells.iter().zip(&ct.cells) { + map_anchored(&o.text, &c.text, ids); + } } } - for n in st.notes.iter_mut().chain(st.endnotes.iter_mut()) { - renumber_anchored(d, &mut Arc::make_mut(n).text, to, stories, ids, pending); + for (o, c) in orig.notes.iter().chain(&orig.endnotes).zip(copy.notes.iter().chain(©.endnotes)) { + map_anchored(&o.text, &c.text, ids); } } @@ -343,39 +334,6 @@ fn remap_xrefs(st: &mut Story, map: &HashMap) { } } -/// [`renumber_anchored`] for one anchored item and its descendants. -fn renumber_anchored_item( - d: &mut designcraft_doc::Document, - it: &mut Item, - to: LayerId, - stories: &mut HashMap, - ids: &mut HashMap, - pending: &mut Vec, -) { - let id = ItemId(d.alloc()); - ids.insert(it.id, id); - it.id = id; - it.layer = to; - if let Content::Text(tf) = &mut it.content { - tf.story = match stories.get(&tf.story) { - Some(n) => *n, - None => { - let n = StoryId(d.alloc()); - let mut copy = d.story(tf.story).cloned().unwrap_or_else(|| Story::new(n)); - copy.id = n; - copy.frames.clear(); - d.stories.insert(n, Arc::new(copy)); - stories.insert(tf.story, n); - pending.push(n); - n - } - }; - } - for c in it.children_mut().into_iter().flatten() { - renumber_anchored_item(d, Arc::make_mut(c), to, stories, ids, pending); - } -} - /// Put `it` and its descendants on layer `to`, and point overrides at the copied parent items and live captions at the /// copied objects. A copy whose parent item was not copied is detached from it, so the original override stays the only /// one of that item. @@ -908,6 +866,39 @@ mod layer_tests { assert_eq!(redone, after); } + #[test] + fn duplicate_layer_gives_objects_anchored_in_endnotes_of_cells_their_own_ids() { + use designcraft_doc::AnchoredObject; + use std::sync::Arc; + + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let base = s.doc().unwrap().doc.layers[0].id.0; + let a = s.execute("frame.create", &json!({"rect": [450, 450, 474, 490]})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.caption", &json!({"ids": [a], "text": "{name}", "live": true})).unwrap(); + let f = s.execute("frame.create", &json!({"rect": [72, 72, 400, 400], "content": "text", "text": "Rows"})).unwrap()["id"].as_u64().unwrap(); + let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().text_frame().unwrap().story; + let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc); + // The caption's source moves into an endnote of a table cell and keeps its id. + let it = d.item(designcraft_doc::ItemId(a)).cloned().unwrap(); + Arc::make_mut(&mut d.spreads[0]).items.retain(|i| i.id.0 != a); + let mut table = designcraft_doc::Table::new(d.alloc(), 1, 1, 0, 0, 40.0); + let cell = &mut table.cells[0].text; + cell.insert_endnote(cell.len(), "Endnote", Default::default()).unwrap(); + Arc::make_mut(&mut cell.endnotes[0]).text.insert_object(0, AnchoredObject::new(it, Default::default())); + d.story_mut(sid).unwrap().insert_table(0, table); + d.check().unwrap(); + let copy = s.execute("layer.duplicate", &json!({"id": base})).unwrap()["id"].as_u64().unwrap(); + let d = &s.doc().unwrap().doc; + d.check().unwrap(); + let anchored: Vec<_> = d.anchored_items().into_iter().map(|(_, i)| (i.id, i.layer.0)).collect(); + assert_eq!(anchored.len(), 2); + let copied = anchored.iter().find(|i| i.1 == copy).unwrap().0; + assert_ne!(copied.0, a, "the copy has an id of its own"); + let caption = on_layer(d, copy).iter().find_map(|i| i.live_caption.clone()).unwrap(); + assert_eq!(caption.source, copied, "the caption's copy follows the anchored object's copy"); + } + #[test] fn duplicate_layer_gives_copied_cell_anchors_ids_no_other_anchor_has() { use std::sync::Arc; diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 634c90a61..a6526e93b 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -1161,16 +1161,19 @@ fn renumber_in(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut } } -/// Fresh ids for the objects anchored in story copy `st`, its table cells, footnotes and endnotes (#1522). +/// Fresh ids for the objects anchored in story copy `st`, its table cells, footnotes and endnotes, at every depth +/// (#1522). fn renumber_anchored(dst: &mut Document, src: &Document, st: &mut Story, stories: &mut HashMap) { - let mut fresh = |t: &mut Story| { - for o in &mut t.objects { - renumber_in(dst, src, &mut Arc::make_mut(o).item, stories, false); + for o in &mut st.objects { + renumber_in(dst, src, &mut Arc::make_mut(o).item, stories, false); + } + for t in st.tables.values_mut() { + for c in &mut Arc::make_mut(t).cells { + renumber_anchored(dst, src, &mut c.text, stories); } - }; - st.for_each_text_mut(&mut fresh); - for n in &mut st.endnotes { - Arc::make_mut(n).text.for_each_text_mut(&mut fresh); + } + for n in st.notes.iter_mut().chain(st.endnotes.iter_mut()) { + renumber_anchored(dst, src, &mut Arc::make_mut(n).text, stories); } } From f7df4052d68bbd23a9ec6d957aba5a1f0d0aaab5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 09:23:07 -0800 Subject: [PATCH 11/18] Layers: count Duplicate Layer's nesting limit across anchored stories Copying an object recurses from its groups into the stories of anchored text frames and on into theirs. The limit on how deeply a layer's objects may nest now counts that depth in all, from the object on the spread, instead of starting again at each story. --- crates/engine/src/cmd/layout/layers.rs | 67 +++++++++++++++++++++----- 1 file changed, 54 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/layout/layers.rs b/crates/engine/src/cmd/layout/layers.rs index f93a3c09c..cd6e72ee2 100644 --- a/crates/engine/src/cmd/layout/layers.rs +++ b/crates/engine/src/cmd/layout/layers.rs @@ -68,7 +68,7 @@ pub(super) fn duplicate_layer(s: &mut Session, p: &Value) -> Result { let id = LayerId(p.get("id").and_then(Value::as_u64).ok_or_else(|| bad(ID, "missing id"))?); let r = s.edit(|d, _| { let i = d.layers.iter().position(|l| l.id == id).ok_or_else(|| bad(ID, "no such layer"))?; - let mut seen = std::collections::HashSet::new(); + let mut seen = HashMap::new(); if d.spreads.iter().chain(&d.parents).flat_map(|sp| sp.items.iter()).any(|it| it.layer == id && too_deep(it, d, &mut seen)) { return Err(bad(ID, "the layer's objects are nested too deeply to copy")); } @@ -205,20 +205,22 @@ fn map_ids(orig: &Item, copy: &Item, ids: &mut HashMap) { } } -/// A layer whose groups, anchored objects, table cells and notes nest deeper than this within one story or object is -/// not duplicated. +/// A layer whose groups, anchored objects, table cells and notes nest deeper than this, counted from an object on a +/// spread through the stories its anchored text frames show, is not duplicated. const MAX_COPY_DEPTH: usize = 64; -/// Whether `it`, or a story its text frames reach directly or through anchored text frames, nests groups, anchored -/// objects, table cells and notes deeper than [`MAX_COPY_DEPTH`]. Stories in `seen` were checked already and are skipped. -fn too_deep(it: &Item, d: &designcraft_doc::Document, seen: &mut std::collections::HashSet) -> bool { - fn item(it: &Item, depth: usize, next: &mut Vec) -> bool { +/// Whether `it`, with the stories its text frames reach directly or through anchored text frames, nests groups, +/// anchored objects, table cells and notes deeper than [`MAX_COPY_DEPTH`] in all: a story counts from the depth of the +/// frame that shows it, as copying it recurses on from there. `seen` holds the deepest level each story was checked at; +/// a story reached again no deeper is skipped. +fn too_deep(it: &Item, d: &designcraft_doc::Document, seen: &mut HashMap) -> bool { + fn item(it: &Item, depth: usize, next: &mut Vec<(StoryId, usize)>) -> bool { if let Some(t) = it.text_frame() { - next.push(t.story); + next.push((t.story, depth)); } depth > MAX_COPY_DEPTH || it.children().iter().any(|c| item(c, depth + 1, next)) } - fn story(st: &Story, depth: usize, next: &mut Vec) -> bool { + fn story(st: &Story, depth: usize, next: &mut Vec<(StoryId, usize)>) -> bool { depth > MAX_COPY_DEPTH || st.objects.iter().any(|o| item(&o.item, depth + 1, next)) || st.tables.values().flat_map(|t| &t.cells).any(|c| story(&c.text, depth + 1, next)) @@ -228,10 +230,13 @@ fn too_deep(it: &Item, d: &designcraft_doc::Document, seen: &mut std::collection if item(it, 0, &mut next) { return true; } - while let Some(sid) = next.pop() { - if seen.insert(sid) - && let Some(st) = d.story(sid) - && story(st, 0, &mut next) + while let Some((sid, depth)) = next.pop() { + if seen.get(&sid).is_some_and(|&checked| checked >= depth) { + continue; + } + seen.insert(sid, depth); + if let Some(st) = d.story(sid) + && story(st, depth, &mut next) { return true; } @@ -1088,6 +1093,42 @@ mod layer_tests { assert_eq!(s.doc().unwrap().doc.layers, before, "nothing changes"); } + /// Two stories, each anchoring groups nested 40 deep, the innermost group of the first holding a text frame that + /// shows the second: within the limit in each story, past it in all. + #[test] + fn duplicate_layer_refuses_nesting_too_deep_across_stories() { + use designcraft_doc::{AnchoredObject, Content, Item, ItemId}; + use std::sync::Arc; + + fn nest(d: &mut designcraft_doc::Document, mut it: Item, levels: usize) -> Item { + for _ in 0..levels { + let mut group = it.clone(); + group.id = ItemId(d.alloc()); + group.content = Content::Group { items: vec![Arc::new(it)] }; + it = group; + } + it + } + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let base = s.doc().unwrap().doc.layers[0].id.0; + let f = s.execute("frame.create", &json!({"rect": [72, 72, 400, 400], "content": "text", "text": "Top"})).unwrap()["id"].as_u64().unwrap(); + let g = s.execute("frame.create", &json!({"rect": [0, 0, 60, 30], "content": "text", "text": "Inner"})).unwrap()["id"].as_u64().unwrap(); + let h = s.execute("frame.create", &json!({"rect": [0, 0, 10, 10]})).unwrap()["id"].as_u64().unwrap(); + let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let top = d.item(ItemId(f)).unwrap().text_frame().unwrap().story; + let (inner_frame, leaf) = (d.item(ItemId(g)).cloned().unwrap(), d.item(ItemId(h)).cloned().unwrap()); + let inner = inner_frame.text_frame().unwrap().story; + Arc::make_mut(&mut d.spreads[0]).items.retain(|i| i.id.0 != g && i.id.0 != h); + let deep_leaf = nest(d, leaf, 40); + d.story_mut(inner).unwrap().insert_object(0, AnchoredObject::new(deep_leaf, Default::default())); + let deep_frame = nest(d, inner_frame, 40); + d.story_mut(top).unwrap().insert_object(0, AnchoredObject::new(deep_frame, Default::default())); + let before = s.doc().unwrap().doc.layers.clone(); + assert!(s.execute("layer.duplicate", &json!({"id": base})).is_err()); + assert_eq!(s.doc().unwrap().doc.layers, before, "nothing changes"); + } + #[test] fn duplicate_layer_detaches_overrides_of_parent_items_on_other_layers() { let mut s = crate::Session::new(); From d3a073470cef09eb58856ca442b83a79cf87db10 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 10:18:07 -0800 Subject: [PATCH 12/18] Images: decode JPEG screen proxies at a reduced size A placed JPEG drawn at a fraction of its pixel size was decoded at full size first and then halved down to the level the screen needs. For a 24 MP photo that is a 96 MB decode per image, and with several such images on a page the decoded pixels outgrow the image cache. A YCbCr or grayscale JPEG whose wanted level is a half, a quarter or an eighth of its size (or smaller) is now decoded at that size straight from its compressed blocks with jpeg-decoder's scaled IDCT, cut to the size the halving path gives the same level, and halved from there for smaller levels. The embedded ICC profile is applied as in a full decode. CMYK and YCCK JPEGs, other formats and the full size still decode in full. --- Cargo.lock | 7 ++ ROADMAP.md | 1 + crates/images/Cargo.toml | 2 + crates/images/src/colour/tests.rs | 22 ++++++ crates/images/src/jpeg.rs | 38 +++++++++ crates/images/src/lib.rs | 18 +++++ crates/images/src/tests.rs | 50 ++++++++++++ crates/render/src/decode.rs | 12 ++- crates/render/src/images.rs | 125 ++++++++++++++++++++++++++++++ 9 files changed, 274 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 9760f61ff..4c4887099 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1504,6 +1504,7 @@ dependencies = [ "designcraft-fonts", "flate2", "image", + "jpeg-decoder", "log", "quick-xml 0.41.0", "resvg", @@ -3305,6 +3306,12 @@ dependencies = [ "libc", ] +[[package]] +name = "jpeg-decoder" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" + [[package]] name = "js-sys" version = "0.3.106" diff --git a/ROADMAP.md b/ROADMAP.md index 402c81438..559fed4e1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -388,6 +388,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m ## Progress log +- **2026-11-12** · A placed JPEG drawn at a half, a quarter or an eighth of its pixel size or less is decoded at that size straight from its compressed blocks, so the canvas, page thumbnails and loaded-graphic thumbnails no longer decode and keep the full-size picture for it. CMYK JPEGs and the full size still decode in full. No readiness percentage change. - **2026-11-12** · The status bar's preflight count and the Preflight panel take their issues from a check on a background worker, which checks the newest state of the document and shows the issues last found meanwhile. Moving or resizing a frame and typing no longer wait for a check of the whole document on every drag step and keystroke. No readiness percentage change. - **2026-11-04** · Striped strokes retain out-and-back cubic segments whose second control point is away from the coincident endpoints and first control point. A geometry regression checks the offset extent and both stripe bands. No readiness percentage change. - **2026-11-04** · Esperanto is a text language (Character › Language, `type.char {language: "Esperanto"}`, IDML `$ID/Esperanto`): tag `eo`, English-style quotes; not hyphenated yet (#1201). No readiness percentage change. diff --git a/crates/images/Cargo.toml b/crates/images/Cargo.toml index 685c7d947..658047784 100644 --- a/crates/images/Cargo.toml +++ b/crates/images/Cargo.toml @@ -21,6 +21,8 @@ log = { workspace = true } craft-cms = { workspace = true } zune-core = { version = "0.5", default-features = false } zune-jpeg = "0.5" +# Reduced-size JPEG decoding (1/2, 1/4, 1/8 through the DCT) for screen proxies. +jpeg-decoder = { version = "0.3", default-features = false } [dev-dependencies] designcraft-fonts = { workspace = true, features = ["testing"] } diff --git a/crates/images/src/colour/tests.rs b/crates/images/src/colour/tests.rs index ca1607965..28bd99c99 100644 --- a/crates/images/src/colour/tests.rs +++ b/crates/images/src/colour/tests.rs @@ -241,3 +241,25 @@ fn unusable_profiles_leave_the_pixels_alone() { let png = png_with_icc(1, 1, &px, Some(vec![0xAB; 300])); assert_eq!(decode_rgba(&png).unwrap().as_raw(), &px); } + +#[test] +fn reduced_jpegs_get_the_colour_handling_of_full_decodes() { + // An Adobe RGB JPEG: the reduced decode applies the profile as the full decode does. + let img = image::RgbImage::from_pixel(64, 48, image::Rgb([64, 192, 64])); + let mut jpeg = Vec::new(); + let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, 95); + enc.set_icc_profile(adobe_rgb_icc()).unwrap(); + enc.write_image(img.as_raw(), 64, 48, image::ExtendedColorType::Rgb8).unwrap(); + let full = decode_rgba(&jpeg).unwrap().get_pixel(3, 3).0; + let reduced = crate::decode_rgba_reduced(&jpeg, 3).unwrap(); + assert_eq!(reduced.dimensions(), (8, 6)); + let p = reduced.get_pixel(1, 1).0; + for c in 0..4 { + assert!(p[c].abs_diff(full[c]) <= 2, "{p:?} vs {full:?}"); + } + let want = adobe_to_srgb([64, 192, 64]); + assert!((f32::from(p[0]) - want[0]).abs() <= 3.0, "{p:?} vs {want:?}"); + // CMYK and YCCK JPEGs stay with the full decode, which converts their inks. + assert!(crate::decode_rgba_reduced(&solid_jpeg([0, 255, 255, 255], Some(0)), 1).is_none()); + assert!(crate::decode_rgba_reduced(&solid_jpeg([76, 85, 255, 255], Some(2)), 1).is_none()); +} diff --git a/crates/images/src/jpeg.rs b/crates/images/src/jpeg.rs index 8b44d0c9a..fea0a1e64 100644 --- a/crates/images/src/jpeg.rs +++ b/crates/images/src/jpeg.rs @@ -62,6 +62,44 @@ pub(crate) fn decode_cmyk(bytes: &[u8]) -> Option<(CmykRaster, Option>)> Some((raster, icc)) } +/// The largest reduction [`decode_reduced`] reads straight from a JPEG's 8×8 blocks: 1/8. +pub(crate) const MAX_SHRINK: u8 = 3; + +/// A YCbCr, RGB or grayscale JPEG decoded at 1/2^`shrink` of its size, each side rounded up, for +/// `shrink` 1 to [`MAX_SHRINK`]: straight RGBA and whether the image has colour. `None` for CMYK, +/// 12-bit and lossless JPEGs, other formats and anything that doesn't decode. +pub(crate) fn decode_reduced(bytes: &[u8], shrink: u8) -> Option<(image::RgbaImage, bool)> { + use jpeg_decoder::PixelFormat; + if !bytes.starts_with(&[0xFF, 0xD8]) || shrink == 0 || shrink > MAX_SHRINK { + return None; + } + let mut d = jpeg_decoder::Decoder::new(bytes); + d.set_max_decoding_buffer_size(MAX_BYTES); + d.read_info().ok()?; + let info = d.info()?; + let colour = match info.pixel_format { + PixelFormat::RGB24 => true, + PixelFormat::L8 => false, + PixelFormat::L16 | PixelFormat::CMYK32 => return None, + }; + let reduced = |n: u16| u16::try_from(u32::from(n).div_ceil(1 << shrink)).ok(); + let (w, h) = d.scale(reduced(info.width)?, reduced(info.height)?).ok()?; + let px = d.decode().ok()?; + let pixels = usize::from(w).checked_mul(usize::from(h))?; + let rgba: Vec = if colour { + if px.len() != pixels.checked_mul(3)? { + return None; + } + px.as_chunks::<3>().0.iter().flat_map(|p| [p[0], p[1], p[2], 255]).collect() + } else { + if px.len() != pixels { + return None; + } + px.iter().flat_map(|&v| [v, v, v, 255]).collect() + }; + Some((image::RgbaImage::from_raw(u32::from(w), u32::from(h), rgba)?, colour)) +} + /// Whether an APP14 "Adobe" segment comes before the image data. fn adobe_marker(bytes: &[u8]) -> bool { let mut i = 2; diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs index 081af6c2b..530ca8097 100644 --- a/crates/images/src/lib.rs +++ b/crates/images/src/lib.rs @@ -442,6 +442,24 @@ pub fn decode_rgba(bytes: &[u8]) -> Option { to_display_rgba(px, Some(&icc), &DisplayColour::active()) } +/// The largest `shrink` [`decode_rgba_reduced`] takes: 3, an eighth of each side. +pub const MAX_REDUCED_SHRINK: u8 = jpeg::MAX_SHRINK; + +/// [`decode_rgba`] of a JPEG at 1/2^`shrink` of its size (`shrink` 1 to [`MAX_REDUCED_SHRINK`], each +/// side rounded up), read from its compressed blocks without a full-size decode. The embedded ICC +/// profile is applied as [`decode_rgba`] applies it. `None` where only a full decode applies: other +/// formats, CMYK, 12-bit and lossless JPEGs, and files [`decode_rgba`] refuses as too large. +pub fn decode_rgba_reduced(bytes: &[u8], shrink: u8) -> Option { + use colour::{DisplayColour, Pixels, to_display_rgba}; + if too_large_to_decode(bytes) { + return None; + } + let (rgba, has_colour) = jpeg::decode_reduced(bytes, shrink)?; + let Some(icc) = embedded_icc(bytes) else { return Some(rgba) }; + let px = if has_colour { Pixels::Rgb(rgba) } else { Pixels::Gray(rgba) }; + to_display_rgba(px, Some(&icc), &DisplayColour::active()) +} + /// False when `bytes` is a WebP whose `ICCP` chunk declares more bytes than the file holds or than /// [`colour::MAX_ICC_BYTES`]. The decoder sizes its buffer from the declared length before it reads /// anything, so such a file must not reach `icc_profile()`; its pixels decode without it. diff --git a/crates/images/src/tests.rs b/crates/images/src/tests.rs index cd577f09a..ae5435a62 100644 --- a/crates/images/src/tests.rs +++ b/crates/images/src/tests.rs @@ -561,3 +561,53 @@ fn a_webp_icc_chunk_that_declares_more_than_it_holds_is_refused_before_decoding( assert!(webp_icc_is_sane(b"\x89PNG not a webp")); assert!(webp_icc_is_sane(&webp_with(&[(b"VP8L", 6, &[0; 6])]))); } + +/// A JPEG of a smooth gradient, `w` × `h`. +fn gradient_jpeg(w: u32, h: u32, gray: bool) -> Vec { + let rgb = image::RgbImage::from_fn(w, h, |x, y| image::Rgb([(x * 255 / w) as u8, (y * 255 / h) as u8, 128])); + let img = if gray { image::DynamicImage::ImageRgb8(rgb).into_luma8().into() } else { image::DynamicImage::ImageRgb8(rgb) }; + let mut out = Vec::new(); + img.write_with_encoder(image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 90)).unwrap(); + out +} + +#[test] +fn reduced_jpegs_decode_at_a_half_quarter_or_eighth_rounded_up() { + let jpeg = gradient_jpeg(1001, 77, false); + let full = decode_rgba(&jpeg).unwrap(); + for (shrink, size) in [(1, (501, 39)), (2, (251, 20)), (3, (126, 10))] { + let img = decode_rgba_reduced(&jpeg, shrink).unwrap(); + assert_eq!(img.dimensions(), size, "shrink {shrink}"); + // The same picture as the full decode sampled at the block centres, give or take rounding. + let step = 1u32 << shrink; + let (mut total, mut n) = (0u64, 0u64); + for y in 0..size.1 - 1 { + for x in 0..size.0 - 1 { + let (p, q) = (img.get_pixel(x, y).0, full.get_pixel(x * step + step / 2, y * step + step / 2).0); + total += (0..3).map(|c| u64::from(p[c].abs_diff(q[c]))).sum::(); + n += 3; + assert_eq!(p[3], 255); + } + } + assert!(total / n <= 3, "shrink {shrink}: mean difference {}", total / n); + } + // Only 1/2, 1/4 and 1/8 are read from the blocks. + assert!(decode_rgba_reduced(&jpeg, 0).is_none()); + assert!(decode_rgba_reduced(&jpeg, 4).is_none()); +} + +#[test] +fn reduced_decoding_is_for_jpegs_and_survives_damaged_ones() { + let gray = decode_rgba_reduced(&gradient_jpeg(64, 64, true), 2).unwrap(); + assert_eq!(gray.dimensions(), (16, 16)); + assert!(gray.pixels().all(|p| p[0] == p[1] && p[1] == p[2] && p[3] == 255)); + let png = png_from_rgba(16, 16, vec![255; 16 * 16 * 4]).unwrap(); + assert!(decode_rgba_reduced(&png, 1).is_none()); + assert!(decode_rgba_reduced(&[0xFF, 0xD8], 1).is_none()); + let jpeg = gradient_jpeg(200, 150, false); + for n in (0..jpeg.len()).step_by(37) { + if let Some(img) = decode_rgba_reduced(&jpeg[..n], 1) { + assert_eq!(img.dimensions(), (100, 75)); + } + } +} diff --git a/crates/render/src/decode.rs b/crates/render/src/decode.rs index 135dbf108..8c703701e 100644 --- a/crates/render/src/decode.rs +++ b/crates/render/src/decode.rs @@ -19,7 +19,17 @@ pub fn decode_pixmap_page(bytes: &[u8], page: u32) -> Option { let data = px.as_chunks::<4>().0.iter().map(|p| vello_cpu::color::PremulRgba8 { r: p[0], g: p[1], b: p[2], a: p[3] }).collect(); return Some(Pixmap::from_parts(data, w.min(u16::MAX as u32) as u16, h.min(u16::MAX as u32) as u16)); } - let img = designcraft_images::decode_rgba(bytes)?; + premultiplied(&designcraft_images::decode_rgba(bytes)?) +} + +/// [`decode_pixmap`] of a JPEG at 1/2^`shrink` of its size, `shrink` 1 to +/// [`designcraft_images::MAX_REDUCED_SHRINK`], without a full-size decode. `None` where only a +/// full decode applies (other formats, CMYK JPEGs). +pub(crate) fn decode_pixmap_reduced(bytes: &[u8], shrink: u8) -> Option { + premultiplied(&designcraft_images::decode_rgba_reduced(bytes, shrink)?) +} + +fn premultiplied(img: &image::RgbaImage) -> Option { let (w, h) = img.dimensions(); if w == 0 || h == 0 || w > u16::MAX as u32 || h > u16::MAX as u32 { return None; diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index 37606097d..69691135e 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -10,6 +10,7 @@ use std::sync::{Arc, Mutex}; use vello_cpu::Pixmap; +use crate::decode::decode_pixmap_reduced; use crate::{decode_pixmap_page, halve}; /// Decoded pixels kept (bytes). @@ -130,6 +131,11 @@ pub fn decoded(bytes: &Arc>, page: u32) -> Option> { /// The mip level of `bytes` closest above `on_screen` device pixels per point for a graphic /// `width_pt` points wide. pub fn mip(bytes: &Arc>, page: u32, width_pt: f64, on_screen: f64) -> Option> { + if lookup((Arc::as_ptr(bytes) as usize, page, 0)).is_none() + && let Some(pm) = reduced(bytes, page, width_pt, on_screen) + { + return Some(pm); + } let full = decoded(bytes, page)?; let ppt = full.width() as f64 / width_pt.max(1e-6); let id = Arc::as_ptr(bytes) as usize; @@ -149,6 +155,71 @@ pub fn mip(bytes: &Arc>, page: u32, width_pt: f64, on_screen: f64) -> Op Some(pm) } +/// [`mip`]'s level for a JPEG, decoded at 1/2, 1/4 or 1/8 of its size straight from its blocks +/// and halved from there, so a screen-sized level needs no full-size decode. `None` when the +/// full size is drawn, for other formats and for JPEGs only a full decode reads (CMYK). +fn reduced(bytes: &Arc>, page: u32, width_pt: f64, on_screen: f64) -> Option> { + if page != 0 || !bytes.starts_with(&[0xFF, 0xD8]) { + return None; + } + let id = Arc::as_ptr(bytes) as usize; + if with(|c| c.bad.contains_key(&(id, page))) { + return None; + } + // The level `mip` picks from a full decode, with each level's size as `halve` gives it. + let (mut w, mut h) = crate::image_size(bytes)?; + let ppt = w as f64 / width_pt.max(1e-6); + let mut sizes = vec![(w, h)]; + while ppt / (1u64 << sizes.len()) as f64 >= on_screen * 1.2 && w > 64 && h > 64 && sizes.len() <= 8 { + (w, h) = ((w / 2).max(1), (h / 2).max(1)); + sizes.push((w, h)); + } + let level = u8::try_from(sizes.len() - 1).ok()?; + if level == 0 { + return None; + } + if let Some(pm) = lookup((id, page, level)) { + return Some(pm); + } + let shrink = level.min(designcraft_images::MAX_REDUCED_SHRINK); + let mut pm = match lookup((id, page, shrink)) { + Some(p) => p, + None => { + // Cut to `halve`'s size (it rounds down, the decoder up), as the level a full + // decode makes. + let (sw, sh) = sizes[usize::from(shrink)]; + let p = Arc::new(crop(decode_pixmap_reduced(bytes, shrink)?, sw, sh)?); + store(bytes, page, shrink, p.clone()); + p + } + }; + for l in shrink + 1..=level { + pm = match lookup((id, page, l)) { + Some(p) => p, + None => { + let half = Arc::new(halve(&pm)); + store(bytes, page, l, half.clone()); + half + } + }; + } + Some(pm) +} + +/// The top-left `w` × `h` of `pm`; `None` when `pm` is smaller. +fn crop(pm: Pixmap, w: u32, h: u32) -> Option { + let (pw, ph) = (u32::from(pm.width()), u32::from(pm.height())); + if (pw, ph) == (w, h) { + return Some(pm); + } + if w > pw || h > ph || w == 0 || h == 0 { + return None; + } + let (pw, w, h) = (pw as usize, w as usize, h as usize); + let data: Vec<_> = pm.data().chunks_exact(pw).take(h).flat_map(|row| &row[..w]).copied().collect(); + Some(Pixmap::from_parts(data, u16::try_from(w).ok()?, u16::try_from(h).ok()?)) +} + /// Drop every decoded image. pub fn clear() { with(|c| { @@ -176,4 +247,58 @@ mod tests { let last = &buffers[MAX_BAD + 9]; assert!(c.bad.contains_key(&(Arc::as_ptr(last) as usize, 0))); } + + fn jpeg(w: u32, h: u32) -> Arc> { + let img = image::RgbImage::from_fn(w, h, |x, y| image::Rgb([(x * 255 / w) as u8, (y * 255 / h) as u8, 128])); + let mut out = Vec::new(); + img.write_with_encoder(image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 90)).unwrap(); + Arc::new(out) + } + + #[test] + fn screen_levels_of_a_jpeg_need_no_full_size_decode() { + let bytes = jpeg(1024, 768); + let id = Arc::as_ptr(&bytes) as usize; + // 1 px per point drawn at 0.1: level 3, an eighth, read straight from the blocks. + let pm = mip(&bytes, 0, 1024.0, 0.1).unwrap(); + assert_eq!((pm.width(), pm.height()), (128, 96)); + assert!(lookup((id, 0, 0)).is_none(), "no full-size decode is kept"); + // Close to the full decode halved three times. + let mut want = decode_pixmap_page(&bytes, 0).unwrap(); + for _ in 0..3 { + want = halve(&want); + } + let diff: u64 = pm.data().iter().zip(want.data()).map(|(p, q)| u64::from(p.r.abs_diff(q.r)) + u64::from(p.g.abs_diff(q.g))).sum(); + assert!(diff / (2 * 128 * 96) <= 3, "mean difference {}", diff / (2 * 128 * 96)); + // Smaller levels are halved from that eighth, down to 64 px as before. + let small = mip(&bytes, 0, 1024.0, 0.02).unwrap(); + assert_eq!((small.width(), small.height()), (64, 48)); + assert!(lookup((id, 0, 0)).is_none(), "no full-size decode is kept"); + // The full size is still a full decode. + let full = mip(&bytes, 0, 1024.0, 1.0).unwrap(); + assert_eq!((full.width(), full.height()), (1024, 768)); + } + + #[test] + fn reduced_levels_of_odd_sized_jpegs_have_the_halved_size() { + let bytes = jpeg(1001, 771); + let mut want = decode_pixmap_page(&bytes, 0).unwrap(); + for _ in 0..3 { + want = halve(&want); + } + let pm = mip(&bytes, 0, 1001.0, 0.1).unwrap(); + assert!(lookup((Arc::as_ptr(&bytes) as usize, 0, 0)).is_none()); + assert_eq!((pm.width(), pm.height()), (want.width(), want.height())); + assert_eq!((pm.width(), pm.height()), (125, 96)); + } + + #[test] + fn a_jpeg_that_does_not_decode_is_remembered_as_failed() { + let mut broken = jpeg(256, 256).to_vec(); + broken.truncate(40); + let bytes = Arc::new(broken); + assert!(mip(&bytes, 0, 256.0, 0.1).is_none()); + assert!(with(|c| c.bad.contains_key(&(Arc::as_ptr(&bytes) as usize, 0)))); + assert!(mip(&bytes, 0, 256.0, 0.1).is_none()); + } } From 855d017d5b56b61bdf5a46c974a280f0eace7398 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 10:34:02 -0800 Subject: [PATCH 13/18] Images: limit reduced JPEG decodes by the full image size A reduced decode now applies the full decode's size limits before it reads any image data: - A file whose size pixel_size cannot read is refused, so the 256 Mpx check always has a size to compare. The JPEG decoder's own reading of the frame size is checked against the same limit. - A progressive JPEG keeps the coefficients of the full-size image until its last scan, whatever the output size. When those take more than 512 MiB (each component counted at full size, each side rounded up to 32 pixels), the reduced decode is refused and the full decode, with its own limits, applies. --- crates/images/src/jpeg.rs | 21 ++++++++++++-- crates/images/src/lib.rs | 12 ++++++-- crates/images/src/tests.rs | 58 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 4 deletions(-) diff --git a/crates/images/src/jpeg.rs b/crates/images/src/jpeg.rs index fea0a1e64..6188a53fc 100644 --- a/crates/images/src/jpeg.rs +++ b/crates/images/src/jpeg.rs @@ -67,21 +67,38 @@ pub(crate) const MAX_SHRINK: u8 = 3; /// A YCbCr, RGB or grayscale JPEG decoded at 1/2^`shrink` of its size, each side rounded up, for /// `shrink` 1 to [`MAX_SHRINK`]: straight RGBA and whether the image has colour. `None` for CMYK, -/// 12-bit and lossless JPEGs, other formats and anything that doesn't decode. +/// 12-bit and lossless JPEGs, other formats and anything that doesn't decode. `None` as well for a +/// JPEG whose full size is over the full decode's limit, and for a progressive JPEG whose +/// full-size coefficients take more than [`MAX_BYTES`]; the full decode applies its own limits to +/// those. pub(crate) fn decode_reduced(bytes: &[u8], shrink: u8) -> Option<(image::RgbaImage, bool)> { - use jpeg_decoder::PixelFormat; + use jpeg_decoder::{CodingProcess, PixelFormat}; if !bytes.starts_with(&[0xFF, 0xD8]) || shrink == 0 || shrink > MAX_SHRINK { return None; } let mut d = jpeg_decoder::Decoder::new(bytes); + // This limits the output only, which is the reduced size. d.set_max_decoding_buffer_size(MAX_BYTES); d.read_info().ok()?; let info = d.info()?; + if crate::over_rgba_budget(u32::from(info.width), u32::from(info.height)) { + return None; + } let colour = match info.pixel_format { PixelFormat::RGB24 => true, PixelFormat::L8 => false, PixelFormat::L16 | PixelFormat::CMYK32 => return None, }; + if info.coding_process == CodingProcess::DctProgressive { + // A progressive JPEG holds every coefficient of the full-size image (two bytes each) until + // its last scan, whatever the output size. Each component is counted at the full size and + // each side is rounded up to the largest MCU (32 pixels): an upper bound. + let side = |n: u16| u64::from(n).next_multiple_of(32); + let components: u64 = if colour { 3 } else { 1 }; + if side(info.width) * side(info.height) * components * 2 > MAX_BYTES as u64 { + return None; + } + } let reduced = |n: u16| u16::try_from(u32::from(n).div_ceil(1 << shrink)).ok(); let (w, h) = d.scale(reduced(info.width)?, reduced(info.height)?).ok()?; let px = d.decode().ok()?; diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs index 530ca8097..5754e8788 100644 --- a/crates/images/src/lib.rs +++ b/crates/images/src/lib.rs @@ -407,7 +407,12 @@ const MAX_RGBA_BYTES: u64 = 1 << 30; /// Whether a raster's header gives it more pixels than [`decode_rgba`] decodes. pub fn too_large_to_decode(bytes: &[u8]) -> bool { // `image` limits only its own output, which can be a quarter of the RGBA copy or less. - pixel_size(bytes).is_some_and(|(w, h)| u64::from(w).saturating_mul(u64::from(h)).saturating_mul(4) > MAX_RGBA_BYTES) + pixel_size(bytes).is_some_and(|(w, h)| over_rgba_budget(w, h)) +} + +/// Whether a `w` × `h` raster takes more than [`MAX_RGBA_BYTES`] as RGBA. +fn over_rgba_budget(w: u32, h: u32) -> bool { + u64::from(w).saturating_mul(u64::from(h)).saturating_mul(4) > MAX_RGBA_BYTES } /// Straight (non-premultiplied) RGBA8 of a raster in display sRGB: an embedded ICC profile is @@ -448,9 +453,12 @@ pub const MAX_REDUCED_SHRINK: u8 = jpeg::MAX_SHRINK; /// [`decode_rgba`] of a JPEG at 1/2^`shrink` of its size (`shrink` 1 to [`MAX_REDUCED_SHRINK`], each /// side rounded up), read from its compressed blocks without a full-size decode. The embedded ICC /// profile is applied as [`decode_rgba`] applies it. `None` where only a full decode applies: other -/// formats, CMYK, 12-bit and lossless JPEGs, and files [`decode_rgba`] refuses as too large. +/// formats, CMYK, 12-bit and lossless JPEGs, files whose size [`pixel_size`] cannot read, and files +/// [`decode_rgba`] refuses as too large. pub fn decode_rgba_reduced(bytes: &[u8], shrink: u8) -> Option { use colour::{DisplayColour, Pixels, to_display_rgba}; + // The size check must have a size to check: a header only the JPEG decoder reads is refused. + pixel_size(bytes)?; if too_large_to_decode(bytes) { return None; } diff --git a/crates/images/src/tests.rs b/crates/images/src/tests.rs index ae5435a62..184e3c9c9 100644 --- a/crates/images/src/tests.rs +++ b/crates/images/src/tests.rs @@ -611,3 +611,61 @@ fn reduced_decoding_is_for_jpegs_and_survives_damaged_ones() { } } } + +#[test] +fn reduced_decoding_refuses_a_jpeg_whose_size_the_size_check_cannot_read() { + let jpeg = gradient_jpeg(64, 48, false); + assert_eq!(decode_rgba_reduced(&jpeg, 1).unwrap().dimensions(), (32, 24)); + // A stray byte after the start marker: the JPEG decoder skips it, but `pixel_size` doesn't + // recognize the file, so `too_large_to_decode` has no size to check. + let mut stray = jpeg.clone(); + stray.insert(2, 0); + assert_eq!(pixel_size(&stray), None); + assert!(decode_rgba_reduced(&stray, 1).is_none()); + assert!(decode_rgba(&stray).is_none()); +} + +/// A progressive JPEG with only its DC scan, `w` × `h` with `components` 1×1-sampled components: +/// every coefficient is zero (a flat mid-gray image). +fn progressive_jpeg(w: u16, h: u16, components: u8) -> Vec { + let mut out = vec![0xFF, 0xD8]; + // One quantization table of ones. + out.extend([0xFF, 0xDB, 0, 67, 0]); + out.extend([1; 64]); + // Start of frame, progressive DCT. + out.extend([0xFF, 0xC2, 0, 8 + 3 * components, 8]); + out.extend(h.to_be_bytes()); + out.extend(w.to_be_bytes()); + out.push(components); + for id in 1..=components { + out.extend([id, 0x11, 0]); + } + // A DC table with one code, "0", for a difference of 0. + out.extend([0xFF, 0xC4, 0, 20, 0x00, 1]); + out.extend([0; 15]); + out.push(0); + // The DC scan of every component (Ss, Se, Ah/Al all 0), then zero bits. + out.extend([0xFF, 0xDA, 0, 6 + 2 * components, components]); + for id in 1..=components { + out.extend([id, 0x00]); + } + out.extend([0, 0, 0]); + out.extend([0; 16]); + out.extend([0xFF, 0xD9]); + out +} + +#[test] +fn reduced_decoding_refuses_progressive_jpegs_whose_full_size_coefficients_are_over_the_limit() { + let small = decode_rgba_reduced(&progressive_jpeg(64, 48, 3), 3).unwrap(); + assert_eq!(small.dimensions(), (8, 6)); + // 10,240 × 8,800 is within the RGBA limit, but its three components' coefficients take + // 10,240 × 8,800 × 3 × 2 bytes, over 512 MiB, whatever the output size. + let large = progressive_jpeg(10_240, 8_800, 3); + assert_eq!(pixel_size(&large), Some((10_240, 8_800))); + assert!(!too_large_to_decode(&large)); + assert!(decode_rgba_reduced(&large, 3).is_none()); + // One component of the same size stays under the limit. + let gray = decode_rgba_reduced(&progressive_jpeg(10_240, 8_800, 1), 3).unwrap(); + assert_eq!(gray.dimensions(), (1_280, 1_100)); +} From b30d39fdec19ade8a1aabcf47237f8c995e3869a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 12:48:41 -0800 Subject: [PATCH 14/18] Layers: Duplicate Layer maps copies by the original's anchors and tables object::renumber now gives a story copy's anchors and tables new ids. Duplicate Layer keys each copied anchor by the original anchor's id, so copied cross-references point at the copied paragraph, and pairs copied tables with the originals in order, so objects anchored in table cells keep their live captions. A copy that renumber reports incomplete is refused with the nested-too-deeply error. --- crates/engine/src/cmd/layout/layers.rs | 53 +++++++++++++++++++++----- 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/layout/layers.rs b/crates/engine/src/cmd/layout/layers.rs index cd6e72ee2..6601e11e0 100644 --- a/crates/engine/src/cmd/layout/layers.rs +++ b/crates/engine/src/cmd/layout/layers.rs @@ -86,7 +86,9 @@ pub(super) fn duplicate_layer(s: &mut Session, p: &Value) -> Result { let Some(sp) = src.spread(r) else { continue }; for it in sp.items.iter().filter(|it| it.layer == id) { let mut copy = Item::clone(it); - super::super::object::renumber(d, &src, &mut copy, &mut stories); + if !super::super::object::renumber(d, &src, &mut copy, &mut stories) { + return Err(bad(ID, "the layer's objects are nested too deeply to copy")); + } map_ids(it, ©, &mut ids); copies.push((r, copy)); } @@ -113,8 +115,10 @@ pub(super) fn duplicate_layer(s: &mut Session, p: &Value) -> Result { // and those of the other copies point at, and tables with ids of their own, the tables in its cells and notes // included, allocated in story order. let mut anchors = HashMap::new(); + // The copies' own anchors are given ids below, so only the other stories' anchors count as taken. + let copied: HashSet = stories.values().copied().collect(); let mut used = HashSet::new(); - for st in d.stories.values() { + for st in d.stories.values().filter(|st| !copied.contains(&st.id)) { anchor_ids(st, &mut used); } let mut order: Vec<(StoryId, StoryId)> = stories.iter().map(|(old, new)| (*old, *new)).collect(); @@ -125,7 +129,10 @@ pub(super) fn duplicate_layer(s: &mut Session, p: &Value) -> Result { st.link = Some((stories.get(&parent).copied().unwrap_or(parent), revision)); } st.frames = src.story(*old).map(|o| o.frames.iter().filter_map(|f| ids.get(f).copied()).collect()).unwrap_or_default(); - for (k, a) in (1..).zip(st.anchors.iter_mut()) { + // The copy's anchors may already have new ids: each is keyed by the id of the original's anchor, which the + // copied cross-references still point at. + let originals: Vec = src.story(*old).map(|o| o.anchors.iter().map(|a| a.id).collect()).unwrap_or_default(); + for ((k, a), orig_id) in (1..).zip(st.anchors.iter_mut()).zip(originals) { // `story id << 24 | n`, as anchors are made, or a new id when another anchor has that one or the story id // does not fit. let made = new.0.checked_mul(1 << 24).and_then(|base| base.checked_add(k)).filter(|id| !used.contains(id)); @@ -138,7 +145,7 @@ pub(super) fn duplicate_layer(s: &mut Session, p: &Value) -> Result { } }, }; - anchors.insert(a.id, id); + anchors.insert(orig_id, id); used.insert(id); Arc::make_mut(a).id = id; } @@ -250,11 +257,10 @@ fn map_anchored(orig: &Story, copy: &Story, ids: &mut HashMap) { for (o, c) in orig.objects.iter().zip(©.objects) { map_ids(&o.item, &c.item, ids); } - for (k, t) in &orig.tables { - if let Some(ct) = copy.tables.get(k) { - for (o, c) in t.cells.iter().zip(&ct.cells) { - map_anchored(&o.text, &c.text, ids); - } + // 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, ct) in orig.tables.values().zip(copy.tables.values()) { + for (o, c) in t.cells.iter().zip(&ct.cells) { + map_anchored(&o.text, &c.text, ids); } } for (o, c) in orig.notes.iter().chain(&orig.endnotes).zip(copy.notes.iter().chain(©.endnotes)) { @@ -1129,6 +1135,35 @@ mod layer_tests { assert_eq!(s.doc().unwrap().doc.layers, before, "nothing changes"); } + /// An empty group inside 64 others: within the layer's depth check, which counts the objects below a group, but + /// past the limit of the copy, which counts the group itself. + #[test] + fn duplicate_layer_refuses_an_empty_group_at_the_copy_limit() { + use designcraft_doc::{Content, ItemId}; + use std::sync::Arc; + + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let base = s.doc().unwrap().doc.layers[0].id.0; + let f = s.execute("frame.create", &json!({"rect": [0, 0, 10, 10]})).unwrap()["id"].as_u64().unwrap(); + let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let mut it = d.item(ItemId(f)).cloned().unwrap(); + it.content = Content::Group { items: Vec::new() }; + for _ in 0..super::MAX_COPY_DEPTH { + let mut group = it.clone(); + group.id = ItemId(d.alloc()); + group.content = Content::Group { items: vec![Arc::new(it)] }; + it = group; + } + let items = &mut Arc::make_mut(&mut d.spreads[0]).items; + items.retain(|i| i.id.0 != f); + items.push(Arc::new(it)); + let before = s.doc().unwrap().doc.clone(); + let e = s.execute("layer.duplicate", &json!({"id": base})).unwrap_err().to_string(); + assert!(e.contains("too deeply to copy"), "{e}"); + assert_eq!(s.doc().unwrap().doc, before, "nothing changes"); + } + #[test] fn duplicate_layer_detaches_overrides_of_parent_items_on_other_layers() { let mut s = crate::Session::new(); From 55963c491872cec854735278c7d80b60da2bc6cc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 12:57:16 -0800 Subject: [PATCH 15/18] Images: pick a reduced JPEG's scale by its long side The JPEG decoder takes the smallest scale that meets either requested side. A short side rounds up to the same size at several scales, so a 100 x 2 JPEG asked for at a half decoded at an eighth (13 x 1). The long side alone now picks the scale, and a decode whose size differs from the requested reduction is refused, which falls back to the full decode. --- crates/images/src/jpeg.rs | 9 ++++++++- crates/images/src/tests.rs | 13 +++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/images/src/jpeg.rs b/crates/images/src/jpeg.rs index 6188a53fc..a4a2a0971 100644 --- a/crates/images/src/jpeg.rs +++ b/crates/images/src/jpeg.rs @@ -100,7 +100,14 @@ pub(crate) fn decode_reduced(bytes: &[u8], shrink: u8) -> Option<(image::RgbaIma } } let reduced = |n: u16| u16::try_from(u32::from(n).div_ceil(1 << shrink)).ok(); - let (w, h) = d.scale(reduced(info.width)?, reduced(info.height)?).ok()?; + let expected = (reduced(info.width)?, reduced(info.height)?); + // The decoder takes the smallest scale that meets either requested side, and a short side + // rounds up to the same size at several scales, so only the long side is requested. + let request = if info.width >= info.height { (expected.0, u16::MAX) } else { (u16::MAX, expected.1) }; + let (w, h) = d.scale(request.0, request.1).ok()?; + if (w, h) != expected { + return None; + } let px = d.decode().ok()?; let pixels = usize::from(w).checked_mul(usize::from(h))?; let rgba: Vec = if colour { diff --git a/crates/images/src/tests.rs b/crates/images/src/tests.rs index 184e3c9c9..3edfa89b9 100644 --- a/crates/images/src/tests.rs +++ b/crates/images/src/tests.rs @@ -596,6 +596,19 @@ fn reduced_jpegs_decode_at_a_half_quarter_or_eighth_rounded_up() { assert!(decode_rgba_reduced(&jpeg, 4).is_none()); } +#[test] +fn a_thin_reduced_jpeg_keeps_its_long_side_at_the_requested_reduction() { + // The short side rounds up to 1 at every reduction, so it cannot pick the scale. + for (w, h) in [(100, 2), (2, 100)] { + let jpeg = gradient_jpeg(w, h, false); + for (shrink, long) in [(1, 50), (2, 25), (3, 13)] { + let img = decode_rgba_reduced(&jpeg, shrink).unwrap(); + let size = if w > h { (long, 1) } else { (1, long) }; + assert_eq!(img.dimensions(), size, "{w} × {h}, shrink {shrink}"); + } + } +} + #[test] fn reduced_decoding_is_for_jpegs_and_survives_damaged_ones() { let gray = decode_rgba_reduced(&gradient_jpeg(64, 64, true), 2).unwrap(); From 07d264fd238013bae6ac85e2f976f96a294a884f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 12:57:17 -0800 Subject: [PATCH 16/18] Copy: bound the anchored-object walk through cells and notes Copying a story gives the objects anchored in its table cells and notes fresh ids down to 64 levels, as the table copy does. A copy whose stories below that level hold anchored objects, tables or notes is refused with the nested-too-deeply error instead of recursing without limit. --- crates/engine/src/cmd/object.rs | 70 +++++++++++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 8b0e1cd51..b7a6d780b 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -1281,21 +1281,41 @@ fn retarget_xrefs(copy: &mut Story, orig: &Story, map: &HashMap, depth } } -/// Fresh ids for the objects anchored in story copy `st`, its table cells, footnotes and endnotes, at every depth -/// (#1522), each `depth` levels deep. False when one of them nests groups deeper than [`MAX_COPY_DEPTH`]. +/// Fresh ids for the objects anchored in story copy `st`, its table cells, footnotes and endnotes down to +/// [`MAX_COPY_DEPTH`] levels (#1522), each `depth` levels deep. False when one of them nests groups deeper than +/// [`MAX_COPY_DEPTH`], or when the stories below that level hold anchored objects, tables or notes. #[must_use = "false means the copy is incomplete and must not be inserted"] fn renumber_anchored(dst: &mut Document, src: &Document, st: &mut Story, stories: &mut HashMap, depth: usize) -> bool { + renumber_anchored_in(dst, src, st, stories, depth, 0) +} + +/// [`renumber_anchored`] for a story `level` table cells and notes below the story copy. +#[must_use = "false means the copy is incomplete and must not be inserted"] +fn renumber_anchored_in( + dst: &mut Document, + src: &Document, + st: &mut Story, + stories: &mut HashMap, + depth: usize, + level: usize, +) -> bool { let mut complete = true; for o in &mut st.objects { complete &= renumber_in(dst, src, &mut Arc::make_mut(o).item, stories, depth, false); } + if level >= MAX_COPY_DEPTH { + let leaf = |s: &Story| s.objects.is_empty() && s.tables.is_empty() && s.notes.is_empty() && s.endnotes.is_empty(); + return complete + && st.tables.values().all(|t| t.cells.iter().all(|c| leaf(&c.text))) + && st.notes.iter().chain(&st.endnotes).all(|n| leaf(&n.text)); + } for t in st.tables.values_mut() { for c in &mut Arc::make_mut(t).cells { - complete &= renumber_anchored(dst, src, &mut c.text, stories, depth); + complete &= renumber_anchored_in(dst, src, &mut c.text, stories, depth, level + 1); } } for n in st.notes.iter_mut().chain(st.endnotes.iter_mut()) { - complete &= renumber_anchored(dst, src, &mut Arc::make_mut(n).text, stories, depth); + complete &= renumber_anchored_in(dst, src, &mut Arc::make_mut(n).text, stories, depth, level + 1); } complete } @@ -5744,6 +5764,48 @@ mod copy_ids_tests { assert_eq!(s.doc().unwrap().doc, before, "nothing is pasted"); } + #[test] + fn objects_anchored_in_cells_nested_too_deeply_are_not_copied() { + use std::sync::Arc; + + // The first cell of the story's table holds `n` tables, each in the first cell of the one before, and the cell + // of the innermost one holds an anchored object. + fn nested(n: usize) -> crate::Session { + let mut s = crate::Session::new(); + let (f, sid) = marked_frame(&mut s); + let leaf = s.execute("frame.create", &json!({"rect": [0, 0, 10, 10]})).unwrap()["id"].as_u64().unwrap(); + let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let item = d.item(designcraft_doc::ItemId(leaf)).cloned().unwrap(); + Arc::make_mut(&mut d.spreads[0]).items.retain(|i| i.id.0 != leaf); + let mut chain: Option = None; + for _ in 0..n { + let mut t = designcraft_doc::Table::new(d.alloc(), 1, 1, 0, 0, 40.0); + match chain.take() { + Some(inner) => { + t.cells[0].text.insert_table(0, inner); + } + None => { + t.cells[0].text.insert_object(0, designcraft_doc::AnchoredObject::new(item.clone(), Default::default())); + } + } + chain = Some(t); + } + let st = d.story_mut(sid).unwrap(); + let text = &mut Arc::make_mut(st.tables.values_mut().next().unwrap()).cells[0].text; + text.insert_table(text.len(), chain.unwrap()); + s.execute("selection.set", &json!({"ids": [f]})).unwrap(); + s + } + let mut s = nested(super::MAX_COPY_DEPTH - 1); + s.execute("edit.duplicate", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.anchored_items().len(), 2, "the anchored object is copied"); + let mut s = nested(super::MAX_COPY_DEPTH); + let before = s.doc().unwrap().doc.clone(); + let e = s.execute("edit.duplicate", &json!({})).unwrap_err().to_string(); + assert!(e.contains("too deeply to copy"), "{e}"); + assert_eq!(s.doc().unwrap().doc, before, "nothing is copied"); + } + #[test] fn pasted_cross_references_follow_the_pasted_anchors_of_other_stories() { let mut s = crate::Session::new(); From 5931b17bb6e78c2fd8e47e7569a6366ee35a2663 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 12 Nov 2026 16:22:26 -0500 Subject: [PATCH 17/18] Translate duplicate layer in remaining locales --- crates/ui-egui/src/i18n/fr.rs | 1 + crates/ui-egui/src/i18n/hu.rs | 1 + crates/ui-egui/src/i18n/sv.rs | 1 + 3 files changed, 3 insertions(+) diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 685591a96..91ad54dd1 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -212,6 +212,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("New Hyperlink", "Nouvel hyperlien"), ("New Hyperlink Destination", "Nouvelle destination d'hyperlien"), ("New Layer", "Nouveau calque"), + ("Duplicate Layer \"{name}\"", "Dupliquer le calque « {name} »"), ("New Page Reference", "Nouvelle référence de page"), ("New Parent", "Nouveau gabarit"), ("Export Layouts and Versions…", "Exporter les mises en page et versions…"), diff --git a/crates/ui-egui/src/i18n/hu.rs b/crates/ui-egui/src/i18n/hu.rs index fd9057213..f0e1e28c0 100644 --- a/crates/ui-egui/src/i18n/hu.rs +++ b/crates/ui-egui/src/i18n/hu.rs @@ -865,6 +865,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Last Page", "Utolsó oldal"), ("Last Spread", "Utolsó oldalpár"), ("Layer Options…", "Réteg beállításai…"), + ("Duplicate Layer \"{name}\"", "„{name}” réteg duplikálása"), ("Layers", "Rétegek"), ("Layout", "Elrendezés"), ("Layout/Index", "Elrendezés/Tárgymutató"), diff --git a/crates/ui-egui/src/i18n/sv.rs b/crates/ui-egui/src/i18n/sv.rs index bc826031d..f835146cc 100644 --- a/crates/ui-egui/src/i18n/sv.rs +++ b/crates/ui-egui/src/i18n/sv.rs @@ -2177,6 +2177,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Color Group Options…", "Alternativ för färggrupp…"), ("Layer Options", "Lageralternativ"), ("Layer Options for \"{name}\"…", "Lageralternativ för \"{name}\"…"), + ("Duplicate Layer \"{name}\"", "Duplicera lagret ”{name}”"), ("Swatch Name:", "Färgrutenamn:"), ("Name with Color Value", "Namn med färgvärde"), ("Color Type:", "Färgtyp:"), From 6bb9907de5df77d3532dfbfeb639084bf75f7aef Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 12 Nov 2026 16:54:46 -0500 Subject: [PATCH 18/18] Use shared French duplicate layer translation --- crates/ui-egui/src/i18n/fr.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index bbe23824f..8a7c537ce 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -212,7 +212,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("New Hyperlink", "Nouvel hyperlien"), ("New Hyperlink Destination", "Nouvelle destination d'hyperlien"), ("New Layer", "Nouveau calque"), - ("Duplicate Layer \"{name}\"", "Dupliquer le calque « {name} »"), ("New Page Reference", "Nouvelle référence de page"), ("New Parent", "Nouveau gabarit"), ("Export Layouts and Versions…", "Exporter les mises en page et versions…"),