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