UI: WordArt adjust handles drag a text warp

A selected text warp shows one yellow handle per adjust.
Dragging it sets that guide value in one undo step.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nikita Rakitin
2026-10-13 22:38:16 +03:00
co-authored by Cursor
parent 457406a7a5
commit a7bc932cc8
10 changed files with 380 additions and 15 deletions
+2 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (WordArt adjust handles; previous: hours to ~95% per audience added; ready 56% → 55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON
format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere
@@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | What landed |
|---|---|
| 2026-10-13 | WordArt adjust handles: a selected text warp shows one yellow handle per adjust, and dragging it sets that guide value (one undo step) |
| 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless |
| 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline |
| 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting |
+40
View File
@@ -835,3 +835,43 @@ 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 wordart_adjust_handle_drags_and_undoes() {
let mut s = session();
s.prefs.snap_to_grid = false;
s.prefs.smart_guides = false;
let id =
s.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 60.0, 420.0, 200.0], "text": "ARCHED"})).unwrap()["id"].as_u64().unwrap();
let sid = model::ShapeId(id as u32);
{
let st = s.doc_mut().unwrap();
let slide = st.selection.slide;
let mut doc = (*st.doc).clone();
let sh = model::find_shape_mut(&mut std::sync::Arc::make_mut(&mut doc.slides[slide]).shapes, sid).unwrap();
sh.text.as_mut().unwrap().body.warp = Some("textArchUp".into());
st.doc = std::sync::Arc::new(doc);
}
s.execute("edit.select", &json!({"ids": [id]})).unwrap();
let (at, top) = {
let st = s.doc().unwrap();
let sh = st.shape(sid).unwrap();
let x = cmd::xfrm_of(&st.doc, &st.selection, sh);
let hs = tools::wordart_handles(&st.doc, &st.selection, sh, x.w, x.h);
assert_eq!(hs.len(), 1);
let at = x.affine() * hs[0].pos;
(at, deckcraft_geom::Point::new(x.center().x, x.y))
};
assert!(matches!(s.hit_test(at, 4.0), Some(tools::Hit::Warp { index: 0, .. })));
let ev = |kind, p: deckcraft_geom::Point| PointerEvent { kind, x: p.x, y: p.y, mods: Mods::default(), tol: 4.0 };
s.pointer(ev(PointerKind::Down, at)).unwrap();
s.pointer(ev(PointerKind::Drag, top)).unwrap();
s.pointer(ev(PointerKind::Up, top)).unwrap();
let adj = s.doc().unwrap().shape(sid).unwrap().text.as_ref().unwrap().body.warp_adj.clone();
let deg = adj.first().copied().unwrap_or(0.0) / 60_000.0;
assert!((deg - 270.0).abs() < 2.0, "{deg} from {adj:?}");
s.execute("edit.undo", &json!({})).unwrap();
let body = &s.doc().unwrap().shape(sid).unwrap().text.as_ref().unwrap().body;
assert_eq!(body.warp.as_deref(), Some("textArchUp"));
assert!(body.warp_adj.is_empty(), "undo restores the default adjusts");
}
+87 -1
View File
@@ -76,6 +76,11 @@ pub enum Hit {
id: ShapeId,
index: usize,
},
/// WordArt warp adjust (yellow handle). `index` is `adj` / `adjN`.
Warp {
id: ShapeId,
index: usize,
},
/// Line end 0/1 of a line-like shape.
LineEnd {
id: ShapeId,
@@ -119,6 +124,14 @@ enum Drag {
index: usize,
orig: Xfrm,
},
/// WordArt: `base` is the adjust list at mouse-down (empty means the preset defaults).
Warp {
id: ShapeId,
index: usize,
name: String,
base: Vec<f64>,
orig: Xfrm,
},
Marquee {
start: Point,
add: bool,
@@ -204,6 +217,45 @@ pub fn box_handles(x: &Xfrm) -> ([Point; 8], Point) {
(pts, rot)
}
/// WordArt adjust handles for `sh`, in shape-local points (the text rectangle). Empty when the
/// resolved body has no warp DeckCraft draws, or the shape has no text to store one on.
pub fn wordart_handles(
doc: &deckcraft_model::Presentation,
sel: &crate::Selection,
sh: &Shape,
w: f64,
h: f64,
) -> Vec<deckcraft_geom::warp::WarpHandle> {
if sh.text.is_none() {
return Vec::new();
}
let Some(ctx) = crate::cmd::text::ctx_for(doc, sel) else { return Vec::new() };
let bp = deckcraft_model::resolve::body(&ctx, sh);
let Some(name) = bp.warp.as_deref().filter(|n| deckcraft_geom::warp::is_supported(n)) else { return Vec::new() };
let tr = deckcraft_render::shape_geometry(sh, w, h).text_rect;
deckcraft_geom::warp::handles(name, &bp.warp_adj, tr)
}
/// Write one warp adjust, keeping the others from `base` (empty = the preset defaults).
fn set_warp_adj(sh: &mut Shape, name: &str, base: &[f64], index: usize, value: f64) {
let Some(text) = sh.text.as_mut() else { return };
if !value.is_finite() || index > 7 {
return;
}
let defaults = deckcraft_geom::warp::adjust_defaults(name);
let n = defaults.len().max(index + 1).min(8);
if n == 0 {
return;
}
let mut adj = Vec::with_capacity(n);
for i in 0..n {
let v = if i == index { value } else { base.get(i).copied().filter(|v| v.is_finite()).or_else(|| defaults.get(i).copied()).unwrap_or(0.0) };
adj.push(v);
}
text.body.warp = Some(name.to_string());
text.body.warp_adj = adj;
}
impl Session {
pub fn set_tool(&mut self, kind: ToolKind) {
self.tool.kind = kind;
@@ -239,6 +291,11 @@ impl Session {
return Some(Hit::Adjust { id: *id, index: hd.index });
}
}
for hd in wordart_handles(&st.doc, &st.selection, sh, x.w, x.h) {
if (x.affine() * hd.pos - p).hypot() <= tol * 1.6 {
return Some(Hit::Warp { id: *id, index: hd.index });
}
}
let (pts, rot) = box_handles(&x);
if (rot - p).hypot() <= tol * 1.8 && st.selection.text.is_none() {
return Some(Hit::Rotate { id: *id });
@@ -370,7 +427,7 @@ impl Session {
{
let x = xfrm_of(&st.doc, &st.selection, sh);
if x.bounds().inflate(tol, tol).contains(p)
&& !matches!(self.hit_test(p, tol), Some(Hit::Resize { .. } | Hit::Rotate { .. } | Hit::Adjust { .. }))
&& !matches!(self.hit_test(p, tol), Some(Hit::Resize { .. } | Hit::Rotate { .. } | Hit::Adjust { .. } | Hit::Warp { .. }))
{
let pos = text_pos(st, sh, p);
let shift = mods.shift;
@@ -412,6 +469,18 @@ impl Session {
self.begin("Adjust Shape");
self.tool.drag = Some(Drag::Adjust { id, index, orig });
}
Some(Hit::Warp { id, index }) => {
let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default();
let found = st.shape(id).and_then(|sh| {
let ctx = crate::cmd::text::ctx_for(&st.doc, &st.selection)?;
let bp = deckcraft_model::resolve::body(&ctx, sh);
bp.warp.map(|n| (n, bp.warp_adj))
});
if let Some((name, base)) = found {
self.begin("Adjust WordArt");
self.tool.drag = Some(Drag::Warp { id, index, name, base, orig });
}
}
Some(Hit::Shape { id, text }) => {
let selected = st.selection.shapes.contains(&id);
let toggle = mods.shift || mods.cmd;
@@ -601,6 +670,23 @@ impl Session {
Ok(())
})?;
}
Drag::Warp { id, index, name, base, orig } => {
let v = {
let st = self.doc()?;
let Some(sh) = st.shape(id) else { return Ok(Value::Null) };
let tr = deckcraft_render::shape_geometry(sh, orig.w, orig.h).text_rect;
let hs = deckcraft_geom::warp::handles(&name, &base, tr);
let Some(h) = hs.iter().find(|h| h.index == index) else { return Ok(Value::Null) };
h.value_at(orig.affine().inverse() * p)
};
self.edit(|doc, sel| {
let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("adjust", "no slide"))?;
if let Some(sh) = deckcraft_model::find_shape_mut(list, id) {
set_warp_adj(sh, &name, &base, index, v);
}
Ok(())
})?;
}
Drag::Marquee { start, .. } => {
self.tool.marquee = Some(Rect::from_points(start, p));
}
+188
View File
@@ -234,6 +234,194 @@ pub fn is_supported(name: &str) -> bool {
!adjust_defaults(name).is_empty()
}
/// A yellow adjust handle for one warp guide value. `pos` is in the same space as `rect`
/// (shape-local points). Dragging projects the pointer onto the handle's axis, or around the
/// text rectangle's centre for an angle.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct WarpHandle {
pub index: usize,
pub pos: Point,
kind: HandleKind,
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum HandleKind {
/// `pos = origin + dir * raw`. `value_at` projects onto that line and clamps.
Line { origin: Point, dir: Vec2, min: f64, max: f64 },
/// Angle about `center` (y down, 0° at +x). `invert` is arch-down, whose guide angle is `180° − adj`.
Angle { center: Point, invert: bool },
}
impl WarpHandle {
/// The raw guide value (`adj` / `adjN`) for a pointer at `p`.
pub fn value_at(&self, p: Point) -> f64 {
match self.kind {
HandleKind::Line { origin, dir, min, max } => {
let l2 = dir.hypot2();
if l2 < 1e-18 || !(p.x.is_finite() && p.y.is_finite()) {
return min;
}
let v = (p - origin).dot(dir) / l2;
if !v.is_finite() {
return min;
}
let (lo, hi) = (min.min(max), max.max(min));
v.clamp(lo, hi).round()
}
HandleKind::Angle { center, invert } => {
let d = p - center;
if !(d.x.is_finite() && d.y.is_finite()) || d.hypot() < 1e-9 {
return 0.0;
}
let mut deg = d.y.atan2(d.x).to_degrees();
if invert {
deg = 180.0 - deg;
}
(deg.rem_euclid(360.0) * 60_000.0).round()
}
}
}
}
fn stored_raw(adj: &[f64], defaults: &[f64], i: usize) -> f64 {
adj.get(i).copied().filter(|v| v.is_finite()).or_else(|| defaults.get(i).copied()).unwrap_or(0.0)
}
fn unit(rect: Rect, u: f64, v: f64) -> Point {
Point::new(rect.x0 + u * rect.width(), rect.y0 + v * rect.height())
}
/// A fraction handle: unit position `(x, sign * frac)`, raw = frac × 100 000, clamped to `lo..=hi`.
fn frac_handle(rect: Rect, index: usize, x: f64, frac: f64, lo: f64, hi: f64, sign: f64) -> WarpHandle {
let frac = frac.clamp(lo, hi);
WarpHandle {
index,
pos: unit(rect, x, sign * frac),
kind: HandleKind::Line {
origin: unit(rect, x, 0.0),
dir: Vec2::new(0.0, sign * rect.height() / 100_000.0),
min: lo * 100_000.0,
max: hi * 100_000.0,
},
}
}
/// A fraction handle that slides horizontally: unit `(frac, y)`.
fn frac_x_handle(rect: Rect, index: usize, y: f64, frac: f64, lo: f64, hi: f64) -> WarpHandle {
let frac = frac.clamp(lo, hi);
WarpHandle {
index,
pos: unit(rect, frac, y),
kind: HandleKind::Line {
origin: unit(rect, 0.0, y),
dir: Vec2::new(rect.width() / 100_000.0, 0.0),
min: lo * 100_000.0,
max: hi * 100_000.0,
},
}
}
/// Angle handle, inset from the ellipse so it doesn't sit on a resize handle.
fn angle_handle(rect: Rect, index: usize, deg: f64, invert: bool) -> WarpHandle {
let geom = if invert { 180.0 - deg } else { deg };
let rad = geom.to_radians();
let c = rect.center();
WarpHandle {
index,
pos: Point::new(c.x + rect.width() * 0.36 * rad.cos(), c.y + rect.height() * 0.36 * rad.sin()),
kind: HandleKind::Angle { center: c, invert },
}
}
/// Inner-ellipse radius (pour presets): the handle sits on the downward point of that ellipse.
fn radius_handle(rect: Rect, r: f64) -> WarpHandle {
let r = r.clamp(0.0, 0.99);
WarpHandle {
index: 1,
pos: unit(rect, 0.5, 0.5 + 0.5 * r),
kind: HandleKind::Line { origin: unit(rect, 0.5, 0.5), dir: Vec2::new(0.0, 0.5 * rect.height() / 100_000.0), min: 0.0, max: 99_000.0 },
}
}
/// Wave phase: slides sideways around the middle of the lower half.
fn shift_handle(rect: Rect, s: f64) -> WarpHandle {
let s = s.clamp(-0.1, 0.1);
WarpHandle {
index: 1,
pos: unit(rect, 0.5 + s, 0.78),
kind: HandleKind::Line { origin: unit(rect, 0.5, 0.78), dir: Vec2::new(rect.width() / 100_000.0, 0.0), min: -10_000.0, max: 10_000.0 },
}
}
/// Yellow handles for warp `name` over text rectangle `rect`. Empty when `name` isn't a drawn
/// preset or `rect` isn't a usable box. Missing adjust values take the preset defaults.
pub fn handles(name: &str, adj: &[f64], rect: Rect) -> Vec<WarpHandle> {
if ![rect.x0, rect.y0, rect.x1, rect.y1].iter().all(|v| v.is_finite()) {
return Vec::new();
}
let rect = Rect::new(rect.x0.min(rect.x1), rect.y0.min(rect.y1), rect.x0.max(rect.x1), rect.y0.max(rect.y1));
if rect.width() < 1e-3 || rect.height() < 1e-3 {
return Vec::new();
}
let defaults = adjust_defaults(name);
if defaults.is_empty() {
return Vec::new();
}
let raw = |i| stored_raw(adj, defaults, i);
let frac = |i| raw(i) / 100_000.0;
let deg = |i| (raw(i) / 60_000.0).rem_euclid(360.0);
let y = |out: &mut Vec<WarpHandle>, x, lo, hi, sign| out.push(frac_handle(rect, 0, x, frac(0), lo, hi, sign));
let xh = |out: &mut Vec<WarpHandle>, yy, lo, hi| out.push(frac_x_handle(rect, 0, yy, frac(0), lo, hi));
let mut out = Vec::with_capacity(defaults.len());
match name {
"textPlain" => xh(&mut out, 0.28, 0.3, 0.7),
"textStop" => y(&mut out, 0.5, 0.14286, 0.5, 1.0),
"textTriangle" | "textTriangleInverted" => y(&mut out, 0.18, 0.0, 1.0, 1.0),
"textChevron" => y(&mut out, 0.18, 0.0, 0.5, 1.0),
"textChevronInverted" => y(&mut out, 0.18, 0.5, 1.0, 1.0),
"textFadeRight" => y(&mut out, 0.82, 0.0, 0.49999, 1.0),
"textFadeLeft" => y(&mut out, 0.18, 0.0, 0.49999, 1.0),
"textFadeUp" => xh(&mut out, 0.22, 0.0, 0.49999),
"textFadeDown" => xh(&mut out, 0.78, 0.0, 0.49999),
"textSlantUp" => y(&mut out, 0.18, 0.44444, 1.0, 1.0),
"textSlantDown" => y(&mut out, 0.18, 0.0, 0.55556, 1.0),
"textCascadeUp" => y(&mut out, 0.82, 0.0, 1.0, 1.0),
"textCascadeDown" => y(&mut out, 0.18, 0.0, 1.0, 1.0),
"textInflate" => y(&mut out, 0.62, 0.0, 0.2, -1.0),
"textDeflate" => y(&mut out, 0.62, 0.0, 0.375, 1.0),
"textInflateBottom" => y(&mut out, 0.62, 0.6, 1.0, 1.0),
"textDeflateBottom" => y(&mut out, 0.62, 0.0625, 1.0, 1.0),
"textInflateTop" => y(&mut out, 0.62, 0.0, 0.5, 1.0),
"textDeflateTop" => y(&mut out, 0.62, 0.0, 0.9375, 1.0),
"textDeflateInflate" => y(&mut out, 0.5, 0.05, 0.95, 1.0),
"textDeflateInflateDeflate" => y(&mut out, 0.5, 0.01, 1.0 / 3.0, 1.0),
"textCurveUp" | "textCurveDown" => y(&mut out, 0.18, 0.0, 0.56818, 1.0),
"textCanUp" => y(&mut out, 0.62, 0.5, 1.0, 1.0),
"textCanDown" => y(&mut out, 0.62, 0.0, 0.5, 1.0),
"textRingInside" | "textRingOutside" => y(&mut out, 0.5, 0.5, 0.99, 1.0),
"textArchUp" | "textCircle" | "textButton" => out.push(angle_handle(rect, 0, deg(0), false)),
"textArchDown" => out.push(angle_handle(rect, 0, deg(0), true)),
"textArchUpPour" | "textCirclePour" | "textButtonPour" => {
out.push(angle_handle(rect, 0, deg(0), false));
out.push(radius_handle(rect, frac(1)));
}
"textArchDownPour" => {
out.push(angle_handle(rect, 0, deg(0), true));
out.push(radius_handle(rect, frac(1)));
}
"textWave1" | "textWave2" => {
y(&mut out, 0.32, 0.0, 0.2, 1.0);
out.push(shift_handle(rect, frac(1)));
}
"textDoubleWave1" | "textWave4" => {
y(&mut out, 0.32, 0.0, 0.125, 1.0);
out.push(shift_handle(rect, frac(1)));
}
_ => return Vec::new(),
}
out
}
/// Segments of a guide in unit coordinates (x, y in 0..1 over the text rectangle).
#[derive(Clone, Copy, Debug)]
enum Seg {
+40
View File
@@ -307,3 +307,43 @@ fn hostile_adjusts_and_boxes_stay_finite() {
}
assert!(warp("textPlain", &[], Rect::new(f64::NAN, 0.0, 1.0, 1.0)).is_none());
}
#[test]
fn every_preset_has_one_handle_per_adjust() {
let r = rect();
for name in ALL {
let hs = handles(name, &[], r);
assert_eq!(hs.len(), adjust_count(name), "{name}");
let defaults = adjust_defaults(name);
for h in &hs {
assert!(h.pos.x.is_finite() && h.pos.y.is_finite(), "{name}");
let back = h.value_at(h.pos);
let want = defaults[h.index];
assert!((back - want).abs() <= 1.0, "{name} handle {}: {back} vs {want}", h.index);
}
}
assert!(handles("textNoShape", &[], r).is_empty());
assert!(handles("textArchUp", &[], Rect::new(0.0, 0.0, f64::NAN, 10.0)).is_empty());
assert!(handles("textArchUp", &[], Rect::new(0.0, 0.0, 0.0, 0.0)).is_empty());
// A hostile adjust still places a finite handle on the clamped default.
let hs = handles("textInflate", &[f64::NAN], r);
assert_eq!(hs.len(), 1);
assert!(hs[0].pos.x.is_finite() && hs[0].pos.y.is_finite());
}
#[test]
fn arch_and_inflate_handles_follow_the_pointer() {
let r = rect();
let arch = &handles("textArchUp", &[], r)[0];
let deg = arch.value_at(Point::new(r.center().x, r.y0)) / 60_000.0;
assert!((deg - 270.0).abs() < 0.5, "{deg}");
let down = &handles("textArchDown", &[], r)[0];
// The arch-down handle starts on the left; a pointer at the bottom is 90°, which is adjust 90
// (guide angle 180 − adj).
let deg = down.value_at(Point::new(r.center().x, r.y1)) / 60_000.0;
assert!((deg - 90.0).abs() < 0.5, "{deg}");
let inf = &handles("textInflate", &[], r)[0];
let more = inf.value_at(Point::new(inf.pos.x, r.y0 - 80.0));
assert!(more > adjust_defaults("textInflate")[0], "{more}");
assert!((more - 20_000.0).abs() < 1.0, "{more}");
}
+5 -1
View File
@@ -404,7 +404,7 @@ fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) {
_ => egui::CursorIcon::ResizeHorizontal,
},
(_, Some(Hit::Rotate { .. })) => egui::CursorIcon::Alias,
(_, Some(Hit::Adjust { .. } | Hit::LineEnd { .. })) => egui::CursorIcon::PointingHand,
(_, Some(Hit::Adjust { .. } | Hit::Warp { .. } | Hit::LineEnd { .. })) => egui::CursorIcon::PointingHand,
(_, Some(Hit::Shape { text: true, .. })) => {
if app.session.active().is_some_and(|d| d.selection.text.is_some()) {
egui::CursorIcon::Text
@@ -484,6 +484,10 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To
let q = xf.to_screen(a * hd.pos);
painter.circle(q, 4.5, Color32::from_rgb(0xFF, 0xD2, 0x3F), Stroke::new(1.0, Color32::from_rgb(0x8A, 0x6A, 0x00)));
}
for hd in deckcraft_engine::tools::wordart_handles(&st.doc, &st.selection, sh, x.w, x.h) {
let q = xf.to_screen(a * hd.pos);
painter.circle(q, 4.5, Color32::from_rgb(0xFF, 0xD2, 0x3F), Stroke::new(1.0, Color32::from_rgb(0x8A, 0x6A, 0x00)));
}
}
}
// Caret / text selection.
+9 -7
View File
@@ -1,6 +1,6 @@
# Where DeckCraft falls short of PowerPoint
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (3-D bevel, camera and face lighting rendered; previous: minor, IME composition and CJK interface text landed, #101; previous: minor, core-workflow alpha gate added; stage stays alpha; before that: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (WordArt adjust handles drag; previous: minor, 3-D bevel, camera and face lighting rendered; previous: minor, IME composition and CJK interface text landed, #101) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
Every known shortfall, one entry each, ranked by how much it stops someone who presents with
PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in
@@ -166,12 +166,13 @@ Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a *
### Edit points, text warp, 3-D
- **Evidence:** checklist rows "Edit points" (P1 missing), "Text warp transforms" (partial: all 40
`prstTxWarp` presets render and their adjusts round-trip, but there is no Transform gallery or
command to apply one, no adjust handles, and arc/circle placement can differ from PowerPoint by
1–3 % of the path length), "Bevel and 3-D rotation" (partial: bevels, camera presets and rotations and face lighting
render as approximations and `scene3d`/`sp3d` round-trip as raw XML; no extrusion or side faces,
only the three-point rig, and no command or pane to apply 3-D).
- **Estimate:** 4–6 h (edit points), 2–4 h (warp gallery, command and handles), 6–10 h (3-D extrusion,
`prstTxWarp` presets render, their adjusts round-trip, and a selected warp shows a yellow handle
per adjust that dragging sets; still no Transform gallery or command to apply one, and arc/circle
placement can differ from PowerPoint by 1–3 % of the path length), "Bevel and 3-D rotation"
(partial: bevels, camera presets and rotations and face lighting render as approximations and
`scene3d`/`sp3d` round-trip as raw XML; no extrusion or side faces, only the three-point rig, and
no command or pane to apply 3-D).
- **Estimate:** 4–6 h (edit points), 1–2 h (Transform gallery and command), 6–10 h (3-D extrusion,
rigs and materials, Format Shape page).
### Proofing: spelling, thesaurus, AutoCorrect
@@ -256,6 +257,7 @@ Detail in [localization-parity.md](localization-parity.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | minor | WordArt adjust handles: a selected text warp shows one yellow handle per adjust, and dragging it sets that guide value (gallery and command still open) |
| 2026-10-13 | minor | Shape, picture and table rendering checked against PowerPoint on ~150 synthetic edge-case slides; 3-D bevels, cameras and face lighting now render approximately (gap narrowed to extrusion, rigs and the 3-D pane) |
| 2026-10-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines |
| 2026-10-12 | minor | EMF/WMF text, pattern and hatch brushes drawn; legacy OLE and ActiveX previews shown; the SVG gap keeps only EMF clip regions |
+3 -2
View File
@@ -1,6 +1,6 @@
# DeckCraft roadmap: milestones and what's next
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (core-workflow alpha gate added; stage stays alpha; previous: major, created; milestones carried from the local execution plan, the beta plan built from gaps.md) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M9 text warps: adjust handles drag; gallery and command remain) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
Forward-looking: where DeckCraft is going and in what order. The one-page summary is
[ROADMAP.md](../ROADMAP.md); every shortfall is in [gaps.md](gaps.md); numbers are in
@@ -79,7 +79,7 @@ From the local execution plan (`plan/execution-plan.md`, gitignored). Status as
| M6 | Transitions and animations | mostly | Animation Pane timeline, remaining effects, transition sounds |
| M7 | Slide show | mostly | second-display placement, laser, record |
| M8 | Tables and charts | partly | chart data grid, elements, chartex types, draw table |
| M9 | SmartArt, equations, WordArt | partly | SmartArt text pane and layouts, equations (PR #55), text warps |
| M9 | SmartArt, equations, WordArt | partly | SmartArt text pane and layouts, equations (PR #55), warp gallery and command |
| M10 | Views | done | grid options, New Window |
| M11 | Output | partly | vector PDF, SVG, GIF, MP4, print |
| M12 | Review | partly | dictionaries, AutoCorrect, find/replace dialog, compare |
@@ -109,5 +109,6 @@ plan above.
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | minor | M9: WordArt adjust handles drag; the Transform gallery and command stay open |
| 2026-10-10 | minor | Added the alpha gate (six core workflows): all pass, two with partial depth; stage stays alpha |
| 2026-10-10 | major | Created: current focus, ranked beta plan with hours, milestones M0–M14 with status, the old alpha checklist as history |
+3 -2
View File
@@ -1,6 +1,6 @@
# DeckCraft vs Microsoft PowerPoint: parity assessment
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready recomputed from written weights, 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; replaces the parity sections of ROADMAP.md) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (text-warp shortfall is the gallery and command; adjust handles drag) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
The authoritative answer to "how close is DeckCraft to PowerPoint, and how much work is left?".
[ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) itemizes every shortfall;
@@ -184,7 +184,7 @@ it is the Features row of the dimension table. Automation and Platforms are scor
| Slides and sections | 6 | 90% | ~85% | 3–5 | Zoom (summary/section/slide), copying slides between decks keeps formatting only partly | [parity-checklist.md](parity-checklist.md) |
| Views | 4 | 90% | ~80% | 3–6 | Snap to grid and grid options, New Window | [ui-parity.md](ui-parity.md) |
| Masters and themes | 7 | 85% | ~70% | 8–14 | Notes and Handout masters, theme variants, `.thmx`, effect schemes | [parity-checklist.md](parity-checklist.md) |
| Shapes | 7 | 86% | ~75% | 8–14 | Edit points, text warp, WordArt depth | [ui-parity.md](ui-parity.md) |
| Shapes | 7 | 86% | ~75% | 8–14 | Edit points, warp gallery and command, WordArt depth | [ui-parity.md](ui-parity.md) |
| Format (fill, line, effects) | 6 | 81% | ~65% | 8–14 | Format Shape pane depth, bevel and 3-D, picture/texture fill options | [ui-parity.md](ui-parity.md) |
| Arrange | 3 | 100% | ~90% | 1–2 | Align to slide vs selected as a menu choice, More Rotation Options | [ui-parity.md](ui-parity.md) |
| 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) |
@@ -276,6 +276,7 @@ scored done (16 open issues).
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | minor | Shapes shortfall "text warp" narrowed: yellow adjust handles drag; the gallery and command remain |
| 2026-10-10 | minor | Added the readiness-by-audience table: each number gets its own hours to ~95% (full 340–600, mainstream 150–250, essentials 50–90). The full number stays an additive weighted sum; no change to the percentages |
| 2026-10-10 | minor | Added the mainstream practitioner (~53%) and essentials user (~62%) numbers with written weights, discounts and user evidence. Features dimension recomputed from written area weights (66% judgement → 62.4% weighted mean), which moves ready for real work from ~56% to ~55%; stage unchanged |
| 2026-10-10 | minor | Stage checked against the core-workflow gate (all six pass); stays alpha |
+3 -1
View File
@@ -1,6 +1,6 @@
# UI parity with PowerPoint
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (File tab / Backstage, #76; previous: off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (WordArt adjust handles; previous: File tab / Backstage, #76; previous: off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
@@ -79,6 +79,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`).
| Shift-constrained move, Option-drag to duplicate | yes | yes |
| Connector glue and rerouting | yes | yes |
| Edit points on freeforms | yes | **no** |
| WordArt adjust handles | yellow handles on transformed text | yes, one per adjust when the text has a warp; dragging sets that guide value |
| Crop handles, crop to shape, aspect ratio | yes | partial |
| Text caret, selection, double/triple click, drag-select | yes | yes; Tab in text boxes ends editing (#78) |
| Exact size/position, lock aspect | yes | yes |
@@ -148,6 +149,7 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | minor | WordArt adjust handles on a selected text warp; dragging one sets that `prstTxWarp` adjust |
| 2026-10-12 | minor | File tab (Backstage) observed in PowerPoint for Windows and matched (#76); the start screen is File › Home |
| 2026-10-12 | minor | Off-slide objects drawn and editable around the slide in Normal view (#270) |
| 2026-10-12 | minor | Selection Pane matched to PowerPoint (#133): drag and drop, arrows, groups, eye, Lock |