refactor(engine): split cmd/smartart.rs into cmd/smartart/{nodes,paint,layout,diagrams,edit}.rs

This commit is contained in:
aiongg
2026-10-26 22:08:44 +00:00
parent 97b98767f1
commit d7c06b53eb
6 changed files with 1805 additions and 1774 deletions
File diff suppressed because it is too large Load Diff
+254
View File
@@ -0,0 +1,254 @@
use super::nodes::Item;
use super::paint::{accent, deco, line_shape, paint, text_shape};
use super::*;
/// Organization Chart: [`hierarchy`] with elbow connectors, and assistants beside their parent on
/// a row of their own between the parent and its other children. `half_circle` draws each node as
/// its text in an open half ring (Half Circle Organization Chart).
pub(super) fn org_chart(d: &Diagram, area: Xfrm) -> Vec<Item> {
let half_circle = d.kind == "halfCircleOrgChart";
let name_title = d.kind == "nameTitleOrgChart";
let nodes = &d.nodes[..d.nodes.len().min(MAX_SHAPES)];
if nodes.is_empty() {
return vec![];
}
let mut parent: Vec<Option<usize>> = vec![None; nodes.len()];
let mut stack: Vec<usize> = vec![];
for (i, n) in nodes.iter().enumerate() {
while stack.last().is_some_and(|&p| nodes[p].level >= n.level) {
stack.pop();
}
parent[i] = stack.last().copied();
stack.push(i);
}
let asst = |i: usize| nodes[i].assistant && parent[i].is_some();
let kids = |i: Option<usize>| -> Vec<usize> { (0..nodes.len()).filter(|&k| parent[k] == i && !asst(k)).collect() };
let mut leaves = vec![0usize; nodes.len()];
for i in (0..nodes.len()).rev() {
let k = kids(Some(i));
leaves[i] = if k.is_empty() { 1 } else { k.iter().map(|&c| leaves[c]).sum() };
}
// Row slots: a node's level is 2 per depth; assistants take the odd slot under their parent.
let mut slot = vec![0usize; nodes.len()];
for i in 0..nodes.len() {
slot[i] = match parent[i] {
None => 0,
Some(p) if asst(i) => slot[p] + 1,
Some(p) => slot[p] + 2,
};
}
let mut used: Vec<usize> = slot.clone();
used.sort_unstable();
used.dedup();
let row = |i: usize| used.iter().position(|&s| s == slot[i]).unwrap_or(0);
let rows = used.len().max(1);
let total: usize = kids(None).iter().map(|&r| leaves[r]).sum::<usize>().max(1);
let cell_w = area.w / total as f64;
let row_h = area.h / rows as f64;
let (bw, bh) = ((cell_w * 0.85).min(area.w * 0.3), row_h * 0.62);
let mut left = vec![0usize; nodes.len()];
let mut next_root = 0;
for i in 0..nodes.len() {
if asst(i) {
continue;
}
let mut start = match parent[i] {
None => {
let s = next_root;
next_root += leaves[i];
s
}
Some(p) => left[p],
};
if let Some(p) = parent[i] {
for s in kids(Some(p)) {
if s == i {
break;
}
start += leaves[s];
}
}
left[i] = start;
}
let mut side = vec![0usize; nodes.len()];
let boxes: Vec<Xfrm> = (0..nodes.len())
.map(|i| {
let y = area.y + row(i) as f64 * row_h + (row_h - bh) / 2.0;
match parent[i] {
Some(p) if asst(i) => {
// Assistants alternate right and left of the parent's trunk.
let k = (0..i).filter(|&j| parent[j] == Some(p) && asst(j)).count();
side[i] = k;
let pc = area.x + (left[p] as f64 + leaves[p] as f64 / 2.0) * cell_w;
let off = bw * 0.15 + (k / 2) as f64 * bw * 1.1;
let x = if k % 2 == 0 { pc + off } else { pc - off - bw };
Xfrm::new(x.clamp(area.x, area.x + area.w - bw), y, bw, bh)
}
_ => {
let cx = area.x + (left[i] as f64 + leaves[i] as f64 / 2.0) * cell_w;
Xfrm::new(cx - bw / 2.0, y, bw, bh)
}
}
})
.collect();
let mut out: Vec<Item> = vec![];
for i in 0..nodes.len() {
let Some(p) = parent[i] else { continue };
let (a, b) = (boxes[p], boxes[i]);
let pc = a.x + a.w / 2.0;
if asst(i) {
let my = b.y + b.h / 2.0;
out.push((line_shape((pc, a.y + a.h), (pc, my)), vec![]));
let ex = if b.x > pc { b.x } else { b.x + b.w };
out.push((line_shape((pc, my), (ex, my)), vec![]));
} else {
let mid = b.y - (row_h - bh) / 2.0;
let cc = b.x + b.w / 2.0;
out.push((line_shape((pc, a.y + a.h), (pc, mid)), vec![]));
out.push((line_shape((pc.min(cc), mid), (pc.max(cc), mid)), vec![]));
out.push((line_shape((cc, mid), (cc, b.y)), vec![]));
}
}
let _ = side;
for (i, n) in nodes.iter().enumerate() {
let depth = slot[i] / 2;
if half_circle {
// Two half rings round the text: the upper left one in the accent, the lower right one
// in its tint.
let x = boxes[i];
for (k, (a0, a1)) in [(140.0, 310.0), (320.0, 130.0)].into_iter().enumerate() {
let mut arc = deco("blockArc", x, 0.0);
arc.geom = Geom::Preset { name: "blockArc".into(), adj: vec![a0 * 60_000.0, a1 * 60_000.0, 4_000.0] };
paint(d, &mut arc, depth, false);
if k == 1 {
let c = ColorRef::scheme(accent(d, depth)).with(ColorTransform::LumMod(60_000)).with(ColorTransform::LumOff(40_000));
arc.fill = Some(Fill::solid(c));
}
arc.line = Some(Line { fill: Some(Fill::None), ..Default::default() });
arc.effects = None;
out.push((arc, vec![]));
}
let mut t = text_shape("rect", x, &[n], true);
paint(d, &mut t, depth, true);
t.fill = Some(Fill::None);
t.line = Some(Line { fill: Some(Fill::None), ..Default::default() });
out.push((t, vec![i]));
} else {
let mut x = boxes[i];
if name_title {
// The name in the upper part; the title box overlaps the lower edge.
x.h *= 0.85;
}
let mut sh = text_shape("rect", x, &[n], true);
paint(d, &mut sh, depth, false);
out.push((sh, vec![i]));
if name_title {
let tb = Xfrm::new(x.x + x.w * 0.12, x.y + x.h * 0.72, x.w * 0.8, x.h * 0.42);
let title = DiagramNode { text: n.title.clone(), ..Default::default() };
let mut t = text_shape("rect", tb, &[&title], true);
paint(d, &mut t, depth, true);
t.fill = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Lt1)));
if let Some(b) = &mut t.text {
for p in &mut b.paragraphs {
for r in &mut p.runs {
r.props.size = Some(11.0);
}
p.end_props.size = Some(11.0);
}
}
out.push((t, vec![]));
}
}
}
out
}
/// An organisation chart: one box per node, rows by level, each subtree as wide as its leaves.
pub(super) fn hierarchy(d: &Diagram, area: Xfrm, horizontal: bool) -> Vec<Item> {
let nodes = &d.nodes[..d.nodes.len().min(MAX_SHAPES)];
if nodes.is_empty() {
return vec![];
}
let mut parent: Vec<Option<usize>> = vec![None; nodes.len()];
let mut stack: Vec<usize> = vec![];
for (i, n) in nodes.iter().enumerate() {
while stack.last().is_some_and(|&p| nodes[p].level >= n.level) {
stack.pop();
}
parent[i] = stack.last().copied();
stack.push(i);
}
let kids = |i: Option<usize>| -> Vec<usize> { (0..nodes.len()).filter(|&k| parent[k] == i).collect() };
let mut leaves = vec![0usize; nodes.len()];
for i in (0..nodes.len()).rev() {
let k = kids(Some(i));
leaves[i] = if k.is_empty() { 1 } else { k.iter().map(|&c| leaves[c]).sum() };
}
let depth: Vec<usize> = (0..nodes.len())
.map(|i| {
let (mut d, mut p) = (0, parent[i]);
while let Some(q) = p {
d += 1;
p = parent[q];
}
d
})
.collect();
let rows = depth.iter().max().copied().unwrap_or(0) + 1;
let total: usize = kids(None).iter().map(|&r| leaves[r]).sum::<usize>().max(1);
// Horizontal Hierarchy: the same tree turned a quarter, levels from left to right.
let (span, across) = if horizontal { (area.h, area.w) } else { (area.w, area.h) };
let cell_w = span / total as f64;
let row_h = across / rows as f64;
let (bw, bh) = if horizontal { (cell_w * 0.8, row_h * 0.7) } else { (cell_w * 0.85, row_h * 0.6) };
// Left edge (in leaves) of each node's span.
let mut left = vec![0usize; nodes.len()];
let mut next_root = 0;
for i in 0..nodes.len() {
let mut start = match parent[i] {
None => {
let s = next_root;
next_root += leaves[i];
s
}
Some(p) => left[p],
};
if let Some(p) = parent[i] {
for s in kids(Some(p)) {
if s == i {
break;
}
start += leaves[s];
}
}
left[i] = start;
}
let boxes: Vec<Xfrm> = (0..nodes.len())
.map(|i| {
let c = (left[i] as f64 + leaves[i] as f64 / 2.0) * cell_w;
let r = depth[i] as f64 * row_h + (row_h - bh) / 2.0;
if horizontal { Xfrm::new(area.x + r, area.y + c - bw / 2.0, bh, bw) } else { Xfrm::new(area.x + c - bw / 2.0, area.y + r, bw, bh) }
})
.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]);
let (p, q) = if horizontal {
((a.x + a.w, a.y + a.h / 2.0), (b.x, b.y + b.h / 2.0))
} else {
((a.x + a.w / 2.0, a.y + a.h), (b.x + b.w / 2.0, b.y))
};
out.push((line_shape(p, q), vec![]));
}
}
out
}
+375
View File
@@ -0,0 +1,375 @@
use super::nodes::{add, demote_node, nodes_from_lines, normalize, promote_node, remove, sync_text};
use super::paint::layout;
use super::*;
/// The SmartArt group that `id` is, or is inside.
fn diagram_group(shapes: &[Shape], id: ShapeId) -> Option<&Shape> {
for sh in shapes {
if sh.id == id && sh.diagram.is_some() {
return Some(sh);
}
if let ShapeKind::Group { children, .. } = &sh.kind {
if sh.diagram.is_some() && deckcraft_model::find_shape(children, id).is_some() {
return Some(sh);
}
if let Some(g) = diagram_group(children, id) {
return Some(g);
}
}
}
None
}
/// The SmartArt graphic the selection is (or is inside), with its outline as the Text Pane shows it.
pub fn selected(st: &crate::DocState) -> Option<(ShapeId, Diagram)> {
let shapes = current_shapes(&st.doc, &st.selection)?;
let g = st.selection.shapes.iter().find_map(|id| diagram_group(shapes, *id))?;
let mut d = g.diagram.clone()?;
if let ShapeKind::Group { children, .. } = &g.kind {
sync_text(&mut d, children);
}
Some((g.id, d))
}
fn target_group(s: &Session, p: &Value, cmd: &str) -> Result<ShapeId> {
let st = s.doc()?;
let shapes = current_shapes(&st.doc, &st.selection).ok_or_else(|| bad(cmd, "no slide"))?;
let ids = targets(s, p)?;
ids.iter().find_map(|id| diagram_group(shapes, *id)).map(|g| g.id).ok_or_else(|| bad(cmd, "select a SmartArt graphic"))
}
/// Children with fresh ids for diagram `d` inside a group whose child space is `area`.
fn regenerate(cmd: &str, doc: &mut Presentation, d: &Diagram, area: Xfrm) -> Result<Vec<Shape>> {
let mut v = layout(d, area);
for sh in &mut v {
sh.id = ShapeId(doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?);
}
Ok(v)
}
/// Read the diagram (with typed text synced), let `f` change it, and regenerate the group.
fn edit_diagram<T>(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&mut Diagram) -> Result<T>) -> Result<T> {
let gid = target_group(s, p, cmd)?;
s.edit(|doc, sel| {
let g = current_shapes(doc, sel).and_then(|v| deckcraft_model::find_shape(v, gid)).cloned().ok_or_else(|| bad(cmd, "no such shape"))?;
let (ShapeKind::Group { children, child }, Some(mut d)) = (g.kind, g.diagram) else { return Err(bad(cmd, "not a SmartArt graphic")) };
sync_text(&mut d, &children);
let out = f(&mut d)?;
normalize(&mut d.nodes);
d.imported = false;
let kids = regenerate(cmd, doc, &d, child)?;
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(cmd, "no slide"))?;
let sh = deckcraft_model::find_shape_mut(shapes, gid).ok_or_else(|| bad(cmd, "no such shape"))?;
sh.kind = ShapeKind::Group { children: kids, child };
sh.diagram = Some(d);
sel.shapes = vec![gid];
sel.text = None;
Ok(out)
})
}
fn index(p: &Value, cmd: &str) -> Result<usize> {
p.get("index").and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok()).ok_or_else(|| bad(cmd, "index is required"))
}
/// Insert a new SmartArt graphic (`insert.smartArt`).
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, picture: None, assistant: false, title: String::new() }).collect();
}
normalize(&mut nodes);
let d = Diagram { kind: kind.into(), nodes, ..Default::default() };
let size = s.doc()?.doc.slide_size;
let area = Xfrm::new(size.width * 0.1, size.height * 0.28, size.width * 0.8, size.height * 0.6);
let id = s.edit(|doc, sel| {
let children = regenerate("insert.smartArt", doc, &d, area)?;
let id = ShapeId(doc.alloc_id().ok_or_else(|| bad("insert.smartArt", NO_ID_SPACE))?);
let g = Shape {
id,
name: format!("SmartArt ({kind})"),
xfrm: Some(area),
kind: ShapeKind::Group { children, child: area },
diagram: Some(d),
..Default::default()
};
crate::shapes_mut(doc, sel).ok_or_else(|| EngineError::Other("no slide".into()))?.push(g);
sel.shapes = vec![id];
sel.text = None;
Ok(id)
})?;
Ok(json!({"id": id.0}))
}
pub(super) fn nodes(s: &mut Session, p: &Value) -> Result<Value> {
let gid = target_group(s, p, "smartArt.nodes")?;
let g = shape_of(s, gid)?;
let mut d = g.diagram.unwrap_or_default();
if let ShapeKind::Group { children, .. } = &g.kind {
sync_text(&mut d, children);
}
Ok(json!({"id": gid.0, "kind": d.kind, "nodes": d.nodes}))
}
pub(super) fn set_nodes(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "smartArt.setNodes";
let given: Option<Vec<DiagramNode>> = match (p.get("nodes"), p.get("lines").and_then(Value::as_array)) {
(Some(v), _) => Some(serde_json::from_value(v.clone()).map_err(|e| bad(CMD, e.to_string()))?),
(None, Some(a)) => Some(nodes_from_lines(a.iter().filter_map(Value::as_str))),
_ => None,
};
let one = p.get("index").map(|_| index(p, CMD)).transpose()?;
let text = str_param(p, "text").map(String::from);
let title = str_param(p, "title").map(String::from);
edit_diagram(s, p, CMD, |d| {
if let Some(n) = given {
d.nodes = n;
}
if let (Some(i), Some(t)) = (one, text) {
d.nodes.get_mut(i).ok_or_else(|| bad(CMD, "no such node"))?.text = t;
}
if let (Some(i), Some(t)) = (one, title) {
d.nodes.get_mut(i).ok_or_else(|| bad(CMD, "no such node"))?.title = t;
}
Ok(Value::Null)
})
}
pub(super) fn add_node(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok());
let pos = str_param(p, "position").unwrap_or("after").to_string();
let text = str_param(p, "text").unwrap_or("").to_string();
edit_diagram(s, p, "smartArt.addNode", |d| {
let at = add(&mut d.nodes, i, &pos, &text);
Ok(json!({"index": at}))
})
}
pub(super) fn remove_node(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "smartArt.removeNode";
let i = index(p, CMD)?;
edit_diagram(s, p, CMD, |d| if remove(&mut d.nodes, i) { Ok(Value::Null) } else { Err(bad(CMD, "no such node")) })
}
pub(super) fn promote(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "smartArt.promote";
let i = index(p, CMD)?;
edit_diagram(s, p, CMD, |d| if promote_node(&mut d.nodes, i) { Ok(Value::Null) } else { Err(bad(CMD, "already at the top level")) })
}
pub(super) fn demote(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "smartArt.demote";
let i = index(p, CMD)?;
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")) })
}
/// Convert to SmartArt: the selected text shape's paragraphs become the nodes of a new graphic in
/// the shape's place.
pub(super) fn from_text(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "smartArt.fromText";
let kind = str_param(p, "kind").filter(|k| is_kind(k)).unwrap_or("list").to_string();
let id = *targets(s, p)?.first().ok_or_else(|| bad(CMD, "select a text box"))?;
let (area, nodes) = {
let st = s.doc()?;
let shapes = current_shapes(&st.doc, &st.selection).ok_or_else(|| bad(CMD, "no slide"))?;
let sh = shapes.iter().find(|x| x.id == id).ok_or_else(|| bad(CMD, "select a text box on the slide"))?;
let body =
sh.text.as_ref().filter(|b| b.paragraphs.iter().any(|p| !p.text().trim().is_empty())).ok_or_else(|| bad(CMD, "the shape has no text"))?;
let mut nodes: Vec<DiagramNode> = body
.paragraphs
.iter()
.filter(|p| !p.text().trim().is_empty())
.map(|p| DiagramNode { text: p.text().trim().to_string(), level: p.level.min(MAX_LEVEL), ..Default::default() })
.collect();
normalize(&mut nodes);
// A small text box grows to a size a diagram can use, keeping its top left on the slide.
let mut a = xfrm_of(&st.doc, &st.selection, sh);
let size = st.doc.slide_size;
a.w = a.w.max(size.width * 0.5).min(size.width);
a.h = a.h.max(size.height * 0.35).min(size.height);
a.x = a.x.min(size.width - a.w).max(0.0);
a.y = a.y.min(size.height - a.h).max(0.0);
a.rot = 0.0;
(a, nodes)
};
let d = Diagram { kind: kind.clone(), nodes, ..Default::default() };
let gid = s.edit(|doc, sel| {
let children = regenerate(CMD, doc, &d, area)?;
let gid = ShapeId(doc.alloc_id().ok_or_else(|| bad(CMD, NO_ID_SPACE))?);
let g = Shape {
id: gid,
name: format!("SmartArt ({kind})"),
xfrm: Some(area),
kind: ShapeKind::Group { children, child: area },
diagram: Some(d),
..Default::default()
};
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(CMD, "no slide"))?;
let at = shapes.iter().position(|x| x.id == id).ok_or_else(|| bad(CMD, "no such shape"))?;
// In the text shape's place in the z-order.
shapes[at] = g;
sel.shapes = vec![gid];
sel.text = None;
Ok(gid)
})?;
Ok(json!({"id": gid.0}))
}
/// Convert to Text: a text box with the outline, one bulleted paragraph per node.
pub(super) fn to_text(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "smartArt.toText";
let gid = target_group(s, p, CMD)?;
let (_, d) = selected_for(s, gid).ok_or_else(|| bad(CMD, "select a SmartArt graphic"))?;
// The sizes the graphic's text shows at: its titles' and its bullets' (shrunk to fit included).
let (title_pt, bullet_pt) = {
let st = s.doc()?;
let shapes = current_shapes(&st.doc, &st.selection).ok_or_else(|| bad(CMD, "no slide"))?;
let g = diagram_group(shapes, gid).ok_or_else(|| bad(CMD, "select a SmartArt graphic"))?;
text_sizes(g.children())
};
let new = s.edit(|doc, sel| {
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(CMD, "no slide"))?;
let at = shapes.iter().position(|x| x.id == gid).ok_or_else(|| bad(CMD, "the graphic isn't at the top of the slide"))?;
let area = shapes[at].xfrm.unwrap_or_default();
let mut body = TextBody::default();
for n in &d.nodes {
let mut para = Paragraph::new(&n.text);
para.level = n.level.min(MAX_LEVEL);
let size = if n.level == 0 { title_pt } else { bullet_pt };
for r in &mut para.runs {
r.props.size = Some(size);
}
para.end_props.size = Some(size);
para.props.bullet = Some(deckcraft_model::text::Bullet::Char { char: "•".into() });
para.props.margin_left = Some(18.0 * (para.level as f64 + 1.0));
para.props.indent = Some(-14.0);
body.paragraphs.push(para);
}
if body.paragraphs.is_empty() {
body.paragraphs.push(Paragraph::new(""));
}
body.body.wrap = Some(true);
body.body.autofit = Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 });
let id = ShapeId(doc.alloc_id().ok_or_else(|| bad(CMD, NO_ID_SPACE))?);
let tb =
Shape { id, name: "Text".into(), xfrm: Some(area), text_box: true, text: Some(body), geom: Geom::preset("rect"), ..Default::default() };
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(CMD, "no slide"))?;
shapes[at] = tb;
sel.shapes = vec![id];
sel.text = None;
Ok(id)
})?;
Ok(json!({"id": new.0}))
}
/// Convert to Shapes: keep the drawing as an ordinary group.
pub(super) fn to_shapes(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "smartArt.toShapes";
let gid = target_group(s, p, CMD)?;
s.edit(|doc, sel| {
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(CMD, "no slide"))?;
let g = deckcraft_model::find_shape_mut(shapes, gid).ok_or_else(|| bad(CMD, "no such shape"))?;
g.diagram = None;
g.name = "Group".into();
sel.shapes = vec![gid];
Ok(())
})?;
Ok(Value::Null)
}
/// The most common title and bullet font sizes in a diagram's shapes (20 and 14 pt when none).
fn text_sizes(children: &[Shape]) -> (f64, f64) {
let mut titles: Vec<f64> = vec![];
let mut bullets: Vec<f64> = vec![];
for c in children {
let Some(b) = &c.text else { continue };
for p in &b.paragraphs {
let Some(sz) = p.runs.iter().find_map(|r| r.props.size) else { continue };
if p.props.bullet.is_some() { bullets.push(sz) } else { titles.push(sz) }
}
}
let mode = |v: &mut Vec<f64>, d: f64| {
v.sort_by(|a, b| a.total_cmp(b));
let mut best = (d, 0usize);
let mut k = 0;
while k < v.len() {
let j = v[k..].iter().take_while(|x| (**x - v[k]).abs() < 1e-9).count();
if j > best.1 {
best = (v[k], j);
}
k += j;
}
best.0
};
(mode(&mut titles, 20.0), mode(&mut bullets, 14.0))
}
/// The graphic `gid` with its outline (typed text synced).
fn selected_for(s: &Session, gid: ShapeId) -> Option<(ShapeId, Diagram)> {
let st = s.doc().ok()?;
let shapes = current_shapes(&st.doc, &st.selection)?;
let g = diagram_group(shapes, gid)?;
let mut d = g.diagram.clone()?;
if let ShapeKind::Group { children, .. } = &g.kind {
sync_text(&mut d, children);
}
Some((g.id, d))
}
pub(super) 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) = crate::cmd::insert::media_bytes(p, CMD)?;
let ct = crate::cmd::insert::content_type(&name, &bytes);
target_group(s, p, CMD)?;
Some(s.edit(|doc, _| doc.add_media(&name, ct, bytes).ok_or_else(|| bad(CMD, NO_ID_SPACE)))?)
};
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)
})
}
pub(super) 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)
})
}
pub(super) 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)
})
}
pub(super) fn change_layout(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "smartArt.layout";
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.
d.layout_id.clear();
d.layout_xml = None;
}
d.kind = kind;
Ok(Value::Null)
})
}
+752
View File
@@ -0,0 +1,752 @@
use super::diagrams::{hierarchy, org_chart};
use super::nodes::{Item, groups};
use super::paint::{accent, deco, line_shape, paint, picture_shape, text_shape};
use super::*;
pub(super) 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);
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, false),
"horizontalHierarchy" => return hierarchy(d, area, true),
"orgChart" | "halfCircleOrgChart" | "nameTitleOrgChart" => return org_chart(d, area),
"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 * 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);
}
}
}
"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);
}
}
"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.
// 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()));
}
}
"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);
}
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()));
}
}
"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);
}
}
}
"textCycle" | "continuousCycle" => {
// Text Cycle: the text alone around a circle, arrows between. Continuous Cycle: boxes on
// a ring.
let text_only = d.kind == "textCycle";
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.8).min(2.0 * std::f64::consts::PI * r / nf * 0.62);
if !text_only {
let ring = r * 2.0 + dd * 0.3;
let mut sh = deco("donut", Xfrm::new(cx - ring / 2.0, cy - ring / 2.0, ring, ring), 0.0);
sh.geom = Geom::Preset { name: "donut".into(), adj: vec![7000.0] };
paint(d, &mut sh, 0, true);
out.push((sh, vec![]));
}
for (i, g) in gs.iter().enumerate() {
let a = (i as f64 / nf * 360.0 - 90.0).to_radians();
let (w, h) = (dd * 1.35, dd * 0.7);
let mut sh = text_shape("roundRect", Xfrm::new(cx + r * a.cos() - w / 2.0, cy + r * a.sin() - h / 2.0, w, h), &nodes(g), true);
paint(d, &mut sh, i, false);
if text_only {
sh.fill = Some(Fill::None);
sh.line = Some(Line { fill: Some(Fill::None), ..Default::default() });
sh.effects = None;
let c = ColorRef::scheme(accent(d, i)).with(ColorTransform::LumMod(75_000));
if let Some(b) = &mut sh.text {
for p in &mut b.paragraphs {
for r in &mut p.runs {
r.props.fill = Some(Fill::solid(c.clone()));
}
}
}
if n > 1 {
let m = ((i as f64 + 0.5) / nf * 360.0 - 90.0).to_radians();
let s = (dd * 0.3).max(6.0);
arrow(
&mut out,
Xfrm::new(cx + r * m.cos() - s / 2.0, cy + r * m.sin() - s * 0.4, s, s * 0.8),
(i as f64 + 0.5) / nf * 360.0,
i,
);
}
}
out.push((sh, g.clone()));
}
}
"segmentedCycle" | "pie" => {
// Segmented Cycle: a ring cut into one arc per node. Basic Pie: a disc cut into wedges.
// The text sits in a clear box over each segment's middle.
let ring = d.kind == "segmentedCycle";
let s = area.h.min(area.w);
let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0);
let gap = if ring { 2.0 } else { 0.0 };
for (i, g) in gs.iter().enumerate() {
// Angles clockwise from 3 o'clock, starting at the top.
let a0 = i as f64 / nf * 360.0 - 90.0 + gap / 2.0;
let a1 = (i as f64 + 1.0) / nf * 360.0 - 90.0 - gap / 2.0;
let mut sh = deco(if ring { "blockArc" } else { "pie" }, Xfrm::new(cx - s / 2.0, cy - s / 2.0, s, s), 0.0);
let ang = |a: f64| (a.rem_euclid(360.0) * 60_000.0).round();
// blockArc runs from its first angle to its second clockwise; so does pie.
sh.geom = Geom::Preset {
name: if ring { "blockArc" } else { "pie" }.into(),
adj: if ring { vec![ang(a0), ang(a1), 25_000.0] } else { vec![ang(a0), ang(a1)] },
};
paint(d, &mut sh, i, false);
out.push((sh, vec![]));
let m = ((a0 + a1) / 2.0).to_radians();
let rr = if ring { s * 0.4375 } else { s * 0.3 };
let tw = (s * if ring { 0.22 } else { 0.3 }).min(std::f64::consts::PI * 2.0 * rr / nf * 0.9);
let th = s * 0.16;
let mut t = text_shape("rect", Xfrm::new(cx + rr * m.cos() - tw / 2.0, cy + rr * m.sin() - th / 2.0, tw, th), &nodes(g), true);
paint(d, &mut t, i, false);
t.fill = Some(Fill::None);
t.line = Some(Line { fill: Some(Fill::None), ..Default::default() });
t.effects = None;
out.push((t, g.clone()));
}
}
"multidirectionalCycle" => {
// Multidirectional Cycle: nodes around a circle, double-headed arrows between them.
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.75).min(2.0 * std::f64::consts::PI * r / nf * 0.55);
for (i, g) in gs.iter().enumerate() {
let a = (i as f64 / nf * 360.0 - 90.0).to_radians();
titled(&mut out, "roundRect", Xfrm::new(cx + r * a.cos() - dd * 0.65, cy + r * a.sin() - dd * 0.4, dd * 1.3, dd * 0.8), i, g);
if n > 1 {
let m = ((i as f64 + 0.5) / nf * 360.0 - 90.0).to_radians();
let s = (dd * 0.5).max(8.0);
let mut sh = deco(
"leftRightArrow",
Xfrm::new(cx + r * m.cos() - s / 2.0, cy + r * m.sin() - s * 0.2, s, s * 0.4),
(i as f64 + 0.5) / nf * 360.0,
);
paint(d, &mut sh, i, true);
out.push((sh, vec![]));
}
}
}
"cycleMatrix" => {
// Cycle Matrix: four quarters of a circle in the middle with the top-level text, each
// quarter's sub-points in a box at its corner.
let s = area.h.min(area.w);
let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0);
let c = s * 0.56;
for (i, g) in gs.iter().take(4).enumerate() {
let (sx, sy) = ([-1.0, 1.0, 1.0, -1.0][i], [-1.0, -1.0, 1.0, 1.0][i]);
let b = s * 0.4;
let bx = Xfrm::new(cx + if sx < 0.0 { -s / 2.0 } else { s / 2.0 - b }, cy + if sy < 0.0 { -s / 2.0 } else { s / 2.0 - b }, b, b);
if g.len() > 1 {
let mut sh = text_shape("roundRect", bx, &nodes(&g[1..]), false);
paint(d, &mut sh, i, true);
if let Some(body) = &mut sh.text {
// Text in the box's outer part, clear of the circle.
body.body.inset_l = Some(if sx > 0.0 { b * 0.35 } else { 6.0 });
body.body.inset_r = Some(if sx < 0.0 { b * 0.35 } else { 6.0 });
body.body.anchor = Some(if sy < 0.0 { deckcraft_model::text::Anchor::Top } else { deckcraft_model::text::Anchor::Bottom });
}
out.push((sh, g[1..].to_vec()));
}
}
for (i, g) in gs.iter().take(4).enumerate() {
let (sx, sy) = ([-1.0, 1.0, 1.0, -1.0][i], [-1.0, -1.0, 1.0, 1.0][i]);
let a0 = [180.0, 270.0, 0.0, 90.0][i] + 1.5;
let mut sh = deco("pie", Xfrm::new(cx - c / 2.0, cy - c / 2.0, c, c), 0.0);
sh.geom = Geom::Preset { name: "pie".into(), adj: vec![a0 * 60_000.0, (a0 + 87.0) * 60_000.0] };
paint(d, &mut sh, i, false);
out.push((sh, vec![]));
let q = c * 0.3;
let mut t = text_shape("rect", Xfrm::new(cx + sx * c * 0.2 - q / 2.0, cy + sy * c * 0.2 - q / 2.0, q, q), &nodes(&g[..1]), true);
paint(d, &mut t, i, false);
t.fill = Some(Fill::None);
t.line = Some(Line { fill: Some(Fill::None), ..Default::default() });
t.effects = None;
out.push((t, g[..1].to_vec()));
}
}
"segmentedPyramid" => {
// Segmented Pyramid: a triangle cut into rows of triangles (1, 3, 5, …), one per node,
// every other one upside down.
let rows = (nf.sqrt().ceil() as usize).max(1);
let s = area.h.min(area.w / 1.15);
let pw = s * 1.15;
let (x0, y0) = (area.x + (area.w - pw) / 2.0, area.y + (area.h - s) / 2.0);
let (tw, th) = (pw / rows as f64, s / rows as f64);
let mut k = 0;
'rows: for r in 0..rows {
let left = x0 + pw / 2.0 - (r as f64 + 1.0) * tw / 2.0;
for c in 0..(2 * r + 1) {
let Some(g) = gs.get(k) else { break 'rows };
let x = Xfrm::new(left + c as f64 * tw / 2.0, y0 + r as f64 * th, tw, th);
let mut sh = text_shape("rect", x, &nodes(g), true);
// Drawn as a polygon (not a flipped preset) so upside-down text stays upright.
let path = if c % 2 == 1 {
format!("M 0 0 L {tw:.2} 0 L {:.2} {th:.2} Z", tw / 2.0)
} else {
format!("M {:.2} 0 L {tw:.2} {th:.2} L 0 {th:.2} Z", tw / 2.0)
};
sh.geom = Geom::Custom {
paths: vec![deckcraft_model::CustomPath {
w: tw,
h: th,
d: path,
fill: deckcraft_geom::preset::FillMode::Norm,
stroke: true,
}],
};
if let Some(b) = &mut sh.text {
// The text in the triangle's wide half.
b.body.inset_l = Some(tw * 0.22);
b.body.inset_r = Some(tw * 0.22);
if c % 2 == 1 {
b.body.anchor = Some(deckcraft_model::text::Anchor::Top);
b.body.inset_b = Some(th * 0.35);
} else {
b.body.anchor = Some(deckcraft_model::text::Anchor::Bottom);
b.body.inset_t = Some(th * 0.35);
}
}
paint(d, &mut sh, k, false);
out.push((sh, g.clone()));
k += 1;
}
}
}
"verticalBoxList" => {
// Vertical Box List: a pale box per node with its sub-points, its title on a tab
// overlapping the box's top left.
let h = area.h / nf;
for (i, g) in gs.iter().enumerate() {
let y = area.y + i as f64 * h;
let tab_h = (h * 0.3).min(40.0);
let mut bx = text_shape("rect", Xfrm::new(area.x, y + tab_h / 2.0, area.w, h * 0.9 - tab_h / 2.0), &nodes(&g[1..]), false);
paint(d, &mut bx, i, true);
if let Some(b) = &mut bx.text {
b.body.inset_t = Some(tab_h * 0.6);
}
out.push((bx, g[1..].to_vec()));
titled(&mut out, "roundRect", Xfrm::new(area.x + area.w * 0.05, y, area.w * 0.6, tab_h), i, &g[..1]);
}
}
"trapezoidList" | "tableList" => {
// Trapezoid List: a column per node, each a trapezoid with its sub-points. Table List: the
// first node as a header bar over a column per other node.
let table = d.kind == "tableList";
let (head, cols): (Option<&Vec<usize>>, &[Vec<usize>]) =
if table { gs.split_first().map(|(h, r)| (Some(h), r)).unwrap_or((None, &[])) } else { (None, &gs) };
let top = if head.is_some() { area.h * 0.28 } else { 0.0 };
if let Some(h) = head {
titled(&mut out, "rect", Xfrm::new(area.x, area.y, area.w, top * 0.9), 0, h);
}
let m = cols.len().max(1) as f64;
let w = area.w / m;
for (k, g) in cols.iter().enumerate() {
let i = k + usize::from(table);
let x = Xfrm::new(area.x + k as f64 * w + w * 0.02, area.y + top, w * 0.96, area.h - top);
let mut sh = text_shape(if table { "rect" } else { "trapezoid" }, x, &nodes(g), true);
if let Some(b) = &mut sh.text {
b.body.anchor = Some(deckcraft_model::text::Anchor::Top);
if !table {
b.body.inset_l = Some(x.w * 0.2);
b.body.inset_r = Some(x.w * 0.2);
b.body.inset_t = Some(x.h * 0.08);
}
}
paint(d, &mut sh, i, table);
out.push((sh, g.clone()));
}
}
"accentProcess" => {
// Accent Process: a title box per step with its sub-points in a pale box below and to the
// right, arrows between the titles.
let gap = area.w * 0.05;
let w = (area.w - gap * (nf - 1.0)) / nf;
for (i, g) in gs.iter().enumerate() {
let x = area.x + i as f64 * (w + gap);
let th = area.h * 0.22;
if g.len() > 1 {
let mut sh = text_shape("rect", Xfrm::new(x + w * 0.12, area.y + th * 0.7, w * 0.88, area.h - th * 0.7), &nodes(&g[1..]), false);
paint(d, &mut sh, i, true);
if let Some(b) = &mut sh.text {
b.body.inset_t = Some(th * 0.45);
}
out.push((sh, g[1..].to_vec()));
}
titled(&mut out, "roundRect", Xfrm::new(x, area.y, w * 0.85, th), i, &g[..1]);
if i + 1 < n {
let a = gap * 0.8;
arrow(&mut out, Xfrm::new(x + w + (gap - a) / 2.0 - w * 0.07, area.y + th / 2.0 - a / 2.0, a, a), 0.0, i);
}
}
}
"continuousBlockProcess" => {
// Continuous Block Process: one big pale arrow behind a row of boxes.
let mut big = deco("rightArrow", Xfrm::new(area.x, area.y + area.h * 0.15, area.w, area.h * 0.7), 0.0);
paint(d, &mut big, 0, true);
out.push((big, vec![]));
let w = area.w * 0.85 / nf;
let h = area.h * 0.32;
for (i, g) in gs.iter().enumerate() {
let x = area.x + area.w * 0.02 + i as f64 * w;
titled(&mut out, "roundRect", Xfrm::new(x + w * 0.05, area.y + (area.h - h) / 2.0, w * 0.9, h), i, g);
}
}
"bendingProcess" => {
// Basic Bending Process: rows of boxes read left to right, wrapping, arrows between.
let cols = ((nf * area.w / area.h.max(1.0) / 1.5).sqrt().ceil() as usize).clamp(1, n);
let rows = n.div_ceil(cols);
let (cw, rh) = (area.w / cols as f64, area.h / rows as f64);
let (bw, bh) = (cw * 0.72, (rh * 0.75).min(cw * 0.72 * 0.6));
for (i, g) in gs.iter().enumerate() {
let (c, r) = (i % cols, i / cols);
let x = area.x + c as f64 * cw;
let y = area.y + r as f64 * rh + (rh - bh) / 2.0;
titled(&mut out, "rect", Xfrm::new(x, y, bw, bh), i, g);
if i + 1 < n {
let a = (cw - bw) * 0.7;
if c + 1 < cols {
arrow(&mut out, Xfrm::new(x + bw + (cw - bw - a) / 2.0, y + bh / 2.0 - a / 2.0, a, a), 0.0, i);
} else {
// The bend: down from the row's last box towards the next row's first.
let a = (rh - bh).min(a * 1.5).max(6.0);
arrow(&mut out, Xfrm::new(x + bw / 2.0 - a / 2.0, y + bh + (rh - bh - a) / 2.0, a, a), 90.0, i);
}
}
}
}
"linearVenn" | "stackedVenn" => {
// Linear Venn: overlapping circles in a row. Stacked Venn: circles nested from the bottom,
// the text near each one's top.
let linear = d.kind == "linearVenn";
if linear {
let dc = (area.w / (nf * 0.75 + 0.25)).min(area.h);
let step = dc * 0.75;
let x0 = area.x + (area.w - (step * (nf - 1.0) + dc)) / 2.0;
for (i, g) in gs.iter().enumerate() {
let mut sh = text_shape("ellipse", Xfrm::new(x0 + i as f64 * step, area.y + (area.h - dc) / 2.0, dc, dc), &nodes(g), true);
paint(d, &mut sh, i, false);
if let Some(Fill::Solid { color: c }) = &mut sh.fill {
*c = c.clone().with(ColorTransform::Alpha(55_000));
}
out.push((sh, g.clone()));
}
} else {
let s = area.h.min(area.w);
let (cx, bottom) = (area.x + area.w / 2.0, area.y + (area.h + s) / 2.0);
for (i, _) in gs.iter().enumerate() {
let dc = s * (1.0 - i as f64 / nf);
let mut sh = deco("ellipse", Xfrm::new(cx - dc / 2.0, bottom - dc, dc, dc), 0.0);
paint(d, &mut sh, i, false);
out.push((sh, vec![]));
}
// Each circle's text in the band between its top and the next circle's.
let band = s / nf;
for (i, g) in gs.iter().enumerate() {
let dc = s * (1.0 - i as f64 / nf);
let top = bottom - dc;
let h = if i + 1 < n { band } else { dc };
let w = dc * if i + 1 < n { 0.6 } else { 0.7 };
let mut t =
text_shape("rect", Xfrm::new(cx - w / 2.0, top + if i + 1 < n { band * 0.05 } else { 0.0 }, w, h * 0.9), &nodes(g), true);
paint(d, &mut t, i, false);
t.fill = Some(Fill::None);
t.line = Some(Line { fill: Some(Fill::None), ..Default::default() });
t.effects = None;
out.push((t, g.clone()));
}
}
}
"nestedTarget" => {
// Nested Target: rounded boxes nested inside each other, the text on each one's top band.
let band_x = area.w / 2.0 / nf * 0.6;
let band_y = area.h / nf * 0.9 / 2.0;
for (i, _) in gs.iter().enumerate() {
let (dx, dy) = (band_x * i as f64, band_y * i as f64);
let mut sh = deco("roundRect", Xfrm::new(area.x + dx, area.y + dy, area.w - 2.0 * dx, area.h - 2.0 * dy), 0.0);
paint(d, &mut sh, i, false);
out.push((sh, vec![]));
}
for (i, g) in gs.iter().enumerate() {
let (dx, dy) = (band_x * i as f64, band_y * i as f64);
let h = if i + 1 < n { band_y } else { area.h - 2.0 * dy };
let mut t = text_shape(
"rect",
Xfrm::new(area.x + dx + band_x * 0.3, area.y + dy, (area.w - 2.0 * dx - band_x * 0.6).max(1.0), h),
&nodes(g),
true,
);
paint(d, &mut t, i, false);
t.fill = Some(Fill::None);
t.line = Some(Line { fill: Some(Fill::None), ..Default::default() });
t.effects = None;
out.push((t, g.clone()));
}
}
"gridMatrix" => {
// Grid Matrix: four quadrants on crossed axes.
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 t = s * 0.06;
for (preset, x) in [("leftRightArrow", Xfrm::new(x0, y0 + (s - t) / 2.0, s, t)), ("upDownArrow", Xfrm::new(x0 + (s - t) / 2.0, y0, t, s))]
{
let mut sh = deco(preset, x, 0.0);
paint(d, &mut sh, 0, true);
out.push((sh, vec![]));
}
let q = s / 2.0 - t * 1.2;
for (i, g) in gs.iter().take(4).enumerate() {
let (c, r) = ((i % 2) as f64, (i / 2) as f64);
let x = Xfrm::new(x0 + c * (s / 2.0 + t * 0.7) + t * 0.5 * (1.0 - c), y0 + r * (s / 2.0 + t * 0.7) + t * 0.5 * (1.0 - r), q, q);
titled(&mut out, "rect", x, i, g);
}
}
"gear" => {
// Gear: up to three interlocking gears, the first the biggest.
let s = area.h.min(area.w * 0.62);
let (x0, y0) = (area.x + (area.w - s / 0.62) / 2.0, area.y + (area.h - s) / 2.0);
let spots = [(0.0, 0.18, 0.66, "gear9"), (0.5, 0.0, 0.48, "gear6"), (0.56, 0.48, 0.42, "gear6")];
for (i, g) in gs.iter().take(3).enumerate() {
let (fx, fy, fs, preset) = spots[i];
let z = s * fs;
titled(&mut out, preset, Xfrm::new(x0 + fx * s * 1.3, y0 + fy * s, z, z), i, g);
}
}
"funnel" => {
// Funnel: the ideas poured in at the top (up to three), the last one coming out below.
let s = area.h.min(area.w);
let (cx, y0) = (area.x + area.w / 2.0, area.y);
let fw = s * 0.8;
let fh = s * 0.62;
let mut f = deco("funnel", Xfrm::new(cx - fw / 2.0, y0, fw, fh), 0.0);
paint(d, &mut f, 0, true);
out.push((f, vec![]));
let (inside, last) = match gs.split_last() {
Some((l, r)) if !r.is_empty() => (r, Some(l)),
_ => (&gs[..], None),
};
let k = inside.len().min(3);
let dc = fw * 0.27;
let spots = [(0.0, 0.04), (-0.2, 0.3), (0.2, 0.3)];
for (i, g) in inside.iter().take(3).enumerate() {
let (dx, dy) = if k == 1 { (0.0, 0.15) } else { spots[i] };
titled(&mut out, "ellipse", Xfrm::new(cx + dx * fw - dc / 2.0, y0 + dy * fh + fh * 0.05, dc, dc), i, g);
}
if let Some(g) = last {
let a = s * 0.12;
let mut ar = deco("downArrow", Xfrm::new(cx - a / 2.0, y0 + fh, a, a * 1.2), 0.0);
paint(d, &mut ar, k, true);
out.push((ar, vec![]));
titled(&mut out, "roundRect", Xfrm::new(cx - fw * 0.35, y0 + fh + a * 1.3, fw * 0.7, (s - fh - a * 1.3).max(10.0)), k, g);
}
}
"pictureHList" => {
// Horizontal Picture List: a column per node, a big picture over its text.
let w = area.w / nf;
for (i, g) in gs.iter().enumerate() {
let x = area.x + i as f64 * w + w * 0.04;
let bw = w * 0.92;
let ph = area.h * 0.55;
out.push((picture_shape(d, "rect", Xfrm::new(x, area.y, bw, ph), i, g[0]), vec![]));
let mut sh = text_shape("rect", Xfrm::new(x, area.y + ph, bw, area.h - ph), &nodes(g), true);
if let Some(b) = &mut sh.text {
b.body.anchor = Some(deckcraft_model::text::Anchor::Top);
}
paint(d, &mut sh, i, false);
out.push((sh, g.clone()));
}
}
"timeline" => {
// Basic Timeline: an arrow across the middle, a dot per node on it, the text above and
// below in turn.
let mid = area.y + area.h / 2.0;
let bar = (area.h * 0.08).max(6.0);
let mut sh = deco("rightArrow", Xfrm::new(area.x, mid - bar, area.w, bar * 2.0), 0.0);
paint(d, &mut sh, 0, true);
out.push((sh, vec![]));
let w = area.w * 0.92 / nf;
for (i, g) in gs.iter().enumerate() {
let cx = area.x + w * (i as f64 + 0.5);
let dot = bar * 1.4;
let mut dsh = deco("ellipse", Xfrm::new(cx - dot / 2.0, mid - dot / 2.0, dot, dot), 0.0);
paint(d, &mut dsh, i, false);
out.push((dsh, vec![]));
let th = area.h / 2.0 - bar * 1.6;
let y = if i % 2 == 0 { area.y } else { mid + bar * 1.6 };
let mut t = text_shape("rect", Xfrm::new(cx - w * 0.48, y, w * 0.96, th.max(1.0)), &nodes(g), true);
if let Some(b) = &mut t.text {
b.body.anchor = Some(if i % 2 == 0 { deckcraft_model::text::Anchor::Bottom } else { deckcraft_model::text::Anchor::Top });
}
paint(d, &mut t, i, true);
t.fill = Some(Fill::None);
t.line = Some(Line { fill: Some(Fill::None), ..Default::default() });
out.push((t, g.clone()));
}
}
"venn" => {
// Basic Venn: see-through circles overlapping in a ring (two or three in a row/triangle).
let s = area.h.min(area.w);
let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0);
let dc = if n == 1 { s } else { s * 0.62 };
let r = if n == 1 { 0.0 } else { (s - dc) / 2.0 };
for (i, g) in gs.iter().enumerate() {
let a = (i as f64 / nf * 360.0 + if n == 2 { 180.0 } else { -90.0 }).to_radians();
let mut sh = text_shape("ellipse", Xfrm::new(cx + r * a.cos() - dc / 2.0, cy + r * a.sin() - dc / 2.0, dc, dc), &nodes(g), true);
paint(d, &mut sh, i, false);
if let Some(Fill::Solid { color: c }) = &mut sh.fill {
*c = c.clone().with(ColorTransform::Alpha(50_000));
}
if let Some(b) = &mut sh.text {
// Text towards the circle's outer side, clear of the overlap.
let p = dc * 0.36;
let (dx, dy) = (a.cos() * p, a.sin() * p);
b.body.inset_l = Some((7.2 + dx).max(2.0));
b.body.inset_r = Some((7.2 - dx).max(2.0));
b.body.inset_t = Some((3.6 + dy).max(2.0));
b.body.inset_b = Some((3.6 - dy).max(2.0));
}
out.push((sh, g.clone()));
}
}
"target" => {
// Basic Target: nested rings, the outermost first, each ring's text in a box beside it
// with a line from the ring.
let s = area.h.min(area.w * 0.5);
let (cx, cy) = (area.x + s / 2.0, area.y + area.h / 2.0);
let band = s / 2.0 / nf;
for (i, _) in gs.iter().enumerate() {
let dc = s - 2.0 * band * i as f64;
let mut sh = deco("ellipse", Xfrm::new(cx - dc / 2.0, cy - dc / 2.0, dc, dc), 0.0);
paint(d, &mut sh, i, false);
out.push((sh, vec![]));
}
let tx = area.x + s + area.w * 0.06;
let rh = area.h / nf;
for (i, g) in gs.iter().enumerate() {
let ty = area.y + rh * (i as f64 + 0.5);
// The point on ring i's band level with the text, else its top.
let ry = cy - s / 2.0 + band * (i as f64 + 0.5);
out.push((line_shape((cx, ry.min(ty)), (tx, ty)), vec![]));
let mut t = text_shape("rect", Xfrm::new(tx, ty - rh * 0.45, (area.x + area.w - tx).max(1.0), rh * 0.9), &nodes(g), true);
paint(d, &mut t, i, true);
t.fill = Some(Fill::None);
t.line = Some(Line { fill: Some(Fill::None), ..Default::default() });
if let Some(b) = &mut t.text {
for p in &mut b.paragraphs {
p.props.align = Some(Align::Left);
}
}
out.push((t, g.clone()));
}
}
_ => {}
}
out
}
+146
View File
@@ -0,0 +1,146 @@
use super::paint::layout_items;
use super::*;
/// Nodes from text lines: each leading tab is one outline level.
pub fn nodes_from_lines<'a>(lines: impl IntoIterator<Item = &'a str>) -> Vec<DiagramNode> {
lines
.into_iter()
.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),
picture: None,
assistant: false,
title: String::new(),
}
})
.collect()
}
/// Levels made consistent: the first node is top level and no node is more than one level
/// deeper than the node before it.
pub fn normalize(nodes: &mut [DiagramNode]) {
let mut prev: Option<u8> = None;
for n in nodes.iter_mut() {
n.level = match prev {
None => 0,
Some(p) => n.level.min(p.saturating_add(1)).min(MAX_LEVEL),
};
prev = Some(n.level);
}
}
/// Index one past the last descendant of node `i`.
fn subtree_end(nodes: &[DiagramNode], i: usize) -> usize {
let lv = nodes.get(i).map(|n| n.level).unwrap_or(0);
let mut j = i + 1;
while nodes.get(j).is_some_and(|n| n.level > lv) {
j += 1;
}
j
}
/// Add a node next to node `index` (PowerPoint's Add Shape After/Before/Below/Above). Returns the
/// 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, picture: None, assistant: false, title: String::new() });
return nodes.len() - 1;
};
let lv = nodes[i].level;
let (at, level) = match position {
"before" => (i, lv),
"below" => (subtree_end(nodes, i), (lv + 1).min(MAX_LEVEL)),
"above" => {
let end = subtree_end(nodes, i);
for n in &mut nodes[i..end] {
n.level = (n.level + 1).min(MAX_LEVEL);
}
(i, lv)
}
"assistant" => (i + 1, (lv + 1).min(MAX_LEVEL)),
_ => (subtree_end(nodes, i), lv),
};
nodes.insert(at, DiagramNode { text: text.into(), level, picture: None, assistant: position == "assistant", title: String::new() });
at
}
/// Remove node `i`; its sub-points move up one level.
pub fn remove(nodes: &mut Vec<DiagramNode>, i: usize) -> bool {
if i >= nodes.len() {
return false;
}
let end = subtree_end(nodes, i);
for n in &mut nodes[i + 1..end] {
n.level = n.level.saturating_sub(1);
}
nodes.remove(i);
normalize(nodes);
true
}
/// Move node `i` and its sub-points one level up. False when it is already top level.
pub fn promote_node(nodes: &mut [DiagramNode], i: usize) -> bool {
if nodes.get(i).is_none_or(|n| n.level == 0) {
return false;
}
let end = subtree_end(nodes, i);
for n in &mut nodes[i..end] {
n.level -= 1;
}
true
}
/// Move node `i` and its sub-points one level down, under the node before it. False when there is
/// nothing to go under.
pub fn demote_node(nodes: &mut [DiagramNode], i: usize) -> bool {
let (Some(prev), Some(cur)) = (i.checked_sub(1).and_then(|p| nodes.get(p)), nodes.get(i)) else { return false };
if cur.level > prev.level || cur.level >= MAX_LEVEL {
return false;
}
let end = subtree_end(nodes, i);
if nodes[i..end].iter().any(|n| n.level >= MAX_LEVEL) {
return false;
}
for n in &mut nodes[i..end] {
n.level += 1;
}
true
}
/// A generated shape and the nodes whose text it shows (none for arrows and lines).
pub(super) type Item = (Shape, Vec<usize>);
/// Top-level nodes, each with its sub-points.
pub(super) 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() {
Some(g) if n.level > 0 => g.push(i),
_ => out.push(vec![i]),
}
}
out.truncate(MAX_SHAPES);
out
}
/// Read text typed into the diagram's shapes back into its nodes.
pub(super) fn sync_text(d: &mut Diagram, children: &[Shape]) {
if d.imported {
return;
}
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 idx.is_empty() || body.paragraphs.len() != idx.len() {
continue;
}
for (p, i) in body.paragraphs.iter().zip(idx) {
if let Some(n) = d.nodes.get_mut(i) {
n.text = p.text();
}
}
}
}
+263
View File
@@ -0,0 +1,263 @@
use super::layout::layout_raw;
use super::nodes::Item;
use super::*;
fn slot(i: usize) -> SchemeSlot {
[SchemeSlot::Accent1, SchemeSlot::Accent2, SchemeSlot::Accent3, SchemeSlot::Accent4, SchemeSlot::Accent5, SchemeSlot::Accent6][i % 6]
}
/// The accent shape `i` is drawn in under Change Colors: `colorful` cycles the accents, `accentN`
/// keeps one.
pub(super) fn accent(d: &Diagram, i: usize) -> SchemeSlot {
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).
pub(super) 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, camera: &str) -> String {
format!(
"<a:scene3d><a:camera prst=\"{camera}\"/><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
)
}
/// Brick Scene's camera: the diagram's shapes seen from above and to the side.
pub const BRICK_CAMERA: &str = "isometricOffAxis2Top";
/// 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.
pub(super) fn paint(d: &Diagram, sh: &mut Shape, i: usize, light: bool) {
let a = accent(d, i);
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 || matches!(d.style.as_str(), "outline" | "powder");
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() });
}
"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, if d.style == "brick" { BRICK_CAMERA } else { "orthographicFront" })),
..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).
pub(super) 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: Some(1.0), line_reduction: 0.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);
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);
para.props.align = Some(Align::Left);
para.props.bullet = Some(deckcraft_model::text::Bullet::Char { char: "•".into() });
para.props.margin_left = Some(12.0 * (para.level as f64 + 1.0));
para.props.indent = Some(-10.0);
}
let size = if head { 20.0 } else { 14.0 };
for r in &mut para.runs {
r.props.size = Some(size);
}
para.end_props.size = Some(size);
body.paragraphs.push(para);
}
if body.paragraphs.is_empty() {
body.paragraphs.push(Paragraph::new(""));
}
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).
pub(super) 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() }
}
pub(super) fn line_shape(a: (f64, f64), b: (f64, f64)) -> Shape {
let mut x = Xfrm::new(a.0.min(b.0), a.1.min(b.1), (b.0 - a.0).abs(), (b.1 - a.1).abs());
x.flip_h = (b.0 < a.0) != (b.1 < a.1);
Shape {
name: "SmartArt connector".into(),
xfrm: Some(x),
geom: Geom::preset("line"),
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> {
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; text is
/// shrunk per shape so its longest word fits on a line.
pub(super) 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.
pub(super) 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 };
// Round shapes keep their text in the inscribed rectangle (about 71% of the width).
let text_w = match sh.geom.preset_name() {
Some("ellipse" | "gear6" | "gear9") => w * 0.71,
_ => w,
};
let Some(body) = &mut sh.text else { return };
let avail = text_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.
pub(super) 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
}