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/88] 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/88] 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 8c2560f31cba86def42f779b2d396d6d37253061 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:06:23 -0500 Subject: [PATCH 03/88] codec: validate configuration NAL types --- crates/h264/src/decoder.rs | 35 +++++++++++++++++++++++++++++----- crates/h264/src/synth_tests.rs | 14 ++++++++++++++ crates/hevc/src/decoder.rs | 31 ++++++++++++++++++++++++++++-- crates/hevc/src/synth_tests.rs | 15 +++++++++++++++ 4 files changed, 88 insertions(+), 7 deletions(-) diff --git a/crates/h264/src/decoder.rs b/crates/h264/src/decoder.rs index 37ef1f299..64fcb48b1 100644 --- a/crates/h264/src/decoder.rs +++ b/crates/h264/src/decoder.rs @@ -216,26 +216,37 @@ impl Decoder { pub fn configure_avcc(&mut self, avcc: &[u8]) -> Result<()> { ensure!(avcc.len() >= 7, "avcC too short"); ensure!(avcc[0] == 1, "unsupported avcC version {}", avcc[0]); - self.nal_length_size = Some((avcc[4] & 3) as usize + 1); let mut pos = 5; - let read_sets = |pos: &mut usize, count: usize, d: &mut Decoder| -> Result<()> { + let mut sets = Vec::new(); + let read_sets = |pos: &mut usize, count: usize, sets: &mut Vec<(usize, usize)>| -> Result<()> { for _ in 0..count { ensure!(*pos + 2 <= avcc.len(), "avcC truncated"); let len = u16::from_be_bytes([avcc[*pos], avcc[*pos + 1]]) as usize; *pos += 2; ensure!(*pos + len <= avcc.len(), "avcC truncated"); - d.handle_nal(&avcc[*pos..*pos + len], 0)?; + sets.push((*pos, len)); *pos += len; } Ok(()) }; let nsps = (avcc[pos] & 0x1f) as usize; pos += 1; - read_sets(&mut pos, nsps, self)?; + read_sets(&mut pos, nsps, &mut sets)?; ensure!(pos < avcc.len(), "avcC truncated"); let npps = avcc[pos] as usize; pos += 1; - read_sets(&mut pos, npps, self) + read_sets(&mut pos, npps, &mut sets)?; + for (i, (start, len)) in sets.iter().enumerate() { + let nal = &avcc[*start..*start + *len]; + let header = nal.first().copied().ok_or_else(|| Error::Invalid("empty avcC parameter set".into()))?; + let expected = if i < nsps { nal_type::SPS } else { nal_type::PPS }; + ensure!(header & 0x1f == expected, "avcC parameter set has NAL type {}, expected {expected}", header & 0x1f); + } + for (start, len) in sets { + self.handle_nal(&avcc[start..start + len], 0)?; + } + self.nal_length_size = Some((avcc[4] & 3) as usize + 1); + Ok(()) } /// Length of NAL length prefixes (from avcC), or None for Annex-B input. @@ -675,3 +686,17 @@ fn make_picture(o: &Output) -> Picture { draft: o.meta.draft, } } + +#[cfg(test)] +mod config_tests { + use super::Decoder; + + #[test] + fn avcc_rejects_picture_slices_without_starting_a_picture() { + let config = [1, 66, 0, 30, 0xff, 0xe1, 0, 2, 0x41, 0x80, 0]; + let mut decoder = Decoder::with_threads(1); + assert!(decoder.configure_avcc(&config).is_err()); + assert!(decoder.pending.is_none()); + assert_eq!(decoder.stats().pictures, 0); + } +} diff --git a/crates/h264/src/synth_tests.rs b/crates/h264/src/synth_tests.rs index a0c9da2f2..8588c5402 100644 --- a/crates/h264/src/synth_tests.rs +++ b/crates/h264/src/synth_tests.rs @@ -82,6 +82,20 @@ fn pps(cabac: bool, weighted: bool) -> Vec { nal(3, 8, &w.finish()) } +#[test] +fn avcc_accepts_generated_sps_and_pps() { + let sps = sps(66, 4, false, PocType::T2); + let pps = pps(false, false); + let (sps, pps) = (&sps[4..], &pps[4..]); + let mut avcc = vec![1, 66, 0, 30, 0xff, 0xe1]; + avcc.extend_from_slice(&(sps.len() as u16).to_be_bytes()); + avcc.extend_from_slice(sps); + avcc.push(1); + avcc.extend_from_slice(&(pps.len() as u16).to_be_bytes()); + avcc.extend_from_slice(pps); + assert!(Decoder::from_avcc(&avcc).is_ok()); +} + #[derive(Default, Clone)] struct Pic { idr: bool, diff --git a/crates/hevc/src/decoder.rs b/crates/hevc/src/decoder.rs index 4b46044da..a78600f20 100644 --- a/crates/hevc/src/decoder.rs +++ b/crates/hevc/src/decoder.rs @@ -160,11 +160,12 @@ impl Decoder { pub fn configure_hvcc(&mut self, hvcc: &[u8]) -> Result<()> { ensure!(hvcc.len() >= 23, "hvcC too short"); ensure!(hvcc[0] == 1, "unsupported hvcC version {}", hvcc[0]); - self.nal_length_size = Some((hvcc[21] & 3) as usize + 1); let num_arrays = hvcc[22] as usize; let mut pos = 23; + let mut sets = Vec::new(); for _ in 0..num_arrays { ensure!(pos + 3 <= hvcc.len(), "hvcC truncated"); + let array_type = hvcc[pos] & 0x3f; let n = u16::from_be_bytes([hvcc[pos + 1], hvcc[pos + 2]]) as usize; pos += 3; for _ in 0..n { @@ -172,10 +173,19 @@ impl Decoder { let len = u16::from_be_bytes([hvcc[pos], hvcc[pos + 1]]) as usize; pos += 2; ensure!(pos + len <= hvcc.len(), "hvcC truncated"); - self.handle_nal(&hvcc[pos..pos + len], 0)?; + sets.push((array_type, &hvcc[pos..pos + len])); pos += len; } } + for (array_type, nal) in &sets { + let header = NalHeader::parse(nal)?; + ensure!([nal_type::VPS, nal_type::SPS, nal_type::PPS].contains(array_type), "hvcC array has non-parameter-set NAL type {array_type}"); + ensure!(header.nal_type == *array_type, "hvcC array type {array_type} disagrees with NAL header type {}", header.nal_type); + } + for (_, nal) in sets { + self.handle_nal(nal, 0)?; + } + self.nal_length_size = Some((hvcc[21] & 3) as usize + 1); Ok(()) } @@ -651,3 +661,20 @@ fn make_picture(o: &Output) -> Picture { draft: o.meta.draft, } } + +#[cfg(test)] +mod config_tests { + use super::Decoder; + + #[test] + fn hvcc_rejects_picture_slices_without_starting_a_picture() { + let mut config = vec![0; 23]; + config[0] = 1; + config[22] = 1; + config.extend_from_slice(&[32, 0, 1, 0, 2, 0x02, 0x01]); + let mut decoder = Decoder::with_threads(1); + assert!(decoder.configure_hvcc(&config).is_err()); + assert!(decoder.pending.is_none()); + assert_eq!(decoder.stats().pictures, 0); + } +} diff --git a/crates/hevc/src/synth_tests.rs b/crates/hevc/src/synth_tests.rs index 4c2f1c968..52a6e177e 100644 --- a/crates/hevc/src/synth_tests.rs +++ b/crates/hevc/src/synth_tests.rs @@ -644,3 +644,18 @@ fn picture_store_is_checked_against_the_memory_budget() { assert!(!decodes_within(&stream, pictures * one - 1)); assert!(decodes_within(&stream, deckcraft_bitstream::budget::DECODE_MEMORY_BUDGET)); } + +#[test] +fn hvcc_accepts_generated_vps_sps_and_pps() { + let sets = [vps(), sps(true), pps(0, &None, false)]; + let mut hvcc = vec![0; 23]; + hvcc[0] = 1; + hvcc[22] = sets.len() as u8; + for (kind, set) in [32, 33, 34].into_iter().zip(sets) { + let nal = &set[4..]; + hvcc.extend_from_slice(&[kind, 0, 1]); + hvcc.extend_from_slice(&(nal.len() as u16).to_be_bytes()); + hvcc.extend_from_slice(nal); + } + assert!(Decoder::from_hvcc(&hvcc).is_ok()); +} From 33b6e11726206967b2172b5bf1b7612880366f54 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:48:37 -0500 Subject: [PATCH 04/88] Use private directories for print previews --- ROADMAP.md | 3 +- crates/ui-egui/src/app.rs | 3 +- crates/ui-egui/src/app/files.rs | 102 +++++++++++++++++++++++++- crates/ui-egui/src/backstage/tests.rs | 15 +++- crates/ui-egui/src/lib.rs | 1 + 5 files changed, 116 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b04c4b69f..39c1c6baf 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-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) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-28 · **Change:** trivial (print previews use unique private temporary directories and are cleaned up with the UI app; parity scores unchanged; 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: Ctrl+wheel zoom during the slide show, #1286; previous: trivial, 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: picture and embedded object fills; previous: trivial, 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-22 · **Change:** trivial (fill and outline edits reach group members, #1079; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -137,6 +137,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-28 | Print previews use unique temporary directories, owner-only on Unix, and are removed when the UI app closes; parity scores unchanged | | 2026-10-25 | Windows installer: the Start menu shortcut takes the icon embedded in `deckcraft.exe` instead of an MSI ``, so a taskbar pin keeps its icon after an update (#1114); a pin that already lost it has to be unpinned and pinned again once | | 2026-10-24 | AutoFormat as you type: a paragraph that starts with `*`, `-`, `--`, `>`, `<>`, `1.`, `1)`, `I.`, `i.`, `A.`, `a.` or `a)` and a space or tab becomes a bulleted or numbered list in that style; Backspace right after puts the typed text back, and Tools ▸ AutoCorrect and `text.autocorrect {lists}` turn it off | | 2026-10-24 | Tables: while the caret is in a cell, dragging into another cell selects that range of cells (shaded, merged cells taken in whole), and Shift+arrow grows or shrinks it; formatting applies to the selected cells only, and Delete clears their text and keeps the table | diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 8496d0ed9..3b3da5929 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -13,7 +13,7 @@ use super::{ mod clipboard; mod commands; mod control; -mod files; +pub(crate) mod files; mod frame; mod ime; mod input; @@ -77,6 +77,7 @@ impl SlideApp { integrated_titlebar: false, backstage: None, pending_urls: vec![], + print_previews: files::PrintPreviews::default(), #[cfg(not(target_arch = "wasm32"))] updates_rt: updates_ui::Runtime::default(), pending_copy: None, diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 0f3c944f7..310956a8e 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -3,6 +3,93 @@ use super::{ json, media, }; +#[derive(Default)] +pub(crate) struct PrintPreviews(Vec); + +impl PrintPreviews { + fn create(&mut self, title: &str) -> std::io::Result { + self.create_in(&std::env::temp_dir(), title) + } + + fn create_in(&mut self, base: &std::path::Path, title: &str) -> std::io::Result { + let dir = create_private_print_dir(base)?; + self.0.push(dir.clone()); + let safe: String = title.chars().map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' { c } else { '_' }).collect(); + Ok(dir.join(format!("{safe} (print).pdf"))) + } + + fn discard(&mut self, path: &std::path::Path) { + if let Some(dir) = path.parent() { + let _ = std::fs::remove_dir_all(dir); + self.0.retain(|owned| owned != dir); + } + } +} + +#[cfg(test)] +mod print_preview_tests { + use super::PrintPreviews; + use std::time::{SystemTime, UNIX_EPOCH}; + + #[test] + fn same_title_gets_distinct_private_paths_and_drop_cleans_them() { + let stamp = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default(); + let base = std::env::temp_dir().join(format!("deckcraft-print-test-{}-{stamp}", std::process::id())); + std::fs::create_dir(&base).unwrap(); + let (first, second) = { + let mut previews = PrintPreviews::default(); + let first = previews.create_in(&base, "Same title").unwrap(); + let second = previews.create_in(&base, "Same title").unwrap(); + assert_ne!(first, second); + assert_eq!(first.file_name(), second.file_name()); + assert!(first.parent().is_some_and(|dir| dir.is_dir())); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(first.parent().unwrap()).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o700); + } + (first, second) + }; + assert!(!first.parent().is_some_and(|dir| dir.exists())); + assert!(!second.parent().is_some_and(|dir| dir.exists())); + std::fs::remove_dir(base).unwrap(); + } +} + +impl Drop for PrintPreviews { + fn drop(&mut self) { + for dir in &self.0 { + let _ = std::fs::remove_dir_all(dir); + } + } +} + +fn create_private_print_dir(base: &std::path::Path) -> std::io::Result { + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT_DIR: AtomicU64 = AtomicU64::new(0); + + for _ in 0..128 { + let id = NEXT_DIR.fetch_add(1, Ordering::Relaxed); + let path = base.join(format!("deckcraft-print-{}-{id}", std::process::id())); + #[cfg(unix)] + let mut builder = std::fs::DirBuilder::new(); + #[cfg(not(unix))] + let builder = std::fs::DirBuilder::new(); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + match builder.create(&path) { + Ok(()) => return Ok(path), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e), + } + } + Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "could not allocate a unique print preview directory")) +} + fn purpose_of(purpose: &str) -> FilePurpose { match purpose { "open" => FilePurpose::Open, @@ -207,8 +294,14 @@ impl SlideApp { let bytes = deckcraft_engine::cmd::file::pdf_bytes(&doc, ¶ms); return self.download(&format!("{title}.pdf"), bytes); } - let safe: String = title.chars().map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' { c } else { '_' }).collect(); - let path = std::env::temp_dir().join(format!("{safe} (print).pdf")).to_string_lossy().to_string(); + let preview_path = match self.print_previews.create(&title) { + Ok(path) => path, + Err(e) => { + self.set_status(format!("Couldn't create a private print preview: {e}")); + return false; + } + }; + let path = preview_path.to_string_lossy().to_string(); if let Some(o) = params.as_object_mut() { o.insert("path".into(), json!(path)); o.insert("format".into(), json!("pdf")); @@ -219,7 +312,10 @@ impl SlideApp { self.set_status("Opened the presentation as a PDF to print"); true } - Err(_) => false, + Err(_) => { + self.print_previews.discard(&preview_path); + false + } } } diff --git a/crates/ui-egui/src/backstage/tests.rs b/crates/ui-egui/src/backstage/tests.rs index 78330b1b5..305777970 100644 --- a/crates/ui-egui/src/backstage/tests.rs +++ b/crates/ui-egui/src/backstage/tests.rs @@ -618,10 +618,19 @@ fn print_previews_pages_and_prints_a_pdf() { h.run_steps(2); let url = h.state().pending_urls.first().cloned().unwrap_or_default(); // The URL was handed to egui at the end of the frame; check what it pointed at. - let printed = std::env::temp_dir().join(format!("{} (print).pdf", h.state().session.active().unwrap().title())); - assert!(printed.is_file(), "the PDF to print was written: {url}"); + assert!(url.contains("deckcraft-print-"), "print preview uses its private directory: {url}"); + let title = h.state().session.active().unwrap().title(); + let safe: String = title.chars().map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' { c } else { '_' }).collect(); + let file_name = format!("{safe} (print).pdf"); + let printed = std::fs::read_dir(std::env::temp_dir()) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| entry.file_name().to_string_lossy().starts_with("deckcraft-print-")) + .map(|entry| entry.path().join(&file_name)) + .find(|path| path.is_file()) + .expect("the PDF to print was written in its private directory"); assert!(std::fs::read(&printed).unwrap().starts_with(b"%PDF")); - let _ = std::fs::remove_file(printed); + let _ = std::fs::remove_dir_all(printed.parent().unwrap()); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 30b5a0cd5..fd3deaa45 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -278,6 +278,7 @@ pub struct SlideApp { /// creating or closing a presentation leaves it. pub backstage: Option, pending_urls: Vec, + print_previews: app::files::PrintPreviews, #[cfg(not(target_arch = "wasm32"))] updates_rt: updates_ui::Runtime, /// Text for the system clipboard from a Copy or Cut command, handed over on the next frame. From 14eef3aceb47119a0fd7a5d482b2bdcafd53be03 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:09:41 -0500 Subject: [PATCH 05/88] dc-show-click-targets: follow animated show click targets --- crates/ui-egui/src/show/media.rs | 112 ++++++++++++++++++++++++++----- docs/gaps.md | 14 ++-- 2 files changed, 103 insertions(+), 23 deletions(-) diff --git a/crates/ui-egui/src/show/media.rs b/crates/ui-egui/src/show/media.rs index ee8b1dec5..ff69c3f4e 100644 --- a/crates/ui-egui/src/show/media.rs +++ b/crates/ui-egui/src/show/media.rs @@ -1,5 +1,6 @@ use super::render::fit; -use super::{Click, HitShape, Hyperlink, Presentation, Rect, ShapeId, Show, SlideApp, Ui, pos2, vec2}; +use super::{Click, HitShape, Hyperlink, PlacedShape, Presentation, Rect, ShapeId, Show, SlideApp, Ui, pos2, vec2}; +use deckcraft_anim::AnimState; /// The media-host voice for slide transition sounds (no shape has this id). pub(crate) const TRANSITION_SOUND: ShapeId = ShapeId(u32::MAX); @@ -152,6 +153,55 @@ pub(super) struct Under { pub(super) pick: Option, } +/// Convert a slide-space point back into a shape's own coordinates, undoing its animation and +/// those of its enclosing groups. +fn hit_point(mut point: (f64, f64), shapes: &[PlacedShape], states: &[AnimState], index: usize) -> Option<(f64, f64)> { + let mut chain = Vec::new(); + let mut at = Some(index); + for _ in 0..=shapes.len() { + let Some(i) = at else { break }; + let shape = shapes.get(i)?; + chain.push(i); + at = shape.parent; + } + if at.is_some() { + return None; + } + chain.reverse(); + let mut centers = Vec::with_capacity(chain.len()); + for &i in &chain { + let r = shapes.get(i)?.rect; + let center = transform_point((r.center().x, r.center().y), centers.last().copied()); + centers.push((center, *states.get(i)?)); + } + for &(center, state) in centers.iter().rev() { + point = inverse_transform_point(point, center, state)?; + } + Some(point) +} + +fn transform_point(mut p: (f64, f64), prior: Option<((f64, f64), AnimState)>) -> (f64, f64) { + if let Some((center, state)) = prior { + let (x, y) = (p.0 - center.0, p.1 - center.1); + let angle = state.rotate.to_radians(); + p = ( + center.0 + (x * angle.cos() - y * angle.sin()) * state.scale_x + state.offset_x, + center.1 + (x * angle.sin() + y * angle.cos()) * state.scale_y + state.offset_y, + ); + } + p +} + +fn inverse_transform_point(p: (f64, f64), center: (f64, f64), state: AnimState) -> Option<(f64, f64)> { + if state.scale_x.abs() <= f64::EPSILON || state.scale_y.abs() <= f64::EPSILON { + return None; + } + let (x, y) = (p.0 - center.0 - state.offset_x, p.1 - center.1 - state.offset_y); + let angle = -state.rotate.to_radians(); + let (x, y) = (x * angle.cos() - y * angle.sin(), x * angle.sin() + y * angle.cos()); + Some((center.0 + x / state.scale_x, center.1 + y / state.scale_y)) +} + impl Under { fn visible(&self, id: ShapeId) -> bool { self.shapes.iter().zip(&self.visible).find(|(h, _)| h.id == id).is_none_or(|(_, v)| *v) @@ -175,31 +225,29 @@ pub(super) fn shapes_under_pointer(ctx: &egui::Context, ui: &Ui, show: &mut Show } show.timeline(doc); let (Some((_, tl)), Some((_, links, shapes))) = (&show.timeline, &show.links) else { return None }; - let inside = |x0: f64, y0: f64, x1: f64, y1: f64| x0 <= x && x <= x1 && y0 <= y && y <= y1; - let hits: Vec = shapes - .iter() - .map(|s| HitShape { - id: s.id, - parent: s.parent, - is_group: s.is_group, - link: s.link.as_ref().map(|l| l.action.clone()), - under_pointer: inside(s.rect.x0, s.rect.y0, s.rect.x1, s.rect.y1), - }) - .collect(); let (step, plays, stamps) = (show.state.step, show.state.trigger_plays(), show.state.trigger_stamps()); let t = if show.state.playing { now - show.step_start } else { 0.0 }; - let visible: Vec = shapes + let states: Vec = shapes.iter().map(|s| tl.state_ordered(s.id, None, step, t, &plays, &stamps)).collect(); + let point_for = |i: usize| hit_point((x, y), shapes, &states, i); + let hits: Vec = shapes .iter() - .map(|s| { - let a = tl.state_ordered(s.id, None, step, t, &plays, &stamps); - a.visible && a.opacity > 0.0 + .enumerate() + .map(|(i, s)| { + let under_pointer = point_for(i).is_some_and(|(x, y)| s.rect.x0 <= x && x <= s.rect.x1 && s.rect.y0 <= y && y <= s.rect.y1); + HitShape { id: s.id, parent: s.parent, is_group: s.is_group, link: s.link.as_ref().map(|l| l.action.clone()), under_pointer } }) .collect(); + let visible: Vec = states.iter().map(|a| a.visible && a.opacity > 0.0).collect(); let mut under = Under { shapes: hits, visible, pick: None }; let shown = |id: ShapeId| under.visible(id); // A hyperlink in text applies when its shape is the one on top. let top = show.state.top(doc, tl, &under.shapes, &shown); - let text = links.iter().rev().filter(|l| l.text && inside(l.rect.x0, l.rect.y0, l.rect.x1, l.rect.y1)).find(|l| { + let text = links.iter().rev().filter(|l| l.text).find(|l| { + let Some(i) = shapes.iter().position(|s| s.id == l.shape) else { return false }; + let Some((x, y)) = point_for(i) else { return false }; + if !(l.rect.x0 <= x && x <= l.rect.x1 && l.rect.y0 <= y && y <= l.rect.y1) { + return false; + } let mut at = top; (0..=under.shapes.len()).any(|_| match at.and_then(|k| under.shapes.get(k)) { Some(h) if h.id == l.shape => true, @@ -217,6 +265,36 @@ pub(super) fn shapes_under_pointer(ctx: &egui::Context, ui: &Ui, show: &mut Show Some(under) } +#[cfg(test)] +mod hit_tests { + use super::*; + + fn shape(id: u32, parent: Option, rect: deckcraft_geom::Rect) -> PlacedShape { + PlacedShape { id: ShapeId(id), parent, is_group: parent.is_none(), rect, link: None } + } + + #[test] + fn motion_path_moves_the_hit_box() { + let shapes = [shape(1, None, deckcraft_geom::Rect::new(10.0, 10.0, 30.0, 30.0))]; + let states = [AnimState { offset_x: 50.0, scale_x: 2.0, ..Default::default() }]; + + let moved = hit_point((65.0, 20.0), &shapes, &states, 0); + let abandoned = hit_point((15.0, 20.0), &shapes, &states, 0); + assert!(moved.is_some_and(|(x, y)| (10.0..=30.0).contains(&x) && (10.0..=30.0).contains(&y))); + assert!(!abandoned.is_some_and(|(x, y)| (10.0..=30.0).contains(&x) && (10.0..=30.0).contains(&y))); + } + + #[test] + fn parent_animation_moves_child_hit_box() { + let shapes = + [shape(1, None, deckcraft_geom::Rect::new(0.0, 0.0, 100.0, 100.0)), shape(2, Some(0), deckcraft_geom::Rect::new(10.0, 10.0, 30.0, 30.0))]; + let states = [AnimState { offset_x: 40.0, ..Default::default() }, AnimState::default()]; + + let moved = hit_point((55.0, 20.0), &shapes, &states, 1); + assert!(moved.is_some_and(|(x, y)| (10.0..=30.0).contains(&x) && (10.0..=30.0).contains(&y))); + } +} + impl Show { /// Advance the clock of the running triggered animations. pub(super) fn tick_triggers(&mut self, doc: &Presentation, now: f64) { diff --git a/docs/gaps.md b/docs/gaps.md index c4a875f1c..9512b6715 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,7 +1,7 @@ # Where DeckCraft falls short of PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** 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-28 · **Last updated:** 2026-10-28 · **Change:** 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-28 · **Last updated:** 2026-10-28 · **Change:** minor (slide-show click targets follow animated shapes and text links; previous: 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) 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 @@ -219,11 +219,12 @@ and starts at that effect. The pane has an advanced timeline with draggable bars found by running three classroom game decks (Jeopardy-style boards, hide-and-reveal, score counters): a click on a shape whose action is "play sound" (`ppaction://media`, 126 and 486 shapes in two decks) does nothing; triggers that start on `onStopAudio` or `begin` conditions are - not read as triggers; click boxes are the shape's bounding box (a hexagon's corners count) and do - not follow a shape that a motion path has moved. Trigger sequences now keep their separate + not read as triggers; click boxes are the shape's bounding box (a hexagon's corners count). + Slide-show click targets now follow animated translations and scales, including text links. + Trigger sequences now keep their separate click steps on PPTX save, including paragraph builds (`crates/pptx/tests/it/trigger_steps.rs`). -- **Impact:** games built around sound effects on buttons, audio-triggered effects or moving - clickable shapes do not play as authored. +- **Impact:** games built around sound effects on buttons or audio-triggered effects do not play + as authored. - **Estimate:** 4–8 h. ### Notes and Handout masters @@ -440,6 +441,7 @@ Detail in [localization-parity.md](localization-parity.md). | 2026-10-13 | trivial | Chart workbook round-trip (#335) noted under the charts gap; editing chart data is still open | | 2026-10-13 | minor | Shape, picture and table rendering checked against PowerPoint on ~150 synthetic edge-case slides; 3-D bevels, cameras and face lighting now render approximately (gap narrowed to extrusion, rigs and the 3-D pane) | | 2026-10-13 | minor | Interactive decks: triggers, return-to-slide state and click rules landed in the slide show; the remaining pieces are one new entry under Features | +| 2026-10-28 | minor | Slide-show click targets follow animated movement and scale for shapes and text links; sound actions and audio triggers remain open | | 2026-10-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines | | 2026-10-12 | minor | EMF/WMF text, pattern and hatch brushes drawn; legacy OLE and ActiveX previews shown; the SVG gap keeps only EMF clip regions | | 2026-10-12 | minor | Text warps render (all 40 presets, adjusts round-trip); the warp gap is now the gallery, command and handles | 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 06/88] 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 07/88] 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 b082f12f70bbd1da2d17123c093382abdd969a3b Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:01:04 -0500 Subject: [PATCH 08/88] #129: inspect slide actions --- ROADMAP.md | 4 +- crates/engine/src/cmd/inspect.rs | 139 +++++++++++++++++++++++++++++++ docs/control-protocol.md | 1 + 3 files changed, 143 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index b04c4b69f..2cd05e360 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-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) +> **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-24 · **Change:** trivial (progress log: Ctrl+wheel zoom during the slide show, #1286; previous: trivial, 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: picture and embedded object fills; previous: trivial, 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-22 · **Change:** trivial (fill and outline edits reach group members, #1079; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -137,6 +137,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 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-25 | Windows installer: the Start menu shortcut takes the icon embedded in `deckcraft.exe` instead of an MSI ``, so a taskbar pin keeps its icon after an update (#1114); a pin that already lost it has to be unpinned and pinned again once | | 2026-10-24 | AutoFormat as you type: a paragraph that starts with `*`, `-`, `--`, `>`, `<>`, `1.`, `1)`, `I.`, `i.`, `A.`, `a.` or `a)` and a space or tab becomes a bulleted or numbered list in that style; Backspace right after puts the typed text back, and Tools ▸ AutoCorrect and `text.autocorrect {lists}` turn it off | | 2026-10-24 | Tables: while the caret is in a cell, dragging into another cell selects that range of cells (shaded, merged cells taken in whole), and Shift+arrow grows or shrinks it; formatting applies to the selected cells only, and Delete clears their text and keeps the table | @@ -294,6 +295,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | trivial | Progress log: read-only slide action inspection; parity scores unchanged | | 2026-10-25 | trivial | Progress log: Windows shortcut icon survives updates (#1114). Scores and milestone names unchanged | | 2026-10-24 | trivial | Progress log: selecting a range of table cells by dragging. Scores and milestone names unchanged | | 2026-10-24 | trivial | Progress log: measurement units in centimetres (#1326). Scores unchanged | diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index 8995e6b59..e09cf14a0 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -1,6 +1,7 @@ //! Inspection for agents: the deck, slides and shapes as JSON (verify work without screenshots). use deckcraft_model::resolve::{self, Ctx}; +use deckcraft_model::text::Hyperlink; use deckcraft_model::{Shape, ShapeKind}; use serde_json::{Value, json}; @@ -11,6 +12,7 @@ pub fn specs() -> Vec { vec![ cmd!(query "document.inspect", "Inspect Presentation", [], None, "{} → slides with titles, layouts, shape counts; selection; size; theme", has_doc, document), cmd!(query "slide.inspect", "Inspect Slide", [], None, "{index?} → the slide's shapes with ids, kinds, boxes, text, fills, animations, transition, notes (text, and notesBody with its formatting)", has_doc, slide), + cmd!(query "slide.actions", "Inspect Slide Actions", [], None, "{index?} → {index, slideId, actions: [{owner, event, type, target, hyperlink}]}; lists shape click/hover actions and text-run links/hover actions (including table cells) without executing them", has_doc, actions), cmd!(query "shape.inspect", "Inspect Shape", [], None, "{id} → full shape JSON plus its effective box", has_doc, shape), cmd!(query "commands.list", "List Commands", [], None, "{filter?: substring} → [{id, label, params, enabled}]", always, commands), ] @@ -135,6 +137,70 @@ fn slide(s: &mut Session, p: &Value) -> Result { })) } +fn actions(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let i = usize_param(p, "index").unwrap_or(st.selection.slide); + let sl = st.doc.slides.get(i).ok_or_else(|| bad("slide.actions", format!("no slide {i}")))?; + let mut found = Vec::new(); + for sh in &sl.shapes { + collect_actions(sh, &mut Vec::new(), &mut found); + } + Ok(json!({"index": i, "slideId": sl.id, "actions": found})) +} + +fn collect_actions(sh: &Shape, path: &mut Vec, found: &mut Vec) { + path.push(json!({"id": sh.id, "name": sh.name})); + let owner = json!({"shapeId": sh.id, "shapeName": sh.name, "path": path}); + if let Some(link) = &sh.click { + found.push(action_entry(owner.clone(), "click", link)); + } + if let Some(link) = &sh.hover { + found.push(action_entry(owner.clone(), "hover", link)); + } + if let Some(text) = &sh.text { + collect_text_actions(text, &owner, found); + } + if let ShapeKind::Table(table) = &sh.kind { + for (row_index, row) in table.rows.iter().enumerate() { + for (cell_index, cell) in row.cells.iter().enumerate() { + let mut cell_owner = owner.clone(); + cell_owner["tableRowIndex"] = json!(row_index); + cell_owner["tableCellIndex"] = json!(cell_index); + collect_text_actions(&cell.text, &cell_owner, found); + } + } + } + if let ShapeKind::Group { children, .. } = &sh.kind { + for child in children { + collect_actions(child, path, found); + } + } + path.pop(); +} + +fn collect_text_actions(text: &deckcraft_model::text::TextBody, owner: &Value, found: &mut Vec) { + for (paragraph_index, paragraph) in text.paragraphs.iter().enumerate() { + for (run_index, run) in paragraph.runs.iter().enumerate() { + for (event, link) in [("textLink", run.props.link.as_ref()), ("textHover", run.props.hover.as_ref())] { + if let Some(link) = link { + let mut text_owner = owner.clone(); + text_owner["paragraphIndex"] = json!(paragraph_index); + text_owner["runIndex"] = json!(run_index); + text_owner["text"] = json!(run.content()); + found.push(action_entry(text_owner, event, link)); + } + } + } + } +} + +fn action_entry(owner: Value, event: &str, link: &Hyperlink) -> Value { + let action = serde_json::to_value(&link.action).unwrap_or_default(); + let kind = action.get("kind").cloned().unwrap_or(Value::Null); + let target = ["url", "slide", "name", "path"].iter().find_map(|key| action.get(key).cloned()).unwrap_or(Value::Null); + json!({"owner": owner, "event": event, "type": kind, "target": target, "hyperlink": link}) +} + fn shape(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let id = @@ -154,3 +220,76 @@ fn commands(s: &mut Session, p: &Value) -> Result { .collect(), )) } + +#[cfg(test)] +mod tests { + use super::*; + use deckcraft_geom::Xfrm; + use deckcraft_model::table::Row; + use deckcraft_model::text::{Action, Paragraph, Run, RunProps, TextBody}; + use deckcraft_model::{Cell, Shape, ShapeId, ShapeKind, Table}; + + fn link(action: Action) -> Hyperlink { + Hyperlink { action, tooltip: String::new(), highlight_click: false, sound: None, stop_sound: false, zoom_return: None } + } + + #[test] + fn slide_actions_lists_group_shape_and_text_actions() { + let mut s = Session::with_new(); + let mut child = Shape { id: ShapeId(12), name: "Child".into(), ..Default::default() }; + child.click = Some(link(Action::NextSlide)); + child.hover = Some(link(Action::Url { url: "https://example.com/hover".into() })); + let mut run_props = RunProps::default(); + run_props.link = Some(link(Action::Slide { slide: deckcraft_model::SlideId(7) })); + let mut paragraph = Paragraph::new("linked"); + paragraph.runs = vec![Run::with("linked", run_props)]; + child.text = Some(TextBody { paragraphs: vec![paragraph], ..Default::default() }); + let group = Shape { + id: ShapeId(11), + name: "Group".into(), + kind: ShapeKind::Group { children: vec![child], child: Xfrm::default() }, + ..Default::default() + }; + let st = s.doc_mut().unwrap(); + let slide_index = st.selection.slide; + let presentation = std::sync::Arc::make_mut(&mut st.doc); + std::sync::Arc::make_mut(&mut presentation.slides[slide_index]).shapes = vec![group]; + + let v = s.execute("slide.actions", &json!({})).unwrap(); + let actions = v["actions"].as_array().unwrap(); + assert_eq!(actions.len(), 3, "{v}"); + assert_eq!(actions[0]["event"], "click"); + assert_eq!(actions[0]["type"], "nextSlide"); + assert_eq!(actions[0]["owner"]["path"][0]["name"], "Group"); + assert_eq!(actions[0]["owner"]["path"][1]["id"], 12); + assert_eq!(actions[1]["event"], "hover"); + assert_eq!(actions[1]["target"], "https://example.com/hover"); + assert_eq!(actions[2]["event"], "textLink"); + assert_eq!(actions[2]["owner"]["runIndex"], 0); + assert_eq!(actions[2]["target"], 7); + + let mut cell_props = RunProps::default(); + cell_props.hover = Some(link(Action::Url { url: "https://example.com/cell".into() })); + let mut cell_text = TextBody::from_text("cell link"); + cell_text.paragraphs[0].runs = vec![Run::with("cell link", cell_props)]; + let table = + Table { rows: vec![Row { cells: vec![Cell { text: cell_text, ..Default::default() }], ..Default::default() }], ..Default::default() }; + let table_shape = Shape { id: ShapeId(13), name: "Table".into(), kind: ShapeKind::Table(table), ..Default::default() }; + let st = s.doc_mut().unwrap(); + let presentation = std::sync::Arc::make_mut(&mut st.doc); + std::sync::Arc::make_mut(&mut presentation.slides[slide_index]).shapes.push(table_shape); + let v = s.execute("slide.actions", &json!({})).unwrap(); + let table_action = v["actions"].as_array().unwrap().last().cloned().unwrap(); + assert_eq!(table_action["event"], "textHover"); + assert_eq!(table_action["owner"]["tableRowIndex"], 0); + assert_eq!(table_action["owner"]["tableCellIndex"], 0); + assert_eq!(table_action["target"], "https://example.com/cell"); + } + + #[test] + fn slide_actions_returns_empty_array_for_slide_without_actions() { + let mut s = Session::with_new(); + let v = s.execute("slide.actions", &json!({})).unwrap(); + assert_eq!(v["actions"], json!([])); + } +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9edb40a91..d1d130c77 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -32,6 +32,7 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note | `engine.execute` / `ui.menu.invoke` / `command` | `{command, params}` | Run any command (see `engine.commands`; `deckcraft-cli describe COMMAND` documents one) | | `engine.commands` | — | Every command: id, label, menu path, shortcut, params doc, enabled / disabled reason | | `document.inspect` | — | Slides, sections, layouts, shapes, selection | +| `slide.actions` | `{index?}` | Actions on one slide; returns `{index, slideId, actions}`. Each action has an `owner` (shape id/name and group path; text actions also include paragraph/run indexes and run text, plus table row/cell indexes for table text), `event` (`click`, `hover`, `textLink`, or `textHover`), action `type`, `target`, and the full `hyperlink`. Listing is read-only and never executes an action. An empty slide returns `"actions": []`. | | `ui.inspect` | — | UI state, active tool, window/canvas/slide rects, dialog, `home` (the File tab open over a presentation), `backstage` (its page, also the start screen), slide show state (`grid`, `zoomArm`, `zoom`, `blank`, `presenterPaused`, `presenterSecs`, `notesPx`, `custom`, `goto`; `cells` while See All Slides is open), documents, perf (fps, render ms) | | `ui.inspect` | — | UI state, active tool, window/canvas/slide rects, dialog, `home` (the File tab open over a presentation), `backstage` (its page, also the start screen), slide show state, `animPreview` (canvas playback: slide, time, duration), documents, perf (fps, render ms) | | `ui.tool.select` | `{tool, preset?}` | `select`, `text`, `shape` + preset, any preset name, `curve`, `freeform`, `scribble`, `pen`, `highlighter`, `eraser` | From ef6fd299282d9d6f721e5d31031c206b11cc0010 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:00:43 -0500 Subject: [PATCH 09/88] DC-1521: hit-test each shape animation independently --- crates/ui-egui/src/show/media.rs | 72 ++++++++++++++++---------------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/crates/ui-egui/src/show/media.rs b/crates/ui-egui/src/show/media.rs index 60b6d9416..51cd7b99f 100644 --- a/crates/ui-egui/src/show/media.rs +++ b/crates/ui-egui/src/show/media.rs @@ -232,6 +232,42 @@ pub(super) fn shapes_under_pointer(ctx: &egui::Context, ui: &Ui, show: &mut Show Some(under) } +impl Show { + /// Advance the clock of the running triggered animations. + pub(super) fn tick_triggers(&mut self, doc: &Presentation, now: f64) { + self.timeline(doc); + if let Some((_, tl)) = &self.timeline { + self.state.tick(tl, now); + } + } + + /// A click on the slide: the shape under the pointer acts, or the background advances. + pub(super) fn click_slide(&mut self, doc: &Presentation, under: &Under, now: f64) -> Click { + self.timeline(doc); + let last = self.last_viewed; + match &self.timeline { + Some((_, tl)) => self.state.click(doc, tl, &under.shapes, &|id| under.visible(id), now, last), + None => Click::None, + } + } +} + +/// A slide-space box on screen, the slide being drawn in `srect` at `k` pixels per point. +pub(super) fn on_screen(srect: Rect, k: f32, x: &deckcraft_geom::Xfrm) -> Rect { + Rect::from_min_size(pos2(srect.min.x + x.x as f32 * k, srect.min.y + x.y as f32 * k), vec2(x.w as f32 * k, x.h as f32 * k)) +} + +/// The animated GIF whose play/pause button is under the pointer on the shown slide. +pub(super) fn gif_hit(ctx: &egui::Context, ui: &Ui, show: &Show, doc_uid: u64, doc: &Presentation, app: &mut SlideApp) -> Option { + let p = ctx.input(|i| i.pointer.interact_pos())?; + let full = ui.max_rect(); + let avail = if show.reading { Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - 34.0)) } else { full }; + let srect = fit(avail, doc); + let k = srect.width() / doc.slide_size.width.max(1.0) as f32; + let gifs = app.media.gifs(doc_uid, doc, show.state.slide, ctx.input(|i| i.time), &app.session.gif_paused); + gifs.iter().rev().find(|g| crate::media::gif_button_rect(on_screen(srect, k, &g.rect)).contains(p)).map(|g| g.id) +} + #[cfg(test)] mod hit_tests { use super::*; @@ -273,39 +309,3 @@ mod hit_tests { assert!(!displaced.is_some_and(|(x, y)| (10.0..=30.0).contains(&x) && (10.0..=30.0).contains(&y))); } } - -impl Show { - /// Advance the clock of the running triggered animations. - pub(super) fn tick_triggers(&mut self, doc: &Presentation, now: f64) { - self.timeline(doc); - if let Some((_, tl)) = &self.timeline { - self.state.tick(tl, now); - } - } - - /// A click on the slide: the shape under the pointer acts, or the background advances. - pub(super) fn click_slide(&mut self, doc: &Presentation, under: &Under, now: f64) -> Click { - self.timeline(doc); - let last = self.last_viewed; - match &self.timeline { - Some((_, tl)) => self.state.click(doc, tl, &under.shapes, &|id| under.visible(id), now, last), - None => Click::None, - } - } -} - -/// A slide-space box on screen, the slide being drawn in `srect` at `k` pixels per point. -pub(super) fn on_screen(srect: Rect, k: f32, x: &deckcraft_geom::Xfrm) -> Rect { - Rect::from_min_size(pos2(srect.min.x + x.x as f32 * k, srect.min.y + x.y as f32 * k), vec2(x.w as f32 * k, x.h as f32 * k)) -} - -/// The animated GIF whose play/pause button is under the pointer on the shown slide. -pub(super) fn gif_hit(ctx: &egui::Context, ui: &Ui, show: &Show, doc_uid: u64, doc: &Presentation, app: &mut SlideApp) -> Option { - let p = ctx.input(|i| i.pointer.interact_pos())?; - let full = ui.max_rect(); - let avail = if show.reading { Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - 34.0)) } else { full }; - let srect = fit(avail, doc); - let k = srect.width() / doc.slide_size.width.max(1.0) as f32; - let gifs = app.media.gifs(doc_uid, doc, show.state.slide, ctx.input(|i| i.time), &app.session.gif_paused); - gifs.iter().rev().find(|g| crate::media::gif_button_rect(on_screen(srect, k, &g.rect)).contains(p)).map(|g| g.id) -} 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 10/88] 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 2c11865b2da6caf8189652adad99cb8c87b600c5 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:49:21 -0500 Subject: [PATCH 11/88] codec: accept hvcC SEI arrays without allocation --- crates/h264/src/decoder.rs | 14 -------- crates/h264/src/synth_tests.rs | 34 +++++++++++++++++++ crates/hevc/src/decoder.rs | 61 ++++++++++++++++++---------------- crates/hevc/src/synth_tests.rs | 43 ++++++++++++++++++++++++ 4 files changed, 109 insertions(+), 43 deletions(-) diff --git a/crates/h264/src/decoder.rs b/crates/h264/src/decoder.rs index b4b429b9a..a3244d473 100644 --- a/crates/h264/src/decoder.rs +++ b/crates/h264/src/decoder.rs @@ -687,17 +687,3 @@ fn make_picture(o: &Output) -> Picture { draft: o.meta.draft, } } - -#[cfg(test)] -mod config_tests { - use super::Decoder; - - #[test] - fn avcc_rejects_picture_slices_without_starting_a_picture() { - let config = [1, 66, 0, 30, 0xff, 0xe1, 0, 2, 0x41, 0x80, 0]; - let mut decoder = Decoder::with_threads(1); - assert!(decoder.configure_avcc(&config).is_err()); - assert!(decoder.pending.is_none()); - assert_eq!(decoder.stats().pictures, 0); - } -} diff --git a/crates/h264/src/synth_tests.rs b/crates/h264/src/synth_tests.rs index 8588c5402..24a104cf2 100644 --- a/crates/h264/src/synth_tests.rs +++ b/crates/h264/src/synth_tests.rs @@ -96,6 +96,40 @@ fn avcc_accepts_generated_sps_and_pps() { assert!(Decoder::from_avcc(&avcc).is_ok()); } +#[test] +fn avcc_rejects_a_valid_picture_slice_without_starting_a_picture() { + let sequence = sps(66, 4, false, PocType::T0); + let picture = pps(false, false); + let slice = slice_nal( + &Pic { + idr: true, + frame_num: 0, + ref_idc: 3, + long_term_ref: false, + mods: vec![], + mmcos: vec![], + mbs: vec![Some(vec![128; 16 * 16]); NMB], + b: false, + poc_lsb: Some(0), + poc_delta: None, + wt: None, + }, + false, + ); + let mut avcc = vec![1, 66, 0, 30, 0xff, 0xe2]; + for nal in [&sequence[4..], &slice[4..]] { + avcc.extend_from_slice(&(nal.len() as u16).to_be_bytes()); + avcc.extend_from_slice(nal); + } + avcc.push(1); + avcc.extend_from_slice(&((picture.len() - 4) as u16).to_be_bytes()); + avcc.extend_from_slice(&picture[4..]); + + let mut decoder = Decoder::with_threads(1); + assert!(decoder.configure_avcc(&avcc).is_err()); + assert_eq!(decoder.stats().pictures, 0); +} + #[derive(Default, Clone)] struct Pic { idr: bool, diff --git a/crates/hevc/src/decoder.rs b/crates/hevc/src/decoder.rs index d3419103f..b4ad29f98 100644 --- a/crates/hevc/src/decoder.rs +++ b/crates/hevc/src/decoder.rs @@ -161,30 +161,33 @@ impl Decoder { ensure!(hvcc.len() >= 23, "hvcC too short"); ensure!(hvcc[0] == 1, "unsupported hvcC version {}", hvcc[0]); let num_arrays = hvcc[22] as usize; - let mut pos = 23; - let mut sets = Vec::new(); - for _ in 0..num_arrays { - ensure!(pos + 3 <= hvcc.len(), "hvcC truncated"); - let array_type = hvcc[pos] & 0x3f; - let n = u16::from_be_bytes([hvcc[pos + 1], hvcc[pos + 2]]) as usize; - pos += 3; - for _ in 0..n { - ensure!(pos + 2 <= hvcc.len(), "hvcC truncated"); - let len = u16::from_be_bytes([hvcc[pos], hvcc[pos + 1]]) as usize; - pos += 2; - ensure!(pos + len <= hvcc.len(), "hvcC truncated"); - sets.push((array_type, &hvcc[pos..pos + len])); - pos += len; + for pass in 0..2 { + let mut pos = 23; + for _ in 0..num_arrays { + ensure!(pos <= hvcc.len() && hvcc.len() - pos >= 3, "hvcC truncated"); + let array_type = hvcc[pos] & 0x3f; + let n = u16::from_be_bytes([hvcc[pos + 1], hvcc[pos + 2]]) as usize; + pos += 3; + for _ in 0..n { + ensure!(pos <= hvcc.len() && hvcc.len() - pos >= 2, "hvcC truncated"); + let len = u16::from_be_bytes([hvcc[pos], hvcc[pos + 1]]) as usize; + pos += 2; + ensure!(len <= hvcc.len() - pos, "hvcC truncated"); + let nal = &hvcc[pos..pos + len]; + if pass == 0 { + let header = NalHeader::parse(nal)?; + ensure!( + [nal_type::VPS, nal_type::SPS, nal_type::PPS, nal_type::PREFIX_SEI, nal_type::SUFFIX_SEI].contains(&array_type), + "hvcC array has unsupported NAL type {array_type}" + ); + ensure!(header.nal_type == array_type, "hvcC array type {array_type} disagrees with NAL header type {}", header.nal_type); + } else { + self.handle_nal(nal, 0)?; + } + pos += len; + } } } - for (array_type, nal) in &sets { - let header = NalHeader::parse(nal)?; - ensure!([nal_type::VPS, nal_type::SPS, nal_type::PPS].contains(array_type), "hvcC array has non-parameter-set NAL type {array_type}"); - ensure!(header.nal_type == *array_type, "hvcC array type {array_type} disagrees with NAL header type {}", header.nal_type); - } - for (_, nal) in sets { - self.handle_nal(nal, 0)?; - } self.nal_length_size = Some((hvcc[21] & 3) as usize + 1); Ok(()) } @@ -669,14 +672,14 @@ mod config_tests { use super::Decoder; #[test] - fn hvcc_rejects_picture_slices_without_starting_a_picture() { + fn hvcc_skips_prefix_and_suffix_sei_arrays() { let mut config = vec![0; 23]; config[0] = 1; - config[22] = 1; - config.extend_from_slice(&[32, 0, 1, 0, 2, 0x02, 0x01]); - let mut decoder = Decoder::with_threads(1); - assert!(decoder.configure_hvcc(&config).is_err()); - assert!(decoder.pending.is_none()); - assert_eq!(decoder.stats().pictures, 0); + config[22] = 2; + config.extend_from_slice(&[39, 0, 1, 0, 2, 0x4e, 0x01]); + config.extend_from_slice(&[40, 0, 1, 0, 2, 0x50, 0x01]); + + let decoder = Decoder::from_hvcc(&config); + assert!(decoder.is_ok()); } } diff --git a/crates/hevc/src/synth_tests.rs b/crates/hevc/src/synth_tests.rs index 52a6e177e..7baf7b496 100644 --- a/crates/hevc/src/synth_tests.rs +++ b/crates/hevc/src/synth_tests.rs @@ -659,3 +659,46 @@ fn hvcc_accepts_generated_vps_sps_and_pps() { } assert!(Decoder::from_hvcc(&hvcc).is_ok()); } + +#[test] +fn hvcc_rejects_a_valid_picture_slice_without_starting_a_picture() { + let sets = [vps(), sps(true), pps(0, &None, false)]; + let mut stream = Vec::new(); + let mut saved = None; + encode_picture( + &PicDesc { + nal_type: 19, + poc: 0, + intra: true, + cu: Cu::Pcm, + pps_id: 0, + st: vec![], + lt: vec![], + num_ref: 0, + list_entry: None, + segments: whole(WC * HC), + }, + &None, + &content(0), + &mut stream, + &mut saved, + ); + + let mut hvcc = vec![0; 23]; + hvcc[0] = 1; + hvcc[22] = 4; + for (kind, set) in [32, 33, 34].into_iter().zip(sets) { + let nal = &set[4..]; + hvcc.extend_from_slice(&[kind, 0, 1]); + hvcc.extend_from_slice(&(nal.len() as u16).to_be_bytes()); + hvcc.extend_from_slice(nal); + } + let slice = &stream[4..]; + hvcc.extend_from_slice(&[19, 0, 1]); + hvcc.extend_from_slice(&(slice.len() as u16).to_be_bytes()); + hvcc.extend_from_slice(slice); + + let mut decoder = Decoder::with_threads(1); + assert!(decoder.configure_hvcc(&hvcc).is_err()); + assert_eq!(decoder.stats().pictures, 0); +} From b4c1905d7815bca00d04c33e8952f3359f03e46d Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:51:34 -0500 Subject: [PATCH 12/88] dc-fix-1519: address print preview review --- apps/deckcraft/src/main.rs | 2 + crates/ui-egui/src/app.rs | 5 ++ crates/ui-egui/src/app/files.rs | 83 ++++++++++++++++----------- crates/ui-egui/src/backstage/tests.rs | 31 +++++----- 4 files changed, 75 insertions(+), 46 deletions(-) diff --git a/apps/deckcraft/src/main.rs b/apps/deckcraft/src/main.rs index 03859dc0a..ad3f820fa 100644 --- a/apps/deckcraft/src/main.rs +++ b/apps/deckcraft/src/main.rs @@ -60,6 +60,8 @@ impl eframe::App for App { // nothing after it is needed: the preferences are saved and the log file is unbuffered. // `on_exit` only runs once the app exists, never on a failed start. Same as GridCraft #117. #[cfg(target_os = "macos")] + self.0.cleanup_print_previews(); + #[cfg(target_os = "macos")] std::process::exit(0); } } diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 3b3da5929..7c9c373ab 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -30,6 +30,11 @@ pub use keys::key_name; const STATUS_LINGER_MS: f64 = 6000.0; impl SlideApp { + /// Remove temporary PDFs created for printing. + pub fn cleanup_print_previews(&mut self) { + self.print_previews.cleanup(); + } + /// Build application UI state, taking ownership of audio output and initialising media, dialogs, /// synthetic input, and timing fields. pub fn new(session: Session, mut services: Services) -> Self { diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 310956a8e..1e846e0db 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -24,44 +24,17 @@ impl PrintPreviews { self.0.retain(|owned| owned != dir); } } -} -#[cfg(test)] -mod print_preview_tests { - use super::PrintPreviews; - use std::time::{SystemTime, UNIX_EPOCH}; - - #[test] - fn same_title_gets_distinct_private_paths_and_drop_cleans_them() { - let stamp = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default(); - let base = std::env::temp_dir().join(format!("deckcraft-print-test-{}-{stamp}", std::process::id())); - std::fs::create_dir(&base).unwrap(); - let (first, second) = { - let mut previews = PrintPreviews::default(); - let first = previews.create_in(&base, "Same title").unwrap(); - let second = previews.create_in(&base, "Same title").unwrap(); - assert_ne!(first, second); - assert_eq!(first.file_name(), second.file_name()); - assert!(first.parent().is_some_and(|dir| dir.is_dir())); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mode = std::fs::metadata(first.parent().unwrap()).unwrap().permissions().mode(); - assert_eq!(mode & 0o777, 0o700); - } - (first, second) - }; - assert!(!first.parent().is_some_and(|dir| dir.exists())); - assert!(!second.parent().is_some_and(|dir| dir.exists())); - std::fs::remove_dir(base).unwrap(); + pub(crate) fn cleanup(&mut self) { + for dir in self.0.drain(..) { + let _ = std::fs::remove_dir_all(dir); + } } } impl Drop for PrintPreviews { fn drop(&mut self) { - for dir in &self.0 { - let _ = std::fs::remove_dir_all(dir); - } + self.cleanup(); } } @@ -761,3 +734,49 @@ pub(crate) fn web_image_files( }) .collect() } + +#[cfg(test)] +mod print_preview_tests { + use super::PrintPreviews; + use std::time::{SystemTime, UNIX_EPOCH}; + + #[test] + fn same_title_gets_distinct_private_paths_and_drop_cleans_them() { + let stamp = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default(); + let base = std::env::temp_dir().join(format!("deckcraft-print-test-{}-{stamp}", std::process::id())); + std::fs::create_dir(&base).unwrap(); + let (first, second) = { + let mut previews = PrintPreviews::default(); + let first = previews.create_in(&base, "Same title").unwrap(); + let second = previews.create_in(&base, "Same title").unwrap(); + assert_ne!(first, second); + assert_eq!(first.file_name(), second.file_name()); + assert!(first.parent().is_some_and(|dir| dir.is_dir())); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(first.parent().unwrap()).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o700); + } + (first, second) + }; + assert!(!first.parent().is_some_and(|dir| dir.exists())); + assert!(!second.parent().is_some_and(|dir| dir.exists())); + std::fs::remove_dir(base).unwrap(); + } + + #[test] + fn explicit_cleanup_removes_previews() { + let stamp = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default(); + let base = std::env::temp_dir().join(format!("deckcraft-print-cleanup-test-{}-{stamp}", std::process::id())); + std::fs::create_dir(&base).unwrap(); + let mut previews = PrintPreviews::default(); + let path = previews.create_in(&base, "Explicit cleanup").unwrap(); + let dir = path.parent().unwrap().to_path_buf(); + + previews.cleanup(); + + assert!(!dir.exists()); + std::fs::remove_dir(base).unwrap(); + } +} diff --git a/crates/ui-egui/src/backstage/tests.rs b/crates/ui-egui/src/backstage/tests.rs index 276461d01..22a27972b 100644 --- a/crates/ui-egui/src/backstage/tests.rs +++ b/crates/ui-egui/src/backstage/tests.rs @@ -634,21 +634,24 @@ fn print_previews_pages_and_prints_a_pdf() { h.state_mut().backstage.as_mut().unwrap().print_range = "1-2".into(); h.run_steps(2); click_on_page(&h, "Print"); - h.run_steps(2); - let url = h.state().pending_urls.first().cloned().unwrap_or_default(); - // The URL was handed to egui at the end of the frame; check what it pointed at. + h.step(); + let url = h + .output() + .platform_output + .commands + .iter() + .find_map(|command| match command { + egui::OutputCommand::OpenUrl(open) => Some(open.url.as_str()), + _ => None, + }) + .expect("Print emits an egui OpenUrl command"); assert!(url.contains("deckcraft-print-"), "print preview uses its private directory: {url}"); - let title = h.state().session.active().unwrap().title(); - let safe: String = title.chars().map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' { c } else { '_' }).collect(); - let file_name = format!("{safe} (print).pdf"); - let printed = std::fs::read_dir(std::env::temp_dir()) - .unwrap() - .filter_map(Result::ok) - .filter(|entry| entry.file_name().to_string_lossy().starts_with("deckcraft-print-")) - .map(|entry| entry.path().join(&file_name)) - .find(|path| path.is_file()) - .expect("the PDF to print was written in its private directory"); - assert!(std::fs::read(&printed).unwrap().starts_with(b"%PDF")); + let path = url.strip_prefix("file://").expect("print preview URL is a file URL"); + #[cfg(windows)] + let path = path.strip_prefix('/').unwrap_or(path); + let printed = std::path::Path::new(path); + assert!(printed.is_file(), "the PDF at the emitted URL was written: {url}"); + assert!(std::fs::read(printed).unwrap().starts_with(b"%PDF")); let _ = std::fs::remove_dir_all(printed.parent().unwrap()); let _ = std::fs::remove_dir_all(&dir); } From ddd39249ba40256ce7370d08a3cd4e863d9c306b Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:03:36 -0500 Subject: [PATCH 13/88] test(codec): strengthen configuration slice regressions --- crates/h264/src/synth_tests.rs | 8 +++++--- crates/hevc/src/synth_tests.rs | 1 + 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/h264/src/synth_tests.rs b/crates/h264/src/synth_tests.rs index 24a104cf2..a2f9988ae 100644 --- a/crates/h264/src/synth_tests.rs +++ b/crates/h264/src/synth_tests.rs @@ -116,14 +116,16 @@ fn avcc_rejects_a_valid_picture_slice_without_starting_a_picture() { }, false, ); - let mut avcc = vec![1, 66, 0, 30, 0xff, 0xe2]; - for nal in [&sequence[4..], &slice[4..]] { + let mut avcc = vec![1, 66, 0, 30, 0xff, 0xe1]; + for nal in [&sequence[4..]] { avcc.extend_from_slice(&(nal.len() as u16).to_be_bytes()); avcc.extend_from_slice(nal); } - avcc.push(1); + avcc.push(2); avcc.extend_from_slice(&((picture.len() - 4) as u16).to_be_bytes()); avcc.extend_from_slice(&picture[4..]); + avcc.extend_from_slice(&((slice.len() - 4) as u16).to_be_bytes()); + avcc.extend_from_slice(&slice[4..]); let mut decoder = Decoder::with_threads(1); assert!(decoder.configure_avcc(&avcc).is_err()); diff --git a/crates/hevc/src/synth_tests.rs b/crates/hevc/src/synth_tests.rs index 7baf7b496..4d044a3d5 100644 --- a/crates/hevc/src/synth_tests.rs +++ b/crates/hevc/src/synth_tests.rs @@ -700,5 +700,6 @@ fn hvcc_rejects_a_valid_picture_slice_without_starting_a_picture() { let mut decoder = Decoder::with_threads(1); assert!(decoder.configure_hvcc(&hvcc).is_err()); + assert!(decoder.flush().is_empty()); assert_eq!(decoder.stats().pictures, 0); } From 7190c858604fb2c94d332554cdeeb8ad755d0707 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:06:36 -0500 Subject: [PATCH 14/88] test(h264): avoid singleton fixture loop --- crates/h264/src/synth_tests.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/h264/src/synth_tests.rs b/crates/h264/src/synth_tests.rs index a2f9988ae..ac00d8604 100644 --- a/crates/h264/src/synth_tests.rs +++ b/crates/h264/src/synth_tests.rs @@ -117,10 +117,9 @@ fn avcc_rejects_a_valid_picture_slice_without_starting_a_picture() { false, ); let mut avcc = vec![1, 66, 0, 30, 0xff, 0xe1]; - for nal in [&sequence[4..]] { - avcc.extend_from_slice(&(nal.len() as u16).to_be_bytes()); - avcc.extend_from_slice(nal); - } + let nal = &sequence[4..]; + avcc.extend_from_slice(&(nal.len() as u16).to_be_bytes()); + avcc.extend_from_slice(nal); avcc.push(2); avcc.extend_from_slice(&((picture.len() - 4) as u16).to_be_bytes()); avcc.extend_from_slice(&picture[4..]); From 3c068df62879ce6444d0d04562d3b483def6a85e Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:13:38 -0500 Subject: [PATCH 15/88] test(h264): complete picture slice fixture --- crates/h264/src/synth_tests.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/h264/src/synth_tests.rs b/crates/h264/src/synth_tests.rs index ac00d8604..76609dbde 100644 --- a/crates/h264/src/synth_tests.rs +++ b/crates/h264/src/synth_tests.rs @@ -108,7 +108,7 @@ fn avcc_rejects_a_valid_picture_slice_without_starting_a_picture() { long_term_ref: false, mods: vec![], mmcos: vec![], - mbs: vec![Some(vec![128; 16 * 16]); NMB], + mbs: vec![Some(vec![128; 384]); NMB], b: false, poc_lsb: Some(0), poc_delta: None, @@ -128,6 +128,7 @@ fn avcc_rejects_a_valid_picture_slice_without_starting_a_picture() { let mut decoder = Decoder::with_threads(1); assert!(decoder.configure_avcc(&avcc).is_err()); + assert!(decoder.flush().is_empty()); assert_eq!(decoder.stats().pictures, 0); } From 446bc528ad43864a89d68bd6b574f89e1d6cc194 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Wed, 28 Oct 2026 13:45:57 +0000 Subject: [PATCH 16/88] Make layout/master placeholder formatting reach slides Fixes #1487 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/text/formatting.rs | 32 ++++++++++++++++++++++++ crates/engine/src/tests.rs | 26 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/crates/engine/src/cmd/text/formatting.rs b/crates/engine/src/cmd/text/formatting.rs index 158af0962..b58cb69e4 100644 --- a/crates/engine/src/cmd/text/formatting.rs +++ b/crates/engine/src/cmd/text/formatting.rs @@ -31,8 +31,10 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy return Err(bad("format", SELECT_TEXT_OR_SHAPE)); } let cells = st.selection.cells; + let on_master = editing_master(st); edit_shapes(s, &json!({"ids": ids}), "format", |sh| { let id = sh.id; + let inherited = on_master && sh.ph.is_some(); match &mut sh.kind { ShapeKind::Table(t) => { // Only the selected cells, when a range of them is selected. @@ -48,6 +50,13 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy _ => { let body = sh.text.get_or_insert_with(TextBody::default); ed::format_all(body, f); + if inherited { + for l in 0..LIST_LEVELS { + let mut lvl = body.list_style.level(l).cloned().unwrap_or_default(); + f(&mut lvl.run); + body.list_style.set(l, lvl); + } + } } } Ok(()) @@ -58,6 +67,15 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy ok() } +/// List-style levels (`lvl1pPr`–`lvl9pPr`) a placeholder passes on to the slides that use it. +const LIST_LEVELS: u8 = 9; + +/// Formatting a placeholder on a layout or master (Slide Master view) also sets its list style, +/// which is what the placeholders on slides inherit; its own runs may be few or none. +fn editing_master(st: &DocState) -> bool { + matches!(st.selection.target, crate::Target::Layout { .. } | crate::Target::Master { .. }) +} + /// What `format_run` formats while editing: the selection, or for a bare caret the word it's in /// (without its trailing space). Empty when the caret is at a word boundary. Insert Hyperlink /// edits this same span, so the dialog reads it too. @@ -128,8 +146,10 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex return Err(bad("format", SELECT_TEXT_OR_SHAPE)); } let cells = st.selection.cells; + let on_master = editing_master(st); edit_shapes(s, &json!({"ids": ids}), "format", |sh| { let id = sh.id; + let inherited = on_master && sh.ph.is_some(); match &mut sh.kind { ShapeKind::Table(t) => { let range = crate::cmd::table::selected_range(cells, id, t); @@ -147,6 +167,18 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex body.paragraphs.push(Default::default()); } body.paragraphs.iter_mut().for_each(f); + if inherited { + for l in 0..LIST_LEVELS { + let mut lvl = body.list_style.level(l).cloned().unwrap_or_default(); + let mut p = deckcraft_model::text::Paragraph { level: l, props: lvl.para, ..Default::default() }; + f(&mut p); + // A level change (indent/outdent) means nothing for a list-style level. + if p.level == l { + lvl.para = p.props; + body.list_style.set(l, lvl); + } + } + } } } Ok(()) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 85cbe9d8c..5b3dcb03b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5956,3 +5956,29 @@ fn invalid_slide_index_never_falls_back_to_the_selection_in_export_commands() { } let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn master_layout_placeholder_format_inherits() { + let mut s = Session::new(); + sample::open_sample(&mut s).unwrap(); + let title_of = |shapes: &[model::Shape]| shapes.iter().find(|sh| sh.ph.as_ref().is_some_and(|p| p.kind.is_title())).map(|sh| sh.id.0).unwrap(); + let existing = title_of(&s.doc().unwrap().doc.slides[0].shapes); + s.execute("view.slideMaster", &json!({"master": 0, "layout": 0})).unwrap(); + let layout_title = title_of(&s.doc().unwrap().doc.masters[0].layouts[0].shapes); + s.execute("edit.select", &json!({"ids": [layout_title]})).unwrap(); + s.execute("format.font", &json!({"family": "Montserrat"})).unwrap(); + s.execute("format.align", &json!({"align": "right"})).unwrap(); + let st = s.execute("format.state", &json!({})).unwrap(); + assert_eq!(st["fontRef"], "Montserrat", "layout placeholder: {st}"); + s.execute("view.closeMaster", &json!({})).unwrap(); + s.execute("slide.new", &json!({"layout": "title"})).unwrap(); + let slide = s.doc().unwrap().selection.slide; + let fresh = title_of(&s.doc().unwrap().doc.slides[slide].shapes); + for (index, id) in [(slide, fresh), (0, existing)] { + s.execute("slide.go", &json!({"index": index})).unwrap(); + s.execute("edit.select", &json!({"ids": [id]})).unwrap(); + let st = s.execute("format.state", &json!({})).unwrap(); + assert_eq!(st["fontRef"], "Montserrat", "slide placeholder {id}: {st}"); + assert_eq!(st["align"], "r", "slide placeholder {id}: {st}"); + } +} From adaa4e6fa1286794cf4cc0bd182f1e200ac76fb6 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 30 Oct 2026 23:06:29 +0100 Subject: [PATCH 17/88] fix: keep per-level formatting when formatting layout/master placeholders Co-Authored-By: Claude Sonnet 5.5 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/format.rs | 2 +- crates/engine/src/cmd/format/font.rs | 18 +++- crates/engine/src/cmd/text.rs | 2 +- crates/engine/src/cmd/text/formatting.rs | 126 +++++++++++++++++++---- crates/engine/src/tests.rs | 49 +++++++++ 5 files changed, 168 insertions(+), 29 deletions(-) diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index 0f81cfaf5..4e273dc1c 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -8,7 +8,7 @@ use deckcraft_model::{ColorRef, ColorTransform, Effects, Fill, Line, SchemeSlot, use serde_json::{Value, json}; use std::sync::Arc; -use super::text::{format_body, format_para, format_run}; +use super::text::{format_body, format_para, format_run, format_run_levels}; use super::{ CommandSpec, always, bad, base64_decode, bool_or, bool_param, cmd, color_param, edit_shapes, f64_or, f64_param, has_doc, has_text, has_text_or_shapes, ids_param, ok, str_param, diff --git a/crates/engine/src/cmd/format/font.rs b/crates/engine/src/cmd/format/font.rs index 11af99b7e..fa30e93c4 100644 --- a/crates/engine/src/cmd/format/font.rs +++ b/crates/engine/src/cmd/format/font.rs @@ -1,6 +1,6 @@ use super::{ Arc, Caps, ColorRef, ColorTransform, Fill, Result, RunProps, SchemeSlot, Session, Shape, ShapeId, ShapeKind, Strike, Value, bad, bool_or, - bool_param, color_param, current_run, ed, edit_shapes, f64_param, format_run, json, ok, on_param, resolve, str_param, + bool_param, color_param, current_run, ed, edit_shapes, f64_param, format_run, format_run_levels, json, ok, on_param, resolve, str_param, }; pub(super) fn bold(s: &mut Session, p: &Value) -> Result { @@ -72,10 +72,18 @@ fn grow_shrink(s: &mut Session, up: bool) -> Result { let ctx_sizes = current_run(s).size.unwrap_or(18.0); let _ = st; let base = ctx_sizes; - format_run(s, &move |r| { - let cur = r.size.unwrap_or(base); - r.size = Some(step(cur, up)); - }) + // A layout/master level steps from its own effective size, not the first run's. + format_run_levels( + s, + &move |r| { + let cur = r.size.unwrap_or(base); + r.size = Some(step(cur, up)); + }, + &move |r, eff| { + let cur = r.size.or(eff.size).unwrap_or(18.0); + r.size = Some(step(cur, up)); + }, + ) } pub(super) fn grow(s: &mut Session, _p: &Value) -> Result { diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 6f85df870..d095d7faf 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -30,7 +30,7 @@ use editing::{edit, exit}; pub(crate) use editing::{insert_field, insert_text, paste_body}; pub use formatting::format_range; use formatting::{fonts_used, replace_font}; -pub(crate) use formatting::{format_body, format_para, format_run, link_text}; +pub(crate) use formatting::{format_body, format_para, format_run, format_run_levels, link_text}; pub use ime::{IME_SURROUNDING_MAX_BYTES, ImeSurrounding, cell_range_underlines, composition_underlines, ime_surrounding, range_underlines}; use ime::{commit, compose}; pub(crate) use layout::ctx_for; diff --git a/crates/engine/src/cmd/text/formatting.rs b/crates/engine/src/cmd/text/formatting.rs index b58cb69e4..d942ee282 100644 --- a/crates/engine/src/cmd/text/formatting.rs +++ b/crates/engine/src/cmd/text/formatting.rs @@ -8,22 +8,34 @@ use super::{ /// selected shapes). A caret without a range formats the word it's in (PowerPoint behaviour), /// or sets the formatting for what is typed next at a word boundary. pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sync)) -> Result { + format_run_levels(s, f, &|r, _| f(r)) +} + +/// Like [`format_run`], with a separate change for the list-style levels of a layout or master +/// placeholder: `lf` gets the level's own run props and the level's effective ones (what a run at +/// that level resolves to), so a relative change (grow/shrink) steps each level from its own value. +pub(crate) fn format_run_levels( + s: &mut Session, + f: &(dyn Fn(&mut RunProps) + Send + Sync), + lf: &(dyn Fn(&mut RunProps, &RunProps) + Send + Sync), +) -> Result { let st = s.doc()?; if let Some(t) = st.selection.text.clone() { let (a, b) = format_range(st, &t); - let collapsed = a == b; + // Only the levels of the paragraphs the selected range touches. + let levels = if a == b { Vec::new() } else { master_levels(st, &t, t.ordered()) }; edit_text(s, |body, _| { - if collapsed { - // Next typed text: put the props on an empty paragraph's end mark or keep pending. + if levels.is_empty() { ed::format(body, a, b, f); } else { - ed::format(body, a, b, f); + format_by_level(body, a, b, lf, &levels); + } + for (l, eff) in &levels { + let mut lvl = body.list_style.level(*l).cloned().unwrap_or_default(); + lf(&mut lvl.run, eff); + body.list_style.set(*l, lvl); } })?; - if collapsed { - // Remember as pending typing props by inserting nothing: handled via end mark for - // empty paragraphs; mid-paragraph carets keep surrounding formatting. - } return ok(); } let ids = st.selection.shapes.clone(); @@ -31,10 +43,21 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy return Err(bad("format", SELECT_TEXT_OR_SHAPE)); } let cells = st.selection.cells; - let on_master = editing_master(st); + // Effective run props of every level of the selected placeholders, resolved before editing. + let effective: Vec<(ShapeId, Vec)> = if editing_master(st) { + let ctx = crate::cmd::text::ctx_for(&st.doc, &st.selection); + ids.iter() + .filter_map(|id| { + let sh = st.shape(*id).filter(|sh| sh.ph.is_some())?; + let ctx = ctx.as_ref()?; + Some((*id, (0..LIST_LEVELS).map(|l| deckcraft_model::resolve::level_style(ctx, sh, l).run).collect())) + }) + .collect() + } else { + Vec::new() + }; edit_shapes(s, &json!({"ids": ids}), "format", |sh| { let id = sh.id; - let inherited = on_master && sh.ph.is_some(); match &mut sh.kind { ShapeKind::Table(t) => { // Only the selected cells, when a range of them is selected. @@ -49,11 +72,19 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy } _ => { let body = sh.text.get_or_insert_with(TextBody::default); - ed::format_all(body, f); - if inherited { - for l in 0..LIST_LEVELS { + let eff = effective.iter().find(|(i, _)| *i == id).map(|(_, e)| e); + match eff { + Some(eff) => { + let levels: Vec<(u8, RunProps)> = (0..LIST_LEVELS).zip(eff.iter().cloned()).collect(); + format_by_level(body, (0, 0), (usize::MAX, usize::MAX), lf, &levels); + } + None => ed::format_all(body, f), + } + // The whole shape is selected: every level of its list style. + if let Some(eff) = eff { + for (l, eff) in (0..LIST_LEVELS).zip(eff) { let mut lvl = body.list_style.level(l).cloned().unwrap_or_default(); - f(&mut lvl.run); + lf(&mut lvl.run, eff); body.list_style.set(l, lvl); } } @@ -67,6 +98,25 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy ok() } +/// Format the runs of [a, b) paragraph by paragraph, each with its own level's effective props, so +/// a relative change (grow/shrink) steps every paragraph from its own size instead of one shared +/// value. `a`/`b` are clamped to the body. +fn format_by_level(body: &mut TextBody, a: Pos, b: Pos, lf: &(dyn Fn(&mut RunProps, &RunProps) + Send + Sync), levels: &[(u8, RunProps)]) { + let Some(last) = body.paragraphs.len().checked_sub(1) else { return }; + let (first, end) = (a.0.min(last), b.0.min(last)); + for pi in first..=end { + let p = &body.paragraphs[pi]; + let from = if pi == first { a.1.min(p.char_len()) } else { 0 }; + let to = if pi == end { b.1.min(p.char_len()) } else { p.char_len() }; + if from == to && p.char_len() > 0 { + continue; + } + let lvl = p.level.min(LIST_LEVELS - 1); + let Some((_, eff)) = levels.iter().find(|(l, _)| *l == lvl) else { continue }; + ed::format(body, (pi, from), (pi, to), &|r| lf(r, eff)); + } +} + /// List-style levels (`lvl1pPr`–`lvl9pPr`) a placeholder passes on to the slides that use it. const LIST_LEVELS: u8 = 9; @@ -76,6 +126,27 @@ fn editing_master(st: &DocState) -> bool { matches!(st.selection.target, crate::Target::Layout { .. } | crate::Target::Master { .. }) } +/// The distinct list-style levels of the paragraphs `range` touches in a layout or master +/// placeholder, with each level's effective run props. Empty anywhere else. +fn master_levels(st: &DocState, t: &TextSel, range: (Pos, Pos)) -> Vec<(u8, RunProps)> { + if !editing_master(st) || t.notes || t.cell.is_some() { + return Vec::new(); + } + let (Some(sh), Some(body), Some(ctx)) = + (st.shape(t.shape).filter(|sh| sh.ph.is_some()), body_of(st, t), crate::cmd::text::ctx_for(&st.doc, &st.selection)) + else { + return Vec::new(); + }; + let mut levels: Vec = + body.paragraphs.iter().enumerate().filter(|(i, _)| (range.0.0..=range.1.0).contains(i)).map(|(_, p)| p.level.min(LIST_LEVELS - 1)).collect(); + if body.paragraphs.is_empty() { + levels.push(0); + } + levels.sort_unstable(); + levels.dedup(); + levels.into_iter().map(|l| (l, deckcraft_model::resolve::level_style(&ctx, sh, l).run)).collect() +} + /// What `format_run` formats while editing: the selection, or for a bare caret the word it's in /// (without its trailing space). Empty when the caret is at a word boundary. Insert Hyperlink /// edits this same span, so the dialog reads it too. @@ -128,6 +199,8 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex let st = s.doc()?; if let Some(t) = st.selection.text.clone() { let (a, b) = t.ordered(); + // Only the list-style levels of the paragraphs touched, on a layout or master placeholder. + let levels: Vec = master_levels(st, &t, (a, b)).into_iter().map(|(l, _)| l).collect(); edit_text(s, |body, _| { if body.paragraphs.is_empty() { body.paragraphs.push(Default::default()); @@ -138,6 +211,9 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex f(p); } } + for l in levels { + format_list_level(body, l, f); + } })?; return ok(); } @@ -167,16 +243,10 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex body.paragraphs.push(Default::default()); } body.paragraphs.iter_mut().for_each(f); + // The whole shape is selected: every level of its list style. if inherited { for l in 0..LIST_LEVELS { - let mut lvl = body.list_style.level(l).cloned().unwrap_or_default(); - let mut p = deckcraft_model::text::Paragraph { level: l, props: lvl.para, ..Default::default() }; - f(&mut p); - // A level change (indent/outdent) means nothing for a list-style level. - if p.level == l { - lvl.para = p.props; - body.list_style.set(l, lvl); - } + format_list_level(body, l, f); } } } @@ -189,6 +259,18 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex ok() } +/// Apply a paragraph change to one level of a body's list style. +fn format_list_level(body: &mut TextBody, l: u8, f: &(dyn Fn(&mut deckcraft_model::text::Paragraph) + Send + Sync)) { + let mut lvl = body.list_style.level(l).cloned().unwrap_or_default(); + let mut p = deckcraft_model::text::Paragraph { level: l, props: lvl.para, ..Default::default() }; + f(&mut p); + // A level change (indent/outdent) means nothing for a list-style level. + if p.level == l { + lvl.para = p.props; + body.list_style.set(l, lvl); + } +} + /// Apply body (text box) changes to the edited or selected shapes. pub(crate) fn format_body(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::text::BodyProps) + Send + Sync)) -> Result { let st = s.doc()?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5b3dcb03b..b3276046d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5982,3 +5982,52 @@ fn master_layout_placeholder_format_inherits() { assert_eq!(st["align"], "r", "slide placeholder {id}: {st}"); } } + +#[test] +fn master_body_placeholder_format_keeps_per_level_formatting() { + let mut s = Session::new(); + sample::open_sample(&mut s).unwrap(); + s.execute("view.slideMaster", &json!({"master": 0})).unwrap(); + let body_idx = + s.doc().unwrap().doc.masters[0].shapes.iter().position(|sh| sh.ph.as_ref().is_some_and(|p| p.kind == model::PhType::Body)).unwrap(); + let id = { + let st = s.doc_mut().unwrap(); + let sh = &mut std::sync::Arc::make_mut(&mut std::sync::Arc::make_mut(&mut st.doc).masters[0]).shapes[body_idx]; + let mut body = model::TextBody::from_text("Alpha\nBeta"); + body.paragraphs[1].level = 1; + for (l, size) in [(0u8, 32.0), (1, 28.0)] { + let mut lvl = model::text::LevelStyle::default(); + lvl.run.size = Some(size); + body.list_style.set(l, lvl); + } + sh.text = Some(body); + sh.id + }; + let level = + |s: &Session, l: u8| s.doc().unwrap().doc.masters[0].shapes[body_idx].text.as_ref().unwrap().list_style.level(l).cloned().unwrap_or_default(); + // Bolding the level-2 paragraph reaches level 2 only. + s.doc_mut().unwrap().selection.shapes = vec![id]; + s.doc_mut().unwrap().selection.text = Some(TextSel { shape: id, anchor: (1, 0), caret: (1, 4), cell: None, notes: false, line_end: None }); + s.execute("format.bold", &json!({"on": true})).unwrap(); + assert_eq!(level(&s, 1).run.bold, Some(true)); + for l in [0, 2, 3, 8] { + assert_eq!(level(&s, l).run.bold, None, "level {}", l + 1); + } + // Grow and shrink step each level from its own size. + s.doc_mut().unwrap().selection.text = None; + // The text itself: a run's explicit size, else its level's effective size. + let shown = |s: &Session| -> Vec> { + let st = s.doc().unwrap(); + let ctx = crate::cmd::text::ctx_for(&st.doc, &st.selection).unwrap(); + let sh = &st.doc.masters[0].shapes[body_idx]; + let body = sh.text.as_ref().unwrap(); + body.paragraphs.iter().map(|p| p.runs[0].props.size.or(deckcraft_model::resolve::level_style(&ctx, sh, p.level).run.size)).collect() + }; + s.execute("format.grow", &json!({})).unwrap(); + assert_eq!((level(&s, 0).run.size, level(&s, 1).run.size), (Some(36.0), Some(32.0))); + assert_eq!(shown(&s), vec![Some(36.0), Some(32.0)]); + s.execute("format.shrink", &json!({})).unwrap(); + s.execute("format.shrink", &json!({})).unwrap(); + 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)]); +} From ee4bdbcd7d5396964e13142f309710519180d2e5 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:23:00 -0500 Subject: [PATCH 18/88] fix: initialize slide action test run props --- crates/engine/src/cmd/inspect.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index e09cf14a0..1da38843f 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -239,8 +239,7 @@ mod tests { let mut child = Shape { id: ShapeId(12), name: "Child".into(), ..Default::default() }; child.click = Some(link(Action::NextSlide)); child.hover = Some(link(Action::Url { url: "https://example.com/hover".into() })); - let mut run_props = RunProps::default(); - run_props.link = Some(link(Action::Slide { slide: deckcraft_model::SlideId(7) })); + let run_props = RunProps { link: Some(link(Action::Slide { slide: deckcraft_model::SlideId(7) })), ..Default::default() }; let mut paragraph = Paragraph::new("linked"); paragraph.runs = vec![Run::with("linked", run_props)]; child.text = Some(TextBody { paragraphs: vec![paragraph], ..Default::default() }); @@ -268,8 +267,7 @@ mod tests { assert_eq!(actions[2]["owner"]["runIndex"], 0); assert_eq!(actions[2]["target"], 7); - let mut cell_props = RunProps::default(); - cell_props.hover = Some(link(Action::Url { url: "https://example.com/cell".into() })); + let cell_props = RunProps { hover: Some(link(Action::Url { url: "https://example.com/cell".into() })), ..Default::default() }; let mut cell_text = TextBody::from_text("cell link"); cell_text.paragraphs[0].runs = vec![Run::with("cell link", cell_props)]; let table = 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 19/88] 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 443f503407b58dcc3a08d40d5023a88949caf2b9 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 30 Oct 2026 23:16:32 +0000 Subject: [PATCH 20/88] Reject invalid index in transition commands instead of using selection Found while testing crates/engine/src/cmd/transition.rs:47-54 (slide_indices, via usize_param) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/transition.rs | 40 ++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/transition.rs b/crates/engine/src/cmd/transition.rs index 293b3d32c..6bd339563 100644 --- a/crates/engine/src/cmd/transition.rs +++ b/crates/engine/src/cmd/transition.rs @@ -6,7 +6,7 @@ use deckcraft_model::Transition; use deckcraft_model::anim::TRANSITIONS; use serde_json::{Value, json}; -use super::{CommandSpec, NO_ID_SPACE, always, bad, bool_param, cmd, has_slide, ok, str_param, usize_param}; +use super::{CommandSpec, NO_ID_SPACE, always, bad, bool_param, cmd, has_slide, ok, opt_usize_param, str_param}; use crate::{Result, Session}; pub fn specs() -> Vec { @@ -44,9 +44,9 @@ pub fn specs() -> Vec { ] } -fn slide_indices(s: &Session, p: &Value) -> Result> { +fn slide_indices(s: &Session, p: &Value, cmd: &str) -> Result> { let st = s.doc()?; - if let Some(i) = usize_param(p, "index") { + if let Some(i) = opt_usize_param(p, "index", cmd)? { return Ok(vec![i]); } let v: Vec = st.selection.slides.iter().filter_map(|id| st.doc.slide_index(*id)).collect(); @@ -59,7 +59,7 @@ fn set(s: &mut Session, p: &Value) -> Result { TRANSITIONS.iter().find(|t| t.0.eq_ignore_ascii_case(kind)).ok_or_else(|| bad("transition.set", format!("unknown transition `{kind}`")))?; let option = str_param(p, "option").map(String::from).or_else(|| info.4.first().map(|o| o.to_string())).unwrap_or_default(); let duration = p.get("duration").and_then(Value::as_u64).map(|v| v.min(59_000) as u32); - let list = slide_indices(s, p)?; + let list = slide_indices(s, p, "transition.set")?; s.edit(|doc, _| { for i in list { if let Some(sl) = doc.slides.get_mut(i) { @@ -83,7 +83,7 @@ fn set(s: &mut Session, p: &Value) -> Result { fn options(s: &mut Session, p: &Value) -> Result { let option = str_param(p, "option").ok_or_else(|| bad("transition.options", "missing `option`"))?.to_string(); - let list = slide_indices(s, p)?; + let list = slide_indices(s, p, "transition.options")?; s.edit(|doc, _| { for i in list { if let Some(sl) = doc.slides.get_mut(i) @@ -98,7 +98,7 @@ fn options(s: &mut Session, p: &Value) -> Result { } fn timing(s: &mut Session, p: &Value) -> Result { - let list = slide_indices(s, p)?; + let list = slide_indices(s, p, "transition.timing")?; s.edit(|doc, _| { for i in list { if let Some(sl) = doc.slides.get_mut(i) { @@ -127,7 +127,7 @@ fn timing(s: &mut Session, p: &Value) -> Result { /// Transitions › Sound: embed an audio file as the slides' transition sound, or `none` to remove it. fn sound(s: &mut Session, p: &Value) -> Result { - let list = slide_indices(s, p)?; + let list = slide_indices(s, p, "transition.sound")?; let looping = bool_param(p, "loop"); let edit_all = |s: &mut Session, f: &dyn Fn(&mut Transition)| { s.edit(|doc, _| { @@ -215,3 +215,29 @@ fn apply_all(s: &mut Session, _p: &Value) -> Result { fn list(_s: &mut Session, _p: &Value) -> Result { Ok(Value::Array(TRANSITIONS.iter().map(|t| json!({"id": t.0, "label": t.1, "category": t.2, "duration": t.3, "options": t.4})).collect())) } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn invalid_index_params_are_rejected_not_defaulted_to_selection() { + let mut s = Session::with_new(); + while s.doc().expect("doc").doc.slides.len() < 3 { + s.execute("slide.new", &json!({})).expect("new slide"); + } + for bad in [json!(-1), json!(1.5)] { + for (cmd, p) in [ + ("transition.set", json!({"kind": "fade", "index": bad})), + ("transition.options", json!({"option": "x", "index": bad})), + ("transition.timing", json!({"duration": 500, "index": bad})), + ("transition.sound", json!({"none": true, "index": bad})), + ] { + assert!(s.execute(cmd, &p).is_err(), "{cmd} must reject {p}"); + assert!(s.doc().expect("doc").doc.slides.iter().all(|sl| sl.transition.is_none()), "{cmd} {p}"); + } + } + } +} 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 21/88] 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 4c1d77ddce19d75d4cea08da4b61260df250ae31 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 30 Oct 2026 20:12:24 -0400 Subject: [PATCH 22/88] fix(flatpak): allow reading launcher version pins (#1575) * fix(flatpak): allow reading launcher version pins * fix(flatpak): allow reading launcher version pins --- packaging/linux/flatpak/ai.storyteller.deckcraft.bundle.yml | 1 + packaging/linux/flatpak/ai.storyteller.deckcraft.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/packaging/linux/flatpak/ai.storyteller.deckcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.deckcraft.bundle.yml index 230c22896..5b54f575f 100644 --- a/packaging/linux/flatpak/ai.storyteller.deckcraft.bundle.yml +++ b/packaging/linux/flatpak/ai.storyteller.deckcraft.bundle.yml @@ -17,6 +17,7 @@ runtime-version: '25.08' sdk: org.freedesktop.Sdk command: deckcraft finish-args: + - --filesystem=xdg-data/ArtCraft/versions:ro # Display: Wayland first; X11 only when no Wayland compositor is running. - --socket=wayland - --socket=fallback-x11 diff --git a/packaging/linux/flatpak/ai.storyteller.deckcraft.yml b/packaging/linux/flatpak/ai.storyteller.deckcraft.yml index b99365c28..8c1d1e24f 100644 --- a/packaging/linux/flatpak/ai.storyteller.deckcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.deckcraft.yml @@ -18,6 +18,7 @@ sdk-extensions: - org.freedesktop.Sdk.Extension.rust-stable command: deckcraft finish-args: + - --filesystem=xdg-data/ArtCraft/versions:ro # Display: Wayland first; X11 only when no Wayland compositor is running. - --socket=wayland - --socket=fallback-x11 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 23/88] 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 6113f4e1a29ef787cf07f3b71600e996e447e4c8 Mon Sep 17 00:00:00 2001 From: marshiyar <57055687+marshiyar@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:57:10 -0700 Subject: [PATCH 24/88] contributors: add names for marshiyar --- contributors/people.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index caf4daa46..f0425452f 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -71,3 +71,8 @@ real_name = "Sofia Alves Figueredo" [people.dylanmtaylor] sync_github_name = true + +[people.marshiyar] +display_name = "Arshiya" +real_name = "Arshiya Rahgozar" +sync_github_name = false 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 25/88] 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 ede7a7ead7fc7fa927ec6c312f3ec811fdb2cfbd Mon Sep 17 00:00:00 2001 From: marshiyar <57055687+marshiyar@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:19:00 -0700 Subject: [PATCH 26/88] ogg: bound damage recovery work and the warnings list What was wrong: when a page failed its CRC the demuxer had already read the whole page (up to about 64 KiB), then moved on by one byte and tried again. Nothing was charged for it, and only the scan for the next capture pattern had a budget, which a candidate that matches "OggS" at the current position never reaches. A file made of capture patterns 64 bytes apart, each claiming a full page, made a 1 MB file read about 957 MB and compute a CRC over all of it. The warnings list also grew with every damaged page, without limit. What changed: the per-file recovery budget (32 MiB, the scan's existing one) is now also charged with the length of every page read only to find its CRC wrong. When it is spent the pages found so far are returned with a warning saying the rest was not searched. At most 256 warnings are kept, plus one saying the rest were dropped. How it was tested: two new tests failed before the fix and pass after it, a counting ByteSource over 1 MB of dense fake pages (bytes read stay within the budget; before, 957 MB were read) and 100,000 bad-CRC pages (before, 199,999 warnings). A file of good pages is not charged and has no warnings. The other ogg tests still pass, and clippy is clean with -D warnings. Completes the Ogg part of storytold/filmcraft#2794 --- crates/ogg/src/lib.rs | 56 +++++++++++++++++++++++------ crates/ogg/src/tests.rs | 80 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 124 insertions(+), 12 deletions(-) diff --git a/crates/ogg/src/lib.rs b/crates/ogg/src/lib.rs index 69ff1858a..b009f0425 100644 --- a/crates/ogg/src/lib.rs +++ b/crates/ogg/src/lib.rs @@ -196,6 +196,21 @@ pub struct OggFile { pub warnings: Vec, } +/// Source bytes damage recovery may read per file: the windows the scan for the next capture +/// pattern fills and the pages read only to find their CRC wrong. +const RECOVERY_BUDGET: u64 = 32 << 20; +/// Warnings kept per file (not counting the one about the budget); the rest are counted in one last entry. +const MAX_WARNINGS: usize = 256; + +/// Record a warning, keeping at most `MAX_WARNINGS` and one entry saying the rest were dropped. +fn warn(warnings: &mut Vec, msg: String) { + match warnings.len().cmp(&MAX_WARNINGS) { + std::cmp::Ordering::Less => warnings.push(msg), + std::cmp::Ordering::Equal => warnings.push("further warnings omitted".into()), + std::cmp::Ordering::Greater => {} + } +} + struct Partial { parts: Vec<(u64, u32)>, size: u32, @@ -219,7 +234,7 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result { match scanner.find(src, pos + 1, len)? { Some(p) => { if found_any { - warnings.push(format!("lost sync at byte {pos}; resumed at {p}")); + warn(&mut warnings, format!("lost sync at byte {pos}; resumed at {p}")); } pos = p; continue; @@ -229,14 +244,14 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result { } let nseg = hdr[26] as usize; if pos + 27 + nseg as u64 > len { - warnings.push(format!("truncated page header at byte {pos}")); + warn(&mut warnings, format!("truncated page header at byte {pos}")); break; } src.read_at(pos + 27, &mut hdr[27..27 + nseg])?; let body_len: u64 = hdr[27..27 + nseg].iter().map(|&b| b as u64).sum(); let page_len = 27 + nseg as u64 + body_len; if pos + page_len > len { - warnings.push(format!("truncated page at byte {pos}")); + warn(&mut warnings, format!("truncated page at byte {pos}")); break; } let mut page = vec![0u8; page_len as usize]; @@ -244,7 +259,11 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result { let stored_crc = u32::from_le_bytes([page[22], page[23], page[24], page[25]]); page[22..26].fill(0); if crc32(&page) != stored_crc { - warnings.push(format!("bad CRC on page at byte {pos}")); + warn(&mut warnings, format!("bad CRC on page at byte {pos}")); + // a page read only to find it damaged is recovery work, charged like the scan's windows + if scanner.charge(page_len) { + break; + } pos += 1; continue; } @@ -275,7 +294,7 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result { // a continued page without a pending packet (lost a page): drop the fragment let continued = flags & 0x01 != 0; if !continued && partial.remove(&serial).is_some() { - warnings.push(format!("incomplete packet before page at byte {pos}")); + warn(&mut warnings, format!("incomplete packet before page at byte {pos}")); } let mut completed: Vec = Vec::new(); let mut off = 0u64; @@ -331,8 +350,12 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result { if !found_any { return Err(Error::NotOgg); } + if scanner.spent() { + // once per file, so it is not subject to the cap on the others + warnings.push("recovery budget spent; the rest of the damaged data was not searched".into()); + } if !partial.is_empty() { - warnings.push("file ends inside a packet".into()); + warn(&mut warnings, "file ends inside a packet".into()); } Ok(OggFile { streams, warnings }) } @@ -340,12 +363,23 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result { struct CaptureScanner { buf: Vec, buf_start: u64, - budget: usize, + budget: u64, } impl CaptureScanner { fn new() -> Self { - Self { buf: Vec::new(), buf_start: 0, budget: 32 * 1024 * 1024 } + Self { buf: Vec::new(), buf_start: 0, budget: RECOVERY_BUDGET } + } + + /// Charge `bytes` of recovery work; true once the budget is spent. + fn charge(&mut self, bytes: u64) -> bool { + self.budget = self.budget.saturating_sub(bytes); + self.spent() + } + + /// Nothing left to search with: fewer bytes than a capture pattern can still be charged for. + fn spent(&self) -> bool { + self.budget < 4 } fn find(&mut self, src: &(impl ByteSource + ?Sized), from: u64, len: u64) -> Result> { @@ -360,17 +394,17 @@ impl CaptureScanner { at = buf_end.saturating_sub(3); continue; } - if self.budget == 0 { + if self.spent() { return Ok(None); } - let n = ((len - at) as usize).min(64 * 1024).min(self.budget); + let n = ((len - at) as usize).min(64 * 1024).min(usize::try_from(self.budget).unwrap_or(usize::MAX)); if n < 4 { break; } self.buf.resize(n, 0); src.read_at(at, &mut self.buf)?; self.buf_start = at; - self.budget = self.budget.saturating_sub(n); + self.budget = self.budget.saturating_sub(n as u64); if let Some(i) = self.buf.windows(4).position(|w| w == b"OggS") { return Ok(Some(at + i as u64)); } diff --git a/crates/ogg/src/tests.rs b/crates/ogg/src/tests.rs index cf2720d88..480c1cda2 100644 --- a/crates/ogg/src/tests.rs +++ b/crates/ogg/src/tests.rs @@ -1,6 +1,9 @@ //! Synthetic Ogg streams: a small page writer (RFC 3533) and Opus-like packets. -use super::{Codec, Error, OpusTiming, Packet, Stream, crc32, open, opus_packet_samples, sniff}; +use std::cell::Cell; +use std::io; + +use super::{ByteSource, CaptureScanner, Codec, Error, OpusTiming, Packet, Stream, crc32, open, opus_packet_samples, sniff}; /// Write one page holding `segments` (lacing already computed by `lace`). fn page(out: &mut Vec, flags: u8, granule: i64, serial: u32, seq: u32, lacing: &[u8], body: &[u8]) { @@ -196,3 +199,78 @@ fn opus_timing_rejects_a_granule_that_overflows() { assert_eq!(ok.total, 480); assert!(OpusTiming::of(&packet(i64::MIN), 0).is_err()); } + +/// A source that counts the bytes read from it. +struct Counting<'a> { + data: &'a [u8], + read: Cell, +} + +impl ByteSource for Counting<'_> { + fn len(&self) -> u64 { + self.data.len() as u64 + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + self.read.set(self.read.get() + buf.len() as u64); + self.data.read_at(offset, buf) + } +} + +/// A valid file followed by `len` bytes of 0xFF with a capture pattern every 64 bytes. Each one +/// claims 255 full lacing values, so it reads as a page of about 64 KiB that fails its CRC. +fn dense_fake_pages(len: usize) -> Vec { + let mut f = opus_file(6, 312, 0); + let start = f.len(); + f.resize(start + len, 0xFF); + for at in (start..start + len - 64).step_by(64) { + f[at..at + 5].copy_from_slice(b"OggS\0"); + f[at + 26] = 255; + } + f +} + +#[test] +fn recovery_reads_a_bounded_amount_of_a_damaged_file() { + let f = dense_fake_pages(1 << 20); + let src = Counting { data: &f, read: Cell::new(0) }; + let o = open(&src).unwrap(); + assert_eq!(o.streams[0].packets.len(), 6, "the good pages are kept"); + let read = src.read.get(); + assert!(read <= 40 << 20, "read {read} bytes of a {} byte file", f.len()); + assert!(o.warnings.iter().any(|w| w.contains("recovery budget")), "{:?}", o.warnings); +} + +#[test] +fn warnings_are_capped() { + // 100,000 pages with no segments and a wrong CRC, 27 bytes each + let mut f = opus_file(6, 312, 0); + for _ in 0..100_000 { + let at = f.len(); + f.extend_from_slice(b"OggS\0\0"); + f.resize(at + 27, 0); + f[at + 22] = 1; + } + let o = open(&f).unwrap(); + assert!(o.warnings.len() <= 257, "{} warnings", o.warnings.len()); + assert!(o.warnings.iter().any(|w| w.contains("omitted")), "{:?}", o.warnings.last()); +} + +#[test] +fn a_file_of_good_pages_is_not_charged_for_recovery() { + let f = opus_file(2000, 312, 0); + let src = Counting { data: &f, read: Cell::new(0) }; + let o = open(&src).unwrap(); + assert_eq!(o.streams[0].packets.len(), 2000); + assert!(o.warnings.is_empty(), "{:?}", o.warnings); +} + +#[test] +fn a_budget_too_small_to_search_is_spent() { + // 1 to 3 bytes left cannot hold a capture pattern: the scan would stop without saying why + let mut scanner = CaptureScanner::new(); + assert!(!scanner.spent()); + assert!(scanner.charge(super::RECOVERY_BUDGET - 3)); + assert!(scanner.spent()); + let f = opus_file(6, 312, 0); + assert_eq!(scanner.find(&f, 0, f.len() as u64).unwrap(), None); +} From eaeb06c65581fb6f824112b641a9d8b35c3ea162 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 30 Oct 2026 22:59:19 -0400 Subject: [PATCH 27/88] Release 0.13.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 56 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a6ef452e9..a30b0c9d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -984,7 +984,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" [[package]] name = "deckcraft" -version = "0.12.0" +version = "0.13.0" dependencies = [ "arboard", "chrono", @@ -1006,7 +1006,7 @@ dependencies = [ [[package]] name = "deckcraft-anim" -version = "0.12.0" +version = "0.13.0" dependencies = [ "deckcraft-geom", "deckcraft-model", @@ -1015,7 +1015,7 @@ dependencies = [ [[package]] name = "deckcraft-av1" -version = "0.12.0" +version = "0.13.0" dependencies = [ "deckcraft-bitstream", "rayon", @@ -1024,14 +1024,14 @@ dependencies = [ [[package]] name = "deckcraft-bitstream" -version = "0.12.0" +version = "0.13.0" dependencies = [ "craft-media", ] [[package]] name = "deckcraft-cli" -version = "0.12.0" +version = "0.13.0" dependencies = [ "deckcraft-engine", "deckcraft-mcp", @@ -1041,14 +1041,14 @@ dependencies = [ [[package]] name = "deckcraft-color" -version = "0.12.0" +version = "0.13.0" dependencies = [ "serde", ] [[package]] name = "deckcraft-engine" -version = "0.12.0" +version = "0.13.0" dependencies = [ "chrono", "deckcraft-anim", @@ -1074,7 +1074,7 @@ dependencies = [ [[package]] name = "deckcraft-fonts" -version = "0.12.0" +version = "0.13.0" dependencies = [ "epaint_default_fonts", "harfrust 0.13.3", @@ -1086,7 +1086,7 @@ dependencies = [ [[package]] name = "deckcraft-format" -version = "0.12.0" +version = "0.13.0" dependencies = [ "deckcraft-model", "serde", @@ -1097,7 +1097,7 @@ dependencies = [ [[package]] name = "deckcraft-geom" -version = "0.12.0" +version = "0.13.0" dependencies = [ "kurbo", "serde", @@ -1105,7 +1105,7 @@ dependencies = [ [[package]] name = "deckcraft-h264" -version = "0.12.0" +version = "0.13.0" dependencies = [ "deckcraft-bitstream", "rayon", @@ -1114,7 +1114,7 @@ dependencies = [ [[package]] name = "deckcraft-hevc" -version = "0.12.0" +version = "0.13.0" dependencies = [ "deckcraft-bitstream", "rayon", @@ -1123,21 +1123,21 @@ dependencies = [ [[package]] name = "deckcraft-isobmff" -version = "0.12.0" +version = "0.13.0" dependencies = [ "craft-media", ] [[package]] name = "deckcraft-matroska" -version = "0.12.0" +version = "0.13.0" dependencies = [ "craft-media", ] [[package]] name = "deckcraft-mcp" -version = "0.12.0" +version = "0.13.0" dependencies = [ "deckcraft-engine", "deckcraft-render", @@ -1147,7 +1147,7 @@ dependencies = [ [[package]] name = "deckcraft-media" -version = "0.12.0" +version = "0.13.0" dependencies = [ "deckcraft-av1", "deckcraft-h264", @@ -1165,7 +1165,7 @@ dependencies = [ [[package]] name = "deckcraft-metafile" -version = "0.12.0" +version = "0.13.0" dependencies = [ "image", "kurbo", @@ -1174,7 +1174,7 @@ dependencies = [ [[package]] name = "deckcraft-model" -version = "0.12.0" +version = "0.13.0" dependencies = [ "deckcraft-color", "deckcraft-geom", @@ -1186,15 +1186,15 @@ dependencies = [ [[package]] name = "deckcraft-ogg" -version = "0.12.0" +version = "0.13.0" [[package]] name = "deckcraft-opus" -version = "0.12.0" +version = "0.13.0" [[package]] name = "deckcraft-pdf" -version = "0.12.0" +version = "0.13.0" dependencies = [ "craft-pdf", "deckcraft-fonts", @@ -1212,7 +1212,7 @@ dependencies = [ [[package]] name = "deckcraft-pptx" -version = "0.12.0" +version = "0.13.0" dependencies = [ "deckcraft-color", "deckcraft-engine", @@ -1228,7 +1228,7 @@ dependencies = [ [[package]] name = "deckcraft-render" -version = "0.12.0" +version = "0.13.0" dependencies = [ "deckcraft-anim", "deckcraft-color", @@ -1247,7 +1247,7 @@ dependencies = [ [[package]] name = "deckcraft-text" -version = "0.12.0" +version = "0.13.0" dependencies = [ "chrono", "deckcraft-color", @@ -1261,7 +1261,7 @@ dependencies = [ [[package]] name = "deckcraft-ui-egui" -version = "0.12.0" +version = "0.13.0" dependencies = [ "craft-ui", "craft-update", @@ -1286,7 +1286,7 @@ dependencies = [ [[package]] name = "deckcraft-vp9" -version = "0.12.0" +version = "0.13.0" dependencies = [ "deckcraft-bitstream", "rayon", @@ -1295,7 +1295,7 @@ dependencies = [ [[package]] name = "deckcraft-web" -version = "0.12.0" +version = "0.13.0" dependencies = [ "deckcraft-engine", "deckcraft-ui-egui", @@ -6177,7 +6177,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.12.0" +version = "0.13.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 3eada7087..79d9f5356 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"] [workspace.package] -version = "0.12.0" +version = "0.13.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From e83f964077ad650006a5596c28462e6aa2d01d6a Mon Sep 17 00:00:00 2001 From: marshiyar <57055687+marshiyar@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:13:00 -0700 Subject: [PATCH 28/88] mcp: insert_chart takes x, y, w and h What was wrong: the insert_chart tool had no x, y, w or h in its schema, so the server refused them as unknown arguments, while insert_picture and insert_table take them. A chart could only be placed at the default box, [144, 81, 672, 378] on a 960x540 slide, and then had to be moved with a second command. What changed: insert_chart declares x, y, w and h and folds them into the engine's `rect` when all four are given, as insert_picture and insert_table do. Without all four the chart keeps its default place. How it was tested: insert_chart_takes_a_box failed before the change (the arguments were refused) and passes after it. It checks the box of the placed chart, that a partial box leaves the default place, and that an extreme box is answered without a panic. The rest of the deckcraft-mcp tests pass, and clippy is clean with -D warnings. Fixes #1151 --- crates/mcp/src/tests.rs | 34 ++++++++++++++++++++++++++++++++++ crates/mcp/src/tools.rs | 12 +++++++++--- docs/mcp.md | 2 +- 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index bfffa83d0..665591872 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -62,6 +62,40 @@ fn handshake_and_tool_list() { assert!(s.handle_line("{}").is_some()); } +/// #1151: `insert_chart` takes x, y, w and h like `insert_picture` and `insert_table`. +#[test] +fn insert_chart_takes_a_box() { + let mut s = Server::new(Box::new(Headless::new())); + tool(&mut s, "new_presentation", json!({"theme": "Harbor"})); + let chart = json!({"type": "pie", "categories": ["A", "B"], "series": [{"name": "S", "values": [3, 5]}]}); + let box_of = |s: &mut Server, id: u64| { + let sl = tool(s, "inspect_slide", json!({})); + let shape = sl["shapes"].as_array().unwrap().iter().find(|x| x["id"].as_u64() == Some(id)).unwrap().clone(); + assert_eq!(shape["kind"], "chart"); + shape["box"].clone() + }; + let mut args = chart.clone(); + for (k, v) in [("x", 50), ("y", 60), ("w", 300), ("h", 200)] { + args[k] = json!(v); + } + let placed = tool(&mut s, "insert_chart", args)["id"].as_u64().unwrap(); + assert_eq!(box_of(&mut s, placed), json!([50.0, 60.0, 300.0, 200.0])); + // without a full box the chart keeps its default place, as for pictures and tables + let plain = tool(&mut s, "insert_chart", chart.clone())["id"].as_u64().unwrap(); + let default_box = box_of(&mut s, plain); + let mut partial = chart.clone(); + partial["x"] = json!(50); + let partial_id = tool(&mut s, "insert_chart", partial)["id"].as_u64().unwrap(); + assert_eq!(box_of(&mut s, partial_id), default_box); + // a hostile box is answered, not a panic + let mut odd = chart; + for (k, v) in [("x", json!(-1e300)), ("y", json!(1e300)), ("w", json!(-5)), ("h", json!(0))] { + odd[k] = v; + } + let r = call(&mut s, 11, "tools/call", json!({"name": "insert_chart", "arguments": odd})); + assert!(r["result"].is_object(), "{r}"); +} + #[test] fn agent_builds_a_deck() { let mut s = Server::new(Box::new(Headless::new())); diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index c4f7e85da..2bf09b8a9 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -234,9 +234,9 @@ pub fn tool_definitions() -> Vec { tool( "insert_chart", "Insert chart", - "Insert a chart (column, bar, line, pie, doughnut, area, scatter, stackedColumn…) with categories and series.", + "Insert a chart (column, bar, line, pie, doughnut, area, scatter, stackedColumn…) with categories and series, optionally at a box.", obj( - json!({"type": string("Chart type"), "title": string("Chart title"), "categories": {"type": "array", "items": {"type": "string"}}, "series": {"type": "array", "items": obj(json!({"name": string("Series name"), "values": {"type": "array", "items": {"type": "number"}}}), &["values"])}}), + json!({"type": string("Chart type"), "title": string("Chart title"), "categories": {"type": "array", "items": {"type": "string"}}, "series": {"type": "array", "items": obj(json!({"name": string("Series name"), "values": {"type": "array", "items": {"type": "number"}}}), &["values"])}, "x": num("Left"), "y": num("Top"), "w": num("Width"), "h": num("Height")}), &[], ), false, @@ -444,7 +444,13 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult { } wrap(exec(b, "insert.table", p)) } - "insert_chart" => wrap(exec(b, "insert.chart", a)), + "insert_chart" => { + let mut p = a.clone(); + if let (Some(x), Some(y), Some(w), Some(h)) = (f(&a, "x"), f(&a, "y"), f(&a, "w"), f(&a, "h")) { + p["rect"] = json!([x, y, w, h]); + } + wrap(exec(b, "insert.chart", p)) + } "select" => wrap(exec(b, "edit.select", a)), "pointer" => wrap(b.call("ui.pointer", a)), "select_tool" => wrap(b.call("ui.tool.select", a)), diff --git a/docs/mcp.md b/docs/mcp.md index f936af660..7da2ffb3b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -96,7 +96,7 @@ files it was given rather than guarding against other software on the computer. | `screenshot` | The whole window, UI included (connected mode) | | `call_app` | Any [control protocol](control-protocol.md) method (connected mode) | -A tool refuses an argument that is not in its schema (`insert_chart: unknown argument "data" (expected: categories, series, title, type)`). Keys inside `run_command`'s `params` are that command's parameters. +A tool refuses an argument that is not in its schema (`insert_chart: unknown argument "data" (expected: categories, h, series, title, type, w, x, y)`). Keys inside `run_command`'s `params` are that command's parameters. Useful commands for building decks: `slide.new {layout, title, body}`, `shape.insert {preset, rect, text}`, `shape.connect {from, to, preset}`, `shape.freeform {points, closed, smooth}`, From b7482dd392aeedae8ff51543668aefb8340066b9 Mon Sep 17 00:00:00 2001 From: marshiyar <57055687+marshiyar@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:13:05 -0700 Subject: [PATCH 29/88] engine: animation.add and animation.set take the points of a custom motion path What was wrong: animation.add ignored a `path` parameter, so a customPath animation always got the default straight path of a quarter of the slide height, and no command could set or change the points of one. What changed: animation.add, animation.set and animation.options accept `path` for a motion path: either points [[x, y], ...] in slide points, as offsets from where the shape sits (converted to the stored slide-fraction path; `closed` returns to the first point), or the OOXML path string animation.get answers. At most 1000 points, finite numbers within 1e6 points, and strings of at most 16 KiB made of path commands and numbers are accepted; anything else is refused before the deck changes, as is `path` on an animation that is not a motion path. animation.add answers the stored path. How it was tested: three new tests in cmd/animation.rs: points become the expected path (add, set with `closed`, back to the default without `path`), a raw string is kept and animation.options replaces it, and malformed paths are refused with nothing added. The animation tests pass, and clippy is clean with -D warnings. Fixes #1576 --- crates/engine/src/cmd/animation.rs | 158 +++++++++++++++++++++++++++-- 1 file changed, 149 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/animation.rs b/crates/engine/src/cmd/animation.rs index b5d00bba1..e69d97b67 100644 --- a/crates/engine/src/cmd/animation.rs +++ b/crates/engine/src/cmd/animation.rs @@ -17,7 +17,7 @@ pub fn specs() -> Vec { "Animation Styles", ["Animations", "Animation"], None, - "{effect: fade|fly|zoom|wipe|appear|spin|pulse|fadeOut|lines|… (animation.list), class?: entrance|emphasis|exit|path, option?, ids?} — replaces the shapes' animations", + "{effect: fade|fly|zoom|wipe|appear|spin|pulse|fadeOut|lines|… (animation.list), class?: entrance|emphasis|exit|path, option?, path?: [[x,y],…] or OOXML string, closed?: bool, ids?} — replaces the shapes' animations. path (motion paths): points in slide points, as offsets from where the shape sits, or the OOXML path string (slide fractions) animation.get answers", has_selection, set ), @@ -26,7 +26,7 @@ pub fn specs() -> Vec { "Add Animation", ["Animations", "Advanced Animation"], None, - "{effect, class?, option?, start?: onClick|withPrevious|afterPrevious, duration?: ms, delay?: ms, ids?}", + "{effect, class?, option?, path?: [[x,y],…] or OOXML string, closed?: bool, start?: onClick|withPrevious|afterPrevious, duration?: ms, delay?: ms, ids?} → {count, path?}", has_selection, add ), @@ -46,7 +46,7 @@ pub fn specs() -> Vec { "Effect Options", ["Animations", "Animation"], None, - "{option?, textBuild?: asOne|byParagraph|byWord|byLetter, index?}", + "{option?, textBuild?: asOne|byParagraph|byWord|byLetter, path?: [[x,y],…] or OOXML string, closed?: bool, index?}", has_slide, options ), @@ -129,7 +129,7 @@ fn resolve_option(allowed: &[&str], given: &str) -> Option { cands.into_iter().find(|c| allowed.contains(&c.as_str())) } -fn build(p: &Value) -> Result { +fn build(p: &Value, slide: (f64, f64)) -> Result { let effect = str_param(p, "effect").ok_or_else(|| bad("animation", "missing `effect`"))?; let class = class_of(str_param(p, "class")); let info = match class { @@ -155,7 +155,9 @@ fn build(p: &Value) -> Result { a.start = start_of(st); } if a.class == AnimClass::Path { - a.path = Some(default_path(&a.effect, &a.option)); + a.path = Some(path_param(p, slide)?.unwrap_or_else(|| default_path(&a.effect, &a.option))); + } else if p.get("path").is_some() { + return Err(bad("animation", "`path` only fits a motion path (class `path`)")); } if a.effect == "spin" { a.amount = Some(360.0); @@ -166,6 +168,60 @@ fn build(p: &Value) -> Result { Ok(a) } +/// Most points of a motion path given as an array, and longest path string given as text. +const MAX_PATH_POINTS: usize = 1_000; +const MAX_PATH_TEXT: usize = 16 * 1024; +/// Largest offset of a point, in slide points; anything beyond is not a place on any slide. +const MAX_PATH_OFFSET: f64 = 1.0e6; + +/// A number of a path string: slide fractions, rounded to 6 places. +fn path_num(v: f64) -> String { + let t = format!("{:.6}", v); + let t = t.trim_end_matches('0').trim_end_matches('.'); + if t == "-0" || t.is_empty() { "0".into() } else { t.into() } +} + +/// The motion path `path` asks for, as the OOXML path string animations store: either points +/// (`[[x, y], …]`, slide points as offsets from where the shape sits, joined by lines, `closed?` +/// to return to the first) or that string itself. +fn path_param(p: &Value, slide: (f64, f64)) -> Result> { + let Some(v) = p.get("path") else { return Ok(None) }; + let refuse = |why: &str| bad("animation", format!("`path` {why}")); + match v { + Value::String(text) => { + let ok = text.len() <= MAX_PATH_TEXT + && text.contains(|c: char| c.is_ascii_digit()) + && text.chars().all(|c| c.is_ascii_digit() || " .+-eE,MmLlCcQqHhVvZz".contains(c)); + if !ok { + return Err(refuse("string must be an OOXML motion path such as `M 0 0 L 0.25 0 E` (at most 16 KiB)")); + } + Ok(Some(text.clone())) + } + Value::Array(points) => { + if !(2..=MAX_PATH_POINTS).contains(&points.len()) { + return Err(refuse(&format!("needs 2 to {MAX_PATH_POINTS} points"))); + } + if !(slide.0.is_finite() && slide.1.is_finite() && slide.0 > 0.0 && slide.1 > 0.0) { + return Err(refuse("can't be placed: the slide has no size")); + } + let mut out = String::new(); + for (k, pt) in points.iter().enumerate() { + let xy = pt.as_array().filter(|a| a.len() == 2).and_then(|a| Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?))); + let Some((x, y)) = xy.filter(|(x, y)| x.abs() <= MAX_PATH_OFFSET && y.abs() <= MAX_PATH_OFFSET) else { + return Err(refuse(&format!("point {k} must be [x, y], two finite numbers within ±{MAX_PATH_OFFSET} points"))); + }; + out.push_str(&format!("{} {} {} ", if k == 0 { "M" } else { "L" }, path_num(x / slide.0), path_num(y / slide.1))); + } + if p.get("closed").and_then(Value::as_bool) == Some(true) { + out.push_str("Z "); + } + out.push('E'); + Ok(Some(out)) + } + _ => Err(refuse("must be [[x, y], …] or an OOXML path string")), + } +} + /// Default motion paths, in slide-fraction units relative to the shape's position. pub fn default_path(effect: &str, option: &str) -> String { let (dx, dy) = match option { @@ -185,7 +241,7 @@ pub fn default_path(effect: &str, option: &str) -> String { fn set(s: &mut Session, p: &Value) -> Result { let ids = targets(s, p)?; - let a = build(p)?; + let a = build(p, slide_extent(s)?)?; s.edit(|doc, sel| { let sl = doc.slides.get_mut(sel.slide).ok_or_else(|| bad("animation.set", "no slide"))?; let sl = Arc::make_mut(sl); @@ -215,9 +271,16 @@ fn set(s: &mut Session, p: &Value) -> Result { }) } +/// The slide's width and height in points. +fn slide_extent(s: &mut Session) -> Result<(f64, f64)> { + let size = &s.doc()?.doc.slide_size; + Ok((size.width, size.height)) +} + fn add(s: &mut Session, p: &Value) -> Result { let ids = targets(s, p)?; - let a = build(p)?; + let a = build(p, slide_extent(s)?)?; + let path = a.path.clone(); s.edit(|doc, sel| { let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("animation.add", "no slide"))?); for (k, id) in ids.iter().enumerate() { @@ -227,7 +290,10 @@ fn add(s: &mut Session, p: &Value) -> Result { } sl.animations.push(x); } - Ok(json!({"count": sl.animations.len()})) + Ok(match &path { + Some(path) => json!({"count": sl.animations.len(), "path": path}), + None => json!({"count": sl.animations.len()}), + }) }) } @@ -320,13 +386,17 @@ fn options(s: &mut Session, p: &Value) -> Result { "byLetter" => TextBuild::ByLetter, _ => TextBuild::AsOne, }); + let path = path_param(p, slide_extent(s)?)?; each_target(s, p, |a| { + if let (Some(path), AnimClass::Path) = (&path, a.class) { + a.path = Some(path.clone()); + } if let Some(o) = &option { // Each target takes the option in its own effect's terms; one it has no such // option for keeps its own. let allowed = animation_info(&a.effect, a.class).map(|i| i.5).unwrap_or(&[]); if let Some(o) = resolve_option(allowed, o) { - if a.class == AnimClass::Path { + if a.class == AnimClass::Path && path.is_none() { a.path = Some(default_path(&a.effect, &o)); } a.option = o; @@ -606,4 +676,74 @@ mod tests { assert_eq!(s.execute("animation.get", &json!({})).unwrap().as_array().unwrap().len(), 1); } } + + fn path_of(s: &mut Session) -> Option { + let all = s.execute("animation.get", &json!({})).unwrap(); + all.as_array().unwrap().last().and_then(|a| a.get("path")).and_then(Value::as_str).map(String::from) + } + + fn shape(s: &mut Session) -> u64 { + s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [100.0, 400.0, 20.0, 20.0]})).unwrap().get("id").unwrap().as_u64().unwrap() + } + + /// #1576: points in slide points, as offsets from the shape, become the slide-fraction path. + #[test] + fn a_custom_path_takes_its_points() { + let mut s = Session::with_new(); + let id = shape(&mut s); + let w = s.doc().unwrap().doc.slide_size.width; + let h = s.doc().unwrap().doc.slide_size.height; + let r = s + .execute( + "animation.add", + &json!({"effect": "customPath", "class": "path", "path": [[0, 0], [w / 2.0, -h / 4.0], [w, -h / 2.0]], "ids": [id]}), + ) + .unwrap(); + assert_eq!(r["path"], "M 0 0 L 0.5 -0.25 L 1 -0.5 E"); + assert_eq!(path_of(&mut s).as_deref(), Some("M 0 0 L 0.5 -0.25 L 1 -0.5 E")); + // closed shapes end back at the start; set replaces; no path keeps the default + s.execute("animation.set", &json!({"effect": "customPath", "path": [[0, 0], [w / 4.0, 0], [w / 4.0, h / 4.0]], "closed": true, "ids": [id]})) + .unwrap(); + assert_eq!(path_of(&mut s).as_deref(), Some("M 0 0 L 0.25 0 L 0.25 0.25 Z E")); + s.execute("animation.set", &json!({"effect": "customPath", "ids": [id]})).unwrap(); + assert_eq!(path_of(&mut s).as_deref(), Some("M 0 0 L 0 0.25 E")); + } + + #[test] + fn a_raw_path_string_is_kept_and_options_edits_it() { + let mut s = Session::with_new(); + let id = shape(&mut s); + s.execute("animation.add", &json!({"effect": "customPath", "path": "M 0 0 C 0.1 0 0.2 0.1 0.3 0.1 E", "ids": [id]})).unwrap(); + assert_eq!(path_of(&mut s).as_deref(), Some("M 0 0 C 0.1 0 0.2 0.1 0.3 0.1 E")); + s.execute("animation.options", &json!({"index": 0, "path": [[0, 0], [96, 54]]})).unwrap(); + assert_eq!(path_of(&mut s).as_deref(), Some("M 0 0 L 0.1 0.1 E")); + } + + #[test] + fn a_bad_path_is_refused_and_changes_nothing() { + let mut s = Session::with_new(); + let id = shape(&mut s); + let many: Vec = (0..2_000).map(|i| json!([i, i])).collect(); + for bad_path in [ + json!([[0, 0]]), + json!([]), + json!([[0, 0], [1]]), + json!([[0, 0], ["a", 1]]), + json!([[0, 0], [1e300, 1]]), + json!([[0, 0], [null, 1]]), + json!("M 0 0 L