mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
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:
@@ -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"])
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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"}));
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user