Draw: pen pressure scales ink width

A pen reading of 1 is the full pen width. A lighter touch draws a thinner
stroke, and a zero reading stays visible at 8% of the pen. A mouse, a
missing or non-finite reading, and a stroke whose samples are all the old
placeholder 0.5 still draw at the pen width. PPTX keeps one width per
stroke: the mean of the samples for a pressured stroke, the pen width for
a legacy one. Tilt is not read.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nikita Rakitin
2026-10-14 01:56:23 +03:00
co-authored by Cursor
parent 457406a7a5
commit 1a0ffc6413
18 changed files with 244 additions and 50 deletions
+3 -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-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: pen pressure scales ink width; previous: minor, 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-14 | Draw: pen pressure scales ink width when the pointer reports it (a mouse or a missing reading stays at the pen width; a stroke saved as a flat 0.5 still draws at the pen width). Tilt is not read |
| 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 |
@@ -150,6 +151,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | trivial | Progress log: pen pressure scales ink width |
| 2026-10-13 | trivial | Progress log: wild text cases |
| 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) |
| 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) |
+1 -1
View File
@@ -201,7 +201,7 @@ fn locked_objects_are_picked_on_the_canvas_but_not_dragged() {
s.execute("shape.lock", &json!({"id": a, "locked": true})).unwrap();
s.execute("edit.select", &json!({"ids": []})).unwrap();
let n = s.doc().unwrap().history.undo.len();
let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 };
let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None };
// A: x 20..120, y 50..130.
s.pointer(ev(PointerKind::Down, 60.0, 90.0)).unwrap();
assert_eq!(selected(&s), vec![a], "a click selects a locked object");
+33 -8
View File
@@ -126,7 +126,7 @@ fn align_and_distribute() {
fn pointer_draw_move_resize() {
let mut s = session();
s.set_tool(ToolKind::Shape { preset: "rect".into() });
let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 };
let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None };
s.pointer(ev(PointerKind::Down, 100.0, 100.0)).unwrap();
s.pointer(ev(PointerKind::Drag, 200.0, 180.0)).unwrap();
let r = s.pointer(ev(PointerKind::Up, 200.0, 180.0)).unwrap();
@@ -157,7 +157,7 @@ fn pointer_draw_move_resize() {
#[test]
fn click_into_placeholder_starts_editing() {
let mut s = session();
let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 };
let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None };
s.pointer(ev(PointerKind::Down, 480.0, 200.0)).unwrap();
s.pointer(ev(PointerKind::Up, 480.0, 200.0)).unwrap();
assert!(s.doc().unwrap().selection.text.is_some());
@@ -290,7 +290,7 @@ fn drawing_a_line_between_shapes_glues_it() {
let b = s.execute("shape.insert", &json!({"preset": "rect", "rect": [400, 100, 100, 100]})).unwrap()["id"].as_u64().unwrap();
s.execute("edit.deselect", &json!({})).unwrap();
s.tool.kind = tools::ToolKind::Shape { preset: "straightConnector1".into() };
let ev = |kind, x: f64, y: f64| tools::PointerEvent { kind, x, y, mods: Default::default(), tol: 4.0 };
let ev = |kind, x: f64, y: f64| tools::PointerEvent { kind, x, y, mods: Default::default(), tol: 4.0, pressure: None };
// Right site of the ellipse (200,150) → left site of the rect (400,150), a little off.
s.pointer(ev(tools::PointerKind::Down, 202.0, 151.0)).unwrap();
s.pointer(ev(tools::PointerKind::Drag, 300.0, 150.0)).unwrap();
@@ -323,7 +323,7 @@ fn freeform_tools_and_command() {
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().shapes().len(), before + 1);
let ev = |kind, x: f64, y: f64| tools::PointerEvent { kind, x, y, mods: Default::default(), tol: 3.0 };
let ev = |kind, x: f64, y: f64| tools::PointerEvent { kind, x, y, mods: Default::default(), tol: 3.0, pressure: None };
// Polygon: click, click, click, click on the start → closed, filled.
s.set_tool(tools::ToolKind::Shape { preset: "freeform".into() });
for (x, y) in [(300.0, 300.0), (400.0, 300.0), (400.0, 400.0)] {
@@ -376,7 +376,7 @@ fn double_click_in_table_uses_grown_row_heights() {
for row in 0..3 {
s.execute("table.rowHeight", &json!({"id": id, "row": row, "height": 4.0})).unwrap();
}
let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 };
let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None };
s.pointer(ev(PointerKind::DoubleClick, 120.0, 106.0)).unwrap();
assert_eq!(s.doc().unwrap().selection.text.as_ref().and_then(|t| t.cell), Some((0, 0)));
}
@@ -580,7 +580,7 @@ fn shapes_off_the_slide_can_be_selected_and_moved() {
// Inserting off the slide keeps the box where it was asked for.
assert_eq!(s.execute("shape.inspect", &json!({"id": off})).unwrap()["box"]["x"], 1000.0);
s.execute("edit.select", &json!({"ids": []})).unwrap();
let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 };
let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None };
// Hover and click wholly off the slide.
assert!(matches!(s.hit_test(deckcraft_geom::Point::new(1060.0, 140.0), 3.0), Some(tools::Hit::Shape { id, .. }) if id.0 as u64 == off));
s.pointer(ev(PointerKind::Down, 1060.0, 140.0)).unwrap();
@@ -698,9 +698,9 @@ fn ime_composition_is_kept_by_a_key_a_click_or_another_command() {
assert_eq!(s.doc().unwrap().history.undo.len(), undo0 + 1);
s.compose_text("녕", None).unwrap();
s.pointer(PointerEvent { kind: PointerKind::Move, x: 5.0, y: 5.0, mods: Mods::default(), tol: 4.0 }).unwrap();
s.pointer(PointerEvent { kind: PointerKind::Move, x: 5.0, y: 5.0, mods: Mods::default(), tol: 4.0, pressure: None }).unwrap();
assert!(s.doc().unwrap().composition.is_some(), "hovering keeps composing");
s.pointer(PointerEvent { kind: PointerKind::Down, x: 5.0, y: 5.0, mods: Mods::default(), tol: 4.0 }).unwrap();
s.pointer(PointerEvent { kind: PointerKind::Down, x: 5.0, y: 5.0, mods: Mods::default(), tol: 4.0, pressure: None }).unwrap();
assert!(s.doc().unwrap().composition.is_none(), "a click settles it");
assert_eq!(title_text(&s), "안녕");
@@ -835,3 +835,28 @@ 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 pen_pressure_is_kept_and_a_mouse_draws_at_full_width() {
let mut s = session();
s.set_tool(tools::ToolKind::Ink { mode: "pen".into(), color: deckcraft_model::Rgba::rgb(20, 20, 20), width: 4.0 });
let ev = |kind, x, y, pressure| tools::PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure };
s.pointer(ev(PointerKind::Down, 40.0, 80.0, Some(0.2))).unwrap();
s.pointer(ev(PointerKind::Drag, 80.0, 80.0, Some(1.0))).unwrap();
s.pointer(ev(PointerKind::Drag, 120.0, 80.0, Some(0.4))).unwrap();
s.pointer(ev(PointerKind::Up, 120.0, 80.0, Some(0.4))).unwrap();
s.pointer(ev(PointerKind::Down, 40.0, 160.0, None)).unwrap();
s.pointer(ev(PointerKind::Drag, 100.0, 160.0, Some(f32::NAN))).unwrap();
s.pointer(ev(PointerKind::Up, 100.0, 160.0, None)).unwrap();
let got = ink_pressures(&s);
assert_eq!(got, vec![vec![0.2, 1.0, 0.4], vec![1.0, 1.0]]);
}
fn ink_pressures(s: &Session) -> Vec<Vec<f32>> {
let st = s.doc().unwrap();
let Some(sh) = st.shapes().iter().rev().find(|sh| matches!(sh.kind, deckcraft_model::ShapeKind::Ink { .. })) else {
return vec![];
};
let deckcraft_model::ShapeKind::Ink { strokes } = &sh.kind else { return vec![] };
strokes.iter().map(|stroke| stroke.points.iter().map(|p| p.2).collect()).collect()
}
+20 -3
View File
@@ -40,6 +40,10 @@ pub struct PointerEvent {
pub mods: Mods,
/// Hit tolerance in points (≈ 4 screen px at the current zoom).
pub tol: f64,
/// Pen pressure `0..=1` when the device reported one. `None` is a mouse: the stroke
/// is stored at full pen width.
#[serde(default)]
pub pressure: Option<f32>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
@@ -178,6 +182,8 @@ pub struct ToolState {
pub path_preview: Vec<Point>,
pub path_smooth: bool,
pub path_closing: bool,
/// Pressure from the pointer event being handled, if the device reported one.
pressure: Option<f32>,
}
impl ToolState {
@@ -267,6 +273,7 @@ impl Session {
pub fn pointer(&mut self, ev: PointerEvent) -> Result<Value> {
let tol = if ev.tol > 0.0 { ev.tol } else { 4.0 };
let p = Point::new(ev.x, ev.y);
self.tool.pressure = ev.pressure.filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 1.0));
if !(p.x.is_finite() && p.y.is_finite()) {
return Ok(Value::Null);
}
@@ -355,8 +362,9 @@ impl Session {
if mode == "eraser" {
return self.erase_at(p, tol);
}
self.tool.drag = Some(Drag::Ink { points: vec![(p.x, p.y, 0.5)] });
self.tool.ink_preview = vec![(p.x, p.y, 0.5)];
let sample = self.ink_sample(p);
self.tool.drag = Some(Drag::Ink { points: vec![sample] });
self.tool.ink_preview = vec![sample];
return Ok(Value::Null);
}
ToolKind::Select => {}
@@ -653,7 +661,7 @@ impl Session {
self.tool.drag = Some(Drag::Freeform { mode, points, pressed });
}
Drag::Ink { mut points } => {
points.push((p.x, p.y, 0.5));
points.push(self.ink_sample(p));
self.tool.ink_preview = points.clone();
self.tool.drag = Some(Drag::Ink { points });
}
@@ -1040,6 +1048,15 @@ impl Session {
nx
}
/// One ink sample. No device reading is full pen width (`1`), not the old placeholder `0.5`.
fn ink_sample(&self, p: Point) -> (f64, f64, f32) {
let pressure = match self.tool.pressure {
Some(v) if v.is_finite() => v.clamp(0.0, 1.0),
_ => 1.0,
};
(p.x, p.y, pressure)
}
fn add_ink(&mut self, points: Vec<(f64, f64, f32)>) -> Result<Value> {
let ToolKind::Ink { mode, color, width } = self.tool.kind.clone() else { return Ok(Value::Null) };
let stroke = deckcraft_model::InkStroke { points, color, width, highlighter: mode == "highlighter" };
+2 -1
View File
@@ -88,7 +88,8 @@ impl Headless {
let x = e.get("x").and_then(Value::as_f64).ok_or("pointer event needs `x`")?;
let y = e.get("y").and_then(Value::as_f64).ok_or("pointer event needs `y`")?;
let mods = e.get("mods").map(mods_from).unwrap_or(base);
last = self.session.pointer(PointerEvent { kind, x, y, mods, tol: 3.0 }).map_err(|e| e.to_string())?;
let pressure = e.get("pressure").and_then(Value::as_f64).map(|v| v as f32);
last = self.session.pointer(PointerEvent { kind, x, y, mods, tol: 3.0, pressure }).map_err(|e| e.to_string())?;
}
self.session.ui_requests.clear();
Ok(json!({"result": last, "selection": self.session.active().map(|d| serde_json::to_value(&d.selection).unwrap_or_default())}))
+20 -1
View File
@@ -249,7 +249,8 @@ pub struct MediaClip {
pub height: u32,
}
/// One ink stroke (Draw tab): points in slide coordinates with pressure.
/// One ink stroke (Draw tab): points in slide coordinates with pressure (`0..=1`,
/// where `1` is [`InkStroke::width`]).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct InkStroke {
@@ -259,6 +260,24 @@ pub struct InkStroke {
pub highlighter: bool,
}
/// True when every sample is the placeholder `0.5` written before a device reported pressure.
/// Those strokes were drawn at [`InkStroke::width`] and still are.
pub fn ink_pressure_is_legacy(points: &[(f64, f64, f32)]) -> bool {
!points.is_empty() && points.iter().all(|p| (p.2 - 0.5).abs() <= 1.0e-3)
}
/// Width of one ink sample. `pressure` of `1` is the pen width. A legacy flat stroke
/// (`legacy`) draws at the pen width. A missing reading draws at the pen width too.
/// A light touch stays visible (at least 8% of the pen).
pub fn ink_sample_width(pen: f64, pressure: f32, legacy: bool) -> f64 {
let pen = if pen.is_finite() { pen.abs() } else { 0.0 };
if legacy {
return pen;
}
let p = if pressure.is_finite() { pressure.clamp(0.08, 1.0) } else { 1.0 };
pen * f64::from(p)
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "type")]
pub enum ShapeKind {
+13
View File
@@ -231,3 +231,16 @@ fn sibling_lists_and_parents_reach_into_groups() {
assert_eq!(sibling_list_mut(&mut tree, ShapeId(5)).map(|v| ids(v)), Some(vec![5]));
assert!(sibling_list_mut(&mut tree, ShapeId(9)).is_none());
}
#[test]
fn ink_width_follows_pressure_and_keeps_the_old_placeholder() {
assert!(ink_pressure_is_legacy(&[(0.0, 0.0, 0.5), (1.0, 1.0, 0.5)]));
assert!(!ink_pressure_is_legacy(&[(0.0, 0.0, 0.5), (1.0, 1.0, 1.0)]));
assert!(!ink_pressure_is_legacy(&[]));
assert!((ink_sample_width(8.0, 0.5, true) - 8.0).abs() < 1e-9);
assert!((ink_sample_width(8.0, 1.0, false) - 8.0).abs() < 1e-9);
assert!((ink_sample_width(8.0, 0.25, false) - 2.0).abs() < 1e-9);
let floor = ink_sample_width(8.0, 0.0, false);
assert!((floor - 0.64).abs() < 1e-4, "a light touch stays visible, got {floor}");
assert!((ink_sample_width(8.0, f32::NAN, false) - 8.0).abs() < 1e-9);
}
+10 -1
View File
@@ -408,6 +408,15 @@ fn ink_shape(st: &deckcraft_model::InkStroke, i: usize) -> Option<Shape> {
return None;
}
let (w, h) = ((x1 - x0).max(1.0), (y1 - y0).max(1.0));
// PPTX keeps one line width. A pressured stroke exports at its mean sample width;
// a legacy flat `0.5` stroke stays at the pen width.
let legacy = deckcraft_model::ink_pressure_is_legacy(&st.points);
let width = if legacy {
st.width
} else {
let n = st.points.len().max(1) as f64;
st.points.iter().map(|p| deckcraft_model::ink_sample_width(st.width, p.2, false)).sum::<f64>() / n
};
let mut d = String::new();
for (k, (px, py)) in pts.iter().enumerate() {
d.push_str(if k == 0 { "M " } else { "L " });
@@ -426,7 +435,7 @@ fn ink_shape(st: &deckcraft_model::InkStroke, i: usize) -> Option<Shape> {
line: Some(deckcraft_model::Line {
cap: Some(deckcraft_model::style::LineCap::Round),
join: Some(deckcraft_model::style::LineJoin::Round),
..deckcraft_model::Line::solid(c, st.width.max(0.25))
..deckcraft_model::Line::solid(c, width.max(0.25))
}),
..Default::default()
})
+33 -2
View File
@@ -487,9 +487,40 @@ fn pictures_media_and_ink_round_trip() {
assert_eq!((m.trim_start_ms, m.fade_in_ms), (500, 250));
assert!(q.media(m.media).is_some_and(|x| x.data.starts_with(b"\x00\x00\x00\x18ftyp")));
assert!(m.poster.is_some());
// Ink comes back as freeform lines in a group.
// Ink comes back as freeform lines in a group. A flat 0.5 stroke keeps the pen width.
let ink = by_name(&q.slides[2], "Ink");
assert!(matches!(&ink.kind, ShapeKind::Group { children, .. } if children.len() == 1 && matches!(children[0].geom, Geom::Custom { .. })));
let ShapeKind::Group { children, .. } = &ink.kind else { panic!("ink is not a group: {:?}", ink.kind) };
assert_eq!(children.len(), 1);
assert!(matches!(children[0].geom, Geom::Custom { .. }));
let w = children[0].line.as_ref().and_then(|l| l.width).unwrap_or(0.0);
assert!((w - 3.0).abs() < 0.05, "a flat 0.5 stroke keeps the pen width, got {w}");
}
#[test]
fn pressured_ink_exports_at_its_mean_width() {
let mut p = Presentation::default();
let id = ShapeId(p.alloc_id());
let slide = std::sync::Arc::make_mut(&mut p.slides[0]);
slide.shapes.push(Shape {
id,
name: "Ink".into(),
xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)),
kind: ShapeKind::Ink {
strokes: vec![InkStroke {
points: vec![(10.0, 10.0, 1.0), (40.0, 10.0, 0.5), (80.0, 10.0, 0.0)],
color: Rgba::rgb(0, 0, 0),
width: 10.0,
highlighter: false,
}],
},
..Default::default()
});
let q = round(&p);
let ink = by_name(&q.slides[0], "Ink");
let ShapeKind::Group { children, .. } = &ink.kind else { panic!("{:?}", ink.kind) };
let w = children[0].line.as_ref().and_then(|l| l.width).unwrap_or(0.0);
// Full, half, and the 8% floor: (10 + 5 + 0.8) / 3.
assert!((w - (10.0 + 5.0 + 0.8) / 3.0).abs() < 0.05, "{w}");
}
#[test]
+17 -12
View File
@@ -844,24 +844,29 @@ impl Renderer {
}
ShapeKind::Ink { strokes } => {
for sk in strokes {
let w = if sk.width.is_finite() { sk.width.abs() } else { 0.0 };
for (px, py, _) in &sk.points {
let legacy = deckcraft_model::ink_pressure_is_legacy(&sk.points);
let pen = if sk.width.is_finite() { sk.width.abs() } else { 0.0 };
for (px, py, pr) in &sk.points {
let w = deckcraft_model::ink_sample_width(pen, *pr, legacy);
self.track(parent, Rect::new(*px, *py, *px, *py).inflate(w, w));
}
let mut bp = BezPath::new();
for (i, (px, py, _)) in sk.points.iter().enumerate() {
if i == 0 {
bp.move_to((*px, *py));
} else {
bp.line_to((*px, *py));
}
let pts: Vec<(f64, f64, f32)> = sk.points.iter().copied().filter(|p| p.0.is_finite() && p.1.is_finite()).collect();
if pts.len() < 2 {
continue;
}
let (base, rel) = self.line_space(parent, depth);
bp.apply_affine(rel);
ctx.set_transform(base);
ctx.set_paint(color(sk.color, if sk.highlighter { 0.4 } else { 1.0 }));
ctx.set_stroke(kurbo::Stroke::new(sk.width.max(0.1)).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round));
ctx.stroke_path(&bp);
for pair in pts.windows(2) {
let mut bp = BezPath::new();
bp.move_to((pair[0].0, pair[0].1));
bp.line_to((pair[1].0, pair[1].1));
bp.apply_affine(rel);
let a = deckcraft_model::ink_sample_width(pen, pair[0].2, legacy);
let b = deckcraft_model::ink_sample_width(pen, pair[1].2, legacy);
ctx.set_stroke(kurbo::Stroke::new(((a + b) * 0.5).max(0.1)).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round));
ctx.stroke_path(&bp);
}
}
}
_ => self.geometry_shape(ctx, f, rctx, s, m, x.w, x.h, &st, depth),
+43
View File
@@ -35,6 +35,49 @@ fn shape_fill_uses_theme_accent() {
assert_eq!(img.pixel(50, 50), [255, 255, 255, 255]);
}
#[test]
fn ink_pressure_tapers_the_stroke_and_a_flat_half_stays_full_width() {
let opts = RenderOpts { scale: 1.0, ..Default::default() };
let tapered = render_slide(&ink_line(1.0, 0.12), 0, &opts);
let flat = render_slide(&ink_line(0.5, 0.5), 0, &opts);
let full = render_slide(&ink_line(1.0, 1.0), 0, &opts);
let heavy = dark_in(&tapered, 50, 140, 70, 130);
let light = dark_in(&tapered, 280, 360, 70, 130);
assert!(heavy > light * 3, "a firm point covers more of the slide than a light one ({heavy} vs {light})");
let (a, b) = (dark_in(&flat, 40, 370, 70, 130), dark_in(&full, 40, 370, 70, 130));
assert!(a > 100 && (a as i32 - b as i32).unsigned_abs() * 10 < a as u32, "the old constant 0.5 still draws at the pen width ({a} vs {b})");
}
fn ink_line(left: f32, right: f32) -> Presentation {
let points = (0..=40)
.map(|i| {
let pressure = if i < 20 { left } else { right };
(40.0 + f64::from(i) * 8.0, 100.0, pressure)
})
.collect();
let sh = Shape {
id: ShapeId(7),
xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)),
kind: ShapeKind::Ink {
strokes: vec![deckcraft_model::InkStroke { points, color: deckcraft_color::Rgba::rgb(0, 0, 0), width: 24.0, highlighter: false }],
},
..Default::default()
};
deck_with(vec![sh])
}
fn dark_in(img: &Image, x0: u32, x1: u32, y0: u32, y1: u32) -> usize {
let mut n = 0;
for y in y0..y1 {
for x in x0..x1 {
if img.pixel(x, y)[0] < 200 {
n += 1;
}
}
}
n
}
#[test]
fn text_draws_ink() {
let mut sh = Shape { id: ShapeId(501), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), text_box: true, ..Default::default() };
+26 -3
View File
@@ -371,7 +371,8 @@ fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) {
let m = mods(ui);
let Some(pos) = resp.interact_pointer_pos().or(resp.hover_pos()) else { return };
let p = xf.to_slide(pos);
let ev = |kind| PointerEvent { kind, x: p.x, y: p.y, mods: m, tol };
let pressure = reported_pressure(ui);
let ev = |kind| PointerEvent { kind, x: p.x, y: p.y, mods: m, tol, pressure };
let primary = ui.input(|i| i.pointer.primary_pressed());
if resp.hovered() && !app.session.tool.dragging() {
let _ = app.session.pointer(ev(PointerKind::Move));
@@ -581,13 +582,35 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To
if app.session.tool.ink_preview.len() > 1
&& let ToolKind::Ink { color, width, mode } = &app.session.tool.kind
{
let pts: Vec<Pos2> = app.session.tool.ink_preview.iter().map(|(x, y, _)| xf.to_screen(Point::new(*x, *y))).collect();
let pts = &app.session.tool.ink_preview;
let legacy = deckcraft_engine::model::ink_pressure_is_legacy(pts);
let c = theme::to_color32(*color);
let c = if mode == "highlighter" { c.gamma_multiply(0.45) } else { c };
painter.add(egui::Shape::line(pts, Stroke::new((*width as f32 * xf.scale).max(1.0), c)));
for pair in pts.windows(2) {
let a = deckcraft_engine::model::ink_sample_width(*width, pair[0].2, legacy);
let b = deckcraft_engine::model::ink_sample_width(*width, pair[1].2, legacy);
let sw = ((a + b) * 0.5 * f64::from(xf.scale)).max(1.0) as f32;
let p0 = xf.to_screen(Point::new(pair[0].0, pair[0].1));
let p1 = xf.to_screen(Point::new(pair[1].0, pair[1].1));
painter.add(egui::Shape::line(vec![p0, p1], Stroke::new(sw, c)));
}
}
}
/// Pressure from a touch or pen this frame (`0..=1`). A mouse reports none.
fn reported_pressure(ui: &Ui) -> Option<f32> {
ui.input(|i| {
i.events.iter().rev().find_map(|e| match e {
egui::Event::Touch { force: Some(f), phase, .. }
if matches!(phase, egui::TouchPhase::Start | egui::TouchPhase::Move) && f.is_finite() =>
{
Some(f.clamp(0.0, 1.0))
}
_ => None,
})
})
}
fn rotate_handle(p: &egui::Painter, c: Pos2, t: &Tokens) {
p.circle_filled(c, 6.0, t.handle_fill);
p.add(egui::Shape::Path(egui::epaint::PathShape {
+4 -3
View File
@@ -3,8 +3,8 @@
//! Methods (JSON lines over the host's transport, see `docs/control-protocol.md`):
//! - `engine.execute {command, params}` / `ui.menu.invoke {command, params}`: run any command (engine or UI)
//! - `engine.commands`: engine + UI commands with enablement; `document.inspect`; `ui.inspect`
//! - `ui.pointer {events:[{kind: down|drag|up|move|doubleclick, x, y}], mods}`: drive the active tool in
//! slide points (same path as the mouse)
//! - `ui.pointer {events:[{kind: down|drag|up|move|doubleclick, x, y, pressure?}], mods}`: drive the active tool in
//! slide points (same path as the mouse; `pressure` is `0..=1` when a pen reported one)
//! - `ui.key {key, shift?, alt?, cmd?}` / `ui.text {text}`: keyboard through the engine
//! - `ui.tool.select {tool, preset?}`
//! - `ui.click {x, y, button?, count?}` / `ui.move {x, y}` / `ui.drag {x, y, toX, toY, steps?}`: real egui pointer
@@ -125,7 +125,8 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) ->
return err("pointer events need x and y");
};
let mods = e.get("mods").map(mods_from).unwrap_or(base);
match app.session.pointer(PointerEvent { kind, x, y, mods, tol: (4.0 / app.canvas_scale.max(0.05)) as f64 }) {
let pressure = e.get("pressure").and_then(Value::as_f64).map(|v| v as f32);
match app.session.pointer(PointerEvent { kind, x, y, mods, tol: (4.0 / app.canvas_scale.max(0.05)) as f64, pressure }) {
Ok(v) => last = v,
Err(e) => return err(e),
}
+1 -1
View File
@@ -17,7 +17,7 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note
| `document.inspect` | — | Slides, sections, layouts, shapes, selection |
| `ui.inspect` | — | UI state, active tool, window/canvas/slide rects, dialog, `home` (the File tab open over a presentation), `backstage` (its page, also the start screen), slide show state, documents, perf (fps, render ms) |
| `ui.tool.select` | `{tool, preset?}` | `select`, `text`, `shape` + preset, any preset name, `curve`, `freeform`, `scribble`, `pen`, `highlighter`, `eraser` |
| `ui.pointer` | `{events: [{kind: down\|drag\|up\|move\|doubleclick\|tripleclick, x, y, mods?}], mods?}` | Drive the active tool in slide points through the same code path as the mouse |
| `ui.pointer` | `{events: [{kind: down\|drag\|up\|move\|doubleclick\|tripleclick, x, y, mods?, pressure?}], mods?}` | Drive the active tool in slide points through the same code path as the mouse. `pressure` is `0..=1` for a pen; omit it for a mouse |
| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Keyboard input (also drives a running slide show) |
| `ui.ime` | `{preedit, active?: [start, end]}` / `{commit}` | An input method's composition through egui, as a real one arrives: shown in place on the slide or in a focused field; `{"preedit": ""}` cancels |
| `ui.move` / `ui.click` / `ui.drag` | screen points | Real egui pointer input — reaches every widget, menu and pane |
+3 -2
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-14 · **Last updated:** 2026-10-14 · **Change:** minor (pen pressure scales ink width; tilt, pinch and trackpad gestures remain; 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
@@ -217,7 +217,7 @@ Detail in [hardware-parity.md](hardware-parity.md).
or wgpu path).
- **Software-only video decode**; PowerPoint uses VideoToolbox/Media Foundation. 8–14 h (copy
FilmCraft's hardware decode).
- **No pen pressure or tilt for ink**; no pinch-zoom or trackpad gestures. 3–6 h.
- **Pen pressure scales ink width** when the pointer reports it (a mouse or a missing reading stays at the pen width; a stroke saved as a flat `0.5` still draws at the pen width). Tilt is not read. No pinch-zoom or other trackpad gestures. 2–4 h.
## Localization
@@ -256,6 +256,7 @@ Detail in [localization-parity.md](localization-parity.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | minor | Pen pressure scales ink width when the pointer reports it; tilt, pinch-zoom and other trackpad gestures remain |
| 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 |
+5 -4
View File
@@ -1,6 +1,6 @@
# Hardware parity with PowerPoint
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (IME input, #101; previous: major, created from the renderer, media and windowing code) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (pen pressure scales ink width; tilt remains; previous: minor, IME input, #101; previous: major, created from the renderer, media and windowing code) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted
What hardware PowerPoint uses and what DeckCraft uses, per platform. Dimension score: **~40%
ready** (estimated), **25–45 h** to parity. Performance (unmeasured, ~55%) is tracked here too
@@ -44,7 +44,7 @@ because it mostly follows from hardware use.
| Keyboard and presentation clickers (Page Up/Down, B, Esc) | yes | yes |
| Mouse and trackpad scroll | yes | yes |
| Pinch to zoom, two-finger gestures | yes | no |
| Pen pressure and tilt for ink (Apple Pencil via Sidecar, Windows Ink) | yes | no: ink strokes have constant width |
| Pen pressure and tilt for ink (Apple Pencil via Sidecar, Windows Ink) | yes | pressure scales the stroke when the pointer reports it (a mouse or a missing reading draws at the pen width); tilt is not read |
| Touch screens (Windows) | yes | basic pointer only |
| IME input methods for CJK text | yes | yes: composed in place, underlined, candidate window at the caret (#101) |
| Dictation | yes (macOS) | no |
@@ -63,13 +63,14 @@ outlines every frame (#67, fix open), and the decoders run in software.
| GPU slide rasterization (vello's GPU/hybrid path) | 12–20 |
| Hardware video decode (copy FilmCraft's VideoToolbox / Media Foundation / VA-API paths) | 8–14 |
| Windows GPU startup robustness: backend probing, software fallback (#40, #59) | 4–8 (counted under Stability) |
| Pen pressure, pinch zoom | 3–6 |
| Pen tilt, pinch zoom, trackpad gestures | 2–5 |
| Benchmark suite (`cargo xtask bench`) and a first performance pass | 6–12 (Performance dimension) |
| **Hardware total** | **~26–45** |
| **Hardware total** | **~25–44** |
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | minor | Pen pressure scales ink width; tilt, pinch zoom and trackpad gestures remain (estimate 3–6 h → 2–5 h) |
| 2026-10-12 | minor | IME input methods (#101) |
| 2026-10-10 | major | Created: rendering, media, displays, input and performance against PowerPoint 16.113 |
+6 -4
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-14 · **Last updated:** 2026-10-14 · **Change:** trivial (after beta: pen pressure scales ink width, tilt remains; previous: minor, core-workflow alpha gate added; stage stays alpha) · **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
@@ -60,9 +60,10 @@ reference renders, a Windows tester with the affected GPUs, native speakers to r
SmartArt editing, chartex chart types, the remaining animation effects and motion paths, Notes and
Handout masters, edit points, text warp and 3-D, proofing dictionaries, Zoom, recording, video and
GIF export, legacy `.ppt` import and `.odp` (owner decision), GPU rasterization and hardware video
decode, the other 21 languages with RTL mirroring, a larger original template gallery, and local AI
features (owner decision on models). Itemized with hours in [gaps.md](gaps.md).
GIF export, legacy `.ppt` import and `.odp` (owner decision), GPU rasterization, hardware video
decode, pen tilt (pressure already scales ink width) and pinch zoom, the other 21 languages with
RTL mirroring, a larger original template gallery, and local AI features (owner decision on
models). Itemized with hours in [gaps.md](gaps.md).
## Milestones
@@ -109,5 +110,6 @@ plan above.
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | trivial | After beta: pen pressure scales ink width; tilt and pinch zoom remain |
| 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 |
+4 -3
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-14 · **Last updated:** 2026-10-14 · **Change:** trivial (pen pressure scales ink; percentages unchanged; previous: minor, hours to ~95% per audience added; ready recomputed from written weights, 56% → 55%; stage stays alpha) · **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;
@@ -69,7 +69,7 @@ The weights are what those users actually depend on, written down so the total c
| UI/UX fidelity | 15% | ~60% | 30–50 | [ui-parity.md](ui-parity.md) | All 10 ribbon tabs and the contextual tabs exist; Format Shape pane, Animation Pane, dialogs and menus are thinner than PowerPoint's; no native macOS menu bar; user reports #17, #71, #73, #76 |
| Stability | 10% | ~60% | 10–20 | [gaps.md](gaps.md) | Never-crash rules, a panic guard round every command, PPTX fuzz/malformed tests. But startup crashes on some Windows GPUs (#40, #59) and Close discards edits without asking (#60) |
| Performance | 5% | ~55% | 10–20 | [hardware-parity.md](hardware-parity.md) | Unmeasured against PowerPoint. Slides rasterize on the CPU (`vello_cpu`); Morph re-aligns outlines every frame (#67) |
| Hardware | 5% | ~40% | 25–45 | [hardware-parity.md](hardware-parity.md) | GPU-composited window (wgpu), but CPU slide raster, software-only video decode, presenter view not placed on the second display, no pen pressure, no pinch zoom |
| Hardware | 5% | ~40% | 25–45 | [hardware-parity.md](hardware-parity.md) | GPU-composited window (wgpu), but CPU slide raster, software-only video decode, presenter view not placed on the second display, no pen tilt, no pinch zoom |
| Platforms | 5% | ~80% | 5–10 | [ROADMAP.md](../ROADMAP.md) | Ahead in reach: macOS, Windows x64/x86/arm64, Linux (5 formats), FreeBSD and the web, all from one release workflow. Held back by the Windows GPU crashes and Linux/Windows clipboard gaps |
| Localization | 5% | ~8% | 55–90 + native review | [localization-parity.md](localization-parity.md) | English only; no string catalog. PowerPoint for Mac ships 26 languages. Bidi and Arabic shaping in slide text exist |
| Ecosystem (templates, add-ins) | 3% | ~25% | 10–20 | [gaps.md](gaps.md) | 8 original themes vs PowerPoint's large template gallery; no add-ins or VBA (out of scope by policy) |
@@ -197,7 +197,7 @@ it is the Features row of the dimension table. Automation and Platforms are scor
| Animations | 6 | 79% | ~65% | 10–16 | 64 of PowerPoint's ~190 effects, Animation Pane timeline, preview, rewind lost on save (#77) | [animation-parity.md](animation-parity.md) |
| Slide show and presenter view | 8 | 66% | ~60% | 10–16 | Second-display placement, laser pointer, recording, subtitles, ink depth | [slideshow-parity.md](slideshow-parity.md) |
| Review | 3 | 60% | ~45% | 8–14 | Modern comment threads on round trip (#70), compare, version history, real spelling dictionaries | [file-format-parity.md](file-format-parity.md) |
| Draw (ink) | 2 | 33% | ~30% | 5–9 | Pen pressure, ink to shape/text/math, lasso | [hardware-parity.md](hardware-parity.md) |
| Draw (ink) | 2 | 33% | ~30% | 5–9 | Pen tilt, ink to shape/text/math, lasso | [hardware-parity.md](hardware-parity.md) |
| Undo and clipboard | 3 | 88% | ~75% | 3–5 | Paste Special, image paste (#53) | [ui-parity.md](ui-parity.md) |
| Automation ✱ | — | 100% | ahead | 0 | | [mcp.md](mcp.md) |
| Platforms ✱ | — | 67% | ~80% | see platforms | | [ROADMAP.md](../ROADMAP.md) |
@@ -276,6 +276,7 @@ scored done (16 open issues).
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | trivial | Draw and hardware blurbs: pen pressure scales ink width when reported; percentages and hour ranges unchanged |
| 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 |