mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Merge PR #606 from NikitkaRa: SmartArt: 15 layouts, SmartArt Styles and Change Colors
This commit is contained in:
@@ -7,8 +7,11 @@
|
||||
|
||||
use deckcraft_color::SchemeSlot;
|
||||
use deckcraft_geom::Xfrm;
|
||||
use deckcraft_model::style::{Gradient, GradientShape, GradientStop, Shadow};
|
||||
use deckcraft_model::text::{Align, AutoFit, Paragraph, TextBody};
|
||||
use deckcraft_model::{Diagram, DiagramNode, Fill, Geom, Presentation, Shape, ShapeId, ShapeKind, ShapeStyle};
|
||||
use deckcraft_model::{
|
||||
ColorRef, ColorTransform, Diagram, DiagramNode, Effects, Fill, Geom, Line, Presentation, Shape, ShapeId, ShapeKind, ShapeStyle,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
@@ -19,7 +22,41 @@ pub const MAX_LEVEL: u8 = 8;
|
||||
/// Most top-level shapes a layout draws.
|
||||
const MAX_SHAPES: usize = 40;
|
||||
|
||||
pub const KINDS: [&str; 6] = ["list", "process", "cycle", "hierarchy", "pyramid", "matrix"];
|
||||
/// DeckCraft's layouts, with the names PowerPoint's gallery gives them.
|
||||
pub const LAYOUTS: [(&str, &str); 15] = [
|
||||
("list", "Vertical Bullet List"),
|
||||
("blockList", "Basic Block List"),
|
||||
("hBulletList", "Horizontal Bullet List"),
|
||||
("process", "Basic Process"),
|
||||
("chevron", "Basic Chevron Process"),
|
||||
("verticalProcess", "Vertical Process"),
|
||||
("stepUp", "Step Up Process"),
|
||||
("cycle", "Basic Cycle"),
|
||||
("blockCycle", "Block Cycle"),
|
||||
("radial", "Basic Radial"),
|
||||
("hierarchy", "Hierarchy"),
|
||||
("pyramid", "Basic Pyramid"),
|
||||
("invertedPyramid", "Inverted Pyramid"),
|
||||
("matrix", "Basic Matrix"),
|
||||
("titledMatrix", "Titled Matrix"),
|
||||
];
|
||||
/// SmartArt Styles.
|
||||
pub const STYLES: [(&str, &str); 4] =
|
||||
[("flat", "Simple Fill"), ("subtle", "Subtle Effect"), ("intense", "Intense Effect"), ("outline", "White Outline")];
|
||||
/// Change Colors.
|
||||
pub const COLORS: [(&str, &str); 7] = [
|
||||
("colorful", "Colorful"),
|
||||
("accent1", "Accent 1"),
|
||||
("accent2", "Accent 2"),
|
||||
("accent3", "Accent 3"),
|
||||
("accent4", "Accent 4"),
|
||||
("accent5", "Accent 5"),
|
||||
("accent6", "Accent 6"),
|
||||
];
|
||||
|
||||
pub fn is_kind(k: &str) -> bool {
|
||||
LAYOUTS.iter().any(|(id, _)| *id == k)
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -66,10 +103,28 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Change Layout",
|
||||
["SmartArt Design", "Layouts"],
|
||||
None,
|
||||
"{id?, kind: list|process|cycle|hierarchy|pyramid|matrix}",
|
||||
"{id?, kind: list|blockList|hBulletList|process|chevron|verticalProcess|stepUp|cycle|blockCycle|radial|hierarchy|pyramid|invertedPyramid|matrix|titledMatrix}",
|
||||
has_selection,
|
||||
change_layout
|
||||
),
|
||||
cmd!(
|
||||
"smartArt.style",
|
||||
"SmartArt Styles",
|
||||
["SmartArt Design", "SmartArt Styles"],
|
||||
None,
|
||||
"{id?, style: flat|subtle|intense|outline}",
|
||||
has_selection,
|
||||
change_style
|
||||
),
|
||||
cmd!(
|
||||
"smartArt.colors",
|
||||
"Change Colors",
|
||||
["SmartArt Design", "SmartArt Styles"],
|
||||
None,
|
||||
"{id?, colors: colorful|accent1|…|accent6}",
|
||||
has_selection,
|
||||
change_colors
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -175,11 +230,11 @@ pub fn demote_node(nodes: &mut [DiagramNode], i: usize) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Which nodes each generated text shape shows, in child order (lines drawn after these carry none).
|
||||
fn node_map(d: &Diagram) -> Vec<Vec<usize>> {
|
||||
if d.kind == "hierarchy" {
|
||||
return (0..d.nodes.len().min(MAX_SHAPES)).map(|i| vec![i]).collect();
|
||||
}
|
||||
/// A generated shape and the nodes whose text it shows (none for arrows and lines).
|
||||
type Item = (Shape, Vec<usize>);
|
||||
|
||||
/// Top-level nodes, each with its sub-points.
|
||||
fn groups(d: &Diagram) -> Vec<Vec<usize>> {
|
||||
let mut out: Vec<Vec<usize>> = vec![];
|
||||
for (i, n) in d.nodes.iter().enumerate() {
|
||||
match out.last_mut() {
|
||||
@@ -187,8 +242,7 @@ fn node_map(d: &Diagram) -> Vec<Vec<usize>> {
|
||||
_ => out.push(vec![i]),
|
||||
}
|
||||
}
|
||||
let cap = if d.kind == "matrix" { 4 } else { MAX_SHAPES };
|
||||
out.truncate(cap);
|
||||
out.truncate(MAX_SHAPES);
|
||||
out
|
||||
}
|
||||
|
||||
@@ -197,9 +251,10 @@ fn sync_text(d: &mut Diagram, children: &[Shape]) {
|
||||
if d.imported {
|
||||
return;
|
||||
}
|
||||
for (shape, idx) in children.iter().zip(node_map(d)) {
|
||||
let map: Vec<Vec<usize>> = layout_items(d, Xfrm::new(0.0, 0.0, 100.0, 100.0)).into_iter().map(|(_, m)| m).collect();
|
||||
for (shape, idx) in children.iter().zip(map) {
|
||||
let Some(body) = &shape.text else { continue };
|
||||
if body.paragraphs.len() != idx.len() {
|
||||
if idx.is_empty() || body.paragraphs.len() != idx.len() {
|
||||
continue;
|
||||
}
|
||||
for (p, i) in body.paragraphs.iter().zip(idx) {
|
||||
@@ -214,13 +269,91 @@ fn slot(i: usize) -> SchemeSlot {
|
||||
[SchemeSlot::Accent1, SchemeSlot::Accent2, SchemeSlot::Accent3, SchemeSlot::Accent4, SchemeSlot::Accent5, SchemeSlot::Accent6][i % 6]
|
||||
}
|
||||
|
||||
fn text_shape(preset: &str, x: Xfrm, color: usize, lines: &[&DiagramNode]) -> Shape {
|
||||
/// 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()) {
|
||||
Some(n @ 1..=6) => slot(n - 1),
|
||||
_ => slot(i),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 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";
|
||||
sh.style = Some(ShapeStyle::accent(a));
|
||||
match d.style.as_str() {
|
||||
_ if light => {
|
||||
sh.fill = Some(Fill::solid(tint(80)));
|
||||
sh.line = Some(Line::solid(tint(60), 1.0));
|
||||
}
|
||||
"outline" => {
|
||||
sh.fill = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Lt1)));
|
||||
sh.line = Some(Line::solid(base.clone(), 2.25));
|
||||
}
|
||||
"subtle" => {
|
||||
sh.fill = Some(Fill::solid(base.clone()));
|
||||
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() });
|
||||
}
|
||||
"intense" => {
|
||||
sh.fill = Some(Fill::Gradient(Gradient {
|
||||
stops: vec![
|
||||
GradientStop { pos: 0.0, color: tint(30) },
|
||||
GradientStop { pos: 1.0, color: base.clone().with(ColorTransform::LumMod(75_000)) },
|
||||
],
|
||||
shape: GradientShape::Linear { angle: 90.0, scaled: false },
|
||||
rotate_with_shape: true,
|
||||
tile: [0.0; 4],
|
||||
}));
|
||||
sh.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.0));
|
||||
sh.effects = Some(Effects { outer_shadow: Some(shadow(8.0, 5.0)), ..Default::default() });
|
||||
}
|
||||
_ => {
|
||||
sh.fill = Some(Fill::solid(base.clone()));
|
||||
sh.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.5));
|
||||
}
|
||||
}
|
||||
if let Some(body) = &mut sh.text {
|
||||
let c = if dark_text { ColorRef::scheme(SchemeSlot::Tx1) } else { ColorRef::scheme(SchemeSlot::Lt1) };
|
||||
for p in &mut body.paragraphs {
|
||||
for r in &mut p.runs {
|
||||
r.props.fill = Some(Fill::solid(c.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn shadow(blur: f64, dist: f64) -> Shadow {
|
||||
Shadow {
|
||||
color: ColorRef::rgb(deckcraft_model::Rgba { r: 0, g: 0, b: 0, a: 255 }).with(ColorTransform::Alpha(40_000)),
|
||||
blur,
|
||||
dist,
|
||||
dir: 90.0,
|
||||
inner: false,
|
||||
sx: 1.0,
|
||||
sy: 1.0,
|
||||
kx: 0.0,
|
||||
ky: 0.0,
|
||||
align: String::new(),
|
||||
rotate_with_shape: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A shape with text: the first line as the title (centred, larger), the rest as bullets. With
|
||||
/// `title` false every line is a bullet (the bullet boxes of the bullet lists).
|
||||
fn text_shape(preset: &str, x: Xfrm, lines: &[&DiagramNode], title: bool) -> Shape {
|
||||
let mut body = TextBody::default();
|
||||
body.body.autofit = Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 });
|
||||
let top = lines.first().map(|n| n.level).unwrap_or(0);
|
||||
let top = lines.first().map(|n| if title { n.level } else { n.level.saturating_sub(1) }).unwrap_or(0);
|
||||
for (k, n) in lines.iter().enumerate() {
|
||||
let mut para = Paragraph::new(&n.text);
|
||||
if k == 0 {
|
||||
let head = title && k == 0;
|
||||
if head {
|
||||
para.props.align = Some(Align::Center);
|
||||
} else {
|
||||
para.level = n.level.saturating_sub(top).saturating_sub(1).min(MAX_LEVEL);
|
||||
@@ -229,7 +362,7 @@ fn text_shape(preset: &str, x: Xfrm, color: usize, lines: &[&DiagramNode]) -> Sh
|
||||
para.props.margin_left = Some(12.0 * (para.level as f64 + 1.0));
|
||||
para.props.indent = Some(-10.0);
|
||||
}
|
||||
let size = if k == 0 { 20.0 } else { 14.0 };
|
||||
let size = if head { 20.0 } else { 14.0 };
|
||||
for r in &mut para.runs {
|
||||
r.props.size = Some(size);
|
||||
}
|
||||
@@ -239,14 +372,17 @@ fn text_shape(preset: &str, x: Xfrm, color: usize, lines: &[&DiagramNode]) -> Sh
|
||||
if body.paragraphs.is_empty() {
|
||||
body.paragraphs.push(Paragraph::new(""));
|
||||
}
|
||||
Shape {
|
||||
name: format!("SmartArt shape {}", color + 1),
|
||||
xfrm: Some(x),
|
||||
geom: Geom::preset(preset),
|
||||
style: Some(ShapeStyle::accent(slot(color))),
|
||||
text: Some(body),
|
||||
..Default::default()
|
||||
if !title {
|
||||
body.body.anchor = Some(deckcraft_model::text::Anchor::Top);
|
||||
}
|
||||
Shape { name: "SmartArt shape".into(), xfrm: Some(x), geom: Geom::preset(preset), text: Some(body), ..Default::default() }
|
||||
}
|
||||
|
||||
/// A plain shape with no text (arrows between steps).
|
||||
fn deco(preset: &str, x: Xfrm, rot: f64) -> Shape {
|
||||
let mut x = x;
|
||||
x.rot = rot;
|
||||
Shape { name: "SmartArt arrow".into(), xfrm: Some(x), geom: Geom::preset(preset), ..Default::default() }
|
||||
}
|
||||
|
||||
fn line_shape(a: (f64, f64), b: (f64, f64)) -> Shape {
|
||||
@@ -256,63 +392,211 @@ fn line_shape(a: (f64, f64), b: (f64, f64)) -> Shape {
|
||||
name: "SmartArt connector".into(),
|
||||
xfrm: Some(x),
|
||||
geom: Geom::preset("line"),
|
||||
style: Some(ShapeStyle::accent(SchemeSlot::Accent1)),
|
||||
fill: Some(Fill::None),
|
||||
line: Some(Line::solid(ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::LumMod(60_000)), 1.25)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The diagram's shapes laid out in `area` (ids not yet assigned).
|
||||
pub fn layout(d: &Diagram, area: Xfrm) -> Vec<Shape> {
|
||||
let map = node_map(d);
|
||||
let groups: Vec<Vec<&DiagramNode>> = map.iter().map(|g| g.iter().filter_map(|i| d.nodes.get(*i)).collect()).collect();
|
||||
let n = groups.len().max(1);
|
||||
let mut out = vec![];
|
||||
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.
|
||||
fn layout_items(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);
|
||||
let nf = n as f64;
|
||||
let mut out: Vec<Item> = vec![];
|
||||
// A titled shape for top-level group `i`, painted in its accent.
|
||||
let titled = |out: &mut Vec<Item>, preset: &str, x: Xfrm, i: usize, g: &[usize]| {
|
||||
let mut sh = text_shape(preset, x, &nodes(g), true);
|
||||
paint(d, &mut sh, i, false);
|
||||
out.push((sh, g.to_vec()));
|
||||
};
|
||||
let arrow = |out: &mut Vec<Item>, x: Xfrm, rot: f64, i: usize| {
|
||||
let mut sh = deco("rightArrow", x, rot);
|
||||
paint(d, &mut sh, i, true);
|
||||
out.push((sh, vec![]));
|
||||
};
|
||||
match d.kind.as_str() {
|
||||
"hierarchy" => return hierarchy(d, area),
|
||||
"cycle" => {
|
||||
let r = area.h.min(area.w) * 0.38;
|
||||
"list" | "hBulletList" => {
|
||||
// Vertical / Horizontal Bullet List: a coloured title bar, sub-points in a pale box below.
|
||||
let vertical = d.kind == "list";
|
||||
for (i, g) in gs.iter().enumerate() {
|
||||
let (cell, bar_h) = if vertical {
|
||||
(
|
||||
Xfrm::new(area.x, area.y + i as f64 * area.h / nf, area.w, area.h / nf * 0.92),
|
||||
(area.h / nf * 0.92).min(area.h * 0.12).max(18.0),
|
||||
)
|
||||
} else {
|
||||
let w = area.w / nf;
|
||||
(Xfrm::new(area.x + i as f64 * w + w * 0.03, area.y, w * 0.94, area.h), area.h * 0.22)
|
||||
};
|
||||
let has_sub = g.len() > 1;
|
||||
let bar_h = bar_h.min(cell.h);
|
||||
titled(&mut out, "roundRect", Xfrm::new(cell.x, cell.y, cell.w, bar_h), i, &g[..1]);
|
||||
if has_sub {
|
||||
let pad = cell.w * 0.03;
|
||||
let x = Xfrm::new(cell.x + pad, cell.y + bar_h, cell.w - 2.0 * pad, (cell.h - bar_h).max(1.0));
|
||||
let mut sh = text_shape("rect", x, &nodes(&g[1..]), false);
|
||||
paint(d, &mut sh, i, true);
|
||||
out.push((sh, g[1..].to_vec()));
|
||||
}
|
||||
}
|
||||
}
|
||||
"blockList" => {
|
||||
// Basic Block List: equal blocks in rows, as square as the area allows.
|
||||
let cols = ((nf * area.w / area.h.max(1.0) / 1.6).sqrt().ceil() as usize).clamp(1, n);
|
||||
let rows = n.div_ceil(cols);
|
||||
let (w, h) = (area.w / cols as f64, area.h / rows as f64);
|
||||
let (bw, bh) = (w * 0.9, (h * 0.85).min(w * 0.9 * 0.6));
|
||||
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) / 2.0;
|
||||
titled(&mut out, "rect", Xfrm::new(x0 + c as f64 * w + (w - bw) / 2.0, area.y + r as f64 * h + (h - bh) / 2.0, bw, bh), i, g);
|
||||
}
|
||||
}
|
||||
"process" | "chevron" => {
|
||||
let chevron = d.kind == "chevron";
|
||||
let gap = if chevron { -area.w * 0.02 } else { area.w * 0.06 };
|
||||
let w = ((area.w - gap * (nf - 1.0)) / nf).max(1.0);
|
||||
let h = (area.h * 0.5).min(w * if chevron { 0.45 } else { 0.65 });
|
||||
let y = area.y + (area.h - h) / 2.0;
|
||||
for (i, g) in gs.iter().enumerate() {
|
||||
let x = area.x + i as f64 * (w + gap);
|
||||
titled(&mut out, if chevron { "chevron" } else { "roundRect" }, Xfrm::new(x, y, w, h), i, g);
|
||||
if !chevron && i + 1 < n {
|
||||
let aw = gap * 0.7;
|
||||
arrow(&mut out, Xfrm::new(x + w + gap * 0.15, y + h / 2.0 - aw * 0.45, aw, aw * 0.9), 0.0, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
"verticalProcess" => {
|
||||
let gap = area.h * 0.06;
|
||||
let h = ((area.h - gap * (nf - 1.0)) / nf).max(1.0);
|
||||
let w = area.w * 0.6;
|
||||
let x = area.x + (area.w - w) / 2.0;
|
||||
for (i, g) in gs.iter().enumerate() {
|
||||
let y = area.y + i as f64 * (h + gap);
|
||||
titled(&mut out, "roundRect", Xfrm::new(x, y, w, h), i, g);
|
||||
if i + 1 < n {
|
||||
let s = gap * 0.8;
|
||||
// A right arrow turned to point down.
|
||||
arrow(&mut out, Xfrm::new(area.x + area.w / 2.0 - s / 2.0, y + h + gap * 0.1, s, s), 90.0, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
"stepUp" => {
|
||||
// Step Up Process: each step higher than the one before.
|
||||
let w = area.w / nf;
|
||||
let h = area.h / (nf + 1.0) * 1.6;
|
||||
for (i, g) in gs.iter().enumerate() {
|
||||
let y = area.y + area.h - h - i as f64 * (area.h - h) / (nf - 1.0).max(1.0);
|
||||
titled(&mut out, "rect", Xfrm::new(area.x + i as f64 * w + w * 0.04, y, w * 0.92, h), i, g);
|
||||
}
|
||||
}
|
||||
"cycle" | "blockCycle" => {
|
||||
let block = d.kind == "blockCycle";
|
||||
let r = area.h.min(area.w) * 0.36;
|
||||
let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0);
|
||||
let dd = r * 0.62;
|
||||
for (i, g) in groups.iter().enumerate() {
|
||||
let a = (i as f64 / n as f64 * 360.0 - 90.0).to_radians();
|
||||
out.push(text_shape("ellipse", Xfrm::new(cx + r * a.cos() - dd / 2.0, cy + r * a.sin() - dd / 2.0, dd, dd), i, g));
|
||||
let dd = (r * if block { 0.75 } else { 0.8 }).min(2.0 * std::f64::consts::PI * r / nf * 0.62);
|
||||
for (i, g) in gs.iter().enumerate() {
|
||||
let a = (i as f64 / nf * 360.0 - 90.0).to_radians();
|
||||
let (w, h) = if block { (dd * 1.3, dd * 0.75) } else { (dd, dd) };
|
||||
titled(
|
||||
&mut out,
|
||||
if block { "roundRect" } else { "ellipse" },
|
||||
Xfrm::new(cx + r * a.cos() - w / 2.0, cy + r * a.sin() - h / 2.0, w, h),
|
||||
i,
|
||||
g,
|
||||
);
|
||||
if n > 1 {
|
||||
// An arrow halfway to the next node, along the circle.
|
||||
let m = ((i as f64 + 0.5) / nf * 360.0 - 90.0).to_radians();
|
||||
let s = (dd * 0.35).max(6.0);
|
||||
let rot = (i as f64 + 0.5) / nf * 360.0;
|
||||
arrow(&mut out, Xfrm::new(cx + r * m.cos() - s / 2.0, cy + r * m.sin() - s * 0.4, s, s * 0.8), rot, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
"pyramid" => {
|
||||
let h = area.h / n as f64;
|
||||
for (i, g) in groups.iter().enumerate() {
|
||||
let w = area.w * 0.25 + area.w * 0.75 * (i as f64 + 1.0) / n as f64;
|
||||
out.push(text_shape("trapezoid", Xfrm::new(area.x + (area.w - w) / 2.0, area.y + i as f64 * h, w, h * 0.95), i, g));
|
||||
"radial" => {
|
||||
// Basic Radial: the first node in the middle, the others around it with spokes.
|
||||
let Some((centre, rest)) = gs.split_first() else { return out };
|
||||
let m = rest.len().max(1) as f64;
|
||||
let r = area.h.min(area.w) * 0.36;
|
||||
let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0);
|
||||
let dc = r * 0.75;
|
||||
let dd = (r * 0.75).min(2.0 * std::f64::consts::PI * r / m * 0.7);
|
||||
for (k, _) in rest.iter().enumerate() {
|
||||
let a = (k as f64 / m * 360.0 - 90.0).to_radians();
|
||||
let (ex, ey) = (cx + (r - dd / 2.0) * a.cos(), cy + (r - dd / 2.0) * a.sin());
|
||||
out.push((line_shape((cx + dc / 2.0 * a.cos(), cy + dc / 2.0 * a.sin()), (ex, ey)), vec![]));
|
||||
}
|
||||
titled(&mut out, "ellipse", Xfrm::new(cx - dc / 2.0, cy - dc / 2.0, dc, dc), 0, centre);
|
||||
for (k, g) in rest.iter().enumerate() {
|
||||
let a = (k as f64 / m * 360.0 - 90.0).to_radians();
|
||||
titled(&mut out, "ellipse", Xfrm::new(cx + r * a.cos() - dd / 2.0, cy + r * a.sin() - dd / 2.0, dd, dd), k + 1, g);
|
||||
}
|
||||
}
|
||||
"matrix" => {
|
||||
let (w, h) = (area.w / 2.0, area.h / 2.0);
|
||||
for (i, g) in groups.iter().enumerate() {
|
||||
let x = Xfrm::new(area.x + (i % 2) as f64 * w + 4.0, area.y + (i / 2) as f64 * h + 4.0, w - 8.0, h - 8.0);
|
||||
out.push(text_shape("roundRect", x, i, g));
|
||||
"pyramid" | "invertedPyramid" => {
|
||||
// One triangle cut into bands; inverted, the wide band is on top. Each band is drawn as
|
||||
// its own polygon so the sides keep the triangle's slope and the text stays upright.
|
||||
let inverted = d.kind == "invertedPyramid";
|
||||
let h = area.h / nf;
|
||||
let pw = area.h.min(area.w) * 1.15;
|
||||
let px = area.x + (area.w - pw) / 2.0;
|
||||
for (i, g) in gs.iter().enumerate() {
|
||||
let (a, b) = (i as f64 / nf, (i as f64 + 1.0) / nf);
|
||||
let (t, bt) = if inverted { (1.0 - a, 1.0 - b) } else { (a, b) };
|
||||
let (wt, wb) = (pw * t, pw * bt);
|
||||
let w = wt.max(wb).max(1.0);
|
||||
let bh = h * 0.96;
|
||||
let x = px + (pw - w) / 2.0;
|
||||
let (it, ib) = ((w - wt) / 2.0, (w - wb) / 2.0);
|
||||
let path = format!("M {it:.2} 0 L {:.2} 0 L {:.2} {bh:.2} L {ib:.2} {bh:.2} Z", w - it, w - ib);
|
||||
let mut sh = text_shape("rect", Xfrm::new(x, area.y + i as f64 * h, w, bh), &nodes(g), true);
|
||||
sh.geom = Geom::Custom {
|
||||
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);
|
||||
if let Some(b) = &mut sh.text {
|
||||
b.body.inset_l = Some(inset);
|
||||
b.body.inset_r = Some(inset);
|
||||
}
|
||||
paint(d, &mut sh, i, false);
|
||||
out.push((sh, g.clone()));
|
||||
}
|
||||
}
|
||||
"list" => {
|
||||
let h = area.h / n as f64;
|
||||
for (i, g) in groups.iter().enumerate() {
|
||||
out.push(text_shape("roundRect", Xfrm::new(area.x, area.y + i as f64 * h, area.w, h * 0.85), i, g));
|
||||
"matrix" | "titledMatrix" => {
|
||||
let titled_m = d.kind == "titledMatrix";
|
||||
let s = area.h.min(area.w);
|
||||
let (x0, y0) = (area.x + (area.w - s) / 2.0, area.y + (area.h - s) / 2.0);
|
||||
let (quads, title): (&[Vec<usize>], Option<&Vec<usize>>) =
|
||||
if titled_m { gs.split_first().map(|(t, r)| (r, Some(t))).unwrap_or((&[], None)) } else { (&gs, None) };
|
||||
for (i, g) in quads.iter().take(4).enumerate() {
|
||||
let x = Xfrm::new(x0 + (i % 2) as f64 * s / 2.0 + 3.0, y0 + (i / 2) as f64 * s / 2.0 + 3.0, s / 2.0 - 6.0, s / 2.0 - 6.0);
|
||||
titled(&mut out, "roundRect", x, i + usize::from(titled_m), g);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let gap = 24.0;
|
||||
let w = ((area.w - gap * (n as f64 - 1.0)) / n as f64).max(1.0);
|
||||
for (i, g) in groups.iter().enumerate() {
|
||||
let preset = if i + 1 < n { "homePlate" } else { "roundRect" };
|
||||
out.push(text_shape(preset, Xfrm::new(area.x + i as f64 * (w + gap), area.y + area.h * 0.25, w, area.h * 0.5), i, g));
|
||||
if let Some(t) = title {
|
||||
let c = s * 0.42;
|
||||
let mut sh = text_shape("roundRect", Xfrm::new(x0 + (s - c) / 2.0, y0 + (s - c * 0.6) / 2.0, c, c * 0.6), &nodes(t), true);
|
||||
paint(d, &mut sh, 0, true);
|
||||
out.push((sh, t.clone()));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// An organisation chart: one box per node, rows by level, each subtree as wide as its leaves.
|
||||
fn hierarchy(d: &Diagram, area: Xfrm) -> Vec<Shape> {
|
||||
fn hierarchy(d: &Diagram, area: Xfrm) -> Vec<Item> {
|
||||
let nodes = &d.nodes[..d.nodes.len().min(MAX_SHAPES)];
|
||||
if nodes.is_empty() {
|
||||
return vec![];
|
||||
@@ -375,11 +659,19 @@ fn hierarchy(d: &Diagram, area: Xfrm) -> Vec<Shape> {
|
||||
Xfrm::new(cx - bw / 2.0, area.y + depth[i] as f64 * row_h + (row_h - bh) / 2.0, bw, bh)
|
||||
})
|
||||
.collect();
|
||||
let mut out: Vec<Shape> = nodes.iter().enumerate().map(|(i, n)| text_shape("roundRect", boxes[i], depth[i], &[n])).collect();
|
||||
let mut out: Vec<Item> = nodes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, n)| {
|
||||
let mut sh = text_shape("roundRect", boxes[i], &[n], true);
|
||||
paint(d, &mut sh, depth[i], false);
|
||||
(sh, vec![i])
|
||||
})
|
||||
.collect();
|
||||
for i in 0..nodes.len() {
|
||||
if let Some(p) = parent[i] {
|
||||
let (a, b) = (boxes[p], boxes[i]);
|
||||
out.push(line_shape((a.x + a.w / 2.0, a.y + a.h), (b.x + b.w / 2.0, b.y)));
|
||||
out.push((line_shape((a.x + a.w / 2.0, a.y + a.h), (b.x + b.w / 2.0, b.y)), vec![]));
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -457,7 +749,7 @@ fn index(p: &Value, cmd: &str) -> Result<usize> {
|
||||
|
||||
/// Insert a new SmartArt graphic (`insert.smartArt`).
|
||||
pub fn insert(s: &mut Session, kind: &str, mut nodes: Vec<DiagramNode>) -> Result<Value> {
|
||||
let kind = if KINDS.contains(&kind) { kind } else { "process" };
|
||||
let kind = if is_kind(kind) { kind } else { "process" };
|
||||
if nodes.is_empty() {
|
||||
nodes = (0..3).map(|_| DiagramNode { text: "Text".into(), level: 0 }).collect();
|
||||
}
|
||||
@@ -542,9 +834,33 @@ 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 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();
|
||||
edit_diagram(s, p, CMD, |d| {
|
||||
if d.style != v {
|
||||
d.style_xml = None;
|
||||
}
|
||||
d.style = v;
|
||||
Ok(Value::Null)
|
||||
})
|
||||
}
|
||||
|
||||
fn change_colors(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "smartArt.colors";
|
||||
let v = str_param(p, "colors").filter(|k| COLORS.iter().any(|(id, _)| id == k)).ok_or_else(|| bad(CMD, "unknown colours"))?.to_string();
|
||||
edit_diagram(s, p, CMD, |d| {
|
||||
if d.colors != v {
|
||||
d.colors_xml = None;
|
||||
}
|
||||
d.colors = v;
|
||||
Ok(Value::Null)
|
||||
})
|
||||
}
|
||||
|
||||
fn change_layout(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "smartArt.layout";
|
||||
let kind = str_param(p, "kind").filter(|k| KINDS.contains(k)).ok_or_else(|| bad(CMD, "unknown layout"))?.to_string();
|
||||
let kind = str_param(p, "kind").filter(|k| is_kind(k)).ok_or_else(|| bad(CMD, "unknown layout"))?.to_string();
|
||||
edit_diagram(s, p, CMD, |d| {
|
||||
if d.kind != kind {
|
||||
// PowerPoint's own layout no longer applies.
|
||||
@@ -595,7 +911,7 @@ mod tests {
|
||||
#[test]
|
||||
fn layouts_map_nodes_to_shapes() {
|
||||
let area = Xfrm::new(0.0, 0.0, 800.0, 400.0);
|
||||
let d = Diagram { kind: "list".into(), nodes: mk(&[0, 1, 1, 0]), ..Default::default() };
|
||||
let d = Diagram { kind: "blockList".into(), nodes: mk(&[0, 1, 1, 0]), ..Default::default() };
|
||||
let v = layout(&d, area);
|
||||
assert_eq!(v.len(), 2);
|
||||
let t = v[0].text.as_ref().map(|b| b.paragraphs.len());
|
||||
@@ -608,5 +924,36 @@ mod tests {
|
||||
assert!(x.x >= -0.01 && x.x + x.w <= 800.01 && x.y >= -0.01 && x.y + x.h <= 400.01, "{x:?}");
|
||||
}
|
||||
assert!(layout(&Diagram { kind: "cycle".into(), ..Default::default() }, area).is_empty());
|
||||
// Vertical Bullet List: a bar per top node, its sub-points in a box below.
|
||||
let v = layout_items(&Diagram { kind: "list".into(), nodes: mk(&[0, 1, 1, 0]), ..Default::default() }, area);
|
||||
let maps: Vec<Vec<usize>> = v.into_iter().map(|(_, m)| m).collect();
|
||||
assert_eq!(maps, [vec![0], vec![1, 2], vec![3]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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"] {
|
||||
let d = Diagram {
|
||||
kind: kind.into(),
|
||||
nodes: mk(&[0, 1, 0, 0, 1, 0]),
|
||||
style: style.into(),
|
||||
colors: "accent2".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let items = layout_items(&d, area);
|
||||
let mut shown: Vec<usize> = items.iter().flat_map(|(_, m)| m.clone()).collect();
|
||||
shown.sort_unstable();
|
||||
let want: Vec<usize> = (0..6).collect();
|
||||
assert_eq!(shown, want, "{kind}");
|
||||
for (sh, _) in &items {
|
||||
let x = sh.xfrm.unwrap_or_default();
|
||||
let (cx, cy) = (x.x + x.w / 2.0, x.y + x.h / 2.0);
|
||||
assert!(x.w.is_finite() && x.h.is_finite() && x.w >= 0.0 && x.h >= 0.0, "{kind} {x:?}");
|
||||
assert!((49.0..=851.0).contains(&cx) && (39.0..=441.0).contains(&cy), "{kind} {x:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1683,7 +1683,7 @@ fn undo_history_is_capped_by_memory_as_well_as_steps() {
|
||||
#[test]
|
||||
fn smartart_text_pane_edits_regenerate_the_diagram() {
|
||||
let mut s = session();
|
||||
let r = s.execute("insert.smartArt", &json!({"kind": "list", "items": ["Plan", "\tScope", "Build"]})).unwrap();
|
||||
let r = s.execute("insert.smartArt", &json!({"kind": "blockList", "items": ["Plan", "\tScope", "Build"]})).unwrap();
|
||||
let gid = r["id"].as_u64().unwrap();
|
||||
let kids = |s: &Session| match s.doc().unwrap().shape(deckcraft_model::ShapeId(gid as u32)).map(|x| &x.kind) {
|
||||
Some(deckcraft_model::ShapeKind::Group { children, .. }) => children.clone(),
|
||||
@@ -1734,6 +1734,8 @@ fn smartart_round_trips_through_pptx_as_a_diagram() {
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
s.execute("insert.smartArt", &json!({"kind": "hierarchy", "items": ["CEO", "\tCTO", "\t\tDev", "\tCFO"]})).unwrap();
|
||||
s.execute("smartArt.style", &json!({"style": "intense"})).unwrap();
|
||||
s.execute("smartArt.colors", &json!({"colors": "accent3"})).unwrap();
|
||||
let doc = s.doc().unwrap().doc.as_ref().clone();
|
||||
let bytes = deckcraft_pptx::export(&doc).unwrap();
|
||||
let names: Vec<String> = {
|
||||
@@ -1748,6 +1750,7 @@ fn smartart_round_trips_through_pptx_as_a_diagram() {
|
||||
let g = back.slides[1].shapes.iter().find(|x| x.diagram.is_some()).expect("a SmartArt group");
|
||||
let d = g.diagram.as_ref().unwrap();
|
||||
assert_eq!(d.kind, "hierarchy");
|
||||
assert_eq!((d.style.as_str(), d.colors.as_str()), ("intense", "accent3"), "SmartArt style and colours come back");
|
||||
assert!(d.imported);
|
||||
let outline: Vec<(&str, u8)> = d.nodes.iter().map(|n| (n.text.as_str(), n.level)).collect();
|
||||
assert_eq!(outline, [("CEO", 0), ("CTO", 1), ("Dev", 2), ("CFO", 1)]);
|
||||
|
||||
@@ -336,9 +336,16 @@ pub struct Shape {
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Diagram {
|
||||
/// list, process, cycle, hierarchy, pyramid or matrix.
|
||||
/// The layout: list, blockList, hBulletList, process, chevron, verticalProcess, stepUp, cycle,
|
||||
/// blockCycle, radial, hierarchy, pyramid, invertedPyramid, matrix or titledMatrix.
|
||||
pub kind: String,
|
||||
pub nodes: Vec<DiagramNode>,
|
||||
/// SmartArt Style: `flat` (default), `subtle`, `intense` or `outline`.
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub style: String,
|
||||
/// Change Colors: `colorful` (default: each shape its own accent) or `accent1`…`accent6`.
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub colors: String,
|
||||
/// The group's shapes are the file's drawing, not generated from `nodes`: text typed into them
|
||||
/// isn't mapped back to nodes (the first outline edit regenerates them).
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
|
||||
+135
-29
@@ -19,48 +19,130 @@ pub const CT_STYLE: &str = "application/vnd.openxmlformats-officedocument.drawin
|
||||
pub const CT_COLORS: &str = "application/vnd.openxmlformats-officedocument.drawingml.diagramColors+xml";
|
||||
pub const CT_DRAWING: &str = "application/vnd.ms-office.drawingml.diagramDrawing+xml";
|
||||
pub const RT_DRAWING: &str = "http://schemas.microsoft.com/office/2007/relationships/diagramDrawing";
|
||||
const QS_ID: &str = "urn:microsoft.com/office/officeart/2005/8/quickstyle/simple1";
|
||||
const CS_ID: &str = "urn:microsoft.com/office/officeart/2005/8/colors/accent1_2";
|
||||
const QS_BASE: &str = "urn:microsoft.com/office/officeart/2005/8/quickstyle/";
|
||||
const CS_BASE: &str = "urn:microsoft.com/office/officeart/2005/8/colors/";
|
||||
|
||||
/// PowerPoint's quick style for a SmartArt Style.
|
||||
pub fn qs_id(style: &str) -> String {
|
||||
let s = match style {
|
||||
"subtle" => "simple3",
|
||||
"intense" => "simple5",
|
||||
"outline" => "simple4",
|
||||
_ => "simple1",
|
||||
};
|
||||
format!("{QS_BASE}{s}")
|
||||
}
|
||||
|
||||
/// The SmartArt Style closest to a quick style id.
|
||||
pub fn style_for(qs: &str) -> &'static str {
|
||||
match qs.rsplit('/').next().unwrap_or("") {
|
||||
"simple1" | "" => "",
|
||||
"simple4" => "outline",
|
||||
"simple2" | "simple3" => "subtle",
|
||||
_ => "intense",
|
||||
}
|
||||
}
|
||||
|
||||
/// 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"),
|
||||
_ => format!("{CS_BASE}colorful1"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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}"),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
/// Most nodes read from one diagram.
|
||||
const MAX_NODES: usize = 2000;
|
||||
|
||||
/// PowerPoint's built-in layout DeckCraft's layout `kind` is written as.
|
||||
pub fn layout_id(kind: &str) -> &'static str {
|
||||
match kind {
|
||||
"blockList" => "urn:microsoft.com/office/officeart/2005/8/layout/default",
|
||||
"hBulletList" => "urn:microsoft.com/office/officeart/2005/8/layout/hList1",
|
||||
"process" => "urn:microsoft.com/office/officeart/2005/8/layout/process1",
|
||||
"chevron" => "urn:microsoft.com/office/officeart/2005/8/layout/chevron1",
|
||||
"verticalProcess" => "urn:microsoft.com/office/officeart/2005/8/layout/process2",
|
||||
"stepUp" => "urn:microsoft.com/office/officeart/2009/3/layout/StepUpProcess",
|
||||
"cycle" => "urn:microsoft.com/office/officeart/2005/8/layout/cycle2",
|
||||
"blockCycle" => "urn:microsoft.com/office/officeart/2005/8/layout/cycle5",
|
||||
"radial" => "urn:microsoft.com/office/officeart/2005/8/layout/radial1",
|
||||
"hierarchy" => "urn:microsoft.com/office/officeart/2005/8/layout/hierarchy1",
|
||||
"pyramid" => "urn:microsoft.com/office/officeart/2005/8/layout/pyramid1",
|
||||
"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",
|
||||
_ => "urn:microsoft.com/office/officeart/2005/8/layout/vList2",
|
||||
}
|
||||
}
|
||||
|
||||
/// The DeckCraft layout closest to a PowerPoint layout (by its id and category).
|
||||
pub fn kind_for(layout_id: &str, category: &str) -> &'static str {
|
||||
let s = format!("{layout_id} {category}").to_ascii_lowercase();
|
||||
let last = layout_id.rsplit('/').next().unwrap_or("").to_ascii_lowercase();
|
||||
// Exact ids first: the layouts DeckCraft writes, and their closest siblings.
|
||||
for k in [
|
||||
"blockList",
|
||||
"hBulletList",
|
||||
"process",
|
||||
"chevron",
|
||||
"verticalProcess",
|
||||
"stepUp",
|
||||
"cycle",
|
||||
"blockCycle",
|
||||
"radial",
|
||||
"hierarchy",
|
||||
"pyramid",
|
||||
"invertedPyramid",
|
||||
"matrix",
|
||||
"titledMatrix",
|
||||
"list",
|
||||
] {
|
||||
if layout_id == layout_id_of(k) {
|
||||
return k;
|
||||
}
|
||||
}
|
||||
let s = format!("{last} {}", category.to_ascii_lowercase());
|
||||
if s.contains("hierarchy") || s.contains("orgchart") {
|
||||
"hierarchy"
|
||||
} else if s.contains("cycle") || s.contains("radial") {
|
||||
} else if s.contains("radial") {
|
||||
"radial"
|
||||
} else if s.contains("cycle") {
|
||||
"cycle"
|
||||
} else if s.contains("pyramid") {
|
||||
"pyramid"
|
||||
} else if s.contains("matrix") {
|
||||
"matrix"
|
||||
} else if s.contains("process") || s.contains("chevron") || s.contains("arrow") {
|
||||
} else if s.contains("chevron") {
|
||||
"chevron"
|
||||
} else if s.contains("process") || s.contains("arrow") {
|
||||
"process"
|
||||
} else if last == "default" {
|
||||
"blockList"
|
||||
} else if last.starts_with("hlist") {
|
||||
"hBulletList"
|
||||
} else {
|
||||
"list"
|
||||
}
|
||||
}
|
||||
|
||||
fn layout_id_of(kind: &str) -> &'static str {
|
||||
layout_id(kind)
|
||||
}
|
||||
|
||||
fn category(kind: &str) -> &'static str {
|
||||
match kind {
|
||||
"process" => "process",
|
||||
"cycle" => "cycle",
|
||||
"process" | "chevron" | "verticalProcess" | "stepUp" => "process",
|
||||
"cycle" | "blockCycle" | "radial" => "cycle",
|
||||
"hierarchy" => "hierarchy",
|
||||
"pyramid" => "pyramid",
|
||||
"matrix" => "matrix",
|
||||
"pyramid" | "invertedPyramid" => "pyramid",
|
||||
"matrix" | "titledMatrix" => "matrix",
|
||||
_ => "list",
|
||||
}
|
||||
}
|
||||
@@ -101,10 +183,10 @@ pub fn data_xml(d: &Diagram, seed: u32, drawing_rel: &str) -> Vec<u8> {
|
||||
A::new()
|
||||
.a("loTypeId", &lo)
|
||||
.a("loCatId", category(&d.kind))
|
||||
.a("qsTypeId", QS_ID)
|
||||
.a("qsTypeId", qs_id(&d.style))
|
||||
.a("qsCatId", "simple")
|
||||
.a("csTypeId", CS_ID)
|
||||
.a("csCatId", "accent1")
|
||||
.a("csTypeId", cs_id(&d.colors))
|
||||
.a("csCatId", if d.colors.starts_with("accent") { d.colors.as_str() } else { "colorful" })
|
||||
.a("phldr", "1"),
|
||||
);
|
||||
w.empty0("dgm:spPr");
|
||||
@@ -230,10 +312,20 @@ 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" => ("lin", vec![("linDir", "fromL"), ("nodeVertAlign", "mid")], "roundRect"),
|
||||
"cycle" => ("cycle", vec![("stAng", "0"), ("spanAng", "360")], "ellipse"),
|
||||
"pyramid" => ("pyra", vec![("linDir", "fromT"), ("txDir", "fromT"), ("pyraAcctPos", "bef")], "trapezoid"),
|
||||
"matrix" => ("snake", vec![("grDir", "tL"), ("flowDir", "row"), ("contDir", "sameDir"), ("off", "ctr")], "roundRect"),
|
||||
"process" | "stepUp" => ("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"),
|
||||
"blockCycle" => ("cycle", vec![("stAng", "0"), ("spanAng", "360")], "roundRect"),
|
||||
"pyramid" | "invertedPyramid" => {
|
||||
("pyra", vec![("linDir", if kind == "pyramid" { "fromT" } else { "fromB" }), ("txDir", "fromT"), ("pyraAcctPos", "bef")], "trapezoid")
|
||||
}
|
||||
"matrix" | "titledMatrix" | "blockList" => (
|
||||
"snake",
|
||||
vec![("grDir", "tL"), ("flowDir", "row"), ("contDir", "sameDir"), ("off", "ctr")],
|
||||
if kind == "blockList" { "rect" } else { "roundRect" },
|
||||
),
|
||||
"hBulletList" => ("lin", vec![("linDir", "fromL"), ("nodeVertAlign", "t")], "roundRect"),
|
||||
_ => ("lin", vec![("linDir", "fromT"), ("vertAlign", "mid")], "roundRect"),
|
||||
}
|
||||
}
|
||||
@@ -348,7 +440,7 @@ pub fn layout_xml(kind: &str) -> Vec<u8> {
|
||||
w.empty0("dgm:ruleLst");
|
||||
w.open("dgm:forEach", A::new().a("name", "nodes").a("axis", "ch").a("ptType", "node"));
|
||||
node_layout(&mut w, shape, true);
|
||||
if kind != "cycle" {
|
||||
if !matches!(kind, "cycle" | "blockCycle" | "radial") {
|
||||
space(&mut w);
|
||||
}
|
||||
w.close("dgm:forEach");
|
||||
@@ -359,9 +451,9 @@ pub fn layout_xml(kind: &str) -> Vec<u8> {
|
||||
}
|
||||
|
||||
/// `ppt/diagrams/quickStyleN.xml`: flat shapes drawn from the theme's first styles.
|
||||
pub fn style_xml() -> Vec<u8> {
|
||||
pub fn style_xml(style: &str) -> Vec<u8> {
|
||||
let mut w = W::new();
|
||||
w.open("dgm:styleDef", A::new().a("xmlns:dgm", NS_DGM).a("xmlns:a", crate::opc::NS_A).a("uniqueId", QS_ID));
|
||||
w.open("dgm:styleDef", A::new().a("xmlns:dgm", NS_DGM).a("xmlns:a", crate::opc::NS_A).a("uniqueId", qs_id(style)));
|
||||
w.empty("dgm:title", A::new().a("val", ""));
|
||||
w.empty("dgm:desc", A::new().a("val", ""));
|
||||
w.open0("dgm:catLst");
|
||||
@@ -394,20 +486,30 @@ pub fn style_xml() -> Vec<u8> {
|
||||
}
|
||||
|
||||
/// `ppt/diagrams/colorsN.xml`: accent 1 fills with light text.
|
||||
pub fn colors_xml() -> Vec<u8> {
|
||||
pub fn colors_xml(colors: &str) -> Vec<u8> {
|
||||
let mut w = W::new();
|
||||
w.open("dgm:colorsDef", A::new().a("xmlns:dgm", NS_DGM).a("xmlns:a", crate::opc::NS_A).a("uniqueId", CS_ID));
|
||||
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");
|
||||
w.open0("dgm:catLst");
|
||||
w.empty("dgm:cat", A::new().a("type", "accent1").a("pri", "11200"));
|
||||
w.empty("dgm:cat", A::new().a("type", if single { colors } else { "colorful" }).a("pri", "11200"));
|
||||
w.close("dgm:catLst");
|
||||
w.open("dgm:styleLbl", A::new().a("name", "node1"));
|
||||
for (tag, clr) in [("dgm:fillClrLst", "accent1"), ("dgm:linClrLst", "lt1")] {
|
||||
w.open(tag, A::new().a("meth", "repeat"));
|
||||
w.empty("a:schemeClr", A::new().a("val", clr));
|
||||
w.close(tag);
|
||||
if single {
|
||||
w.open("dgm:fillClrLst", A::new().a("meth", "repeat"));
|
||||
w.empty("a:schemeClr", A::new().a("val", colors));
|
||||
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}")));
|
||||
}
|
||||
w.close("dgm:fillClrLst");
|
||||
}
|
||||
w.open("dgm:linClrLst", A::new().a("meth", "repeat"));
|
||||
w.empty("a:schemeClr", A::new().a("val", "lt1"));
|
||||
w.close("dgm:linClrLst");
|
||||
w.empty0("dgm:effectClrLst");
|
||||
w.empty0("dgm:txLinClrLst");
|
||||
w.empty0("dgm:txFillClrLst");
|
||||
@@ -434,7 +536,7 @@ 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(), colors_xml()] {
|
||||
for x in [layout_xml("hierarchy"), layout_xml("cycle"), style_xml("subtle"), colors_xml(""), colors_xml("accent3")] {
|
||||
assert!(crate::xml::parse(&x).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -445,7 +547,11 @@ mod tests {
|
||||
let doc = crate::xml::parse(xml).unwrap();
|
||||
assert_eq!(outline(&doc.root), nodes(&[("A", 0), ("", 1)]));
|
||||
assert_eq!(kind_for("urn:microsoft.com/office/officeart/2005/8/layout/orgChart1", ""), "hierarchy");
|
||||
assert_eq!(kind_for("urn:x/layout/chevron2", "process"), "process");
|
||||
assert_eq!(kind_for("urn:x/layout/default", "list"), "list");
|
||||
assert_eq!(kind_for("urn:x/layout/chevron2", "process"), "chevron");
|
||||
assert_eq!(kind_for("urn:x/layout/hProcess9", "process"), "process");
|
||||
assert_eq!(kind_for("urn:x/layout/default", "list"), "blockList");
|
||||
assert_eq!(colors_for(&cs_id("accent4")), "accent4");
|
||||
assert_eq!(colors_for(&cs_id("")), "");
|
||||
assert_eq!(style_for(&qs_id("outline")), "outline");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -646,9 +646,14 @@ fn diagram_outline(imp: &Imp, part: &Part, rel_ids: &El, data: &El) -> Option<de
|
||||
.and_then(|p| p.attr("loTypeId"))
|
||||
.unwrap_or("");
|
||||
let id = if layout_id.is_empty() { lo_type.to_string() } else { layout_id };
|
||||
let pr = data.path(&["ptLst"]).and_then(|l| l.children_named("pt").find(|p| p.attr("type") == Some("doc"))).and_then(|p| p.child("prSet"));
|
||||
let style = crate::dgm::style_for(pr.and_then(|p| p.attr("qsTypeId")).unwrap_or("")).to_string();
|
||||
let colors = crate::dgm::colors_for(pr.and_then(|p| p.attr("csTypeId")).unwrap_or(""));
|
||||
Some(deckcraft_model::Diagram {
|
||||
kind: crate::dgm::kind_for(&id, &cat).to_string(),
|
||||
nodes,
|
||||
style,
|
||||
colors,
|
||||
imported: true,
|
||||
layout_id: id,
|
||||
layout_xml,
|
||||
|
||||
@@ -541,10 +541,10 @@ fn diagram(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, d: &dec
|
||||
x.pkg.xml_part(&layout, dgm::CT_LAYOUT, lo_xml);
|
||||
let lo = rel(o, "diagramLayout", &layout);
|
||||
let style = name("quickStyle");
|
||||
x.pkg.xml_part(&style, dgm::CT_STYLE, d.style_xml.clone().map(String::into_bytes).unwrap_or_else(dgm::style_xml));
|
||||
x.pkg.xml_part(&style, dgm::CT_STYLE, d.style_xml.clone().map(String::into_bytes).unwrap_or_else(|| dgm::style_xml(&d.style)));
|
||||
let qs = rel(o, "diagramQuickStyle", &style);
|
||||
let colors = name("colors");
|
||||
x.pkg.xml_part(&colors, dgm::CT_COLORS, d.colors_xml.clone().map(String::into_bytes).unwrap_or_else(dgm::colors_xml));
|
||||
x.pkg.xml_part(&colors, dgm::CT_COLORS, d.colors_xml.clone().map(String::into_bytes).unwrap_or_else(|| dgm::colors_xml(&d.colors)));
|
||||
let cs = rel(o, "diagramColors", &colors);
|
||||
let mut fs = s.clone();
|
||||
fs.xfrm = Some(frame);
|
||||
|
||||
@@ -2587,9 +2587,9 @@ fn chart_design(app: &mut SlideApp, ui: &mut Ui) {
|
||||
fn smartart_design(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let sel = app.session.active().and_then(deckcraft_engine::cmd::smartart::selected);
|
||||
let row = app.ui.smartart_row;
|
||||
let (gid, n, kind) = match &sel {
|
||||
Some((id, d)) => (Some(id.0), d.nodes.len(), d.kind.clone()),
|
||||
None => (None, 0, String::new()),
|
||||
let (gid, n, kind, style, colors) = match &sel {
|
||||
Some((id, d)) => (Some(id.0), d.nodes.len(), d.kind.clone(), d.style.clone(), d.colors.clone()),
|
||||
None => (None, 0, String::new(), String::new(), String::new()),
|
||||
};
|
||||
let at = row.min(n.saturating_sub(1));
|
||||
group(ui, |ui| {
|
||||
@@ -2617,16 +2617,35 @@ fn smartart_design(app: &mut SlideApp, ui: &mut Ui) {
|
||||
});
|
||||
});
|
||||
group(ui, |ui| {
|
||||
let (r, _) = small_button_ex(ui, Icon::Layout, "Layout", gid.is_some(), false, true);
|
||||
let (r, _) = small_button_ex(ui, Icon::Layout, "Layouts", gid.is_some(), false, true);
|
||||
egui::Popup::menu(&r).show(|ui| {
|
||||
for k in deckcraft_engine::cmd::smartart::KINDS {
|
||||
let label = format!("{}{}", k[..1].to_uppercase(), &k[1..]);
|
||||
for (k, label) in deckcraft_engine::cmd::smartart::LAYOUTS {
|
||||
if ui.selectable_label(kind == k, label).clicked() {
|
||||
run(app, "smartArt.layout", json!({"id": gid, "kind": k}));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
group(ui, |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 {
|
||||
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}));
|
||||
}
|
||||
}
|
||||
});
|
||||
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 {
|
||||
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}));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
arrange_group(app, ui);
|
||||
}
|
||||
|
||||
|
||||
+4
-2
@@ -216,8 +216,10 @@ Detail in [file-format-parity.md](file-format-parity.md).
|
||||
Shape, Promote, Demote, Remove and layout switching (`crates/engine/src/cmd/smartart.rs`). On
|
||||
`.pptx` the outline is written as a real diagram (data, layout, quick style, colours and the
|
||||
cached drawing; `crates/pptx/src/dgm.rs`), and PowerPoint diagrams read their outline from the
|
||||
data model, so they get the Text Pane too. Still missing: DeckCraft's own layout definitions are
|
||||
simple (not yet checked in PowerPoint itself); there are six layouts rather than ~200; editing an
|
||||
data model, so they get the Text Pane too. 15 layouts (bullet and block lists, five process, two cycle and
|
||||
a radial, hierarchy, two pyramids, two matrices), four SmartArt Styles and Change Colors
|
||||
(colourful or one accent). Still missing: DeckCraft's own layout definitions are simple (not yet
|
||||
checked in PowerPoint itself); PowerPoint has ~200 layouts, picture layouts and 3-D styles; editing an
|
||||
imported diagram's outline redraws it with DeckCraft's layout; a diagram with no cached drawing
|
||||
isn't drawn.
|
||||
- **Impact:** a PowerPoint diagram edited in DeckCraft changes look.
|
||||
|
||||
Reference in New Issue
Block a user