mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-26 19:46:58 -06:00
refactor(engine): split cmd/smartart.rs into cmd/smartart/{nodes,paint,layout,diagrams,edit}.rs
This commit is contained in:
+15
-1774
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user