SmartArt: Text Pane, add and remove shapes, promote and demote

A SmartArt group now keeps its outline (Shape.diagram: layout + nodes with
levels) and its shapes are regenerated from it inside the group's child
rectangle. New commands: smartArt.nodes, setNodes, addNode (after, before,
above, below), removeNode, promote, demote and layout. Sub-points become
bullets in their parent's shape; the hierarchy layout is a real tree with
connector lines. Text typed into a diagram shape is read back first.

UI: a SmartArt Design contextual tab and a Text Pane (Enter adds a line,
Tab/Shift+Tab demote/promote, Backspace on an empty line removes it).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nikita Rakitin
2026-10-14 01:22:22 +03:00
co-authored by Cursor
parent 457406a7a5
commit 6e40335424
14 changed files with 908 additions and 98 deletions
+8 -87
View File
@@ -82,7 +82,7 @@ pub fn specs() -> Vec<CommandSpec> {
"SmartArt",
["Insert", "Illustrations"],
None,
"{kind: list|process|cycle|hierarchy|pyramid|matrix, items: [text]}",
"{kind: list|process|cycle|hierarchy|pyramid|matrix, items?: [text] (leading tabs = sub-points), nodes?: [{text, level}]} → {id}",
has_slide,
smart_art
),
@@ -709,92 +709,13 @@ fn chart_options(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// SmartArt-style graphics built from shapes (grouped), with our own layouts.
/// A SmartArt graphic: a group regenerated from its outline (see [`super::smartart`]). Items with
/// leading tabs are sub-points; `nodes` (`[{text, level}]`) may be given instead.
fn smart_art(s: &mut Session, p: &Value) -> Result<Value> {
let kind = str_param(p, "kind").unwrap_or("process").to_string();
let items: Vec<String> =
p.get("items").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(String::from).collect()).unwrap_or_default();
let items = if items.is_empty() { vec!["Text".to_string(); 3] } else { items };
let n = items.len().clamp(1, 12);
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 slots = [SchemeSlot::Accent1, SchemeSlot::Accent2, SchemeSlot::Accent3, SchemeSlot::Accent4, SchemeSlot::Accent5, SchemeSlot::Accent6];
let mut shapes: Vec<(String, Xfrm, usize)> = vec![];
match kind.as_str() {
"cycle" => {
let r = area.h.min(area.w) * 0.38;
let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0);
let d = r * 0.62;
for i in 0..n {
let a = (i as f64 / n as f64 * 360.0 - 90.0).to_radians();
shapes.push(("ellipse".into(), Xfrm::new(cx + r * a.cos() - d / 2.0, cy + r * a.sin() - d / 2.0, d, d), i));
}
}
"hierarchy" => {
let w = area.w / (n.max(2) - 1) as f64 * 0.8;
shapes.push(("roundRect".into(), Xfrm::new(area.x + area.w / 2.0 - w / 2.0, area.y, w, area.h * 0.3), 0));
for i in 1..n {
let x = area.x + (i - 1) as f64 * area.w / (n - 1).max(1) as f64;
shapes.push(("roundRect".into(), Xfrm::new(x + w * 0.1, area.y + area.h * 0.55, w, area.h * 0.3), i));
}
}
"pyramid" => {
let h = area.h / n as f64;
for i in 0..n {
let w = area.w * 0.25 + area.w * 0.75 * (i as f64 + 1.0) / n as f64;
shapes.push(("trapezoid".into(), Xfrm::new(area.x + (area.w - w) / 2.0, area.y + i as f64 * h, w, h * 0.95), i));
}
}
"matrix" => {
let (w, h) = (area.w / 2.0, area.h / 2.0);
for i in 0..n.min(4) {
shapes.push((
"roundRect".into(),
Xfrm::new(area.x + (i % 2) as f64 * w + 4.0, area.y + (i / 2) as f64 * h + 4.0, w - 8.0, h - 8.0),
i,
));
}
}
"list" => {
let h = area.h / n as f64;
for i in 0..n {
shapes.push(("roundRect".into(), Xfrm::new(area.x, area.y + i as f64 * h, area.w, h * 0.85), i));
}
}
_ => {
let gap = 24.0;
let w = (area.w - gap * (n as f64 - 1.0)) / n as f64;
for i in 0..n {
shapes.push((
if i + 1 < n { "homePlate".into() } else { "roundRect".into() },
Xfrm::new(area.x + i as f64 * (w + gap), area.y + area.h * 0.25, w, area.h * 0.5),
i,
));
}
}
}
let mut ids = vec![];
for (preset, x, i) in shapes {
let mut sh = Shape {
xfrm: Some(x),
geom: Geom::preset(&preset),
style: Some(ShapeStyle::accent(slots.get(i % 6).copied().unwrap_or(SchemeSlot::Accent1))),
..Default::default()
};
let mut body = TextBody::from_text(items.get(i).map(String::as_str).unwrap_or(""));
body.body.autofit = Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 });
for para in &mut body.paragraphs {
para.props.align = Some(deckcraft_model::text::Align::Center);
for r in &mut para.runs {
r.props.size = Some(20.0);
}
}
sh.text = Some(body);
ids.push(add_shape(s, sh, false)?.0);
}
let g = s.execute("arrange.group", &json!({"ids": ids}))?;
if let Some(gid) = g.get("id").and_then(Value::as_u64) {
let _ = s.execute("shape.rename", &json!({"id": gid, "name": format!("SmartArt ({kind})")}));
}
Ok(g)
let nodes = match p.get("nodes") {
Some(v) => serde_json::from_value(v.clone()).map_err(|e| bad("insert.smartArt", e.to_string()))?,
None => super::smartart::nodes_from_lines(p.get("items").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str)),
};
super::smartart::insert(s, &kind, nodes)
}
+2
View File
@@ -14,6 +14,7 @@ pub mod review;
pub mod selection;
pub mod shape;
pub mod slide;
pub mod smartart;
pub mod table;
pub mod text;
pub mod transition;
@@ -129,6 +130,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(edit::specs());
v.extend(slide::specs());
v.extend(insert::specs());
v.extend(smartart::specs());
v.extend(shape::specs());
v.extend(arrange::specs());
v.extend(selection::specs());
+603
View File
@@ -0,0 +1,603 @@
//! SmartArt: a diagram's outline (the Text Pane) and the shapes generated from it.
//!
//! A SmartArt graphic is a group whose `diagram` holds its layout and its nodes (text + outline
//! level). Every edit changes the nodes and regenerates the group's children inside the group's
//! child rectangle, so the diagram keeps its size and place. Text typed straight into a diagram
//! shape is read back into the nodes before each regeneration.
use deckcraft_color::SchemeSlot;
use deckcraft_geom::Xfrm;
use deckcraft_model::text::{Align, AutoFit, Paragraph, TextBody};
use deckcraft_model::{Diagram, DiagramNode, Fill, Geom, Presentation, Shape, ShapeId, ShapeKind, ShapeStyle};
use serde_json::{Value, json};
use super::*;
use crate::{EngineError, Result, Session};
/// Deepest outline level the Text Pane allows.
pub const MAX_LEVEL: u8 = 8;
/// Most top-level shapes a layout draws.
const MAX_SHAPES: usize = 40;
pub const KINDS: [&str; 6] = ["list", "process", "cycle", "hierarchy", "pyramid", "matrix"];
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
query "smartArt.nodes",
"Text Pane",
["SmartArt Design", "Create Graphic"],
None,
"{id?} → {id, kind, nodes: [{text, level}]} (id: the SmartArt group or one of its shapes; default the selection)",
has_selection,
nodes
),
cmd!(
"smartArt.setNodes",
"Edit SmartArt Text",
["SmartArt Design", "Create Graphic"],
None,
"{id?, nodes?: [{text, level}], lines?: [string] (leading tabs set the level), index?, text?} (index + text edits one node)",
has_selection,
set_nodes
),
cmd!(
"smartArt.addNode",
"Add Shape",
["SmartArt Design", "Create Graphic"],
None,
"{id?, index?, position?: after|before|below|above, text?} → {index} (default: after the last node)",
has_selection,
add_node
),
cmd!(
"smartArt.removeNode",
"Remove Shape",
["SmartArt Design", "Create Graphic"],
None,
"{id?, index} (its sub-points move up one level)",
has_selection,
remove_node
),
cmd!("smartArt.promote", "Promote", ["SmartArt Design", "Create Graphic"], None, "{id?, index}", has_selection, promote),
cmd!("smartArt.demote", "Demote", ["SmartArt Design", "Create Graphic"], None, "{id?, index}", has_selection, demote),
cmd!(
"smartArt.layout",
"Change Layout",
["SmartArt Design", "Layouts"],
None,
"{id?, kind: list|process|cycle|hierarchy|pyramid|matrix}",
has_selection,
change_layout
),
]
}
/// 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) }
})
.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 });
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)
}
_ => (subtree_end(nodes, i), lv),
};
nodes.insert(at, DiagramNode { text: text.into(), level });
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
}
/// Which nodes each generated text shape shows, in child order (lines drawn after these carry none).
fn node_map(d: &Diagram) -> Vec<Vec<usize>> {
if d.kind == "hierarchy" {
return (0..d.nodes.len().min(MAX_SHAPES)).map(|i| vec![i]).collect();
}
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]),
}
}
let cap = if d.kind == "matrix" { 4 } else { MAX_SHAPES };
out.truncate(cap);
out
}
/// Read text typed into the diagram's shapes back into its nodes.
fn sync_text(d: &mut Diagram, children: &[Shape]) {
for (shape, idx) in children.iter().zip(node_map(d)) {
let Some(body) = &shape.text else { continue };
if 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();
}
}
}
}
fn slot(i: usize) -> SchemeSlot {
[SchemeSlot::Accent1, SchemeSlot::Accent2, SchemeSlot::Accent3, SchemeSlot::Accent4, SchemeSlot::Accent5, SchemeSlot::Accent6][i % 6]
}
fn text_shape(preset: &str, x: Xfrm, color: usize, lines: &[&DiagramNode]) -> Shape {
let mut body = TextBody::default();
body.body.autofit = Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 });
let top = lines.first().map(|n| n.level).unwrap_or(0);
for (k, n) in lines.iter().enumerate() {
let mut para = Paragraph::new(&n.text);
if k == 0 {
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 k == 0 { 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(""));
}
Shape {
name: format!("SmartArt shape {}", color + 1),
xfrm: Some(x),
geom: Geom::preset(preset),
style: Some(ShapeStyle::accent(slot(color))),
text: Some(body),
..Default::default()
}
}
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"),
style: Some(ShapeStyle::accent(SchemeSlot::Accent1)),
fill: Some(Fill::None),
..Default::default()
}
}
/// The diagram's shapes laid out in `area` (ids not yet assigned).
pub fn layout(d: &Diagram, area: Xfrm) -> Vec<Shape> {
let map = node_map(d);
let groups: Vec<Vec<&DiagramNode>> = map.iter().map(|g| g.iter().filter_map(|i| d.nodes.get(*i)).collect()).collect();
let n = groups.len().max(1);
let mut out = vec![];
match d.kind.as_str() {
"hierarchy" => return hierarchy(d, area),
"cycle" => {
let r = area.h.min(area.w) * 0.38;
let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0);
let dd = r * 0.62;
for (i, g) in groups.iter().enumerate() {
let a = (i as f64 / n as f64 * 360.0 - 90.0).to_radians();
out.push(text_shape("ellipse", Xfrm::new(cx + r * a.cos() - dd / 2.0, cy + r * a.sin() - dd / 2.0, dd, dd), i, g));
}
}
"pyramid" => {
let h = area.h / n as f64;
for (i, g) in groups.iter().enumerate() {
let w = area.w * 0.25 + area.w * 0.75 * (i as f64 + 1.0) / n as f64;
out.push(text_shape("trapezoid", Xfrm::new(area.x + (area.w - w) / 2.0, area.y + i as f64 * h, w, h * 0.95), i, g));
}
}
"matrix" => {
let (w, h) = (area.w / 2.0, area.h / 2.0);
for (i, g) in groups.iter().enumerate() {
let x = Xfrm::new(area.x + (i % 2) as f64 * w + 4.0, area.y + (i / 2) as f64 * h + 4.0, w - 8.0, h - 8.0);
out.push(text_shape("roundRect", x, i, g));
}
}
"list" => {
let h = area.h / n as f64;
for (i, g) in groups.iter().enumerate() {
out.push(text_shape("roundRect", Xfrm::new(area.x, area.y + i as f64 * h, area.w, h * 0.85), i, g));
}
}
_ => {
let gap = 24.0;
let w = ((area.w - gap * (n as f64 - 1.0)) / n as f64).max(1.0);
for (i, g) in groups.iter().enumerate() {
let preset = if i + 1 < n { "homePlate" } else { "roundRect" };
out.push(text_shape(preset, Xfrm::new(area.x + i as f64 * (w + gap), area.y + area.h * 0.25, w, area.h * 0.5), i, g));
}
}
}
out
}
/// An organisation chart: one box per node, rows by level, each subtree as wide as its leaves.
fn hierarchy(d: &Diagram, area: Xfrm) -> Vec<Shape> {
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);
let cell_w = area.w / total as f64;
let row_h = area.h / rows as f64;
let (bw, bh) = (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 cx = area.x + (left[i] as f64 + leaves[i] as f64 / 2.0) * cell_w;
Xfrm::new(cx - bw / 2.0, area.y + depth[i] as f64 * row_h + (row_h - bh) / 2.0, bw, bh)
})
.collect();
let mut out: Vec<Shape> = nodes.iter().enumerate().map(|(i, n)| text_shape("roundRect", boxes[i], depth[i], &[n])).collect();
for i in 0..nodes.len() {
if let Some(p) = parent[i] {
let (a, b) = (boxes[p], boxes[i]);
out.push(line_shape((a.x + a.w / 2.0, a.y + a.h), (b.x + b.w / 2.0, b.y)));
}
}
out
}
/// 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(doc: &mut Presentation, d: &Diagram, area: Xfrm) -> Vec<Shape> {
let mut v = layout(d, area);
for sh in &mut v {
sh.id = ShapeId(doc.alloc_id());
}
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);
let kids = regenerate(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 KINDS.contains(&kind) { kind } else { "process" };
if nodes.is_empty() {
nodes = (0..3).map(|_| DiagramNode { text: "Text".into(), level: 0 }).collect();
}
normalize(&mut nodes);
let d = Diagram { kind: kind.into(), nodes };
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(doc, &d, area);
let id = ShapeId(doc.alloc_id());
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}))
}
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}))
}
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);
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;
}
Ok(Value::Null)
})
}
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}))
})
}
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")) })
}
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")) })
}
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")) })
}
fn change_layout(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "smartArt.layout";
let kind = str_param(p, "kind").filter(|k| KINDS.contains(k)).ok_or_else(|| bad(CMD, "unknown layout"))?.to_string();
edit_diagram(s, p, CMD, |d| {
d.kind = kind;
Ok(Value::Null)
})
}
#[cfg(test)]
mod tests {
use super::*;
fn lv(n: &[DiagramNode]) -> Vec<u8> {
n.iter().map(|x| x.level).collect()
}
fn mk(levels: &[u8]) -> Vec<DiagramNode> {
levels.iter().enumerate().map(|(i, l)| DiagramNode { text: format!("n{i}"), level: *l }).collect()
}
#[test]
fn outline_edits() {
let mut n = mk(&[0, 1, 1, 0]);
assert_eq!(add(&mut n, Some(0), "after", "x"), 3, "after the node's sub-points");
assert_eq!(lv(&n), [0, 1, 1, 0, 0]);
assert_eq!(add(&mut n, Some(0), "below", "y"), 3);
assert_eq!(lv(&n), [0, 1, 1, 1, 0, 0]);
assert_eq!(add(&mut n, Some(0), "above", "z"), 0);
assert_eq!(lv(&n), [0, 1, 2, 2, 2, 0, 0]);
assert!(remove(&mut n, 1));
assert_eq!(lv(&n), [0, 1, 1, 1, 0, 0]);
assert!(!promote_node(&mut n, 0));
assert!(!demote_node(&mut n, 0), "nothing above the first node");
assert!(!demote_node(&mut n, 2) || lv(&n)[2] == 2);
let mut m = mk(&[0, 0, 1]);
assert!(demote_node(&mut m, 1));
assert_eq!(lv(&m), [0, 1, 2], "sub-points move with their node");
assert!(promote_node(&mut m, 1));
assert_eq!(lv(&m), [0, 0, 1]);
let mut bad = mk(&[2, 4, 0]);
normalize(&mut bad);
assert_eq!(lv(&bad), [0, 1, 0]);
assert_eq!(lv(&nodes_from_lines(["A", "\tB", "\t\tC", "", "D"])), [0, 1, 2, 0]);
}
#[test]
fn layouts_map_nodes_to_shapes() {
let area = Xfrm::new(0.0, 0.0, 800.0, 400.0);
let d = Diagram { kind: "list".into(), nodes: mk(&[0, 1, 1, 0]) };
let v = layout(&d, area);
assert_eq!(v.len(), 2);
let t = v[0].text.as_ref().map(|b| b.paragraphs.len());
assert_eq!(t, Some(3), "sub-points are bullets in their parent's shape");
let h = Diagram { kind: "hierarchy".into(), nodes: mk(&[0, 1, 2, 1]) };
let v = layout(&h, area);
assert_eq!(v.len(), 4 + 3, "a box per node and a line per parent link");
for sh in &v {
let x = sh.xfrm.unwrap_or_default();
assert!(x.x >= -0.01 && x.x + x.w <= 800.01 && x.y >= -0.01 && x.y + x.h <= 400.01, "{x:?}");
}
assert!(layout(&Diagram { kind: "cycle".into(), nodes: vec![] }, area).is_empty());
}
}
+49
View File
@@ -835,3 +835,52 @@ fn slide_ranges_parse_like_powerpoints_print_box() {
let opts = cmd::file::pdf_options(&json!({"slides": [0, 7, 1]}), 2).unwrap();
assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped");
}
#[test]
fn smartart_text_pane_edits_regenerate_the_diagram() {
let mut s = session();
let r = s.execute("insert.smartArt", &json!({"kind": "list", "items": ["Plan", "\tScope", "Build"]})).unwrap();
let gid = r["id"].as_u64().unwrap();
let kids = |s: &Session| match s.doc().unwrap().shape(deckcraft_model::ShapeId(gid as u32)).map(|x| &x.kind) {
Some(deckcraft_model::ShapeKind::Group { children, .. }) => children.clone(),
_ => panic!("not a group"),
};
assert_eq!(kids(&s).len(), 2, "Scope is a bullet inside Plan");
let box_before = s.doc().unwrap().shape(deckcraft_model::ShapeId(gid as u32)).unwrap().xfrm;
// Type into the second shape directly, then add a node: the typed text is kept.
let build = kids(&s)[1].id.0;
s.execute("text.edit", &json!({"id": build, "end": true})).unwrap();
s.execute("text.insert", &json!({"text": " it"})).unwrap();
let r = s.execute("smartArt.addNode", &json!({"id": gid, "index": 2, "position": "after", "text": "Ship"})).unwrap();
assert_eq!(r["index"], 3);
let n = s.execute("smartArt.nodes", &json!({"id": gid})).unwrap();
let texts: Vec<&str> = n["nodes"].as_array().unwrap().iter().map(|x| x["text"].as_str().unwrap()).collect();
assert_eq!(texts, ["Plan", "Scope", "Build it", "Ship"]);
assert_eq!(kids(&s).len(), 3);
// Demote Ship under Build, promote Scope, remove Plan.
s.execute("smartArt.demote", &json!({"id": gid, "index": 3})).unwrap();
assert_eq!(kids(&s).len(), 2);
s.execute("smartArt.promote", &json!({"id": gid, "index": 1})).unwrap();
assert_eq!(kids(&s).len(), 3);
assert!(s.execute("smartArt.promote", &json!({"id": gid, "index": 0})).is_err());
s.execute("smartArt.removeNode", &json!({"id": gid, "index": 0})).unwrap();
let n = s.execute("smartArt.nodes", &json!({"id": gid})).unwrap();
let lv: Vec<u64> = n["nodes"].as_array().unwrap().iter().map(|x| x["level"].as_u64().unwrap()).collect();
assert_eq!(lv, [0, 0, 1]);
// A child shape also finds its diagram; layouts switch; the box stays put; undo works.
let child = kids(&s)[0].id.0;
s.execute("smartArt.layout", &json!({"id": child, "kind": "hierarchy"})).unwrap();
assert_eq!(kids(&s).len(), 3 + 1, "three boxes and one link");
assert_eq!(s.doc().unwrap().shape(deckcraft_model::ShapeId(gid as u32)).unwrap().xfrm, box_before);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(kids(&s).len(), 2);
// The outline survives a .deckcraft save.
let doc = s.doc().unwrap().doc.as_ref().clone();
let bytes = deckcraft_format::save(&doc).unwrap();
let back = deckcraft_format::load(&bytes).unwrap();
assert!(back.slides[0].shapes.iter().any(|x| x.diagram.as_ref().is_some_and(|d| d.nodes.len() == 3)));
}
+21
View File
@@ -320,6 +320,26 @@ pub struct Shape {
/// Unmodelled XML kept for round-trip (extension lists etc.).
#[serde(skip_serializing_if = "Option::is_none")]
pub raw_ext: Option<String>,
/// On a SmartArt group: the diagram's text outline, from which its shapes are regenerated.
#[serde(skip_serializing_if = "Option::is_none")]
pub diagram: Option<Diagram>,
}
/// A SmartArt diagram's data: its layout and its outline of nodes (the Text Pane).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Diagram {
/// list, process, cycle, hierarchy, pyramid or matrix.
pub kind: String,
pub nodes: Vec<DiagramNode>,
}
/// One line of the Text Pane: its text and outline level (0 = top level).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct DiagramNode {
pub text: String,
pub level: u8,
}
impl Default for Shape {
@@ -345,6 +365,7 @@ impl Default for Shape {
click: None,
hover: None,
raw_ext: None,
diagram: None,
}
}
}
+1
View File
@@ -683,6 +683,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
let (ok, cancel) = buttons(ui, "Insert");
if ok {
run(app, "insert.smartArt", json!({"kind": kind, "items": v.lines().filter(|l| !l.trim().is_empty()).collect::<Vec<_>>()}));
app.ui.pane = Some("smartart".into());
}
ok || cancel
}
+12 -2
View File
@@ -86,7 +86,8 @@ pub struct UiState {
pub notes: bool,
pub notes_height: f32,
pub thumbs_width: f32,
/// Open task pane: `format`, `animation`, `selection`, `comments`, `designer`, `background`.
/// Open task pane: `format`, `animation`, `selection`, `comments`, `designer`, `background`,
/// `smartart` (Text Pane).
pub pane: Option<String>,
pub pane_width: f32,
pub ruler: bool,
@@ -101,6 +102,12 @@ pub struct UiState {
pub ui_scale: f32,
/// Format Shape pane: tab (`fill`, `effects`, `size`, `picture`, `text`).
pub format_tab: String,
/// The Text Pane's current SmartArt line (Add Shape, Promote and Demote act on it).
pub smartart_row: usize,
/// A Text Pane line to focus once it is drawn (after Enter, Tab or Backspace rebuilt the list).
pub smartart_focus: Option<usize>,
/// The Text Pane's line drafts, for the SmartArt group they were read from.
pub smartart_drafts: (u32, Vec<String>),
pub grayscale: bool,
}
@@ -126,6 +133,9 @@ impl Default for UiState {
presenter_view: true,
ui_scale: 1.0,
format_tab: "fill".into(),
smartart_row: 0,
smartart_focus: None,
smartart_drafts: (0, vec![]),
grayscale: false,
}
}
@@ -146,7 +156,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
("view.zoomOut", "Zoom Out", Some("Cmd+-"), "{}"),
("view.zoom", "Zoom…", None, "{percent}"),
("view.fit", "Fit to Window", None, "{}"),
("view.pane", "Task Pane", None, "{pane: format|animation|selection|comments|designer|background|null}"),
("view.pane", "Task Pane", None, "{pane: format|animation|selection|comments|designer|background|smartart|null}"),
("view.tab", "Ribbon Tab", None, "{tab}"),
("view.collapseRibbon", "Collapse the Ribbon", Some("Cmd+F1"), "{}"),
("view.dark", "Dark Mode", None, "{on?: bool}"),
+95
View File
@@ -27,6 +27,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
"selection" => "Selection",
"comments" => "Comments",
"designer" => "Design Ideas",
"smartart" => "Text Pane",
_ => "Pane",
};
ui.horizontal(|ui| {
@@ -50,11 +51,101 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
"selection" => crate::selpane::show(app, ui),
"comments" => comments(app, ui),
"designer" => designer(app, ui),
"smartart" => smartart(app, ui),
_ => {}
});
});
}
/// SmartArt Text Pane: one line per node, indented by level. Enter adds a line after, Tab and
/// Shift+Tab demote and promote, Backspace in an empty line removes it.
fn smartart(app: &mut SlideApp, ui: &mut Ui) {
let Some((gid, d)) = app.session.active().and_then(deckcraft_engine::cmd::smartart::selected) else {
ui.label("Select a SmartArt graphic to edit its text.");
return;
};
let texts: Vec<String> = d.nodes.iter().map(|n| n.text.clone()).collect();
let focused_any = ui.ctx().memory(|m| m.focused().is_some()) || app.ui.smartart_focus.is_some();
if app.ui.smartart_drafts.0 != gid.0 || app.ui.smartart_drafts.1.len() != texts.len() || !focused_any {
app.ui.smartart_drafts = (gid.0, texts.clone());
}
let mut drafts = std::mem::take(&mut app.ui.smartart_drafts.1);
let mut action: Option<(&str, Value)> = None;
let mut commit: Option<usize> = None;
let mut focus_row: Option<usize> = None;
ui.label(egui::RichText::new("Type your text here").color(Tokens::get(ui.ctx()).text_dim));
ui.add_space(4.0);
for (i, node) in d.nodes.iter().enumerate() {
let Some(draft) = drafts.get_mut(i) else { break };
let id = egui::Id::new(("smartart_line", gid.0, i));
ui.horizontal(|ui| {
ui.add_space(16.0 * node.level as f32);
ui.label("•");
let te = egui::TextEdit::singleline(draft).id(id).desired_width(f32::INFINITY);
let r = ui.add(te);
if app.ui.smartart_focus == Some(i) {
r.request_focus();
app.ui.smartart_focus = None;
}
if r.gained_focus() || r.has_focus() {
app.ui.smartart_row = i;
}
if r.has_focus() {
let back = ui.input(|inp| inp.key_pressed(egui::Key::Backspace));
if back && draft.is_empty() && texts.get(i).is_some_and(String::is_empty) && d.nodes.len() > 1 {
action = Some(("smartArt.removeNode", json!({"index": i})));
focus_row = Some(i.saturating_sub(1));
}
}
if r.lost_focus() {
let (tab, shift) = ui.input(|inp| (inp.key_pressed(egui::Key::Tab), inp.modifiers.shift));
if tab {
action = Some((if shift { "smartArt.promote" } else { "smartArt.demote" }, json!({"index": i})));
focus_row = Some(i);
} else if ui.input(|inp| inp.key_pressed(egui::Key::Enter)) {
action = Some(("smartArt.addNode", json!({"index": i, "position": "after"})));
focus_row = Some(i + 1);
}
commit = Some(i);
}
});
}
ui.add_space(6.0);
ui.horizontal(|ui| {
if ui.button("+ Add").clicked() {
action = Some(("smartArt.addNode", json!({"index": app.ui.smartart_row.min(d.nodes.len().saturating_sub(1)), "position": "after"})));
}
if ui.button("Remove").clicked() && !d.nodes.is_empty() {
action = Some(("smartArt.removeNode", json!({"index": app.ui.smartart_row.min(d.nodes.len() - 1)})));
}
});
// Commit a changed line before any structural edit, so the edit sees the new text.
if let Some(i) = commit
&& drafts.get(i) != texts.get(i)
{
run(app, "smartArt.setNodes", json!({"id": gid.0, "index": i, "text": drafts.get(i).cloned().unwrap_or_default()}));
}
if action.is_some() {
for (i, t) in drafts.iter().enumerate() {
if Some(i) != commit && texts.get(i) != Some(t) {
run(app, "smartArt.setNodes", json!({"id": gid.0, "index": i, "text": t}));
}
}
}
app.ui.smartart_drafts.1 = drafts;
if let Some((cmd, mut p)) = action {
if let Some(o) = p.as_object_mut() {
o.insert("id".into(), json!(gid.0));
}
if let Ok(r) = app.run(cmd, p) {
let row = r.get("index").and_then(Value::as_u64).map(|v| v as usize).or(focus_row).unwrap_or(0);
app.ui.smartart_row = row;
app.ui.smartart_drafts.0 = 0;
app.ui.smartart_focus = Some(row);
}
}
}
fn run(app: &mut SlideApp, id: &str, p: Value) {
let _ = app.run(id, p);
}
@@ -635,3 +726,7 @@ fn designer(app: &mut SlideApp, ui: &mut Ui) {
ui.add_space(8.0);
}
}
#[cfg(test)]
#[path = "smartart_tests.rs"]
mod smartart_tests;
+51
View File
@@ -122,6 +122,9 @@ fn contextual(app: &SlideApp) -> Vec<&'static str> {
v.push("Table Design");
v.push("Layout");
}
if deckcraft_engine::cmd::smartart::selected(st).is_some() {
v.push("SmartArt Design");
}
if shapes.iter().any(|s| matches!(s.kind, ShapeKind::Chart(_))) {
v.push("Chart Design");
}
@@ -220,6 +223,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
"Table Design" => table_design(app, ui),
"Layout" => table_layout(app, ui),
"Chart Design" => chart_design(app, ui),
"SmartArt Design" => smartart_design(app, ui),
"Playback" => playback(app, ui),
"Slide Master" => slide_master(app, ui),
_ => {}
@@ -2318,6 +2322,53 @@ fn chart_design(app: &mut SlideApp, ui: &mut Ui) {
arrange_group(app, ui);
}
/// SmartArt Design: Create Graphic (Text Pane, Add Shape, Promote, Demote, Remove) and Layouts.
fn smartart_design(app: &mut SlideApp, ui: &mut Ui) {
let sel = app.session.active().and_then(deckcraft_engine::cmd::smartart::selected);
let row = app.ui.smartart_row;
let (gid, n, kind) = match &sel {
Some((id, d)) => (Some(id.0), d.nodes.len(), d.kind.clone()),
None => (None, 0, String::new()),
};
let at = row.min(n.saturating_sub(1));
group(ui, |ui| {
if big_button(ui, Icon::SmartArt, "Text\nPane", gid.is_some()).on_hover_text("Type and organise the diagram's text").clicked() {
run(app, "view.pane", json!({"pane": "smartart", "toggle": true}));
}
rows(ui, |ui| {
let (r, _) = small_button_ex(ui, Icon::Plus, "Add Shape", gid.is_some(), false, true);
egui::Popup::menu(&r).show(|ui| {
for (label, pos) in
[("Add Shape After", "after"), ("Add Shape Before", "before"), ("Add Shape Above", "above"), ("Add Shape Below", "below")]
{
if ui.button(label).clicked() {
let index = if n == 0 { Value::Null } else { json!(at) };
run(app, "smartArt.addNode", json!({"id": gid, "index": index, "position": pos}));
}
}
});
if small_button(ui, Icon::DecreaseIndent, "Promote", n > 0, false).clicked() {
run(app, "smartArt.promote", json!({"id": gid, "index": at}));
}
if small_button(ui, Icon::IncreaseIndent, "Demote", n > 0, false).clicked() {
run(app, "smartArt.demote", json!({"id": gid, "index": at}));
}
});
});
group(ui, |ui| {
let (r, _) = small_button_ex(ui, Icon::Layout, "Layout", gid.is_some(), false, true);
egui::Popup::menu(&r).show(|ui| {
for k in deckcraft_engine::cmd::smartart::KINDS {
let label = format!("{}{}", k[..1].to_uppercase(), &k[1..]);
if ui.selectable_label(kind == k, label).clicked() {
run(app, "smartArt.layout", json!({"id": gid, "kind": k}));
}
}
});
});
arrange_group(app, ui);
}
fn playback(app: &mut SlideApp, ui: &mut Ui) {
let m = app
.session
+53
View File
@@ -0,0 +1,53 @@
//! The SmartArt Text Pane driven like a person would: type in a line, Enter for a new line, Tab
//! to demote.
use deckcraft_engine::Session;
use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};
use serde_json::json;
use crate::{Services, SlideApp};
fn levels(h: &Harness<'_, SlideApp>) -> Vec<(String, u8)> {
let st = h.state().session.active().unwrap();
let (_, d) = deckcraft_engine::cmd::smartart::selected(st).unwrap();
d.nodes.into_iter().map(|n| (n.text, n.level)).collect()
}
fn line<'a>(h: &'a Harness<'_, SlideApp>, v: &'a str) -> egui_kittest::Node<'a> {
h.get_all_by_value(v).find(|n| n.accesskit_node().role() == egui::accesskit::Role::TextInput).unwrap()
}
#[test]
fn text_pane_types_adds_and_demotes() {
let mut s = Session::with_new();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
s.execute("insert.smartArt", &json!({"kind": "list", "items": ["Plan", "Build"]})).unwrap();
let mut app = SlideApp::new(s, Services::default());
app.ui.pane = Some("smartart".into());
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|ui, app: &mut SlideApp| {
app.logic(ui.ctx());
app.ui(ui);
},
app,
);
h.run_steps(3);
assert!(h.query_by_label("Text Pane").is_some());
line(&h, "Build").click();
h.run_steps(2);
h.key_press(egui::Key::End);
line(&h, "Build").type_text(" it");
h.run_steps(2);
h.key_press(egui::Key::Enter);
h.run_steps(4);
assert_eq!(levels(&h), [("Plan".into(), 0), ("Build it".into(), 0), (String::new(), 0)]);
// The new line has the focus: type, then Tab demotes it under "Build it".
h.event(egui::Event::Text("Test".into()));
h.run_steps(2);
h.key_press(egui::Key::Tab);
h.run_steps(4);
assert_eq!(levels(&h), [("Plan".into(), 0), ("Build it".into(), 0), ("Test".into(), 1)]);
}
+1 -1
View File
@@ -74,7 +74,7 @@ the round trip even though DeckCraft doesn't model it.
| Tables with styles | yes | yes | |
| Charts (`c:chartSpace`) | yes | yes | Original XML kept in `Chart.raw` |
| Charts (`cx:` chartex: waterfall, funnel, treemap, sunburst, histogram, box) | no | no | |
| SmartArt (`dgm`) | drawing part only, as a group | as shapes | Diagram data model lost on save |
| SmartArt (`dgm`) | drawing part only, as a group | as shapes | Diagram data model lost on save (DeckCraft keeps its own outline in `.deckcraft` only) |
| Equations (OMML, `a14:m`) | no | no | PR #55 open |
| Audio / video (`p14:media`, trim, fades, bookmarks) | yes | partly | Playback options lost on save (#74) |
| Ink (`contentPart`) | yes | as a group of freeform lines | PowerPoint sees shapes, not ink |
+8 -4
View File
@@ -150,11 +150,15 @@ Detail in [file-format-parity.md](file-format-parity.md).
- **Impact:** most business decks have charts; editing their data is routine.
- **Estimate:** 12–20 h. Data and elements are **beta blockers** (6–10 h).
### SmartArt is pre-drawn shapes only
- **Evidence:** `crates/pptx/src/read/shapes.rs` reads SmartArt's drawing part as a group; no text
pane, no layout switching, the diagram data isn't regenerated.
### SmartArt editing is shallow
- **Evidence:** a SmartArt graphic inserted in DeckCraft keeps its outline (`Shape.diagram`) and
has a Text Pane, Add Shape (after, before, above, below), Promote, Demote, Remove and layout
switching between DeckCraft's six layouts (`crates/engine/src/cmd/smartart.rs`). But
`crates/pptx/src/read/shapes.rs` reads a PowerPoint diagram's drawing part as a plain group (no
outline, so no Text Pane), the outline isn't written to `.pptx` (`dgm` data), and there are six
layouts rather than PowerPoint's ~200.
- **Impact:** editing a SmartArt diagram from PowerPoint breaks it.
- **Estimate:** 10–16 h.
- **Estimate:** 8–14 h.
### Animation Pane, effect count and preview
Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a **beta blocker**.
+3 -3
View File
@@ -9,7 +9,7 @@ The summary between `<!-- SUMMARY -->
|---|---|---|---|---|---|
| P0 | 72 | 61 | 11 | 0 | 92% |
| P1 | 65 | 38 | 21 | 6 | 75% |
| P2 | 54 | 13 | 15 | 26 | 38% |
| P2 | 54 | 13 | 16 | 25 | 39% |
| Area | Parity |
|---|---|
@@ -24,7 +24,7 @@ The summary between `<!-- SUMMARY -->
| Text | 80% |
| Tables | 89% |
| Charts | 65% |
| SmartArt | 33% |
| SmartArt | 50% |
| Pictures | 48% |
| Media | 67% |
| Transitions | 92% |
@@ -177,7 +177,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou
| Charts | Chart elements (title, legend, labels, axes, gridlines) | P1 | P |
| Charts | Chart styles and colours | P2 | P |
| SmartArt | Insert SmartArt (lists, processes, cycles, hierarchy) | P1 | P |
| SmartArt | Text pane and layout switching | P2 | M |
| SmartArt | Text pane and layout switching | P2 | P |
| Pictures | Insert picture (PNG, JPEG, GIF, WebP, BMP, TIFF) | P0 | D |
| Pictures | SVG pictures | P1 | M |
| Pictures | Crop (and crop to shape, aspect ratio) | P0 | P |
+1 -1
View File
@@ -190,7 +190,7 @@ it is the Features row of the dimension table. Automation and Platforms are scor
| Text | 15 | 80% | ~62% | 18–30 | Font weights beyond Regular/Bold/Italic (#69), Tab in text boxes (#78), IME, true vertical CJK, dictionaries, AutoCorrect, thesaurus, equations (PR #55 open) | [localization-parity.md](localization-parity.md) |
| Tables | 5 | 89% | ~75% | 3–6 | Draw table, borders and cell effects depth | [parity-checklist.md](parity-checklist.md) |
| Charts | 7 | 65% | ~45% | 12–20 | Data grid editing, chart elements, the `chartex` types (waterfall, funnel, treemap, sunburst, histogram, box) | [parity-checklist.md](parity-checklist.md) |
| SmartArt | 4 | 33% | ~25% | 10–16 | Text pane, layout switching, the diagram data model on save | [parity-checklist.md](parity-checklist.md) |
| SmartArt | 4 | 50% | ~35% | 8–14 | PowerPoint diagrams editable (read their data model), the data model on save, more layouts | [parity-checklist.md](parity-checklist.md) |
| Pictures | 7 | 48% | ~45% | 12–20 | SVG/EMF/WMF, clipboard paste on Windows/Linux (#53), artistic effects, remove background, compress | [file-format-parity.md](file-format-parity.md) |
| Media | 4 | 67% | ~60% | 8–14 | WMA/WMV decode, record audio/screen, icons and 3-D models, playback options lost on PPTX round trip (#74) | [file-format-parity.md](file-format-parity.md) |
| Transitions | 4 | 92% | ~80% | 4–8 | Transition sounds, 3-D transitions approximated in 2-D | [animation-parity.md](animation-parity.md) |