From 78ab8f062629894793d9270d7687deb212f89613 Mon Sep 17 00:00:00 2001 From: SusieeTheFemboy <96988330+SusieeTheLinuxUser@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:09:16 +0200 Subject: [PATCH 01/58] H.264: temporal direct mvs are scaled in i32 and clipped to the spec range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The temporal direct derivation truncated (mvCol · DistScaleFactor + 128) >> 8 with `as i16`, wrapping mvL0 past the spec's motion-vector range on crafted streams, and mvL0 − mvCol then panicked a debug build with "attempt to subtract with overflow". The scale and the subtraction now run in i32 (dsf is already clamped to ±1024, so they cannot overflow) and only the results are clipped to −2^15..2^15−1, as ITU-T H.264 8.4.1.2.4 reads — mvL1 scaled from the unclamped mvL0, the shape review settled for the same decoder code in SoundCraft (soundcraft#994): dsf 512, mvCol 32767 keeps mvL1 at 32767. Extends the grouped robustness audit's codec-overflow list (#1183), which covers the POC/MMCO/latency sites but not this one. Co-Authored-By: GLM-5.3 --- crates/h264/src/slicedec.rs | 52 ++++++++++++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/crates/h264/src/slicedec.rs b/crates/h264/src/slicedec.rs index 10132d683..fee675a24 100644 --- a/crates/h264/src/slicedec.rs +++ b/crates/h264/src/slicedec.rs @@ -566,6 +566,19 @@ impl<'a> SliceDecoder<'a> { (refs, mvs, false) } + /// Temporal direct mvs (8.4.1.2.4) from the co-located mv and `DistScaleFactor` (clamped + /// to ±1024 in `SliceDecoder`): mvL0 = clip_mv((mvCol · dsf + 128) >> 8), mvL1 = + /// clip_mv(mvL0 − mvCol), both clipped to the spec's motion-vector range −2^15..2^15−1. + /// The i32 maths cannot overflow; a truncating `as i16` used to wrap mvL0 past the range, + /// after which the mvL1 subtraction overflowed a debug build. mvL1 is scaled from the + /// unclamped mvL0 with only the results clipped, as the sibling SoundCraft decoder's + /// review established (soundcraft#994): dsf 512, mvCol 32767 keeps mvL1 at 32767. + fn temporal_direct_mvs(dsf: i32, mv_col: [i16; 2]) -> ([i16; 2], [i16; 2]) { + let clamp = |v: i32| v.clamp(-(1 << 15), (1 << 15) - 1) as i16; + let m0 = mv_col.map(|c| (dsf * i32::from(c) + 128) >> 8); + (m0.map(clamp), [clamp(m0[0] - i32::from(mv_col[0])), clamp(m0[1] - i32::from(mv_col[1]))]) + } + /// Derive direct-mode motion for the 8x8 blocks in `b8_mask` of the current MB. fn derive_direct(&mut self, b8_mask: u8) -> Result<()> { let spatial = self.sh.direct_spatial_mv_pred; @@ -600,13 +613,7 @@ impl<'a> SliceDecoder<'a> { } }; let (dsf, ltr) = self.tdirect.get(ref0 as usize).copied().unwrap_or((256, true)); - let (mv0, mv1) = if ltr { - (mv_col, [0, 0]) - } else { - let s = |c: i16| ((dsf * c as i32 + 128) >> 8) as i16; - let m0 = [s(mv_col[0]), s(mv_col[1])]; - (m0, [m0[0] - mv_col[0], m0[1] - mv_col[1]]) - }; + let (mv0, mv1) = if ltr { (mv_col, [0, 0]) } else { Self::temporal_direct_mvs(dsf, mv_col) }; ([ref0, 0], [mv0, mv1]) }; self.set_motion(x, y, unit, unit, refs, mvs); @@ -1467,3 +1474,34 @@ impl<'a> SliceDecoder<'a> { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::SliceDecoder; + + /// Temporal direct mvs stay inside the spec's motion-vector range −2^15..2^15−1 with a + /// large `DistScaleFactor` and a co-located mv at an i16 extreme: the scale used to + /// truncate past the range with `as i16`, and mvL0 − mvCol then overflowed a debug build + /// ("attempt to subtract with overflow"). `dsf` 256 is the identity scale. + #[test] + fn temporal_direct_mvs_at_i16_extremes_stay_in_the_spec_range() { + for dsf in [-1024i32, -1, 256, 1023] { + for col in [[i16::MIN, i16::MIN], [i16::MAX, i16::MAX], [i16::MIN, i16::MAX]] { + let (mv0, mv1) = SliceDecoder::temporal_direct_mvs(dsf, col); + for mv in [mv0, mv1] { + for c in mv { + assert!((-(1 << 15)..(1 << 15)).contains(&i32::from(c)), "dsf {dsf}, col {col:?}: {mv:?}"); + } + } + if dsf == 256 { + assert_eq!((mv0, mv1), (col, [0, 0]), "dsf 256 is the identity scale"); + } + // soundcraft#994: mvL1 is scaled from the unclamped mvL0 with only the results + // clipped — deriving it from the clipped mvL0 gave [0, 0] here. + if dsf == 512 && col == [i16::MAX, 0] { + assert_eq!((mv0, mv1), ([i16::MAX, 0], [i16::MAX, 0]), "mvL1 keeps mvL0's off-range part"); + } + } + } + } +} From 48c5ff67bf42ce58bf2f6cd9436bb02050b5f8b7 Mon Sep 17 00:00:00 2001 From: SusieeTheFemboy <96988330+SusieeTheLinuxUser@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:40:21 +0200 Subject: [PATCH 02/58] H.264: the temporal-direct test runs the dsf 512 case The dsf 512 / mvCol [32767, 0] check sat inside loops that never produce that pair, so it never ran. It is now a direct assertion after the sweep. Co-Authored-By: Claude Opus 5.5 --- crates/h264/src/slicedec.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/h264/src/slicedec.rs b/crates/h264/src/slicedec.rs index fee675a24..2ada51594 100644 --- a/crates/h264/src/slicedec.rs +++ b/crates/h264/src/slicedec.rs @@ -1496,12 +1496,11 @@ mod tests { if dsf == 256 { assert_eq!((mv0, mv1), (col, [0, 0]), "dsf 256 is the identity scale"); } - // soundcraft#994: mvL1 is scaled from the unclamped mvL0 with only the results - // clipped — deriving it from the clipped mvL0 gave [0, 0] here. - if dsf == 512 && col == [i16::MAX, 0] { - assert_eq!((mv0, mv1), ([i16::MAX, 0], [i16::MAX, 0]), "mvL1 keeps mvL0's off-range part"); - } } } + // soundcraft#994: mvL1 is scaled from the unclamped mvL0 with only the results clipped; + // deriving it from the clipped mvL0 gave [0, 0] here. + let (mv0, mv1) = SliceDecoder::temporal_direct_mvs(512, [i16::MAX, 0]); + assert_eq!((mv0, mv1), ([i16::MAX, 0], [i16::MAX, 0]), "mvL1 keeps mvL0's off-range part"); } } From 4c1916466b0aad6758c2889193adae8a7316116b Mon Sep 17 00:00:00 2001 From: DominicTheD3v <119626487+DominicTheD3v@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:19:44 +0000 Subject: [PATCH 03/58] Allow resizing on all selected shapes --- crates/engine/src/tools.rs | 4 ++-- crates/engine/src/tools/drag.rs | 12 ++++++++---- crates/engine/src/tools/pointer.rs | 13 ++++++++----- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 78c6ed35c..19440fb33 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -149,9 +149,9 @@ enum Drag { enter_text: Option<(ShapeId, Point)>, }, Resize { - id: ShapeId, + ids: Vec, handle: u8, - orig: Xfrm, + origs: Vec<(ShapeId, Xfrm)>, start: Point, }, LineEnd { diff --git a/crates/engine/src/tools/drag.rs b/crates/engine/src/tools/drag.rs index 21aac4f38..5660a09a2 100644 --- a/crates/engine/src/tools/drag.rs +++ b/crates/engine/src/tools/drag.rs @@ -26,10 +26,14 @@ impl Session { Drag::Move { ids, start, origs, moved, duplicate, enter_text } => { return self.drag_move(MoveDrag { ids, start, origs, moved, duplicate, enter_text }, p, mods, tol); } - Drag::Resize { id, handle, orig, start } => { - let nx = resize_box(&orig, handle, p - start, mods.shift || self.is_picture(id), mods.alt); - let nx = self.snap_resize(nx, &orig, handle, id, tol); - self.set_xfrm(id, nx)?; + Drag::Resize { ids, handle, origs, start } => { + if ids.len() >= 1 { + for (id, o) in &origs { + let nx = resize_box(&o, handle, p - start, mods.shift || self.is_picture(*id), mods.alt); + let nx = self.snap_resize(nx, &o, handle, *id, tol); + self.set_xfrm(*id, nx)?; + } + } } Drag::LineEnd { id, end, orig } => { let a = orig.affine(); diff --git a/crates/engine/src/tools/pointer.rs b/crates/engine/src/tools/pointer.rs index bee8f3203..0a3999705 100644 --- a/crates/engine/src/tools/pointer.rs +++ b/crates/engine/src/tools/pointer.rs @@ -302,9 +302,10 @@ impl Session { Some(Hit::TableColumn { id, col }) => self.begin_table_border(id, true, col, p)?, Some(Hit::TableRow { id, row }) => self.begin_table_border(id, false, row, p)?, Some(Hit::Resize { id, handle }) => { - let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default(); + let ids: Vec = if st.selection.shapes.contains(&id) { st.selection.shapes.clone() } else { vec![id] }; + let origs: Vec<(ShapeId, Xfrm)> = ids.iter().filter_map(|i| st.shape(*i).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect(); self.begin("Size"); - self.tool.drag = Some(Drag::Resize { id, handle, orig, start: p }); + self.tool.drag = Some(Drag::Resize { ids, handle, origs, start: p }); } Some(Hit::LineEnd { id, end }) => { let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default(); @@ -559,10 +560,12 @@ impl Session { out = self.add_ink(points)?; } } - Some(Drag::Resize { id, .. }) => { + Some(Drag::Resize { ids, .. }) => { self.commit(); - let _ = cmd::text::fit_text_box(self, id); - self.refit_after_resize(id)?; + for id in ids { + let _ = cmd::text::fit_text_box(self, id); + self.refit_after_resize(id)?; + } } Some(Drag::TextSelect { before, .. }) => { out = self.paint_if_selection_changed(before, out)?; From 32ef926c46d3b6312aaecfcc0870e14bb1e49c0e Mon Sep 17 00:00:00 2001 From: DominicTheD3v <119626487+DominicTheD3v@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:46:06 +0000 Subject: [PATCH 04/58] fix: address CodeRabbit review findings --- crates/engine/src/tools/pointer.rs | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/tools/pointer.rs b/crates/engine/src/tools/pointer.rs index 0a3999705..50d11c39f 100644 --- a/crates/engine/src/tools/pointer.rs +++ b/crates/engine/src/tools/pointer.rs @@ -303,7 +303,8 @@ impl Session { Some(Hit::TableRow { id, row }) => self.begin_table_border(id, false, row, p)?, Some(Hit::Resize { id, handle }) => { let ids: Vec = if st.selection.shapes.contains(&id) { st.selection.shapes.clone() } else { vec![id] }; - let origs: Vec<(ShapeId, Xfrm)> = ids.iter().filter_map(|i| st.shape(*i).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect(); + let origs: Vec<(ShapeId, Xfrm)> = ids.iter().filter_map(|i| {st.shape(*i).filter(|s| !s.locked).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))}).collect(); + let ids: Vec = origs.iter().map(|(id, _)| *id).collect(); self.begin("Size"); self.tool.drag = Some(Drag::Resize { ids, handle, origs, start: p }); } @@ -560,12 +561,24 @@ impl Session { out = self.add_ink(points)?; } } - Some(Drag::Resize { ids, .. }) => { - self.commit(); - for id in ids { - let _ = cmd::text::fit_text_box(self, id); - self.refit_after_resize(id)?; + Some(Drag::Resize { origs, .. }) => { + let changed: Vec = origs.iter() + .filter_map(|(id, orig)| { + let current = self.active() + .and_then(|st| st.shape(*id).map(|s| { + xfrm_of(&st.doc, &st.selection, s) + }))?; + + (current != *orig).then_some(*id) + }) + .collect(); + if !changed.is_empty() { + for id in changed { + let _ = cmd::text::fit_text_box(self, id); + self.refit_after_resize(id)?; + } } + self.commit(); } Some(Drag::TextSelect { before, .. }) => { out = self.paint_if_selection_changed(before, out)?; From 82adf7326e1e6be4770499e2bef3495091d19ee2 Mon Sep 17 00:00:00 2001 From: DominicTheD3v <119626487+DominicTheD3v@users.noreply.github.com> Date: Fri, 30 Oct 2026 21:13:44 +0000 Subject: [PATCH 05/58] fix: address raised issues --- crates/engine/src/tests.rs | 132 ++++++++++++++++++++++++++++ crates/engine/src/tools/drag.rs | 10 +-- crates/engine/src/tools/geometry.rs | 4 +- crates/engine/src/tools/pointer.rs | 11 ++- 4 files changed, 143 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4dfc21f45..b72dcbd7d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5479,6 +5479,138 @@ fn box_of(s: &mut Session, id: u64) -> serde_json::Value { s.execute("shape.inspect", &json!({"id": id})).unwrap()["box"].clone() } +/// Two selected boxes with different sizes and aligned right edges, on a blank slide. +fn resize_selection_scene() -> (Session, [u64; 2]) { + let mut s = session(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.prefs.smart_guides = false; + s.prefs.snap_to_grid = false; + let ids = [[100, 100, 100, 80], [120, 300, 80, 100]] + .map(|rect| s.execute("shape.insert", &json!({"preset": "rect", "rect": rect, "text": "Resize test"})).unwrap()["id"].as_u64().unwrap()); + s.execute("edit.select", &json!({"ids": ids})).unwrap(); + (s, ids) +} + +/// Simulate file-loaded shapes whose stored boxes have not yet been fitted to their text. +fn pending_resize_autofit(s: &mut Session, ids: &[u64]) { + s.edit(|doc, sel| { + let shapes = crate::shapes_mut(doc, sel).unwrap(); + for id in ids { + let sh = deckcraft_model::find_shape_mut(shapes, ShapeId(*id as u32)).unwrap(); + sh.text.as_mut().unwrap().body.autofit = Some(deckcraft_model::text::AutoFit::Shape); + } + Ok(()) + }) + .unwrap(); +} + +#[test] +fn resizing_selected_shapes_is_one_undo_step() { + let (mut s, [a, b]) = resize_selection_scene(); + let before = s.doc().unwrap().doc.clone(); + let steps = s.doc().unwrap().history.undo.len(); + let down = drag_gesture(&mut s, (200.0, 180.0), (250.0, 210.0)); + assert_eq!(down["hit"]["kind"], "resize"); + assert_eq!(down["hit"]["id"], a); + for (id, expected) in [(a, [100.0, 100.0, 150.0, 110.0]), (b, [120.0, 300.0, 130.0, 130.0])] { + let actual = box_of(&mut s, id); + for (key, value) in ["x", "y", "w", "h"].into_iter().zip(expected) { + assert_eq!(actual[key], value, "{id}: {key}"); + } + } + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + let after = s.doc().unwrap().doc.clone(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before, "one undo restores both shapes"); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, after, "one redo resizes both shapes again"); +} + +#[test] +fn resizing_selected_shapes_refits_text_in_the_same_undo_step() { + let (mut s, ids) = resize_selection_scene(); + pending_resize_autofit(&mut s, &ids); + let before = s.doc().unwrap().doc.clone(); + let steps = s.doc().unwrap().history.undo.len(); + // An east-handle drag changes width only; a height change must come from text fitting. + drag_gesture(&mut s, (200.0, 140.0), (250.0, 140.0)); + for (id, old_height) in ids.into_iter().zip([80.0, 100.0]) { + assert_ne!(box_of(&mut s, id)["h"], old_height, "both shapes' text is refitted"); + } + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + let after = s.doc().unwrap().doc.clone(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, after, "redo includes the final text fit"); +} + +#[test] +fn clicking_a_resize_handle_does_not_change_or_refit_selected_shapes() { + let (mut s, ids) = resize_selection_scene(); + pending_resize_autofit(&mut s, &ids); + let before = s.doc().unwrap().doc.clone(); + let steps = s.doc().unwrap().history.undo.len(); + let revision = s.doc().unwrap().revision; + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(PointerEvent { kind, x: 200.0, y: 180.0, tol: 3.0, ..Default::default() }).unwrap(); + } + assert_eq!(s.doc().unwrap().doc, before); + assert_eq!(s.doc().unwrap().history.undo.len(), steps, "a click creates no undo entry"); + assert_eq!(s.doc().unwrap().revision, revision); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before), "a click does not dirty the document"); + // Ensure this fixture would catch an accidental refit on release. + for id in ids { + cmd::text::fit_text_box(&mut s, ShapeId(id as u32)).unwrap(); + } + assert_ne!(s.doc().unwrap().doc, before); +} + +#[test] +fn resizing_selected_shapes_skips_locked_shapes_and_their_text_fit() { + let (mut s, [a, b]) = resize_selection_scene(); + pending_resize_autofit(&mut s, &[b]); + s.execute("shape.lock", &json!({"ids": [b], "locked": true})).unwrap(); + s.execute("edit.select", &json!({"ids": [a, b]})).unwrap(); + let locked = s.doc().unwrap().shape(ShapeId(b as u32)).unwrap().clone(); + let before = s.doc().unwrap().doc.clone(); + let steps = s.doc().unwrap().history.undo.len(); + drag_gesture(&mut s, (200.0, 180.0), (250.0, 210.0)); + assert_eq!(box_of(&mut s, a)["w"], 150.0); + assert_eq!(box_of(&mut s, a)["h"], 110.0); + assert_eq!(s.doc().unwrap().shape(ShapeId(b as u32)).unwrap(), &locked, "the locked shape is neither resized nor refitted"); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before); +} + +#[test] +fn resizing_selected_shapes_does_not_snap_to_other_resize_targets() { + for reverse in [false, true] { + let (mut s, [a, b]) = resize_selection_scene(); + s.prefs.smart_guides = true; + let ids = if reverse { [b, a] } else { [a, b] }; + s.execute("edit.select", &json!({"ids": ids})).unwrap(); + // Within snap tolerance of the other selected shape's original right edge. + drag_gesture(&mut s, (200.0, 140.0), (203.0, 140.0)); + assert_eq!(box_of(&mut s, a)["w"], 103.0, "first shape must not snap back"); + assert_eq!(box_of(&mut s, b)["w"], 83.0, "selection order must not affect snapping"); + } +} + +#[test] +fn resizing_selected_shapes_still_snaps_to_stationary_shapes() { + let (mut s, [a, b]) = resize_selection_scene(); + let fixed = s.execute("shape.insert", &json!({"preset": "rect", "rect": [207, 430, 100, 80]})).unwrap()["id"].as_u64().unwrap(); + let fixed_before = box_of(&mut s, fixed); + s.execute("edit.select", &json!({"ids": [a, b]})).unwrap(); + s.prefs.smart_guides = true; + drag_gesture(&mut s, (200.0, 140.0), (203.0, 140.0)); + assert_eq!(box_of(&mut s, a)["w"], 107.0); + assert_eq!(box_of(&mut s, b)["w"], 87.0); + assert_eq!(box_of(&mut s, fixed), fixed_before); +} + /// #961: dragging the east handle snaps the dragged (right) edge, even when the fixed left edge /// already lines up with another shape. The nearest line used to be the fixed edge's, at distance 0. #[test] diff --git a/crates/engine/src/tools/drag.rs b/crates/engine/src/tools/drag.rs index 5660a09a2..7dca38225 100644 --- a/crates/engine/src/tools/drag.rs +++ b/crates/engine/src/tools/drag.rs @@ -27,12 +27,10 @@ impl Session { return self.drag_move(MoveDrag { ids, start, origs, moved, duplicate, enter_text }, p, mods, tol); } Drag::Resize { ids, handle, origs, start } => { - if ids.len() >= 1 { - for (id, o) in &origs { - let nx = resize_box(&o, handle, p - start, mods.shift || self.is_picture(*id), mods.alt); - let nx = self.snap_resize(nx, &o, handle, *id, tol); - self.set_xfrm(*id, nx)?; - } + for (id, o) in &origs { + let nx = resize_box(o, handle, p - start, mods.shift || self.is_picture(*id), mods.alt); + let nx = self.snap_resize(nx, handle, &ids, tol); + self.set_xfrm(*id, nx)?; } } Drag::LineEnd { id, end, orig } => { diff --git a/crates/engine/src/tools/geometry.rs b/crates/engine/src/tools/geometry.rs index 536a4ead2..cbbd01275 100644 --- a/crates/engine/src/tools/geometry.rs +++ b/crates/engine/src/tools/geometry.rs @@ -228,7 +228,7 @@ impl Session { } /// Snap the edges that resize handle `handle` drags in the resized box `x` to the smart guides. - pub(super) fn snap_resize(&mut self, x: Xfrm, _orig: &Xfrm, handle: u8, id: ShapeId, tol: f64) -> Xfrm { + pub(super) fn snap_resize(&mut self, x: Xfrm, handle: u8, exclude: &[ShapeId], tol: f64) -> Xfrm { if x.rot != 0.0 { self.tool.guides.clear(); return x; @@ -241,7 +241,7 @@ impl Session { let (top, bottom) = (matches!(handle, 0..=2), matches!(handle, 4..=6)); let (moves_left, moves_right) = if x.flip_h { (right, left) } else { (left, right) }; let (moves_top, moves_bottom) = if x.flip_v { (bottom, top) } else { (top, bottom) }; - let (off, guides) = self.snap_edges(r, &[id], tol * 1.5, true, [moves_left, false, moves_right], [moves_top, false, moves_bottom]); + let (off, guides) = self.snap_edges(r, exclude, tol * 1.5, true, [moves_left, false, moves_right], [moves_top, false, moves_bottom]); let mut nx = x; if moves_left { nx.x += off.x; diff --git a/crates/engine/src/tools/pointer.rs b/crates/engine/src/tools/pointer.rs index 50d11c39f..dbaf495f8 100644 --- a/crates/engine/src/tools/pointer.rs +++ b/crates/engine/src/tools/pointer.rs @@ -303,7 +303,8 @@ impl Session { Some(Hit::TableRow { id, row }) => self.begin_table_border(id, false, row, p)?, Some(Hit::Resize { id, handle }) => { let ids: Vec = if st.selection.shapes.contains(&id) { st.selection.shapes.clone() } else { vec![id] }; - let origs: Vec<(ShapeId, Xfrm)> = ids.iter().filter_map(|i| {st.shape(*i).filter(|s| !s.locked).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))}).collect(); + let origs: Vec<(ShapeId, Xfrm)> = + ids.iter().filter_map(|i| st.shape(*i).filter(|s| !s.locked).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect(); let ids: Vec = origs.iter().map(|(id, _)| *id).collect(); self.begin("Size"); self.tool.drag = Some(Drag::Resize { ids, handle, origs, start: p }); @@ -562,12 +563,10 @@ impl Session { } } Some(Drag::Resize { origs, .. }) => { - let changed: Vec = origs.iter() + let changed: Vec = origs + .iter() .filter_map(|(id, orig)| { - let current = self.active() - .and_then(|st| st.shape(*id).map(|s| { - xfrm_of(&st.doc, &st.selection, s) - }))?; + let current = self.active().and_then(|st| st.shape(*id).map(|s| xfrm_of(&st.doc, &st.selection, s)))?; (current != *orig).then_some(*id) }) From e812da58df5b2d1c268b6674c6b10dbee67ce8ce Mon Sep 17 00:00:00 2001 From: DominicTheD3v <119626487+DominicTheD3v@users.noreply.github.com> Date: Fri, 30 Oct 2026 22:51:33 +0000 Subject: [PATCH 06/58] fix: addressing issues --- crates/engine/src/tests.rs | 12 ++++++++--- crates/engine/src/tools.rs | 2 ++ crates/engine/src/tools/drag.rs | 33 +++++++++++++++++++++++++----- crates/engine/src/tools/pointer.rs | 3 ++- 4 files changed, 41 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b72dcbd7d..ef18a31f6 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5504,6 +5504,7 @@ fn pending_resize_autofit(s: &mut Session, ids: &[u64]) { .unwrap(); } +/// Resizing multiple selected shapes scales them proportionally around their anchors in one undo step. #[test] fn resizing_selected_shapes_is_one_undo_step() { let (mut s, [a, b]) = resize_selection_scene(); @@ -5512,7 +5513,7 @@ fn resizing_selected_shapes_is_one_undo_step() { let down = drag_gesture(&mut s, (200.0, 180.0), (250.0, 210.0)); assert_eq!(down["hit"]["kind"], "resize"); assert_eq!(down["hit"]["id"], a); - for (id, expected) in [(a, [100.0, 100.0, 150.0, 110.0]), (b, [120.0, 300.0, 130.0, 130.0])] { + for (id, expected) in [(a, [100.0, 100.0, 150.0, 110.0]), (b, [120.0, 300.0, 120.0, 137.5])] { let actual = box_of(&mut s, id); for (key, value) in ["x", "y", "w", "h"].into_iter().zip(expected) { assert_eq!(actual[key], value, "{id}: {key}"); @@ -5526,6 +5527,7 @@ fn resizing_selected_shapes_is_one_undo_step() { assert_eq!(s.doc().unwrap().doc, after, "one redo resizes both shapes again"); } +/// Resizing selected shapes refits their text boxes inside the same undo transaction. #[test] fn resizing_selected_shapes_refits_text_in_the_same_undo_step() { let (mut s, ids) = resize_selection_scene(); @@ -5545,6 +5547,7 @@ fn resizing_selected_shapes_refits_text_in_the_same_undo_step() { assert_eq!(s.doc().unwrap().doc, after, "redo includes the final text fit"); } +/// Clicking a resize handle without dragging neither modifies shapes nor triggers text autofit. #[test] fn clicking_a_resize_handle_does_not_change_or_refit_selected_shapes() { let (mut s, ids) = resize_selection_scene(); @@ -5566,6 +5569,7 @@ fn clicking_a_resize_handle_does_not_change_or_refit_selected_shapes() { assert_ne!(s.doc().unwrap().doc, before); } +/// Locked shapes in a multi-selection are excluded from both resizing and post-resize text fitting. #[test] fn resizing_selected_shapes_skips_locked_shapes_and_their_text_fit() { let (mut s, [a, b]) = resize_selection_scene(); @@ -5584,6 +5588,7 @@ fn resizing_selected_shapes_skips_locked_shapes_and_their_text_fit() { assert_eq!(s.doc().unwrap().doc, before); } +/// Resizing selected shapes excludes other selected resize targets from smart-guide snapping. #[test] fn resizing_selected_shapes_does_not_snap_to_other_resize_targets() { for reverse in [false, true] { @@ -5594,10 +5599,11 @@ fn resizing_selected_shapes_does_not_snap_to_other_resize_targets() { // Within snap tolerance of the other selected shape's original right edge. drag_gesture(&mut s, (200.0, 140.0), (203.0, 140.0)); assert_eq!(box_of(&mut s, a)["w"], 103.0, "first shape must not snap back"); - assert_eq!(box_of(&mut s, b)["w"], 83.0, "selection order must not affect snapping"); + assert_eq!(box_of(&mut s, b)["w"], 82.4, "selection order must not affect snapping"); } } +/// Resizing selected shapes snaps the active shape against unselected stationary shapes and scales others proportionally. #[test] fn resizing_selected_shapes_still_snaps_to_stationary_shapes() { let (mut s, [a, b]) = resize_selection_scene(); @@ -5607,7 +5613,7 @@ fn resizing_selected_shapes_still_snaps_to_stationary_shapes() { s.prefs.smart_guides = true; drag_gesture(&mut s, (200.0, 140.0), (203.0, 140.0)); assert_eq!(box_of(&mut s, a)["w"], 107.0); - assert_eq!(box_of(&mut s, b)["w"], 87.0); + assert!((box_of(&mut s, b)["w"].as_f64().unwrap() - 85.6).abs() < 1e-9); assert_eq!(box_of(&mut s, fixed), fixed_before); } diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 19440fb33..82f53dfc3 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -149,6 +149,8 @@ enum Drag { enter_text: Option<(ShapeId, Point)>, }, Resize { + id: ShapeId, + orig: Xfrm, ids: Vec, handle: u8, origs: Vec<(ShapeId, Xfrm)>, diff --git a/crates/engine/src/tools/drag.rs b/crates/engine/src/tools/drag.rs index 7dca38225..367e6b856 100644 --- a/crates/engine/src/tools/drag.rs +++ b/crates/engine/src/tools/drag.rs @@ -26,11 +26,34 @@ impl Session { Drag::Move { ids, start, origs, moved, duplicate, enter_text } => { return self.drag_move(MoveDrag { ids, start, origs, moved, duplicate, enter_text }, p, mods, tol); } - Drag::Resize { ids, handle, origs, start } => { - for (id, o) in &origs { - let nx = resize_box(o, handle, p - start, mods.shift || self.is_picture(*id), mods.alt); - let nx = self.snap_resize(nx, handle, &ids, tol); - self.set_xfrm(*id, nx)?; + Drag::Resize { id, orig, ids, handle, origs, start } => { + let nx = resize_box(&orig, handle, p - start, mods.shift || self.is_picture(id), mods.alt); + let nx = self.snap_resize(nx, handle, &ids, tol); + let sx = if orig.w > 1e-9 { (nx.w / orig.w) * if nx.flip_h != orig.flip_h { -1.0 } else { 1.0 } - 1.0 } else { 0.0 }; + let sy = if orig.h > 1e-9 { (nx.h / orig.h) * if nx.flip_v != orig.flip_v { -1.0 } else { 1.0 } - 1.0 } else { 0.0 }; + let div = if mods.alt { 2.0 } else { 1.0 }; + let hx = match handle { + 0 | 6 | 7 => -1.0, + 2..=4 => 1.0, + _ => 0.0, + }; + let hy = match handle { + 0..=2 => -1.0, + 4..=6 => 1.0, + _ => 0.0, + }; + for (tid, o) in &origs { + let target_nx = if *tid == id { + nx + } else { + let lp = Affine::rotate(o.rot.to_radians()) + * Point::new( + hx * sx * o.w / div * if o.flip_h { -1.0 } else { 1.0 }, + hy * sy * o.h / div * if o.flip_v { -1.0 } else { 1.0 }, + ); + resize_box(o, handle, Vec2::new(lp.x, lp.y), mods.shift || self.is_picture(*tid), mods.alt) + }; + self.set_xfrm(*tid, target_nx)?; } } Drag::LineEnd { id, end, orig } => { diff --git a/crates/engine/src/tools/pointer.rs b/crates/engine/src/tools/pointer.rs index dbaf495f8..1f4cba0c5 100644 --- a/crates/engine/src/tools/pointer.rs +++ b/crates/engine/src/tools/pointer.rs @@ -302,12 +302,13 @@ impl Session { Some(Hit::TableColumn { id, col }) => self.begin_table_border(id, true, col, p)?, Some(Hit::TableRow { id, row }) => self.begin_table_border(id, false, row, p)?, Some(Hit::Resize { id, handle }) => { + let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default(); let ids: Vec = if st.selection.shapes.contains(&id) { st.selection.shapes.clone() } else { vec![id] }; let origs: Vec<(ShapeId, Xfrm)> = ids.iter().filter_map(|i| st.shape(*i).filter(|s| !s.locked).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect(); let ids: Vec = origs.iter().map(|(id, _)| *id).collect(); self.begin("Size"); - self.tool.drag = Some(Drag::Resize { ids, handle, origs, start: p }); + self.tool.drag = Some(Drag::Resize { id, orig, ids, handle, origs, start: p }); } Some(Hit::LineEnd { id, end }) => { let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default(); From 09bbfc72176af42ae586795367caec9dd5e8db77 Mon Sep 17 00:00:00 2001 From: DominicTheD3v <119626487+DominicTheD3v@users.noreply.github.com> Date: Fri, 30 Oct 2026 23:25:01 +0000 Subject: [PATCH 07/58] fix: addressing issues raised by coderabbit --- crates/engine/src/tools/drag.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/tools/drag.rs b/crates/engine/src/tools/drag.rs index 367e6b856..24f0ae978 100644 --- a/crates/engine/src/tools/drag.rs +++ b/crates/engine/src/tools/drag.rs @@ -27,7 +27,11 @@ impl Session { return self.drag_move(MoveDrag { ids, start, origs, moved, duplicate, enter_text }, p, mods, tol); } Drag::Resize { id, orig, ids, handle, origs, start } => { - let nx = resize_box(&orig, handle, p - start, mods.shift || self.is_picture(id), mods.alt); + if orig.w <= 0.0 || orig.h <= 0.0 { + return Ok(Value::Null); + } + let keep_aspect = mods.shift || self.is_picture(id); + let nx = resize_box(&orig, handle, p - start, keep_aspect, mods.alt); let nx = self.snap_resize(nx, handle, &ids, tol); let sx = if orig.w > 1e-9 { (nx.w / orig.w) * if nx.flip_h != orig.flip_h { -1.0 } else { 1.0 } - 1.0 } else { 0.0 }; let sy = if orig.h > 1e-9 { (nx.h / orig.h) * if nx.flip_v != orig.flip_v { -1.0 } else { 1.0 } - 1.0 } else { 0.0 }; @@ -51,7 +55,7 @@ impl Session { hx * sx * o.w / div * if o.flip_h { -1.0 } else { 1.0 }, hy * sy * o.h / div * if o.flip_v { -1.0 } else { 1.0 }, ); - resize_box(o, handle, Vec2::new(lp.x, lp.y), mods.shift || self.is_picture(*tid), mods.alt) + resize_box(o, handle, Vec2::new(lp.x, lp.y), keep_aspect, mods.alt) }; self.set_xfrm(*tid, target_nx)?; } From d453bee54f4dc0810e07b97ead32fa5c6c19b7d9 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:57:51 -0500 Subject: [PATCH 08/58] D604: readable animation effect labels --- ROADMAP.md | 3 + crates/ui-egui/src/ribbon/animations.rs | 89 ++++++++++++++++++++++++- docs/animation-parity.md | 5 +- docs/gaps.md | 4 +- 4 files changed, 96 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a99369256..5a31b3bdf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (progress log: animation Effect Options show readable labels; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-27 · **Change:** trivial (progress log: Set Up Slide Show ▸ Keep mouse cursor visible, #1445; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-26 · **Change:** trivial (progress log: dragging several selected slides moves them all; previous: trivial, progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -146,6 +147,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-30 | Animation ▸ Effect Options shows readable labels for directions, split/shape variants, wheel spokes and other option values; stored codes are unchanged | | 2026-10-29 | Slide Show: going back to the previous slide plays the transition of the slide being left, reversed, as PowerPoint does, instead of the previous slide's own entrance transition (#1531); a jump (Go to Slide, links) still plays the target slide's transition | | 2026-10-27 | Set Up Slide Show ▸ Keep mouse cursor visible: a full-screen show no longer hides a still pointer, so the choices of a branching deck stay visible and clickable; the setting is kept with the user's preferences (`ui.json`, `showKeepCursor`), off by default (#1445) | | 2026-10-25 | A photo in a picture placeholder is cut into the shape its layout gives the placeholder, as PowerPoint draws it: a layout's round picture placeholder keeps a slide's photo in a circle with the layout's white border, where it came out as a rectangle (the reader only passed `p:sp` placeholders to `inherit_placeholder_geometry`, not `p:pic`) | @@ -314,6 +316,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | trivial | Progress log: animation Effect Options show readable labels. Scores and milestone names unchanged | | 2026-10-29 | trivial | Progress log: going back in the slide show reverses the left slide's transition (#1531). Scores and milestone names unchanged | | 2026-10-29 | trivial | Progress log: the Picture Styles row leaves room for Crop and Size (#1232). Scores and milestone names unchanged | | 2026-10-25 | trivial | Progress log: a picture in a picture placeholder takes the layout's shape. Scores and milestone names unchanged | diff --git a/crates/ui-egui/src/ribbon/animations.rs b/crates/ui-egui/src/ribbon/animations.rs index 67f0bbd00..6d35afdcb 100644 --- a/crates/ui-egui/src/ribbon/animations.rs +++ b/crates/ui-egui/src/ribbon/animations.rs @@ -1,4 +1,3 @@ -use super::transitions::option_label; use super::{ Align2, AnimClass, CornerRadius, Icon, Rect, Sense, SlideApp, Tokens, Ui, big, big_button, big_button_ex, dropdown, enabled, group, icons, json, pos2, rows, run, theme, vec2, widgets, @@ -81,12 +80,13 @@ pub(super) fn animations(app: &mut SlideApp, ui: &mut Ui) { } }); let effect_rows = cur.as_ref().map(|c| animation_effect_rows(&c.effect, c.class, Some(c.option.as_str()))).unwrap_or_default(); + let effect = cur.as_ref().map(|c| c.effect.as_str()).unwrap_or_default(); let build = cur.as_ref().map(|c| text_build_id(c.text_build)); let build_rows = marked_options(&["asOne", "byParagraph"], build); let r = big_button(ui, Icon::EffectOptions, "Effect\nOptions", !effect_rows.is_empty()); egui::Popup::menu(&r).show(|ui| { for &(o, on) in &effect_rows { - if ui.selectable_label(on, option_label(o)).clicked() { + if ui.selectable_label(on, animation_option_label(effect, o)).clicked() { run(app, "animation.options", json!({"option": o})); ui.close(); } @@ -216,6 +216,67 @@ pub(super) fn animation_effect_rows(effect: &str, class: AnimClass, current: Opt marked_options(opts, current) } +fn animation_option_label(effect: &str, option: &str) -> String { + let direction = match option { + "b" => Some("From Bottom"), + "lb" => Some("From Bottom-Left"), + "l" => Some("From Left"), + "lt" => Some("From Top-Left"), + "t" => Some("From Top"), + "rt" => Some("From Top-Right"), + "r" => Some("From Right"), + "rb" => Some("From Bottom-Right"), + _ => None, + }; + if matches!(effect, "fly" | "flyOut" | "wipe" | "wipeOut" | "peek" | "peekOut") + && let Some(label) = direction + { + return label.into(); + } + + match (effect, option) { + ("float" | "floatOut", "u") => "Up".into(), + ("float" | "floatOut", "d") => "Down".into(), + ("split" | "splitOut", "horzIn") => "Horizontal In".into(), + ("split" | "splitOut", "horzOut") => "Horizontal Out".into(), + ("split" | "splitOut", "vertIn") => "Vertical In".into(), + ("split" | "splitOut", "vertOut") => "Vertical Out".into(), + ("shape" | "shapeOut", o) if o.ends_with("In") => format!("{} In", title_first(shape_option_name(o))), + ("shape" | "shapeOut", o) if o.ends_with("Out") => format!("{} Out", title_first(shape_option_name(o))), + ("wheel" | "wheelOut", "1") => "1 Spoke".into(), + ("wheel" | "wheelOut", n) => format!("{n} Spokes"), + ("box" | "boxOut" | "circle" | "diamond", "in") => format!("{} In", title_first(effect.trim_end_matches("Out"))), + ("box" | "boxOut" | "circle" | "diamond", "out") => format!("{} Out", title_first(effect.trim_end_matches("Out"))), + ("zoom" | "zoomOut", "in") => "Zoom In".into(), + ("zoom" | "zoomOut", "out") => "Zoom Out".into(), + (_, "lu") => "From Top-Left".into(), + (_, "ru") => "From Top-Right".into(), + (_, "ld") => "From Bottom-Left".into(), + (_, "rd") => "From Bottom-Right".into(), + (_, "horz") => "Horizontal".into(), + (_, "vert") => "Vertical".into(), + (_, "across") => "Across".into(), + (_, "down") => "Down".into(), + (_, "up") => "Up".into(), + (_, "right") => "Right".into(), + (_, "left") => "Left".into(), + (_, "clockwise") => "Clockwise".into(), + (_, "counterClockwise") => "Counterclockwise".into(), + (_, "in") => "In".into(), + (_, "out") => "Out".into(), + (_, other) => super::transitions::option_label(other), + } +} + +fn shape_option_name(option: &str) -> &str { + option.strip_suffix("In").or_else(|| option.strip_suffix("Out")).unwrap_or(option) +} + +fn title_first(value: &str) -> String { + let mut chars = value.chars(); + chars.next().map(|c| c.to_uppercase().collect::() + chars.as_str()).unwrap_or_default() +} + pub(super) fn text_build_id(build: deckcraft_model::anim::TextBuild) -> &'static str { match build { deckcraft_model::anim::TextBuild::ByParagraph => "byParagraph", @@ -224,3 +285,27 @@ pub(super) fn text_build_id(build: deckcraft_model::anim::TextBuild) -> &'static deckcraft_model::anim::TextBuild::AsOne => "asOne", } } + +#[cfg(test)] +mod animation_option_label_tests { + use super::animation_option_label; + use deckcraft_model::anim::ANIMATIONS; + + #[test] + fn labels_every_animation_gallery_option_without_changing_its_code() { + for &(effect, _, _, _, _, options) in ANIMATIONS { + for &option in options { + let label = animation_option_label(effect, option); + assert!(!label.is_empty(), "{effect}/{option}"); + assert_ne!(label, option, "raw option shown for {effect}/{option}"); + } + } + assert_eq!(animation_option_label("fly", "lb"), "From Bottom-Left"); + assert_eq!(animation_option_label("flyOut", "rt"), "From Top-Right"); + assert_eq!(animation_option_label("wipe", "b"), "From Bottom"); + assert_eq!(animation_option_label("wheel", "1"), "1 Spoke"); + assert_eq!(animation_option_label("split", "vertOut"), "Vertical Out"); + assert_eq!(animation_option_label("shape", "circleIn"), "Circle In"); + assert_eq!(animation_option_label("diamond", "out"), "Diamond Out"); + } +} diff --git a/docs/animation-parity.md b/docs/animation-parity.md index b7f840a92..ffca88c9d 100644 --- a/docs/animation-parity.md +++ b/docs/animation-parity.md @@ -1,6 +1,6 @@ # Transitions and animations: parity with PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-29 · **Change:** minor (going back in the show reverses the left slide's transition, #1531; previous: minor, Animation Pane ▸ Play From plays from the selected effect; previous: community PR wave 2026-10-14: interactive-deck triggers, canvas preview, the Animation Pane timeline, repeat and bounce end round-trip; duplicated rows and headers folded; previous: major, transitions measured frame by frame against PowerPoint for Windows' video export) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (animation Effect Options translate stored codes into readable labels; previous: minor, going back in the show reverses the left slide's transition, #1531; previous: minor, Animation Pane ▸ Play From plays from the selected effect; previous: community PR wave 2026-10-14: interactive-deck triggers, canvas preview, the Animation Pane timeline, repeat and bounce end round-trip; duplicated rows and headers folded; previous: major, transitions measured frame by frame against PowerPoint for Windows' video export) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Transitions and animations are what make a deck a presentation, and the part of PowerPoint people notice first when it's missing. Scores roll up into [target-app-parity.md](target-app-parity.md) @@ -65,7 +65,7 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85 | Animation feature | PowerPoint | DeckCraft | |---|---|---| | Start On Click / With Previous / After Previous, duration, delay | yes | yes | -| Effect Options (direction, sequence by paragraph) | yes | yes | +| Effect Options (direction, sequence by paragraph) | yes | yes; every gallery option has a readable label while retaining its stored code | | Effect dialog: sound, after-animation dim/hide, animate text by word/letter, smooth start/end, bounce end, repeat, rewind | yes | partly; repeat, bounce end and rewind round-trip | | By-paragraph text builds, sub-bullets follow parents | yes | yes; builds edited in PowerPoint (own start per paragraph, other effects in between, by 2nd-level paragraphs) import as one effect per paragraph and keep their clicks (#844) | | Triggers (on click of a shape) | yes | yes; played in the show: a step per click, the sequence starts over after its last step, a click completes a playing step. Separate click steps, mixed starts and paragraph builds survive PPTX save (`trigger_steps.rs`) | @@ -94,6 +94,7 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85 | Date | Change | Summary | |---|---|---| +| 2026-10-30 | trivial | Animation Effect Options show readable, effect-aware labels for all gallery option codes; stored values and command parameters stay unchanged | | 2026-10-29 | minor | Going back in the show plays the transition of the slide being left, reversed, instead of the previous slide's entrance transition (#1531); new row in the transition features. Scores unchanged | | 2026-10-22 | minor | Animation Pane ▸ Play From: with an effect selected, the preview starts at that effect on the play-all clock; the row says yes and the estimate drops to ~14–26 h | | 2026-10-15 | trivial | PPTX interactive sequences preserve later On Click effects and one click per paragraph; no change to playback or feature scores | diff --git a/docs/gaps.md b/docs/gaps.md index c1746a17f..885a64d16 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,5 +1,6 @@ # Where DeckCraft falls short of PowerPoint +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (animation Effect Options now show readable labels; Animation Pane gap remains) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-28 · **Change:** trivial (AutoFormat lists run independently of Replace text as you type; previous: minor, Animation Pane ▸ Play From; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: soak tests, PPTX corpus, comment threads (#70), embedded fonts, SmartArt Text Pane, Animation Pane timeline, Hunspell spelling, thesaurus, AutoCorrect, 14 new dialogs, pen pressure and trackpad gestures; duplicated entries and headers folded; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-20 · **Change:** trivial (empty chart number-format sections hide values; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: soak tests, PPTX corpus, comment threads (#70), embedded fonts, SmartArt Text Pane, Animation Pane timeline, Hunspell spelling, thesaurus, AutoCorrect, 14 new dialogs, pen pressure and trackpad gestures; duplicated entries and headers folded; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-27 · **Last updated:** 2026-10-27 · **Change:** minor (docs refresh against merged PRs: #67 and #77 landed, #75 closed unmerged, #53 closed, Windows image paste is #1414; one header line; previous: minor, Animation Pane ▸ Play From; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: soak tests, PPTX corpus, comment threads (#70), embedded fonts, SmartArt Text Pane, Animation Pane timeline, Hunspell spelling, thesaurus, AutoCorrect, 14 new dialogs, pen pressure and trackpad gestures; duplicated entries and headers folded; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -28,7 +29,7 @@ revision history. | 7 | Fonts: only Regular, Bold and Italic faces (#69) | Feature | B | 3–5 | [ui-parity.md](ui-parity.md) | | 8 | Image paste fails on Windows (#1414); Linux not re-checked | Feature | B | 2–4 | [ui-parity.md](ui-parity.md) | | 9 | Presenter view isn't placed on the second display | Hardware | B | 3–5 | [slideshow-parity.md](slideshow-parity.md) | -| 10 | Animation Pane: 64 of ~190 effects, effect options (timeline and Play From done) | Feature | B (pane) | 7–13 | [animation-parity.md](animation-parity.md) | +| 10 | Animation Pane: 64 of ~190 effects, effect options (readable labels, timeline and Play From done) | Feature | B (pane) | 7–13 | [animation-parity.md](animation-parity.md) | | 11 | Charts: data grid editing, elements, `chartex` types | Feature | B (data, elements) | 12–20 | [parity-checklist.md](parity-checklist.md) | | 12 | Format Shape pane thinner than PowerPoint's | UI/UX | B | 6–10 | [ui-parity.md](ui-parity.md) | | 13 | No native macOS menu bar | UI/UX | B | 3–5 | [ui-parity.md](ui-parity.md) | @@ -370,6 +371,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | trivial | Animation effect options display readable labels for every gallery value; the broader Animation Pane gap remains | | 2026-10-28 | trivial | Automatic lists now run independently of Replace text as you type; proofing gap description updated | | 2026-10-27 | minor | Refreshed against merged PRs: removed the Morph alignment entry (#67 merged 2026-10-14); #77 (animation rewind, PR #83) is fixed, leaving #74 in the round-trip gap; #75 was closed unmerged, so #60 stays open with no fix PR; #53 is closed and the image-paste gap is now Windows #1414; Reuse Slides (#1103) and Compare reject (#850) noted in the smaller rows; the duplicate header line is gone | | 2026-10-25 | trivial | New entries: copying text and cell ranges (no media, no table), a control-server timing test that may fail under load; Proofing notes that automatic lists follow Replace text as you type and that Backspace puts the typed lead back | From 77e3a444b816eaea93dc2b608cf46043e243d902 Mon Sep 17 00:00:00 2001 From: DominicTheD3v <119626487+DominicTheD3v@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:21:06 +0000 Subject: [PATCH 09/58] Update crates/engine/src/tools/drag.rs Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- crates/engine/src/tools/drag.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/tools/drag.rs b/crates/engine/src/tools/drag.rs index 24f0ae978..a25e47858 100644 --- a/crates/engine/src/tools/drag.rs +++ b/crates/engine/src/tools/drag.rs @@ -55,7 +55,7 @@ impl Session { hx * sx * o.w / div * if o.flip_h { -1.0 } else { 1.0 }, hy * sy * o.h / div * if o.flip_v { -1.0 } else { 1.0 }, ); - resize_box(o, handle, Vec2::new(lp.x, lp.y), keep_aspect, mods.alt) + resize_box(o, handle, Vec2::new(lp.x, lp.y), self.is_picture(*tid), mods.alt) }; self.set_xfrm(*tid, target_nx)?; } From 9aef8cc5ab9d9b2d82198ffb632ea9fc0a8a90be Mon Sep 17 00:00:00 2001 From: DominicTheD3v <119626487+DominicTheD3v@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:03:53 +0000 Subject: [PATCH 10/58] fix: addressing issues raised by coderabbit --- crates/engine/src/tools/drag.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/engine/src/tools/drag.rs b/crates/engine/src/tools/drag.rs index 24f0ae978..d65ae690a 100644 --- a/crates/engine/src/tools/drag.rs +++ b/crates/engine/src/tools/drag.rs @@ -27,9 +27,6 @@ impl Session { return self.drag_move(MoveDrag { ids, start, origs, moved, duplicate, enter_text }, p, mods, tol); } Drag::Resize { id, orig, ids, handle, origs, start } => { - if orig.w <= 0.0 || orig.h <= 0.0 { - return Ok(Value::Null); - } let keep_aspect = mods.shift || self.is_picture(id); let nx = resize_box(&orig, handle, p - start, keep_aspect, mods.alt); let nx = self.snap_resize(nx, handle, &ids, tol); From b1900e526d02ab4b05c88de0782a0b8953f99be4 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 22:42:28 -0500 Subject: [PATCH 11/58] Preserve point-sized bullets in PPTX --- crates/model/src/text.rs | 3 +++ crates/pptx/src/read/dml.rs | 4 ++++ crates/pptx/src/write/dml.rs | 4 ++++ crates/pptx/tests/it/roundtrip.rs | 24 ++++++++++++++++++++++++ crates/text/src/shape.rs | 4 ++-- 5 files changed, 37 insertions(+), 2 deletions(-) diff --git a/crates/model/src/text.rs b/crates/model/src/text.rs index 17d606de7..525f1df3c 100644 --- a/crates/model/src/text.rs +++ b/crates/model/src/text.rs @@ -240,6 +240,8 @@ pub struct ParaProps { pub bullet_color: Option, /// Bullet size as a fraction of the text size. pub bullet_size: Option, + /// Absolute bullet font size in points, independent of the paragraph's run size. + pub bullet_size_points: Option, pub tabs: Option>, pub default_tab: Option, pub rtl: Option, @@ -270,6 +272,7 @@ impl ParaProps { self.bullet_color.clone_from(&p.bullet_color); } self.bullet_size = self.bullet_size.or(p.bullet_size); + self.bullet_size_points = self.bullet_size_points.or(p.bullet_size_points); if self.tabs.is_none() { self.tabs.clone_from(&p.tabs); } diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index 767b7dc02..7a3cc2179 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -433,6 +433,10 @@ pub fn ppr(imp: &mut Imp, part: &Part, p: &El) -> (Option, ParaProps, Option "buFont" => pp.bullet_font = e.attr("typeface").map(String::from), "buClr" => pp.bullet_color = color(e), "buSzPct" => pp.bullet_size = pct(e, "val").map(|v| v.clamp(0.25, 4.0)), + "buSzPts" => { + pp.bullet_size_points = + e.attr("val").and_then(|v| v.parse::().ok()).filter(|v| v.is_finite() && *v > 0.0 && *v <= 40_000.0).map(|v| v / 100.0); + } "tabLst" => { pp.tabs = Some( e.children_named("tab") diff --git a/crates/pptx/src/write/dml.rs b/crates/pptx/src/write/dml.rs index 518f8c4af..70b085e2e 100644 --- a/crates/pptx/src/write/dml.rs +++ b/crates/pptx/src/write/dml.rs @@ -535,6 +535,7 @@ pub fn ppr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, level: Option, p: || p.bullet_font.is_some() || p.bullet_color.is_some() || p.bullet_size.is_some() + || p.bullet_size_points.is_some() || p.tabs.is_some() || def.is_some(); if !has_kids { @@ -559,6 +560,9 @@ pub fn ppr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, level: Option, p: if let Some(s) = p.bullet_size { w.val("a:buSzPct", pct(s.clamp(0.25, 4.0))); } + if let Some(s) = p.bullet_size_points { + w.val("a:buSzPts", ((s.clamp(0.01, 400.0) * 100.0).round() as u32).to_string()); + } if let Some(f) = &p.bullet_font { w.empty("a:buFont", A::new().a("typeface", f)); } diff --git a/crates/pptx/tests/it/roundtrip.rs b/crates/pptx/tests/it/roundtrip.rs index 370cc9a9f..7936234ce 100644 --- a/crates/pptx/tests/it/roundtrip.rs +++ b/crates/pptx/tests/it/roundtrip.rs @@ -488,6 +488,30 @@ fn shapes_fills_lines_effects_round_trip() { assert_eq!(by_name(&q.slides[1], "Group").id, by_name(&p.slides[1], "Group").id); } +#[test] +fn bullet_size_in_points_survives_two_pptx_round_trips() { + let mut p = Presentation::default(); + let mut textbox = shape(&mut p, "Point-sized bullet", 20.0, 20.0, 400.0, 100.0); + textbox.text = Some(TextBody { + paragraphs: vec![Paragraph { + props: deckcraft_model::text::ParaProps { + bullet: Some(Bullet::Char { char: "•".into() }), + bullet_size_points: Some(12.0), + ..Default::default() + }, + runs: vec![Run::with("Bullet text", RunProps::default())], + ..Default::default() + }], + ..Default::default() + }); + Arc::make_mut(&mut p.slides[0]).shapes.push(textbox); + + let q = round(&round(&p)); + let props = &by_name(&q.slides[0], "Point-sized bullet").text.as_ref().expect("text").paragraphs[0].props; + assert_eq!(props.bullet_size_points, Some(12.0)); + assert_eq!(props.bullet_size, None); +} + #[test] fn pictures_media_and_ink_round_trip() { let p = rich_deck(); diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index 2be8424f7..d5558001d 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -76,7 +76,7 @@ pub(crate) fn shape_para( if let Some(c) = &props.bullet_color { rp.fill = Some(Fill::solid(c.clone())); } - rp.size = Some(lead.size.unwrap_or(18.0) * props.bullet_size.unwrap_or(1.0)); + rp.size = Some(props.bullet_size_points.unwrap_or(lead.size.unwrap_or(18.0) * props.bullet_size.unwrap_or(1.0))); rp.underline = None; rp.strike = None; rp.highlight = None; @@ -105,7 +105,7 @@ pub(crate) fn shape_para( if let Some(c) = &props.bullet_color { rp.fill = Some(Fill::solid(c.clone())); } - rp.size = Some(lead.size.unwrap_or(18.0) * props.bullet_size.unwrap_or(1.0)); + rp.size = Some(props.bullet_size_points.unwrap_or(lead.size.unwrap_or(18.0) * props.bullet_size.unwrap_or(1.0))); rp.underline = None; let mut st = make_style(ctx, &rp, scale, opts.prompt_color); let label = autonum(scheme, start_at.saturating_sub(1).saturating_add(number.unwrap_or(1))); From 917d4b96a9ceb2063a2739a098375fad1395f3bc Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 23:07:54 -0500 Subject: [PATCH 12/58] Honor bullet size schema bounds and inheritance --- crates/model/src/text.rs | 29 +++++++++++++++++++++++++++-- crates/pptx/src/read/dml.rs | 8 ++++++-- crates/pptx/src/write/dml.rs | 7 +++---- crates/pptx/tests/it/roundtrip.rs | 25 ++++++++++++++----------- 4 files changed, 50 insertions(+), 19 deletions(-) diff --git a/crates/model/src/text.rs b/crates/model/src/text.rs index 525f1df3c..8d01cdcd2 100644 --- a/crates/model/src/text.rs +++ b/crates/model/src/text.rs @@ -271,8 +271,13 @@ impl ParaProps { if self.bullet_color.is_none() { self.bullet_color.clone_from(&p.bullet_color); } - self.bullet_size = self.bullet_size.or(p.bullet_size); - self.bullet_size_points = self.bullet_size_points.or(p.bullet_size_points); + if self.bullet_size.is_none() && self.bullet_size_points.is_none() { + if p.bullet_size_points.is_some() { + self.bullet_size_points = p.bullet_size_points; + } else { + self.bullet_size = p.bullet_size; + } + } if self.tabs.is_none() { self.tabs.clone_from(&p.tabs); } @@ -287,6 +292,26 @@ impl ParaProps { } } +#[cfg(test)] +mod bullet_size_tests { + use super::ParaProps; + + #[test] + fn bullet_size_inheritance_keeps_percentage_and_points_as_one_mode() { + let parent = ParaProps { bullet_size_points: Some(12.0), ..Default::default() }; + let mut child = ParaProps { bullet_size: Some(2.0), ..Default::default() }; + child.inherit(&parent); + assert_eq!(child.bullet_size, Some(2.0)); + assert_eq!(child.bullet_size_points, None); + + let parent = ParaProps { bullet_size: Some(1.5), ..Default::default() }; + let mut child = ParaProps { bullet_size_points: Some(14.0), ..Default::default() }; + child.inherit(&parent); + assert_eq!(child.bullet_size, None); + assert_eq!(child.bullet_size_points, Some(14.0)); + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum Caps { diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index 7a3cc2179..5076c3761 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -432,10 +432,14 @@ pub fn ppr(imp: &mut Imp, part: &Part, p: &El) -> (Option, ParaProps, Option } "buFont" => pp.bullet_font = e.attr("typeface").map(String::from), "buClr" => pp.bullet_color = color(e), - "buSzPct" => pp.bullet_size = pct(e, "val").map(|v| v.clamp(0.25, 4.0)), + "buSzPct" => { + pp.bullet_size_points = None; + pp.bullet_size = pct(e, "val").map(|v| v.clamp(0.25, 4.0)); + } "buSzPts" => { + pp.bullet_size = None; pp.bullet_size_points = - e.attr("val").and_then(|v| v.parse::().ok()).filter(|v| v.is_finite() && *v > 0.0 && *v <= 40_000.0).map(|v| v / 100.0); + e.attr("val").and_then(|v| v.parse::().ok()).filter(|v| (100..=400_000).contains(v)).map(|v| v as f64 / 100.0); } "tabLst" => { pp.tabs = Some( diff --git a/crates/pptx/src/write/dml.rs b/crates/pptx/src/write/dml.rs index 70b085e2e..9efb990f7 100644 --- a/crates/pptx/src/write/dml.rs +++ b/crates/pptx/src/write/dml.rs @@ -557,12 +557,11 @@ pub fn ppr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, level: Option, p: color(w, c); w.close("a:buClr"); } - if let Some(s) = p.bullet_size { + if let Some(s) = p.bullet_size_points.filter(|s| s.is_finite() && (1.0..=4000.0).contains(s)) { + w.val("a:buSzPts", ((s * 100.0).round() as u32).to_string()); + } else if let Some(s) = p.bullet_size { w.val("a:buSzPct", pct(s.clamp(0.25, 4.0))); } - if let Some(s) = p.bullet_size_points { - w.val("a:buSzPts", ((s.clamp(0.01, 400.0) * 100.0).round() as u32).to_string()); - } if let Some(f) = &p.bullet_font { w.empty("a:buFont", A::new().a("typeface", f)); } diff --git a/crates/pptx/tests/it/roundtrip.rs b/crates/pptx/tests/it/roundtrip.rs index 7936234ce..065eb8495 100644 --- a/crates/pptx/tests/it/roundtrip.rs +++ b/crates/pptx/tests/it/roundtrip.rs @@ -493,23 +493,26 @@ fn bullet_size_in_points_survives_two_pptx_round_trips() { let mut p = Presentation::default(); let mut textbox = shape(&mut p, "Point-sized bullet", 20.0, 20.0, 400.0, 100.0); textbox.text = Some(TextBody { - paragraphs: vec![Paragraph { - props: deckcraft_model::text::ParaProps { - bullet: Some(Bullet::Char { char: "•".into() }), - bullet_size_points: Some(12.0), + paragraphs: [12.0, 500.0, 4000.0] + .into_iter() + .map(|size| Paragraph { + props: deckcraft_model::text::ParaProps { + bullet: Some(Bullet::Char { char: "•".into() }), + bullet_size_points: Some(size), + ..Default::default() + }, + runs: vec![Run::with("Bullet text", RunProps::default())], ..Default::default() - }, - runs: vec![Run::with("Bullet text", RunProps::default())], - ..Default::default() - }], + }) + .collect(), ..Default::default() }); Arc::make_mut(&mut p.slides[0]).shapes.push(textbox); let q = round(&round(&p)); - let props = &by_name(&q.slides[0], "Point-sized bullet").text.as_ref().expect("text").paragraphs[0].props; - assert_eq!(props.bullet_size_points, Some(12.0)); - assert_eq!(props.bullet_size, None); + let props = &by_name(&q.slides[0], "Point-sized bullet").text.as_ref().expect("text").paragraphs; + assert_eq!(props.iter().map(|p| p.props.bullet_size_points).collect::>(), vec![Some(12.0), Some(500.0), Some(4000.0)]); + assert!(props.iter().all(|p| p.props.bullet_size.is_none())); } #[test] From 98138f563b357921ebc2f6fe6ff7ffd57d50aae7 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 23:24:07 -0500 Subject: [PATCH 13/58] Trim whitespace in bullet point sizes --- crates/pptx/src/read/dml.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index 5076c3761..11949be56 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -23,6 +23,10 @@ pub fn pct(e: &El, name: &str) -> Option { e.f64(name).map(|v| v / 100_000.0) } +fn bullet_size_points(value: Option<&str>) -> Option { + value.and_then(|v| v.trim().parse::().ok()).filter(|v| (100..=400_000).contains(v)).map(|v| v as f64 / 100.0) +} + /// 1/60 000 degree → degrees. pub fn ang(e: &El, name: &str) -> Option { e.f64(name).map(|v| v / 60_000.0) @@ -438,8 +442,7 @@ pub fn ppr(imp: &mut Imp, part: &Part, p: &El) -> (Option, ParaProps, Option } "buSzPts" => { pp.bullet_size = None; - pp.bullet_size_points = - e.attr("val").and_then(|v| v.parse::().ok()).filter(|v| (100..=400_000).contains(v)).map(|v| v as f64 / 100.0); + pp.bullet_size_points = bullet_size_points(e.attr("val")); } "tabLst" => { pp.tabs = Some( @@ -676,6 +679,13 @@ mod tests { assert_eq!(color(&d.root).unwrap().base, ColorBase::Rgb { rgb: Rgba::rgb(255, 0, 0) }); } + #[test] + fn bullet_point_size_trims_schema_whitespace() { + let d = parse(br#""#).unwrap(); + assert_eq!(bullet_size_points(d.root.attr("val")), Some(14.0)); + assert_eq!(bullet_size_points(Some("1400")), Some(14.0)); + } + #[test] fn body_props() { let d = parse(br#""#).unwrap(); From e747bff42525c7e6fde01bea4651db0dc7a94ae6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 22:02:08 -0700 Subject: [PATCH 14/58] Shape Fill: the gradient help lists every kind and key the command takes The shape.fill params doc listed only linear and radial gradients with stops, angle and kind. The command also takes the rectangular and path kinds, focus and rotateWithShape. The doc now names them, and a test checks the doc's gradient keys and kinds against what the command reads. --- crates/engine/src/cmd/shape.rs | 2 +- crates/engine/src/tests.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs index 9565cdf7b..08ed9be64 100644 --- a/crates/engine/src/cmd/shape.rs +++ b/crates/engine/src/cmd/shape.rs @@ -103,7 +103,7 @@ pub fn specs() -> Vec { "Shape Fill", ["Shape Format", "Shape Styles"], None, - "{color? | none?: true | background?: true (the slide background shows through; shapes and text boxes only) | gradient?: {stops: [[pos, color]], angle?, kind?: linear|radial} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill; a group also formats its members", + "{color? | none?: true | background?: true (the slide background shows through; shapes and text boxes only) | gradient?: {stops: [[pos 0..1, color]], kind?: linear|radial|rectangular|path, angle?: deg (linear), focus?: [l, t, r, b] fractions (radial, rectangular and path: where the first stop sits), rotateWithShape?: bool} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill; a group also formats its members", has_selection, fill ), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 85cbe9d8c..1fc7fb2d7 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4281,6 +4281,38 @@ fn a_params_doc_spells_out_each_parameter() { ); } +/// `shape.fill`'s params doc names each key and kind its `gradient` takes. +#[test] +fn shape_fill_documents_each_gradient_key_and_kind() { + use deckcraft_model::style::GradientShape; + let params = cmd::parse_params(find_command("shape.fill").unwrap().params).unwrap(); + let about = params.iter().find(|p| p.name == "gradient").unwrap().about; + assert_eq!(cmd::param_keys(about).unwrap(), ["stops", "kind", "angle", "focus", "rotateWithShape"]); + for kind in ["linear", "radial", "rectangular", "path"] { + assert!(about.contains(kind), "{kind}: {about}"); + let g = cmd::shape::gradient_param(&json!({ + "stops": [[0, "#ffffff"], [1, "#000000"]], + "kind": kind, + "angle": 30, + "focus": [0, 0, 1, 1], + "rotateWithShape": false + })) + .unwrap(); + assert!(!g.rotate_with_shape, "{kind}"); + match g.shape { + GradientShape::Linear { angle, .. } => assert_eq!((kind, angle), ("linear", 30.0)), + GradientShape::Path { path, focus } => { + let expected = match kind { + "radial" => "circle", + "rectangular" => "rect", + _ => "shape", + }; + assert_eq!((path.as_str(), focus), (expected, [0.0, 0.0, 1.0, 1.0]), "{kind}"); + } + } + } +} + #[test] fn what_a_command_acts_on_names_only_keys_it_takes() { for c in command_specs() { From c3e9bd07e5600662ee4230b609144498f3fe7e05 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 31 Oct 2026 06:38:17 +0100 Subject: [PATCH 15/58] Reject invalid slide index in design.background Found while testing crates/engine/src/cmd/design/slide_background.rs:65 (index via usize_param); write_background at :175 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/design/slide_background.rs | 4 ++-- crates/engine/src/cmd/design/tests.rs | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/design/slide_background.rs b/crates/engine/src/cmd/design/slide_background.rs index 0de78e7e3..92f3ce773 100644 --- a/crates/engine/src/cmd/design/slide_background.rs +++ b/crates/engine/src/cmd/design/slide_background.rs @@ -1,6 +1,6 @@ use super::{ Arc, BTreeSet, Background, Fill, NO_ID_SPACE, Presentation, Result, Session, Target, Value, bad, bool_or, bool_param, color_param, color_value, - f64_param, ok, usize_param, with_param, + f64_param, ok, opt_usize_param, usize_param, with_param, }; pub(super) fn background(s: &mut Session, p: &Value) -> Result { @@ -62,7 +62,7 @@ pub(super) fn background(s: &mut Session, p: &Value) -> Result { )); }; let all = bool_or(p, "all", false); - let index = usize_param(p, "index"); + let index = opt_usize_param(p, "index", "design.background")?; if matches!(op, BgOp::Adjust) { let next = { let st = s.doc()?; diff --git a/crates/engine/src/cmd/design/tests.rs b/crates/engine/src/cmd/design/tests.rs index 9765fcc8f..82eed6feb 100644 --- a/crates/engine/src/cmd/design/tests.rs +++ b/crates/engine/src/cmd/design/tests.rs @@ -485,3 +485,16 @@ fn background_all_clears_layout_overrides_even_if_slides_and_master_match() { s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap(); assert_eq!(undo_len(&s), n + 1, "now it is truly a no-op"); } + +#[test] +fn background_rejects_invalid_index() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({})).unwrap(); + s.execute("slide.go", &json!({"index": 0})).unwrap(); + let before = s.doc().unwrap().doc.slides[0].background.clone(); + for bad in [json!(-1), json!(1.5)] { + assert!(s.execute("design.background", &json!({"color": "#0000C8", "index": bad})).is_err()); + assert!(s.execute("design.background", &json!({"transparency": 0.5, "index": bad})).is_err()); + assert_eq!(s.doc().unwrap().doc.slides[0].background, before); + } +} From 84ac51a43953a11b5b1c3327b8a6c32e4d4e7951 Mon Sep 17 00:00:00 2001 From: DominicTheD3v <119626487+DominicTheD3v@users.noreply.github.com> Date: Sat, 31 Oct 2026 07:04:09 +0000 Subject: [PATCH 16/58] fix: addressing more issues --- crates/engine/src/tests.rs | 60 +++++++++++++++++++++++++++++++++ crates/engine/src/tools/drag.rs | 14 +++++++- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ef18a31f6..39c04610e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5617,6 +5617,66 @@ fn resizing_selected_shapes_still_snaps_to_stationary_shapes() { assert_eq!(box_of(&mut s, fixed), fixed_before); } +/// Secondary pictures resize uniformly around their anchor, preserving independent flips +/// and keeping the complete multi-selection resize in one undo step. +#[test] +fn resizing_selected_shapes_resizes_secondary_pictures() { + // Pointer destination, Alt, expected rectangle, expected picture, expected flips. + let cases = [ + ((180.0, 200.0), false, [100.0, 100.0, 80.0, 100.0], [300.0, 300.0, 80.0, 80.0], [false, false]), + ((200.0, 180.0), false, [100.0, 100.0, 100.0, 80.0], [300.0, 300.0, 80.0, 80.0], [false, false]), + ((220.0, 200.0), false, [100.0, 100.0, 120.0, 100.0], [300.0, 300.0, 120.0, 120.0], [false, false]), + ((200.0, 220.0), false, [100.0, 100.0, 100.0, 120.0], [300.0, 300.0, 120.0, 120.0], [false, false]), + ((180.0, 200.0), true, [120.0, 100.0, 60.0, 100.0], [320.0, 320.0, 60.0, 60.0], [false, false]), + ((200.0, 180.0), true, [100.0, 120.0, 100.0, 60.0], [320.0, 320.0, 60.0, 60.0], [false, false]), + ((80.0, 200.0), false, [80.0, 100.0, 20.0, 100.0], [280.0, 300.0, 20.0, 20.0], [true, false]), + ((200.0, 80.0), false, [100.0, 80.0, 100.0, 20.0], [300.0, 280.0, 20.0, 20.0], [false, true]), + ]; + + for (to, alt, expected_rect, expected_picture, flips) in cases { + let mut s = session(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.prefs.smart_guides = false; + s.prefs.snap_to_grid = false; + + let rect = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 100, 100]})).unwrap()["id"].as_u64().unwrap(); + let picture = ramp_picture(&mut s, 1, [300, 300, 100, 100]).as_u64().unwrap(); + s.execute("edit.select", &json!({"ids": [rect, picture]})).unwrap(); + + let before = s.doc().unwrap().doc.clone(); + let steps = s.doc().unwrap().history.undo.len(); + let mods = Mods { alt, ..Default::default() }; + let ev = |kind, (x, y): (f64, f64)| PointerEvent { kind, x, y, mods, tol: 3.0, pressure: None }; + + let down = s.pointer(ev(PointerKind::Down, (200.0, 200.0))).unwrap(); + assert_eq!(down["hit"]["kind"], "resize"); + assert_eq!(down["hit"]["id"], rect); + assert_eq!(down["hit"]["handle"], 4); + + s.pointer(ev(PointerKind::Drag, ((200.0 + to.0) / 2.0, (200.0 + to.1) / 2.0))).unwrap(); + s.pointer(ev(PointerKind::Drag, to)).unwrap(); + s.pointer(ev(PointerKind::Up, to)).unwrap(); + + for (id, expected) in [(rect, expected_rect), (picture, expected_picture)] { + let actual = box_of(&mut s, id); + for (key, value) in ["x", "y", "w", "h"].into_iter().zip(expected) { + let got = actual[key].as_f64().unwrap(); + assert!((got - value).abs() < 1e-9, "{to:?}, alt={alt}, shape={id}, {key}: expected {value}, got {got}"); + } + + let xfrm = s.doc().unwrap().shape(ShapeId(id as u32)).unwrap().xfrm.unwrap(); + assert_eq!([xfrm.flip_h, xfrm.flip_v], flips, "{to:?}, alt={alt}, shape={id}"); + } + + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + let after = s.doc().unwrap().doc.clone(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, after); + } +} + /// #961: dragging the east handle snaps the dragged (right) edge, even when the fixed left edge /// already lines up with another shape. The nearest line used to be the fixed edge's, at distance 0. #[test] diff --git a/crates/engine/src/tools/drag.rs b/crates/engine/src/tools/drag.rs index 9b672ffcd..11392c13d 100644 --- a/crates/engine/src/tools/drag.rs +++ b/crates/engine/src/tools/drag.rs @@ -47,12 +47,24 @@ impl Session { let target_nx = if *tid == id { nx } else { + let picture = self.is_picture(*tid); + let (sx, sy) = if picture && matches!(handle, 0 | 2 | 4 | 6) { + // Use the axis with the larger relative change so an unchanged + // dimension cannot cancel shrinking. Preserve each axis's flip. + let scale_x = 1.0 + sx; + let scale_y = 1.0 + sy; + let scale = if sx.abs() >= sy.abs() { scale_x.abs() } else { scale_y.abs() }; + (scale.copysign(scale_x) - 1.0, scale.copysign(scale_y) - 1.0) + } else { + (sx, sy) + }; + let lp = Affine::rotate(o.rot.to_radians()) * Point::new( hx * sx * o.w / div * if o.flip_h { -1.0 } else { 1.0 }, hy * sy * o.h / div * if o.flip_v { -1.0 } else { 1.0 }, ); - resize_box(o, handle, Vec2::new(lp.x, lp.y), self.is_picture(*tid), mods.alt) + resize_box(o, handle, Vec2::new(lp.x, lp.y), picture, mods.alt) }; self.set_xfrm(*tid, target_nx)?; } From c5ac1dd44bb4bfe19777190fc98bd589d1fb26e2 Mon Sep 17 00:00:00 2001 From: DominicTheD3v <119626487+DominicTheD3v@users.noreply.github.com> Date: Sat, 31 Oct 2026 07:52:55 +0000 Subject: [PATCH 17/58] docs: add missing docstrings --- crates/engine/src/tools/drag.rs | 1 + crates/engine/src/tools/pointer.rs | 2 ++ 2 files changed, 3 insertions(+) diff --git a/crates/engine/src/tools/drag.rs b/crates/engine/src/tools/drag.rs index 11392c13d..dcee9cf7d 100644 --- a/crates/engine/src/tools/drag.rs +++ b/crates/engine/src/tools/drag.rs @@ -13,6 +13,7 @@ struct MoveDrag { } impl Session { + /// Update the active drag interaction using the pointer position and modifiers. pub(super) fn drag(&mut self, p: Point, mods: Mods, tol: f64) -> Result { let Some(drag) = self.tool.drag.clone() else { return Ok(Value::Null) }; match drag { diff --git a/crates/engine/src/tools/pointer.rs b/crates/engine/src/tools/pointer.rs index 1f4cba0c5..d049dcf35 100644 --- a/crates/engine/src/tools/pointer.rs +++ b/crates/engine/src/tools/pointer.rs @@ -220,6 +220,7 @@ impl Session { } } + /// Handle a pointer press and start the applicable tool interaction. fn down(&mut self, p: Point, mods: Mods, tol: f64) -> Result { if let Some(Drag::Freeform { mode, mut points, .. }) = self.tool.drag.clone() { // Next click of a freeform: close on the start point, else add a vertex. @@ -440,6 +441,7 @@ impl Session { Ok(json!({"hit": serde_json::to_value(&hit).unwrap_or_default()})) } + /// Handle a pointer release and finalize the active tool interaction. fn up(&mut self, p: Point, mods: Mods) -> Result { let drag = self.tool.drag.take(); self.tool.guides.clear(); From 2ed2ce18d8e229e95776068e73daffb194f9fd96 Mon Sep 17 00:00:00 2001 From: Madhasoeki Date: Sat, 31 Oct 2026 22:25:50 +0700 Subject: [PATCH 18/58] Fonts: unavailable monospace fonts fall back to a monospace substitute (#1598) When a document uses a monospace font that is not installed on the system (such as Cousine, Source Code Pro, JetBrains Mono, Consolas, or Cascadia Code), document_face previously fell back to the generic proportional MISSING_FONT_FAMILY (Calibri), causing code snippets and columns to misalign. Add is_monospace_family to recognize monospace families, ensure substitutes returns open monospace replacements (Liberation Mono, Courier New, Menlo, Courier, DejaVu Sans Mono), recognize Cousine as a metric twin of Courier New, and keep monospace families on their substitute chain in document_face. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/fonts/src/fontdb.rs | 80 ++++++++++++++++++++++++++++++++++++-- crates/fonts/src/lib.rs | 3 +- crates/fonts/src/tests.rs | 51 ++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 5 deletions(-) diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 043b8f2dd..cc2bd417a 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -30,12 +30,15 @@ pub fn bundled() -> Vec<&'static [u8]> { /// Open substitutes for families documents often ask for but we can't ship (metric-compatible /// where one exists), tried in order after the family itself. pub fn substitutes(family: &str) -> &'static [&'static str] { - match family.to_ascii_lowercase().as_str() { + let f = family.to_ascii_lowercase(); + if is_monospace_family(&f) { + return &["Liberation Mono", "Courier New", "Menlo", "Courier", "DejaVu Sans Mono"]; + } + match f.as_str() { "calibri" | "calibri light" => &["Carlito", "Liberation Sans", "Arial", "Inter"], "cambria" | "cambria math" => &["Caladea", "Liberation Serif", "Times New Roman", "Merriweather"], "arial" | "helvetica" | "helvetica neue" | "arial nova" => &["Liberation Sans", "Arial", "Helvetica Neue", "Helvetica", "Inter"], "times new roman" | "times" => &["Liberation Serif", "Times New Roman", "Times", "Merriweather"], - "courier new" | "courier" | "consolas" | "menlo" => &["Liberation Mono", "Courier New", "Menlo", "Courier"], "aptos" | "aptos display" | "aptos narrow" | "segoe ui" | "segoe ui light" | "segoe ui semibold" | "tahoma" | "verdana" => { &["Inter", "Helvetica Neue", "Arial", "Liberation Sans"] } @@ -52,12 +55,75 @@ pub fn substitutes(family: &str) -> &'static [&'static str] { } } +/// Families that are monospaced: when not installed, documents keep a monospaced substitute +/// ([`substitutes`]) so code blocks and columns stay aligned instead of falling back to +/// proportional Calibri. +pub fn is_monospace_family(family: &str) -> bool { + let f = family.to_ascii_lowercase(); + let s = f.trim(); + if matches!( + s, + "courier new" + | "courier" + | "cousine" + | "consolas" + | "menlo" + | "monaco" + | "source code pro" + | "jetbrains mono" + | "cascadia code" + | "cascadia mono" + | "fira code" + | "fira mono" + | "inconsolata" + | "dejavu sans mono" + | "sf mono" + | "roboto mono" + | "hack" + | "ubuntu mono" + | "pt mono" + | "liberation mono" + | "andale mono" + | "lucida console" + | "lucida sans typewriter" + | "fixedsys" + | "terminal" + | "monospace" + | "iosevka" + | "monolisa" + | "envy code r" + | "hasklig" + ) { + return true; + } + s.starts_with("courier") + || s.starts_with("consolas") + || s.starts_with("inconsolata") + || s.starts_with("iosevka") + || s.ends_with(" mono") + || s.ends_with(" code") + || s.ends_with(" console") + || s.ends_with(" typewriter") + || s.ends_with(" monospace") + || s.split_whitespace().any(|w| w == "mono" || w == "code" || w == "terminal" || w == "monospace") +} + /// Families whose open substitutes ([`substitutes`]) keep their metrics, so text drawn with /// them breaks into the same lines. fn has_metric_twin(family: &str) -> bool { matches!( family.to_ascii_lowercase().as_str(), - "calibri" | "calibri light" | "cambria" | "cambria math" | "arial" | "helvetica" | "times new roman" | "times" | "courier new" | "courier" + "calibri" + | "calibri light" + | "cambria" + | "cambria math" + | "arial" + | "helvetica" + | "times new roman" + | "times" + | "courier new" + | "courier" + | "cousine" ) } @@ -1080,9 +1146,15 @@ impl FontDb { /// when it is installed or bundled, or a metric-compatible stand-in (Carlito for Calibri, /// Liberation Sans for Arial…), or the family PowerPoint maps it to (Liberation Sans as Arial, /// see `office_twin`); any other missing family is drawn in Calibri (or Carlito). + /// Monospaced families keep a monospaced substitute instead of falling back to Calibri. /// DeckCraft's own default family keeps [`FontDb::face`]'s fallbacks. pub fn document_face(&self, family: &str, style: &str) -> Arc { - if family.eq_ignore_ascii_case(FALLBACK_FAMILY) || has_metric_twin(family) || is_office_cloud_font(family) || self.has_family(family) { + if family.eq_ignore_ascii_case(FALLBACK_FAMILY) + || has_metric_twin(family) + || is_monospace_family(family) + || is_office_cloud_font(family) + || self.has_family(family) + { return self.face(family, style); } if let Some(twin) = office_twin(family) { diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 92d2cb44d..0bb0edb50 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -15,7 +15,8 @@ mod shape; pub use fallback::{bundled_families, covers_cluster, is_emoji_cluster, ligature_carets}; pub use fontdb::{ - FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LAST_RESORT_FAMILY, MISSING_FONT_FAMILY, base_style, bundled, substitutes, system_font_dirs, + FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LAST_RESORT_FAMILY, MISSING_FONT_FAMILY, base_style, bundled, is_monospace_family, substitutes, + system_font_dirs, }; pub use harfrust::Feature; use harfrust::Tag; diff --git a/crates/fonts/src/tests.rs b/crates/fonts/src/tests.rs index a6fca4c4d..69f54227d 100644 --- a/crates/fonts/src/tests.rs +++ b/crates/fonts/src/tests.rs @@ -237,6 +237,57 @@ fn office_fonts_have_substitutes() { assert!(substitutes("Times New Roman").contains(&"Liberation Serif")); assert!(substitutes("Aptos").contains(&"Inter")); assert!(substitutes("zzz").is_empty()); + + for mono in [ + "Courier New", + "Cousine", + "Consolas", + "Menlo", + "Monaco", + "Source Code Pro", + "JetBrains Mono", + "Cascadia Code", + "Cascadia Mono", + "Fira Code", + "Inconsolata", + "DejaVu Sans Mono", + "Roboto Mono", + "Hack", + "Ubuntu Mono", + "Custom Monospace", + "Fantasque Sans Mono", + ] { + assert!(substitutes(mono).contains(&"Liberation Mono"), "{mono} should have Liberation Mono substitute"); + assert!(is_monospace_family(mono), "{mono} should be recognized as monospace"); + } + + for prop in ["Calibri", "Arial", "Times New Roman", "Inter", "Monotype Corsiva", "Monotype Baskerville", "zzz"] { + assert!(!is_monospace_family(prop), "{prop} should not be recognized as monospace"); + } +} + +#[test] +fn missing_monospace_fonts_keep_monospace_fallback() { + let db = FontDb::global(); + let mono_sub = db.face("Unavailable JetBrains Mono", "Regular").family.clone(); + let calibri_target = db.face(MISSING_FONT_FAMILY, "Regular").family.clone(); + + assert!(is_monospace_family(&mono_sub), "substitute {mono_sub} must be monospace"); + + // Cousine is metric-compatible with Courier New / Liberation Mono and must not fall back to Calibri. + if !db.has_family("Cousine") { + assert_eq!(db.document_face("Cousine", "Regular").family, mono_sub); + } + + // Monospace fonts missing from the system must resolve to a monospace substitute. + for mono in ["Unavailable JetBrains Mono", "Custom App Code", "Unknown Space Mono", "Mock Terminal Font"] { + let f = db.document_face(mono, "Regular"); + assert_eq!(f.family, mono_sub, "{mono}"); + assert!(is_monospace_family(&f.family), "{mono} must resolve to a monospace family"); + if mono_sub != calibri_target { + assert_ne!(f.family, calibri_target, "{mono} must not fall back to Calibri"); + } + } } #[test] From cd19752bb5007c8f3c2a8661996b59e24d537f51 Mon Sep 17 00:00:00 2001 From: Elliot Graham Date: Sat, 31 Oct 2026 19:38:08 +0000 Subject: [PATCH 19/58] FIX-1621: let no-fill interiors pass clicks to underlying objects --- ROADMAP.md | 4 +- crates/engine/src/tests.rs | 114 +++++++++++++++++++++++++++++++++ crates/engine/src/tools/hit.rs | 7 +- docs/gaps.md | 3 +- docs/parity-checklist.md | 10 ++- docs/ui-parity.md | 5 +- 6 files changed, 136 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f92554c14..8dc52dfb7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (print-preview tests use egui's emitted URL, and macOS removes its previews before exit; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-10-31 · **Change:** trivial (No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (progress log: read-only `slide.actions` lists shape and table text actions; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-27 · **Change:** trivial (progress log: Set Up Slide Show ▸ Keep mouse cursor visible, #1445; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-26 · **Change:** trivial (progress log: dragging several selected slides moves them all; previous: trivial, progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -148,6 +148,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-31 | No Fill shape interiors allow selection and editing of underlying objects, even when a quick style remains; hit-testing uses the renderer’s fill resolution. Outlines, rotation, text targets and actual fills keep their hit behaviour (#1621) | | 2026-10-30 | Print-preview tests use the URL emitted by egui and its exact file path; previews are explicitly removed before the macOS process exits | | 2026-10-30 | `slide.actions` lists shape click and hover actions and text-run hyperlinks (including table cells and grouped shapes) for audit; listing never runs an action. The UI pane is a follow-up | | 2026-10-29 | Slide Show: going back to the previous slide plays the transition of the slide being left, reversed, as PowerPoint does, instead of the previous slide's own entrance transition (#1531); a jump (Go to Slide, links) still plays the target slide's transition | @@ -318,6 +319,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged | | 2026-10-30 | trivial | Progress log: read-only slide action inspection; parity scores unchanged | | 2026-10-29 | trivial | Progress log: going back in the slide show reverses the left slide's transition (#1531). Scores and milestone names unchanged | | 2026-10-29 | trivial | Progress log: the Picture Styles row leaves room for Crop and Size (#1232). Scores and milestone names unchanged | diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a687de95c..08417b4f8 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5572,6 +5572,120 @@ fn edit_points_conversion_is_one_undo_step() { assert!(preset(&s), "the only step was the conversion"); } +// #1621: No Fill changes hit-testing as well as rendering, even with a quick style. +fn no_fill_overlap(s: &mut Session, text: bool) -> (ShapeId, ShapeId) { + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let under = s + .execute( + if text { "insert.textBox" } else { "shape.insert" }, + &json!({"preset": "rect", "rect": [100, 300, 300, 60], "text": if text { "UNDERLYING TEXT" } else { "" }}), + ) + .unwrap()["id"] + .as_u64() + .unwrap(); + let over = s.execute("shape.insert", &json!({"preset": "rect", "rect": [80, 270, 350, 140]})).unwrap()["id"].as_u64().unwrap(); + (ShapeId(under as u32), ShapeId(over as u32)) +} + +#[test] +fn no_fill_shape_allows_selecting_and_editing_underlying_text() { + let mut s = session(); + let (under, over) = no_fill_overlap(&mut s, true); + s.execute("shape.fill", &json!({"none": true})).unwrap(); + assert!(s.doc().unwrap().shape(over).unwrap().style.is_some(), "the ordinary quick style remains"); + s.execute("edit.deselect", &json!({})).unwrap(); + let p = deckcraft_geom::Point::new(200.0, 310.0); + assert!(matches!(s.hit_test(p, 3.0), Some(tools::Hit::Shape { id, .. }) if id == under)); + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(tev(kind, p.x, p.y)).unwrap(); + } + assert_eq!(s.doc().unwrap().selection.shapes, vec![under]); + assert_eq!(text_sel(&s).unwrap().shape, under); + // The transparent foreground also leaves an already-active text editor's caret alone. + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(tev(kind, p.x + 20.0, p.y)).unwrap(); + } + assert_eq!(text_sel(&s).unwrap().shape, under); + s.execute("edit.deselect", &json!({})).unwrap(); + // The rectangle's outline is still a target, and selecting it does not remove its style. + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(tev(kind, 80.0, 330.0)).unwrap(); + } + assert_eq!(s.doc().unwrap().selection.shapes, vec![over]); + assert!(s.doc().unwrap().shape(over).unwrap().style.is_some()); + // A real fill still catches clicks, even with 100% transparency (distinct from No Fill). + for transparency in [0.0, 1.0] { + s.execute("edit.select", &json!({"ids": [over.0]})).unwrap(); + s.execute("shape.fill", &json!({"color": "#336699", "transparency": transparency})).unwrap(); + s.execute("edit.deselect", &json!({})).unwrap(); + assert!(matches!(s.hit_test(p, 3.0), Some(tools::Hit::Shape { id, .. }) if id == over)); + } +} + +#[test] +fn no_fill_shape_hit_testing_respects_rotation_and_pointer_tolerance() { + let mut s = session(); + let (under, over) = no_fill_overlap(&mut s, false); + s.execute("shape.fill", &json!({"none": true})).unwrap(); + s.execute("shape.rotate", &json!({"deg": 30})).unwrap(); + let x = s.doc().unwrap().shape(over).unwrap().xfrm.unwrap(); + let p = x.affine() * deckcraft_geom::Point::new(x.w / 2.0, x.h / 2.0); + let edge = x.affine() * deckcraft_geom::Point::new(0.0, x.h / 4.0); + for tol in [0.5, 3.0, 8.0] { + s.execute("edit.deselect", &json!({})).unwrap(); + assert!(matches!(s.hit_test(p, tol), Some(tools::Hit::Shape { id, .. }) if id == under)); + assert!(matches!(s.hit_test(edge, tol), Some(tools::Hit::Shape { id, .. }) if id == over)); + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(PointerEvent { tol, ..tev(kind, p.x, p.y) }).unwrap(); + } + assert_eq!(s.doc().unwrap().selection.shapes, vec![under]); + } +} + +#[test] +fn no_fill_hit_testing_resolves_style_and_theme_fills() { + use std::sync::Arc; + for source in ["style", "theme", "absent"] { + let mut s = session(); + let (under, over) = no_fill_overlap(&mut s, true); + { + let st = s.doc_mut().unwrap(); + let doc = Arc::make_mut(&mut st.doc); + if source == "theme" { + Arc::make_mut(&mut doc.masters[0]).theme.format.fills[0] = model::Fill::None; + } + let sh = Arc::make_mut(&mut doc.slides[st.selection.slide]).shapes.iter_mut().find(|sh| sh.id == over).unwrap(); + assert!(sh.fill.is_none(), "the shape has no explicit fill override"); + if source == "absent" { + sh.style = None; + } else { + sh.style.as_mut().unwrap().fill_ref.0 = if source == "style" { 0 } else { 1 }; + } + } + s.execute("edit.deselect", &json!({})).unwrap(); + let p = deckcraft_geom::Point::new(200.0, 310.0); + assert!(matches!(s.hit_test(p, 3.0), Some(tools::Hit::Shape { id, .. }) if id == under), "{source}"); + // An explicit fill takes precedence over the no-fill style/theme. + s.execute("edit.select", &json!({"ids": [over.0]})).unwrap(); + s.execute("shape.fill", &json!({"color": "#336699"})).unwrap(); + s.execute("edit.deselect", &json!({})).unwrap(); + assert!(matches!(s.hit_test(p, 3.0), Some(tools::Hit::Shape { id, .. }) if id == over), "{source}"); + } +} + +#[test] +fn no_fill_text_shapes_keep_their_interior_text_target() { + let mut s = session(); + let (_, over) = no_fill_overlap(&mut s, true); + s.execute("text.set", &json!({"id": over.0, "text": "FOREGROUND TEXT"})).unwrap(); + s.execute("shape.fill", &json!({"none": true})).unwrap(); + s.execute("edit.deselect", &json!({})).unwrap(); + let p = deckcraft_geom::Point::new(200.0, 310.0); + assert!(matches!(s.hit_test(p, 3.0), Some(tools::Hit::Shape { id, text: true }) if id == over)); + s.pointer(tev(PointerKind::DoubleClick, p.x, p.y)).unwrap(); + assert_eq!(text_sel(&s).unwrap().shape, over); +} + /// #931: while the text of a placeholder is being edited, a shape in front of it still takes a /// click (as in PowerPoint); a click on the text where nothing is in front still moves the caret. #[test] diff --git a/crates/engine/src/tools/hit.rs b/crates/engine/src/tools/hit.rs index 6a0da529f..0e4b6ddcb 100644 --- a/crates/engine/src/tools/hit.rs +++ b/crates/engine/src/tools/hit.rs @@ -267,8 +267,11 @@ pub(super) fn hit_shape(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) -> return None; } let has_text = matches!(sh.kind, ShapeKind::Shape) && (sh.text.as_ref().is_some_and(|t| !t.is_empty()) || sh.ph.is_some() || sh.text_box); - // Empty interiors of unfilled, text-less shapes don't catch clicks. - let filled = !matches!(sh.fill, Some(deckcraft_model::Fill::None)) || sh.style.is_some(); + // Match rendering: an explicit No Fill overrides the style, and style/theme fills + // can themselves be No Fill. Empty, text-less interiors then let clicks through. + let fill = + cmd::text::ctx_for(&st.doc, &st.selection).map(|ctx| deckcraft_model::resolve::fill(&ctx, sh).0).unwrap_or_else(|| sh.fill.clone()); + let filled = !matches!(fill, None | Some(deckcraft_model::Fill::None)); let geo = deckcraft_render::shape_geometry(sh, x.w, x.h); let in_geo = { use deckcraft_geom::KurboShape; diff --git a/docs/gaps.md b/docs/gaps.md index 29edfd573..3bc26cce7 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DeckCraft falls short of PowerPoint -> **Last reviewed:** 2026-10-30 · **Last updated:** 2026-10-30 · **Change:** minor (slide-show hit testing follows each shape’s own animation state; scale and rotation covered; previous: trivial, AutoFormat lists run independently of Replace text as you type; previous: minor, slide-show click targets follow animated shapes and text links; previous: minor, Animation Pane ▸ Play From; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: soak tests, PPTX corpus, comment threads (#70), embedded fonts, SmartArt Text Pane, Animation Pane timeline, Hunspell spelling, thesaurus, AutoCorrect, 14 new dialogs, pen pressure and trackpad gestures; duplicated entries and headers folded; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-10-31 · **Change:** trivial (No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **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 @@ -368,6 +368,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged | | 2026-10-30 | minor | Slide-show click targets use each shape’s own animation state; scale and rotation test added | | 2026-10-28 | trivial | Automatic lists now run independently of Replace text as you type; proofing gap description updated | | 2026-10-27 | minor | Refreshed against merged PRs: removed the Morph alignment entry (#67 merged 2026-10-14); #77 (animation rewind, PR #83) is fixed, leaving #74 in the round-trip gap; #75 was closed unmerged, so #60 stays open with no fix PR; #53 is closed and the image-paste gap is now Windows #1414; Reuse Slides (#1103) and Compare reject (#850) noted in the smaller rows; the duplicate header line is gone | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 0c0d3ec60..c088bd018 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -2,6 +2,8 @@ > **Generated:** 2026-10-14 (bullets/numbering remain D after empty-line marker review; summary by `cargo xtask parity`; rows re-scored by the 2026-10-14 community PR wave, duplicate Thesaurus, AutoCorrect, Artistic effects and Corrections rows folded; Preview in editor re-scored 2026-10-13; earlier rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4; video/animated GIF row re-scored 2026-10-11, #171) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last updated:** 2026-10-31 · Fill row re-assessed after #1621: remains D; No Fill hit-testing now follows resolved appearance. Scores unchanged. + The summary between ` **Weighted breadth parity: 84%** over 191 features. @@ -118,7 +120,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Shapes | Text warp transforms | P2 | P | | Shapes | Shape quick styles | P1 | D | | Shapes | Set as default shape | P2 | D | -| Format | Solid fill and transparency | P0 | D | +| Format | Solid fill and transparency (No Fill interiors pass clicks through, #1621) | P0 | D | | Format | Gradient fill (linear, radial, rectangular, path) | P0 | D | | Format | Picture and texture fill | P1 | P | | Format | Pattern fill | P2 | P | @@ -233,3 +235,9 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Platforms (✱) | macOS, Windows, Linux builds | P0 | P | | Platforms (✱) | Web (WASM) build | P1 | D | | Platforms (✱) | BSD build | P2 | P | + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-31 | trivial | No Fill interior hit-testing follows resolved fill (#1621); fill row remains D and summary scores are unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 1e2ef52f4..fa5f9ae88 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-29 · **Change:** trivial (Picture Format: the Picture Styles row leaves room for Crop and Size, #1232; previous: trivial, Edit Points: Shift, Ctrl/Cmd or Alt/Option on a handle drag sets the point type; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: major, community PR wave 2026-10-14: 14 new dialogs (41 in all), Eyedropper, Export button, New Window, Grid Options, WordArt gallery and handles, Slide Sorter sections, Quick Access Toolbar, Format Background and Format Shape depth, rich-text clipboard; duplicated rows and headers folded; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-10-31 · **Change:** trivial (No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-26 · **Change:** trivial (dragging several selected slides moves them all; previous: trivial, Edit Points: Shift, Ctrl/Cmd or Alt/Option on a handle drag sets the point type; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: major, community PR wave 2026-10-14: 14 new dialogs (41 in all), Eyedropper, Export button, New Window, Grid Options, WordArt gallery and handles, Slide Sorter sections, Quick Access Toolbar, Format Background and Format Shape depth, rich-text clipboard; duplicated rows and headers folded; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (group fill and outline edits reach members, #1079; scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (Edit Points: Shift, Ctrl/Cmd or Alt/Option on a handle drag sets the point type; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: major, community PR wave 2026-10-14: 14 new dialogs (41 in all), Eyedropper, Export button, New Window, Grid Options, WordArt gallery and handles, Slide Sorter sections, Quick Access Toolbar, Format Background and Format Shape depth, rich-text clipboard; duplicated rows and headers folded; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -81,7 +81,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Behaviour | PowerPoint | DeckCraft | |---|---|---| -| Selection handles, rotation handle, yellow adjustment handles | yes | yes | +| Selection handles, rotation handle, yellow adjustment handles | yes | yes; an empty No Fill shape passes interior clicks to underlying objects while its outline and handles remain selectable. Explicit, style and theme fills resolve as rendered (#1621) | | Marquee, Shift/Cmd multi-select, Tab to cycle | yes | yes | | Objects off the slide: drawn on the area around it in Normal view (full strength, master graphics too), selectable and movable there; clipped in the show, exports and thumbnails | yes | yes (#270) | | Scroll bars on the slide pane when the zoomed slide is larger than the window | yes | yes, on each axis that overflows: drag the thumb or click the track; the wheel and trackpad still pan. The vertical bar does not turn the page (#1157) | @@ -171,6 +171,7 @@ consistency is open (#73, #56). | Date | Change | Summary | |---|---|---| +| 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged | | 2026-10-29 | trivial | Picture Format: the Picture Styles row shows as many styles as fit beside the groups after it (at least two) and a More arrow with all 24, as the Transitions and Animations galleries do; Crop and Size are in view in the default window (#1232) | | 2026-10-26 | trivial | Thumbnail pane and Slide Sorter: dragging one of several selected slides moves the whole selection | | 2026-10-24 | trivial | Table cells: dragging across cells selects a range of cells | From d990678b32609e7f7301be4f389481c83c7eba96 Mon Sep 17 00:00:00 2001 From: Simon Koch-Sultan <126534658+scks1@users.noreply.github.com> Date: Sat, 31 Oct 2026 17:18:44 -0400 Subject: [PATCH 20/58] render: keep even-odd holes open in shadow, glow and reflection silhouettes effects_below merged all subpaths into one silhouette and filled it with the NonZero rule, discarding each subpath's even_odd flag, so the holes of donut, frame and similar preset shapes filled in under outer shadows, glows and reflections. Fill each participating subpath under its own fill rule at the three effect fill sites, mirroring fill_geometry; the glow stroke keeps the merged silhouette. Fixes #1596. The picture-content shadow clip path is unchanged (that is #1597's territory). --- crates/render/src/renderer.rs | 42 ++++++++++++++++++++++++----- crates/render/src/tests.rs | 50 +++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 7 deletions(-) diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs index 84c5e5a8f..c25f6bab6 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -1125,12 +1125,15 @@ impl Renderer { // see-through as its fill (none for an outline-only shape) … None => { if let Some(a) = fill_alpha { - let mut filled = BezPath::new(); - for sp in geo.paths.iter().filter(|sp| sp.fill != FillMode::None) { - filled.extend(sp.path.iter()); - } ctx.set_paint(color(c.with_alpha(255), a)); - ctx.fill_path(&filled); + // Each subpath under its own fill rule, as the shape itself is + // drawn: one merged path filled NonZero fills the hole of an + // even-odd shape (a donut, a frame) (#1596). + for sp in geo.paths.iter().filter(|sp| sp.fill != FillMode::None) { + ctx.set_fill_rule(if sp.even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero }); + ctx.fill_path(&sp.path); + } + ctx.set_fill_rule(peniko::Fill::NonZero); } } } @@ -1161,7 +1164,19 @@ impl Renderer { ctx.stroke_path(&silhouette); // A shape without a fill (an outline, a frame) glows along its line only (#225). if fill_alpha.is_some() || content.is_some() { - ctx.fill_path(&silhouette); + // Per subpath under its own fill rule, as in the shadow above: the + // merged silhouette filled NonZero fills an even-odd hole (#1596). + // When no subpath takes part the silhouette is the outline fallback. + let mut filled_any = false; + for sp in geo.paths.iter().filter(|sp| sp.fill != FillMode::None || fill.is_none()) { + ctx.set_fill_rule(if sp.even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero }); + ctx.fill_path(&sp.path); + filled_any = true; + } + if !filled_any { + ctx.fill_path(&silhouette); + } + ctx.set_fill_rule(peniko::Fill::NonZero); } ctx.pop_layer(); } @@ -1180,7 +1195,20 @@ impl Renderer { } None => { if let Some(fl) = fill { - paint::fill_path(ctx, rctx, fl, ph, &silhouette, Rect::new(0.0, 0.0, w, h), mm, FillMode::Norm, 1.0, rctx.pres); + // Per subpath under its own fill rule, as in the shadow above: + // the merged silhouette filled NonZero fills an even-odd hole + // (#1596). The outline fallback covers shapes with no + // participating subpath. + let mut filled_any = false; + for sp in geo.paths.iter().filter(|sp| sp.fill != FillMode::None || fill.is_none()) { + ctx.set_fill_rule(if sp.even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero }); + paint::fill_path(ctx, rctx, fl, ph, &sp.path, Rect::new(0.0, 0.0, w, h), mm, FillMode::Norm, 1.0, rctx.pres); + filled_any = true; + } + if !filled_any { + paint::fill_path(ctx, rctx, fl, ph, &silhouette, Rect::new(0.0, 0.0, w, h), mm, FillMode::Norm, 1.0, rctx.pres); + } + ctx.set_fill_rule(peniko::Fill::NonZero); } } } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index f01ee1cf8..2de2daabe 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -515,6 +515,56 @@ fn glow_of_a_shape_with_no_fill_and_no_line_is_not_drawn() { } } +#[test] +fn shadow_and_glow_keep_an_even_odd_shapes_hole() { + // #1596: a donut is one even-odd subpath — its hole exists only under the + // even-odd rule. The effects were cast from a merged silhouette filled + // NonZero, so the shadow and the glow filled the hole solid. The hole's + // centre must stay the slide's white in both, while the effects still draw. + let donut = |effects: deckcraft_model::Effects| { + let mut sh = Shape { + id: ShapeId(612), + xfrm: Some(Xfrm::new(50.0, 50.0, 200.0, 200.0)), + geom: Geom::preset("donut"), + fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 160, 0)))), + ..Default::default() + }; + sh.effects = Some(effects); + render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() }) + }; + let img = donut(deckcraft_model::Effects { + outer_shadow: Some(deckcraft_model::style::Shadow { + color: ColorRef::rgb(deckcraft_color::Rgba::BLACK), + blur: 8.0, + dist: 0.0, + dir: 45.0, + inner: false, + sx: 1.0, + sy: 1.0, + kx: 0.0, + ky: 0.0, + align: String::new(), + rotate_with_shape: false, + }), + ..Default::default() + }); + let hole = img.pixel(150, 150); + assert!(hole[0] > 245 && hole[1] > 245 && hole[2] > 245, "shadow in the hole: {hole:?}"); + // The shadow still falls around the shape (just below its bottom edge). + let below = img.pixel(150, 251); + assert!(below[0] < 200 && below[0] == below[2], "no shadow below: {below:?}"); + + let img = donut(deckcraft_model::Effects { + glow: Some(deckcraft_model::style::Glow { color: ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0)), radius: 8.0 }), + ..Default::default() + }); + let hole = img.pixel(150, 150); + assert!(hole[0] > 245 && hole[1] > 245 && hole[2] > 245, "glow in the hole: {hole:?}"); + // The ring itself is the shape's orange. + let ring = img.pixel(150, 75); + assert!(ring[0] > 200 && ring[1] > 100 && ring[2] < 100, "ring: {ring:?}"); +} + #[test] fn placeholder_prompts_only_in_edit_view() { let p = Presentation::default(); From 3fb6c4313657a2f755ab9bc99abd3cfff8a1ec0b Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:08:51 +0100 Subject: [PATCH 21/58] =?UTF-8?q?Lowercase=20word-final=20=CE=A3=20as=20?= =?UTF-8?q?=CF=82=20in=20title=20and=20sentence=20case?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found while testing crates/model/src/edit.rs:470-497 (change_case_parts, title/sentence branch) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/model/src/edit.rs | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/model/src/edit.rs b/crates/model/src/edit.rs index d635c052e..3e21e3bf8 100644 --- a/crates/model/src/edit.rs +++ b/crates/model/src/edit.rs @@ -469,6 +469,9 @@ pub fn change_case(s: &str, mode: &str) -> String { /// piece is the changed form of the matching input piece, even where a character changes length. pub fn change_case_parts(parts: &[&str], mode: &str) -> Vec { let mut start = true; + // All characters of all pieces, so word-final sigma sees neighbours across piece boundaries. + let all: Vec = parts.iter().flat_map(|s| s.chars()).collect(); + let mut base = 0; parts .iter() .map(|s| match mode { @@ -479,10 +482,14 @@ pub fn change_case_parts(parts: &[&str], mode: &str) -> Vec { // `title` restarts after whitespace, sentence case after sentence punctuation. let title = mode == "title"; let mut out = String::new(); - for c in s.chars() { + for (j, c) in s.chars().enumerate() { + let i = base + j; if start && c.is_alphabetic() { out.extend(c.to_uppercase()); start = false; + } else if c == 'Σ' && i > 0 && all[i - 1].is_alphabetic() && !all.get(i + 1).is_some_and(|n| n.is_alphabetic()) { + // Word-final capital sigma lowercases to ς, as `str::to_lowercase` does. + out.push('ς'); } else { out.extend(c.to_lowercase()); } @@ -490,6 +497,7 @@ pub fn change_case_parts(parts: &[&str], mode: &str) -> Vec { start = true; } } + base += s.chars().count(); out } }) @@ -504,6 +512,20 @@ mod tests { TextBody::from_text(t) } + #[test] + fn title_and_sentence_final_sigma() { + assert_eq!(change_case("ΟΔΟΣ", "title"), "Οδος"); + assert_eq!(change_case("ΟΔΟΣ ΜΟΥ.", "sentence"), "Οδος μου."); + assert_eq!(change_case("ΟΔΟΣ", "lower"), "οδος"); + } + + #[test] + fn title_sigma_across_runs_is_not_final() { + assert_eq!(change_case_parts(&["ΟΔΟΣ", "ΑΣ"], "title"), ["Οδοσ", "ας"]); + assert_eq!(change_case_parts(&["ΟΔΟ", "ΣΑ"], "title"), ["Οδο", "σα"]); + assert_eq!(change_case_parts(&["ΟΔΟΣ", " ΜΟΥ"], "title"), ["Οδος", " Μου"]); + } + #[test] fn insert_and_split() { let mut b = body("Hello"); From 87a10fc8e2af63042e560e530336f1cc6ff8c535 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:19:15 +0100 Subject: [PATCH 22/58] Keep combining marks inside spell-check words Found while testing crates/engine/src/spell.rs:693 (paragraph_words, the `inside` closure) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/spell.rs | 12 ++++++++++-- crates/model/src/words.rs | 7 ++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/spell.rs b/crates/engine/src/spell.rs index 93852e3f8..8927ade5b 100644 --- a/crates/engine/src/spell.rs +++ b/crates/engine/src/spell.rs @@ -709,7 +709,8 @@ pub fn paragraph_words(p: &deckcraft_model::Paragraph, default: &str) -> Vec Vec a && !chars[b - 1].0.is_alphanumeric() { + while b > a && !chars[b - 1].0.is_alphanumeric() && !deckcraft_model::words::is_combining_mark(chars[b - 1].0) { b -= 1; } let text: String = chars[a..b].iter().map(|c| c.0).collect(); @@ -819,6 +820,13 @@ SFX D Y 1\nSFX D 0 ed/S .\n"; Dictionary::parse(AFF.as_bytes(), DIC.as_bytes()) } + #[test] + fn nfd_word_not_split() { + let p = deckcraft_model::Paragraph::new("Das na\u{ef}ve Caf\u{e9} und cafe\u{301} na\u{308}ive"); + let w: Vec = paragraph_words(&p, "en-US").into_iter().map(|w| w.text).collect(); + assert_eq!(w, ["Das", "na\u{ef}ve", "Caf\u{e9}", "und", "cafe\u{301}", "na\u{308}ive"]); + } + #[test] fn curly_apostrophe_matches_ascii_entry() { let d = Dictionary::parse(AFF.as_bytes(), "2\ndon't\nwork\n".as_bytes()); diff --git a/crates/model/src/words.rs b/crates/model/src/words.rs index 54b23659f..bcf662e22 100644 --- a/crates/model/src/words.rs +++ b/crates/model/src/words.rs @@ -90,11 +90,16 @@ fn base_class(c: char) -> Class { c if c.is_alphanumeric() => Class::Word, c if is_symbol(c) => Class::Symbol, // A combining mark with no base (or any other mark) stays with letters. - '\u{300}'..='\u{36f}' | '\u{1ab0}'..='\u{1aff}' | '\u{1dc0}'..='\u{1dff}' | '\u{20d0}'..='\u{20ff}' | '\u{fe20}'..='\u{fe2f}' => Class::Word, + c if is_combining_mark(c) => Class::Word, _ => Class::Punct, } } +/// Whether `c` is a combining mark (a decomposed accent), which belongs to the letter before it. +pub fn is_combining_mark(c: char) -> bool { + matches!(c, '\u{300}'..='\u{36f}' | '\u{1ab0}'..='\u{1aff}' | '\u{1dc0}'..='\u{1dff}' | '\u{20d0}'..='\u{20ff}' | '\u{fe20}'..='\u{fe2f}') +} + fn clusters(text: &str) -> Vec { let mut out: Vec = Vec::new(); let mut firsts: Vec = Vec::new(); From 8ec4f29059de67706a72e1066b06cc592828aa80 Mon Sep 17 00:00:00 2001 From: Madhasoeki Date: Sun, 1 Nov 2026 07:19:41 +0700 Subject: [PATCH 23/58] Fonts: refine monospace heuristic and preserve office_twin order (#1598) - Avoid false-positive monospace match on 'code' in split_whitespace for proportional families like Code Pro. - Check office_twin before is_monospace_family in document_face so alias families like Courier New CE resolve to Courier New when installed. - Add negative tests for Code Pro and test Courier New CE mapping. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/fonts/src/fontdb.rs | 12 +++++------- crates/fonts/src/tests.rs | 12 ++++++++---- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index cc2bd417a..1cffbe008 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -105,7 +105,7 @@ pub fn is_monospace_family(family: &str) -> bool { || s.ends_with(" console") || s.ends_with(" typewriter") || s.ends_with(" monospace") - || s.split_whitespace().any(|w| w == "mono" || w == "code" || w == "terminal" || w == "monospace") + || s.split_whitespace().any(|w| w == "mono" || w == "terminal" || w == "monospace") } /// Families whose open substitutes ([`substitutes`]) keep their metrics, so text drawn with @@ -1149,17 +1149,15 @@ impl FontDb { /// Monospaced families keep a monospaced substitute instead of falling back to Calibri. /// DeckCraft's own default family keeps [`FontDb::face`]'s fallbacks. pub fn document_face(&self, family: &str, style: &str) -> Arc { - if family.eq_ignore_ascii_case(FALLBACK_FAMILY) - || has_metric_twin(family) - || is_monospace_family(family) - || is_office_cloud_font(family) - || self.has_family(family) - { + if family.eq_ignore_ascii_case(FALLBACK_FAMILY) || has_metric_twin(family) || is_office_cloud_font(family) || self.has_family(family) { return self.face(family, style); } if let Some(twin) = office_twin(family) { return self.face(twin, style); } + if is_monospace_family(family) { + return self.face(family, style); + } self.face(MISSING_FONT_FAMILY, style) } diff --git a/crates/fonts/src/tests.rs b/crates/fonts/src/tests.rs index 69f54227d..f912f9988 100644 --- a/crates/fonts/src/tests.rs +++ b/crates/fonts/src/tests.rs @@ -216,9 +216,13 @@ fn missing_document_fonts_are_drawn_in_calibri() { assert_eq!(f.family, db.face(MISSING_FONT_FAMILY, "Bold").family, "{missing:?}"); } // PowerPoint's own twins and Windows' substitutes, when the font itself is missing. - for (missing, twin) in - [("Liberation Sans", "Arial"), ("Liberation Serif", "Times New Roman"), ("Arial CE", "Arial"), ("Times New Roman CYR", "Times New Roman")] - { + for (missing, twin) in [ + ("Liberation Sans", "Arial"), + ("Liberation Serif", "Times New Roman"), + ("Arial CE", "Arial"), + ("Times New Roman CYR", "Times New Roman"), + ("Courier New CE", "Courier New"), + ] { if !db.has_family(missing) { assert_eq!(db.document_face(missing, "Regular").family, db.face(twin, "Regular").family, "{missing}"); } @@ -261,7 +265,7 @@ fn office_fonts_have_substitutes() { assert!(is_monospace_family(mono), "{mono} should be recognized as monospace"); } - for prop in ["Calibri", "Arial", "Times New Roman", "Inter", "Monotype Corsiva", "Monotype Baskerville", "zzz"] { + for prop in ["Calibri", "Arial", "Times New Roman", "Inter", "Monotype Corsiva", "Monotype Baskerville", "Code Pro", "zzz"] { assert!(!is_monospace_family(prop), "{prop} should not be recognized as monospace"); } } From 773133ec42c01ea7f418e5bdb1048453dd427c50 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:52:04 +0000 Subject: [PATCH 24/58] Apply alphaMod and alphaOff in ColorRef::alpha Found while testing crates/model/src/style.rs:59-63 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/model/src/style.rs | 4 +++- crates/model/src/tests.rs | 12 ++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/model/src/style.rs b/crates/model/src/style.rs index e2d70b994..3b81d3ae5 100644 --- a/crates/model/src/style.rs +++ b/crates/model/src/style.rs @@ -55,10 +55,12 @@ impl ColorRef { }; deckcraft_color::apply(base, &self.mods) } - /// Alpha (0–1) from an `alpha` transform, if any. + /// Alpha (0–1) from the `alpha`, `alphaMod` and `alphaOff` transforms, applied in order. pub fn alpha(&self) -> f64 { self.mods.iter().fold(1.0, |a, m| match m { ColorTransform::Alpha(v) => (*v as f64 / 100_000.0).clamp(0.0, 1.0), + ColorTransform::AlphaMod(v) => (a * *v as f64 / 100_000.0).clamp(0.0, 1.0), + ColorTransform::AlphaOff(v) => (a + *v as f64 / 100_000.0).clamp(0.0, 1.0), _ => a, }) } diff --git a/crates/model/src/tests.rs b/crates/model/src/tests.rs index 1fd29aeff..4ae6fc0fd 100644 --- a/crates/model/src/tests.rs +++ b/crates/model/src/tests.rs @@ -327,3 +327,15 @@ fn only_web_and_mail_links_are_safe_to_open() { assert!(!safe_url(url), "{url}"); } } + +#[test] +fn colour_alpha_follows_alpha_mod_and_off() { + use deckcraft_color::{ColorTransform, Rgba}; + let base = || ColorRef::rgb(Rgba::rgb(1, 2, 3)); + let mod_half = base().with(ColorTransform::AlphaMod(50000)); + assert!((mod_half.alpha() - 0.5).abs() < 1e-9, "{}", mod_half.alpha()); + let off = base().with(ColorTransform::AlphaOff(-50000)); + assert!((off.alpha() - 0.5).abs() < 1e-9, "{}", off.alpha()); + let both = base().with(ColorTransform::Alpha(40000)).with(ColorTransform::AlphaMod(50000)); + assert!((both.alpha() - 0.2).abs() < 1e-9, "{}", both.alpha()); +} From 5d27461fa633f261a6af037bac9b8ce0953a25ba Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 1 Nov 2026 02:55:58 +0100 Subject: [PATCH 25/58] Keep Xfrm::rot_norm within 0..360 for tiny negative rotations Found while testing crates/geom/src/lib.rs:155-158 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/geom/src/lib.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 3be72e03f..6f695375b 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -153,8 +153,8 @@ impl Xfrm { } /// Rotation normalised to 0..360. pub fn rot_norm(&self) -> f64 { - let r = self.rot % 360.0; - if r < 0.0 { r + 360.0 } else { r } + let r = self.rot.rem_euclid(360.0); + if r >= 360.0 { 0.0 } else { r } } } @@ -266,6 +266,14 @@ mod tests { assert!((p.x - 300.0).abs() < 1e-9 && (p.y - 200.0).abs() < 1e-9); } + #[test] + fn rot_norm_stays_below_360() { + for rot in [-1e-15, -1e-13, -f64::EPSILON, -0.0, 360.0, 720.0, -360.0] { + let n = Xfrm { rot, ..Default::default() }.rot_norm(); + assert!((0.0..360.0).contains(&n), "rot {rot} -> {n}"); + } + } + #[test] fn segment_distance() { assert!((dist_to_segment(Point::new(5.0, 5.0), Point::new(0.0, 0.0), Point::new(10.0, 0.0)) - 5.0).abs() < 1e-9); From c0468b59cd043286ca7453c0465da898a4f36751 Mon Sep 17 00:00:00 2001 From: Madhasoeki Date: Sun, 1 Nov 2026 10:31:17 +0700 Subject: [PATCH 26/58] Fonts: classify Cascadia Code PL as monospace (#1598) Add Cascadia Code PL and Cascadia Mono PL to the monospace family matching list, and add regression tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/fonts/src/fontdb.rs | 2 ++ crates/fonts/src/tests.rs | 1 + 2 files changed, 3 insertions(+) diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 1cffbe008..2e2f35299 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -72,7 +72,9 @@ pub fn is_monospace_family(family: &str) -> bool { | "source code pro" | "jetbrains mono" | "cascadia code" + | "cascadia code pl" | "cascadia mono" + | "cascadia mono pl" | "fira code" | "fira mono" | "inconsolata" diff --git a/crates/fonts/src/tests.rs b/crates/fonts/src/tests.rs index f912f9988..dc52cfbbb 100644 --- a/crates/fonts/src/tests.rs +++ b/crates/fonts/src/tests.rs @@ -251,6 +251,7 @@ fn office_fonts_have_substitutes() { "Source Code Pro", "JetBrains Mono", "Cascadia Code", + "Cascadia Code PL", "Cascadia Mono", "Fira Code", "Inconsolata", From f3d8ba85a62c72bb526dad3b91852485b1ab4f09 Mon Sep 17 00:00:00 2001 From: Madhasoeki Date: Sun, 1 Nov 2026 11:35:53 +0700 Subject: [PATCH 27/58] tests(fonts): add specific tests requested by reviewer for monospace resolution Verify Consolas, Courier New, Fira Code, Monotype Corsiva, Monoton, and ensure proportional family substitutes remain unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/fonts/src/tests.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/fonts/src/tests.rs b/crates/fonts/src/tests.rs index dc52cfbbb..596415d51 100644 --- a/crates/fonts/src/tests.rs +++ b/crates/fonts/src/tests.rs @@ -266,9 +266,24 @@ fn office_fonts_have_substitutes() { assert!(is_monospace_family(mono), "{mono} should be recognized as monospace"); } - for prop in ["Calibri", "Arial", "Times New Roman", "Inter", "Monotype Corsiva", "Monotype Baskerville", "Code Pro", "zzz"] { + for prop in ["Calibri", "Arial", "Times New Roman", "Inter", "Monotype Corsiva", "Monoton", "Monotype Baskerville", "Code Pro", "zzz"] { assert!(!is_monospace_family(prop), "{prop} should not be recognized as monospace"); } + + // Explicit checks requested in review: Consolas, Courier New, Fira Code, Monotype Corsiva, Monoton, + // and ensuring proportional families maintain their unchanged substitutes. + assert!(is_monospace_family("Consolas")); + assert!(is_monospace_family("Courier New")); + assert!(is_monospace_family("Fira Code")); + assert!(!is_monospace_family("Monotype Corsiva")); + assert!(!is_monospace_family("Monoton")); + + assert_eq!(substitutes("Calibri"), &["Carlito", "Liberation Sans", "Arial", "Inter"]); + assert_eq!(substitutes("Arial"), &["Liberation Sans", "Arial", "Helvetica Neue", "Helvetica", "Inter"]); + assert_eq!(substitutes("Times New Roman"), &["Liberation Serif", "Times New Roman", "Times", "Merriweather"]); + assert_eq!(substitutes("Georgia"), &["Merriweather", "Liberation Serif", "Georgia"]); + assert!(substitutes("Monotype Corsiva").is_empty()); + assert!(substitutes("Monoton").is_empty()); } #[test] From c37b1509502cfcbc70c678adcde6f6c4cdab9ec5 Mon Sep 17 00:00:00 2001 From: Naseq Date: Sun, 1 Nov 2026 11:17:08 +0430 Subject: [PATCH 28/58] PPTX: parse each tags part once, cap copied tags, and bound cache (#1164) - Parse each tags part once per import and cache in `Imp::tags_by_part` - Cap total copied tags into the model at `MAX_TAG_BYTES` (64 MiB), charging each pair a fixed size_of::<(String, String)>() in addition to text - Bound the cache: once `tags_capped` is set, return early before parsing or caching any subsequent part - Tests covering deduplicated part copies, empty-tag pair budgets, and verifying unread tag parts are not parsed or cached after the budget is reached --- crates/pptx/src/lib.rs | 1 + crates/pptx/src/read.rs | 72 +++++++++++++++++++++-- crates/pptx/tests/it/tags.rs | 108 +++++++++++++++++++++++++++++++++++ 3 files changed, 175 insertions(+), 6 deletions(-) diff --git a/crates/pptx/src/lib.rs b/crates/pptx/src/lib.rs index 1b1dc5b46..be08c680f 100644 --- a/crates/pptx/src/lib.rs +++ b/crates/pptx/src/lib.rs @@ -14,6 +14,7 @@ mod maps; mod opc; mod png; pub(crate) mod read; +pub use read::MAX_TAG_BYTES; mod tables; mod write; mod xml; diff --git a/crates/pptx/src/read.rs b/crates/pptx/src/read.rs index 917aa9277..288e497f8 100644 --- a/crates/pptx/src/read.rs +++ b/crates/pptx/src/read.rs @@ -40,6 +40,12 @@ pub struct Imp<'a> { pub kept_sidecar_bytes: usize, /// Cached kept sidecars by normalized chart part name. pub kept_charts: HashMap, + /// Add-in tags parts, parsed once each, by normalized part name. + pub tags_by_part: HashMap>, + /// Bytes of tags copied into the model so far (see [`MAX_TAG_BYTES`]). + pub tag_bytes: usize, + /// The tag budget ran out and the warning was given. + pub tags_capped: bool, } impl Imp<'_> { @@ -196,16 +202,64 @@ fn choice_has_model3d(el: &El, rels: &Rels) -> bool { rec(el, rels, 0) } +/// Bytes of tags one import copies into the model, every tags part and reference together: a +/// small compressed tags part listed by many shapes, or many times by one, must not grow without +/// bound (#1164). The package budgets (`opc::MAX_XML_PART`, `MAX_TOTAL`, #1283) bound what is +/// parsed; this bounds what is copied from it. +pub const MAX_TAG_BYTES: usize = 64 << 20; +/// What each copied pair costs besides its text, so empty pairs count too. +const TAG_PAIR_COST: usize = std::mem::size_of::<(String, String)>(); + /// Add-in tags named by `holder`'s `p:custDataLst`: the name/value pairs of each tags part. -pub fn tags(imp: &Imp, part: &Part, holder: &El) -> Vec<(String, String)> { +/// Each part is parsed once per import and counted once per holder. Once [`MAX_TAG_BYTES`] is +/// reached, no further tag parts are parsed or cached (#1164). +pub fn tags(imp: &mut Imp, part: &Part, holder: &El) -> Vec<(String, String)> { + if imp.tags_capped { + return vec![]; + } let Some(list) = holder.child("custDataLst") else { return vec![] }; let mut out = Vec::new(); + let mut seen = HashSet::new(); for t in list.children_named("tags").take(100) { - let Some(doc) = t.attr("r:id").and_then(|id| part.rels.get(id)).and_then(|r| imp.pkg.xml(&r.target)) else { continue }; - for tag in doc.root.children_named("tag").take(10_000) { - if let Some(name) = tag.attr("name") { - out.push((name.to_string(), tag.attr("val").unwrap_or("").to_string())); + if imp.tags_capped { + return out; + } + let Some(target) = t.attr("r:id").and_then(|id| part.rels.get(id)).map(|r| opc::norm(&r.target)) else { continue }; + if !seen.insert(target.clone()) { + continue; + } + let parsed = match imp.tags_by_part.get(&target) { + Some(p) => p.clone(), + None => { + if imp.tags_capped { + return out; + } + let p: Arc<[(String, String)]> = imp + .pkg + .xml(&target) + .map(|doc| { + doc.root + .children_named("tag") + .take(10_000) + .filter_map(|tag| Some((tag.attr("name")?.to_string(), tag.attr("val").unwrap_or("").to_string()))) + .collect() + }) + .unwrap_or_default(); + imp.tags_by_part.insert(target, p.clone()); + p } + }; + for (name, val) in parsed.iter() { + let n = imp.tag_bytes.saturating_add(TAG_PAIR_COST + name.len() + val.len()); + if n > MAX_TAG_BYTES { + if !imp.tags_capped { + log::warn!("pptx: add-in tags over {MAX_TAG_BYTES} bytes; the rest are dropped"); + imp.tags_capped = true; + } + return out; + } + imp.tag_bytes = n; + out.push((name.clone(), val.clone())); } } out @@ -389,6 +443,9 @@ pub fn import_theme(bytes: &[u8]) -> Result<(deckcraft_model::theme::Theme, Vec< vml_previews: HashMap::new(), kept_sidecar_bytes: 0, kept_charts: HashMap::new(), + tags_by_part: HashMap::new(), + tag_bytes: 0, + tags_capped: false, }; let part = Part::new(&pkg, &theme_path, &doc); let theme = theme::read_theme(&mut imp, &part, &doc.root); @@ -427,6 +484,9 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec), PptxE vml_previews: HashMap::new(), kept_sidecar_bytes: 0, kept_charts: HashMap::new(), + tags_by_part: HashMap::new(), + tag_bytes: 0, + tags_capped: false, }; // Slide ids first so hyperlinks anywhere can point at slides. let slide_ids = register_slides(&mut imp, &docs, &slide_entries, &mut lost_slides); @@ -441,7 +501,7 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec), PptxE let layout_by_part = read_masters(&mut imp, &mut p, &master_paths, &docs); let file_slide_ids = read_slides(&mut imp, &mut p, &pres_part, &docs, &slide_ids, &layout_by_part); read_sections(&mut p, &pres_part, pres_el, &file_slide_ids); - p.tags = tags(&imp, &pres_part, pres_el); + p.tags = tags(&mut imp, &pres_part, pres_el); read_custom_shows(&mut p, &imp, &pres_part, pres_el); read_guides_and_show(&mut p, &imp, &pres_part); read_notes_master(&mut imp, &mut p, &pres_part); diff --git a/crates/pptx/tests/it/tags.rs b/crates/pptx/tests/it/tags.rs index e8903b442..8cc0849be 100644 --- a/crates/pptx/tests/it/tags.rs +++ b/crates/pptx/tests/it/tags.rs @@ -36,3 +36,111 @@ fn tags_survive_round_trip() { assert_eq!(first.tags, vec![t("THINKCELLSHAPEDONOTDELETE", "t!abc")]); assert!(back.slides.iter().skip(1).all(|s| s.tags.is_empty())); } + +/// #1164: the same tags part listed over and over is read once, and the tags one import copies +/// into the model are capped however often a shape lists them. +#[test] +fn a_tags_part_listed_many_times_is_copied_once_and_capped() { + let mut s = Session::new(); + deckcraft_engine::sample::open_sample(&mut s).expect("sample"); + for _ in 0..6 { + s.execute("shape.insert", &serde_json::json!({"preset": "rect", "rect": [10, 10, 50, 50]})).expect("shape"); + } + let mut p = s.doc().expect("doc").doc.as_ref().clone(); + let slide = Arc::make_mut(&mut p.slides[0]); + // A quarter of the budget with its name and pair cost: the fifth shape listing it goes over. + let pair = std::mem::size_of::<(String, String)>(); + slide.shapes[0].tags = vec![t("BIG", &"x".repeat(deckcraft_pptx::MAX_TAG_BYTES / 4 - 3 - pair))]; + let bytes = deckcraft_pptx::export(&p).expect("export"); + + // Every shape on the first slide lists that one tags part 100 times. + let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); + let mut xml = String::new(); + zip.by_name("ppt/slides/slide1.xml").expect("slide").read_to_string(&mut xml).expect("read"); + let start = xml.find("").expect("tags listed"); + let end = xml[start..].find("").expect("list ends") + start + "".len(); + let list = &xml[start..end]; + let inner = list.trim_start_matches("").trim_end_matches(""); + let many = format!("{}", inner.repeat(100)); + let xml = xml.replace(list, "").replace("", "").replace("", &format!("{many}")); + assert!(xml.matches(&many).count() >= 5, "enough shapes to go over the budget"); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + for i in 0..zip.len() { + let mut f = zip.by_index(i).expect("entry"); + let name = f.name().to_string(); + let mut data = vec![]; + f.read_to_end(&mut data).expect("read"); + out.start_file(&name, zip::write::SimpleFileOptions::default()).expect("start"); + std::io::Write::write_all(&mut out, if name == "ppt/slides/slide1.xml" { xml.as_bytes() } else { &data }).expect("write"); + } + let bytes = out.finish().expect("finish").into_inner(); + + let back = deckcraft_pptx::import(&bytes).expect("import"); + let tagged: Vec<_> = back.slides[0].shapes.iter().filter(|x| !x.tags.is_empty()).collect(); + assert_eq!(tagged.len(), 4, "four copies fit the budget"); + assert!(tagged.iter().all(|x| x.tags.len() == 1), "each shape keeps the part's tag once, not 100 times"); +} + +/// #1164: empty tags cost something too, or a part of 10,000 empty pairs listed by every shape +/// would be copied without ever reaching the budget. +#[test] +fn empty_tags_count_against_the_budget() { + let pair = std::mem::size_of::<(String, String)>(); + let per_shape = 10_000 * pair; + let shapes = deckcraft_pptx::MAX_TAG_BYTES / per_shape + 2; + let mut s = Session::new(); + deckcraft_engine::sample::open_sample(&mut s).expect("sample"); + let mut p = s.doc().expect("doc").doc.as_ref().clone(); + let slide = Arc::make_mut(&mut p.slides[0]); + let template = slide.shapes[0].clone(); + slide.shapes.clear(); + for i in 0..shapes { + let mut sh = template.clone(); + sh.id = deckcraft_model::ShapeId(10_000 + i as u32); + sh.ph = None; + sh.tags = vec![t("", ""); 10_000]; + slide.shapes.push(sh); + } + let bytes = deckcraft_pptx::export(&p).expect("export"); + let back = deckcraft_pptx::import(&bytes).expect("import"); + let pairs: usize = back.slides[0].shapes.iter().map(|x| x.tags.len()).sum(); + assert!(pairs * pair <= deckcraft_pptx::MAX_TAG_BYTES, "{pairs} pairs copied"); + assert!(pairs < shapes * 10_000, "the budget stopped the copying"); +} + +/// #1164: once the budget is exhausted, further tags parts are not parsed or cached into +/// `tags_by_part`. +#[test] +fn new_tag_parts_are_not_parsed_or_cached_after_the_budget_is_exhausted() { + let mut s = Session::new(); + deckcraft_engine::sample::open_sample(&mut s).expect("sample"); + s.execute("shape.insert", &serde_json::json!({"preset": "rect", "rect": [10, 10, 50, 50]})).expect("shape"); + let mut p = s.doc().expect("doc").doc.as_ref().clone(); + let slide = Arc::make_mut(&mut p.slides[0]); + assert!(slide.shapes.len() >= 2); + // The first shape's tag fits the budget, and a second pair pushes it over the cap. + let pair = std::mem::size_of::<(String, String)>(); + slide.shapes[0].tags = vec![t("BIG", &"x".repeat(deckcraft_pptx::MAX_TAG_BYTES - 3 - pair)), t("OVER", "1")]; + // The second shape has its own separate tag part. + slide.shapes[1].tags = vec![t("SHAPE2_TAG", "dropped")]; + let bytes = deckcraft_pptx::export(&p).expect("export"); + + // Corrupt the second shape's tag part (`tag2.xml`) with invalid XML in the zip. + // If it were parsed, XML loading would fail. Because the budget was capped, it is never read. + let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + for i in 0..zip.len() { + let mut f = zip.by_index(i).expect("entry"); + let name = f.name().to_string(); + let mut data = vec![]; + f.read_to_end(&mut data).expect("read"); + out.start_file(&name, zip::write::SimpleFileOptions::default()).expect("start"); + let payload = if name == "ppt/tags/tag2.xml" { b" Date: Sun, 1 Nov 2026 11:54:38 +0430 Subject: [PATCH 29/58] Text: scale down insets when wider than the shape (#1632) When text insets exceed the shape dimensions (e.g. lIns + rIns > width), proportionally scale them down so the inner text rectangle remains bounded within the shape. Prevents off-slide helper shapes with large insets from having their text center shifted onto the slide canvas. --- crates/text/src/layout.rs | 24 ++++++++++++++++++------ crates/text/src/tests.rs | 21 +++++++++++++++++++++ 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 05cc4beb2..94a0b5f27 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -93,12 +93,24 @@ impl BoxGeom { } else { text_rect }; - let inner = Rect::new( - r.x0 + bp.inset_l.unwrap_or(7.2), - r.y0 + bp.inset_t.unwrap_or(3.6), - (r.x1 - bp.inset_r.unwrap_or(7.2)).max(r.x0 + bp.inset_l.unwrap_or(7.2)), - (r.y1 - bp.inset_b.unwrap_or(3.6)).max(r.y0 + bp.inset_t.unwrap_or(3.6)), - ); + // Insets wider than the shape scale down proportionally to fit inside it (#1632). + let (mut ins_l, mut ins_r) = (bp.inset_l.unwrap_or(7.2).max(0.0), bp.inset_r.unwrap_or(7.2).max(0.0)); + let (mut ins_t, mut ins_b) = (bp.inset_t.unwrap_or(3.6).max(0.0), bp.inset_b.unwrap_or(3.6).max(0.0)); + let w = r.width().max(0.0); + let total_x = ins_l + ins_r; + if total_x > w && total_x > 0.0 { + let s = w / total_x; + ins_l *= s; + ins_r *= s; + } + let h = r.height().max(0.0); + let total_y = ins_t + ins_b; + if total_y > h && total_y > 0.0 { + let s = h / total_y; + ins_t *= s; + ins_b *= s; + } + let inner = Rect::new(r.x0 + ins_l, r.y0 + ins_t, (r.x1 - ins_r).max(r.x0 + ins_l), (r.y1 - ins_b).max(r.y0 + ins_t)); // Text that follows a WordArt arch or circle is set on one line per paragraph. let follow = bp.warp.as_deref().is_some_and(deckcraft_geom::warp::is_follow); let wrap = bp.wrap.unwrap_or(true) && !follow && !matches!(bp.autofit, Some(AutoFit::Shape) if shape.text_box && !bp.wrap.unwrap_or(true)); diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index f9f3f3101..a5d3b3248 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1928,3 +1928,24 @@ fn a_paragraph_with_its_own_start_at_restarts_the_count_there() { // A later paragraph with a new start begins a fresh run. check(&[1, 1, 7, 7], &[1, 2, 7, 8]); } + +/// #1632: when insets are wider than the shape, they scale down proportionally so the text +/// rectangle stays inside the shape bounds and unwrapped text does not project outside it. +#[test] +fn insets_wider_than_the_shape_scale_down_within_the_shape_bounds() { + let (p, mut sh) = setup("Collaboration Hub", 14.17, 14.17); + sh.ph = None; + let bp = &mut sh.text.as_mut().unwrap().body; + bp.wrap = Some(false); + bp.inset_l = Some(127.5); + bp.inset_r = Some(11.33); + bp.inset_t = Some(11.33); + bp.inset_b = Some(11.33); + sh.text.as_mut().unwrap().paragraphs[0].props = deckcraft_model::text::ParaProps { align: Some(Align::Center), ..Default::default() }; + let l = lay(&p, &sh, 14.17, 14.17); + assert_eq!(l.lines.len(), 1); + let line = &l.lines[0]; + let mid = (line.caret_x[0] + *line.caret_x.last().unwrap()) / 2.0; + // The text center must remain inside the 14.17 pt wide shape, not pushed ~127.5 pt to the right. + assert!((0.0..=14.17).contains(&mid), "line center {mid} should be within [0, 14.17]"); +} From b5033b6213edfd91fd3558ff757d1320981bc2b2 Mon Sep 17 00:00:00 2001 From: Naseq Date: Sun, 1 Nov 2026 13:38:14 +0430 Subject: [PATCH 30/58] Text: preserve signed insets and scale only when both are non-negative Address review finding: keep signed insets unchanged when their sum fits, scaling opposing insets proportionally only when both in the pair are non-negative and their sum exceeds the box dimension. --- crates/text/src/layout.rs | 8 ++++---- crates/text/src/tests.rs | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 94a0b5f27..c0422815e 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -94,18 +94,18 @@ impl BoxGeom { text_rect }; // Insets wider than the shape scale down proportionally to fit inside it (#1632). - let (mut ins_l, mut ins_r) = (bp.inset_l.unwrap_or(7.2).max(0.0), bp.inset_r.unwrap_or(7.2).max(0.0)); - let (mut ins_t, mut ins_b) = (bp.inset_t.unwrap_or(3.6).max(0.0), bp.inset_b.unwrap_or(3.6).max(0.0)); + let (mut ins_l, mut ins_r) = (bp.inset_l.unwrap_or(7.2), bp.inset_r.unwrap_or(7.2)); + let (mut ins_t, mut ins_b) = (bp.inset_t.unwrap_or(3.6), bp.inset_b.unwrap_or(3.6)); let w = r.width().max(0.0); let total_x = ins_l + ins_r; - if total_x > w && total_x > 0.0 { + if ins_l >= 0.0 && ins_r >= 0.0 && total_x > w && total_x > 0.0 { let s = w / total_x; ins_l *= s; ins_r *= s; } let h = r.height().max(0.0); let total_y = ins_t + ins_b; - if total_y > h && total_y > 0.0 { + if ins_t >= 0.0 && ins_b >= 0.0 && total_y > h && total_y > 0.0 { let s = h / total_y; ins_t *= s; ins_b *= s; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index a5d3b3248..cb1be0e2e 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1949,3 +1949,18 @@ fn insets_wider_than_the_shape_scale_down_within_the_shape_bounds() { // The text center must remain inside the 14.17 pt wide shape, not pushed ~127.5 pt to the right. assert!((0.0..=14.17).contains(&mid), "line center {mid} should be within [0, 14.17]"); } + +/// #1632: signed insets (e.g. negative left inset with positive right inset) are preserved when +/// their sum fits within the shape bounds. +#[test] +fn signed_insets_are_preserved_when_they_fit() { + let (p, mut sh) = setup("Text", 100.0, 50.0); + sh.ph = None; + let bp = &mut sh.text.as_mut().unwrap().body; + bp.inset_l = Some(-10.0); + bp.inset_r = Some(10.0); + sh.text.as_mut().unwrap().paragraphs[0].props = deckcraft_model::text::ParaProps { align: Some(Align::Left), ..Default::default() }; + let l = lay(&p, &sh, 100.0, 50.0); + assert_eq!(l.lines.len(), 1); + assert_eq!(l.inner.x0, -10.0); +} From 7f742c9d4f4b00f60a16490d1ebc1f3e409fc60f Mon Sep 17 00:00:00 2001 From: Elliot Graham Date: Sun, 1 Nov 2026 09:29:42 +0000 Subject: [PATCH 31/58] FIX-1621: cover locked and outline-free No Fill shapes --- ROADMAP.md | 4 ++- crates/engine/src/tests.rs | 52 +++++++++++++++++++++++++++++++++- crates/engine/src/tools/hit.rs | 1 + docs/gaps.md | 3 +- docs/parity-checklist.md | 3 +- docs/ui-parity.md | 5 ++-- 6 files changed, 62 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8dc52dfb7..f5e084d3c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-10-31 · **Change:** trivial (No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-01 · **Change:** trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (progress log: read-only `slide.actions` lists shape and table text actions; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-27 · **Change:** trivial (progress log: Set Up Slide Show ▸ Keep mouse cursor visible, #1445; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-26 · **Change:** trivial (progress log: dragging several selected slides moves them all; previous: trivial, progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -148,6 +148,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-11-01 | No Fill regression coverage now includes locked shapes (outline selectable, position unchanged by dragging) and empty shapes without an outline, locked or unlocked (#1621). Existing text, outline and interior-hit tests retained | | 2026-10-31 | No Fill shape interiors allow selection and editing of underlying objects, even when a quick style remains; hit-testing uses the renderer’s fill resolution. Outlines, rotation, text targets and actual fills keep their hit behaviour (#1621) | | 2026-10-30 | Print-preview tests use the URL emitted by egui and its exact file path; previews are explicitly removed before the macOS process exits | | 2026-10-30 | `slide.actions` lists shape click and hover actions and text-run hyperlinks (including table cells and grouped shapes) for audit; listing never runs an action. The UI pane is a follow-up | @@ -319,6 +320,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-11-01 | trivial | No Fill tests cover locked outline selection without movement and click-through without an outline; fill row and parity scores unchanged (#1621) | | 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged | | 2026-10-30 | trivial | Progress log: read-only slide action inspection; parity scores unchanged | | 2026-10-29 | trivial | Progress log: going back in the slide show reverses the left slide's transition (#1531). Scores and milestone names unchanged | diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 08417b4f8..aefd3473c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5572,7 +5572,7 @@ fn edit_points_conversion_is_one_undo_step() { assert!(preset(&s), "the only step was the conversion"); } -// #1621: No Fill changes hit-testing as well as rendering, even with a quick style. +/// #1621: build overlapping objects with an ordinary quick style on the foreground shape. fn no_fill_overlap(s: &mut Session, text: bool) -> (ShapeId, ShapeId) { s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); let under = s @@ -5587,6 +5587,7 @@ fn no_fill_overlap(s: &mut Session, text: bool) -> (ShapeId, ShapeId) { (ShapeId(under as u32), ShapeId(over as u32)) } +/// No Fill passes interior clicks through while preserving outline and actual-fill targets. #[test] fn no_fill_shape_allows_selecting_and_editing_underlying_text() { let mut s = session(); @@ -5622,6 +5623,7 @@ fn no_fill_shape_allows_selecting_and_editing_underlying_text() { } } +/// Rotation and pointer tolerance must preserve both pass-through interiors and outline hits. #[test] fn no_fill_shape_hit_testing_respects_rotation_and_pointer_tolerance() { let mut s = session(); @@ -5642,6 +5644,7 @@ fn no_fill_shape_hit_testing_respects_rotation_and_pointer_tolerance() { } } +/// Style, theme and absent fills follow rendering, with explicit fill taking precedence. #[test] fn no_fill_hit_testing_resolves_style_and_theme_fills() { use std::sync::Arc; @@ -5673,6 +5676,7 @@ fn no_fill_hit_testing_resolves_style_and_theme_fills() { } } +/// Text on a foreground No Fill shape retains its selection and editing target. #[test] fn no_fill_text_shapes_keep_their_interior_text_target() { let mut s = session(); @@ -5686,6 +5690,52 @@ fn no_fill_text_shapes_keep_their_interior_text_target() { assert_eq!(text_sel(&s).unwrap().shape, over); } +/// A locked No Fill shape remains selectable on its outline, but dragging cannot move it. +#[test] +fn no_fill_locked_shape_keeps_outline_selection_without_moving() { + let mut s = session(); + let (under, over) = no_fill_overlap(&mut s, true); + s.execute("shape.fill", &json!({"none": true})).unwrap(); + s.execute("shape.lock", &json!({"locked": true})).unwrap(); + let before = s.doc().unwrap().shape(over).unwrap().xfrm; + s.execute("edit.deselect", &json!({})).unwrap(); + let inside = deckcraft_geom::Point::new(200.0, 310.0); + assert!(matches!(s.hit_test(inside, 3.0), Some(tools::Hit::Shape { id, .. }) if id == under)); + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(tev(kind, inside.x, inside.y)).unwrap(); + } + assert_eq!(text_sel(&s).unwrap().shape, under); + s.execute("edit.deselect", &json!({})).unwrap(); + let edge = deckcraft_geom::Point::new(80.0, 330.0); + assert!(matches!(s.hit_test(edge, 3.0), Some(tools::Hit::Shape { id, .. }) if id == over)); + s.pointer(tev(PointerKind::Down, edge.x, edge.y)).unwrap(); + s.pointer(tev(PointerKind::Drag, edge.x + 40.0, edge.y + 20.0)).unwrap(); + s.pointer(tev(PointerKind::Up, edge.x + 40.0, edge.y + 20.0)).unwrap(); + let st = s.doc().unwrap(); + assert_eq!(st.selection.shapes, vec![over]); + assert_eq!(st.shape(over).unwrap().xfrm, before); + assert!(st.shape(over).unwrap().locked); +} + +/// Removing both fill and outline leaves the empty interior transparent to clicks, locked or not. +#[test] +fn no_fill_shape_without_text_or_outline_passes_interior_clicks() { + for locked in [false, true] { + let mut s = session(); + let (under, _) = no_fill_overlap(&mut s, false); + s.execute("shape.fill", &json!({"none": true})).unwrap(); + s.execute("shape.line", &json!({"none": true})).unwrap(); + s.execute("shape.lock", &json!({"locked": locked})).unwrap(); + s.execute("edit.deselect", &json!({})).unwrap(); + let p = deckcraft_geom::Point::new(200.0, 310.0); + assert!(matches!(s.hit_test(p, 3.0), Some(tools::Hit::Shape { id, .. }) if id == under)); + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(tev(kind, p.x, p.y)).unwrap(); + } + assert_eq!(s.doc().unwrap().selection.shapes, vec![under]); + } +} + /// #931: while the text of a placeholder is being edited, a shape in front of it still takes a /// click (as in PowerPoint); a click on the text where nothing is in front still moves the caret. #[test] diff --git a/crates/engine/src/tools/hit.rs b/crates/engine/src/tools/hit.rs index 0e4b6ddcb..247ab1e79 100644 --- a/crates/engine/src/tools/hit.rs +++ b/crates/engine/src/tools/hit.rs @@ -216,6 +216,7 @@ pub(super) fn in_table_cell(st: &crate::DocState, id: ShapeId, p: Point) -> bool }) } +/// Resolve a shape's target at slide point `p`, using its effective fill for interior hits. pub(super) fn hit_shape(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) -> Option { let x = xfrm_of(&st.doc, &st.selection, sh); let inv = x.affine().inverse(); diff --git a/docs/gaps.md b/docs/gaps.md index 3bc26cce7..77764f1cf 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DeckCraft falls short of PowerPoint -> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-10-31 · **Change:** trivial (No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-01 · **Change:** trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **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 @@ -368,6 +368,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-11-01 | trivial | No Fill tests cover locked outline selection without movement and click-through without an outline; fill row and parity scores unchanged (#1621) | | 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged | | 2026-10-30 | minor | Slide-show click targets use each shape’s own animation state; scale and rotation test added | | 2026-10-28 | trivial | Automatic lists now run independently of Replace text as you type; proofing gap description updated | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index c088bd018..f776f4aac 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -2,7 +2,7 @@ > **Generated:** 2026-10-14 (bullets/numbering remain D after empty-line marker review; summary by `cargo xtask parity`; rows re-scored by the 2026-10-14 community PR wave, duplicate Thesaurus, AutoCorrect, Artistic effects and Corrections rows folded; Preview in editor re-scored 2026-10-13; earlier rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4; video/animated GIF row re-scored 2026-10-11, #171) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last updated:** 2026-10-31 · Fill row re-assessed after #1621: remains D; No Fill hit-testing now follows resolved appearance. Scores unchanged. +> **Last updated:** 2026-11-01 · Locked-shape and no-outline regressions added for #1621; fill row re-assessed: remains D; No Fill hit-testing now follows resolved appearance. Scores unchanged. The summary between ` **Weighted breadth parity: 84%** over 191 features. @@ -240,4 +240,5 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Date | Change | Summary | |---|---|---| +| 2026-11-01 | trivial | No Fill tests cover locked outline selection without movement and click-through without an outline; fill row and parity scores unchanged (#1621) | | 2026-10-31 | trivial | No Fill interior hit-testing follows resolved fill (#1621); fill row remains D and summary scores are unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index fa5f9ae88..fd34a7928 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with PowerPoint -> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-10-31 · **Change:** trivial (No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-01 · **Change:** trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-26 · **Change:** trivial (dragging several selected slides moves them all; previous: trivial, Edit Points: Shift, Ctrl/Cmd or Alt/Option on a handle drag sets the point type; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: major, community PR wave 2026-10-14: 14 new dialogs (41 in all), Eyedropper, Export button, New Window, Grid Options, WordArt gallery and handles, Slide Sorter sections, Quick Access Toolbar, Format Background and Format Shape depth, rich-text clipboard; duplicated rows and headers folded; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (group fill and outline edits reach members, #1079; scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (Edit Points: Shift, Ctrl/Cmd or Alt/Option on a handle drag sets the point type; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: major, community PR wave 2026-10-14: 14 new dialogs (41 in all), Eyedropper, Export button, New Window, Grid Options, WordArt gallery and handles, Slide Sorter sections, Quick Access Toolbar, Format Background and Format Shape depth, rich-text clipboard; duplicated rows and headers folded; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -81,7 +81,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Behaviour | PowerPoint | DeckCraft | |---|---|---| -| Selection handles, rotation handle, yellow adjustment handles | yes | yes; an empty No Fill shape passes interior clicks to underlying objects while its outline and handles remain selectable. Explicit, style and theme fills resolve as rendered (#1621) | +| Selection handles, rotation handle, yellow adjustment handles | yes | yes; an empty No Fill shape passes interior clicks to underlying objects while its outline and handles remain selectable. Explicit, style and theme fills resolve as rendered; tests also cover locked outline targets and empty interiors without an outline (#1621) | | Marquee, Shift/Cmd multi-select, Tab to cycle | yes | yes | | Objects off the slide: drawn on the area around it in Normal view (full strength, master graphics too), selectable and movable there; clipped in the show, exports and thumbnails | yes | yes (#270) | | Scroll bars on the slide pane when the zoomed slide is larger than the window | yes | yes, on each axis that overflows: drag the thumb or click the track; the wheel and trackpad still pan. The vertical bar does not turn the page (#1157) | @@ -171,6 +171,7 @@ consistency is open (#73, #56). | Date | Change | Summary | |---|---|---| +| 2026-11-01 | trivial | No Fill tests cover locked outline selection without movement and click-through without an outline; fill row and parity scores unchanged (#1621) | | 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged | | 2026-10-29 | trivial | Picture Format: the Picture Styles row shows as many styles as fit beside the groups after it (at least two) and a More arrow with all 24, as the Transitions and Animations galleries do; Crop and Size are in view in the default window (#1232) | | 2026-10-26 | trivial | Thumbnail pane and Slide Sorter: dragging one of several selected slides moves the whole selection | From 9f952550b2b3b3a56b6b23e7b2b02eafcbe06e45 Mon Sep 17 00:00:00 2001 From: scks1 <126534658+scks1@users.noreply.github.com> Date: Sun, 1 Nov 2026 08:58:11 -0500 Subject: [PATCH 32/58] #1596: add shadow, glow and reflection pixel goldens --- crates/render/src/lib.rs | 3 + crates/render/src/tests_effect_holes.rs | 198 ++++++++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 crates/render/src/tests_effect_holes.rs diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 5fd011411..15b89ec57 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -94,6 +94,9 @@ mod tests; #[cfg(test)] mod tests_wild; +#[cfg(test)] +mod tests_effect_holes; + #[cfg(test)] mod mt_tests { use super::*; diff --git a/crates/render/src/tests_effect_holes.rs b/crates/render/src/tests_effect_holes.rs new file mode 100644 index 000000000..10749229d --- /dev/null +++ b/crates/render/src/tests_effect_holes.rs @@ -0,0 +1,198 @@ +//! Contributor-original pixel goldens for #1596 and the review of #1626. +//! The fixed samples cover background, hole/centre, ring, visible halos and reflected coverage. + +use super::{Fill, Image, RenderOpts, Rgba, render_slide}; +use deckcraft_model::style::{ColorRef, Effects, Glow, Line, Reflection, Shadow}; +use deckcraft_model::{Geom, Presentation, Shape, ShapeId, Xfrm}; + +const PROBES: [(u32, u32); 9] = [ + (20, 20), // Clear background. + (130, 130), // Clear donut hole; filled ellipse centre. + (130, 70), // Original ring/ellipse fill. + (130, 47), // Visible shadow/glow above the outer edge. + (130, 213), // Visible shadow/glow below the outer edge. + (130, 93), // Shadow/glow along the inner rim, inside the hole. + (130, 230), // Reflected upper ring. + (130, 300), // Reflected hole/ellipse centre. + (70, 300), // Reflected ring at the same fade as the hole. +]; + +#[derive(Clone, Copy, Debug)] +enum Effect { + Shadow, + Glow, + Reflection, +} + +fn scene(shape: &str, effect: Effect) -> Presentation { + let mut p = Presentation::default(); + let mut effects = Effects::default(); + match effect { + Effect::Shadow => { + effects.outer_shadow = Some(Shadow { + color: ColorRef::rgb(Rgba::BLACK), + blur: 8.0, + dist: 0.0, + dir: 45.0, + inner: false, + sx: 1.0, + sy: 1.0, + kx: 0.0, + ky: 0.0, + align: String::new(), + rotate_with_shape: false, + }) + } + Effect::Glow => effects.glow = Some(Glow { color: ColorRef::rgb(Rgba::rgb(255, 0, 0)), radius: 8.0 }), + Effect::Reflection => effects.reflection = Some(Reflection { blur: 0.0, start_alpha: 1.0, end_alpha: 0.0, end_pos: 1.0, dist: 10.0 }), + } + let slide = std::sync::Arc::make_mut(&mut p.slides[0]); + slide.background = Some(deckcraft_model::Background::Fill { fill: Fill::solid(ColorRef::rgb(Rgba::WHITE)) }); + slide.shapes = vec![Shape { + id: ShapeId(1596), + xfrm: Some(Xfrm::new(50.0, 50.0, 160.0, 160.0)), + geom: Geom::preset(shape), + fill: Some(Fill::solid(ColorRef::rgb(Rgba::rgb(32, 96, 224)))), + line: Some(Line::none()), + effects: Some(effects), + ..Default::default() + }]; + p +} + +fn draw(shape: &str, effect: Effect) -> Image { + let p = scene(shape, effect); + let image = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + if let Ok(dir) = std::env::var("DECKCRAFT_EFFECT_HOLES_QA") { + use std::io::Write as _; + std::fs::create_dir_all(&dir).unwrap(); + let path = std::path::Path::new(&dir).join(format!("{shape}-{effect:?}.png")); + let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(path).unwrap(); + file.write_all(&image.to_png()).unwrap(); + } + image +} + +// Fixed contributor-original RGBA goldens; tolerate one final quantization step. +// The nonwhite halo/reflection samples also fail if an effect is disabled outright. +fn assert_golden(shape: &str, effect: Effect, expected: [[u8; 4]; 9]) { + let image = draw(shape, effect); + for ((x, y), want) in PROBES.into_iter().zip(expected) { + let got = image.pixel(x, y); + assert!(got.iter().zip(want).all(|(a, b)| (*a as i32 - b as i32).abs() <= 1), "{shape} {effect:?} at {x},{y}: {got:?} vs golden {want:?}"); + } +} + +#[test] +fn donut_shadow_matches_golden() { + assert_golden( + "donut", + Effect::Shadow, + [ + [255, 255, 255, 255], + [255, 255, 255, 255], + [32, 96, 224, 255], + [214, 214, 214, 255], + [233, 233, 233, 255], + [227, 227, 227, 255], + [255, 255, 255, 255], + [255, 255, 255, 255], + [255, 255, 255, 255], + ], + ); +} + +#[test] +fn donut_glow_matches_golden() { + assert_golden( + "donut", + Effect::Glow, + [ + [255, 255, 255, 255], + [255, 255, 255, 255], + [32, 96, 224, 255], + [255, 49, 49, 255], + [255, 111, 111, 255], + [255, 96, 96, 255], + [255, 255, 255, 255], + [255, 255, 255, 255], + [255, 255, 255, 255], + ], + ); +} + +#[test] +fn donut_reflection_matches_golden() { + assert_golden( + "donut", + Effect::Reflection, + [ + [255, 255, 255, 255], + [255, 255, 255, 255], + [32, 96, 224, 255], + [255, 255, 255, 255], + [255, 255, 255, 255], + [255, 255, 255, 255], + [130, 166, 238, 255], + [255, 255, 255, 255], + [189, 208, 246, 255], + ], + ); +} + +#[test] +fn ellipse_shadow_matches_golden() { + assert_golden( + "ellipse", + Effect::Shadow, + [ + [255, 255, 255, 255], + [32, 96, 224, 255], + [32, 96, 224, 255], + [214, 214, 214, 255], + [233, 233, 233, 255], + [32, 96, 224, 255], + [255, 255, 255, 255], + [255, 255, 255, 255], + [255, 255, 255, 255], + ], + ); +} + +#[test] +fn ellipse_glow_matches_golden() { + assert_golden( + "ellipse", + Effect::Glow, + [ + [255, 255, 255, 255], + [32, 96, 224, 255], + [32, 96, 224, 255], + [255, 49, 49, 255], + [255, 111, 111, 255], + [32, 96, 224, 255], + [255, 255, 255, 255], + [255, 255, 255, 255], + [255, 255, 255, 255], + ], + ); +} + +#[test] +fn ellipse_reflection_matches_golden() { + assert_golden( + "ellipse", + Effect::Reflection, + [ + [255, 255, 255, 255], + [32, 96, 224, 255], + [32, 96, 224, 255], + [255, 255, 255, 255], + [255, 255, 255, 255], + [32, 96, 224, 255], + [130, 166, 238, 255], + [189, 208, 246, 255], + [189, 208, 246, 255], + ], + ); +} From 650c4f949786a991247b2c6744d446a25cbab573 Mon Sep 17 00:00:00 2001 From: scks1 <126534658+scks1@users.noreply.github.com> Date: Sun, 1 Nov 2026 08:58:13 -0500 Subject: [PATCH 33/58] #1597: make validation independent of installed fonts and dictionaries --- crates/pdf/src/tests.rs | 5 ++++- crates/ui-egui/src/tests.rs | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 8bdbbce93..ba5cd7d7b 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -202,7 +202,10 @@ fn unsafe_url_links_are_not_exported() { /// memory. Its naming/shaping tables still parse, but its missing head table prevents PDF /// embedding. No font bytes or vendor assets are stored as test fixtures. fn font_without_head() -> Vec { - let mut bytes = FontDb::global().face("Ubuntu", "Regular").data().to_vec(); + // An installed variable Ubuntu can expose many faces; use the bundled static + // Light face so the damaged fixture always adds exactly one face. + let bundled = FontDb::with_font_dirs(Vec::new()); + let mut bytes = bundled.face("Ubuntu", "Light").data().to_vec(); for (old, new) in [(b"Ubuntu".as_slice(), b"BadPDF".as_slice()), (b"\0U\0b\0u\0n\0t\0u".as_slice(), b"\0B\0a\0d\0P\0D\0F".as_slice())] { let mut offset = 0; while offset + old.len() <= bytes.len() { diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index 98f3b85c7..29fa755fd 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -999,7 +999,8 @@ fn the_language_dialog_marks_the_selected_text() { harness.state_mut().run("app.dialog", json!({"id": "language"})).unwrap(); // The anchored window settles over its first frames; click once it stays put. harness.run_steps(4); - harness.get_by_label("Français (France)").click(); + // Installed dictionaries append a marker to the language's accessible label. + harness.get_by_label_contains("Français (France)").click(); harness.run_steps(2); harness.get_by_label("OK").click(); harness.run_steps(2); From 14cc73101b37a7c2305ff3763a29eaf28bd72eaa Mon Sep 17 00:00:00 2001 From: SusieeTheFemboy <96988330+SusieeTheLinuxUser@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:46:05 +0100 Subject: [PATCH 34/58] H.264: cite temporal direct as 8.4.1.2.3 Co-Authored-By: Claude Opus 5.5 --- crates/h264/src/slicedec.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/h264/src/slicedec.rs b/crates/h264/src/slicedec.rs index 2ada51594..557a94d46 100644 --- a/crates/h264/src/slicedec.rs +++ b/crates/h264/src/slicedec.rs @@ -566,7 +566,7 @@ impl<'a> SliceDecoder<'a> { (refs, mvs, false) } - /// Temporal direct mvs (8.4.1.2.4) from the co-located mv and `DistScaleFactor` (clamped + /// Temporal direct mvs (8.4.1.2.3) from the co-located mv and `DistScaleFactor` (clamped /// to ±1024 in `SliceDecoder`): mvL0 = clip_mv((mvCol · dsf + 128) >> 8), mvL1 = /// clip_mv(mvL0 − mvCol), both clipped to the spec's motion-vector range −2^15..2^15−1. /// The i32 maths cannot overflow; a truncating `as i16` used to wrap mvL0 past the range, From cc00a17dfe04194d9952d41b5e052efc634c1cda Mon Sep 17 00:00:00 2001 From: Simon Koch-Sultan <126534658+scks1@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:52:54 -0500 Subject: [PATCH 35/58] #1596: let effect-hole QA images overwrite an earlier run's Co-Authored-By: Claude Opus 5.5 --- crates/render/src/tests_effect_holes.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/render/src/tests_effect_holes.rs b/crates/render/src/tests_effect_holes.rs index 10749229d..fdddf60fd 100644 --- a/crates/render/src/tests_effect_holes.rs +++ b/crates/render/src/tests_effect_holes.rs @@ -64,11 +64,9 @@ fn draw(shape: &str, effect: Effect) -> Image { let p = scene(shape, effect); let image = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); if let Ok(dir) = std::env::var("DECKCRAFT_EFFECT_HOLES_QA") { - use std::io::Write as _; + // Overwrite, so a QA directory can be reused across runs. std::fs::create_dir_all(&dir).unwrap(); - let path = std::path::Path::new(&dir).join(format!("{shape}-{effect:?}.png")); - let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(path).unwrap(); - file.write_all(&image.to_png()).unwrap(); + std::fs::write(std::path::Path::new(&dir).join(format!("{shape}-{effect:?}.png")), image.to_png()).unwrap(); } image } From b0b87c5cad6d258eb7c085dc1df7a5a51c4c5e97 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 2 Nov 2026 05:37:53 +0100 Subject: [PATCH 36/58] Fix Unit::format printing "-0.00" for tiny negative lengths Found while testing crates/geom/src/lib.rs:72-76 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/geom/src/lib.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 3be72e03f..9ef790f3d 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -71,7 +71,10 @@ impl Unit { /// `2.08"` style text for a length in points. pub fn format(self, pt: f64) -> String { let v = pt / self.points_per_unit(); - let s = format!("{v:.2}"); + let mut s = format!("{v:.2}"); + if s.starts_with('-') && s[1..].bytes().all(|b| b == b'0' || b == b'.') { + s.remove(0); + } format!("{s}{}", self.suffix()) } /// Parses `2.5`, `2.5"`, `2.5 in`, `6 cm`, `30 pt`, `40px`, `5mm` into points. A bare number uses `self`. @@ -222,6 +225,14 @@ pub fn preset_flatten(path: &BezPath, f: &mut dyn FnMut(PathEl)) { mod tests { use super::*; + #[test] + fn format_has_no_negative_zero() { + assert_eq!(Unit::Points.format(-0.001), "0.00 pt"); + assert_eq!(Unit::Inches.format(-0.001), "0.00\""); + assert!(!Unit::Centimeters.format(-0.01).starts_with('-')); + assert_eq!(Unit::Points.format(-1.5), "-1.50 pt"); + } + #[test] fn emu_roundtrip_is_exact() { for emu in [0i64, 1, 12_699, 12_700, 914_400, 12_192_000, 6_858_000, -5, 123_456_789] { From 01220df57928a6148d4779eeea9b2fbbdb737c54 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 2 Nov 2026 04:51:01 -0500 Subject: [PATCH 37/58] design.background: check the index before a picture is added to the media Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/design/slide_background.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/design/slide_background.rs b/crates/engine/src/cmd/design/slide_background.rs index 92f3ce773..494b53be8 100644 --- a/crates/engine/src/cmd/design/slide_background.rs +++ b/crates/engine/src/cmd/design/slide_background.rs @@ -7,6 +7,8 @@ pub(super) fn background(s: &mut Session, p: &Value) -> Result { if bool_or(p, "copy", false) { return copy_slide_background(s); } + // Check the index before anything is edited: a picture is added to the media first. + let index = opt_usize_param(p, "index", "design.background")?; let props = p.get("pictureProps").filter(|v| v.is_object()).cloned(); let transparency = f64_param(p, "transparency"); let reset = bool_or(p, "reset", false); @@ -62,7 +64,6 @@ pub(super) fn background(s: &mut Session, p: &Value) -> Result { )); }; let all = bool_or(p, "all", false); - let index = opt_usize_param(p, "index", "design.background")?; if matches!(op, BgOp::Adjust) { let next = { let st = s.doc()?; From 5eb0b7d2c0492b2e648140a3ff8ec7f792e55e92 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 2 Nov 2026 04:51:13 -0500 Subject: [PATCH 38/58] docs: one revision row per doc for #1621, keep the Last reviewed dates Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 +--- docs/gaps.md | 3 +-- docs/parity-checklist.md | 1 - docs/ui-parity.md | 3 +-- 4 files changed, 3 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f5e084d3c..ecf27f318 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-01 · **Change:** trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-01 · **Change:** trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (progress log: read-only `slide.actions` lists shape and table text actions; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-27 · **Change:** trivial (progress log: Set Up Slide Show ▸ Keep mouse cursor visible, #1445; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-26 · **Change:** trivial (progress log: dragging several selected slides moves them all; previous: trivial, progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -148,7 +148,6 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| -| 2026-11-01 | No Fill regression coverage now includes locked shapes (outline selectable, position unchanged by dragging) and empty shapes without an outline, locked or unlocked (#1621). Existing text, outline and interior-hit tests retained | | 2026-10-31 | No Fill shape interiors allow selection and editing of underlying objects, even when a quick style remains; hit-testing uses the renderer’s fill resolution. Outlines, rotation, text targets and actual fills keep their hit behaviour (#1621) | | 2026-10-30 | Print-preview tests use the URL emitted by egui and its exact file path; previews are explicitly removed before the macOS process exits | | 2026-10-30 | `slide.actions` lists shape click and hover actions and text-run hyperlinks (including table cells and grouped shapes) for audit; listing never runs an action. The UI pane is a follow-up | @@ -320,7 +319,6 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| -| 2026-11-01 | trivial | No Fill tests cover locked outline selection without movement and click-through without an outline; fill row and parity scores unchanged (#1621) | | 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged | | 2026-10-30 | trivial | Progress log: read-only slide action inspection; parity scores unchanged | | 2026-10-29 | trivial | Progress log: going back in the slide show reverses the left slide's transition (#1531). Scores and milestone names unchanged | diff --git a/docs/gaps.md b/docs/gaps.md index 77764f1cf..1fefa5d9d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DeckCraft falls short of PowerPoint -> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-01 · **Change:** trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-30 · **Last updated:** 2026-11-01 · **Change:** trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **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 @@ -368,7 +368,6 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| -| 2026-11-01 | trivial | No Fill tests cover locked outline selection without movement and click-through without an outline; fill row and parity scores unchanged (#1621) | | 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged | | 2026-10-30 | minor | Slide-show click targets use each shape’s own animation state; scale and rotation test added | | 2026-10-28 | trivial | Automatic lists now run independently of Replace text as you type; proofing gap description updated | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index f776f4aac..606f1228d 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -240,5 +240,4 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Date | Change | Summary | |---|---|---| -| 2026-11-01 | trivial | No Fill tests cover locked outline selection without movement and click-through without an outline; fill row and parity scores unchanged (#1621) | | 2026-10-31 | trivial | No Fill interior hit-testing follows resolved fill (#1621); fill row remains D and summary scores are unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index fd34a7928..0d4d5a649 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with PowerPoint -> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-01 · **Change:** trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-01 · **Change:** trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-26 · **Change:** trivial (dragging several selected slides moves them all; previous: trivial, Edit Points: Shift, Ctrl/Cmd or Alt/Option on a handle drag sets the point type; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: major, community PR wave 2026-10-14: 14 new dialogs (41 in all), Eyedropper, Export button, New Window, Grid Options, WordArt gallery and handles, Slide Sorter sections, Quick Access Toolbar, Format Background and Format Shape depth, rich-text clipboard; duplicated rows and headers folded; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (group fill and outline edits reach members, #1079; scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (Edit Points: Shift, Ctrl/Cmd or Alt/Option on a handle drag sets the point type; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: major, community PR wave 2026-10-14: 14 new dialogs (41 in all), Eyedropper, Export button, New Window, Grid Options, WordArt gallery and handles, Slide Sorter sections, Quick Access Toolbar, Format Background and Format Shape depth, rich-text clipboard; duplicated rows and headers folded; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -171,7 +171,6 @@ consistency is open (#73, #56). | Date | Change | Summary | |---|---|---| -| 2026-11-01 | trivial | No Fill tests cover locked outline selection without movement and click-through without an outline; fill row and parity scores unchanged (#1621) | | 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged | | 2026-10-29 | trivial | Picture Format: the Picture Styles row shows as many styles as fit beside the groups after it (at least two) and a More arrow with all 24, as the Transitions and Animations galleries do; Crop and Size are in view in the default window (#1232) | | 2026-10-26 | trivial | Thumbnail pane and Slide Sorter: dragging one of several selected slides moves the whole selection | From ffd77c7db00fb080f02b92663b6ef006e9f706ae Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 2 Nov 2026 04:53:47 -0500 Subject: [PATCH 39/58] Spell check: a mark on trailing punctuation is trimmed with it Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/spell.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/spell.rs b/crates/engine/src/spell.rs index 8927ade5b..2dda5c6f4 100644 --- a/crates/engine/src/spell.rs +++ b/crates/engine/src/spell.rs @@ -726,8 +726,14 @@ pub fn paragraph_words(p: &deckcraft_model::Paragraph, default: &str) -> Vec a && !chars[b - 1].0.is_alphanumeric() && !deckcraft_model::words::is_combining_mark(chars[b - 1].0) { - b -= 1; + // Trim back to the last letter or digit, keeping only the marks that sit on it + // (a mark on a trailing apostrophe goes with the apostrophe). + while b > a { + let k = (a..b).rev().find(|&k| !deckcraft_model::words::is_combining_mark(chars[k].0)).unwrap_or(a); + if chars[k].0.is_alphanumeric() { + break; + } + b = k; } let text: String = chars[a..b].iter().map(|c| c.0).collect(); if text.is_empty() || text.chars().any(|c| c.is_ascii_digit()) || !text.chars().any(char::is_alphabetic) { @@ -827,6 +833,13 @@ SFX D Y 1\nSFX D 0 ed/S .\n"; assert_eq!(w, ["Das", "na\u{ef}ve", "Caf\u{e9}", "und", "cafe\u{301}", "na\u{308}ive"]); } + #[test] + fn a_mark_on_trailing_punctuation_is_trimmed_with_it() { + let p = deckcraft_model::Paragraph::new("word'\u{301} cafe\u{301}' x"); + let w: Vec = paragraph_words(&p, "en-US").into_iter().map(|w| w.text).collect(); + assert_eq!(w, ["word", "cafe\u{301}", "x"]); + } + #[test] fn curly_apostrophe_matches_ascii_entry() { let d = Dictionary::parse(AFF.as_bytes(), "2\ndon't\nwork\n".as_bytes()); From ea9dad8ef988362f4a365d5cf44f1d525b00ad5b Mon Sep 17 00:00:00 2001 From: Naseq Date: Mon, 2 Nov 2026 15:04:05 +0430 Subject: [PATCH 40/58] Transitions: play transition preview from previous slide when Preview is clicked (#1654) - When Preview is clicked on the Transitions ribbon, initialize Show with start_transition_preview from the prior slide into the selected slide (or from black for slide 0). - If tr.old is unset for a non-part transition with tr.from, render the previous slide texture on the first frame so the transition blends cleanly. - Indicate active playback on the Preview button while previewing. - Regression tests for transitions from previous slide and from black on slide 0. --- crates/ui-egui/src/ribbon/transitions.rs | 13 +++++---- crates/ui-egui/src/show.rs | 21 +++++++++++++++ crates/ui-egui/src/show/tests.rs | 34 ++++++++++++++++++++++++ crates/ui-egui/src/show/ui.rs | 10 +++++++ 4 files changed, 73 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/ribbon/transitions.rs b/crates/ui-egui/src/ribbon/transitions.rs index 3f43ebd98..6986d828c 100644 --- a/crates/ui-egui/src/ribbon/transitions.rs +++ b/crates/ui-egui/src/ribbon/transitions.rs @@ -12,11 +12,14 @@ pub(super) fn transitions(app: &mut SlideApp, ui: &mut Ui) { let cur = app.session.active().and_then(|d| d.current_slide().and_then(|s| s.transition.clone())); let kind = cur.as_ref().map(|t| t.kind.clone()).unwrap_or_else(|| "none".into()); group(ui, |ui| { - if big_button(ui, Icon::Preview, "Preview", true).clicked() { - let from = app.session.active().map(|d| d.selection.slide).unwrap_or(0); - app.start_show(from, true); - if let Some(s) = app.show.as_mut() { - s.preview_only = true; + let playing = app.show.as_ref().is_some_and(|s| s.preview_only); + let (resp, _) = widgets::big_button_ex(ui, Icon::Preview, "Preview", true, false, playing); + if resp.on_hover_text("Play this slide's transition").clicked() { + let to = app.session.active().map(|d| d.selection.slide).unwrap_or(0); + app.start_show(to, true); + let now = ui.input(|i| i.time); + if let (Some(s), Some(d)) = (app.show.as_mut(), app.session.active()) { + s.start_transition_preview(&d.doc, now); } } }); diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index 1896e3be7..7bd2edf7d 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -261,6 +261,27 @@ impl Show { } } + /// Play this slide's transition from the previous slide (or black for slide 0) as a preview (#1654). + pub fn start_transition_preview(&mut self, doc: &Presentation, now: f64) { + let i = self.state.slide; + let from = i.checked_sub(1); + let (kind, option, dur) = doc + .slides + .get(i) + .and_then(|s| s.transition.as_ref()) + .map(|t| (t.kind.clone(), t.option.clone(), t.duration_ms as f64 / 1000.0)) + .unwrap_or(("none".into(), String::new(), 0.0)); + self.trans = if kind != "none" && dur > 0.0 && !doc.show.without_animation { + Some(Transition { old: None, from, morph: None, parts: None, start: now, kind, option, dur, reverse: false }) + } else { + None + }; + self.preview_only = true; + self.slide_start = now; + self.step_start = now; + self.step_end = now; + } + /// Identity of the show state a queued window-point event belongs to. pub(crate) fn input_state(&self) -> ShowInputState { let bits = |v: f32| if v.is_finite() { v.to_bits() } else { 0 }; diff --git a/crates/ui-egui/src/show/tests.rs b/crates/ui-egui/src/show/tests.rs index a1069a5bb..5e39bd18e 100644 --- a/crates/ui-egui/src/show/tests.rs +++ b/crates/ui-egui/src/show/tests.rs @@ -1238,3 +1238,37 @@ fn a_preview_of_an_edited_presentation_is_rendered_again() { let tex = harness.state().1.next_tex.as_ref().map(|(d, k, _)| (std::sync::Arc::ptr_eq(d, &edited), *k)); assert_eq!(tex, Some((true, 1)), "the preview comes from the edited presentation"); } + +/// #1654: Preview on the Transitions ribbon plays the current slide's transition from the +/// previous slide into the current slide, and exits when finished. +#[test] +fn transition_preview_plays_from_the_previous_slide() { + let mut s = deckcraft_engine::Session::with_new(); + s.execute("slide.new", &json!({})).unwrap(); + s.execute("transition.set", &json!({"kind": "push", "index": 1, "duration": 1.0})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let mut show = Show::new(&doc, 1, true, false); + show.start_transition_preview(&doc, 10.0); + assert!(show.preview_only); + let tr = show.trans.as_ref().expect("transition preview is playing"); + assert_eq!(tr.kind, "push"); + assert_eq!(tr.from, Some(0), "enters from slide 0 into slide 1"); + assert_eq!(tr.dur, 1.0); + assert!(!tr.reverse); + assert_eq!(tr.start, 10.0); +} + +/// #1654: Transition preview for slide 0 plays with from = None (transitioning from black). +#[test] +fn transition_preview_for_first_slide_transitions_from_black() { + let mut s = deckcraft_engine::Session::with_new(); + s.execute("transition.set", &json!({"kind": "fade", "index": 0, "duration": 500})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let mut show = Show::new(&doc, 0, true, false); + show.start_transition_preview(&doc, 5.0); + assert!(show.preview_only); + let tr = show.trans.as_ref().expect("fade transition preview is playing"); + assert_eq!(tr.kind, "fade"); + assert_eq!(tr.from, None, "slide 0 transitions from black (None)"); + assert_eq!(tr.dur, 0.5); +} diff --git a/crates/ui-egui/src/show/ui.rs b/crates/ui-egui/src/show/ui.rs index 71107f94f..321f8116d 100644 --- a/crates/ui-egui/src/show/ui.rs +++ b/crates/ui-egui/src/show/ui.rs @@ -388,6 +388,16 @@ fn draw_transition(show: &mut Show, f: &Fr, sl: &Sl, painter: &egui::Painter, te if tr.parts.is_none() && deckcraft_anim::uses_slide_parts(&tr.kind, &tr.option) { tr.parts = Some(part_textures(ctx, doc, tr.from, idx, size)); } + if tr.old.is_none() + && let Some(prev) = tr.from + && !deckcraft_anim::uses_slide_parts(&tr.kind, &tr.option) + { + let threads = + if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) }; + let opts = RenderOpts { scale: size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(size), threads, ..Default::default() }; + let img = deckcraft_render::render_slide(doc, prev, &opts); + tr.old = Some(crate::textures::upload(ctx, "trans-old", crate::textures::to_color_image(&img, false))); + } let aspect = doc.slide_size.width / doc.slide_size.height.max(1.0); let part = |k: usize| tr.parts.as_ref().and_then(|p| p.get(k).cloned().flatten()); let parts = [part(0), part(1), part(2), part(3)]; From 4bed9b6c370350cc9e76625e7e3a7d409631beee Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:50:54 +0100 Subject: [PATCH 41/58] Reject unsupported handout perPage values in file.export Found while testing crates/engine/src/cmd/file.rs:362 (pdf_options) with crates/pdf/src/lib.rs:364-371 (handout_boxes) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/file.rs | 8 +++++++- crates/engine/src/tests.rs | 13 +++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 0d52e6358..dc9f1a44e 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -359,7 +359,13 @@ pub fn pdf_options(p: &Value, count: usize) -> Result let mut o = deckcraft_pdf::PdfOptions { layout: deckcraft_pdf::PageLayout::Slides, ..Default::default() }; o.layout = match str_param(p, "layout").unwrap_or("slides") { "notes" => deckcraft_pdf::PageLayout::Notes, - "handouts" => deckcraft_pdf::PageLayout::Handouts { per_page: usize_param(p, "perPage").unwrap_or(6).clamp(1, 9) as u8 }, + "handouts" => { + let per_page = usize_param(p, "perPage").unwrap_or(6); + if ![1, 2, 3, 4, 6, 9].contains(&per_page) { + return Err(bad("file.export", format!("perPage {per_page} isn't one of 1, 2, 3, 4, 6, 9"))); + } + deckcraft_pdf::PageLayout::Handouts { per_page: per_page as u8 } + } _ => deckcraft_pdf::PageLayout::Slides, }; o.dpi = f64_or(p, "dpi", o.dpi).clamp(36.0, 600.0); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 68de13418..e8038c9a2 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2410,6 +2410,19 @@ fn pdf_options_take_paper_and_orientation() { assert!(cmd::file::pdf_options(&json!({"orientation": "sideways"}), 1).is_err()); } +/// Handout `perPage` accepts only 1, 2, 3, 4, 6 and 9; anything else is an error, not a 9-up page. +#[test] +fn pdf_options_reject_unsupported_per_page() { + for n in [1, 2, 3, 4, 6, 9] { + let o = cmd::file::pdf_options(&json!({"layout": "handouts", "perPage": n}), 1).unwrap(); + assert!(matches!(o.layout, deckcraft_pdf::PageLayout::Handouts { per_page } if per_page as usize == n)); + } + for n in [0, 5, 7, 8, 10] { + let e = cmd::file::pdf_options(&json!({"layout": "handouts", "perPage": n}), 1).unwrap_err(); + assert!(e.to_string().contains("1, 2, 3, 4, 6, 9"), "perPage {n}: {e}"); + } +} + #[test] fn shape_line_sets_compound_and_arrow_sizes() { // The Format Shape pane's Line page: compound type, arrowheads and their sizes. From e0992c132568a26259d686c84c2c804deae201ee Mon Sep 17 00:00:00 2001 From: Naseq Date: Mon, 2 Nov 2026 15:25:31 +0430 Subject: [PATCH 42/58] Transitions: render cached previous slide with completed animation state - Use render_frame with timeline.steps() and t = 0.0 so the previous slide is rendered in its final completed state during transition preview. - Pass duration: 1000 integer ms in test to explicitly test the requested duration. --- crates/ui-egui/src/show/tests.rs | 2 +- crates/ui-egui/src/show/ui.rs | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/show/tests.rs b/crates/ui-egui/src/show/tests.rs index 5e39bd18e..c7c34b171 100644 --- a/crates/ui-egui/src/show/tests.rs +++ b/crates/ui-egui/src/show/tests.rs @@ -1245,7 +1245,7 @@ fn a_preview_of_an_edited_presentation_is_rendered_again() { fn transition_preview_plays_from_the_previous_slide() { let mut s = deckcraft_engine::Session::with_new(); s.execute("slide.new", &json!({})).unwrap(); - s.execute("transition.set", &json!({"kind": "push", "index": 1, "duration": 1.0})).unwrap(); + s.execute("transition.set", &json!({"kind": "push", "index": 1, "duration": 1000})).unwrap(); let doc = s.active().unwrap().doc.clone(); let mut show = Show::new(&doc, 1, true, false); show.start_transition_preview(&doc, 10.0); diff --git a/crates/ui-egui/src/show/ui.rs b/crates/ui-egui/src/show/ui.rs index 321f8116d..b02a181bc 100644 --- a/crates/ui-egui/src/show/ui.rs +++ b/crates/ui-egui/src/show/ui.rs @@ -10,7 +10,7 @@ use super::zoom::{ }; use super::{ Align2, BackKey, Click, Color32, CornerRadius, Key, Layer, LinkJump, Pos2, Rect, RenderOpts, Sense, ShapeId, ShapeState, Show, ShowAction, - SlideApp, Source, Stroke, TextureHandle, Ui, json, pos2, show_fills_screen, state_for, theme, vec2, viewport_visible, + ShowState, SlideApp, Source, Stroke, TextureHandle, Ui, json, pos2, show_fills_screen, state_for, theme, vec2, viewport_visible, }; use deckcraft_anim::{StepStamp, TriggerPlay}; use deckcraft_model::Presentation; @@ -394,9 +394,10 @@ fn draw_transition(show: &mut Show, f: &Fr, sl: &Sl, painter: &egui::Painter, te { let threads = if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) }; - let opts = RenderOpts { scale: size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(size), threads, ..Default::default() }; - let img = deckcraft_render::render_slide(doc, prev, &opts); - tr.old = Some(crate::textures::upload(ctx, "trans-old", crate::textures::to_color_image(&img, false))); + let timeline = ShowState::timeline(doc, prev); + let step = timeline.steps(); + let ci = render_frame(doc, prev, timeline, step, 0.0, size, vec![], &[], threads, &[], &[], None); + tr.old = Some(crate::textures::upload(ctx, "trans-old", ci)); } let aspect = doc.slide_size.width / doc.slide_size.height.max(1.0); let part = |k: usize| tr.parts.as_ref().and_then(|p| p.get(k).cloned().flatten()); From 72ac32715ffd5c94de9e6d6b1cfb9bce7ccb65c4 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:21:37 +0100 Subject: [PATCH 43/58] Reject invalid master and layout indices in view.slideMaster Found while testing crates/engine/src/cmd/design/master_view.rs:8 (master_view; `master` read via usize_param, `layout` at :14) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/design/master_view.rs | 6 +++--- crates/engine/src/tests.rs | 11 +++++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/design/master_view.rs b/crates/engine/src/cmd/design/master_view.rs index 5dfe04964..c03bfa574 100644 --- a/crates/engine/src/cmd/design/master_view.rs +++ b/crates/engine/src/cmd/design/master_view.rs @@ -1,17 +1,17 @@ use super::{ Arc, Fill, NO_ID_SPACE, PhType, Result, Run, RunKind, Session, Shape, ShapeId, Size, Target, TextBody, Value, bad, bool_param, json, - master_index, ok, opt_usize_param, rect_param, str_param, usize_param, + master_index, ok, opt_usize_param, rect_param, str_param, }; pub(super) fn master_view(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; - let mi = usize_param(p, "master").unwrap_or_else(|| master_index(&st.doc, &st.selection)); + let mi = opt_usize_param(p, "master", "view.slideMaster")?.unwrap_or_else(|| master_index(&st.doc, &st.selection)); // A master thumbnail sends `{master}` and means the master itself. `{master, layout}` selects // that layout. With neither key, follow the current slide's layout. let layout = if p.get("master").is_some() && p.get("layout").is_none() { None } else { - usize_param(p, "layout") + opt_usize_param(p, "layout", "view.slideMaster")? .or_else(|| st.current_slide().and_then(|sl| st.doc.masters.get(mi).and_then(|m| m.layouts.iter().position(|l| l.id == sl.layout)))) }; let target = match layout { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 68de13418..140a88def 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6458,3 +6458,14 @@ fn master_body_placeholder_format_keeps_per_level_formatting() { assert_eq!((level(&s, 0).run.size, level(&s, 1).run.size), (Some(28.0), Some(24.0))); assert_eq!(shown(&s), vec![Some(28.0), Some(24.0)]); } + +#[test] +fn slide_master_view_rejects_invalid_indices() { + let mut s = Session::with_new(); + for p in [json!({"master": -1}), json!({"master": 0.5}), json!({"master": 0, "layout": -1}), json!({"master": 0, "layout": 1.5})] { + let r = s.execute("view.slideMaster", &p); + assert!(r.is_err(), "invalid index accepted for {p}: {r:?}"); + } + assert!(s.execute("view.slideMaster", &json!({})).is_ok()); + assert!(s.execute("view.slideMaster", &json!({"master": 0})).is_ok()); +} From 77cd617596da9052fb833ed65401aa0afbd4f118 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:37:19 +0100 Subject: [PATCH 44/58] Use a short temp file name in write_file for long file names Found while testing crates/engine/src/io.rs:49 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/io.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 7d21bb4b8..38f4de8d2 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -41,12 +41,13 @@ pub fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> { Ok(canonical) => canonical, Err(_) => std::path::PathBuf::from(path), }; - let target_str = target_path.to_str().unwrap_or(path); let existing_perms = std::fs::metadata(&target_path).ok().map(|m| m.permissions()); let mut attempts = 0; let (tmp, mut file) = loop { - let tmp = format!("{target_str}.{}.{}.saving", std::process::id(), COUNTER.fetch_add(1, Ordering::Relaxed)); + // A short fixed-size name: appending to the target's name could exceed the file name limit. + let tmp_name = format!(".deckcraft.{}.{}.saving", std::process::id(), COUNTER.fetch_add(1, Ordering::Relaxed)); + let tmp = target_path.with_file_name(tmp_name); match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) { Ok(f) => break (tmp, f), Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists && attempts < 16 => attempts += 1, @@ -120,4 +121,18 @@ mod tests { let _ = std::fs::remove_file(&test_file); let _ = std::fs::remove_dir(&tmp_dir); } + + #[test] + fn write_file_overwrites_file_with_name_near_the_length_limit() { + let dir = std::env::temp_dir().join("deckcraft_io_long_name_test"); + let _ = std::fs::create_dir_all(&dir); + let file = dir.join(format!("{}.pptx", "a".repeat(250))); + std::fs::write(&file, b"old").unwrap(); + let path_str = file.to_str().unwrap(); + + write_file(path_str, b"new").unwrap(); + assert_eq!(read_file(path_str).unwrap(), b"new"); + + let _ = std::fs::remove_file(&file); + } } From 62ada3ab9a00f9fc5724ded9814745b69b3d80ec Mon Sep 17 00:00:00 2001 From: Leonardo Date: Mon, 2 Nov 2026 16:29:41 -0300 Subject: [PATCH 45/58] fix(render): tiled picture fills keep their size in scaled groups and upright tiles A tiled picture fill (a:blipFill with a:tile) was sized in the shape's own units. Inside a group whose child space differs from its box (old decks write child spaces ~1800 times smaller) one tile covered hundreds of boxes, so the fill showed a single flat texel instead of the texture. PowerPoint's own PNG export of a synthetic probe deck shows the tile keeps its size, offset, anchor and mirroring in slide points whatever the group's scale, uniform or stretched unevenly, nested or flipped. The tile is now converted from points to shape units per axis (Renderer::per_point_xy, paint:: tiled_per_point), and snapped to whole device pixels per axis. Tiles with rotWithShape="0" are drawn in device pixels and were sized in pixels, ignoring the zoom (even outside groups); they are now sized to pixels first. Tests: a tile in groups of seven scales (uniform and uneven, wide and tall tile), and an upright tile in a turned shape at zoom 1 and 2. Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 2 + crates/render/src/paint.rs | 31 ++++++++-- crates/render/src/renderer.rs | 21 +++++-- crates/render/src/tests_wild.rs | 100 ++++++++++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index ecf27f318..2b472291e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -148,6 +148,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-11-02 | Tiled picture fills in groups: a tile (`a:blipFill` with `a:tile`) keeps its size, offset and anchor in slide points whatever the group's child-coordinate scale, uniform or stretched unevenly, nested or flipped, and tiles with `rotWithShape="0"` keep their size in points at any zoom, as PowerPoint draws them (measured on its own PNG export of a synthetic probe deck: every grouped case within ~3/255 of the ungrouped control's own difference from PowerPoint, which is ~13/255 from resampling). Before, a group whose child space was ~1800 times smaller than its box showed one flat texel of the texture (a Venn diagram with paper-textured circles and a hatched intersection) | | 2026-10-31 | No Fill shape interiors allow selection and editing of underlying objects, even when a quick style remains; hit-testing uses the renderer’s fill resolution. Outlines, rotation, text targets and actual fills keep their hit behaviour (#1621) | | 2026-10-30 | Print-preview tests use the URL emitted by egui and its exact file path; previews are explicitly removed before the macOS process exits | | 2026-10-30 | `slide.actions` lists shape click and hover actions and text-run hyperlinks (including table cells and grouped shapes) for audit; listing never runs an action. The UI pane is a follow-up | @@ -319,6 +320,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-11-02 | trivial | Progress log: tiled picture fills keep their size in scaled groups. Scores and milestone names unchanged | | 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged | | 2026-10-30 | trivial | Progress log: read-only slide action inspection; parity scores unchanged | | 2026-10-29 | trivial | Progress log: going back in the slide show reverses the left slide's transition (#1531). Scores and milestone names unchanged | diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index ee572fd98..3c4b1db54 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -91,7 +91,10 @@ pub fn fill_path( let on_path = m * path.clone(); ctx.set_transform(Affine::IDENTITY); ctx.push_clip_layer(&on_path); - picture(ctx, pres, &resolved(rctx, pf, ph), on_slide, Affine::IDENTITY, None); + // Drawn in device pixels, so a tile (given in shape units) is sized to pixels first. + let tiled = tiled_per_point(fill, (c[0].hypot(c[1]), c[2].hypot(c[3]))); + let upright = if let Fill::Picture(p) = tiled.as_ref() { p } else { pf }; + picture(ctx, pres, &resolved(rctx, upright, ph), on_slide, Affine::IDENTITY, None); ctx.pop_layer(); return; } @@ -226,9 +229,12 @@ pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, b } let (nw, nh) = images::natural_size(&item.data).unwrap_or((iw, ih)); // PowerPoint draws the tile at a whole number of output pixels. - let k = px_scale.max(1e-9); - let snap = |v: f64| (v * k).round().max(1.0) / k; - let (tile_w, tile_h) = (snap(nw * 0.75 * sx), snap(nh * 0.75 * sy)); + // Per axis: a group can stretch its child space unevenly, and a pixel is then a different + // number of shape units across and down. + let c = m.as_coeffs(); + let (kx, ky) = (c[0].hypot(c[1]).max(1e-9), c[2].hypot(c[3]).max(1e-9)); + let snap = |v: f64, k: f64| (v * k).round().max(1.0) / k; + let (tile_w, tile_h) = (snap(nw * 0.75 * sx, kx), snap(nh * 0.75 * sy, ky)); if !(tile_w > 1e-6 && tile_h > 1e-6 && tile_w.is_finite() && tile_h.is_finite()) { return; } @@ -260,6 +266,23 @@ pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, b draw_image(ctx, &image, iw, ih, dest, m, alpha); } +/// `fill`, with a tiled picture's tile size and offset moved from points to shape units, given +/// the shape units to the point along x and y. A group's child coordinates scale its shapes' +/// boxes (a group can stretch them unevenly), but a tile keeps its size on the slide as PowerPoint +/// draws it, so in a group with a legacy child space the tile would otherwise cover hundreds of +/// boxes and the fill show one flat texel. Anything but a tiled picture, or a unit scale, is +/// returned as it is. +pub fn tiled_per_point(fill: &Fill, per_point: (f64, f64)) -> std::borrow::Cow<'_, Fill> { + let Fill::Picture(pf) = fill else { return std::borrow::Cow::Borrowed(fill) }; + let PictureMode::Tile { tx, ty, sx, sy, flip, align } = &pf.mode else { return std::borrow::Cow::Borrowed(fill) }; + let (kx, ky) = per_point; + if !(kx.is_finite() && ky.is_finite() && kx > 1e-12 && ky > 1e-12) || ((kx - 1.0).abs() < 1e-9 && (ky - 1.0).abs() < 1e-9) { + return std::borrow::Cow::Borrowed(fill); + } + let mode = PictureMode::Tile { tx: tx * kx, ty: ty * ky, sx: sx * kx, sy: sy * ky, flip: flip.clone(), align: align.clone() }; + std::borrow::Cow::Owned(Fill::Picture(PictureFill { mode, ..pf.clone() })) +} + /// Where the first tile of a tiled picture fill goes in `bounds`: its corner, edge middle or /// centre named by `align` (`tl`, `t`, `tr`, `l`, `ctr`, `r`, `bl`, `b`, `br`) on the same point /// of the box, then moved by the offset (points). diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs index c25f6bab6..916f508e6 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -346,6 +346,14 @@ impl Renderer { if k.is_finite() && k > 1e-12 { 1.0 / k } else { 1.0 } } + /// [`Renderer::per_point`] along the shape's own x and y axes: a group can stretch its child + /// space unevenly, and a tiled fill keeps its size in points on the slide on both axes. + fn per_point_xy(&self, m: Affine, depth: usize) -> (f64, f64) { + let c = self.line_space(m, depth).1.as_coeffs(); + let inv = |s: f64| if s.is_finite() && s > 1e-12 { 1.0 / s } else { 1.0 }; + (inv(c[0].hypot(c[1])), inv(c[2].hypot(c[3]))) + } + /// The size and transform a shape's text is set in: inside a group whose child coordinates /// scale the shape, the shape's size in slide points and `m` with that scale taken out, so /// insets and font sizes stay in points; otherwise `w`, `h` and `m` themselves. @@ -541,6 +549,7 @@ impl Renderer { geo: &Geometry, (w, h): (f64, f64), m: Affine, + per_point: (f64, f64), fade: &dyn Fn(usize) -> deckcraft_geom::morph::Fade, ) { for (i, sp) in geo.paths.iter().enumerate() { @@ -566,7 +575,10 @@ impl Renderer { paint::fill_path(ctx, rctx, gf, None, &path, *gbox, *gm, sp.fill, fade(i).fill, f.pres); } } - _ => paint::fill_path(ctx, rctx, fl, fill_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, sp.fill, fade(i).fill, f.pres), + _ => { + let fl = paint::tiled_per_point(fl, per_point); + paint::fill_path(ctx, rctx, &fl, fill_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, sp.fill, fade(i).fill, f.pres) + } } ctx.set_fill_rule(peniko::Fill::NonZero); } @@ -736,6 +748,7 @@ impl Renderer { let fill = &d.fill; let outline = &d.outline; let fade = |i: usize| d.fade(i); + let pt_xy = self.per_point_xy(m, d.depth); // Fill. match &s.kind { ShapeKind::Picture { fill: pf } => { @@ -744,7 +757,7 @@ impl Renderer { if s.fill.as_ref().is_some_and(|f| !f.is_none()) && let Some(fl) = &fill { - self.fill_geometry(ctx, f, rctx, fl, fill_ph, geo, (w, h), m, &fade); + self.fill_geometry(ctx, f, rctx, fl, fill_ph, geo, (w, h), m, pt_xy, &fade); } ctx.set_transform(m); let bounds = Rect::new(0.0, 0.0, w, h); @@ -761,7 +774,7 @@ impl Renderer { if s.fill.as_ref().is_some_and(|f| !f.is_none()) && let Some(fl) = &fill { - self.fill_geometry(ctx, f, rctx, fl, fill_ph, geo, (w, h), m, &fade); + self.fill_geometry(ctx, f, rctx, fl, fill_ph, geo, (w, h), m, pt_xy, &fade); } ctx.set_transform(m); let pf = deckcraft_model::style::PictureFill { media: *media, ..Default::default() }; @@ -787,7 +800,7 @@ impl Renderer { if let Some(fl) = &fill && !(empty_ph && f.opts.edit && s.fill.is_none() && s.line.is_none() && s.style.is_none()) { - self.fill_geometry(ctx, f, rctx, fl, fill_ph, geo, (w, h), m, &fade); + self.fill_geometry(ctx, f, rctx, fl, fill_ph, geo, (w, h), m, pt_xy, &fade); } } } diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index ae78f64b1..96a546f0c 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -454,6 +454,106 @@ fn tiled_pictures_flip_align_and_skip_non_positive_scales() { assert!(close(img.pixel(115, 115), WHITE, 5) && close(img.pixel(300, 150), WHITE, 5)); } +#[allow(clippy::unwrap_used)] +fn red_over_blue() -> Vec { + let img = image::RgbImage::from_fn(4, 8, |_, y| if y < 4 { image::Rgb([255, 0, 0]) } else { image::Rgb([0, 0, 255]) }); + let mut out = std::io::Cursor::new(vec![]); + image::DynamicImage::ImageRgb8(img).write_to(&mut out, image::ImageFormat::Png).unwrap(); + out.into_inner() +} + +#[test] +fn a_tile_keeps_its_size_in_a_group_with_scaled_child_coordinates() { + use deckcraft_model::style::{PictureFill, PictureMode}; + // A group's child space scales its shapes' boxes, not the tile: the same tiles show whether + // the group's child space is 1600 times smaller (old decks write them so), 4 times larger, + // the slide's own or stretched unevenly (PowerPoint draws all of them alike, measured on its + // own export of a probe deck). Before, one tile covered hundreds of boxes and the fill was one + // flat texel, and an uneven group stretched the tile. + // Wide: 8x4 px = 6x3 pt, ten times = 60x30 pt tiles, red then blue across. + // Tall: 4x8 px = 3x6 pt, ten times = 30x60 pt tiles, red over blue down. + let blue = Rgba::rgb(0, 0, 255); + let scales = [(1.0, 1.0), (1600.0, 1600.0), (0.25, 0.25), (2.0, 0.5), (0.5, 2.0), (1800.0, 1.0), (1.0, 1800.0)]; + for (kx, ky) in scales { + for tall in [false, true] { + let mut p = Presentation::default(); + let png = if tall { red_over_blue() } else { half_red_half_blue() }; + let media = p.add_media("t.png", "image/png", png).expect("media"); + let mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 10.0, sy: 10.0, flip: "none".into(), align: "tl".into() }; + let member = Shape { + id: ShapeId(22), + xfrm: Some(Xfrm::new(0.0, 0.0, 240.0 / kx, 60.0 / ky)), + fill: Some(Fill::Picture(PictureFill { media, mode, ..Default::default() })), + line: no_line(), + ..Default::default() + }; + let group = Shape { + id: ShapeId(21), + xfrm: Some(Xfrm::new(100.0, 100.0, 240.0, 60.0)), + kind: ShapeKind::Group { children: vec![member], child: Xfrm::new(0.0, 0.0, 240.0 / kx, 60.0 / ky) }, + ..Default::default() + }; + std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![group]; + let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + // Along the tile's long side: first half red, second blue, then red again. + let (a, b, c) = if tall { ((115, 115), (115, 145), (115, 175)) } else { ((115, 115), (145, 115), (175, 115)) }; + let (pa, pb, pc) = (img.pixel(a.0, a.1), img.pixel(b.0, b.1), img.pixel(c.0, c.1)); + assert!( + close(pa, RED, 30) && close(pb, blue, 30) && (tall || close(pc, RED, 30)) && (!tall || close(pc, WHITE, 5) || close(pc, RED, 30)), + "child space {kx} x {ky}, tall={tall}: {pa:?} {pb:?} {pc:?}" + ); + } + } +} + +#[test] +fn an_upright_tile_in_a_turned_shape_keeps_its_size_in_points_at_any_zoom() { + use deckcraft_model::style::{PictureFill, PictureMode}; + // `rotWithShape="0"` draws the tiles on the slide's axes, in device pixels: they were sized in + // pixels, so zooming (or a group's child scale) changed their size on the slide. PowerPoint's + // export of the same shapes shows 60x30 pt tiles at every zoom. + let draw = |scale: f64, group: f64| { + let mut p = Presentation::default(); + let media = p.add_media("t.png", "image/png", half_red_half_blue()).expect("media"); + let mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 10.0, sy: 10.0, flip: "none".into(), align: "tl".into() }; + let fill = Fill::Picture(PictureFill { media, mode, upright: true, ..Default::default() }); + let shape = Shape { + id: ShapeId(22), + xfrm: Some(Xfrm { rot: 20.0, ..Xfrm::new(0.0, 0.0, 240.0 / group, 120.0 / group) }), + fill: Some(fill), + line: no_line(), + ..Default::default() + }; + let shape = if group == 1.0 { + Shape { xfrm: Some(Xfrm { rot: 20.0, ..Xfrm::new(100.0, 100.0, 240.0, 120.0) }), ..shape } + } else { + Shape { + id: ShapeId(21), + xfrm: Some(Xfrm::new(100.0, 100.0, 240.0, 120.0)), + kind: ShapeKind::Group { children: vec![shape], child: Xfrm::new(0.0, 0.0, 240.0 / group, 120.0 / group) }, + ..Default::default() + } + }; + std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![shape]; + render_slide(&p, 0, &RenderOpts { scale, ..Default::default() }) + }; + for group in [1.0, 0.25] { + let (one, two) = (draw(1.0, group), draw(2.0, group)); + let (mut same, mut total) = (0, 0); + for y in (120..220).step_by(7) { + for x in (120..320).step_by(9) { + let (a, b) = (one.pixel(x, y), two.pixel(2 * x, 2 * y)); + if close(a, WHITE, 5) && close(b, WHITE, 5) { + continue; // outside the turned rectangle + } + total += 1; + same += usize::from(close(a, Rgba::rgb(b[0], b[1], b[2]), 40)); + } + } + assert!(total > 40 && same * 10 >= total * 9, "group scale {group}: {same} of {total} samples agree between zoom 1 and 2"); + } +} + #[test] fn a_tiles_first_copy_sits_on_the_point_its_alignment_names() { let b = Rect::new(0.0, 0.0, 100.0, 50.0); From 5cb6ba8403a0d1598c59c83e9fd5c83a227a29ee Mon Sep 17 00:00:00 2001 From: Luis <43929895+Luis-0508@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:03:34 +0100 Subject: [PATCH 46/58] Comments: the pane keeps its width and resizes again (fixes #1431) A comment card's reply row reserved a fixed 70 px for its Resolve and delete buttons, which need more. The row overflowed the card, the pane grew by the overflow every frame until it stuck at its widest, and dragging its edge no longer changed it. The buttons are now laid out first, from the right, and the reply field takes the width they leave. --- crates/ui-egui/src/panels/comments.rs | 47 +++++++++++++++------------ crates/ui-egui/src/tests.rs | 44 +++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 21 deletions(-) diff --git a/crates/ui-egui/src/panels/comments.rs b/crates/ui-egui/src/panels/comments.rs index edf0e8b84..5acb71402 100644 --- a/crates/ui-egui/src/panels/comments.rs +++ b/crates/ui-egui/src/panels/comments.rs @@ -130,29 +130,34 @@ fn comment_card(app: &mut SlideApp, ui: &mut Ui, slide: usize, index: usize, c: for r in &c.replies { ui.label(egui::RichText::new(format!("↳ {}: {}", r.author, r.text)).color(t.text_dim)); } + // The buttons are laid out first, from the right, so the reply field takes exactly the + // width they leave. A fixed allowance overflowed the card and widened the pane every + // frame until it stuck at its widest (#1431). ui.horizontal(|ui| { - let id = ui.scope_id().with(("reply", slide, index)); - let mut txt: String = ui.data_mut(|d| d.get_temp(id).unwrap_or_default()); - let r = ui.add(egui::TextEdit::singleline(&mut txt).hint_text("Reply…").desired_width(ui.available_width() - 70.0)); - if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && !txt.trim().is_empty() { - focus_comment(app, slide, index); - run(app, "comment.reply", json!({"index": index, "text": txt})); - txt.clear(); - } - ui.data_mut(|d| d.insert_temp(id, txt)); - if ui.small_button(if c.resolved { "Reopen" } else { "Resolve" }).clicked() { - focus_comment(app, slide, index); - run(app, "comment.resolve", json!({"index": index, "resolved": !c.resolved})); - } - if ui.small_button("🗑").clicked() { - let _ = app.run("slide.go", json!({"index": slide})); - let _ = app.run("comment.delete", json!({"index": index})); - match app.ui.comment_focus { - Some((fs, fi)) if fs == slide && fi == index => app.ui.comment_focus = None, - Some((fs, fi)) if fs == slide && fi > index => app.ui.comment_focus = Some((fs, fi - 1)), - _ => {} + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.small_button("🗑").clicked() { + let _ = app.run("slide.go", json!({"index": slide})); + let _ = app.run("comment.delete", json!({"index": index})); + match app.ui.comment_focus { + Some((fs, fi)) if fs == slide && fi == index => app.ui.comment_focus = None, + Some((fs, fi)) if fs == slide && fi > index => app.ui.comment_focus = Some((fs, fi - 1)), + _ => {} + } } - } + if ui.small_button(if c.resolved { "Reopen" } else { "Resolve" }).clicked() { + focus_comment(app, slide, index); + run(app, "comment.resolve", json!({"index": index, "resolved": !c.resolved})); + } + let id = ui.scope_id().with(("reply", slide, index)); + let mut txt: String = ui.data_mut(|d| d.get_temp(id).unwrap_or_default()); + let r = ui.add(egui::TextEdit::singleline(&mut txt).hint_text("Reply…").desired_width(ui.available_width())); + if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && !txt.trim().is_empty() { + focus_comment(app, slide, index); + run(app, "comment.reply", json!({"index": index, "text": txt})); + txt.clear(); + } + ui.data_mut(|d| d.insert_temp(id, txt)); + }); }); }); if focused { diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index 29fa755fd..5221544a1 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -2361,3 +2361,47 @@ fn overdue_visible_logic_does_not_reanchor_a_later_hidden_show() { let _ = ctx.run_logic(&raw_at(12.0, true, false), |ctx| app.tick_hidden_show(ctx, visible_anchor + std::time::Duration::from_secs(3))); assert_eq!(app.show.as_ref().unwrap().state.slide, 1, "time from the full visible frame is preserved through the logic-only pass"); } + +/// #1431: a comment card's reply row was wider than the pane, so the Comments pane grew every +/// frame until it stuck at its widest and dragging its edge did nothing. +#[test] +fn comments_pane_keeps_its_width_and_resizes() { + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + h.run_steps(3); + h.state_mut().run("comment.add", json!({"text": "Check the figures on this slide"})).unwrap(); + h.state_mut().run("comment.add", json!({"text": "Resolved one"})).unwrap(); + h.state_mut().run("comment.resolve", json!({"index": 1, "resolved": true})).unwrap(); + h.state_mut().run("view.pane", json!({"pane": "comments"})).unwrap(); + h.run_steps(30); + let width = h.state().ui.pane_width; + assert!(width < 320.0, "the pane keeps its default width, not {width}"); + // The cards stay compact: the second one starts near the top, not below a stretched first. + let second = h.get_by_label("Resolved one").rect(); + assert!(second.top() < 450.0, "the second comment sits near the top, at {second:?}"); + + // Drag the pane's left edge: right narrows it, left widens it. + let drag = |h: &mut egui_kittest::Harness<'_, SlideApp>, dx: f32| { + let start = egui::pos2(1200.0 - h.state().ui.pane_width - 1.0, 500.0); + let end = start + egui::vec2(dx, 0.0); + h.event(egui::Event::PointerMoved(start)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: start, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + for i in 1..=6 { + h.event(egui::Event::PointerMoved(start + (end - start) * (i as f32 / 6.0))); + h.run_steps(1); + } + h.event(egui::Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(10); + h.state().ui.pane_width + }; + let narrow = drag(&mut h, 50.0); + assert!(narrow < width - 40.0, "dragging right narrows the pane: {width} -> {narrow}"); + let wide = drag(&mut h, -150.0); + assert!(wide > narrow + 140.0, "dragging left widens the pane: {narrow} -> {wide}"); +} From 9e59b5f2238f386a44c0324e3dbb8690d19c7bd3 Mon Sep 17 00:00:00 2001 From: Leonardo Date: Mon, 2 Nov 2026 17:31:10 -0300 Subject: [PATCH 47/58] fix(render): size a group's own tile and a picture shape's own tile in points too Review of #1693 found two more places that drew a tiled picture fill in the shape's own (child-space) units inside a scaled group. A group nested in a scaled group stored its own tiled fill unconverted, so the members that take it (grpFill) got oversized tiles. The owning group now converts the fill with per_point_xy when it stores it, so inheriting members, directly or through a chain of grpFill groups, get the converted tile. A picture shape (p:pic) whose blip fill is a tile passed its own fill straight to paint::picture. It now goes through paint::tiled_picture_per_point, which tiled_per_point also uses. Tests: a group's tile passed to its members and a picture shape's own tile, each in outer groups scaled 1, 0.25, 1600 and 2x0.5; both fail without the change. Docs: the Picture and texture fill checklist row is re-assessed (remains P) with its status line and revision history, and the ROADMAP progress log row mentions both cases. Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 2 +- crates/render/src/paint.rs | 14 ++++++-- crates/render/src/renderer.rs | 6 ++-- crates/render/src/tests_wild.rs | 62 +++++++++++++++++++++++++++++++++ docs/parity-checklist.md | 5 ++- 5 files changed, 82 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2b472291e..3a1889a8c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -148,7 +148,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| -| 2026-11-02 | Tiled picture fills in groups: a tile (`a:blipFill` with `a:tile`) keeps its size, offset and anchor in slide points whatever the group's child-coordinate scale, uniform or stretched unevenly, nested or flipped, and tiles with `rotWithShape="0"` keep their size in points at any zoom, as PowerPoint draws them (measured on its own PNG export of a synthetic probe deck: every grouped case within ~3/255 of the ungrouped control's own difference from PowerPoint, which is ~13/255 from resampling). Before, a group whose child space was ~1800 times smaller than its box showed one flat texel of the texture (a Venn diagram with paper-textured circles and a hatched intersection) | +| 2026-11-02 | Tiled picture fills in groups: a tile (`a:blipFill` with `a:tile`) keeps its size, offset and anchor in slide points whatever the group's child-coordinate scale, uniform or stretched unevenly, nested or flipped (also a group's own tile fill passed to its members, and a picture shape's own tile), and tiles with `rotWithShape="0"` keep their size in points at any zoom, as PowerPoint draws them (measured on its own PNG export of a synthetic probe deck: every grouped case within ~3/255 of the ungrouped control's own difference from PowerPoint, which is ~13/255 from resampling). Before, a group whose child space was ~1800 times smaller than its box showed one flat texel of the texture (a Venn diagram with paper-textured circles and a hatched intersection) | | 2026-10-31 | No Fill shape interiors allow selection and editing of underlying objects, even when a quick style remains; hit-testing uses the renderer’s fill resolution. Outlines, rotation, text targets and actual fills keep their hit behaviour (#1621) | | 2026-10-30 | Print-preview tests use the URL emitted by egui and its exact file path; previews are explicitly removed before the macOS process exits | | 2026-10-30 | `slide.actions` lists shape click and hover actions and text-run hyperlinks (including table cells and grouped shapes) for audit; listing never runs an action. The UI pane is a follow-up | diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index 3c4b1db54..74d111236 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -274,13 +274,21 @@ pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, b /// returned as it is. pub fn tiled_per_point(fill: &Fill, per_point: (f64, f64)) -> std::borrow::Cow<'_, Fill> { let Fill::Picture(pf) = fill else { return std::borrow::Cow::Borrowed(fill) }; - let PictureMode::Tile { tx, ty, sx, sy, flip, align } = &pf.mode else { return std::borrow::Cow::Borrowed(fill) }; + match tiled_picture_per_point(pf, per_point) { + std::borrow::Cow::Borrowed(_) => std::borrow::Cow::Borrowed(fill), + std::borrow::Cow::Owned(p) => std::borrow::Cow::Owned(Fill::Picture(p)), + } +} + +/// [`tiled_per_point`] for a picture fill itself (a picture shape's own fill is not a [`Fill`]). +pub fn tiled_picture_per_point(pf: &PictureFill, per_point: (f64, f64)) -> std::borrow::Cow<'_, PictureFill> { + let PictureMode::Tile { tx, ty, sx, sy, flip, align } = &pf.mode else { return std::borrow::Cow::Borrowed(pf) }; let (kx, ky) = per_point; if !(kx.is_finite() && ky.is_finite() && kx > 1e-12 && ky > 1e-12) || ((kx - 1.0).abs() < 1e-9 && (ky - 1.0).abs() < 1e-9) { - return std::borrow::Cow::Borrowed(fill); + return std::borrow::Cow::Borrowed(pf); } let mode = PictureMode::Tile { tx: tx * kx, ty: ty * ky, sx: sx * kx, sy: sy * ky, flip: flip.clone(), align: align.clone() }; - std::borrow::Cow::Owned(Fill::Picture(PictureFill { mode, ..pf.clone() })) + std::borrow::Cow::Owned(PictureFill { mode, ..pf.clone() }) } /// Where the first tile of a tiled picture fill goes in `bounds`: its corner, edge middle or diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs index 916f508e6..699a938f2 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -436,7 +436,8 @@ impl Renderer { // A group's own fill (a group filled with its parent's passes that on). let own = match &s.fill { Some(Fill::Group) | None => self.group_fill.clone(), - Some(fl) => Some((fl.clone(), m, Rect::new(0.0, 0.0, x.w, x.h))), + // A tile is given in points; this group's own space may be scaled by its parents. + Some(fl) => Some((paint::tiled_per_point(fl, self.per_point_xy(m, depth)).into_owned(), m, Rect::new(0.0, 0.0, x.w, x.h))), }; let outer = std::mem::replace(&mut self.group_fill, own); // A group's outer shadow is cast by everything in it, as PowerPoint draws it: the @@ -763,7 +764,8 @@ impl Renderer { let bounds = Rect::new(0.0, 0.0, w, h); ctx.push_clip_layer(outline); let t = f.opts.gif_times.iter().find(|g| g.0 == s.id).map(|g| g.1); - paint::picture(ctx, f.pres, &paint::resolved(rctx, pf, None), bounds, m, t); + let pf = paint::tiled_picture_per_point(pf, pt_xy); + paint::picture(ctx, f.pres, &paint::resolved(rctx, &pf, None), bounds, m, t); ctx.pop_layer(); } // An object DeckCraft doesn't model (an embedded OLE object, an unknown graphic): its diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index 96a546f0c..c81e481c5 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -506,6 +506,68 @@ fn a_tile_keeps_its_size_in_a_group_with_scaled_child_coordinates() { } } +/// A slide with `shape` (built for a box of `w` x `h` child units) in a group at (100, 100) that is +/// 240 x 60 pt on the slide and scaled `kx` x `ky` against its child space, drawn at 1 px per point. +fn in_a_scaled_group(kx: f64, ky: f64, p: Presentation, shape: impl FnOnce(f64, f64) -> Shape) -> Image { + let (w, h) = (240.0 / kx, 60.0 / ky); + let group = Shape { + id: ShapeId(21), + xfrm: Some(Xfrm::new(100.0, 100.0, 240.0, 60.0)), + kind: ShapeKind::Group { children: vec![shape(w, h)], child: Xfrm::new(0.0, 0.0, w, h) }, + ..Default::default() + }; + let mut p = p; + std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![group]; + render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }) +} + +#[test] +fn a_tile_a_group_passes_to_its_members_keeps_its_size_inside_a_scaled_group() { + use deckcraft_model::style::{PictureFill, PictureMode}; + // The group that owns the tiled fill sits inside another group whose child space is scaled: the + // members that take it (`grpFill`) get tiles of 60 x 30 pt, as with no outer group. + let blue = Rgba::rgb(0, 0, 255); + for (kx, ky) in [(1.0, 1.0), (0.25, 0.25), (1600.0, 1600.0), (2.0, 0.5)] { + let mut p = Presentation::default(); + let media = p.add_media("t.png", "image/png", half_red_half_blue()).expect("media"); + let mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 10.0, sy: 10.0, flip: "none".into(), align: "tl".into() }; + let img = in_a_scaled_group(kx, ky, p, |w, h| { + let member = + Shape { id: ShapeId(23), xfrm: Some(Xfrm::new(0.0, 0.0, w, h)), fill: Some(Fill::Group), line: no_line(), ..Default::default() }; + Shape { + id: ShapeId(22), + xfrm: Some(Xfrm::new(0.0, 0.0, w, h)), + fill: Some(Fill::Picture(PictureFill { media, mode, ..Default::default() })), + kind: ShapeKind::Group { children: vec![member], child: Xfrm::new(0.0, 0.0, w, h) }, + ..Default::default() + } + }); + let (a, b, c) = (img.pixel(115, 115), img.pixel(145, 115), img.pixel(175, 115)); + assert!(close(a, RED, 30) && close(b, blue, 30) && close(c, RED, 30), "outer group {kx} x {ky}: {a:?} {b:?} {c:?}"); + } +} + +#[test] +fn a_picture_shapes_own_tile_keeps_its_size_inside_a_scaled_group() { + use deckcraft_model::style::{PictureFill, PictureMode}; + // A picture (`p:pic`) whose blip fill is a tile rather than a stretch. + let blue = Rgba::rgb(0, 0, 255); + for (kx, ky) in [(1.0, 1.0), (0.25, 0.25), (1600.0, 1600.0), (2.0, 0.5)] { + let mut p = Presentation::default(); + let media = p.add_media("t.png", "image/png", half_red_half_blue()).expect("media"); + let mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 10.0, sy: 10.0, flip: "none".into(), align: "tl".into() }; + let img = in_a_scaled_group(kx, ky, p, |w, h| Shape { + id: ShapeId(22), + xfrm: Some(Xfrm::new(0.0, 0.0, w, h)), + kind: ShapeKind::Picture { fill: PictureFill { media, mode, ..Default::default() } }, + line: no_line(), + ..Default::default() + }); + let (a, b, c) = (img.pixel(115, 115), img.pixel(145, 115), img.pixel(175, 115)); + assert!(close(a, RED, 30) && close(b, blue, 30) && close(c, RED, 30), "group {kx} x {ky}: {a:?} {b:?} {c:?}"); + } +} + #[test] fn an_upright_tile_in_a_turned_shape_keeps_its_size_in_points_at_any_zoom() { use deckcraft_model::style::{PictureFill, PictureMode}; diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 606f1228d..8af3745ea 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -2,7 +2,9 @@ > **Generated:** 2026-10-14 (bullets/numbering remain D after empty-line marker review; summary by `cargo xtask parity`; rows re-scored by the 2026-10-14 community PR wave, duplicate Thesaurus, AutoCorrect, Artistic effects and Corrections rows folded; Preview in editor re-scored 2026-10-13; earlier rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4; video/animated GIF row re-scored 2026-10-11, #171) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last updated:** 2026-11-01 · Locked-shape and no-outline regressions added for #1621; fill row re-assessed: remains D; No Fill hit-testing now follows resolved appearance. Scores unchanged. +> **Last updated:** 2026-11-02 · Tiled picture fills keep their size in scaled groups and upright tiles (#1693); Picture and texture fill row re-assessed: remains P, since only how tiles are sized changed. Scores unchanged. +> +> **Previously (2026-11-01):** Locked-shape and no-outline regressions added for #1621; fill row re-assessed: remains D; No Fill hit-testing now follows resolved appearance. Scores unchanged. The summary between ` **Weighted breadth parity: 84%** over 191 features. @@ -240,4 +242,5 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Date | Change | Summary | |---|---|---| +| 2026-11-02 | trivial | Tiled picture fills keep their size in scaled groups and upright tiles (#1693); Picture and texture fill row remains P and summary scores are unchanged | | 2026-10-31 | trivial | No Fill interior hit-testing follows resolved fill (#1621); fill row remains D and summary scores are unchanged | From e90e7f6c03afbf7a3694e88521aff6be5df53ba0 Mon Sep 17 00:00:00 2001 From: Leonardo Date: Mon, 2 Nov 2026 17:46:33 -0300 Subject: [PATCH 48/58] fix(render): size the tile of a picture's shadow and reflection in points too Review of #1693 found that cast_effects built the picture content that a shadow, a glow and a reflection are drawn from without converting a tiled fill to points, so inside a scaled group the reflection of a tiled picture showed child-space tiles while its face showed the right ones. The content now goes through paint::tiled_picture_per_point with per_point_xy, as the face does. Test: a tiled picture's reflection in groups scaled 0.25, 1600 and 2x0.5 shows the same colours as in an unscaled group; it fails without the change. Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 2 +- crates/render/src/renderer.rs | 8 ++++++-- crates/render/src/tests_wild.rs | 30 ++++++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3a1889a8c..050c3958b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -148,7 +148,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| -| 2026-11-02 | Tiled picture fills in groups: a tile (`a:blipFill` with `a:tile`) keeps its size, offset and anchor in slide points whatever the group's child-coordinate scale, uniform or stretched unevenly, nested or flipped (also a group's own tile fill passed to its members, and a picture shape's own tile), and tiles with `rotWithShape="0"` keep their size in points at any zoom, as PowerPoint draws them (measured on its own PNG export of a synthetic probe deck: every grouped case within ~3/255 of the ungrouped control's own difference from PowerPoint, which is ~13/255 from resampling). Before, a group whose child space was ~1800 times smaller than its box showed one flat texel of the texture (a Venn diagram with paper-textured circles and a hatched intersection) | +| 2026-11-02 | Tiled picture fills in groups: a tile (`a:blipFill` with `a:tile`) keeps its size, offset and anchor in slide points whatever the group's child-coordinate scale, uniform or stretched unevenly, nested or flipped (also a group's own tile fill passed to its members, and a picture shape's own tile, with its shadow and reflection), and tiles with `rotWithShape="0"` keep their size in points at any zoom, as PowerPoint draws them (measured on its own PNG export of a synthetic probe deck: every grouped case within ~3/255 of the ungrouped control's own difference from PowerPoint, which is ~13/255 from resampling). Before, a group whose child space was ~1800 times smaller than its box showed one flat texel of the texture (a Venn diagram with paper-textured circles and a hatched intersection) | | 2026-10-31 | No Fill shape interiors allow selection and editing of underlying objects, even when a quick style remains; hit-testing uses the renderer’s fill resolution. Outlines, rotation, text targets and actual fills keep their hit behaviour (#1621) | | 2026-10-30 | Print-preview tests use the URL emitted by egui and its exact file path; previews are explicitly removed before the macOS process exits | | 2026-10-30 | `slide.actions` lists shape click and hover actions and text-run hyperlinks (including table cells and grouped shapes) for audit; listing never runs an action. The UI pane is a follow-up | diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs index 699a938f2..e7cd5ff35 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -668,9 +668,13 @@ impl Renderer { let line = &d.line; // Effects below the shape. if let Some(e) = &effects { - // A picture (or picture fill) casts its shadow from its pixels, not its frame. + // A picture (or picture fill) casts its shadow from its pixels, not its frame. A tile + // is sized in points on the slide, as the face's is, and so is its shadow and reflection. let content = match (&s.kind, &fill) { - (ShapeKind::Picture { fill: pf }, _) | (_, Some(Fill::Picture(pf))) => Some(paint::resolved(rctx, pf, None).into_owned()), + (ShapeKind::Picture { fill: pf }, _) | (_, Some(Fill::Picture(pf))) => { + let pf = paint::tiled_picture_per_point(pf, self.per_point_xy(m, depth)); + Some(paint::resolved(rctx, &pf, None).into_owned()) + } _ => None, }; // What casts the shadow: the fill as see-through as it is, and the outline. diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index c81e481c5..987781f15 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -568,6 +568,36 @@ fn a_picture_shapes_own_tile_keeps_its_size_inside_a_scaled_group() { } } +#[test] +fn a_tiled_pictures_reflection_keeps_the_tile_size_inside_a_scaled_group() { + use deckcraft_model::style::{PictureFill, PictureMode, Reflection}; + // The reflection mirrors the picture's pixels, so its tiles must be the face's, 60 x 30 pt, + // whatever the group's child scale: just below the box it shows the same colours. + let reflected = |kx: f64, ky: f64| { + let mut p = Presentation::default(); + let media = p.add_media("t.png", "image/png", half_red_half_blue()).expect("media"); + let mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 10.0, sy: 10.0, flip: "none".into(), align: "tl".into() }; + let rf = Reflection { blur: 0.0, start_alpha: 1.0, end_alpha: 0.0, end_pos: 0.5, dist: 0.0 }; + let img = in_a_scaled_group(kx, ky, p, |w, h| Shape { + id: ShapeId(22), + xfrm: Some(Xfrm::new(0.0, 0.0, w, h)), + kind: ShapeKind::Picture { fill: PictureFill { media, mode, ..Default::default() } }, + line: no_line(), + effects: Some(deckcraft_model::Effects { reflection: Some(rf), ..Default::default() }), + ..Default::default() + }); + [img.pixel(115, 163), img.pixel(145, 163), img.pixel(175, 163)] + }; + let plain = reflected(1.0, 1.0); + assert!(plain[0][0] > plain[0][2] && plain[1][2] > plain[1][0] && plain[2][0] > plain[2][2], "red, blue, red in the reflection: {plain:?}"); + for (kx, ky) in [(0.25, 0.25), (1600.0, 1600.0), (2.0, 0.5)] { + let got = reflected(kx, ky); + for (g, e) in got.iter().zip(&plain) { + assert!(close(*g, Rgba::rgb(e[0], e[1], e[2]), 40), "child scale {kx} x {ky}: {got:?} against {plain:?}"); + } + } +} + #[test] fn an_upright_tile_in_a_turned_shape_keeps_its_size_in_points_at_any_zoom() { use deckcraft_model::style::{PictureFill, PictureMode}; From f2803102c1abb1cc65681f7a21522b3ba7a125a9 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:41:17 -0600 Subject: [PATCH 49/58] Correct animation option labels --- crates/ui-egui/src/ribbon/animations.rs | 37 +++++++++++++++++++------ 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/ribbon/animations.rs b/crates/ui-egui/src/ribbon/animations.rs index 6d35afdcb..d43801603 100644 --- a/crates/ui-egui/src/ribbon/animations.rs +++ b/crates/ui-egui/src/ribbon/animations.rs @@ -228,9 +228,12 @@ fn animation_option_label(effect: &str, option: &str) -> String { "rb" => Some("From Bottom-Right"), _ => None, }; - if matches!(effect, "fly" | "flyOut" | "wipe" | "wipeOut" | "peek" | "peekOut") + if matches!(effect, "fly" | "flyOut" | "wipe" | "wipeOut" | "peek" | "peekOut" | "crawl" | "crawlOut") && let Some(label) = direction { + if matches!(effect, "flyOut" | "peekOut" | "crawlOut") { + return label.replacen("From", "To", 1); + } return label.into(); } @@ -245,14 +248,14 @@ fn animation_option_label(effect: &str, option: &str) -> String { ("shape" | "shapeOut", o) if o.ends_with("Out") => format!("{} Out", title_first(shape_option_name(o))), ("wheel" | "wheelOut", "1") => "1 Spoke".into(), ("wheel" | "wheelOut", n) => format!("{n} Spokes"), - ("box" | "boxOut" | "circle" | "diamond", "in") => format!("{} In", title_first(effect.trim_end_matches("Out"))), - ("box" | "boxOut" | "circle" | "diamond", "out") => format!("{} Out", title_first(effect.trim_end_matches("Out"))), + ("box" | "boxOut" | "circle" | "diamond" | "diamondOut", "in") => format!("{} In", title_first(effect.trim_end_matches("Out"))), + ("box" | "boxOut" | "circle" | "diamond" | "diamondOut", "out") => format!("{} Out", title_first(effect.trim_end_matches("Out"))), ("zoom" | "zoomOut", "in") => "Zoom In".into(), ("zoom" | "zoomOut", "out") => "Zoom Out".into(), - (_, "lu") => "From Top-Left".into(), - (_, "ru") => "From Top-Right".into(), - (_, "ld") => "From Bottom-Left".into(), - (_, "rd") => "From Bottom-Right".into(), + ("strips" | "stripsOut", "lu") => "Left Up".into(), + ("strips" | "stripsOut", "ru") => "Right Up".into(), + ("strips" | "stripsOut", "ld") => "Left Down".into(), + ("strips" | "stripsOut", "rd") => "Right Down".into(), (_, "horz") => "Horizontal".into(), (_, "vert") => "Vertical".into(), (_, "across") => "Across".into(), @@ -301,11 +304,29 @@ mod animation_option_label_tests { } } assert_eq!(animation_option_label("fly", "lb"), "From Bottom-Left"); - assert_eq!(animation_option_label("flyOut", "rt"), "From Top-Right"); + assert_eq!(animation_option_label("flyOut", "rt"), "To Top-Right"); + assert_eq!(animation_option_label("flyOut", "b"), "To Bottom"); + assert_eq!(animation_option_label("peekOut", "l"), "To Left"); assert_eq!(animation_option_label("wipe", "b"), "From Bottom"); assert_eq!(animation_option_label("wheel", "1"), "1 Spoke"); assert_eq!(animation_option_label("split", "vertOut"), "Vertical Out"); assert_eq!(animation_option_label("shape", "circleIn"), "Circle In"); assert_eq!(animation_option_label("diamond", "out"), "Diamond Out"); + assert_eq!(animation_option_label("diamondOut", "in"), "Diamond In"); + assert_eq!(animation_option_label("diamondOut", "out"), "Diamond Out"); + assert_eq!(animation_option_label("strips", "lu"), "Left Up"); + assert_eq!(animation_option_label("strips", "ru"), "Right Up"); + assert_eq!(animation_option_label("strips", "ld"), "Left Down"); + assert_eq!(animation_option_label("strips", "rd"), "Right Down"); + assert_eq!(animation_option_label("crawl", "b"), "From Bottom"); + assert_eq!(animation_option_label("crawl", "l"), "From Left"); + assert_eq!(animation_option_label("crawl", "t"), "From Top"); + assert_eq!(animation_option_label("crawl", "r"), "From Right"); + assert_eq!(animation_option_label("crawlOut", "l"), "To Left"); + assert_eq!(animation_option_label("crawlOut", "r"), "To Right"); + assert_eq!(animation_option_label("plus", "in"), "In"); + assert_eq!(animation_option_label("plus", "out"), "Out"); + assert_eq!(animation_option_label("changeFontSize", "increase"), "Increase"); + assert_eq!(animation_option_label("changeFontSize", "decrease"), "Decrease"); } } From 3cfd4c5e6e14bcbda200cb962bc439878aec8d95 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:47:58 -0600 Subject: [PATCH 50/58] Parse percentage-form bullet sizes --- crates/pptx/src/read/dml.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index 372502018..300773a57 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -27,6 +27,12 @@ fn bullet_size_points(value: Option<&str>) -> Option { value.and_then(|v| v.trim().parse::().ok()).filter(|v| (100..=400_000).contains(v)).map(|v| v as f64 / 100.0) } +fn bullet_size_percent(value: Option<&str>) -> Option { + let value = value?.trim(); + let (value, scale) = if let Some(value) = value.strip_suffix('%') { (value.trim(), 100.0) } else { (value, 100_000.0) }; + value.parse::().ok().filter(|v| v.is_finite()).map(|v| v / scale) +} + /// 1/60 000 degree → degrees. pub fn ang(e: &El, name: &str) -> Option { e.f64(name).map(|v| v / 60_000.0) @@ -461,7 +467,7 @@ pub fn ppr(imp: &mut Imp, part: &Part, p: &El) -> (Option, ParaProps, Option "buSzPct" => { pp.bullet_size_points = None; pp.bullet_size_follow_text = None; - pp.bullet_size = pct(e, "val").map(|v| v.clamp(0.25, 4.0)); + pp.bullet_size = bullet_size_percent(e.attr("val")).map(|v| v.clamp(0.25, 4.0)); } "buSzPts" => { pp.bullet_size = None; @@ -716,6 +722,14 @@ mod tests { assert_eq!(bullet_size_points(Some("1400")), Some(14.0)); } + #[test] + fn bullet_percentage_size_accepts_numeric_and_percent_forms() { + assert_eq!(bullet_size_percent(Some("100000")), Some(1.0)); + assert_eq!(bullet_size_percent(Some("100%")), Some(1.0)); + assert_eq!(bullet_size_percent(Some(" 125% ")), Some(1.25)); + assert_eq!(bullet_size_percent(Some("invalid")), None); + } + #[test] fn miter_limit_accepts_both_percentage_forms_with_schema_bounds() { assert_eq!(parse_miter_limit("200000"), Some(200_000)); From f68150ee10966d71f187869a1c2e40f06aa2f047 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 2 Nov 2026 19:02:14 -0500 Subject: [PATCH 51/58] Replace the Account page with About (#1697) * UI: replace Account backstage page with About * UI: stack About columns in narrow windows; keep PowerPoint's Account in parity doc * UI: About review fixes: stack columns below 860pt, Help icon, Credits task; doc headers --- ROADMAP.md | 3 ++ crates/ui-egui/src/backstage.rs | 22 ++++++----- crates/ui-egui/src/backstage/info.rs | 54 ++++++++++++--------------- crates/ui-egui/src/backstage/tests.rs | 43 ++++++++++++++++++++- crates/ui-egui/src/lib.rs | 2 +- docs/ui-parity.md | 9 +++-- 6 files changed, 87 insertions(+), 46 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index ecf27f318..2c8c6571e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away +> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-02 · **Change:** trivial (File › About replaces Account; product links, credits and appearance retained; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-01 · **Change:** trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (progress log: read-only `slide.actions` lists shape and table text actions; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-27 · **Change:** trivial (progress log: Set Up Slide Show ▸ Keep mouse cursor visible, #1445; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -148,6 +149,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-11-02 | File › About replaces the unused Account page, with version, project links, contributor/model credits and appearance preferences | | 2026-10-31 | No Fill shape interiors allow selection and editing of underlying objects, even when a quick style remains; hit-testing uses the renderer’s fill resolution. Outlines, rotation, text targets and actual fills keep their hit behaviour (#1621) | | 2026-10-30 | Print-preview tests use the URL emitted by egui and its exact file path; previews are explicitly removed before the macOS process exits | | 2026-10-30 | `slide.actions` lists shape click and hover actions and text-run hyperlinks (including table cells and grouped shapes) for audit; listing never runs an action. The UI pane is a follow-up | @@ -319,6 +321,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-11-02 | trivial | File › About replaces Account; product links, credits and appearance retained | | 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged | | 2026-10-30 | trivial | Progress log: read-only slide action inspection; parity scores unchanged | | 2026-10-29 | trivial | Progress log: going back in the slide show reverses the left slide's transition (#1531). Scores and milestone names unchanged | diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 1278e7d8a..0fec08a52 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -2,7 +2,7 @@ //! bar until Esc or the back arrow returns to the presentation. With nothing open it is the start //! screen: PowerPoint's start screen and File › Home are the same page. //! -//! A side menu on the left picks a page (Home, New, Open, Info, Save As, Print, Export, Account); +//! A side menu on the left picks a page (Home, New, Open, Info, Save As, Print, Export, About); //! Save, Close and Options act at once. Every action runs an existing command through //! [`SlideApp::run`] or one of the app's file helpers ([`SlideApp::export_as`], //! [`SlideApp::print_pdf`], [`SlideApp::save_as_dialog`]); the state here is UI-only and never @@ -24,7 +24,7 @@ mod print; pub use files::split_path; use files::{home, new_page, open_page}; -use info::{PROPERTIES, account, commit_props, info, save_as}; +use info::{PROPERTIES, about, commit_props, info, save_as}; pub use info::{SAVE_TYPES, format_bytes}; use print::{export, print}; pub use print::{print_page_size, print_params, print_slides}; @@ -47,11 +47,11 @@ pub enum Page { SaveAs, Print, Export, - Account, + About, } impl Page { - pub const ALL: [Page; 8] = [Page::Home, Page::New, Page::Open, Page::Info, Page::SaveAs, Page::Print, Page::Export, Page::Account]; + pub const ALL: [Page; 8] = [Page::Home, Page::New, Page::Open, Page::Info, Page::SaveAs, Page::Print, Page::Export, Page::About]; /// The id `app.backstage {page}` takes. pub fn id(self) -> &'static str { @@ -63,12 +63,16 @@ impl Page { Page::SaveAs => "saveAs", Page::Print => "print", Page::Export => "export", - Page::Account => "account", + Page::About => "about", } } pub fn parse(id: &str) -> Option { let id = id.trim(); + // Keep existing control-channel callers working after the visible page rename. + if id.eq_ignore_ascii_case("account") { + return Some(Page::About); + } Page::ALL.into_iter().find(|p| p.id().eq_ignore_ascii_case(id)) } @@ -82,7 +86,7 @@ impl Page { Page::SaveAs => "Save As", Page::Print => "Print", Page::Export => "Export", - Page::Account => "Account", + Page::About => "About", } } @@ -95,7 +99,7 @@ impl Page { Page::SaveAs => Icon::SaveAs, Page::Print => Icon::Print, Page::Export => Icon::Export, - Page::Account => Icon::Account, + Page::About => Icon::Help, } } @@ -140,7 +144,7 @@ pub fn entries(has_doc: bool) -> (Vec