Browse / github.com/storytold/deckcraft / patch
Copy: a copied slide also goes to the clipboard as a picture, beside its text (macOS, #1309)
Copy: a copied slide also goes to the clipboard as a picture, beside its text (macOS, #1309) Copying a slide from the thumbnails now also puts a picture of it on the clipboard, so it pastes into a chat, a browser or Preview's New from Clipboard (macOS for now). Microsoft's page on sharing a single slide (https://support.microsoft.com/en-us/powerpoint/share-a-single-powerpoint-slide-with-someone-else) has you copy the thumbnail and paste it into an email as a picture, or into Preview with New from Clipboard. Here the slide copy (Ctrl+C, and Copy or Cut from the thumbnail's right-click menu, the ribbon or the Edit menu) writes a PNG of the slide (960×540 for a 16:9 deck, the same rende
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- marcoarnulfo (imported from pull request #1330, open)
- Base
38d2ade2b77de39c0e06278ddfd44d1082ff80a8- Size
- +229 −27 in 10 files
- Review
- pending
- Shared
- 2026-10-28
- Id
68e5e82b957f9162c6990432
How it went
No other operator has reported using it yet.
Diff
raw · Cargo.lock ROADMAP.md apps/deckcraft-cli/tests/it/cli.rs apps/deckcraft/Cargo.toml apps/deckcraft/src/main.rs apps/deckcraft/src/pasteboard.rs crates/engine/src/cmd/edit.rs crates/engine/src/tests.rs crates/ui-egui/src/lib.rs docs/ui-parity.md
@@ -898,6 +898,8 @@ dependencies = [ "fmv-macos-events", "image", "log",+ "objc2-app-kit 0.3.2",+ "objc2-foundation 0.3.2", "rfd", "serde_json", "winit",@@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-23 · **Change:** trivial (progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: a copied slide also goes to other apps as a picture on macOS, #1309; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (fill and outline edits reach group members, #1079; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-20 · **Change:** trivial (empty chart number-format sections hide values; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (Symbol-font characters (U+F0xx) remap to the installed Symbol face's real Unicode on macOS, so they draw from Symbol instead of a look-alike; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)@@ -134,6 +134,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---|+| 2026-10-24 | Copying slides from the thumbnails also puts a PNG of the first one (in deck order) on the system clipboard, beside the text DeckCraft pastes from, so a chat, a browser or Preview's New from Clipboard gets the picture and Ctrl+V in DeckCraft still pastes the slides (macOS; Windows, Linux and the web keep the text only, #1309) | | 2026-10-23 | Chart text colour: `chart.options {textColor}` and Font Color on a selected chart colour its title, axes, legend and data labels; the colour is written to the chart part (`c:txPr` on the axes, legend, labels and chart, the title's run defaults) so an imported chart keeps its `txPr` colours through an edit and a save; text without a colour of its own takes PowerPoint's default, the theme's text colour at 65 % plus 35 % (595959 on a light theme, light grey on a dark one) | | 2026-10-23 | Format Background ▸ Apply to All also sets the background of the slide masters the slides use (and clears their layouts' overrides), so new slides keep it (#891) | | 2026-10-22 | Table text takes the master's other-text style (`otherStyle`), shapes and text boxes the presentation's default text style, as PowerPoint for Mac draws them (synthetic probe: 14 pt cells under a 14 pt `otherStyle` and a 26 pt `defaultTextStyle`); a private deck's table had been drawn 20 pt instead of 18 |@@ -280,6 +281,7 @@ app's JSON control channel (`deckcraft --control PORT`). |---|---|---| | 2026-10-22 | trivial | Progress log: Animation Pane ▸ Play From. Scores unchanged | | 2026-10-23 | trivial | Progress log: scroll bars on the zoomed slide pane (#1157). Scores and milestone names unchanged |+| 2026-10-24 | trivial | Progress log: a copied slide goes to other apps as a picture on macOS (#1309). Scores and milestone names unchanged | | 2026-10-20 | trivial | Explicit empty chart number-format sections hide their values; blank whole-code General fallback is retained. Synthetic formatter regressions; chart-element scores and readiness estimates unchanged | | 2026-10-21 | trivial | Progress log: Reuse Slides picker (#1103). Scores and milestone names unchanged | | 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged |@@ -104,7 +104,9 @@ Acts on the selected text or shapes. Run it with: deckcraft-cli run --cmd 'format.size={…}' " );- assert!(stdout(&cli(&["describe", "edit.copy"])).contains("Answers with {text, html?}.\n\nRun it with: deckcraft-cli run --cmd edit.copy\n"));+ assert!(+ stdout(&cli(&["describe", "edit.copy"])).contains("Answers with {text, html?, png?}.\n\nRun it with: deckcraft-cli run --cmd edit.copy\n")+ ); assert!(stdout(&cli(&["describe", "text.set"])).contains("Note: replaces the whole text, keeping the first run's formatting\n")); assert_eq!( stdout(&cli(&["describe", "shape.setDefault"])),@@ -36,6 +36,10 @@ cpal = "0.16" # GridCraft, PdfCraft and PhotoCraft) registers the handler (Objective-C interop needs unsafe, which # our workspace forbids) behind a safe API. Pinned exactly: review the diff before bumping. fmv-macos-events = "=0.1.0"+# A copied slide's picture and its text written to NSPasteboard together (#1309); arboard writes+# one kind at a time. Already in the tree through arboard; these calls need no unsafe.+objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSPasteboard"] }+objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSData", "NSString"] } [target.'cfg(all(unix, not(target_os = "macos")))'.dependencies] winit = { version = "0.30", default-features = false, features = ["x11"] }@@ -126,6 +126,8 @@ fn services() -> Services { clipboard_image: Some(Box::new(pasteboard::image_png)), clipboard_html: Some(Box::new(pasteboard::html)), clipboard_set_html: Some(Box::new(pasteboard::set_html)),+ #[cfg(target_os = "macos")]+ clipboard_set_image: Some(Box::new(pasteboard::set_image)), // Media is mixed and clocked by the output device; `DECKCRAFT_NO_AUDIO` plays silently. audio_out: std::env::var_os("DECKCRAFT_NO_AUDIO") .is_none()@@ -1,8 +1,8 @@ //! System pasteboard: HTML rich text, and a picture (TIFF on macOS, via arboard). //! //! Pages keeps bold, italic and underline when the pasteboard carries HTML plus the same plain-//! words. A shape with no text is copied as an image by the UI; this module only reads a picture-//! back (Keynote's TIFF) as PNG bytes.+//! words. A shape with no text is copied as an image by the UI; this module reads a picture back+//! (Keynote's TIFF) as PNG bytes, and on macOS writes a copied slide's picture beside its text. /// HTML currently on the pasteboard, when there is some. pub fn html() -> Option<String> {@@ -18,6 +18,23 @@ pub fn set_html(html: &str, text: &str) -> bool { cb.set_html(html, Some(text)).is_ok() } +/// Write a PNG picture and its plain-text alternative in one go (#1309): other apps paste the+/// picture, and DeckCraft pastes its own copy through the text, since egui-winit drops Ctrl+V+/// when the pasteboard holds only a picture (#182). `false` when either is refused. arboard+/// writes one kind at a time, so this goes to `NSPasteboard` directly.+#[cfg(target_os = "macos")]+pub fn set_image(png: &[u8], text: &str) -> bool {+ use objc2_app_kit::NSPasteboard;+ use objc2_foundation::{NSData, NSString};+ let pb = NSPasteboard::generalPasteboard();+ pb.clearContents();+ // The values of NSPasteboardTypePNG and NSPasteboardTypeString, read as extern statics only+ // through unsafe code.+ let picture = pb.setData_forType(Some(&NSData::with_bytes(png)), &NSString::from_str("public.png"));+ let plain = pb.setString_forType(&NSString::from_str(text), &NSString::from_str("public.utf8-plain-text"));+ picture && plain+}+ /// The pasteboard picture as PNG bytes (TIFF on macOS is decoded by arboard). pub fn image_png() -> Option<Vec<u8>> { let mut cb = arboard::Clipboard::new().ok()?;@@ -34,8 +51,9 @@ pub fn image_png() -> Option<Vec<u8>> { mod tests { use std::borrow::Cow; - /// Puts HTML and a 1×1 picture on the macOS pasteboard and reads them back.- /// Restores whatever plain text was there. Other platforms return immediately.+ /// Puts HTML and a 1×1 picture on the macOS pasteboard and reads them back, then a picture+ /// with its text (#1309). Restores whatever plain text was there. Other platforms return+ /// immediately. #[test] fn html_and_a_picture_round_trip_on_macos() { if !cfg!(target_os = "macos") {@@ -56,6 +74,25 @@ mod tests { let decoded = image::load_from_memory(&png).expect("decode").to_rgba8(); let p = decoded.get_pixel(0, 0); assert!(p[0] > 200 && p[1] < 40 && p[3] > 200, "{p:?}");++ // #1309: a PNG and its plain text land together, so other apps read the picture and+ // DeckCraft still reads its own text. In this test, not its own: tests run in parallel+ // and share the one pasteboard.+ #[cfg(target_os = "macos")]+ {+ let mut png = Vec::new();+ image::RgbaImage::from_pixel(2, 1, image::Rgba([0, 0, 255, 255]))+ .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)+ .expect("encode");+ assert!(super::set_image(&png, "\u{2060}"), "set image");+ let text = arboard::Clipboard::new().ok().and_then(|mut cb| cb.get_text().ok()).unwrap_or_default();+ assert_eq!(text, "\u{2060}");+ let back = super::image_png().expect("png");+ let decoded = image::load_from_memory(&back).expect("decode").to_rgba8();+ assert_eq!(decoded.dimensions(), (2, 1));+ let p = decoded.get_pixel(1, 0);+ assert!(p[2] > 200 && p[0] < 40 && p[3] > 200, "{p:?}");+ } } /// Puts the previous plain text back, including when the test fails halfway.@@ -14,8 +14,8 @@ pub fn specs() -> Vec<CommandSpec> { vec![ cmd!(noundo "edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo), cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Y"), "{}", can_redo, redo),- cmd!("edit.cut", "Cut", ["Home", "Clipboard"], Some("Cmd+X"), "{scope?: slides} → {text, html?}", has_doc, cut),- cmd!(noundo "edit.copy", "Copy", ["Home", "Clipboard"], Some("Cmd+C"), "{scope?: slides} → {text, html?}", has_doc, copy),+ cmd!("edit.cut", "Cut", ["Home", "Clipboard"], Some("Cmd+X"), "{scope?: slides, picture?: bool} → {text, html?, png?}", has_doc, cut),+ cmd!(noundo "edit.copy", "Copy", ["Home", "Clipboard"], Some("Cmd+C"), "{scope?: slides, picture?: bool} → {text, html?, png?}", has_doc, copy), cmd!( "edit.paste", "Paste",@@ -154,7 +154,23 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> { let caret = copying_caret(s); let t = copy_inner(s, p)?; let html = if caret { String::new() } else { copied_html(s) };- Ok(json!({"text": t, "html": html}))+ let mut v = json!({"text": t, "html": html});+ if !caret {+ add_slide_picture(s, p, &mut v);+ }+ Ok(v)+}++/// With `picture`, a copy of slides also carries a PNG of the first copied slide in deck order+/// (base64 `png`), for other apps to paste as a picture (#1309). Called before Cut deletes it.+fn add_slide_picture(s: &Session, p: &Value, v: &mut Value) {+ if !bool_or(p, "picture", false) || s.clipboard.slides.is_empty() {+ return;+ }+ let Some(st) = s.active() else { return };+ let Some(i) = s.clipboard.slides.iter().filter_map(|sl| st.doc.slide_index(sl.id)).min() else { return };+ let (png, _, _) = super::file::render_png(&st.doc, i, 1.0, false);+ v["png"] = Value::String(base64_encode(&png)); } /// HTML for what was just copied, when it is text (a selection, or the text on the copied shapes).@@ -182,8 +198,10 @@ fn cut(s: &mut Session, p: &Value) -> Result<Value> { return Ok(json!({"text": t, "html": ""})); } let html = copied_html(s);+ let mut v = json!({"text": t, "html": html});+ add_slide_picture(s, p, &mut v); delete(s, p)?;- Ok(json!({"text": t, "html": html}))+ Ok(v) } /// Give pasted shapes fresh ids (groups included) and return the old-to-new map. Connector ends@@ -210,6 +210,34 @@ fn clipboard_copy_paste_shapes_and_slides() { assert!(s.doc().unwrap().doc.validate().is_empty()); } +/// #1309: Copy and Cut of slides, asked for a picture, also return a PNG of the first copied+/// slide in deck order (not the first clicked, not the current one), rendered before Cut removes+/// it. A plain copy and a copy of shapes carry none.+#[test]+fn a_slide_copy_can_carry_a_picture_of_its_first_slide() {+ let mut s = session();+ for (i, color) in ["#C80000", "#0000C8", "#00C800"].into_iter().enumerate() {+ if i > 0 {+ s.execute("slide.new", &json!({"layout": "blank"})).unwrap();+ }+ s.execute("design.background", &json!({"color": color, "index": i})).unwrap();+ }+ let corner = |v: &serde_json::Value| {+ let png = cmd::base64_decode(v["png"].as_str().expect("png")).unwrap();+ image::load_from_memory(&png).unwrap().to_rgba8().get_pixel(5, 5).0+ };+ s.execute("slide.selectSlides", &json!({"indices": [2, 0, 1]})).unwrap();+ assert!(s.execute("edit.copy", &json!({})).unwrap().get("png").is_none(), "only when asked");+ let v = s.execute("edit.copy", &json!({"picture": true})).unwrap();+ assert_eq!(corner(&v), [200, 0, 0, 255], "the first slide of the deck");+ s.execute("slide.selectSlides", &json!({"indices": [2]})).unwrap();+ let v = s.execute("edit.cut", &json!({"picture": true})).unwrap();+ assert_eq!(corner(&v), [0, 200, 0, 255], "the cut slide, drawn before it goes");+ assert_eq!(s.doc().unwrap().doc.slides.len(), 2);+ s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 50, 50]})).unwrap();+ assert!(s.execute("edit.copy", &json!({"picture": true})).unwrap().get("png").is_none(), "shapes have no slide picture");+}+ #[test] fn bold_copy_is_html_and_pages_html_pastes_bold() { let mut s = session();@@ -218,6 +218,9 @@ pub struct Services { pub clipboard_html: Option<Box<dyn FnMut() -> Option<String>>>, /// Put HTML and its plain-text alternative on the system clipboard. `true` when it landed. pub clipboard_set_html: Option<Box<dyn FnMut(&str, &str) -> bool>>,+ /// Put a PNG picture and its plain-text alternative on the system clipboard together, in one+ /// write. `true` when both landed.+ pub clipboard_set_image: Option<Box<dyn FnMut(&[u8], &str) -> bool>>, /// Audio output for media playback (cpal on desktop); without one media plays silently. pub audio_out: Option<Box<dyn deckcraft_media::AudioOut>>, }@@ -487,8 +490,9 @@ pub struct SlideApp { /// creating or closing a presentation leaves it. pub backstage: Option<backstage::Backstage>, pending_urls: Vec<String>,- /// Text for the system clipboard from a Copy or Cut command, handed over on the next frame.- pending_copy: Option<String>,+ /// Text for the system clipboard from a Copy or Cut command, with the copied slide's picture+ /// when there is one (#1309), handed over on the next frame.+ pending_copy: Option<(String, Option<Vec<u8>>)>, shot_token: u64, queued_shots: Vec<(u64, f64, u32)>, pending_shots: Vec<(u64, Option<String>, Sender<ControlResponse>, f64)>,@@ -666,18 +670,32 @@ impl SlideApp { /// Run a command by id (UI commands here, everything else in the engine). Errors show in the /// status bar and are returned.- pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {- let r = if UI_COMMANDS.iter().any(|c| c.0 == id) {+ pub fn run(&mut self, id: &str, mut params: Value) -> Result<Value, String> {+ // The slide picture for the system clipboard is asked here, so a caller that did not ask+ // for it (the control channel, MCP) does not get the PNG back.+ let asked = params.get("picture").and_then(Value::as_bool).unwrap_or(false);+ let copy = matches!(id, "edit.copy" | "edit.cut");+ if copy+ && self.services.clipboard_set_image.is_some()+ && let Some(p) = params.as_object_mut()+ {+ p.insert("picture".into(), Value::Bool(true));+ }+ let mut r = if UI_COMMANDS.iter().any(|c| c.0 == id) { self.run_ui(id, ¶ms) } else { self.session.execute(id, ¶ms).map_err(|e| e.to_string()) };- match &r {+ match &mut r { // Copy and Cut from the ribbon, menus, the slide's context menu or the control channel // put the text on the system clipboard, as Ctrl+C and Ctrl+X do (#431).- Ok(v) if matches!(id, "edit.copy" | "edit.cut") => {+ Ok(v) if copy => {+ let png = v.get("png").and_then(Value::as_str).and_then(deckcraft_engine::cmd::base64_decode);+ if !asked && let Some(o) = v.as_object_mut() {+ o.remove("png");+ } if let Some(t) = v.get("text").and_then(Value::as_str) {- self.pending_copy = Some(t.to_string());+ self.pending_copy = Some((t.to_string(), png)); } } Ok(_) => {}@@ -1791,12 +1809,14 @@ impl SlideApp { egui::Event::Copy => self.publish_clipboard(ctx, false), egui::Event::Cut => self.publish_clipboard(ctx, true), egui::Event::Paste(text) => {- // Our own marker for objects without text: paste what the session copied.- let text = if text == OBJECTS_MARKER { String::new() } else { text };+ // Our own marker for objects without text: paste what the session copied, even+ // when a picture of the copied slide sits beside it (#1309).+ let ours = text == OBJECTS_MARKER;+ let text = if ours { String::new() } else { text }; // A picture (TIFF from Keynote, a screenshot) lands when the paste has no text. // Text keeps its HTML (bold, italic, underline) when the host can read it. let html = self.services.clipboard_html.as_mut().and_then(|f| f()).filter(|h| !h.is_empty());- let img = if text.is_empty() { self.services.clipboard_image.as_mut().and_then(|f| f()) } else { None };+ let img = if text.is_empty() && !ours { self.services.clipboard_image.as_mut().and_then(|f| f()) } else { None }; let r = match img { Some(png) => self .session@@ -1842,19 +1862,27 @@ 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+ /// Slides go out as plain text, and as a picture of the first one beside that text when the+ /// host can write both at once (#1309). 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 /// 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). fn publish_clipboard(&mut self, ctx: &egui::Context, cut: bool) { let cmd = if cut { "edit.cut" } else { "edit.copy" };- let Ok(v) = self.session.execute(cmd, &json!({})) else { return };+ let picture = self.services.clipboard_set_image.is_some();+ let Ok(v) = self.session.execute(cmd, &json!({"picture": picture})) 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)) { return; }+ let png = v.get("png").and_then(Value::as_str).and_then(deckcraft_engine::cmd::base64_decode);+ if let Some(png) = png+ && self.services.clipboard_set_image.as_mut().is_some_and(|f| f(&png, &system_clipboard_text(&text)))+ {+ return;+ } ctx.copy_text(system_clipboard_text(&text)); } @@ -1926,8 +1954,11 @@ impl SlideApp { for url in std::mem::take(&mut self.pending_urls) { ctx.open_url(egui::OpenUrl::new_tab(url)); }- if let Some(t) = self.pending_copy.take() {- ctx.copy_text(t);+ if let Some((t, png)) = self.pending_copy.take() {+ let landed = png.is_some_and(|png| self.services.clipboard_set_image.as_mut().is_some_and(|f| f(&png, &system_clipboard_text(&t))));+ if !landed {+ ctx.copy_text(t);+ } } self.drain_requests(); doc_window::show(self, &ctx);@@ -3298,6 +3329,81 @@ mod tests { assert!(cmds.iter().any(|c| matches!(c, egui::output::OutputCommand::CopyText(t) if t == OBJECTS_MARKER)), "{cmds:?}"); } + /// #1309: a slide copied from the thumbnails goes out as a PNG together with its text, in+ /// one write. With both on the clipboard, Ctrl+V here still pastes the slide, not the picture.+ #[test]+ fn a_copied_slide_offers_a_picture_and_still_pastes_as_a_slide() {+ use std::cell::RefCell;+ use std::rc::Rc;+ let clip: Rc<RefCell<Option<(Vec<u8>, String)>>> = Rc::new(RefCell::new(None));+ let (write, read) = (clip.clone(), clip.clone());+ let services = Services {+ clipboard_set_image: Some(Box::new(move |png: &[u8], text: &str| {+ *write.borrow_mut() = Some((png.to_vec(), text.to_string()));+ true+ })),+ clipboard_image: Some(Box::new(move || read.borrow().as_ref().map(|(png, _)| png.clone()))),+ ..Default::default()+ };+ let mut h = step_app(services);+ h.state_mut().session.execute("slide.selectSlides", &json!({"indices": [0]})).unwrap();+ h.event(egui::Event::Copy);+ h.run_steps(1);+ let (png, text) = clip.borrow().clone().expect("picture offered");+ assert!(png.starts_with(b"\x89PNG\r\n\x1a\n"), "a PNG");+ let side = |at: usize| png.get(at..at + 4).map_or(0, |b| u32::from_be_bytes([b[0], b[1], b[2], b[3]]));+ assert!(side(16) > 100 && side(20) > 50, "the whole slide: {}x{}", side(16), side(20));+ assert_eq!(text, OBJECTS_MARKER, "an untitled slide keeps the marker beside the picture");+ assert!(!h.output().platform_output.commands.iter().any(|c| matches!(c, egui::output::OutputCommand::CopyText(_))));++ h.event(egui::Event::Paste(text));+ h.run_steps(1);+ let doc = &h.state().session.active().unwrap().doc;+ assert_eq!(doc.slides.len(), 2, "the slide is pasted");+ let picture = |sl: &deckcraft_model::Slide| sl.shapes.iter().any(|sh| matches!(sh.kind, deckcraft_model::ShapeKind::Picture { .. }));+ assert!(!doc.slides.iter().any(|sl| picture(sl)), "no picture is inserted");+ }++ /// A host that cannot write a picture leaves the slide copy as it was: plain text only.+ #[test]+ fn a_slide_copy_falls_back_to_text_when_the_picture_is_refused() {+ let services = Services { clipboard_set_image: Some(Box::new(|_: &[u8], _: &str| false)), ..Default::default() };+ let mut h = step_app(services);+ h.state_mut().session.execute("slide.selectSlides", &json!({"indices": [0]})).unwrap();+ h.event(egui::Event::Copy);+ h.run_steps(1);+ let cmds = &h.output().platform_output.commands;+ assert!(cmds.iter().any(|c| matches!(c, egui::output::OutputCommand::CopyText(t) if t == OBJECTS_MARKER)), "{cmds:?}");+ }++ /// #1309: Copy from the thumbnail's context menu, the ribbon or the Edit menu runs the command+ /// (#431) and offers the picture too, not only the text.+ #[test]+ fn copy_from_a_menu_offers_the_slide_picture() {+ use std::cell::RefCell;+ use std::rc::Rc;+ let seen: Rc<RefCell<Option<(Vec<u8>, String)>>> = Rc::new(RefCell::new(None));+ let record = seen.clone();+ let services = Services {+ clipboard_set_image: Some(Box::new(move |png: &[u8], text: &str| {+ *record.borrow_mut() = Some((png.to_vec(), text.to_string()));+ true+ })),+ ..Default::default()+ };+ let mut h = step_app(services);+ let v = h.state_mut().run("edit.copy", json!({"scope": "slides"})).unwrap();+ h.run_steps(1);+ let (png, text) = seen.borrow().clone().expect("picture offered");+ assert!(png.starts_with(b"\x89PNG\r\n\x1a\n"), "a PNG");+ assert_eq!(text, OBJECTS_MARKER);+ assert!(!h.output().platform_output.commands.iter().any(|c| matches!(c, egui::output::OutputCommand::CopyText(_))));+ assert!(v.get("png").is_none(), "the caller did not ask for the picture");++ let asked = h.state_mut().run("edit.copy", json!({"scope": "slides", "picture": true})).unwrap();+ assert!(asked.get("png").is_some(), "asked, it comes back");+ }+ fn redder(px: [u8; 4]) -> i32 { px[0] as i32 - px[1].max(px[2]) as i32 }@@ -1,6 +1,6 @@ # UI parity with PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-23 · **Change:** trivial (scroll bars on the zoomed slide pane, #1157; previous: major, community PR wave 2026-10-14: 14 new dialogs (41 in all), Eyedropper, Export button, New Window, Grid Options, WordArt gallery and handles, Slide Sorter sections, Quick Access Toolbar, Format Background and Format Shape depth, rich-text clipboard; duplicated rows and headers folded; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (a copied slide goes to other apps as a picture on macOS, #1309; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: major, community PR wave 2026-10-14: 14 new dialogs (41 in all), Eyedropper, Export button, New Window, Grid Options, WordArt gallery and handles, Slide Sorter sections, Quick Access Toolbar, Format Background and Format Shape depth, rich-text clipboard; duplicated rows and headers folded; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (group fill and outline edits reach members, #1079; scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) How DeckCraft looks and feels next to PowerPoint: the shell, the ribbon, menus, panes, dialogs,@@ -102,7 +102,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Rulers with indent markers and tab stops | yes | yes: indent markers and tab stops drag on the ruler (a click adds a tab, the corner selector picks its kind); Tabs dialog from Home › Paragraph and the Paragraph dialog | | Drag and drop of files and between thumbnails | yes | yes | | Slide Sorter sections: a bar per section, add, rename in place, collapse, drag the whole section (its slides move with it) | yes | yes |-| Image paste from the clipboard | yes | macOS pastes a picture when the paste carries no text (TIFF), and rich text copies and pastes as HTML (bold, italic, underline). Windows and Linux image paste stays #53 |+| Image paste from the clipboard | yes | macOS pastes a picture when the paste carries no text (TIFF), and rich text copies and pastes as HTML (bold, italic, underline). Windows and Linux image paste stays #53. Copying slides also offers a picture of the first one to other apps on macOS (#1309) | | Font menu: every installed family with its faces | yes | families yes; only Regular/Bold/Italic faces (#69) | ## Keyboard shortcuts@@ -204,6 +204,7 @@ consistency is open (#73, #56). | 2026-10-15 | minor | Clicking the frame of a text box or placeholder selects the box whole instead of starting text entry (#787) | | 2026-10-13 | trivial | Editor slide rasters sit on the physical pixel grid (#184) | | 2026-10-14 | minor | Copy and paste rich text (HTML: bold, italic, underline) with other apps |+| 2026-10-24 | trivial | Copied slides also go to other apps as a picture of the first one, on macOS (#1309) | | 2026-10-13 | minor | Animations ▸ Preview and Animation Pane ▸ Play All play the current slide on the editor canvas | | 2026-10-14 | trivial | Format Shape Fill transparency slider follows the selected solid fill and does not clear it on focus loss | | 2026-10-14 | minor | Paragraph dialog from the Home › Paragraph launcher; Tabs… opens a standalone Tabs dialog |