diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index b8d456d27..cd79a3efa 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -139,7 +139,7 @@ pub fn specs() -> Vec { } /// A document holding copies of the selected items (and their stories) on spread 0. -fn clip_doc(s: &Session) -> Result { +pub(crate) fn clip_doc(s: &Session) -> Result { let st = s.doc()?; let mut d = (*st.doc).clone(); let keep: Vec = st.selection.items.clone(); @@ -153,11 +153,33 @@ fn clip_doc(s: &Session) -> Result { for sp in d.spreads.iter_mut() { Arc::make_mut(sp).items.clear(); } + // Kept frames and graphics (including inside groups). + let mut frames = std::collections::HashSet::new(); + let mut assets = std::collections::HashSet::new(); + for it in &items { + it.walk(&mut |i| match &i.content { + designcraft_doc::Content::Text(_) => { + frames.insert(i.id); + } + designcraft_doc::Content::Graphic(g) => { + assets.insert(g.asset); + } + _ => {} + }); + } if let Some(sp) = d.spreads.first_mut() { Arc::make_mut(sp).items = items; } + // Parent spreads aren't part of a clipping; stories keep only the copied frames. + for p in d.parents.iter_mut() { + Arc::make_mut(p).items.clear(); + } + d.stories.retain(|_, st| st.frames.iter().any(|f| frames.contains(f))); + for st in d.stories.values_mut() { + Arc::make_mut(st).frames.retain(|f| frames.contains(f)); + } + d.assets.retain(|k, _| assets.contains(k)); + d.hyperlinks.clear(); + d.bookmarks.clear(); Ok(d) } - -#[allow(dead_code)] -fn _unused(_: Value) {} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index e15d6ac7e..883797473 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -31,6 +31,17 @@ pub fn specs() -> Vec { s.set_active(p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize); ok() }), + cmd!(query "snippet.export", "Export Selection as Snippet", ["File", "Export"], None, + "{path?} — the selected items (with their stories, styles and images) as a .designcraft snippet; no path: {base64}", super::has_selection, snippet_export), + cmd!( + "snippet.place", + "Place Snippet", + [], + None, + "{path | base64, spread?, x?, y?} — items keep their positions unless x/y given (top-left)", + has_doc, + snippet_place + ), cmd!( "place.load", "Load Place Cursor", @@ -335,3 +346,49 @@ fn place_drop(s: &mut Session, p: &Value) -> Result { s.set_tool("selection"); Ok(r) } + +fn snippet_export(s: &mut Session, p: &Value) -> Result { + let d = super::edit::clip_doc(s)?; + let bytes = to_bytes(&d); + match str_param(p, "path") { + Some(path) => { + #[cfg(not(target_arch = "wasm32"))] + std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + Ok(json!({"path": path, "bytes": bytes.len(), "items": d.spreads.first().map(|s| s.items.len()).unwrap_or(0)})) + } + None => Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})), + } +} + +fn snippet_place(s: &mut Session, p: &Value) -> Result { + let (bytes, _, _) = read_source(p)?; + let src = from_bytes(&bytes)?; + let ids: Vec = src.spreads.first().map(|sp| sp.items.iter().map(|i| i.id).collect()).unwrap_or_default(); + if ids.is_empty() { + return Err(bad("snippet.place", "the snippet is empty")); + } + let sr = super::spread_param(p, "spread"); + let bounds = ids.iter().filter_map(|i| src.item(*i).map(|it| it.bounds())).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO); + let off = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) { + (Some(x), Some(y)) => designcraft_geom::Vec2::new(x - bounds.x0, y - bounds.y0), + _ => designcraft_geom::Vec2::ZERO, + }; + s.edit(|d, sel| { + // Bring over styles and swatches the snippet uses that this document lacks. + for sw in &src.swatches { + if d.swatch(&sw.name).is_none() { + d.swatches.push(sw.clone()); + } + } + let missing_p: Vec<_> = src.styles.paragraph.iter().filter(|ps| d.styles.para(&ps.name).is_none()).cloned().collect(); + let missing_c: Vec<_> = src.styles.character.iter().filter(|cs| d.styles.char_style(&cs.name).is_none()).cloned().collect(); + if !missing_p.is_empty() || !missing_c.is_empty() { + let st = d.styles_mut(); + st.paragraph.extend(missing_p); + st.character.extend(missing_c); + } + let new = super::object::duplicate_from(d, &src, &ids, sr, off)?; + *sel = Selection::items(new.clone()); + Ok(json!({"ids": new.iter().map(|i| i.0).collect::>()})) + }) +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4b7ceaa91..3599e0fb1 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -253,6 +253,24 @@ fn step_and_repeat_grid() { assert_eq!(last.y0, 36.0 + 2.0 * 40.0); } +#[test] +fn snippets_roundtrip_between_documents() { + let mut s = Session::new(); + s.execute("file.newSample", &json!({})).unwrap(); + s.execute("selection.set", &json!({"ids": [39]})).unwrap(); + let snip = s.execute("snippet.export", &json!({})).unwrap(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("snippet.place", &json!({"base64": snip["base64"], "x": 36.0, "y": 36.0})).unwrap(); + let id = designcraft_doc::ItemId(r["ids"][0].as_u64().unwrap()); + let st = s.doc().unwrap(); + let it = st.doc.item(id).unwrap(); + assert_eq!(it.bounds().x0, 36.0); + let sid = it.text_frame().unwrap().story; + assert!(st.doc.story(sid).unwrap().text.starts_with("Every page begins")); + assert!(st.doc.styles.para("Body").is_some()); + st.doc.check().unwrap(); +} + /// Unicode text per page, through hayro's interpreter (ToUnicode / ActualText). fn pdf_text(bytes: &[u8]) -> Vec { use hayro_interpret::font::Glyph;