From 30c2fe4b6de23ef48764f08f357e39e6cd6b10b8 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 23:04:41 +0300 Subject: [PATCH] Design: Browse for Themes from a .thmx, .pptx or .potx file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deckcraft_pptx::import_theme reads the theme of a .thmx (its main document is a theme part) or of a presentation's first master, with the media its format scheme uses. design.browseTheme {path | name, data} applies it to the current master as one undo step, giving the theme's pictures ids of this deck. Design tab: Browse for Themes… next to the theme gallery and in the Slide Master Themes menu; the desktop picker offers Office Theme (.thmx) and presentations. Ribbon small buttons expose their label to accessibility (same change as #512). Parity row Browse themes M → D. Co-authored-by: Cursor --- apps/deckcraft/src/main.rs | 1 + crates/engine/src/cmd/design.rs | 99 +++++++++++++++++++++++++ crates/pptx/src/lib.rs | 59 +++++++++++++++ crates/pptx/src/read/mod.rs | 40 ++++++++++ crates/ui-egui/src/browsetheme_tests.rs | 49 ++++++++++++ crates/ui-egui/src/lib.rs | 14 ++++ crates/ui-egui/src/ribbon.rs | 7 ++ crates/ui-egui/src/widgets.rs | 4 + docs/parity-checklist.md | 8 +- 9 files changed, 277 insertions(+), 4 deletions(-) create mode 100644 crates/ui-egui/src/browsetheme_tests.rs diff --git a/apps/deckcraft/src/main.rs b/apps/deckcraft/src/main.rs index 7b5e48bb..a71e6bf8 100644 --- a/apps/deckcraft/src/main.rs +++ b/apps/deckcraft/src/main.rs @@ -100,6 +100,7 @@ fn services() -> Services { &["wav", "mp3", "m4a", "m4b", "aac", "flac", "ogg", "oga", "opus", "aif", "aiff", "aifc", "caf", "wma", "weba", "mka"], ), "video" => d.add_filter("Video", &["mp4", "m4v", "mov", "webm", "mkv", "wmv"]), + "theme" => d.add_filter("Themes and presentations", &["thmx", "pptx", "potx", "ppsx"]).add_filter("Office Theme", &["thmx"]), _ => d .add_filter("Presentations", &["deckcraft", "pptx", "potx", "ppsx"]) .add_filter("DeckCraft", &["deckcraft"]) diff --git a/crates/engine/src/cmd/design.rs b/crates/engine/src/cmd/design.rs index 3cad3f00..48bbc41c 100644 --- a/crates/engine/src/cmd/design.rs +++ b/crates/engine/src/cmd/design.rs @@ -15,6 +15,15 @@ use crate::{Result, Session, Target}; pub fn specs() -> Vec { vec![ cmd!("design.theme", "Themes", ["Design", "Themes"], None, "{name} (see design.themes)", has_doc, theme), + cmd!( + "design.browseTheme", + "Browse for Themes…", + ["Design", "Themes"], + None, + "{path} | {name, data: base64} — apply the theme of a .thmx, .pptx or .potx file", + has_doc, + browse_theme + ), cmd!(query "design.themes", "Theme Gallery", [], None, "{} → themes, colour schemes and font schemes", always, themes), cmd!("design.colors", "Colors", ["Design", "Variants"], None, "{name} | {colors: {accent1: #RRGGBB, …}}", has_doc, colors), cmd!("design.fonts", "Fonts", ["Design", "Variants"], None, "{name} | {major, minor}", has_doc, fonts), @@ -79,6 +88,68 @@ fn theme(s: &mut Session, p: &Value) -> Result { Ok(json!({"theme": name})) } +fn browse_theme(s: &mut Session, p: &Value) -> Result { + let bytes = if let Some(data) = str_param(p, "data") { + super::base64_decode(data).ok_or_else(|| bad("design.browseTheme", "invalid base64 `data`"))? + } else { + #[cfg(not(target_arch = "wasm32"))] + { + let path = str_param(p, "path").ok_or_else(|| bad("design.browseTheme", "give `path` or `data`"))?; + std::fs::read(path).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))? + } + #[cfg(target_arch = "wasm32")] + return Err(bad("design.browseTheme", "give `data`")); + }; + if !deckcraft_pptx::sniff(&bytes) { + return Err(bad("design.browseTheme", "not a theme or PowerPoint file")); + } + let (theme, media) = deckcraft_pptx::import_theme(&bytes).map_err(|e| bad("design.browseTheme", e.to_string()))?; + let name = theme.name.clone(); + s.edit(|doc, sel| { + // The theme's pictures (format-scheme blip fills) get ids of this deck. + let mut map = std::collections::HashMap::new(); + for m in &media { + let id = doc.add_media(&m.name, &m.content_type, m.data.as_ref().clone()); + map.insert(m.id.0, id.0); + } + let theme = if map.is_empty() { theme.clone() } else { remap_media_ids(&theme, &map)? }; + let mi = master_index(doc, sel); + let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("design.browseTheme", "no master"))?); + m.theme = theme; + m.color_map = vec![]; + Ok(()) + })?; + Ok(json!({"theme": name})) +} + +/// `theme` with every `media` id field rewritten through `map` (format-scheme picture fills). +fn remap_media_ids(theme: &deckcraft_model::theme::Theme, map: &std::collections::HashMap) -> Result { + fn rec(v: &mut Value, map: &std::collections::HashMap, depth: usize) { + if depth > 64 { + return; + } + match v { + Value::Object(o) => { + for (k, x) in o.iter_mut() { + if k == "media" + && let Some(n) = x.as_u64().and_then(|n| u32::try_from(n).ok()) + && let Some(new) = map.get(&n) + { + *x = json!(new); + } else { + rec(x, map, depth + 1); + } + } + } + Value::Array(a) => a.iter_mut().for_each(|x| rec(x, map, depth + 1)), + _ => {} + } + } + let mut v = serde_json::to_value(theme).map_err(|e| bad("design.browseTheme", e.to_string()))?; + rec(&mut v, map, 0); + serde_json::from_value(v).map_err(|e| bad("design.browseTheme", e.to_string())) +} + fn master_index(doc: &Presentation, sel: &crate::Selection) -> usize { match sel.target { Target::Master { master } | Target::Layout { master, .. } => master, @@ -564,3 +635,31 @@ mod tests { assert_eq!(super::civil(20_734), (2026, 10, 8)); } } + +#[cfg(test)] +mod browse_theme_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn applies_the_theme_of_another_deck_and_undoes() { + let mut other = Session::with_new(); + other.execute("design.fonts", &json!({"major": "Lato", "minor": "Source Sans 3"})).unwrap(); + other.execute("design.colors", &json!({"colors": {"accent1": "#123456"}})).unwrap(); + let data = other.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].as_str().unwrap().to_string(); + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "T", "body": "B"})).unwrap(); + let before = s.doc().unwrap().doc.masters[0].theme.clone(); + s.execute("design.browseTheme", &json!({"name": "Brand.pptx", "data": data})).unwrap(); + let t = &s.doc().unwrap().doc.masters[0].theme; + assert_eq!(t.fonts.major.latin, "Lato"); + assert_eq!(t.fonts.minor.latin, "Source Sans 3"); + assert_eq!(t.colors.get(deckcraft_color::SchemeSlot::Accent1).hex().to_lowercase(), "123456"); + assert!(s.doc().unwrap().doc.validate().is_empty()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.masters[0].theme, before); + assert!(s.execute("design.browseTheme", &json!({"data": "AAAA"})).is_err()); + assert!(s.execute("design.browseTheme", &json!({})).is_err()); + } +} diff --git a/crates/pptx/src/lib.rs b/crates/pptx/src/lib.rs index 92274b62..13daa3fb 100644 --- a/crates/pptx/src/lib.rs +++ b/crates/pptx/src/lib.rs @@ -49,6 +49,11 @@ pub fn import(bytes: &[u8]) -> Result { read::import(bytes) } +/// Read the theme of a `.thmx` file, or of a `.pptx` / `.potx`'s first master, with its media. +pub fn import_theme(bytes: &[u8]) -> Result<(deckcraft_model::theme::Theme, Vec), PptxError> { + read::import_theme(bytes) +} + /// Write a `.pptx` package. pub fn export(p: &Presentation) -> Result, PptxError> { write::export(p) @@ -83,3 +88,57 @@ pub(crate) fn mime_for(name: &str) -> &'static str { _ => "application/octet-stream", } } + +#[cfg(test)] +mod import_theme_tests { + use std::io::{Read, Write}; + + use deckcraft_model::Presentation; + + fn custom() -> Presentation { + let mut p = Presentation::default(); + let m = std::sync::Arc::make_mut(&mut p.masters[0]); + m.theme.name = "Harbour".into(); + m.theme.fonts.major.latin = "Lato".into(); + m.theme.fonts.minor.latin = "Source Sans 3".into(); + m.theme.colors.set(deckcraft_color::SchemeSlot::Accent1, deckcraft_color::Rgba::rgb(0x12, 0x34, 0x56)); + p + } + + /// A `.thmx`: the deck's theme part as the package's main document. + fn thmx(pptx: &[u8]) -> Vec { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(pptx)).unwrap(); + let mut theme = String::new(); + z.by_name("ppt/theme/theme1.xml").unwrap().read_to_string(&mut theme).unwrap(); + let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let o = zip::write::SimpleFileOptions::default(); + out.start_file("[Content_Types].xml", o).unwrap(); + out.write_all(br#""#).unwrap(); + out.start_file("_rels/.rels", o).unwrap(); + out.write_all(br#""#).unwrap(); + out.start_file("theme/theme/theme1.xml", o).unwrap(); + out.write_all(theme.as_bytes()).unwrap(); + out.finish().unwrap().into_inner() + } + + #[test] + fn reads_the_theme_of_a_pptx_and_of_a_thmx() { + let pptx = crate::export(&custom()).unwrap(); + for bytes in [pptx.clone(), thmx(&pptx)] { + let (t, _) = crate::import_theme(&bytes).unwrap(); + assert_eq!(t.name, "Harbour"); + assert_eq!(t.fonts.major.latin, "Lato"); + assert_eq!(t.fonts.minor.latin, "Source Sans 3"); + assert_eq!(t.colors.get(deckcraft_color::SchemeSlot::Accent1), deckcraft_color::Rgba::rgb(0x12, 0x34, 0x56)); + } + } + + #[test] + fn non_theme_packages_are_errors() { + assert!(crate::import_theme(b"not a zip").is_err()); + let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + out.start_file("[Content_Types].xml", zip::write::SimpleFileOptions::default()).unwrap(); + out.write_all(b"").unwrap(); + assert!(crate::import_theme(&out.finish().unwrap().into_inner()).is_err()); + } +} diff --git a/crates/pptx/src/read/mod.rs b/crates/pptx/src/read/mod.rs index 057614eb..41186fb2 100644 --- a/crates/pptx/src/read/mod.rs +++ b/crates/pptx/src/read/mod.rs @@ -179,6 +179,46 @@ fn max_cnvpr(el: &El, m: &mut u32) { } } +/// The theme of a `.thmx` theme file (its `officeDocument` is a theme part), or of the first +/// master of a `.pptx` / `.potx`, with the media its format scheme uses (ids from 1). +pub fn import_theme(bytes: &[u8]) -> Result<(deckcraft_model::theme::Theme, Vec), PptxError> { + let pkg = Package::open(bytes)?; + let target = pkg.rels("").first_of("officeDocument").map(|r| r.target.clone()); + let theme_path = match &target { + Some(t) if pkg.xml(t).is_some_and(|d| d.root.is("theme")) => t.clone(), + _ => { + // A presentation: its first master's theme. + let pres = target.unwrap_or_else(|| "ppt/presentation.xml".into()); + let pres_doc = load(&pkg, &pres).ok_or_else(|| PptxError::NotPptx("neither a theme nor a presentation".into()))?; + let pres_part = Part::new(&pkg, &pres, &pres_doc); + let master = pres_doc + .root + .child("sldMasterIdLst") + .and_then(|l| l.children_named("sldMasterId").next()) + .and_then(|m| pres_part.rels.target(m.attr("r:id").unwrap_or("")).map(String::from)) + .or_else(|| pres_part.rels.all_of("slideMaster").next().map(|r| r.target.clone())) + .ok_or_else(|| PptxError::NotPptx("the presentation has no slide master".into()))?; + pkg.rels(&master).first_of("theme").map(|r| r.target.clone()).ok_or_else(|| PptxError::NotPptx("the slide master has no theme".into()))? + } + }; + let doc = load(&pkg, &theme_path).ok_or_else(|| PptxError::NotPptx(format!("missing or unreadable {theme_path}")))?; + if !doc.root.is("theme") { + return Err(PptxError::NotPptx(format!("{theme_path} is not a theme part"))); + } + let mut imp = Imp { + pkg: &pkg, + media: vec![], + media_by_part: HashMap::new(), + next: 0, + slide_by_part: HashMap::new(), + custom_show_names: HashMap::new(), + table_style_ids: Vec::new(), + }; + let part = Part::new(&pkg, &theme_path, &doc); + let theme = theme::read_theme(&mut imp, &part, &doc.root); + Ok((theme, imp.media)) +} + pub fn import(bytes: &[u8]) -> Result { let pkg = Package::open(bytes)?; let root_rels = pkg.rels(""); diff --git a/crates/ui-egui/src/browsetheme_tests.rs b/crates/ui-egui/src/browsetheme_tests.rs new file mode 100644 index 00000000..cb65ec59 --- /dev/null +++ b/crates/ui-egui/src/browsetheme_tests.rs @@ -0,0 +1,49 @@ +//! Design › Browse for Themes…: picking a .pptx applies its theme. + +use std::cell::RefCell; +use std::rc::Rc; + +use deckcraft_engine::Session; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +#[test] +fn browse_button_applies_the_picked_files_theme() { + let dir = std::env::temp_dir().join(format!("deckcraft-thmx-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let mut brand = Session::with_new(); + brand.execute("design.fonts", &json!({"major": "Lato", "minor": "Lato"})).unwrap(); + let path = dir.join("Brand.pptx"); + brand.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); + let asked: Rc>> = Rc::default(); + let log = asked.clone(); + let p2 = path.to_string_lossy().to_string(); + let services = Services { + pick_open: Some(Box::new(move |purpose: &str| { + log.borrow_mut().push(purpose.to_string()); + Some(p2.clone()) + })), + read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), + ..Default::default() + }; + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "title", "title": "Hello"})).unwrap(); + let mut app = SlideApp::new(s, services); + app.ui.tab = "Design".into(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h.get_by_label("Browse for Themes…").click(); + h.run_steps(3); + assert_eq!(asked.borrow().as_slice(), ["theme"]); + assert_eq!(h.state().session.active().unwrap().doc.masters[0].theme.fonts.major.latin, "Lato"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec97e6e6..1aba5ac1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -167,6 +167,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.insertVideoDialog", "Video from File…", None, "{}"), ("app.saveAsDialog", "Save As…", None, "{}"), ("app.exportDialog", "Export…", None, "{}"), + ("app.browseThemeDialog", "Browse for Themes…", None, "{}"), ("app.dialog", "Open Dialog", None, "{id}"), ("app.links", "Community Links", None, "{}"), ]; @@ -541,6 +542,17 @@ impl SlideApp { "app.insertVideoDialog" => self.pick_and_insert("video", "insert.video"), "app.saveAsDialog" => self.save_as_dialog("deckcraft"), "app.exportDialog" => self.dialog = Some(dialogs::Dialog::new("export")), + "app.browseThemeDialog" => { + if self.session.active().is_none() { + return Err("open a presentation first".into()); + } + if self.services.pick_open.is_some() { + self.pick_and_insert("theme", "design.browseTheme"); + } else { + // The browser's asynchronous picker would open the file as a new document. + self.set_status("Browse for Themes needs the desktop app for now".to_string()); + } + } "app.dialog" => { let d = p.get("id").and_then(Value::as_str).ok_or("missing `id`")?; self.dialog = Some(dialogs::Dialog::new(d)); @@ -1127,6 +1139,8 @@ fn js_now() -> f64 { js_sys::Date::now() } +#[cfg(test)] +mod browsetheme_tests; #[cfg(test)] mod ime_tests; diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..81322839 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1232,6 +1232,9 @@ fn design(app: &mut SlideApp, ui: &mut Ui) { { run(app, "design.theme", json!({"name": n})); } + if small_button(ui, Icon::Themes, "Browse for Themes…", enabled(app, "design.browseTheme"), false).clicked() { + run(app, "app.browseThemeDialog", json!({})); + } }); group(ui, |ui| { let r = big_button(ui, Icon::Colors, "Colors", true); @@ -2398,6 +2401,10 @@ fn slide_master(app: &mut SlideApp, ui: &mut Ui) { run(app, "design.theme", json!({"name": th.name})); } } + ui.separator(); + if ui.button("Browse for Themes…").clicked() { + run(app, "app.browseThemeDialog", json!({})); + } }); big(app, ui, Icon::FormatBackground, "Background\nStyles", "view.pane", json!({"pane": "background"})); }); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 5827b396..972630ac 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -121,6 +121,10 @@ pub fn small_button_ex(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, sele let main_rect = Rect::from_min_max(rect.min, pos2(rect.max.x - arrow_w, rect.max.y)); let main_resp = if split { ui.interact(main_rect, resp.id.with("m"), if enabled { Sense::click() } else { Sense::hover() }) } else { resp.clone() }; + // Screen readers (and UI tests) find the button by its text. + if !label.is_empty() { + main_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, label)); + } let arrow_resp = split.then(|| { ui.interact( Rect::from_min_max(pos2(rect.max.x - arrow_w, rect.min.y), rect.max), diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 2a3d450c..1227f986 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -3,13 +3,13 @@ > **Generated:** 2026-10-10 (summary by `cargo xtask parity`; rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) The summary between ` -**Weighted breadth parity: 79%** over 191 features. +**Weighted breadth parity: 80%** over 191 features. | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| | P0 | 72 | 61 | 11 | 0 | 92% | | P1 | 65 | 38 | 21 | 6 | 75% | -| P2 | 54 | 13 | 15 | 26 | 38% | +| P2 | 54 | 14 | 15 | 25 | 40% | | Area | Parity | |---|---| @@ -17,7 +17,7 @@ The summary between ` | Files | 71% | | Slides | 90% | | Views | 90% | -| Masters & themes | 85% | +| Masters & themes | 88% | | Shapes | 88% | | Format | 83% | | Arrange | 100% | @@ -102,7 +102,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Masters & themes | Hide background graphics | P1 | D | | Masters & themes | Slide size (16:9, 4:3, presets, custom) | P0 | D | | Masters & themes | Header & footer (date, slide number, footer) | P1 | D | -| Masters & themes | Browse themes from .pptx/.thmx | P2 | M | +| Masters & themes | Browse themes from .pptx/.thmx | P2 | D | | Masters & themes | Designer layout suggestions (offline) | P2 | M | | Shapes | Preset shapes (lines, rectangles, basic, arrows, equation, flowchart, stars, callouts) | P0 | D | | Shapes | Action buttons | P2 | D |