Clipboard: rich text and pictures for other apps

Copy puts HTML on the pasteboard with the same plain words, so Pages keeps
bold, italic and underline, and a paste of that HTML keeps them here. A
shape with no text copies out as a picture.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nikita Rakitin
2026-10-14 06:37:49 +03:00
co-authored by Cursor
parent 457406a7a5
commit 5d9f47c81a
11 changed files with 763 additions and 33 deletions
+3 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: copy and paste rich text and pictures with other apps; previous: hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON
format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere
@@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | What landed |
|---|---|
| 2026-10-14 | Copy and paste with other apps: bold, italic and underline travel as HTML (Pages keeps them), and a shape with no text copies out as a picture |
| 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless |
| 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline |
| 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting |
@@ -150,6 +151,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | trivial | Progress log: copy and paste rich text and pictures with other apps |
| 2026-10-13 | trivial | Progress log: wild text cases |
| 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) |
| 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) |
+5 -8
View File
@@ -17,6 +17,8 @@ mod control_server;
#[cfg(any(target_os = "windows", test))]
mod graphics;
mod logging;
#[cfg(not(target_arch = "wasm32"))]
mod pasteboard;
use deckcraft_engine::Session;
use deckcraft_ui_egui::{Services, SlideApp};
@@ -111,14 +113,9 @@ fn services() -> Services {
pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))),
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))),
clipboard_image: Some(Box::new(|| {
let mut cb = arboard::Clipboard::new().ok()?;
let img = cb.get_image().ok()?;
let rgba = image::RgbaImage::from_raw(img.width as u32, img.height as u32, img.bytes.into_owned())?;
let mut out = Vec::new();
rgba.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?;
Some(out)
})),
clipboard_image: Some(Box::new(pasteboard::image_png)),
clipboard_html: Some(Box::new(pasteboard::html)),
clipboard_set_html: Some(Box::new(pasteboard::set_html)),
// 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()
+77
View File
@@ -0,0 +1,77 @@
//! 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.
/// HTML currently on the pasteboard, when there is some.
pub fn html() -> Option<String> {
let mut cb = arboard::Clipboard::new().ok()?;
let html = cb.get().html().ok()?;
let html = html.trim();
if html.is_empty() { None } else { Some(html.to_string()) }
}
/// Write `html` and the plain alternative `text`. `false` when the pasteboard refuses it.
pub fn set_html(html: &str, text: &str) -> bool {
let Ok(mut cb) = arboard::Clipboard::new() else { return false };
cb.set_html(html, Some(text)).is_ok()
}
/// 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()?;
let img = cb.get_image().ok()?;
let width = u32::try_from(img.width).ok()?;
let height = u32::try_from(img.height).ok()?;
let rgba = image::RgbaImage::from_raw(width, height, img.bytes.into_owned())?;
let mut out = Vec::new();
rgba.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?;
Some(out)
}
#[cfg(test)]
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.
#[test]
fn html_and_a_picture_round_trip_on_macos() {
if !cfg!(target_os = "macos") {
return;
}
let previous = arboard::Clipboard::new().ok().and_then(|mut cb| cb.get_text().ok());
let _restore = Restore(previous);
assert!(super::set_html("<b>DeckCraft</b>", "DeckCraft"), "set html");
let html = super::html().unwrap_or_default();
assert!(html.contains("<b>") || html.contains("<B>"), "{html}");
let text = arboard::Clipboard::new().ok().and_then(|mut cb| cb.get_text().ok()).unwrap_or_default();
assert_eq!(text, "DeckCraft");
let mut cb = arboard::Clipboard::new().expect("pasteboard");
let img = arboard::ImageData { width: 1, height: 1, bytes: Cow::Owned(vec![255, 0, 0, 255]) };
cb.set_image(img).expect("set image");
drop(cb);
let png = super::image_png().expect("png");
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:?}");
}
/// Puts the previous plain text back, including when the test fails halfway.
struct Restore(Option<String>);
impl Drop for Restore {
fn drop(&mut self) {
let Ok(mut cb) = arboard::Clipboard::new() else { return };
match self.0.take() {
Some(t) => {
let _ = cb.set_text(t);
}
None => {
let _ = cb.clear();
}
}
}
}
}
+74 -5
View File
@@ -2,6 +2,7 @@
use std::sync::Arc;
use deckcraft_model::text::TextBody;
use deckcraft_model::{MediaId, Shape, ShapeId, Slide, SlideId};
use serde_json::{Value, json};
@@ -12,9 +13,17 @@ 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}", has_doc, cut),
cmd!(noundo "edit.copy", "Copy", ["Home", "Clipboard"], Some("Cmd+C"), "{scope?: slides} → {text}", has_doc, copy),
cmd!("edit.paste", "Paste", ["Home", "Clipboard"], Some("Cmd+V"), "{text?: plain text to paste, scope?: slides}", has_doc, paste),
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.paste",
"Paste",
["Home", "Clipboard"],
Some("Cmd+V"),
"{text?: plain text to paste, html?: rich text from another app, scope?: slides}",
has_doc,
paste
),
cmd!("edit.pasteText", "Paste and Match Formatting", ["Edit"], Some("Cmd+Shift+V"), "{text?}", has_doc, paste_text),
cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+D"), "{ids?}", has_selection, duplicate),
cmd!("edit.delete", "Delete", ["Edit"], Some("Delete"), "{ids?, scope?: slides}", has_doc, delete),
@@ -114,15 +123,41 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {
Ok(plain)
}
fn copying_caret(s: &Session) -> bool {
s.active().is_some_and(|d| d.selection.text.as_ref().is_some_and(|t| !t.is_range()))
}
fn copy(s: &mut Session, p: &Value) -> Result<Value> {
// A caret copies nothing; the previous clipboard must not leak into this answer.
let caret = copying_caret(s);
let t = copy_inner(s, p)?;
Ok(json!({"text": t}))
let html = if caret { String::new() } else { copied_html(s) };
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 {
if let Some(body) = &s.clipboard.text {
return richclip::body_to_html(body);
}
let mut paragraphs = Vec::new();
for sh in &s.clipboard.shapes {
if let Some(t) = &sh.text {
paragraphs.extend(t.paragraphs.clone());
}
}
if paragraphs.is_empty() {
return String::new();
}
richclip::body_to_html(&TextBody { paragraphs, ..Default::default() })
}
fn cut(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) };
delete(s, p)?;
Ok(json!({"text": t}))
Ok(json!({"text": t, "html": html}))
}
/// Give pasted shapes fresh ids (groups included) and map connector ends.
@@ -157,9 +192,16 @@ fn bring_media(doc: &mut deckcraft_model::Presentation, media: &[deckcraft_model
fn paste(s: &mut Session, p: &Value) -> Result<Value> {
// 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")
&& t != s.clipboard.plain
{
if let Some(html) = str_param(p, "html")
&& let Some(body) = richclip::html_to_body(html)
&& richclip::same_words(&body.text(), t)
{
return paste_external_body(s, &body);
}
return paste_plain(s, t);
}
let editing = s.doc()?.selection.text.is_some();
@@ -228,6 +270,33 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
fn paste_external_body(s: &mut Session, body: &TextBody) -> Result<Value> {
if s.doc()?.selection.text.is_some() {
return super::text::paste_body(s, body);
}
let plain = body.text();
let st = s.doc()?;
let size = st.doc.slide_size;
let rect = [size.width * 0.25, size.height * 0.4, size.width * 0.5, 40.0];
let made = s.execute("insert.textBox", &json!({"rect": rect, "text": plain}))?;
let raw = made.get("id").and_then(Value::as_u64).ok_or_else(|| bad("edit.paste", "no text box"))?;
let id = u32::try_from(raw).map_err(|_| bad("edit.paste", "no text box"))?;
let body = body.clone();
s.edit(|doc, sel| {
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("edit.paste", "no slide"))?;
let sh = deckcraft_model::find_shape_mut(list, ShapeId(id)).ok_or_else(|| bad("edit.paste", "no text box"))?;
if let Some(t) = sh.text.as_mut() {
let (wrap, autofit) = (t.body.wrap, t.body.autofit);
*t = body.clone();
t.body.wrap = wrap;
t.body.autofit = autofit;
}
sel.shapes = vec![ShapeId(id)];
sel.text = None;
Ok(json!({"id": id}))
})
}
fn paste_plain(s: &mut Session, text: &str) -> Result<Value> {
if s.doc()?.selection.text.is_some() {
return super::text::insert_text(s, text, None);
+1
View File
@@ -11,6 +11,7 @@ pub mod inspect;
pub mod media;
pub mod merge;
pub mod review;
mod richclip;
pub mod selection;
pub mod shape;
pub mod slide;
+346
View File
@@ -0,0 +1,346 @@
//! Rich text on the system clipboard, as HTML.
//!
//! Pages and Keynote put copied text on the pasteboard as HTML (and plain text). DeckCraft writes
//! the same pair when it copies, and reads it back on paste: bold, italic and underline survive.
//! Anything else in the markup is skipped. A hostile fragment is capped, never executed.
use deckcraft_model::text::{Paragraph, Run, RunProps, TextBody};
const MAX_HTML: usize = 1 << 20;
const MAX_PARAS: usize = 2_000;
const MAX_RUNS: usize = 10_000;
/// HTML for `body`: one `<p>` per paragraph, `<b>`, `<i>` and `<u>` for the formatting.
pub fn body_to_html(body: &TextBody) -> String {
let mut out = String::new();
for (i, p) in body.paragraphs.iter().enumerate() {
if i > 0 {
out.push_str("<br>");
}
out.push_str("<p>");
for r in &p.runs {
let (open, close) = tags(&r.props);
out.push_str(&open);
match &r.kind {
deckcraft_model::text::RunKind::Break => out.push_str("<br>"),
_ => push_escaped(&mut out, &r.text),
}
out.push_str(&close);
if out.len() > MAX_HTML {
break;
}
}
out.push_str("</p>");
}
out
}
fn tags(props: &RunProps) -> (String, String) {
let mut open = String::new();
let mut close = String::new();
if props.bold == Some(true) {
open.push_str("<b>");
close.insert_str(0, "</b>");
}
if props.italic == Some(true) {
open.push_str("<i>");
close.insert_str(0, "</i>");
}
if props.underline.as_deref().is_some_and(|u| !u.eq_ignore_ascii_case("none")) {
open.push_str("<u>");
close.insert_str(0, "</u>");
}
(open, close)
}
fn push_escaped(out: &mut String, text: &str) {
for c in text.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
_ => out.push(c),
}
}
}
/// The formatted text of an HTML fragment, or `None` when it has no text.
///
/// Understands `<b>`, `<strong>`, `<i>`, `<em>`, `<u>`, `<br>`, paragraphs, and a `style` that
/// says bold, italic or underline. Other tags are ignored. The result is capped.
pub fn html_to_body(html: &str) -> Option<TextBody> {
if html.len() > MAX_HTML {
return None;
}
let bytes = html.as_bytes();
let mut i = 0;
let mut bold = 0i32;
let mut italic = 0i32;
let mut underline = 0i32;
let mut stack: Vec<(&'static str, i32, i32, i32)> = Vec::new();
let mut paragraphs: Vec<Paragraph> = vec![Paragraph::default()];
let mut runs = 0usize;
let mut skip = 0i32;
while i < bytes.len() && paragraphs.len() <= MAX_PARAS && runs <= MAX_RUNS {
if bytes[i] == b'<' {
let end = bytes[i + 1..].iter().position(|c| *c == b'>').map(|n| i + 1 + n).unwrap_or(bytes.len());
let raw = &html[i + 1..end];
i = (end + 1).min(bytes.len());
let token = raw.trim();
if token.starts_with('!') || token.starts_with('?') {
continue;
}
let closing = token.starts_with('/');
let name_src = token.trim_start_matches('/').trim();
let name_end = name_src.find(|c: char| c.is_whitespace() || c == '/' || c == '>').unwrap_or(name_src.len());
let name = name_src[..name_end].to_ascii_lowercase();
if name == "script" || name == "style" || name == "head" {
if closing {
skip = (skip - 1).max(0);
} else if !token.ends_with('/') {
skip += 1;
}
continue;
}
if skip > 0 {
continue;
}
if closing {
let kind = tag_kind(&name);
if !kind.is_empty()
&& let Some(pos) = stack.iter().rposition(|e| e.0 == kind)
{
let (_, db, di, du) = stack[pos];
bold -= db;
italic -= di;
underline -= du;
stack.truncate(pos);
}
if is_block(&name) {
new_para(&mut paragraphs);
}
continue;
}
if is_break(&name) {
new_para(&mut paragraphs);
continue;
}
let (db, di, du) = tag_style(&name, name_src);
if db + di + du > 0 {
bold += db;
italic += di;
underline += du;
if !token.ends_with('/') {
stack.push((tag_kind(&name), db, di, du));
} else {
bold -= db;
italic -= di;
underline -= du;
}
}
continue;
}
if skip > 0 {
i += 1;
continue;
}
let end = bytes[i..].iter().position(|c| *c == b'<').map(|n| i + n).unwrap_or(bytes.len());
let text = decode_text(&html[i..end]);
i = end;
if text.is_empty() {
continue;
}
push_run(&mut paragraphs, &mut runs, text, bold > 0, italic > 0, underline > 0);
}
while paragraphs.last().is_some_and(Paragraph::is_empty) && paragraphs.len() > 1 {
paragraphs.pop();
}
if paragraphs.iter().all(Paragraph::is_empty) {
return None;
}
for p in &mut paragraphs {
p.normalize();
}
Some(TextBody { paragraphs, ..Default::default() })
}
fn tag_kind(name: &str) -> &'static str {
match name {
"b" | "strong" => "b",
"i" | "em" => "i",
"u" => "u",
"span" | "font" => "span",
"p" | "div" | "li" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" => "block",
_ => "",
}
}
fn is_break(name: &str) -> bool {
name == "br" || name == "hr"
}
fn is_block(name: &str) -> bool {
matches!(name, "p" | "div" | "li" | "tr" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "blockquote")
}
fn tag_style(name: &str, src: &str) -> (i32, i32, i32) {
let mut bold = i32::from(name == "b" || name == "strong");
let mut italic = i32::from(name == "i" || name == "em");
let mut underline = i32::from(name == "u");
if let Some(style) = attr(src, "style") {
let s = style.to_ascii_lowercase();
if s.contains("font-weight:bold") || s.contains("font-weight: bold") || weight_bold(&s) {
bold = 1;
}
if s.contains("font-style:italic") || s.contains("font-style: italic") {
italic = 1;
}
if s.contains("text-decoration:underline")
|| s.contains("text-decoration: underline")
|| s.contains("text-decoration-line:underline")
|| s.contains("text-decoration-line: underline")
{
underline = 1;
}
}
(bold, italic, underline)
}
fn weight_bold(style: &str) -> bool {
let Some(at) = style.find("font-weight") else { return false };
let rest = style[at + "font-weight".len()..].trim_start_matches(|c: char| c == ':' || c.is_whitespace());
rest.starts_with('6') || rest.starts_with('7') || rest.starts_with('8') || rest.starts_with('9')
}
fn attr<'a>(tag: &'a str, key: &str) -> Option<&'a str> {
let lower = tag.to_ascii_lowercase();
let at = lower.find(key)?;
let after = tag.get(at + key.len()..)?.trim_start();
let after = after.strip_prefix('=')?.trim_start();
let quote = after.chars().next()?;
if quote != '"' && quote != '\'' {
return None;
}
let rest = after.get(quote.len_utf8()..)?;
let end = rest.find(quote)?;
rest.get(..end)
}
fn new_para(paragraphs: &mut Vec<Paragraph>) {
if paragraphs.len() >= MAX_PARAS {
return;
}
if paragraphs.last().is_some_and(|p| !p.is_empty()) {
paragraphs.push(Paragraph::default());
}
}
fn push_run(paragraphs: &mut [Paragraph], runs: &mut usize, text: String, bold: bool, italic: bool, underline: bool) {
if *runs >= MAX_RUNS {
return;
}
let Some(p) = paragraphs.last_mut() else { return };
let mut props = RunProps::default();
if bold {
props.bold = Some(true);
}
if italic {
props.italic = Some(true);
}
if underline {
props.underline = Some("sng".into());
}
p.runs.push(Run::with(text, props));
*runs += 1;
}
fn decode_text(raw: &str) -> String {
let mut out = String::new();
let mut rest = raw;
while let Some(at) = rest.find('&') {
out.push_str(&rest[..at].replace(['\n', '\r'], ""));
rest = &rest[at..];
let Some(end) = rest.find(';') else {
out.push_str(&rest.replace(['\n', '\r'], ""));
break;
};
let entity = &rest[1..end];
rest = &rest[end + 1..];
match entity {
"amp" => out.push('&'),
"lt" => out.push('<'),
"gt" => out.push('>'),
"quot" => out.push('"'),
"apos" => out.push('\''),
"nbsp" => out.push(' '),
_ => {
let ch = entity.strip_prefix('#').and_then(numeric_char);
if let Some(c) = ch {
out.push(c);
}
}
}
}
if !rest.is_empty() && !rest.starts_with('&') {
out.push_str(&rest.replace(['\n', '\r'], ""));
}
out
}
fn numeric_char(digits: &str) -> Option<char> {
let hex = digits.strip_prefix('x').or_else(|| digits.strip_prefix('X'));
let n = if let Some(hex) = hex { u32::from_str_radix(hex, 16).ok()? } else { digits.parse().ok()? };
char::from_u32(n).filter(|c| !c.is_control() || *c == '\n')
}
/// Do the HTML's words and the paste's plain text say the same thing?
pub fn same_words(html_plain: &str, pasted: &str) -> bool {
let a = fold(html_plain);
let b = fold(pasted);
a == b
}
fn fold(s: &str) -> String {
s.replace("\r\n", "\n").replace('\r', "\n").replace('\u{00a0}', " ").trim_matches(['\n', ' ', '\t']).to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bold_italic_and_underline_round_trip() {
let mut body = TextBody::from_text("Hi there");
body.paragraphs[0].runs = vec![
Run::with("Hi", RunProps { bold: Some(true), ..Default::default() }),
Run::new(" "),
Run::with("there", RunProps { italic: Some(true), underline: Some("sng".into()), ..Default::default() }),
];
let html = body_to_html(&body);
assert!(html.contains("<b>Hi</b>"), "{html}");
assert!(html.contains("<i><u>there</u></i>") || html.contains("<u><i>there</i></u>"), "{html}");
let back = html_to_body(&html).unwrap();
assert_eq!(back.text(), "Hi there");
assert_eq!(back.paragraphs[0].runs[0].props.bold, Some(true));
let last = back.paragraphs[0].runs.last().unwrap();
assert_eq!(last.props.italic, Some(true));
assert!(last.props.underline.is_some());
}
#[test]
fn pages_style_and_escapes() {
let html = "<html><head><style>p{}</style></head><body><p><span style=\"font-weight: bold\">A &amp; B</span></p><p><span style=\"font-style: italic\">C</span></p></body></html>";
let body = html_to_body(html).unwrap();
assert_eq!(body.text(), "A & B\nC");
assert_eq!(body.paragraphs[0].runs[0].props.bold, Some(true));
assert_eq!(body.paragraphs[1].runs[0].props.italic, Some(true));
assert!(same_words(&body.text(), "A & B\nC"));
assert!(!same_words(&body.text(), "other"));
}
#[test]
fn a_huge_fragment_is_refused() {
let html = "x".repeat(MAX_HTML + 1);
assert!(html_to_body(&html).is_none());
}
}
+56
View File
@@ -93,6 +93,62 @@ fn clipboard_copy_paste_shapes_and_slides() {
assert!(s.doc().unwrap().doc.validate().is_empty());
}
#[test]
fn bold_copy_is_html_and_pages_html_pastes_bold() {
let mut s = session();
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
s.execute("text.edit", &json!({"id": title})).unwrap();
s.execute("text.insert", &json!({"text": "Hello"})).unwrap();
s.execute("text.selectAll", &json!({})).unwrap();
s.execute("format.bold", &json!({"on": true})).unwrap();
let v = s.execute("edit.copy", &json!({})).unwrap();
let html = v["html"].as_str().unwrap();
assert!(html.contains("<b>"), "{html}");
assert_eq!(v["text"], "Hello");
// The same plain words are our own clipboard, so a plainer HTML fragment is not reparsed.
s.execute("text.select", &json!({"anchor": [0, 5], "caret": [0, 5]})).unwrap();
s.execute("edit.paste", &json!({"text": "Hello", "html": "<p>Hello</p>"})).unwrap();
let body = s.doc().unwrap().shape(deckcraft_model::ShapeId(title)).unwrap().text.clone().unwrap();
assert_eq!(body.text(), "HelloHello");
assert!(body.paragraphs.iter().flat_map(|p| p.runs.iter()).all(|r| r.props.bold == Some(true)), "{body:?}");
s.execute("text.select", &json!({"anchor": [0, 10], "caret": [0, 10]})).unwrap();
let caret = s.execute("edit.copy", &json!({})).unwrap();
assert_eq!(caret["text"], "");
assert_eq!(caret["html"], "");
let mut ext = session();
ext.execute("edit.paste", &json!({"text": "Hello", "html": "<p style=\"font-weight: bold\">Hello</p>"})).unwrap();
let pasted = ext
.doc()
.unwrap()
.current_slide()
.unwrap()
.shapes
.iter()
.find(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Hello"))
.unwrap()
.text
.clone()
.unwrap();
assert_eq!(pasted.paragraphs[0].runs[0].props.bold, Some(true), "{pasted:?}");
let mut mismatched = session();
mismatched.execute("edit.paste", &json!({"text": "Hi", "html": "<b>Nope</b>"})).unwrap();
let plain = mismatched
.doc()
.unwrap()
.current_slide()
.unwrap()
.shapes
.iter()
.find(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Hi"))
.unwrap()
.text
.clone()
.unwrap();
assert_ne!(plain.paragraphs[0].runs[0].props.bold, Some(true));
}
#[test]
fn group_ungroup_roundtrip_positions() {
let mut s = session();
+43
View File
@@ -2107,6 +2107,49 @@ fn draw_glyph_with(
}
}
/// Render `ids` together onto a transparent canvas, in their slide positions (copy as a picture).
pub fn render_shapes(pres: &Presentation, slide: &Slide, ids: &[ShapeId], scale: f64) -> Option<Image> {
if ids.is_empty() || !scale.is_finite() || scale <= 0.0 {
return None;
}
let rctx = Ctx::for_slide(pres, slide)?;
let mut items = Vec::new();
let mut bounds: Option<Rect> = None;
for id in ids.iter().take(256) {
let Some(s) = slide.shape(*id) else { continue };
let b = resolve::xfrm(&rctx, s).bounds();
if !b.width().is_finite() || !b.height().is_finite() || b.width() < 0.0 || b.height() < 0.0 {
continue;
}
bounds = Some(match bounds {
Some(u) => u.union(b),
None => b,
});
items.push(s);
}
let b = bounds?.inflate(8.0, 8.0);
if !(b.width() > 0.0 && b.height() > 0.0 && b.width().is_finite() && b.height().is_finite()) {
return None;
}
let fit = (2048.0 / b.width().max(b.height())).min(scale);
if !(fit.is_finite() && fit > 0.0) {
return None;
}
let w = (b.width() * fit).ceil().clamp(1.0, 2048.0) as u16;
let h = (b.height() * fit).ceil().clamp(1.0, 2048.0) as u16;
let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
let opts = RenderOpts { scale: fit, ..Default::default() };
let f = Frame { pres, opts: &opts, fields: SlideFields::new(1, String::new(), &opts) };
let view = Affine::scale(fit) * Affine::translate(-b.origin().to_vec2());
RENDERER.with(|r| {
let mut r = r.borrow_mut();
for s in items {
r.shape(&mut ctx, &f, &rctx, s, view, 0);
}
Some(r.finish(ctx, w, h))
})
}
/// Render one shape alone onto a transparent canvas (drag previews, copy as picture).
pub fn render_shape(pres: &Presentation, slide: &Slide, id: ShapeId, scale: f64) -> Option<Image> {
let rctx = Ctx::for_slide(pres, slide)?;
+16
View File
@@ -35,6 +35,22 @@ fn shape_fill_uses_theme_accent() {
assert_eq!(img.pixel(50, 50), [255, 255, 255, 255]);
}
#[test]
fn render_shapes_draws_the_selection_on_a_transparent_canvas() {
let sh = Shape {
id: ShapeId(880),
xfrm: Some(Xfrm::new(100.0, 80.0, 60.0, 40.0)),
fill: Some(deckcraft_model::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0)))),
line: Some(deckcraft_model::Line::none()),
..Default::default()
};
let p = deck_with(vec![sh]);
let img = render_shapes(&p, &p.slides[0], &[ShapeId(880)], 1.0).expect("picture");
let mid = img.pixel(img.width / 2, img.height / 2);
assert!(mid[0] > 200 && mid[1] < 40 && mid[3] > 200, "{mid:?}");
assert_eq!(img.pixel(0, 0)[3], 0, "{:?}", img.pixel(0, 0));
}
#[test]
fn text_draws_ink() {
let mut sh = Shape { id: ShapeId(501), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), text_box: true, ..Default::default() };
+138 -16
View File
@@ -60,6 +60,10 @@ pub struct Services {
pub inbox: Option<Inbox>,
/// Image on the system clipboard (PNG bytes), when the host can read one.
pub clipboard_image: Option<Box<dyn FnMut() -> Option<Vec<u8>>>>,
/// HTML on the system clipboard (Pages, Keynote, and other apps that write `public.html`).
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>>,
/// Audio output for media playback (cpal on desktop); without one media plays silently.
pub audio_out: Option<Box<dyn deckcraft_media::AudioOut>>,
}
@@ -881,28 +885,24 @@ impl SlideApp {
}
}
egui::Event::Ime(ime) => self.ime_event(&ime),
egui::Event::Copy => {
if let Ok(v) = self.session.execute("edit.copy", &json!({}))
&& let Some(t) = v.get("text").and_then(Value::as_str)
{
ctx.copy_text(t.to_string());
}
}
egui::Event::Cut => {
if let Ok(v) = self.session.execute("edit.cut", &json!({}))
&& let Some(t) = v.get("text").and_then(Value::as_str)
{
ctx.copy_text(t.to_string());
}
}
egui::Event::Copy => self.publish_clipboard(ctx, false),
egui::Event::Cut => self.publish_clipboard(ctx, true),
egui::Event::Paste(text) => {
// An image on the clipboard (screenshots, copied pictures) becomes a picture.
// 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 r = match img {
Some(png) => self
.session
.execute("insert.picture", &json!({"name": "Pasted image.png", "data": deckcraft_engine::cmd::base64_encode(&png)})),
None => self.session.execute("edit.paste", &json!({"text": text})),
None => {
let mut params = json!({"text": text});
if let Some(h) = html {
params["html"] = Value::String(h);
}
self.session.execute("edit.paste", &params)
}
};
if let Err(e) = r {
self.set_status(e.to_string());
@@ -925,6 +925,61 @@ impl SlideApp {
self.drain_requests();
}
/// Offer the copy or cut on the system clipboard.
///
/// Slides go out as plain text. A textless shape goes out as a picture (Keynote and Pages
/// paste that). Text goes out as HTML plus the same plain words, so a paste back into
/// DeckCraft still uses the internal clipboard.
fn publish_clipboard(&mut self, ctx: &egui::Context, cut: bool) {
// Cut deletes the shapes, so the picture is taken first.
let picture = self.textless_selection_image();
let cmd = if cut { "edit.cut" } else { "edit.copy" };
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() {
ctx.copy_text(text);
return;
}
if let Some(img) = picture {
ctx.copy_image(img);
return;
}
if !html.is_empty() && self.services.clipboard_set_html.as_mut().is_some_and(|f| f(&html, &text)) {
return;
}
ctx.copy_text(text);
}
/// A picture of the selected shapes when none of them has text, on the slide (not a master).
fn textless_selection_image(&self) -> Option<egui::ColorImage> {
let st = self.session.active()?;
if st.selection.target != deckcraft_engine::Target::Slides || st.selection.text.is_some() || st.selection.shapes.is_empty() {
return None;
}
let mut plain = String::new();
let mut any = false;
for id in &st.selection.shapes {
let Some(sh) = st.shape(*id) else { continue };
any = true;
if let Some(t) = &sh.text {
if !plain.is_empty() {
plain.push('\n');
}
plain.push_str(&t.text());
}
}
if !any || !plain.is_empty() {
return None;
}
let slide = st.current_slide()?;
let img = deckcraft_render::render_shapes(&st.doc, slide, &st.selection.shapes, 2.0)?;
if img.width == 0 || img.height == 0 {
return None;
}
Some(textures::to_color_image(&img, false))
}
/// Lay out the whole window.
pub fn ui(&mut self, ui: &mut egui::Ui) {
let ctx = ui.ctx().clone();
@@ -1408,4 +1463,71 @@ mod tests {
harness.run_steps(3);
assert_eq!(harness.state().textures.canvas_renders, n, "an unchanged slide isn't rendered again");
}
fn step_app(services: Services) -> egui_kittest::Harness<'static, SlideApp> {
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SlideApp| {
app.logic(ui.ctx());
app.ui(ui);
},
SlideApp::new(Session::with_new(), services),
);
h.run_steps(2);
h
}
/// Copy of a bold selection offers HTML; paste of Pages-like HTML keeps the bold.
#[test]
fn copy_offers_html_and_paste_keeps_bold() {
use std::cell::RefCell;
use std::rc::Rc;
let seen = Rc::new(RefCell::new(None));
let record = seen.clone();
let services = Services {
clipboard_set_html: Some(Box::new(move |html, plain| {
*record.borrow_mut() = Some((html.to_string(), plain.to_string()));
true
})),
..Default::default()
};
let mut h = step_app(services);
let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
h.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap();
h.event(egui::Event::Text("Hi".into()));
h.run_steps(1);
h.state_mut().session.execute("text.selectAll", &json!({})).unwrap();
h.state_mut().session.execute("format.bold", &json!({"on": true})).unwrap();
h.event(egui::Event::Copy);
h.run_steps(1);
let (html, plain) = seen.borrow().clone().expect("html offered");
assert!(html.contains("<b>"), "{html}");
assert_eq!(plain, "Hi");
assert!(!h.output().platform_output.commands.iter().any(|c| matches!(c, egui::output::OutputCommand::CopyText(_))));
let services = Services { clipboard_html: Some(Box::new(|| Some("<p><b>Hi</b></p>".into()))), ..Default::default() };
let mut h = step_app(services);
h.event(egui::Event::Paste("Hi".into()));
h.run_steps(1);
let st = h.state().session.active().unwrap();
let body = st
.current_slide()
.unwrap()
.shapes
.iter()
.find(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Hi"))
.and_then(|sh| sh.text.clone())
.expect("pasted text");
assert_eq!(body.paragraphs[0].runs[0].props.bold, Some(true), "{body:?}");
}
/// A shape with no text copies out as a picture.
#[test]
fn a_textless_shape_copies_as_a_picture() {
let mut h = step_app(Services::default());
h.state_mut().session.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 80.0, 120.0, 80.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::CopyImage(img) if img.size[0] > 0 && img.size[1] > 0)), "{cmds:?}");
}
}
+4 -3
View File
@@ -1,8 +1,8 @@
# UI parity with PowerPoint
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (File tab / Backstage, #76; previous: off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-14 · **Change:** minor (copy and paste rich text and pictures with other apps; previous: text editing keys, #268, and File tab, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-14 · **Change:** minor (copy and paste rich text and pictures with other apps; previous: text editing keys, #268, and File tab, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
How DeckCraft looks and feels next to PowerPoint: the shell, the ribbon, menus, panes, dialogs,
direct manipulation, keyboard and precision. Observations of PowerPoint are kept locally in
@@ -85,7 +85,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`).
| Rulers with indent markers and tab stops | yes | rulers yes; tab stops partial |
| Context menus on shapes, slides, text, thumbnails | yes | yes, shorter |
| Drag and drop of files and between thumbnails | yes | yes |
| Image paste from the clipboard | yes | macOS yes; Windows and Linux no (#53) |
| Image paste from the clipboard | yes | macOS pastes a picture when the paste carries no text (TIFF), a textless shape copies out as a picture, and rich text copies and pastes as HTML (bold, italic, underline). Windows and Linux image paste stays #53 |
| Font menu: every installed family with its faces | yes | families yes; only Regular/Bold/Italic faces (#69) |
## Keyboard shortcuts
@@ -148,6 +148,7 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | minor | Copy and paste rich text (HTML: bold, italic, underline) and a picture of a textless shape with other apps |
| 2026-10-12 | minor | File tab (Backstage) observed in PowerPoint for Windows and matched (#76); the start screen is File › Home |
| 2026-10-12 | minor | Off-slide objects drawn and editable around the slide in Normal view (#270) |
| 2026-10-12 | minor | Selection Pane matched to PowerPoint (#133): drag and drop, arrows, groups, eye, Lock |