collective

Browse / github.com/storytold/deckcraft / patch

Paste: slides copied in one DeckCraft window paste in another

Paste: slides copied in one DeckCraft window paste in another ## What this fixes Copy a slide, open another deck from the file manager (which starts a **separate DeckCraft process**), press Ctrl+V: nothing is pasted (or only the slide titles, as text). Copied slides lived only in the copying process's `Session::clipboard`; `publish_clipboard` put only their titles on the system clipboard. Within one process (File › New) pasting already worked; I confirmed that with a harness test before changing anything. ## Change - **Copy** (`crates/engine/src/cmd/edit.rs`): `edit.copy`/`edit.cut` of slides also return HTML: the visible titles, preceded by `<!--dec

Status
open
Score
0.00
Adopters
0 other operators
Author
Naseq (imported from pull request #1372, open)
Base
ee875abffa1a31cb760c843bb29e79391a90a766
Size
+216 −10 in 5 files
Review
pending
Shared
2026-10-28
Id
17b5aaadae41989fbcf537cb

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/cmd/edit.rs crates/engine/src/lib.rs crates/engine/src/tests.rs crates/format/src/lib.rs crates/ui-egui/src/app/clipboard.rs

diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs--- a/crates/engine/src/cmd/edit.rs+++ b/crates/engine/src/cmd/edit.rs@@ -4,7 +4,7 @@ use std::collections::HashMap; use std::sync::Arc;  use deckcraft_model::text::TextBody;-use deckcraft_model::{MediaId, Shape, ShapeId, Slide, SlideId};+use deckcraft_model::{MediaId, MediaItem, Shape, ShapeId, Slide, SlideId}; use serde_json::{Value, json};  use super::{@@ -98,7 +98,7 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {             mids.extend(crate::media_refs::slide_media(sl));         }         let media = st.doc.media.iter().filter(|m| mids.contains(&m.id)).cloned().collect();-        let plain = slides.iter().map(|x| x.title()).collect::<Vec<_>>().join("\n");+        let plain = slide_titles(&slides);         s.clipboard = crate::Clipboard { slides, media, plain: plain.clone(), ..Default::default() };         return Ok(plain);     }@@ -140,8 +140,14 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {     Ok(json!({"text": t, "html": html})) } -/// HTML for what was just copied, when it is text (a selection, or the text on the copied shapes).-fn copied_html(s: &Session) -> String {+/// HTML for what was just copied, when it is text (a selection, or the text on the copied shapes)+/// or slides.+fn copied_html(s: &mut Session) -> String {+    if !s.clipboard.slides.is_empty() {+        let (html, id) = slides_html(s);+        s.clipboard.slides_payload = id;+        return html;+    }     if let Some(body) = &s.clipboard.text {         return richclip::body_to_html(body);     }@@ -263,10 +269,88 @@ fn bring_media(cmd: &str, doc: &mut deckcraft_model::Presentation, media: &[deck     Ok(map) } +/// Copied slides reach another DeckCraft process (a deck opened from the file manager) in the+/// clipboard HTML: a comment holding them as a `.deckcraft` file in base64, before their titles.+const SLIDES_MARK: &str = "<!--deckcraft-slides:";+/// Larger copies paste only in the window they were copied in.+const MAX_SLIDES_B64: usize = 16 << 20;++/// The plain text copied slides leave: their titles, a line each.+fn slide_titles(slides: &[Slide]) -> String {+    slides.iter().map(|x| x.title()).collect::<Vec<_>>().join("\n")+}++/// The clipboard HTML for the copied slides and the identity of the payload it carries:+/// `<!--deckcraft-slides:{id}:{base64}-->` before the visible titles. `None` when the copy is too+/// large to carry (it then pastes only in this window).+fn slides_html(s: &Session) -> (String, Option<u64>) {+    let c = &s.clipboard;+    let visible = richclip::body_to_html(&TextBody::from_text(&c.plain));+    // Media saved as is can't shrink: past the limit, don't serialize it only to drop it.+    let stored: usize = c.media.iter().filter(|m| deckcraft_format::is_compressed(&m.content_type)).map(|m| m.data.len()).sum();+    if stored.div_ceil(3).saturating_mul(4) > MAX_SLIDES_B64 {+        return (visible, None);+    }+    let Ok(st) = s.doc() else { return (visible, None) };+    let mut deck = (*st.doc).clone();+    deck.slides = c.slides.iter().cloned().map(Arc::new).collect();+    deck.media = c.media.clone();+    match deckcraft_format::save(&deck) {+        Ok(bytes) if bytes.len().div_ceil(3) * 4 <= MAX_SLIDES_B64 => {+            let id = {+                use std::hash::{Hash, Hasher};+                let mut h = std::hash::DefaultHasher::new();+                bytes.hash(&mut h);+                h.finish()+            };+            (format!("{SLIDES_MARK}{id:016x}:{}-->{visible}", super::base64_encode(&bytes)), Some(id))+        }+        _ => (visible, None),+    }+}++/// The payload identity and base64 in clipboard HTML from [`slides_html`].+fn slides_payload(html: &str) -> Option<(u64, &str)> {+    let start = html.find(SLIDES_MARK)? + SLIDES_MARK.len();+    let rest = html.get(start..)?;+    let (id, rest) = rest.split_once(':')?;+    let id = u64::from_str_radix(id, 16).ok()?;+    Some((id, rest.get(..rest.find("-->")?)?))+}++/// The slides and their media in a payload. Clipboard bytes are untrusted: the size is capped and+/// the file goes through the checked loader.+fn load_slides(b64: &str) -> Option<(Vec<Slide>, Vec<MediaItem>)> {+    if b64.len() > MAX_SLIDES_B64 {+        return None;+    }+    // A payload missing a part (media without its bytes) is refused rather than pasted half.+    let (deck, damaged) = deckcraft_format::load_checked(&super::base64_decode(b64)?).ok()?;+    if !damaged.is_empty() {+        return None;+    }+    Some((deck.slides.iter().map(|x| (**x).clone()).collect(), deck.media))+}+ fn paste(s: &mut Session, p: &Value) -> Result<Value> {+    // Windows hands clipboard text over with CRLF line ends; ours are LF.+    let text = str_param(p, "text").map(|t| t.replace("\r\n", "\n"));+    let text = text.as_deref();+    // Slides copied in another DeckCraft process arrive in the clipboard HTML; slides this session+    // copied carry its own payload identity and paste from its clipboard as before. A marker left+    // behind by an older copy is not taken: the clipboard text no longer says those slides' titles.+    if let Some((id, b64)) = str_param(p, "html").and_then(slides_payload)+        && s.clipboard.slides_payload != Some(id)+        && let Some((slides, media)) = load_slides(b64)+    {+        let plain = slide_titles(&slides);+        if text.is_none_or(|t| t == plain) {+            s.clipboard = crate::Clipboard { slides, media, plain, slides_payload: Some(id), ..Default::default() };+        }+    }     // External plain text (from the system clipboard) wins when given and differs from ours.     // HTML from Pages or Keynote keeps bold, italic and underline when it says the same words.-    if let Some(t) = str_param(p, "text")+    if let Some(t) = text         && t != s.clipboard.plain     {         if let Some(html) = str_param(p, "html")diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs--- a/crates/engine/src/lib.rs+++ b/crates/engine/src/lib.rs@@ -467,6 +467,9 @@ pub struct Clipboard {     pub source_theme: Option<(deckcraft_color::ColorScheme, deckcraft_model::theme::FontScheme)>,     /// How many times the shapes were pasted (each paste offsets further).     pub pastes: u32,+    /// Identity of the copied slides' payload on the system clipboard, so a paste can tell this+    /// copy from another process's.+    pub slides_payload: Option<u64>, }  pub struct Session {diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs--- a/crates/engine/src/tests.rs+++ b/crates/engine/src/tests.rs@@ -2185,6 +2185,123 @@ mod copy_remap {         assert_eq!(table_pictures(&b, &shape(&b, 0, tb)), deck_pictures("b"));     } +    /// Slides copied in one DeckCraft process paste in another (a deck opened from the file+    /// manager starts its own process): only the system clipboard's text and HTML get there.+    #[test]+    fn slides_copied_in_another_process_paste_from_the_clipboard_html() {+        let mut a = Session::with_new();+        picture_table(&mut a, "a");+        let copied = a.execute("edit.copy", &json!({"scope": "slides"})).unwrap();+        // An untitled slide: the UI hands over empty text with the HTML.+        let (text, html) = (copied["text"].as_str().unwrap(), copied["html"].as_str().unwrap());+        let mut b = Session::with_new();+        b.execute("edit.paste", &json!({"text": text, "html": html})).unwrap();+        let d = b.doc().unwrap();+        assert_eq!(d.doc.slides.len(), 2, "the slide is pasted, not its title as text");+        let pasted = &d.doc.slides[1];+        let table = pasted.shapes.iter().find(|x| matches!(x.kind, ShapeKind::Table(_))).cloned().unwrap();+        assert_eq!(table_pictures(&b, &table), deck_pictures("a"));+        // Damaged or foreign HTML pastes as text, as before.+        let mut c = Session::with_new();+        c.execute("edit.paste", &json!({"text": "Hello", "html": "<!--deckcraft-slides:!!!-->Hello"})).unwrap();+        assert_eq!(c.doc().unwrap().doc.slides.len(), 1);+    }++    /// Another process's slides with the same (empty) title text still paste: the payload's+    /// identity, not the plain text, says whose copy it is.+    #[test]+    fn slides_from_another_process_win_over_a_local_copy_with_the_same_text() {+        let mut a = Session::with_new();+        picture_table(&mut a, "a");+        let copied = a.execute("edit.copy", &json!({"scope": "slides"})).unwrap();+        let mut b = Session::with_new();+        b.execute("edit.copy", &json!({"scope": "slides"})).unwrap();+        assert_eq!(b.clipboard.plain, copied["text"].as_str().unwrap(), "both untitled");+        b.execute("edit.paste", &json!({"text": copied["text"], "html": copied["html"]})).unwrap();+        let d = b.doc().unwrap();+        assert!(d.doc.slides[1].shapes.iter().any(|x| matches!(x.kind, ShapeKind::Table(_))), "a's slide, not b's own");+    }++    /// The session's own copy pastes from its clipboard; a slide marker left on the clipboard by+    /// an older copy, under other text, is not pasted.+    #[test]+    fn own_and_stale_slide_payloads() {+        let mut a = Session::with_new();+        a.execute("slide.new", &json!({"title": "Hello"})).unwrap();+        let copied = a.execute("edit.copy", &json!({"scope": "slides"})).unwrap();+        assert!(a.clipboard.slides_payload.is_some());+        a.execute("edit.paste", &json!({"text": copied["text"], "html": copied["html"]})).unwrap();+        assert_eq!(a.doc().unwrap().doc.slides.len(), 3);+        let mut b = Session::with_new();+        b.execute("edit.paste", &json!({"text": "Other words", "html": copied["html"]})).unwrap();+        assert_eq!(b.doc().unwrap().doc.slides.len(), 1, "the newer text is pasted, not the old slides");+        // Two titles with a CRLF line end (Windows) still paste as slides, here and in another deck.+        a.execute("slide.new", &json!({"title": "World"})).unwrap();+        let ids: Vec<_> = a.doc().unwrap().doc.slides.iter().rev().take(2).map(|x| x.id).collect();+        a.doc_mut().unwrap().selection.slides = ids;+        let two = a.execute("edit.copy", &json!({"scope": "slides"})).unwrap();+        let crlf = two["text"].as_str().unwrap().replace('\n', "\r\n");+        assert!(crlf.contains("\r\n"));+        let mut c = Session::with_new();+        c.execute("edit.paste", &json!({"text": crlf, "html": two["html"]})).unwrap();+        assert_eq!(c.doc().unwrap().doc.slides.len(), 3, "both slides, not their titles as text");+        let before = a.doc().unwrap().doc.slides.len();+        a.execute("edit.paste", &json!({"text": crlf, "html": two["html"]})).unwrap();+        assert_eq!(a.doc().unwrap().doc.slides.len(), before + 2);+    }++    /// A payload that loads with a part missing (a picture's bytes) is refused, not pasted with+    /// an empty picture: the paste falls back to the text.+    #[test]+    fn a_damaged_slide_payload_pastes_as_text() {+        use std::io::{Read, Write};+        let mut a = Session::with_new();+        picture_table(&mut a, "a");+        a.execute("slide.new", &json!({"title": "Hello"})).unwrap();+        let ids = vec![a.doc().unwrap().doc.slides[0].id];+        a.doc_mut().unwrap().selection.slides = ids;+        let html = a.execute("edit.copy", &json!({"scope": "slides"})).unwrap()["html"].as_str().unwrap().to_string();+        let start = html.find(':').unwrap() + 1;+        let (id, rest) = html[start..].split_once(':').unwrap();+        let b64 = &rest[..rest.find("-->").unwrap()];+        let bytes = crate::cmd::base64_decode(b64).unwrap();+        // The same file without its media members.+        let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();+        let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));+        for i in 0..zip.len() {+            let mut f = zip.by_index(i).unwrap();+            if f.name().starts_with("media/") {+                continue;+            }+            let name = f.name().to_string();+            let mut data = vec![];+            f.read_to_end(&mut data).unwrap();+            out.start_file(&name, zip::write::SimpleFileOptions::default()).unwrap();+            out.write_all(&data).unwrap();+        }+        let damaged = crate::cmd::base64_encode(&out.finish().unwrap().into_inner());+        let html = format!("<!--deckcraft-slides:{id}:{damaged}-->");+        let mut b = Session::with_new();+        b.execute("edit.paste", &json!({"text": "", "html": html})).unwrap();+        assert_eq!(b.doc().unwrap().doc.slides.len(), 1, "no slide with missing pictures");+    }++    /// Media saved as is that can't fit the payload isn't serialized only to be dropped.+    #[test]+    fn slides_with_too_much_stored_media_stay_in_this_window() {+        let mut a = Session::with_new();+        {+            let st = a.doc_mut().unwrap();+            let doc = Arc::make_mut(&mut st.doc);+            let id = doc.add_media("big.jpg", "image/jpeg", vec![0u8; 13 << 20]).expect("media");+            let slide = Arc::make_mut(&mut doc.slides[0]);+            slide.background = Some(deckcraft_model::Background::Fill { fill: Fill::Picture(PictureFill { media: id, ..Default::default() }) });+        }+        let copied = a.execute("edit.copy", &json!({"scope": "slides"})).unwrap();+        assert!(!copied["html"].as_str().unwrap().contains("deckcraft-slides"));+        assert!(a.clipboard.slides_payload.is_none());+    }+     #[test]     fn copied_connectors_follow_copied_ends_and_detach_from_the_rest() {         let mut s = Session::with_new();diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs--- a/crates/format/src/lib.rs+++ b/crates/format/src/lib.rs@@ -93,7 +93,9 @@ pub fn save(p: &Presentation) -> Result<Vec<u8>> {     Ok(buf.into_inner()) } -fn is_compressed(ct: &str) -> bool {+/// Whether [`save`] stores media of content type `ct` as is (already compressed) rather than+/// deflating it.+pub fn is_compressed(ct: &str) -> bool {     ct.starts_with("image/jpeg")         || ct.starts_with("image/png")         || ct.starts_with("video/")diff --git a/crates/ui-egui/src/app/clipboard.rs b/crates/ui-egui/src/app/clipboard.rs--- a/crates/ui-egui/src/app/clipboard.rs+++ b/crates/ui-egui/src/app/clipboard.rs@@ -14,8 +14,8 @@ pub(crate) fn system_clipboard_text(t: &str) -> String { impl SlideApp {     /// Offer the copy or cut on the system clipboard.     ///-    /// Slides go out as plain text. Text goes out as HTML plus the same plain words, so a paste-    /// back into DeckCraft still uses the internal clipboard. A textless shape leaves the+    /// Text and slides go out as HTML plus the same plain words, so a paste back into DeckCraft+    /// still uses the internal clipboard, and slides also paste into another DeckCraft process. A textless shape leaves the     /// invisible [`OBJECTS_MARKER`] rather than a picture: with only an image on the clipboard     /// egui-winit drops Ctrl+V, and the shortcut could no longer paste the shape here (#182).     pub(super) fn publish_clipboard(&mut self, ctx: &egui::Context, cut: bool) {@@ -23,8 +23,8 @@ impl SlideApp {         let Ok(v) = self.session.execute(cmd, &json!({})) else { return };         let text = v.get("text").and_then(Value::as_str).unwrap_or("").to_string();         let html = v.get("html").and_then(Value::as_str).unwrap_or("").to_string();-        if self.session.clipboard.slides.is_empty() && !html.is_empty() && self.services.clipboard_set_html.as_mut().is_some_and(|f| f(&html, &text))-        {+        // Untitled slides still need plain text, or egui-winit drops the Ctrl+V (#182).+        if !html.is_empty() && self.services.clipboard_set_html.as_mut().is_some_and(|f| f(&html, &system_clipboard_text(&text))) {             return;         }         ctx.copy_text(system_clipboard_text(&text));