Design: Browse for Themes from a .thmx, .pptx or .potx file

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 <cursoragent@cursor.com>
This commit is contained in:
Nikita Rakitin
2026-10-13 23:04:41 +03:00
co-authored by Cursor
parent 457406a7a5
commit 30c2fe4b6d
9 changed files with 277 additions and 4 deletions
+1
View File
@@ -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"])
+99
View File
@@ -15,6 +15,15 @@ use crate::{Result, Session, Target};
pub fn specs() -> Vec<CommandSpec> {
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<Value> {
Ok(json!({"theme": name}))
}
fn browse_theme(s: &mut Session, p: &Value) -> Result<Value> {
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<u32, u32>) -> Result<deckcraft_model::theme::Theme> {
fn rec(v: &mut Value, map: &std::collections::HashMap<u32, u32>, 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());
}
}
+59
View File
@@ -49,6 +49,11 @@ pub fn import(bytes: &[u8]) -> Result<Presentation, PptxError> {
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<deckcraft_model::MediaItem>), PptxError> {
read::import_theme(bytes)
}
/// Write a `.pptx` package.
pub fn export(p: &Presentation) -> Result<Vec<u8>, 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<u8> {
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#"<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/theme/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/></Types>"#).unwrap();
out.start_file("_rels/.rels", o).unwrap();
out.write_all(br#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="theme/theme/theme1.xml"/></Relationships>"#).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"<Types/>").unwrap();
assert!(crate::import_theme(&out.finish().unwrap().into_inner()).is_err());
}
}
+40
View File
@@ -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<MediaItem>), 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<Presentation, PptxError> {
let pkg = Package::open(bytes)?;
let root_rels = pkg.rels("");
+49
View File
@@ -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<RefCell<Vec<String>>> = 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);
}
+14
View File
@@ -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;
+7
View File
@@ -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"}));
});
+4
View File
@@ -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),
+4 -4
View File
@@ -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 `<!-- SUMMARY -->
**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 `<!-- SUMMARY -->
| 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 |