Merge PR #610 from NikitkaRa: SmartArt: shrink text to fit, picture layouts, 3-D styles and more colours

This commit is contained in:
Brandon Thomas
2026-10-14 13:26:24 -04:00
6 changed files with 431 additions and 37 deletions
+246 -18
View File
@@ -23,7 +23,7 @@ pub const MAX_LEVEL: u8 = 8;
const MAX_SHAPES: usize = 40;
/// DeckCraft's layouts, with the names PowerPoint's gallery gives them.
pub const LAYOUTS: [(&str, &str); 15] = [
pub const LAYOUTS: [(&str, &str); 18] = [
("list", "Vertical Bullet List"),
("blockList", "Basic Block List"),
("hBulletList", "Horizontal Bullet List"),
@@ -39,19 +39,41 @@ pub const LAYOUTS: [(&str, &str); 15] = [
("invertedPyramid", "Inverted Pyramid"),
("matrix", "Basic Matrix"),
("titledMatrix", "Titled Matrix"),
("pictureList", "Vertical Picture List"),
("pictureCaption", "Picture Caption List"),
("pictureProcess", "Picture Accent Process"),
];
/// SmartArt Styles.
pub const STYLES: [(&str, &str); 4] =
[("flat", "Simple Fill"), ("subtle", "Subtle Effect"), ("intense", "Intense Effect"), ("outline", "White Outline")];
pub const STYLES: [(&str, &str); 9] = [
("flat", "Simple Fill"),
("subtle", "Subtle Effect"),
("intense", "Intense Effect"),
("outline", "White Outline"),
("polished", "Polished"),
("inset", "Inset"),
("cartoon", "Cartoon"),
("powder", "Powder"),
("brick", "Brick Scene"),
];
/// Change Colors.
pub const COLORS: [(&str, &str); 7] = [
pub const COLORS: [(&str, &str); 17] = [
("colorful", "Colorful"),
("range23", "Colorful Range - Accent Colors 2 to 3"),
("range34", "Colorful Range - Accent Colors 3 to 4"),
("range45", "Colorful Range - Accent Colors 4 to 5"),
("range56", "Colorful Range - Accent Colors 5 to 6"),
("accent1", "Accent 1"),
("accent2", "Accent 2"),
("accent3", "Accent 3"),
("accent4", "Accent 4"),
("accent5", "Accent 5"),
("accent6", "Accent 6"),
("accent1Grad", "Gradient Range - Accent 1"),
("accent2Grad", "Gradient Range - Accent 2"),
("accent3Grad", "Gradient Range - Accent 3"),
("accent4Grad", "Gradient Range - Accent 4"),
("accent5Grad", "Gradient Range - Accent 5"),
("accent6Grad", "Gradient Range - Accent 6"),
];
pub fn is_kind(k: &str) -> bool {
@@ -103,16 +125,25 @@ pub fn specs() -> Vec<CommandSpec> {
"Change Layout",
["SmartArt Design", "Layouts"],
None,
"{id?, kind: list|blockList|hBulletList|process|chevron|verticalProcess|stepUp|cycle|blockCycle|radial|hierarchy|pyramid|invertedPyramid|matrix|titledMatrix}",
"{id?, kind: list|blockList|hBulletList|process|chevron|verticalProcess|stepUp|cycle|blockCycle|radial|hierarchy|pyramid|invertedPyramid|matrix|titledMatrix|pictureList|pictureCaption|pictureProcess}",
has_selection,
change_layout
),
cmd!(
"smartArt.picture",
"Insert Picture in SmartArt",
["SmartArt Design", "Create Graphic"],
None,
"{id?, index, data?: base64 | path?: string | reset?: bool} (the node's picture placeholder in picture layouts)",
has_selection,
set_picture
),
cmd!(
"smartArt.style",
"SmartArt Styles",
["SmartArt Design", "SmartArt Styles"],
None,
"{id?, style: flat|subtle|intense|outline}",
"{id?, style: flat|subtle|intense|outline|polished|inset|cartoon|powder|brick}",
has_selection,
change_style
),
@@ -121,7 +152,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Change Colors",
["SmartArt Design", "SmartArt Styles"],
None,
"{id?, colors: colorful|accent1|…|accent6}",
"{id?, colors: colorful|range23|range34|range45|range56|accent1|…|accent6|accent1Grad|…|accent6Grad}",
has_selection,
change_colors
),
@@ -135,7 +166,7 @@ pub fn nodes_from_lines<'a>(lines: impl IntoIterator<Item = &'a str>) -> Vec<Dia
.filter(|l| !l.trim().is_empty())
.map(|l| {
let tabs = l.chars().take_while(|c| *c == '\t').count();
DiagramNode { text: l.trim().to_string(), level: u8::try_from(tabs).unwrap_or(MAX_LEVEL).min(MAX_LEVEL) }
DiagramNode { text: l.trim().to_string(), level: u8::try_from(tabs).unwrap_or(MAX_LEVEL).min(MAX_LEVEL), picture: None }
})
.collect()
}
@@ -167,7 +198,7 @@ fn subtree_end(nodes: &[DiagramNode], i: usize) -> usize {
/// new node's index.
pub fn add(nodes: &mut Vec<DiagramNode>, index: Option<usize>, position: &str, text: &str) -> usize {
let Some(i) = index.filter(|i| *i < nodes.len()) else {
nodes.push(DiagramNode { text: text.into(), level: 0 });
nodes.push(DiagramNode { text: text.into(), level: 0, picture: None });
return nodes.len() - 1;
};
let lv = nodes[i].level;
@@ -183,7 +214,7 @@ pub fn add(nodes: &mut Vec<DiagramNode>, index: Option<usize>, position: &str, t
}
_ => (subtree_end(nodes, i), lv),
};
nodes.insert(at, DiagramNode { text: text.into(), level });
nodes.insert(at, DiagramNode { text: text.into(), level, picture: None });
at
}
@@ -272,19 +303,49 @@ fn slot(i: usize) -> SchemeSlot {
/// The accent shape `i` is drawn in under Change Colors: `colorful` cycles the accents, `accentN`
/// keeps one.
fn accent(d: &Diagram, i: usize) -> SchemeSlot {
match d.colors.strip_prefix("accent").and_then(|n| n.parse::<usize>().ok()) {
let digit = |s: &str, k: usize| s.chars().nth(k).and_then(|c| c.to_digit(10)).map(|n| n as usize);
if let Some(r) = d.colors.strip_prefix("range")
&& let Some(a @ 1..=6) = digit(r, 0)
{
// Colorful Range: alternate between two neighbouring accents.
return slot(a - 1 + i % 2);
}
match d.colors.strip_prefix("accent").and_then(|r| digit(r, 0)) {
Some(n @ 1..=6) => slot(n - 1),
_ => slot(i),
}
}
/// Gradient Range: each shape a step darker than the one before (the steps cycle).
fn shade(d: &Diagram, i: usize) -> Option<ColorTransform> {
d.colors.ends_with("Grad").then(|| ColorTransform::LumMod(100_000 - (i % 4) as i32 * 15_000))
}
/// The 3-D XML (`scene3d` + `sp3d`) for a 3-D SmartArt Style: a top bevel (`w`, `h` in points)
/// lit by the three-point rig.
fn three_d(bevel: &str, w: f64, h: f64) -> String {
format!(
"<a:scene3d><a:camera prst=\"orthographicFront\"/><a:lightRig rig=\"threePt\" dir=\"t\"/></a:scene3d><a:sp3d prstMaterial=\"matte\"><a:bevelT w=\"{}\" h=\"{}\" prst=\"{bevel}\"/></a:sp3d>",
(w * 12700.0).round() as i64,
(h * 12700.0).round() as i64
)
}
/// Whether `style` is one of the 3-D SmartArt Styles.
pub fn is_3d(style: &str) -> bool {
matches!(style, "polished" | "inset" | "cartoon" | "powder" | "brick")
}
/// Fill, outline, text colour and effects for the SmartArt style. `light` shapes (the bullet
/// boxes of the bullet lists) are a pale tint of the accent with dark text.
fn paint(d: &Diagram, sh: &mut Shape, i: usize, light: bool) {
let a = accent(d, i);
let base = ColorRef::scheme(a);
let base = match shade(d, i) {
Some(t) => ColorRef::scheme(a).with(t),
None => ColorRef::scheme(a),
};
let tint = |pct: i32| ColorRef::scheme(a).with(ColorTransform::LumMod(100_000 - pct * 1000)).with(ColorTransform::LumOff(pct * 1000));
let dark_text = light || d.style == "outline";
let dark_text = light || matches!(d.style.as_str(), "outline" | "powder");
sh.style = Some(ShapeStyle::accent(a));
match d.style.as_str() {
_ if light => {
@@ -300,6 +361,23 @@ fn paint(d: &Diagram, sh: &mut Shape, i: usize, light: bool) {
sh.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.5));
sh.effects = Some(Effects { outer_shadow: Some(shadow(4.0, 3.0)), ..Default::default() });
}
"polished" | "inset" | "cartoon" | "powder" | "brick" => {
let (bevel, bw, line, fill) = match d.style.as_str() {
"polished" => ("circle", 12.0, Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 0.75)), base.clone()),
"inset" => ("relaxedInset", 10.0, None, base.clone()),
"cartoon" => ("softRound", 8.0, Some(Line::solid(ColorRef::scheme(SchemeSlot::Tx1), 2.0)), base.clone()),
"powder" => ("convex", 14.0, None, tint(40)),
_ => ("slope", 6.0, Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.0)), base.clone()),
};
sh.fill = Some(Fill::solid(fill));
sh.line = Some(line.unwrap_or_else(|| Line { fill: Some(Fill::None), ..Default::default() }));
sh.effects = Some(Effects {
outer_shadow: (d.style != "inset").then(|| shadow(6.0, 4.0)),
bevel: Some(bevel.into()),
raw3d: Some(three_d(bevel, bw, bw * 0.6)),
..Default::default()
});
}
"intense" => {
sh.fill = Some(Fill::Gradient(Gradient {
stops: vec![
@@ -403,8 +481,73 @@ pub fn layout(d: &Diagram, area: Xfrm) -> Vec<Shape> {
layout_items(d, area).into_iter().map(|(s, _)| s).collect()
}
/// Lay out `d`, painted with its style and colours, each shape with the nodes it shows.
/// Lay out `d`, painted with its style and colours, each shape with the nodes it shows; text is
/// shrunk per shape so its longest word fits on a line.
fn layout_items(d: &Diagram, area: Xfrm) -> Vec<Item> {
let mut v = layout_raw(d, area);
for (sh, _) in &mut v {
fit_words(sh);
}
v
}
/// Rough advance of a character as a fraction of the font size (a sans text face).
const CHAR_W: f64 = 0.6;
/// Smallest font scale shrinking to fit goes to.
const MIN_SCALE: f64 = 0.25;
/// Shrink the shape's text (all paragraphs together, as PowerPoint's per-shape font size does) so
/// no word is wider than its line and would break mid-word. Overflow in height is left to the
/// body's shrink-on-overflow autofit.
fn fit_words(sh: &mut Shape) {
let Some(w) = sh.xfrm.map(|x| x.w) else { return };
let Some(body) = &mut sh.text else { return };
let avail = w - body.body.inset_l.unwrap_or(7.2) - body.body.inset_r.unwrap_or(7.2);
let mut scale: f64 = 1.0;
for p in &body.paragraphs {
let line = avail - p.props.margin_left.unwrap_or(0.0).max(0.0);
for r in &p.runs {
let size = r.props.size.unwrap_or(18.0);
let word = r.text.split_whitespace().map(|t| t.chars().count()).max().unwrap_or(0) as f64;
let need = word * size * CHAR_W;
if need > 0.0 {
scale = scale.min(line.max(0.0) / need);
}
}
}
let scale = scale.max(MIN_SCALE);
if scale >= 0.999 {
return;
}
let shrink = |v: &mut Option<f64>| {
if let Some(x) = v {
*x = (*x * scale * 2.0).floor() / 2.0;
}
};
for p in &mut body.paragraphs {
for r in &mut p.runs {
shrink(&mut r.props.size);
}
shrink(&mut p.end_props.size);
shrink(&mut p.props.margin_left);
if let Some(i) = &mut p.props.indent {
*i *= scale;
}
}
}
/// A picture placeholder for node `node`: its picture when it has one, else a pale box.
fn picture_shape(d: &Diagram, preset: &str, x: Xfrm, i: usize, node: usize) -> Shape {
let mut sh = Shape { name: "SmartArt picture".into(), xfrm: Some(x), geom: Geom::preset(preset), ..Default::default() };
paint(d, &mut sh, i, true);
if let Some(m) = d.nodes.get(node).and_then(|n| n.picture) {
sh.fill = Some(Fill::Picture(deckcraft_model::style::PictureFill { media: m, ..Default::default() }));
sh.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.5));
}
sh
}
fn layout_raw(d: &Diagram, area: Xfrm) -> Vec<Item> {
let gs = groups(d);
let nodes = |g: &[usize]| -> Vec<&DiagramNode> { g.iter().filter_map(|i| d.nodes.get(*i)).collect() };
let n = gs.len().max(1);
@@ -564,10 +707,15 @@ fn layout_items(d: &Diagram, area: Xfrm) -> Vec<Item> {
paths: vec![deckcraft_model::CustomPath { w, h: bh, d: path, fill: deckcraft_geom::preset::FillMode::Norm, stroke: true }],
};
// Keep the text inside the sloped sides: inset it to a bit past the band's mid width.
let inset = ((it + ib) / 2.0 + (it - ib).abs() / 4.0 + 4.0).min(w * 0.2);
// The tip band is a triangle: its text sits at the wide end, inset to the width there.
let tip = wt.min(wb) < 1e-6;
let inset = if tip { w * 0.22 } else { (it + ib) / 2.0 + (it - ib).abs() / 4.0 + 4.0 };
if let Some(b) = &mut sh.text {
b.body.inset_l = Some(inset);
b.body.inset_r = Some(inset);
if tip {
b.body.anchor = Some(if inverted { deckcraft_model::text::Anchor::Top } else { deckcraft_model::text::Anchor::Bottom });
}
}
paint(d, &mut sh, i, false);
out.push((sh, g.clone()));
@@ -590,6 +738,46 @@ fn layout_items(d: &Diagram, area: Xfrm) -> Vec<Item> {
out.push((sh, t.clone()));
}
}
"pictureList" => {
// Vertical Picture List: a picture square at the left of each row, the text beside it.
let h = area.h / nf;
let bh = h * 0.88;
for (i, g) in gs.iter().enumerate() {
let y = area.y + i as f64 * h;
let s = bh.min(area.w * 0.3);
out.push((picture_shape(d, "rect", Xfrm::new(area.x, y, s, bh), i, g[0]), vec![]));
titled(&mut out, "roundRect", Xfrm::new(area.x + s + bh * 0.08, y, (area.w - s - bh * 0.08).max(1.0), bh), i, g);
}
}
"pictureCaption" | "pictureProcess" => {
// Picture Caption List: a picture over each caption, in rows. Picture Accent Process: one
// row, arrows between the steps.
let process = d.kind == "pictureProcess";
let cols = if process { n } else { ((nf * area.w / area.h.max(1.0) / 1.2).sqrt().ceil() as usize).clamp(1, n) };
let rows = n.div_ceil(cols);
let gap = if process { area.w * 0.05 } else { 0.0 };
let w = (area.w - gap * (cols as f64 - 1.0)) / cols as f64;
let h = area.h / rows as f64;
let bw = if process { w } else { w * 0.9 };
let ch = (h * 0.9).min(bw * 1.1);
for (i, g) in gs.iter().enumerate() {
let (c, r) = (i % cols, i / cols);
let in_row = if r + 1 == rows { n - r * cols } else { cols };
let x0 = area.x + (area.w - in_row as f64 * w - gap * (in_row as f64 - 1.0)) / 2.0;
let x = x0 + c as f64 * (w + gap) + (w - bw) / 2.0;
let y = area.y + r as f64 * h + (h - ch) / 2.0;
let ph = ch * 0.62;
out.push((
picture_shape(d, if process { "ellipse" } else { "rect" }, Xfrm::new(x + (bw - ph.min(bw)) / 2.0, y, ph.min(bw), ph), i, g[0]),
vec![],
));
titled(&mut out, if process { "roundRect" } else { "rect" }, Xfrm::new(x, y + ph + ch * 0.03, bw, ch * 0.35), i, g);
if process && i + 1 < n {
let a = (gap * 0.7).min(ch * 0.2);
arrow(&mut out, Xfrm::new(x + bw + (gap - a) / 2.0, y + ph / 2.0 - a / 2.0, a, a), 0.0, i);
}
}
}
_ => {}
}
out
@@ -751,7 +939,7 @@ fn index(p: &Value, cmd: &str) -> Result<usize> {
pub fn insert(s: &mut Session, kind: &str, mut nodes: Vec<DiagramNode>) -> Result<Value> {
let kind = if is_kind(kind) { kind } else { "process" };
if nodes.is_empty() {
nodes = (0..3).map(|_| DiagramNode { text: "Text".into(), level: 0 }).collect();
nodes = (0..3).map(|_| DiagramNode { text: "Text".into(), level: 0, picture: None }).collect();
}
normalize(&mut nodes);
let d = Diagram { kind: kind.into(), nodes, ..Default::default() };
@@ -834,6 +1022,26 @@ fn demote(s: &mut Session, p: &Value) -> Result<Value> {
edit_diagram(s, p, CMD, |d| if demote_node(&mut d.nodes, i) { Ok(Value::Null) } else { Err(bad(CMD, "nothing above to demote under")) })
}
fn set_picture(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "smartArt.picture";
let i = index(p, CMD)?;
let media = if bool_or(p, "reset", false) {
None
} else {
let (name, bytes) = super::insert::media_bytes(p, CMD)?;
let ct = super::insert::content_type(&name, &bytes);
target_group(s, p, CMD)?;
Some(s.edit(|doc, _| Ok(doc.add_media(&name, ct, bytes)))?)
};
edit_diagram(s, p, CMD, |d| {
// A sub-point's picture is its top-level node's: that's the node the placeholder shows.
let top = (0..=i.min(d.nodes.len().saturating_sub(1))).rev().find(|k| d.nodes.get(*k).is_some_and(|n| n.level == 0)).unwrap_or(i);
let n = d.nodes.get_mut(top).ok_or_else(|| bad(CMD, "no such node"))?;
n.picture = media;
Ok(Value::Null)
})
}
fn change_style(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "smartArt.style";
let v = str_param(p, "style").filter(|k| STYLES.iter().any(|(id, _)| id == k)).ok_or_else(|| bad(CMD, "unknown style"))?.to_string();
@@ -880,7 +1088,7 @@ mod tests {
n.iter().map(|x| x.level).collect()
}
fn mk(levels: &[u8]) -> Vec<DiagramNode> {
levels.iter().enumerate().map(|(i, l)| DiagramNode { text: format!("n{i}"), level: *l }).collect()
levels.iter().enumerate().map(|(i, l)| DiagramNode { text: format!("n{i}"), level: *l, picture: None }).collect()
}
#[test]
@@ -934,7 +1142,7 @@ mod tests {
fn every_layout_shows_every_node_inside_its_area() {
let area = Xfrm::new(50.0, 40.0, 800.0, 400.0);
for (kind, _) in LAYOUTS {
for style in ["", "subtle", "intense", "outline"] {
for style in std::iter::once("").chain(STYLES.iter().map(|(k, _)| *k)) {
let d = Diagram {
kind: kind.into(),
nodes: mk(&[0, 1, 0, 0, 1, 0]),
@@ -956,4 +1164,24 @@ mod tests {
}
}
}
#[test]
fn long_words_shrink_to_fit_their_shape() {
let d = Diagram { kind: "pyramid".into(), nodes: nodes_from_lines(["Internationalisation", "Build", "Test"]), ..Default::default() };
let items = layout_items(&d, Xfrm::new(0.0, 0.0, 600.0, 300.0));
let size = |k: usize| items[k].0.text.as_ref().and_then(|b| b.paragraphs[0].runs[0].props.size).unwrap_or(0.0);
assert!(size(0) < 20.0 && size(0) >= 20.0 * MIN_SCALE - 0.5, "the tip's long word shrinks: {}", size(0));
assert!((size(1) - 20.0).abs() < 1e-9, "short words keep their size");
}
#[test]
fn colour_variants_pick_accents() {
let d = |c: &str| Diagram { colors: c.into(), ..Default::default() };
assert_eq!(
(accent(&d("range45"), 0), accent(&d("range45"), 1), accent(&d("range45"), 2)),
(SchemeSlot::Accent4, SchemeSlot::Accent5, SchemeSlot::Accent4)
);
assert_eq!(accent(&d("accent3Grad"), 4), SchemeSlot::Accent3);
assert!(shade(&d("accent3Grad"), 1).is_some() && shade(&d("accent3"), 1).is_none());
}
}
+45
View File
@@ -1769,3 +1769,48 @@ fn smartart_round_trips_through_pptx_as_a_diagram() {
let d = again.slides[1].shapes.iter().find_map(|x| x.diagram.clone()).unwrap();
assert_eq!(d.nodes.last().map(|n| (n.text.as_str(), n.level)), Some(("COO", 1)));
}
#[test]
fn smartart_pictures_3d_styles_and_colour_variants() {
let mut s = session();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
s.execute("insert.smartArt", &json!({"kind": "pictureCaption", "items": ["Lakes", "\tBlue", "Hills"]})).unwrap();
let pics = |s: &Session| -> Vec<deckcraft_model::Shape> {
let st = s.doc().unwrap();
let g = st.doc.slides[1].shapes.iter().find(|x| x.diagram.is_some()).unwrap().clone();
g.children().iter().filter(|c| c.name == "SmartArt picture").cloned().collect()
};
assert_eq!(pics(&s).len(), 2, "one picture placeholder per top-level node");
assert!(pics(&s).iter().all(|p| !matches!(p.fill, Some(deckcraft_model::Fill::Picture(_)))));
// A 1×1 PNG put in through a sub-point lands in its top-level node.
let png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
s.execute("smartArt.picture", &json!({"index": 1, "name": "p.png", "data": png})).unwrap();
let p = pics(&s);
assert!(matches!(p[0].fill, Some(deckcraft_model::Fill::Picture(_))));
assert!(!matches!(p[1].fill, Some(deckcraft_model::Fill::Picture(_))));
// Text edits keep the picture; reset removes it.
s.execute("smartArt.addNode", &json!({"text": "Woods"})).unwrap();
assert!(matches!(pics(&s)[0].fill, Some(deckcraft_model::Fill::Picture(_))));
assert_eq!(pics(&s).len(), 3);
// The picture is written to the .pptx drawing.
let bytes = deckcraft_pptx::export(s.doc().unwrap().doc.as_ref()).unwrap();
let back = deckcraft_pptx::import(&bytes).unwrap();
assert!(back.validate().is_empty());
assert_eq!(back.slides[1].shapes.iter().find_map(|x| x.diagram.as_ref()).map(|d| d.kind.clone()).as_deref(), Some("pictureCaption"));
s.execute("smartArt.picture", &json!({"index": 0, "reset": true})).unwrap();
assert!(pics(&s).iter().all(|p| !matches!(p.fill, Some(deckcraft_model::Fill::Picture(_)))));
// 3-D styles carry a lit bevel; colour variants round trip through .pptx.
s.execute("smartArt.style", &json!({"style": "cartoon"})).unwrap();
let st = s.doc().unwrap();
let g = st.doc.slides[1].shapes.iter().find(|x| x.diagram.is_some()).unwrap().clone();
let raw = g.children().iter().find_map(|c| c.effects.as_ref().and_then(|e| e.raw3d.clone())).expect("3-D XML");
assert!(raw.contains("scene3d") && raw.contains("softRound"));
for (style, colors) in [("polished", "range34"), ("powder", "accent5Grad"), ("brick", "accent2")] {
s.execute("smartArt.style", &json!({"style": style})).unwrap();
s.execute("smartArt.colors", &json!({"colors": colors})).unwrap();
let back = deckcraft_pptx::import(&deckcraft_pptx::export(s.doc().unwrap().doc.as_ref()).unwrap()).unwrap();
let d = back.slides[1].shapes.iter().find_map(|x| x.diagram.clone()).unwrap();
assert_eq!((d.style.as_str(), d.colors.as_str()), (style, colors));
}
}
+3
View File
@@ -369,6 +369,9 @@ pub struct Diagram {
pub struct DiagramNode {
pub text: String,
pub level: u8,
/// The picture shown in the node's picture placeholder (picture layouts).
#[serde(skip_serializing_if = "Option::is_none")]
pub picture: Option<MediaId>,
}
impl Default for Shape {
+103 -17
View File
@@ -28,6 +28,11 @@ pub fn qs_id(style: &str) -> String {
"subtle" => "simple3",
"intense" => "simple5",
"outline" => "simple4",
"polished" => "3d1",
"inset" => "3d2",
"cartoon" => "3d3",
"powder" => "3d4",
"brick" => "3d5",
_ => "simple1",
};
format!("{QS_BASE}{s}")
@@ -38,6 +43,11 @@ pub fn style_for(qs: &str) -> &'static str {
match qs.rsplit('/').next().unwrap_or("") {
"simple1" | "" => "",
"simple4" => "outline",
"3d1" => "polished",
"3d2" => "inset",
"3d3" => "cartoon",
"3d4" => "powder",
"3d5" => "brick",
"simple2" | "simple3" => "subtle",
_ => "intense",
}
@@ -45,17 +55,37 @@ pub fn style_for(qs: &str) -> &'static str {
/// PowerPoint's colour definition for Change Colors.
pub fn cs_id(colors: &str) -> String {
match colors.strip_prefix("accent").and_then(|n| n.parse::<u8>().ok()) {
Some(n @ 1..=6) => format!("{CS_BASE}accent{n}_2"),
if let Some(a @ 2..=5) = colors.strip_prefix("range").and_then(|r| r.get(..1)).and_then(|n| n.parse::<u8>().ok()) {
return format!("{CS_BASE}colorful{a}");
}
let grad = colors.ends_with("Grad");
match colors.strip_prefix("accent").and_then(|r| r.get(..1)).and_then(|n| n.parse::<u8>().ok()) {
Some(n @ 1..=6) => format!("{CS_BASE}accent{n}_{}", if grad { 3 } else { 2 }),
_ => format!("{CS_BASE}colorful1"),
}
}
/// The accents a Change Colors choice fills shapes with, in turn.
fn fill_accents(colors: &str) -> Vec<String> {
if let Some(a @ 2..=5) = colors.strip_prefix("range").and_then(|r| r.get(..1)).and_then(|n| n.parse::<u8>().ok()) {
return vec![format!("accent{a}"), format!("accent{}", a + 1)];
}
match colors.strip_prefix("accent").and_then(|r| r.get(..1)).and_then(|n| n.parse::<u8>().ok()) {
Some(n @ 1..=6) => vec![format!("accent{n}")],
_ => (1..=6).map(|k| format!("accent{k}")).collect(),
}
}
/// Change Colors from a colour definition id: `accentN_…` keeps accent N, anything else is colourful.
pub fn colors_for(cs: &str) -> String {
let last = cs.rsplit('/').next().unwrap_or("");
match last.strip_prefix("accent").and_then(|r| r.split('_').next()).and_then(|n| n.parse::<u8>().ok()) {
Some(n @ 1..=6) => format!("accent{n}"),
if let Some(a @ 2..=5) = last.strip_prefix("colorful").and_then(|n| n.parse::<u8>().ok()) {
return format!("range{a}{}", a + 1);
}
let mut parts = last.strip_prefix("accent").unwrap_or("").split('_');
match (parts.next().and_then(|n| n.parse::<u8>().ok()), parts.next()) {
(Some(n @ 1..=6), Some("3" | "4" | "5")) => format!("accent{n}Grad"),
(Some(n @ 1..=6), _) => format!("accent{n}"),
_ => String::new(),
}
}
@@ -79,6 +109,9 @@ pub fn layout_id(kind: &str) -> &'static str {
"invertedPyramid" => "urn:microsoft.com/office/officeart/2005/8/layout/pyramid3",
"matrix" => "urn:microsoft.com/office/officeart/2005/8/layout/matrix3",
"titledMatrix" => "urn:microsoft.com/office/officeart/2005/8/layout/matrix1",
"pictureList" => "urn:microsoft.com/office/officeart/2005/8/layout/vList4",
"pictureCaption" => "urn:microsoft.com/office/officeart/2005/8/layout/pList1",
"pictureProcess" => "urn:microsoft.com/office/officeart/2005/8/layout/pList2",
_ => "urn:microsoft.com/office/officeart/2005/8/layout/vList2",
}
}
@@ -102,6 +135,9 @@ pub fn kind_for(layout_id: &str, category: &str) -> &'static str {
"invertedPyramid",
"matrix",
"titledMatrix",
"pictureList",
"pictureCaption",
"pictureProcess",
"list",
] {
if layout_id == layout_id_of(k) {
@@ -138,7 +174,7 @@ fn layout_id_of(kind: &str) -> &'static str {
fn category(kind: &str) -> &'static str {
match kind {
"process" | "chevron" | "verticalProcess" | "stepUp" => "process",
"process" | "chevron" | "verticalProcess" | "stepUp" | "pictureProcess" => "process",
"cycle" | "blockCycle" | "radial" => "cycle",
"hierarchy" => "hierarchy",
"pyramid" | "invertedPyramid" => "pyramid",
@@ -288,7 +324,7 @@ pub fn outline(data: &El) -> Vec<DiagramNode> {
if out.len() >= MAX_NODES || !seen.insert(id) {
continue;
}
out.push(DiagramNode { text: texts.get(id).cloned().unwrap_or_default(), level: level.min(8) });
out.push(DiagramNode { text: texts.get(id).cloned().unwrap_or_default(), level: level.min(8), picture: None });
if let Some(v) = kids.get(id) {
stack.extend(v.iter().rev().map(|(_, c)| (*c, level.saturating_add(1))));
}
@@ -312,7 +348,7 @@ fn paragraphs(t: &El) -> String {
/// The layout algorithm and node shape of a DeckCraft layout.
fn alg(kind: &str) -> (&'static str, Vec<(&'static str, &'static str)>, &'static str) {
match kind {
"process" | "stepUp" => ("lin", vec![("linDir", "fromL"), ("nodeVertAlign", "mid")], "roundRect"),
"process" | "stepUp" | "pictureProcess" | "pictureCaption" => ("lin", vec![("linDir", "fromL"), ("nodeVertAlign", "mid")], "roundRect"),
"chevron" => ("lin", vec![("linDir", "fromL"), ("nodeVertAlign", "mid")], "chevron"),
"verticalProcess" => ("lin", vec![("linDir", "fromT"), ("nodeHorzAlign", "ctr")], "roundRect"),
"cycle" | "radial" => ("cycle", vec![("stAng", "0"), ("spanAng", "360")], "ellipse"),
@@ -450,6 +486,18 @@ pub fn layout_xml(kind: &str) -> Vec<u8> {
w.finish()
}
/// The top bevel (preset, width in EMU) of a 3-D SmartArt Style.
fn bevel(style: &str) -> Option<(&'static str, i64)> {
Some(match style {
"polished" => ("circle", 76200),
"inset" => ("relaxedInset", 63500),
"cartoon" => ("softRound", 50800),
"powder" => ("convex", 101600),
"brick" => ("slope", 50800),
_ => return None,
})
}
/// `ppt/diagrams/quickStyleN.xml`: flat shapes drawn from the theme's first styles.
pub fn style_xml(style: &str) -> Vec<u8> {
let mut w = W::new();
@@ -457,7 +505,7 @@ pub fn style_xml(style: &str) -> Vec<u8> {
w.empty("dgm:title", A::new().a("val", ""));
w.empty("dgm:desc", A::new().a("val", ""));
w.open0("dgm:catLst");
w.empty("dgm:cat", A::new().a("type", "simple").a("pri", "10100"));
w.empty("dgm:cat", A::new().a("type", if bevel(style).is_some() { "3D" } else { "simple" }).a("pri", "10100"));
w.close("dgm:catLst");
w.open0("dgm:scene3d");
w.empty("a:camera", A::new().a("prst", "orthographicFront"));
@@ -468,7 +516,14 @@ pub fn style_xml(style: &str) -> Vec<u8> {
w.empty("a:camera", A::new().a("prst", "orthographicFront"));
w.empty("a:lightRig", A::new().a("rig", "threePt").a("dir", "t"));
w.close("dgm:scene3d");
w.empty0("dgm:sp3d");
match bevel(style) {
Some((prst, w_emu)) => {
w.open0("dgm:sp3d");
w.empty("a:bevelT", A::new().a("w", w_emu.to_string()).a("h", (w_emu * 3 / 5).to_string()).a("prst", prst));
w.close("dgm:sp3d");
}
None => w.empty0("dgm:sp3d"),
}
w.empty("dgm:txPr", A::new());
w.open0("dgm:style");
for (tag, idx) in [("a:lnRef", "2"), ("a:fillRef", "1"), ("a:effectRef", "0")] {
@@ -491,19 +546,26 @@ pub fn colors_xml(colors: &str) -> Vec<u8> {
w.open("dgm:colorsDef", A::new().a("xmlns:dgm", NS_DGM).a("xmlns:a", crate::opc::NS_A).a("uniqueId", cs_id(colors)));
w.empty("dgm:title", A::new().a("val", ""));
w.empty("dgm:desc", A::new().a("val", ""));
let single = colors.starts_with("accent");
let accents = fill_accents(colors);
let single = accents.len() == 1;
let cat = if single { accents.first().map(String::as_str).unwrap_or("accent1") } else { "colorful" };
w.open0("dgm:catLst");
w.empty("dgm:cat", A::new().a("type", if single { colors } else { "colorful" }).a("pri", "11200"));
w.empty("dgm:cat", A::new().a("type", cat).a("pri", "11200"));
w.close("dgm:catLst");
w.open("dgm:styleLbl", A::new().a("name", "node1"));
if single {
if colors.ends_with("Grad") {
// Gradient Range: the accent shaded darker across the shapes.
w.open("dgm:fillClrLst", A::new().a("meth", "repeat"));
w.empty("a:schemeClr", A::new().a("val", colors));
for k in 0..4 {
w.open("a:schemeClr", A::new().a("val", cat));
w.empty("a:lumMod", A::new().a("val", (100_000 - k * 15_000).to_string()));
w.close("a:schemeClr");
}
w.close("dgm:fillClrLst");
} else {
w.open("dgm:fillClrLst", A::new().a("meth", "repeat"));
for k in 1..=6 {
w.empty("a:schemeClr", A::new().a("val", format!("accent{k}")));
for a in &accents {
w.empty("a:schemeClr", A::new().a("val", a.as_str()));
}
w.close("dgm:fillClrLst");
}
@@ -524,7 +586,7 @@ mod tests {
use super::*;
fn nodes(v: &[(&str, u8)]) -> Vec<DiagramNode> {
v.iter().map(|(t, l)| DiagramNode { text: (*t).into(), level: *l }).collect()
v.iter().map(|(t, l)| DiagramNode { text: (*t).into(), level: *l, picture: None }).collect()
}
#[test]
@@ -536,11 +598,35 @@ mod tests {
assert_eq!(outline(&doc.root), d.nodes);
let s = String::from_utf8(xml).unwrap();
assert!(s.contains("hierarchy1") && s.contains("relId=\"rId9\""));
for x in [layout_xml("hierarchy"), layout_xml("cycle"), style_xml("subtle"), colors_xml(""), colors_xml("accent3")] {
for x in [
layout_xml("hierarchy"),
layout_xml("cycle"),
style_xml("subtle"),
colors_xml(""),
colors_xml("accent3"),
style_xml("polished"),
colors_xml("range23"),
colors_xml("accent6Grad"),
layout_xml("pictureList"),
] {
assert!(crate::xml::parse(&x).is_ok());
}
}
#[test]
fn style_and_colour_ids_round_trip() {
for st in ["", "subtle", "intense", "outline", "polished", "inset", "cartoon", "powder", "brick"] {
assert_eq!(style_for(&qs_id(st)), st);
}
for c in ["", "range23", "range56", "accent1", "accent4Grad"] {
assert_eq!(colors_for(&cs_id(c)), c);
}
assert!(String::from_utf8(style_xml("cartoon")).unwrap().contains("bevelT"));
for k in ["pictureList", "pictureCaption", "pictureProcess"] {
assert_eq!(kind_for(layout_id(k), ""), k);
}
}
#[test]
fn hostile_links_are_bounded() {
let xml = br#"<dgm:dataModel xmlns:dgm="d"><dgm:ptLst><dgm:pt modelId="0" type="doc"/><dgm:pt modelId="1"><dgm:t><a:p xmlns:a="a"><a:r><a:t>A</a:t></a:r></a:p></dgm:t></dgm:pt><dgm:pt modelId="2"/></dgm:ptLst><dgm:cxnLst><dgm:cxn srcId="0" destId="1"/><dgm:cxn srcId="1" destId="2"/><dgm:cxn srcId="2" destId="1"/><dgm:cxn type="presOf" srcId="0" destId="2"/></dgm:cxnLst></dgm:dataModel>"#;
+20 -2
View File
@@ -196,6 +196,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
("app.preferences", "Preferences…", Some("Cmd+,"), "{}"),
("app.openDialog", "Open…", None, "{purpose?}"),
("app.insertPictureDialog", "Picture from File…", None, "{}"),
("app.smartArtPictureDialog", "Insert Picture in SmartArt…", None, "{id?, index}"),
("app.insertAudioDialog", "Audio from File…", None, "{}"),
("app.insertVideoDialog", "Video from File…", None, "{}"),
("app.saveAsDialog", "Save As…", None, "{}"),
@@ -598,6 +599,12 @@ impl SlideApp {
"app.preferences" => self.dialog = Some(dialogs::Dialog::new("preferences")),
"app.openDialog" => self.pick_and_open(),
"app.insertPictureDialog" => self.pick_and_insert("picture", "insert.picture"),
"app.smartArtPictureDialog" => {
if self.services.pick_open.is_none() {
return Err("picking a SmartArt picture needs the desktop app".into());
}
self.pick_and_run("picture", "smartArt.picture", p);
}
"app.insertAudioDialog" => self.pick_and_insert("audio", "insert.audio"),
"app.insertVideoDialog" => self.pick_and_insert("video", "insert.video"),
"app.saveAsDialog" => self.save_as_dialog("deckcraft"),
@@ -652,14 +659,25 @@ impl SlideApp {
}
fn pick_and_insert(&mut self, purpose: &str, cmd: &str) {
self.pick_and_run(purpose, cmd, &json!({}));
}
/// Pick a file and run `cmd` with it (`name` + `data`, or `path`) added to `extra`.
fn pick_and_run(&mut self, purpose: &str, cmd: &str, extra: &Value) {
if let Some(pick) = self.services.pick_open.as_mut() {
if let Some(path) = pick(purpose) {
let mut args = if extra.is_object() { extra.clone() } else { json!({}) };
let r = match self.services.read.as_ref().map(|r| r(&path)) {
Some(Ok(bytes)) => {
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
self.session.execute(cmd, &json!({"name": name, "data": deckcraft_engine::cmd::base64_encode(&bytes)}))
args["name"] = json!(name);
args["data"] = json!(deckcraft_engine::cmd::base64_encode(&bytes));
self.session.execute(cmd, &args)
}
_ => {
args["path"] = json!(path);
self.session.execute(cmd, &args)
}
_ => self.session.execute(cmd, &json!({"path": path})),
};
if let Err(e) = r {
self.set_status(e.to_string());
+14
View File
@@ -2615,6 +2615,13 @@ fn smartart_design(app: &mut SlideApp, ui: &mut Ui) {
run(app, "smartArt.demote", json!({"id": gid, "index": at}));
}
});
let pictures = kind.starts_with("picture");
if small_button(ui, Icon::Picture, "Insert Picture", pictures && n > 0, false)
.on_hover_text("Put a picture in the selected node's picture placeholder")
.clicked()
{
run(app, "app.smartArtPictureDialog", json!({"id": gid, "index": at}));
}
});
group(ui, |ui| {
let (r, _) = small_button_ex(ui, Icon::Layout, "Layouts", gid.is_some(), false, true);
@@ -2630,6 +2637,9 @@ fn smartart_design(app: &mut SlideApp, ui: &mut Ui) {
let (r, _) = small_button_ex(ui, Icon::ChangeChartType, "Change Colors", gid.is_some(), false, true);
egui::Popup::menu(&r).show(|ui| {
for (k, label) in deckcraft_engine::cmd::smartart::COLORS {
if matches!(k, "range23" | "accent1" | "accent1Grad") {
ui.separator();
}
let on = colors == k || (colors.is_empty() && k == "colorful");
if ui.selectable_label(on, label).clicked() {
run(app, "smartArt.colors", json!({"id": gid, "colors": k}));
@@ -2639,6 +2649,10 @@ fn smartart_design(app: &mut SlideApp, ui: &mut Ui) {
let (r, _) = small_button_ex(ui, Icon::SmartArt, "SmartArt Styles", gid.is_some(), false, true);
egui::Popup::menu(&r).show(|ui| {
for (k, label) in deckcraft_engine::cmd::smartart::STYLES {
if k == "polished" {
ui.separator();
ui.label("3-D");
}
let on = style == k || (style.is_empty() && k == "flat");
if ui.selectable_label(on, label).clicked() {
run(app, "smartArt.style", json!({"id": gid, "style": k}));