From cc6773c95f1f23800c82329ec404f68868c8b22b Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:02:59 +0200 Subject: [PATCH 01/79] Use evaluated field values in the placed text so the PDF text layer shows the slide number (#1153) --- crates/render/src/placed.rs | 21 ++++++++++++++++++--- crates/render/src/tests.rs | 12 ++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/crates/render/src/placed.rs b/crates/render/src/placed.rs index 0164f3f07..f83af46d8 100644 --- a/crates/render/src/placed.rs +++ b/crates/render/src/placed.rs @@ -2,9 +2,9 @@ //! that put real text over a raster (PDF text layer, links) and for text extraction. use deckcraft_model::resolve::{self, Ctx}; -use deckcraft_model::text::{Hyperlink, TextBody}; +use deckcraft_model::text::{Hyperlink, RunKind, TextBody}; use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind}; -use deckcraft_text::{Opts, TextLayout}; +use deckcraft_text::{Fields, Opts, TextLayout}; use kurbo::{Affine, Rect}; use crate::{SlideFields, shape_geometry, table}; @@ -162,7 +162,22 @@ fn place_text(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, owner: ShapeId let r = Rect::new(x0, line.top, x1, line.bottom); out.links.push(PlacedLink { rect: m.transform_rect_bbox(r), link, shape: owner, text: true }); } - out.texts.push(PlacedText { layout, transform: m, body: body.clone() }); + // Field runs carry their saved text (`‹#›`); the layout drew their values, so the body that + // goes with it (character offsets, text layer) must hold the same values. + let mut body = body.clone(); + for para in &mut body.paragraphs { + let src = para.clone(); + for run in &mut para.runs { + if let RunKind::Field { field } = &run.kind { + let lang = resolve::run(rctx, s, &src, &run.props).lang; + if let Some(v) = fields.field_in(field, lang.as_deref()) { + run.text = v; + run.kind = RunKind::Text; + } + } + } + } + out.texts.push(PlacedText { layout, transform: m, body }); } /// The hyperlink of the model run containing character `at` of paragraph `para`. diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 4ee1c889d..9987bd7c9 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -2595,3 +2595,15 @@ fn empty_bullet_is_gray_only_while_editing_and_never_in_exports() { assert!(changed.iter().any(|(px, _)| px[0] == 179 && px[1] == 179 && px[2] == 179)); assert!(changed.iter().all(|(px, _)| px[0] >= 179 && px[0] == px[1] && px[1] == px[2])); } + +#[test] +fn placed_text_holds_the_field_value_not_the_saved_placeholder() { + let mut p = deck_with(vec![field_shape(Some("slidenum"), "<#>")]); + let second = p.slides[0].clone(); + p.slides.push(second); + let placed = place_slide(&p, 1); + assert_eq!(placed.texts.len(), 1); + assert_eq!(placed.texts[0].body.text(), "2"); + // The layout was drawn from the same value, so the offsets agree with the body. + assert_eq!(placed.texts[0].layout.runs.iter().map(|r| r.chars.1).max(), Some(1)); +} From 813378cc20aa0d94228bb3f700645e7d546b795d Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:08:53 +0200 Subject: [PATCH 02/79] Keep the PDF text layer aligned with line breaks, use one clock reading for raster and text layer, and test the page text (#1153) --- crates/pdf/src/lib.rs | 9 +++--- crates/pdf/src/tests.rs | 61 ++++++++++++++++++++++++++++++++++--- crates/render/src/lib.rs | 2 +- crates/render/src/placed.rs | 30 ++++++++++-------- crates/render/src/tests.rs | 9 ++++++ 5 files changed, 89 insertions(+), 22 deletions(-) diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 22dc42d36..cc5d5e56a 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -299,7 +299,7 @@ pub fn master_text_pieces(pres: &Presentation, m: &deckcraft_model::Master, pape .body .paragraphs .get(run.para) - .map(|p| p.runs.iter().map(|r| r.text.as_str()).collect::()) + .map(|p| p.runs.iter().map(|r| r.content()).collect::()) .unwrap_or_default() .chars() .skip(run.chars.0) @@ -440,7 +440,8 @@ impl Exporter<'_> { let k = b.width() / sw; // Raster at the requested dpi relative to the box size on paper. let scale = (b.width() / 72.0 * self.opts.dpi.clamp(36.0, 600.0)) / sw; - let ropts = RenderOpts { scale, threads: 4, ..Default::default() }; + // One clock reading for the raster and the text layer, so a date field cannot differ between them. + let ropts = RenderOpts { scale, threads: 4, now: deckcraft_text::datetime::DateTime::now_local(), ..Default::default() }; // Two rasters per slide: the backdrop (background, master and layout graphics) is the same // on every slide that shares its layout, so the writer stores it once, and the slide's own // shapes draw over it (#845). @@ -476,7 +477,7 @@ impl Exporter<'_> { } if self.opts.text_layer { let view = Affine::translate((b.x0, b.y0)) * Affine::scale(k); - for pt in deckcraft_render::place_slide(pres, i).texts { + for pt in deckcraft_render::place_slide_with(pres, i, &ropts).texts { self.text_layer(s, &pt, view, false)?; } let _ = sh; @@ -494,7 +495,7 @@ impl Exporter<'_> { .body .paragraphs .get(run.para) - .map(|p| p.runs.iter().map(|r| r.text.as_str()).collect::()) + .map(|p| p.runs.iter().map(|r| r.content()).collect::()) .unwrap_or_default() .chars() .skip(run.chars.0) diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 2bc5c41ac..fd1839377 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -200,8 +200,6 @@ fn unembeddable_font_returns_error_instead_of_a_pdf_missing_text() { #[cfg_attr(not(target_arch = "wasm32"), test)] fn supported_unicode_is_shaped_and_recovered_from_the_page_text_layer() { use deckcraft_model::{Shape, ShapeId, Xfrm}; - use hayro_syntax::content::ops::TypedInstruction; - use hayro_syntax::object::{Dict, Object, Stream}; let text = "Café Ελληνικά Привет"; let face = FontDb::global().face("Ubuntu", "Regular"); @@ -223,8 +221,17 @@ fn supported_unicode_is_shaped_and_recovered_from_the_page_text_layer() { assert!(runs.iter().all(|run| run.face.family == "Ubuntu" && !run.glyphs.is_empty() && run.glyphs.iter().all(|glyph| glyph.0 != 0))); let bytes = export(&p, &opts(PageLayout::Slides)).unwrap(); - let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap(); - let page = &pdf.pages()[0]; + assert_eq!(page_text(&bytes, 0), text); +} + +/// The text of page `index`, read the way a PDF reader does: the content stream's text-showing +/// operators decoded through the page font's ToUnicode map. +fn page_text(bytes: &[u8], index: usize) -> String { + use hayro_syntax::content::ops::TypedInstruction; + use hayro_syntax::object::{Dict, Object, Stream}; + + let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).unwrap(); + let page = &pdf.pages()[index]; let resources = page.raw().get::>(b"Resources").unwrap(); let fonts = resources.get::>(b"Font").unwrap(); assert_eq!(fonts.len(), 1); @@ -275,7 +282,51 @@ fn supported_unicode_is_shaped_and_recovered_from_the_page_text_layer() { _ => {} } } - assert_eq!(extracted, text); + extracted +} + +fn ubuntu_runs(runs: Vec) -> TextBody { + let paragraph = deckcraft_model::text::Paragraph { runs, ..Default::default() }; + let mut body = TextBody { paragraphs: vec![paragraph], ..Default::default() }; + for run in &mut body.paragraphs[0].runs { + run.props.font = Some("Ubuntu".into()); + run.props.size = Some(20.0); + } + body +} + +fn deck_with_body(n: usize, body: TextBody) -> Presentation { + use deckcraft_model::{Shape, ShapeId, Xfrm}; + let mut p = deck(n); + for slide in &mut p.slides { + Arc::make_mut(slide).shapes = + vec![Shape { id: ShapeId(9001), xfrm: Some(Xfrm::new(20.0, 20.0, 920.0, 100.0)), text: Some(body.clone()), ..Default::default() }]; + } + p +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn slide_number_field_is_recovered_from_the_page_text_layer() { + use deckcraft_model::text::{Run, RunKind}; + let mut field = Run::new("<#>"); + field.kind = RunKind::Field { field: "slidenum".into() }; + let p = deck_with_body(3, ubuntu_runs(vec![Run::new("Page "), field])); + let bytes = export(&p, &opts(PageLayout::Slides)).unwrap(); + for (i, want) in ["Page 1", "Page 2", "Page 3"].into_iter().enumerate() { + assert_eq!(page_text(&bytes, i), want); + } +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn line_break_keeps_the_text_layer_aligned_with_the_layout() { + use deckcraft_model::text::{Run, RunKind}; + let mut brk = Run::new(""); + brk.kind = RunKind::Break; + let p = deck_with_body(1, ubuntu_runs(vec![Run::new("one"), brk, Run::new("two")])); + let bytes = export(&p, &opts(PageLayout::Slides)).unwrap(); + assert_eq!(page_text(&bytes, 0), "onetwo"); } /// Notes pages and handouts print on the chosen paper, landscape included, with every slide box diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index cf124a9f6..722162721 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -53,7 +53,7 @@ pub use extent::{Region, TILE, layout_extent, pasteboard_region, slide_extent}; pub use images::{decode as decode_image, gif_frame}; pub use morph_path::PathMorph; pub use morph_text::TextMorph; -pub use placed::{Placed, PlacedLink, PlacedShape, PlacedText, place_master_texts, place_slide, text_outline}; +pub use placed::{Placed, PlacedLink, PlacedShape, PlacedText, place_master_texts, place_slide, place_slide_with, text_outline}; pub use table::cell_body as table_cell_body; pub use table::cell_rects as table_cell_rects; pub use table::row_heights as table_row_heights; diff --git a/crates/render/src/placed.rs b/crates/render/src/placed.rs index f83af46d8..2081b2400 100644 --- a/crates/render/src/placed.rs +++ b/crates/render/src/placed.rs @@ -52,10 +52,16 @@ pub struct Placed { /// Text and links of slide `index` (master and layout graphics first, as rendered). pub fn place_slide(pres: &Presentation, index: usize) -> Placed { + place_slide_with(pres, index, &crate::RenderOpts::default()) +} + +/// [`place_slide`] with the render options the slide was drawn with, so date fields take the same +/// date and time as the raster (`opts.now`). +pub fn place_slide_with(pres: &Presentation, index: usize, opts: &crate::RenderOpts) -> Placed { let mut out = Placed::default(); let Some(slide) = pres.slides.get(index) else { return out }; let Some(rctx) = Ctx::for_slide(pres, slide) else { return out }; - let fields = SlideFields::new(pres.slide_number(index), pres.header_footer.footer_text.clone(), &crate::RenderOpts::default()); + let fields = SlideFields::new(pres.slide_number(index), pres.header_footer.footer_text.clone(), opts); let (show_layout, show_master) = resolve::show_master_shapes(slide, rctx.layout); if show_master { let mctx = Ctx::for_master(pres, rctx.master); @@ -164,20 +170,20 @@ fn place_text(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, owner: ShapeId } // Field runs carry their saved text (`‹#›`); the layout drew their values, so the body that // goes with it (character offsets, text layer) must hold the same values. - let mut body = body.clone(); - for para in &mut body.paragraphs { - let src = para.clone(); - for run in &mut para.runs { - if let RunKind::Field { field } = &run.kind { - let lang = resolve::run(rctx, s, &src, &run.props).lang; - if let Some(v) = fields.field_in(field, lang.as_deref()) { - run.text = v; - run.kind = RunKind::Text; - } + let mut placed = body.clone(); + for (pi, para) in body.paragraphs.iter().enumerate() { + for (ri, run) in para.runs.iter().enumerate() { + let RunKind::Field { field } = &run.kind else { continue }; + let lang = resolve::run(rctx, s, para, &run.props).lang; + if let Some(v) = fields.field_in(field, lang.as_deref()) + && let Some(out_run) = placed.paragraphs.get_mut(pi).and_then(|p| p.runs.get_mut(ri)) + { + out_run.text = v; + out_run.kind = RunKind::Text; } } } - out.texts.push(PlacedText { layout, transform: m, body }); + out.texts.push(PlacedText { layout, transform: m, body: placed }); } /// The hyperlink of the model run containing character `at` of paragraph `para`. diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 9987bd7c9..b9011f999 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -2607,3 +2607,12 @@ fn placed_text_holds_the_field_value_not_the_saved_placeholder() { // The layout was drawn from the same value, so the offsets agree with the body. assert_eq!(placed.texts[0].layout.runs.iter().map(|r| r.chars.1).max(), Some(1)); } + +#[test] +fn placed_text_takes_its_date_from_the_given_render_options() { + use deckcraft_text::datetime::DateTime; + let p = deck_with(vec![field_shape(Some("datetime1"), "1/1/2000")]); + let at = |now: DateTime| place_slide_with(&p, 0, &RenderOpts { now: Some(now), ..Default::default() }); + assert_eq!(at(DateTime::new(2026, 10, 9, 20, 5, 7)).texts[0].body.text(), "10/9/2026"); + assert_eq!(at(DateTime::new(2027, 3, 4, 0, 0, 0)).texts[0].body.text(), "3/4/2027"); +} From d8936f94af1dd1a9a9c72cb626c5a0062ed19abb Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:58:38 +0100 Subject: [PATCH 03/79] Keep whole character after a backslash in RTF outline import Found while testing crates/format/src/rtf.rs:301-326 (symbol / text, reached via escape) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/format/src/rtf.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/format/src/rtf.rs b/crates/format/src/rtf.rs index 4800aba26..b546a832f 100644 --- a/crates/format/src/rtf.rs +++ b/crates/format/src/rtf.rs @@ -299,7 +299,8 @@ impl Outline<'_> { /// A one-character control symbol (`\\`, `\{`, `\~`, ...). fn symbol(&mut self, n: u8) { - self.i += 1; + let c = self.rtf.get(self.i..).and_then(|r| r.chars().next()); + self.i = advance(self.rtf, self.i, 1); if self.st.skip { return; } @@ -308,6 +309,7 @@ impl Outline<'_> { b'~' => self.line.push('\u{a0}'), b'_' => self.line.push('\u{2011}'), b'\n' | b'\r' => self.flush(), + _ if !n.is_ascii() => self.line.extend(c), _ => {} } } @@ -419,6 +421,11 @@ mod tests { assert_eq!(rtf_to_outline(r"{\rtf1 x\u-70000?y\par}"), "x\u{ee90}y\n"); } + #[test] + fn multibyte_control_symbol_keeps_character_and_following_text() { + assert_eq!(rtf_to_outline("{\\rtf1 A\\éB\\par}"), "AéB\n"); + } + /// `\\_` is RTF's non-breaking hyphen: a visible character, not markup. #[test] fn non_breaking_hyphen_is_kept() { From 6303166a1816693d7dfa7b04263cee61daf062fa Mon Sep 17 00:00:00 2001 From: Zach Berry <1112351+zberry@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:58:27 -0500 Subject: [PATCH 04/79] [PP1] show.setup showMediaControls survives a pptx save and reopen PowerPoint stores Show media controls as the p14:showMediaCtrls extension inside p:showPr (uri {2FDB2607-...}); absent means shown. Write it when hidden, read it back. Co-Authored-By: Claude Sonnet 5.5 --- crates/pptx/src/read.rs | 1 + crates/pptx/src/write.rs | 8 ++++++++ crates/pptx/tests/it/roundtrip.rs | 9 +++++++++ 3 files changed, 18 insertions(+) diff --git a/crates/pptx/src/read.rs b/crates/pptx/src/read.rs index 917aa9277..2df349cb7 100644 --- a/crates/pptx/src/read.rs +++ b/crates/pptx/src/read.rs @@ -1283,6 +1283,7 @@ fn read_show(sp: &El, shows: &HashMap) -> ShowSettings { without_narration: sp.bool("showNarration") == Some(false), without_animation: sp.bool("showAnimation") == Some(false), use_timings: sp.bool("useTimings").unwrap_or(true), + show_media_controls: sp.find("showMediaCtrls").and_then(|e| e.bool("val")).unwrap_or(true), ..Default::default() }; if sp.child("browse").is_some() { diff --git a/crates/pptx/src/write.rs b/crates/pptx/src/write.rs index 5d3e60e92..37cf5cd7d 100644 --- a/crates/pptx/src/write.rs +++ b/crates/pptx/src/write.rs @@ -1181,6 +1181,14 @@ fn pres_props_xml(p: &Presentation) -> Vec { w.open0("p:penClr"); w.val("a:srgbClr", s.pen_color.hex()); w.close("p:penClr"); + if !s.show_media_controls { + // "Show media controls" is PowerPoint's p14 extension; absent means shown. + w.open0("p:extLst"); + w.open("p:ext", A::new().a("uri", "{2FDB2607-1784-4EEB-B798-7EB5836EED8A}")); + w.empty("p14:showMediaCtrls", A::new().a("xmlns:p14", NS_P14).a("val", 0)); + w.close("p:ext"); + w.close("p:extLst"); + } w.close("p:showPr"); w.close("p:presentationPr"); w.finish() diff --git a/crates/pptx/tests/it/roundtrip.rs b/crates/pptx/tests/it/roundtrip.rs index 370cc9a9f..c61e6e433 100644 --- a/crates/pptx/tests/it/roundtrip.rs +++ b/crates/pptx/tests/it/roundtrip.rs @@ -1969,3 +1969,12 @@ fn default_durations_match_powerpoint() { assert_eq!(a.4, ms, "{effect}"); } } + +/// Show media controls is PowerPoint's `p14:showMediaCtrls` extension inside `p:showPr`. +#[test] +fn hidden_media_controls_survive_save_and_reopen() { + let mut p = Presentation::default(); + p.show.show_media_controls = false; + assert!(!round(&p).show.show_media_controls); + assert!(round(&Presentation::default()).show.show_media_controls); +} From d453bee54f4dc0810e07b97ead32fa5c6c19b7d9 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:57:51 -0500 Subject: [PATCH 05/79] D604: readable animation effect labels --- ROADMAP.md | 3 + crates/ui-egui/src/ribbon/animations.rs | 89 ++++++++++++++++++++++++- docs/animation-parity.md | 5 +- docs/gaps.md | 4 +- 4 files changed, 96 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a99369256..5a31b3bdf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (progress log: animation Effect Options show readable labels; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-27 · **Change:** trivial (progress log: Set Up Slide Show ▸ Keep mouse cursor visible, #1445; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-26 · **Change:** trivial (progress log: dragging several selected slides moves them all; previous: trivial, progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -146,6 +147,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-30 | Animation ▸ Effect Options shows readable labels for directions, split/shape variants, wheel spokes and other option values; stored codes are unchanged | | 2026-10-29 | Slide Show: going back to the previous slide plays the transition of the slide being left, reversed, as PowerPoint does, instead of the previous slide's own entrance transition (#1531); a jump (Go to Slide, links) still plays the target slide's transition | | 2026-10-27 | Set Up Slide Show ▸ Keep mouse cursor visible: a full-screen show no longer hides a still pointer, so the choices of a branching deck stay visible and clickable; the setting is kept with the user's preferences (`ui.json`, `showKeepCursor`), off by default (#1445) | | 2026-10-25 | A photo in a picture placeholder is cut into the shape its layout gives the placeholder, as PowerPoint draws it: a layout's round picture placeholder keeps a slide's photo in a circle with the layout's white border, where it came out as a rectangle (the reader only passed `p:sp` placeholders to `inherit_placeholder_geometry`, not `p:pic`) | @@ -314,6 +316,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | trivial | Progress log: animation Effect Options show readable labels. Scores and milestone names unchanged | | 2026-10-29 | trivial | Progress log: going back in the slide show reverses the left slide's transition (#1531). Scores and milestone names unchanged | | 2026-10-29 | trivial | Progress log: the Picture Styles row leaves room for Crop and Size (#1232). Scores and milestone names unchanged | | 2026-10-25 | trivial | Progress log: a picture in a picture placeholder takes the layout's shape. Scores and milestone names unchanged | diff --git a/crates/ui-egui/src/ribbon/animations.rs b/crates/ui-egui/src/ribbon/animations.rs index 67f0bbd00..6d35afdcb 100644 --- a/crates/ui-egui/src/ribbon/animations.rs +++ b/crates/ui-egui/src/ribbon/animations.rs @@ -1,4 +1,3 @@ -use super::transitions::option_label; use super::{ Align2, AnimClass, CornerRadius, Icon, Rect, Sense, SlideApp, Tokens, Ui, big, big_button, big_button_ex, dropdown, enabled, group, icons, json, pos2, rows, run, theme, vec2, widgets, @@ -81,12 +80,13 @@ pub(super) fn animations(app: &mut SlideApp, ui: &mut Ui) { } }); let effect_rows = cur.as_ref().map(|c| animation_effect_rows(&c.effect, c.class, Some(c.option.as_str()))).unwrap_or_default(); + let effect = cur.as_ref().map(|c| c.effect.as_str()).unwrap_or_default(); let build = cur.as_ref().map(|c| text_build_id(c.text_build)); let build_rows = marked_options(&["asOne", "byParagraph"], build); let r = big_button(ui, Icon::EffectOptions, "Effect\nOptions", !effect_rows.is_empty()); egui::Popup::menu(&r).show(|ui| { for &(o, on) in &effect_rows { - if ui.selectable_label(on, option_label(o)).clicked() { + if ui.selectable_label(on, animation_option_label(effect, o)).clicked() { run(app, "animation.options", json!({"option": o})); ui.close(); } @@ -216,6 +216,67 @@ pub(super) fn animation_effect_rows(effect: &str, class: AnimClass, current: Opt marked_options(opts, current) } +fn animation_option_label(effect: &str, option: &str) -> String { + let direction = match option { + "b" => Some("From Bottom"), + "lb" => Some("From Bottom-Left"), + "l" => Some("From Left"), + "lt" => Some("From Top-Left"), + "t" => Some("From Top"), + "rt" => Some("From Top-Right"), + "r" => Some("From Right"), + "rb" => Some("From Bottom-Right"), + _ => None, + }; + if matches!(effect, "fly" | "flyOut" | "wipe" | "wipeOut" | "peek" | "peekOut") + && let Some(label) = direction + { + return label.into(); + } + + match (effect, option) { + ("float" | "floatOut", "u") => "Up".into(), + ("float" | "floatOut", "d") => "Down".into(), + ("split" | "splitOut", "horzIn") => "Horizontal In".into(), + ("split" | "splitOut", "horzOut") => "Horizontal Out".into(), + ("split" | "splitOut", "vertIn") => "Vertical In".into(), + ("split" | "splitOut", "vertOut") => "Vertical Out".into(), + ("shape" | "shapeOut", o) if o.ends_with("In") => format!("{} In", title_first(shape_option_name(o))), + ("shape" | "shapeOut", o) if o.ends_with("Out") => format!("{} Out", title_first(shape_option_name(o))), + ("wheel" | "wheelOut", "1") => "1 Spoke".into(), + ("wheel" | "wheelOut", n) => format!("{n} Spokes"), + ("box" | "boxOut" | "circle" | "diamond", "in") => format!("{} In", title_first(effect.trim_end_matches("Out"))), + ("box" | "boxOut" | "circle" | "diamond", "out") => format!("{} Out", title_first(effect.trim_end_matches("Out"))), + ("zoom" | "zoomOut", "in") => "Zoom In".into(), + ("zoom" | "zoomOut", "out") => "Zoom Out".into(), + (_, "lu") => "From Top-Left".into(), + (_, "ru") => "From Top-Right".into(), + (_, "ld") => "From Bottom-Left".into(), + (_, "rd") => "From Bottom-Right".into(), + (_, "horz") => "Horizontal".into(), + (_, "vert") => "Vertical".into(), + (_, "across") => "Across".into(), + (_, "down") => "Down".into(), + (_, "up") => "Up".into(), + (_, "right") => "Right".into(), + (_, "left") => "Left".into(), + (_, "clockwise") => "Clockwise".into(), + (_, "counterClockwise") => "Counterclockwise".into(), + (_, "in") => "In".into(), + (_, "out") => "Out".into(), + (_, other) => super::transitions::option_label(other), + } +} + +fn shape_option_name(option: &str) -> &str { + option.strip_suffix("In").or_else(|| option.strip_suffix("Out")).unwrap_or(option) +} + +fn title_first(value: &str) -> String { + let mut chars = value.chars(); + chars.next().map(|c| c.to_uppercase().collect::() + chars.as_str()).unwrap_or_default() +} + pub(super) fn text_build_id(build: deckcraft_model::anim::TextBuild) -> &'static str { match build { deckcraft_model::anim::TextBuild::ByParagraph => "byParagraph", @@ -224,3 +285,27 @@ pub(super) fn text_build_id(build: deckcraft_model::anim::TextBuild) -> &'static deckcraft_model::anim::TextBuild::AsOne => "asOne", } } + +#[cfg(test)] +mod animation_option_label_tests { + use super::animation_option_label; + use deckcraft_model::anim::ANIMATIONS; + + #[test] + fn labels_every_animation_gallery_option_without_changing_its_code() { + for &(effect, _, _, _, _, options) in ANIMATIONS { + for &option in options { + let label = animation_option_label(effect, option); + assert!(!label.is_empty(), "{effect}/{option}"); + assert_ne!(label, option, "raw option shown for {effect}/{option}"); + } + } + assert_eq!(animation_option_label("fly", "lb"), "From Bottom-Left"); + assert_eq!(animation_option_label("flyOut", "rt"), "From Top-Right"); + assert_eq!(animation_option_label("wipe", "b"), "From Bottom"); + assert_eq!(animation_option_label("wheel", "1"), "1 Spoke"); + assert_eq!(animation_option_label("split", "vertOut"), "Vertical Out"); + assert_eq!(animation_option_label("shape", "circleIn"), "Circle In"); + assert_eq!(animation_option_label("diamond", "out"), "Diamond Out"); + } +} diff --git a/docs/animation-parity.md b/docs/animation-parity.md index b7f840a92..ffca88c9d 100644 --- a/docs/animation-parity.md +++ b/docs/animation-parity.md @@ -1,6 +1,6 @@ # Transitions and animations: parity with PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-29 · **Change:** minor (going back in the show reverses the left slide's transition, #1531; previous: minor, Animation Pane ▸ Play From plays from the selected effect; previous: community PR wave 2026-10-14: interactive-deck triggers, canvas preview, the Animation Pane timeline, repeat and bounce end round-trip; duplicated rows and headers folded; previous: major, transitions measured frame by frame against PowerPoint for Windows' video export) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (animation Effect Options translate stored codes into readable labels; previous: minor, going back in the show reverses the left slide's transition, #1531; previous: minor, Animation Pane ▸ Play From plays from the selected effect; previous: community PR wave 2026-10-14: interactive-deck triggers, canvas preview, the Animation Pane timeline, repeat and bounce end round-trip; duplicated rows and headers folded; previous: major, transitions measured frame by frame against PowerPoint for Windows' video export) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Transitions and animations are what make a deck a presentation, and the part of PowerPoint people notice first when it's missing. Scores roll up into [target-app-parity.md](target-app-parity.md) @@ -65,7 +65,7 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85 | Animation feature | PowerPoint | DeckCraft | |---|---|---| | Start On Click / With Previous / After Previous, duration, delay | yes | yes | -| Effect Options (direction, sequence by paragraph) | yes | yes | +| Effect Options (direction, sequence by paragraph) | yes | yes; every gallery option has a readable label while retaining its stored code | | Effect dialog: sound, after-animation dim/hide, animate text by word/letter, smooth start/end, bounce end, repeat, rewind | yes | partly; repeat, bounce end and rewind round-trip | | By-paragraph text builds, sub-bullets follow parents | yes | yes; builds edited in PowerPoint (own start per paragraph, other effects in between, by 2nd-level paragraphs) import as one effect per paragraph and keep their clicks (#844) | | Triggers (on click of a shape) | yes | yes; played in the show: a step per click, the sequence starts over after its last step, a click completes a playing step. Separate click steps, mixed starts and paragraph builds survive PPTX save (`trigger_steps.rs`) | @@ -94,6 +94,7 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85 | Date | Change | Summary | |---|---|---| +| 2026-10-30 | trivial | Animation Effect Options show readable, effect-aware labels for all gallery option codes; stored values and command parameters stay unchanged | | 2026-10-29 | minor | Going back in the show plays the transition of the slide being left, reversed, instead of the previous slide's entrance transition (#1531); new row in the transition features. Scores unchanged | | 2026-10-22 | minor | Animation Pane ▸ Play From: with an effect selected, the preview starts at that effect on the play-all clock; the row says yes and the estimate drops to ~14–26 h | | 2026-10-15 | trivial | PPTX interactive sequences preserve later On Click effects and one click per paragraph; no change to playback or feature scores | diff --git a/docs/gaps.md b/docs/gaps.md index c1746a17f..885a64d16 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,5 +1,6 @@ # Where DeckCraft falls short of PowerPoint +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (animation Effect Options now show readable labels; Animation Pane gap remains) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-28 · **Change:** trivial (AutoFormat lists run independently of Replace text as you type; previous: minor, Animation Pane ▸ Play From; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: soak tests, PPTX corpus, comment threads (#70), embedded fonts, SmartArt Text Pane, Animation Pane timeline, Hunspell spelling, thesaurus, AutoCorrect, 14 new dialogs, pen pressure and trackpad gestures; duplicated entries and headers folded; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-20 · **Change:** trivial (empty chart number-format sections hide values; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: soak tests, PPTX corpus, comment threads (#70), embedded fonts, SmartArt Text Pane, Animation Pane timeline, Hunspell spelling, thesaurus, AutoCorrect, 14 new dialogs, pen pressure and trackpad gestures; duplicated entries and headers folded; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-27 · **Last updated:** 2026-10-27 · **Change:** minor (docs refresh against merged PRs: #67 and #77 landed, #75 closed unmerged, #53 closed, Windows image paste is #1414; one header line; previous: minor, Animation Pane ▸ Play From; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: soak tests, PPTX corpus, comment threads (#70), embedded fonts, SmartArt Text Pane, Animation Pane timeline, Hunspell spelling, thesaurus, AutoCorrect, 14 new dialogs, pen pressure and trackpad gestures; duplicated entries and headers folded; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -28,7 +29,7 @@ revision history. | 7 | Fonts: only Regular, Bold and Italic faces (#69) | Feature | B | 3–5 | [ui-parity.md](ui-parity.md) | | 8 | Image paste fails on Windows (#1414); Linux not re-checked | Feature | B | 2–4 | [ui-parity.md](ui-parity.md) | | 9 | Presenter view isn't placed on the second display | Hardware | B | 3–5 | [slideshow-parity.md](slideshow-parity.md) | -| 10 | Animation Pane: 64 of ~190 effects, effect options (timeline and Play From done) | Feature | B (pane) | 7–13 | [animation-parity.md](animation-parity.md) | +| 10 | Animation Pane: 64 of ~190 effects, effect options (readable labels, timeline and Play From done) | Feature | B (pane) | 7–13 | [animation-parity.md](animation-parity.md) | | 11 | Charts: data grid editing, elements, `chartex` types | Feature | B (data, elements) | 12–20 | [parity-checklist.md](parity-checklist.md) | | 12 | Format Shape pane thinner than PowerPoint's | UI/UX | B | 6–10 | [ui-parity.md](ui-parity.md) | | 13 | No native macOS menu bar | UI/UX | B | 3–5 | [ui-parity.md](ui-parity.md) | @@ -370,6 +371,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | trivial | Animation effect options display readable labels for every gallery value; the broader Animation Pane gap remains | | 2026-10-28 | trivial | Automatic lists now run independently of Replace text as you type; proofing gap description updated | | 2026-10-27 | minor | Refreshed against merged PRs: removed the Morph alignment entry (#67 merged 2026-10-14); #77 (animation rewind, PR #83) is fixed, leaving #74 in the round-trip gap; #75 was closed unmerged, so #60 stays open with no fix PR; #53 is closed and the image-paste gap is now Windows #1414; Reuse Slides (#1103) and Compare reject (#850) noted in the smaller rows; the duplicate header line is gone | | 2026-10-25 | trivial | New entries: copying text and cell ranges (no media, no table), a control-server timing test that may fail under load; Proofing notes that automatic lists follow Replace text as you type and that Backspace puts the typed lead back | From b1900e526d02ab4b05c88de0782a0b8953f99be4 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 22:42:28 -0500 Subject: [PATCH 06/79] Preserve point-sized bullets in PPTX --- crates/model/src/text.rs | 3 +++ crates/pptx/src/read/dml.rs | 4 ++++ crates/pptx/src/write/dml.rs | 4 ++++ crates/pptx/tests/it/roundtrip.rs | 24 ++++++++++++++++++++++++ crates/text/src/shape.rs | 4 ++-- 5 files changed, 37 insertions(+), 2 deletions(-) diff --git a/crates/model/src/text.rs b/crates/model/src/text.rs index 17d606de7..525f1df3c 100644 --- a/crates/model/src/text.rs +++ b/crates/model/src/text.rs @@ -240,6 +240,8 @@ pub struct ParaProps { pub bullet_color: Option, /// Bullet size as a fraction of the text size. pub bullet_size: Option, + /// Absolute bullet font size in points, independent of the paragraph's run size. + pub bullet_size_points: Option, pub tabs: Option>, pub default_tab: Option, pub rtl: Option, @@ -270,6 +272,7 @@ impl ParaProps { self.bullet_color.clone_from(&p.bullet_color); } self.bullet_size = self.bullet_size.or(p.bullet_size); + self.bullet_size_points = self.bullet_size_points.or(p.bullet_size_points); if self.tabs.is_none() { self.tabs.clone_from(&p.tabs); } diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index 767b7dc02..7a3cc2179 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -433,6 +433,10 @@ pub fn ppr(imp: &mut Imp, part: &Part, p: &El) -> (Option, ParaProps, Option "buFont" => pp.bullet_font = e.attr("typeface").map(String::from), "buClr" => pp.bullet_color = color(e), "buSzPct" => pp.bullet_size = pct(e, "val").map(|v| v.clamp(0.25, 4.0)), + "buSzPts" => { + pp.bullet_size_points = + e.attr("val").and_then(|v| v.parse::().ok()).filter(|v| v.is_finite() && *v > 0.0 && *v <= 40_000.0).map(|v| v / 100.0); + } "tabLst" => { pp.tabs = Some( e.children_named("tab") diff --git a/crates/pptx/src/write/dml.rs b/crates/pptx/src/write/dml.rs index 518f8c4af..70b085e2e 100644 --- a/crates/pptx/src/write/dml.rs +++ b/crates/pptx/src/write/dml.rs @@ -535,6 +535,7 @@ pub fn ppr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, level: Option, p: || p.bullet_font.is_some() || p.bullet_color.is_some() || p.bullet_size.is_some() + || p.bullet_size_points.is_some() || p.tabs.is_some() || def.is_some(); if !has_kids { @@ -559,6 +560,9 @@ pub fn ppr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, level: Option, p: if let Some(s) = p.bullet_size { w.val("a:buSzPct", pct(s.clamp(0.25, 4.0))); } + if let Some(s) = p.bullet_size_points { + w.val("a:buSzPts", ((s.clamp(0.01, 400.0) * 100.0).round() as u32).to_string()); + } if let Some(f) = &p.bullet_font { w.empty("a:buFont", A::new().a("typeface", f)); } diff --git a/crates/pptx/tests/it/roundtrip.rs b/crates/pptx/tests/it/roundtrip.rs index 370cc9a9f..7936234ce 100644 --- a/crates/pptx/tests/it/roundtrip.rs +++ b/crates/pptx/tests/it/roundtrip.rs @@ -488,6 +488,30 @@ fn shapes_fills_lines_effects_round_trip() { assert_eq!(by_name(&q.slides[1], "Group").id, by_name(&p.slides[1], "Group").id); } +#[test] +fn bullet_size_in_points_survives_two_pptx_round_trips() { + let mut p = Presentation::default(); + let mut textbox = shape(&mut p, "Point-sized bullet", 20.0, 20.0, 400.0, 100.0); + textbox.text = Some(TextBody { + paragraphs: vec![Paragraph { + props: deckcraft_model::text::ParaProps { + bullet: Some(Bullet::Char { char: "•".into() }), + bullet_size_points: Some(12.0), + ..Default::default() + }, + runs: vec![Run::with("Bullet text", RunProps::default())], + ..Default::default() + }], + ..Default::default() + }); + Arc::make_mut(&mut p.slides[0]).shapes.push(textbox); + + let q = round(&round(&p)); + let props = &by_name(&q.slides[0], "Point-sized bullet").text.as_ref().expect("text").paragraphs[0].props; + assert_eq!(props.bullet_size_points, Some(12.0)); + assert_eq!(props.bullet_size, None); +} + #[test] fn pictures_media_and_ink_round_trip() { let p = rich_deck(); diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index 2be8424f7..d5558001d 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -76,7 +76,7 @@ pub(crate) fn shape_para( if let Some(c) = &props.bullet_color { rp.fill = Some(Fill::solid(c.clone())); } - rp.size = Some(lead.size.unwrap_or(18.0) * props.bullet_size.unwrap_or(1.0)); + rp.size = Some(props.bullet_size_points.unwrap_or(lead.size.unwrap_or(18.0) * props.bullet_size.unwrap_or(1.0))); rp.underline = None; rp.strike = None; rp.highlight = None; @@ -105,7 +105,7 @@ pub(crate) fn shape_para( if let Some(c) = &props.bullet_color { rp.fill = Some(Fill::solid(c.clone())); } - rp.size = Some(lead.size.unwrap_or(18.0) * props.bullet_size.unwrap_or(1.0)); + rp.size = Some(props.bullet_size_points.unwrap_or(lead.size.unwrap_or(18.0) * props.bullet_size.unwrap_or(1.0))); rp.underline = None; let mut st = make_style(ctx, &rp, scale, opts.prompt_color); let label = autonum(scheme, start_at.saturating_sub(1).saturating_add(number.unwrap_or(1))); From 917d4b96a9ceb2063a2739a098375fad1395f3bc Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 23:07:54 -0500 Subject: [PATCH 07/79] Honor bullet size schema bounds and inheritance --- crates/model/src/text.rs | 29 +++++++++++++++++++++++++++-- crates/pptx/src/read/dml.rs | 8 ++++++-- crates/pptx/src/write/dml.rs | 7 +++---- crates/pptx/tests/it/roundtrip.rs | 25 ++++++++++++++----------- 4 files changed, 50 insertions(+), 19 deletions(-) diff --git a/crates/model/src/text.rs b/crates/model/src/text.rs index 525f1df3c..8d01cdcd2 100644 --- a/crates/model/src/text.rs +++ b/crates/model/src/text.rs @@ -271,8 +271,13 @@ impl ParaProps { if self.bullet_color.is_none() { self.bullet_color.clone_from(&p.bullet_color); } - self.bullet_size = self.bullet_size.or(p.bullet_size); - self.bullet_size_points = self.bullet_size_points.or(p.bullet_size_points); + if self.bullet_size.is_none() && self.bullet_size_points.is_none() { + if p.bullet_size_points.is_some() { + self.bullet_size_points = p.bullet_size_points; + } else { + self.bullet_size = p.bullet_size; + } + } if self.tabs.is_none() { self.tabs.clone_from(&p.tabs); } @@ -287,6 +292,26 @@ impl ParaProps { } } +#[cfg(test)] +mod bullet_size_tests { + use super::ParaProps; + + #[test] + fn bullet_size_inheritance_keeps_percentage_and_points_as_one_mode() { + let parent = ParaProps { bullet_size_points: Some(12.0), ..Default::default() }; + let mut child = ParaProps { bullet_size: Some(2.0), ..Default::default() }; + child.inherit(&parent); + assert_eq!(child.bullet_size, Some(2.0)); + assert_eq!(child.bullet_size_points, None); + + let parent = ParaProps { bullet_size: Some(1.5), ..Default::default() }; + let mut child = ParaProps { bullet_size_points: Some(14.0), ..Default::default() }; + child.inherit(&parent); + assert_eq!(child.bullet_size, None); + assert_eq!(child.bullet_size_points, Some(14.0)); + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum Caps { diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index 7a3cc2179..5076c3761 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -432,10 +432,14 @@ pub fn ppr(imp: &mut Imp, part: &Part, p: &El) -> (Option, ParaProps, Option } "buFont" => pp.bullet_font = e.attr("typeface").map(String::from), "buClr" => pp.bullet_color = color(e), - "buSzPct" => pp.bullet_size = pct(e, "val").map(|v| v.clamp(0.25, 4.0)), + "buSzPct" => { + pp.bullet_size_points = None; + pp.bullet_size = pct(e, "val").map(|v| v.clamp(0.25, 4.0)); + } "buSzPts" => { + pp.bullet_size = None; pp.bullet_size_points = - e.attr("val").and_then(|v| v.parse::().ok()).filter(|v| v.is_finite() && *v > 0.0 && *v <= 40_000.0).map(|v| v / 100.0); + e.attr("val").and_then(|v| v.parse::().ok()).filter(|v| (100..=400_000).contains(v)).map(|v| v as f64 / 100.0); } "tabLst" => { pp.tabs = Some( diff --git a/crates/pptx/src/write/dml.rs b/crates/pptx/src/write/dml.rs index 70b085e2e..9efb990f7 100644 --- a/crates/pptx/src/write/dml.rs +++ b/crates/pptx/src/write/dml.rs @@ -557,12 +557,11 @@ pub fn ppr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, level: Option, p: color(w, c); w.close("a:buClr"); } - if let Some(s) = p.bullet_size { + if let Some(s) = p.bullet_size_points.filter(|s| s.is_finite() && (1.0..=4000.0).contains(s)) { + w.val("a:buSzPts", ((s * 100.0).round() as u32).to_string()); + } else if let Some(s) = p.bullet_size { w.val("a:buSzPct", pct(s.clamp(0.25, 4.0))); } - if let Some(s) = p.bullet_size_points { - w.val("a:buSzPts", ((s.clamp(0.01, 400.0) * 100.0).round() as u32).to_string()); - } if let Some(f) = &p.bullet_font { w.empty("a:buFont", A::new().a("typeface", f)); } diff --git a/crates/pptx/tests/it/roundtrip.rs b/crates/pptx/tests/it/roundtrip.rs index 7936234ce..065eb8495 100644 --- a/crates/pptx/tests/it/roundtrip.rs +++ b/crates/pptx/tests/it/roundtrip.rs @@ -493,23 +493,26 @@ fn bullet_size_in_points_survives_two_pptx_round_trips() { let mut p = Presentation::default(); let mut textbox = shape(&mut p, "Point-sized bullet", 20.0, 20.0, 400.0, 100.0); textbox.text = Some(TextBody { - paragraphs: vec![Paragraph { - props: deckcraft_model::text::ParaProps { - bullet: Some(Bullet::Char { char: "•".into() }), - bullet_size_points: Some(12.0), + paragraphs: [12.0, 500.0, 4000.0] + .into_iter() + .map(|size| Paragraph { + props: deckcraft_model::text::ParaProps { + bullet: Some(Bullet::Char { char: "•".into() }), + bullet_size_points: Some(size), + ..Default::default() + }, + runs: vec![Run::with("Bullet text", RunProps::default())], ..Default::default() - }, - runs: vec![Run::with("Bullet text", RunProps::default())], - ..Default::default() - }], + }) + .collect(), ..Default::default() }); Arc::make_mut(&mut p.slides[0]).shapes.push(textbox); let q = round(&round(&p)); - let props = &by_name(&q.slides[0], "Point-sized bullet").text.as_ref().expect("text").paragraphs[0].props; - assert_eq!(props.bullet_size_points, Some(12.0)); - assert_eq!(props.bullet_size, None); + let props = &by_name(&q.slides[0], "Point-sized bullet").text.as_ref().expect("text").paragraphs; + assert_eq!(props.iter().map(|p| p.props.bullet_size_points).collect::>(), vec![Some(12.0), Some(500.0), Some(4000.0)]); + assert!(props.iter().all(|p| p.props.bullet_size.is_none())); } #[test] From 98138f563b357921ebc2f6fe6ff7ffd57d50aae7 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 30 Oct 2026 23:24:07 -0500 Subject: [PATCH 08/79] Trim whitespace in bullet point sizes --- crates/pptx/src/read/dml.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index 5076c3761..11949be56 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -23,6 +23,10 @@ pub fn pct(e: &El, name: &str) -> Option { e.f64(name).map(|v| v / 100_000.0) } +fn bullet_size_points(value: Option<&str>) -> Option { + value.and_then(|v| v.trim().parse::().ok()).filter(|v| (100..=400_000).contains(v)).map(|v| v as f64 / 100.0) +} + /// 1/60 000 degree → degrees. pub fn ang(e: &El, name: &str) -> Option { e.f64(name).map(|v| v / 60_000.0) @@ -438,8 +442,7 @@ pub fn ppr(imp: &mut Imp, part: &Part, p: &El) -> (Option, ParaProps, Option } "buSzPts" => { pp.bullet_size = None; - pp.bullet_size_points = - e.attr("val").and_then(|v| v.parse::().ok()).filter(|v| (100..=400_000).contains(v)).map(|v| v as f64 / 100.0); + pp.bullet_size_points = bullet_size_points(e.attr("val")); } "tabLst" => { pp.tabs = Some( @@ -676,6 +679,13 @@ mod tests { assert_eq!(color(&d.root).unwrap().base, ColorBase::Rgb { rgb: Rgba::rgb(255, 0, 0) }); } + #[test] + fn bullet_point_size_trims_schema_whitespace() { + let d = parse(br#""#).unwrap(); + assert_eq!(bullet_size_points(d.root.attr("val")), Some(14.0)); + assert_eq!(bullet_size_points(Some("1400")), Some(14.0)); + } + #[test] fn body_props() { let d = parse(br#""#).unwrap(); From ea9dad8ef988362f4a365d5cf44f1d525b00ad5b Mon Sep 17 00:00:00 2001 From: Naseq Date: Mon, 2 Nov 2026 15:04:05 +0430 Subject: [PATCH 09/79] Transitions: play transition preview from previous slide when Preview is clicked (#1654) - When Preview is clicked on the Transitions ribbon, initialize Show with start_transition_preview from the prior slide into the selected slide (or from black for slide 0). - If tr.old is unset for a non-part transition with tr.from, render the previous slide texture on the first frame so the transition blends cleanly. - Indicate active playback on the Preview button while previewing. - Regression tests for transitions from previous slide and from black on slide 0. --- crates/ui-egui/src/ribbon/transitions.rs | 13 +++++---- crates/ui-egui/src/show.rs | 21 +++++++++++++++ crates/ui-egui/src/show/tests.rs | 34 ++++++++++++++++++++++++ crates/ui-egui/src/show/ui.rs | 10 +++++++ 4 files changed, 73 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/ribbon/transitions.rs b/crates/ui-egui/src/ribbon/transitions.rs index 3f43ebd98..6986d828c 100644 --- a/crates/ui-egui/src/ribbon/transitions.rs +++ b/crates/ui-egui/src/ribbon/transitions.rs @@ -12,11 +12,14 @@ pub(super) fn transitions(app: &mut SlideApp, ui: &mut Ui) { let cur = app.session.active().and_then(|d| d.current_slide().and_then(|s| s.transition.clone())); let kind = cur.as_ref().map(|t| t.kind.clone()).unwrap_or_else(|| "none".into()); group(ui, |ui| { - if big_button(ui, Icon::Preview, "Preview", true).clicked() { - let from = app.session.active().map(|d| d.selection.slide).unwrap_or(0); - app.start_show(from, true); - if let Some(s) = app.show.as_mut() { - s.preview_only = true; + let playing = app.show.as_ref().is_some_and(|s| s.preview_only); + let (resp, _) = widgets::big_button_ex(ui, Icon::Preview, "Preview", true, false, playing); + if resp.on_hover_text("Play this slide's transition").clicked() { + let to = app.session.active().map(|d| d.selection.slide).unwrap_or(0); + app.start_show(to, true); + let now = ui.input(|i| i.time); + if let (Some(s), Some(d)) = (app.show.as_mut(), app.session.active()) { + s.start_transition_preview(&d.doc, now); } } }); diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index 1896e3be7..7bd2edf7d 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -261,6 +261,27 @@ impl Show { } } + /// Play this slide's transition from the previous slide (or black for slide 0) as a preview (#1654). + pub fn start_transition_preview(&mut self, doc: &Presentation, now: f64) { + let i = self.state.slide; + let from = i.checked_sub(1); + let (kind, option, dur) = doc + .slides + .get(i) + .and_then(|s| s.transition.as_ref()) + .map(|t| (t.kind.clone(), t.option.clone(), t.duration_ms as f64 / 1000.0)) + .unwrap_or(("none".into(), String::new(), 0.0)); + self.trans = if kind != "none" && dur > 0.0 && !doc.show.without_animation { + Some(Transition { old: None, from, morph: None, parts: None, start: now, kind, option, dur, reverse: false }) + } else { + None + }; + self.preview_only = true; + self.slide_start = now; + self.step_start = now; + self.step_end = now; + } + /// Identity of the show state a queued window-point event belongs to. pub(crate) fn input_state(&self) -> ShowInputState { let bits = |v: f32| if v.is_finite() { v.to_bits() } else { 0 }; diff --git a/crates/ui-egui/src/show/tests.rs b/crates/ui-egui/src/show/tests.rs index a1069a5bb..5e39bd18e 100644 --- a/crates/ui-egui/src/show/tests.rs +++ b/crates/ui-egui/src/show/tests.rs @@ -1238,3 +1238,37 @@ fn a_preview_of_an_edited_presentation_is_rendered_again() { let tex = harness.state().1.next_tex.as_ref().map(|(d, k, _)| (std::sync::Arc::ptr_eq(d, &edited), *k)); assert_eq!(tex, Some((true, 1)), "the preview comes from the edited presentation"); } + +/// #1654: Preview on the Transitions ribbon plays the current slide's transition from the +/// previous slide into the current slide, and exits when finished. +#[test] +fn transition_preview_plays_from_the_previous_slide() { + let mut s = deckcraft_engine::Session::with_new(); + s.execute("slide.new", &json!({})).unwrap(); + s.execute("transition.set", &json!({"kind": "push", "index": 1, "duration": 1.0})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let mut show = Show::new(&doc, 1, true, false); + show.start_transition_preview(&doc, 10.0); + assert!(show.preview_only); + let tr = show.trans.as_ref().expect("transition preview is playing"); + assert_eq!(tr.kind, "push"); + assert_eq!(tr.from, Some(0), "enters from slide 0 into slide 1"); + assert_eq!(tr.dur, 1.0); + assert!(!tr.reverse); + assert_eq!(tr.start, 10.0); +} + +/// #1654: Transition preview for slide 0 plays with from = None (transitioning from black). +#[test] +fn transition_preview_for_first_slide_transitions_from_black() { + let mut s = deckcraft_engine::Session::with_new(); + s.execute("transition.set", &json!({"kind": "fade", "index": 0, "duration": 500})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let mut show = Show::new(&doc, 0, true, false); + show.start_transition_preview(&doc, 5.0); + assert!(show.preview_only); + let tr = show.trans.as_ref().expect("fade transition preview is playing"); + assert_eq!(tr.kind, "fade"); + assert_eq!(tr.from, None, "slide 0 transitions from black (None)"); + assert_eq!(tr.dur, 0.5); +} diff --git a/crates/ui-egui/src/show/ui.rs b/crates/ui-egui/src/show/ui.rs index 71107f94f..321f8116d 100644 --- a/crates/ui-egui/src/show/ui.rs +++ b/crates/ui-egui/src/show/ui.rs @@ -388,6 +388,16 @@ fn draw_transition(show: &mut Show, f: &Fr, sl: &Sl, painter: &egui::Painter, te if tr.parts.is_none() && deckcraft_anim::uses_slide_parts(&tr.kind, &tr.option) { tr.parts = Some(part_textures(ctx, doc, tr.from, idx, size)); } + if tr.old.is_none() + && let Some(prev) = tr.from + && !deckcraft_anim::uses_slide_parts(&tr.kind, &tr.option) + { + let threads = + if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) }; + let opts = RenderOpts { scale: size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(size), threads, ..Default::default() }; + let img = deckcraft_render::render_slide(doc, prev, &opts); + tr.old = Some(crate::textures::upload(ctx, "trans-old", crate::textures::to_color_image(&img, false))); + } let aspect = doc.slide_size.width / doc.slide_size.height.max(1.0); let part = |k: usize| tr.parts.as_ref().and_then(|p| p.get(k).cloned().flatten()); let parts = [part(0), part(1), part(2), part(3)]; From 4bed9b6c370350cc9e76625e7e3a7d409631beee Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:50:54 +0100 Subject: [PATCH 10/79] Reject unsupported handout perPage values in file.export Found while testing crates/engine/src/cmd/file.rs:362 (pdf_options) with crates/pdf/src/lib.rs:364-371 (handout_boxes) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/file.rs | 8 +++++++- crates/engine/src/tests.rs | 13 +++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 0d52e6358..dc9f1a44e 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -359,7 +359,13 @@ pub fn pdf_options(p: &Value, count: usize) -> Result let mut o = deckcraft_pdf::PdfOptions { layout: deckcraft_pdf::PageLayout::Slides, ..Default::default() }; o.layout = match str_param(p, "layout").unwrap_or("slides") { "notes" => deckcraft_pdf::PageLayout::Notes, - "handouts" => deckcraft_pdf::PageLayout::Handouts { per_page: usize_param(p, "perPage").unwrap_or(6).clamp(1, 9) as u8 }, + "handouts" => { + let per_page = usize_param(p, "perPage").unwrap_or(6); + if ![1, 2, 3, 4, 6, 9].contains(&per_page) { + return Err(bad("file.export", format!("perPage {per_page} isn't one of 1, 2, 3, 4, 6, 9"))); + } + deckcraft_pdf::PageLayout::Handouts { per_page: per_page as u8 } + } _ => deckcraft_pdf::PageLayout::Slides, }; o.dpi = f64_or(p, "dpi", o.dpi).clamp(36.0, 600.0); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 68de13418..e8038c9a2 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2410,6 +2410,19 @@ fn pdf_options_take_paper_and_orientation() { assert!(cmd::file::pdf_options(&json!({"orientation": "sideways"}), 1).is_err()); } +/// Handout `perPage` accepts only 1, 2, 3, 4, 6 and 9; anything else is an error, not a 9-up page. +#[test] +fn pdf_options_reject_unsupported_per_page() { + for n in [1, 2, 3, 4, 6, 9] { + let o = cmd::file::pdf_options(&json!({"layout": "handouts", "perPage": n}), 1).unwrap(); + assert!(matches!(o.layout, deckcraft_pdf::PageLayout::Handouts { per_page } if per_page as usize == n)); + } + for n in [0, 5, 7, 8, 10] { + let e = cmd::file::pdf_options(&json!({"layout": "handouts", "perPage": n}), 1).unwrap_err(); + assert!(e.to_string().contains("1, 2, 3, 4, 6, 9"), "perPage {n}: {e}"); + } +} + #[test] fn shape_line_sets_compound_and_arrow_sizes() { // The Format Shape pane's Line page: compound type, arrowheads and their sizes. From e0992c132568a26259d686c84c2c804deae201ee Mon Sep 17 00:00:00 2001 From: Naseq Date: Mon, 2 Nov 2026 15:25:31 +0430 Subject: [PATCH 11/79] Transitions: render cached previous slide with completed animation state - Use render_frame with timeline.steps() and t = 0.0 so the previous slide is rendered in its final completed state during transition preview. - Pass duration: 1000 integer ms in test to explicitly test the requested duration. --- crates/ui-egui/src/show/tests.rs | 2 +- crates/ui-egui/src/show/ui.rs | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/show/tests.rs b/crates/ui-egui/src/show/tests.rs index 5e39bd18e..c7c34b171 100644 --- a/crates/ui-egui/src/show/tests.rs +++ b/crates/ui-egui/src/show/tests.rs @@ -1245,7 +1245,7 @@ fn a_preview_of_an_edited_presentation_is_rendered_again() { fn transition_preview_plays_from_the_previous_slide() { let mut s = deckcraft_engine::Session::with_new(); s.execute("slide.new", &json!({})).unwrap(); - s.execute("transition.set", &json!({"kind": "push", "index": 1, "duration": 1.0})).unwrap(); + s.execute("transition.set", &json!({"kind": "push", "index": 1, "duration": 1000})).unwrap(); let doc = s.active().unwrap().doc.clone(); let mut show = Show::new(&doc, 1, true, false); show.start_transition_preview(&doc, 10.0); diff --git a/crates/ui-egui/src/show/ui.rs b/crates/ui-egui/src/show/ui.rs index 321f8116d..b02a181bc 100644 --- a/crates/ui-egui/src/show/ui.rs +++ b/crates/ui-egui/src/show/ui.rs @@ -10,7 +10,7 @@ use super::zoom::{ }; use super::{ Align2, BackKey, Click, Color32, CornerRadius, Key, Layer, LinkJump, Pos2, Rect, RenderOpts, Sense, ShapeId, ShapeState, Show, ShowAction, - SlideApp, Source, Stroke, TextureHandle, Ui, json, pos2, show_fills_screen, state_for, theme, vec2, viewport_visible, + ShowState, SlideApp, Source, Stroke, TextureHandle, Ui, json, pos2, show_fills_screen, state_for, theme, vec2, viewport_visible, }; use deckcraft_anim::{StepStamp, TriggerPlay}; use deckcraft_model::Presentation; @@ -394,9 +394,10 @@ fn draw_transition(show: &mut Show, f: &Fr, sl: &Sl, painter: &egui::Painter, te { let threads = if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) }; - let opts = RenderOpts { scale: size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(size), threads, ..Default::default() }; - let img = deckcraft_render::render_slide(doc, prev, &opts); - tr.old = Some(crate::textures::upload(ctx, "trans-old", crate::textures::to_color_image(&img, false))); + let timeline = ShowState::timeline(doc, prev); + let step = timeline.steps(); + let ci = render_frame(doc, prev, timeline, step, 0.0, size, vec![], &[], threads, &[], &[], None); + tr.old = Some(crate::textures::upload(ctx, "trans-old", ci)); } let aspect = doc.slide_size.width / doc.slide_size.height.max(1.0); let part = |k: usize| tr.parts.as_ref().and_then(|p| p.get(k).cloned().flatten()); From 72ac32715ffd5c94de9e6d6b1cfb9bce7ccb65c4 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:21:37 +0100 Subject: [PATCH 12/79] Reject invalid master and layout indices in view.slideMaster Found while testing crates/engine/src/cmd/design/master_view.rs:8 (master_view; `master` read via usize_param, `layout` at :14) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/design/master_view.rs | 6 +++--- crates/engine/src/tests.rs | 11 +++++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/design/master_view.rs b/crates/engine/src/cmd/design/master_view.rs index 5dfe04964..c03bfa574 100644 --- a/crates/engine/src/cmd/design/master_view.rs +++ b/crates/engine/src/cmd/design/master_view.rs @@ -1,17 +1,17 @@ use super::{ Arc, Fill, NO_ID_SPACE, PhType, Result, Run, RunKind, Session, Shape, ShapeId, Size, Target, TextBody, Value, bad, bool_param, json, - master_index, ok, opt_usize_param, rect_param, str_param, usize_param, + master_index, ok, opt_usize_param, rect_param, str_param, }; pub(super) fn master_view(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; - let mi = usize_param(p, "master").unwrap_or_else(|| master_index(&st.doc, &st.selection)); + let mi = opt_usize_param(p, "master", "view.slideMaster")?.unwrap_or_else(|| master_index(&st.doc, &st.selection)); // A master thumbnail sends `{master}` and means the master itself. `{master, layout}` selects // that layout. With neither key, follow the current slide's layout. let layout = if p.get("master").is_some() && p.get("layout").is_none() { None } else { - usize_param(p, "layout") + opt_usize_param(p, "layout", "view.slideMaster")? .or_else(|| st.current_slide().and_then(|sl| st.doc.masters.get(mi).and_then(|m| m.layouts.iter().position(|l| l.id == sl.layout)))) }; let target = match layout { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 68de13418..140a88def 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6458,3 +6458,14 @@ fn master_body_placeholder_format_keeps_per_level_formatting() { assert_eq!((level(&s, 0).run.size, level(&s, 1).run.size), (Some(28.0), Some(24.0))); assert_eq!(shown(&s), vec![Some(28.0), Some(24.0)]); } + +#[test] +fn slide_master_view_rejects_invalid_indices() { + let mut s = Session::with_new(); + for p in [json!({"master": -1}), json!({"master": 0.5}), json!({"master": 0, "layout": -1}), json!({"master": 0, "layout": 1.5})] { + let r = s.execute("view.slideMaster", &p); + assert!(r.is_err(), "invalid index accepted for {p}: {r:?}"); + } + assert!(s.execute("view.slideMaster", &json!({})).is_ok()); + assert!(s.execute("view.slideMaster", &json!({"master": 0})).is_ok()); +} From 77cd617596da9052fb833ed65401aa0afbd4f118 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:37:19 +0100 Subject: [PATCH 13/79] Use a short temp file name in write_file for long file names Found while testing crates/engine/src/io.rs:49 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/io.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 7d21bb4b8..38f4de8d2 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -41,12 +41,13 @@ pub fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> { Ok(canonical) => canonical, Err(_) => std::path::PathBuf::from(path), }; - let target_str = target_path.to_str().unwrap_or(path); let existing_perms = std::fs::metadata(&target_path).ok().map(|m| m.permissions()); let mut attempts = 0; let (tmp, mut file) = loop { - let tmp = format!("{target_str}.{}.{}.saving", std::process::id(), COUNTER.fetch_add(1, Ordering::Relaxed)); + // A short fixed-size name: appending to the target's name could exceed the file name limit. + let tmp_name = format!(".deckcraft.{}.{}.saving", std::process::id(), COUNTER.fetch_add(1, Ordering::Relaxed)); + let tmp = target_path.with_file_name(tmp_name); match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) { Ok(f) => break (tmp, f), Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists && attempts < 16 => attempts += 1, @@ -120,4 +121,18 @@ mod tests { let _ = std::fs::remove_file(&test_file); let _ = std::fs::remove_dir(&tmp_dir); } + + #[test] + fn write_file_overwrites_file_with_name_near_the_length_limit() { + let dir = std::env::temp_dir().join("deckcraft_io_long_name_test"); + let _ = std::fs::create_dir_all(&dir); + let file = dir.join(format!("{}.pptx", "a".repeat(250))); + std::fs::write(&file, b"old").unwrap(); + let path_str = file.to_str().unwrap(); + + write_file(path_str, b"new").unwrap(); + assert_eq!(read_file(path_str).unwrap(), b"new"); + + let _ = std::fs::remove_file(&file); + } } From 62ada3ab9a00f9fc5724ded9814745b69b3d80ec Mon Sep 17 00:00:00 2001 From: Leonardo Date: Mon, 2 Nov 2026 16:29:41 -0300 Subject: [PATCH 14/79] fix(render): tiled picture fills keep their size in scaled groups and upright tiles A tiled picture fill (a:blipFill with a:tile) was sized in the shape's own units. Inside a group whose child space differs from its box (old decks write child spaces ~1800 times smaller) one tile covered hundreds of boxes, so the fill showed a single flat texel instead of the texture. PowerPoint's own PNG export of a synthetic probe deck shows the tile keeps its size, offset, anchor and mirroring in slide points whatever the group's scale, uniform or stretched unevenly, nested or flipped. The tile is now converted from points to shape units per axis (Renderer::per_point_xy, paint:: tiled_per_point), and snapped to whole device pixels per axis. Tiles with rotWithShape="0" are drawn in device pixels and were sized in pixels, ignoring the zoom (even outside groups); they are now sized to pixels first. Tests: a tile in groups of seven scales (uniform and uneven, wide and tall tile), and an upright tile in a turned shape at zoom 1 and 2. Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 2 + crates/render/src/paint.rs | 31 ++++++++-- crates/render/src/renderer.rs | 21 +++++-- crates/render/src/tests_wild.rs | 100 ++++++++++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index ecf27f318..2b472291e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -148,6 +148,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-11-02 | Tiled picture fills in groups: a tile (`a:blipFill` with `a:tile`) keeps its size, offset and anchor in slide points whatever the group's child-coordinate scale, uniform or stretched unevenly, nested or flipped, and tiles with `rotWithShape="0"` keep their size in points at any zoom, as PowerPoint draws them (measured on its own PNG export of a synthetic probe deck: every grouped case within ~3/255 of the ungrouped control's own difference from PowerPoint, which is ~13/255 from resampling). Before, a group whose child space was ~1800 times smaller than its box showed one flat texel of the texture (a Venn diagram with paper-textured circles and a hatched intersection) | | 2026-10-31 | No Fill shape interiors allow selection and editing of underlying objects, even when a quick style remains; hit-testing uses the renderer’s fill resolution. Outlines, rotation, text targets and actual fills keep their hit behaviour (#1621) | | 2026-10-30 | Print-preview tests use the URL emitted by egui and its exact file path; previews are explicitly removed before the macOS process exits | | 2026-10-30 | `slide.actions` lists shape click and hover actions and text-run hyperlinks (including table cells and grouped shapes) for audit; listing never runs an action. The UI pane is a follow-up | @@ -319,6 +320,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-11-02 | trivial | Progress log: tiled picture fills keep their size in scaled groups. Scores and milestone names unchanged | | 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged | | 2026-10-30 | trivial | Progress log: read-only slide action inspection; parity scores unchanged | | 2026-10-29 | trivial | Progress log: going back in the slide show reverses the left slide's transition (#1531). Scores and milestone names unchanged | diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index ee572fd98..3c4b1db54 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -91,7 +91,10 @@ pub fn fill_path( let on_path = m * path.clone(); ctx.set_transform(Affine::IDENTITY); ctx.push_clip_layer(&on_path); - picture(ctx, pres, &resolved(rctx, pf, ph), on_slide, Affine::IDENTITY, None); + // Drawn in device pixels, so a tile (given in shape units) is sized to pixels first. + let tiled = tiled_per_point(fill, (c[0].hypot(c[1]), c[2].hypot(c[3]))); + let upright = if let Fill::Picture(p) = tiled.as_ref() { p } else { pf }; + picture(ctx, pres, &resolved(rctx, upright, ph), on_slide, Affine::IDENTITY, None); ctx.pop_layer(); return; } @@ -226,9 +229,12 @@ pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, b } let (nw, nh) = images::natural_size(&item.data).unwrap_or((iw, ih)); // PowerPoint draws the tile at a whole number of output pixels. - let k = px_scale.max(1e-9); - let snap = |v: f64| (v * k).round().max(1.0) / k; - let (tile_w, tile_h) = (snap(nw * 0.75 * sx), snap(nh * 0.75 * sy)); + // Per axis: a group can stretch its child space unevenly, and a pixel is then a different + // number of shape units across and down. + let c = m.as_coeffs(); + let (kx, ky) = (c[0].hypot(c[1]).max(1e-9), c[2].hypot(c[3]).max(1e-9)); + let snap = |v: f64, k: f64| (v * k).round().max(1.0) / k; + let (tile_w, tile_h) = (snap(nw * 0.75 * sx, kx), snap(nh * 0.75 * sy, ky)); if !(tile_w > 1e-6 && tile_h > 1e-6 && tile_w.is_finite() && tile_h.is_finite()) { return; } @@ -260,6 +266,23 @@ pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, b draw_image(ctx, &image, iw, ih, dest, m, alpha); } +/// `fill`, with a tiled picture's tile size and offset moved from points to shape units, given +/// the shape units to the point along x and y. A group's child coordinates scale its shapes' +/// boxes (a group can stretch them unevenly), but a tile keeps its size on the slide as PowerPoint +/// draws it, so in a group with a legacy child space the tile would otherwise cover hundreds of +/// boxes and the fill show one flat texel. Anything but a tiled picture, or a unit scale, is +/// returned as it is. +pub fn tiled_per_point(fill: &Fill, per_point: (f64, f64)) -> std::borrow::Cow<'_, Fill> { + let Fill::Picture(pf) = fill else { return std::borrow::Cow::Borrowed(fill) }; + let PictureMode::Tile { tx, ty, sx, sy, flip, align } = &pf.mode else { return std::borrow::Cow::Borrowed(fill) }; + let (kx, ky) = per_point; + if !(kx.is_finite() && ky.is_finite() && kx > 1e-12 && ky > 1e-12) || ((kx - 1.0).abs() < 1e-9 && (ky - 1.0).abs() < 1e-9) { + return std::borrow::Cow::Borrowed(fill); + } + let mode = PictureMode::Tile { tx: tx * kx, ty: ty * ky, sx: sx * kx, sy: sy * ky, flip: flip.clone(), align: align.clone() }; + std::borrow::Cow::Owned(Fill::Picture(PictureFill { mode, ..pf.clone() })) +} + /// Where the first tile of a tiled picture fill goes in `bounds`: its corner, edge middle or /// centre named by `align` (`tl`, `t`, `tr`, `l`, `ctr`, `r`, `bl`, `b`, `br`) on the same point /// of the box, then moved by the offset (points). diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs index c25f6bab6..916f508e6 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -346,6 +346,14 @@ impl Renderer { if k.is_finite() && k > 1e-12 { 1.0 / k } else { 1.0 } } + /// [`Renderer::per_point`] along the shape's own x and y axes: a group can stretch its child + /// space unevenly, and a tiled fill keeps its size in points on the slide on both axes. + fn per_point_xy(&self, m: Affine, depth: usize) -> (f64, f64) { + let c = self.line_space(m, depth).1.as_coeffs(); + let inv = |s: f64| if s.is_finite() && s > 1e-12 { 1.0 / s } else { 1.0 }; + (inv(c[0].hypot(c[1])), inv(c[2].hypot(c[3]))) + } + /// The size and transform a shape's text is set in: inside a group whose child coordinates /// scale the shape, the shape's size in slide points and `m` with that scale taken out, so /// insets and font sizes stay in points; otherwise `w`, `h` and `m` themselves. @@ -541,6 +549,7 @@ impl Renderer { geo: &Geometry, (w, h): (f64, f64), m: Affine, + per_point: (f64, f64), fade: &dyn Fn(usize) -> deckcraft_geom::morph::Fade, ) { for (i, sp) in geo.paths.iter().enumerate() { @@ -566,7 +575,10 @@ impl Renderer { paint::fill_path(ctx, rctx, gf, None, &path, *gbox, *gm, sp.fill, fade(i).fill, f.pres); } } - _ => paint::fill_path(ctx, rctx, fl, fill_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, sp.fill, fade(i).fill, f.pres), + _ => { + let fl = paint::tiled_per_point(fl, per_point); + paint::fill_path(ctx, rctx, &fl, fill_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, sp.fill, fade(i).fill, f.pres) + } } ctx.set_fill_rule(peniko::Fill::NonZero); } @@ -736,6 +748,7 @@ impl Renderer { let fill = &d.fill; let outline = &d.outline; let fade = |i: usize| d.fade(i); + let pt_xy = self.per_point_xy(m, d.depth); // Fill. match &s.kind { ShapeKind::Picture { fill: pf } => { @@ -744,7 +757,7 @@ impl Renderer { if s.fill.as_ref().is_some_and(|f| !f.is_none()) && let Some(fl) = &fill { - self.fill_geometry(ctx, f, rctx, fl, fill_ph, geo, (w, h), m, &fade); + self.fill_geometry(ctx, f, rctx, fl, fill_ph, geo, (w, h), m, pt_xy, &fade); } ctx.set_transform(m); let bounds = Rect::new(0.0, 0.0, w, h); @@ -761,7 +774,7 @@ impl Renderer { if s.fill.as_ref().is_some_and(|f| !f.is_none()) && let Some(fl) = &fill { - self.fill_geometry(ctx, f, rctx, fl, fill_ph, geo, (w, h), m, &fade); + self.fill_geometry(ctx, f, rctx, fl, fill_ph, geo, (w, h), m, pt_xy, &fade); } ctx.set_transform(m); let pf = deckcraft_model::style::PictureFill { media: *media, ..Default::default() }; @@ -787,7 +800,7 @@ impl Renderer { if let Some(fl) = &fill && !(empty_ph && f.opts.edit && s.fill.is_none() && s.line.is_none() && s.style.is_none()) { - self.fill_geometry(ctx, f, rctx, fl, fill_ph, geo, (w, h), m, &fade); + self.fill_geometry(ctx, f, rctx, fl, fill_ph, geo, (w, h), m, pt_xy, &fade); } } } diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index ae78f64b1..96a546f0c 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -454,6 +454,106 @@ fn tiled_pictures_flip_align_and_skip_non_positive_scales() { assert!(close(img.pixel(115, 115), WHITE, 5) && close(img.pixel(300, 150), WHITE, 5)); } +#[allow(clippy::unwrap_used)] +fn red_over_blue() -> Vec { + let img = image::RgbImage::from_fn(4, 8, |_, y| if y < 4 { image::Rgb([255, 0, 0]) } else { image::Rgb([0, 0, 255]) }); + let mut out = std::io::Cursor::new(vec![]); + image::DynamicImage::ImageRgb8(img).write_to(&mut out, image::ImageFormat::Png).unwrap(); + out.into_inner() +} + +#[test] +fn a_tile_keeps_its_size_in_a_group_with_scaled_child_coordinates() { + use deckcraft_model::style::{PictureFill, PictureMode}; + // A group's child space scales its shapes' boxes, not the tile: the same tiles show whether + // the group's child space is 1600 times smaller (old decks write them so), 4 times larger, + // the slide's own or stretched unevenly (PowerPoint draws all of them alike, measured on its + // own export of a probe deck). Before, one tile covered hundreds of boxes and the fill was one + // flat texel, and an uneven group stretched the tile. + // Wide: 8x4 px = 6x3 pt, ten times = 60x30 pt tiles, red then blue across. + // Tall: 4x8 px = 3x6 pt, ten times = 30x60 pt tiles, red over blue down. + let blue = Rgba::rgb(0, 0, 255); + let scales = [(1.0, 1.0), (1600.0, 1600.0), (0.25, 0.25), (2.0, 0.5), (0.5, 2.0), (1800.0, 1.0), (1.0, 1800.0)]; + for (kx, ky) in scales { + for tall in [false, true] { + let mut p = Presentation::default(); + let png = if tall { red_over_blue() } else { half_red_half_blue() }; + let media = p.add_media("t.png", "image/png", png).expect("media"); + let mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 10.0, sy: 10.0, flip: "none".into(), align: "tl".into() }; + let member = Shape { + id: ShapeId(22), + xfrm: Some(Xfrm::new(0.0, 0.0, 240.0 / kx, 60.0 / ky)), + fill: Some(Fill::Picture(PictureFill { media, mode, ..Default::default() })), + line: no_line(), + ..Default::default() + }; + let group = Shape { + id: ShapeId(21), + xfrm: Some(Xfrm::new(100.0, 100.0, 240.0, 60.0)), + kind: ShapeKind::Group { children: vec![member], child: Xfrm::new(0.0, 0.0, 240.0 / kx, 60.0 / ky) }, + ..Default::default() + }; + std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![group]; + let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + // Along the tile's long side: first half red, second blue, then red again. + let (a, b, c) = if tall { ((115, 115), (115, 145), (115, 175)) } else { ((115, 115), (145, 115), (175, 115)) }; + let (pa, pb, pc) = (img.pixel(a.0, a.1), img.pixel(b.0, b.1), img.pixel(c.0, c.1)); + assert!( + close(pa, RED, 30) && close(pb, blue, 30) && (tall || close(pc, RED, 30)) && (!tall || close(pc, WHITE, 5) || close(pc, RED, 30)), + "child space {kx} x {ky}, tall={tall}: {pa:?} {pb:?} {pc:?}" + ); + } + } +} + +#[test] +fn an_upright_tile_in_a_turned_shape_keeps_its_size_in_points_at_any_zoom() { + use deckcraft_model::style::{PictureFill, PictureMode}; + // `rotWithShape="0"` draws the tiles on the slide's axes, in device pixels: they were sized in + // pixels, so zooming (or a group's child scale) changed their size on the slide. PowerPoint's + // export of the same shapes shows 60x30 pt tiles at every zoom. + let draw = |scale: f64, group: f64| { + let mut p = Presentation::default(); + let media = p.add_media("t.png", "image/png", half_red_half_blue()).expect("media"); + let mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 10.0, sy: 10.0, flip: "none".into(), align: "tl".into() }; + let fill = Fill::Picture(PictureFill { media, mode, upright: true, ..Default::default() }); + let shape = Shape { + id: ShapeId(22), + xfrm: Some(Xfrm { rot: 20.0, ..Xfrm::new(0.0, 0.0, 240.0 / group, 120.0 / group) }), + fill: Some(fill), + line: no_line(), + ..Default::default() + }; + let shape = if group == 1.0 { + Shape { xfrm: Some(Xfrm { rot: 20.0, ..Xfrm::new(100.0, 100.0, 240.0, 120.0) }), ..shape } + } else { + Shape { + id: ShapeId(21), + xfrm: Some(Xfrm::new(100.0, 100.0, 240.0, 120.0)), + kind: ShapeKind::Group { children: vec![shape], child: Xfrm::new(0.0, 0.0, 240.0 / group, 120.0 / group) }, + ..Default::default() + } + }; + std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![shape]; + render_slide(&p, 0, &RenderOpts { scale, ..Default::default() }) + }; + for group in [1.0, 0.25] { + let (one, two) = (draw(1.0, group), draw(2.0, group)); + let (mut same, mut total) = (0, 0); + for y in (120..220).step_by(7) { + for x in (120..320).step_by(9) { + let (a, b) = (one.pixel(x, y), two.pixel(2 * x, 2 * y)); + if close(a, WHITE, 5) && close(b, WHITE, 5) { + continue; // outside the turned rectangle + } + total += 1; + same += usize::from(close(a, Rgba::rgb(b[0], b[1], b[2]), 40)); + } + } + assert!(total > 40 && same * 10 >= total * 9, "group scale {group}: {same} of {total} samples agree between zoom 1 and 2"); + } +} + #[test] fn a_tiles_first_copy_sits_on_the_point_its_alignment_names() { let b = Rect::new(0.0, 0.0, 100.0, 50.0); From 5cb6ba8403a0d1598c59c83e9fd5c83a227a29ee Mon Sep 17 00:00:00 2001 From: Luis <43929895+Luis-0508@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:03:34 +0100 Subject: [PATCH 15/79] Comments: the pane keeps its width and resizes again (fixes #1431) A comment card's reply row reserved a fixed 70 px for its Resolve and delete buttons, which need more. The row overflowed the card, the pane grew by the overflow every frame until it stuck at its widest, and dragging its edge no longer changed it. The buttons are now laid out first, from the right, and the reply field takes the width they leave. --- crates/ui-egui/src/panels/comments.rs | 47 +++++++++++++++------------ crates/ui-egui/src/tests.rs | 44 +++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 21 deletions(-) diff --git a/crates/ui-egui/src/panels/comments.rs b/crates/ui-egui/src/panels/comments.rs index edf0e8b84..5acb71402 100644 --- a/crates/ui-egui/src/panels/comments.rs +++ b/crates/ui-egui/src/panels/comments.rs @@ -130,29 +130,34 @@ fn comment_card(app: &mut SlideApp, ui: &mut Ui, slide: usize, index: usize, c: for r in &c.replies { ui.label(egui::RichText::new(format!("↳ {}: {}", r.author, r.text)).color(t.text_dim)); } + // The buttons are laid out first, from the right, so the reply field takes exactly the + // width they leave. A fixed allowance overflowed the card and widened the pane every + // frame until it stuck at its widest (#1431). ui.horizontal(|ui| { - let id = ui.scope_id().with(("reply", slide, index)); - let mut txt: String = ui.data_mut(|d| d.get_temp(id).unwrap_or_default()); - let r = ui.add(egui::TextEdit::singleline(&mut txt).hint_text("Reply…").desired_width(ui.available_width() - 70.0)); - if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && !txt.trim().is_empty() { - focus_comment(app, slide, index); - run(app, "comment.reply", json!({"index": index, "text": txt})); - txt.clear(); - } - ui.data_mut(|d| d.insert_temp(id, txt)); - if ui.small_button(if c.resolved { "Reopen" } else { "Resolve" }).clicked() { - focus_comment(app, slide, index); - run(app, "comment.resolve", json!({"index": index, "resolved": !c.resolved})); - } - if ui.small_button("🗑").clicked() { - let _ = app.run("slide.go", json!({"index": slide})); - let _ = app.run("comment.delete", json!({"index": index})); - match app.ui.comment_focus { - Some((fs, fi)) if fs == slide && fi == index => app.ui.comment_focus = None, - Some((fs, fi)) if fs == slide && fi > index => app.ui.comment_focus = Some((fs, fi - 1)), - _ => {} + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.small_button("🗑").clicked() { + let _ = app.run("slide.go", json!({"index": slide})); + let _ = app.run("comment.delete", json!({"index": index})); + match app.ui.comment_focus { + Some((fs, fi)) if fs == slide && fi == index => app.ui.comment_focus = None, + Some((fs, fi)) if fs == slide && fi > index => app.ui.comment_focus = Some((fs, fi - 1)), + _ => {} + } } - } + if ui.small_button(if c.resolved { "Reopen" } else { "Resolve" }).clicked() { + focus_comment(app, slide, index); + run(app, "comment.resolve", json!({"index": index, "resolved": !c.resolved})); + } + let id = ui.scope_id().with(("reply", slide, index)); + let mut txt: String = ui.data_mut(|d| d.get_temp(id).unwrap_or_default()); + let r = ui.add(egui::TextEdit::singleline(&mut txt).hint_text("Reply…").desired_width(ui.available_width())); + if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && !txt.trim().is_empty() { + focus_comment(app, slide, index); + run(app, "comment.reply", json!({"index": index, "text": txt})); + txt.clear(); + } + ui.data_mut(|d| d.insert_temp(id, txt)); + }); }); }); if focused { diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index 29fa755fd..5221544a1 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -2361,3 +2361,47 @@ fn overdue_visible_logic_does_not_reanchor_a_later_hidden_show() { let _ = ctx.run_logic(&raw_at(12.0, true, false), |ctx| app.tick_hidden_show(ctx, visible_anchor + std::time::Duration::from_secs(3))); assert_eq!(app.show.as_ref().unwrap().state.slide, 1, "time from the full visible frame is preserved through the logic-only pass"); } + +/// #1431: a comment card's reply row was wider than the pane, so the Comments pane grew every +/// frame until it stuck at its widest and dragging its edge did nothing. +#[test] +fn comments_pane_keeps_its_width_and_resizes() { + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + h.run_steps(3); + h.state_mut().run("comment.add", json!({"text": "Check the figures on this slide"})).unwrap(); + h.state_mut().run("comment.add", json!({"text": "Resolved one"})).unwrap(); + h.state_mut().run("comment.resolve", json!({"index": 1, "resolved": true})).unwrap(); + h.state_mut().run("view.pane", json!({"pane": "comments"})).unwrap(); + h.run_steps(30); + let width = h.state().ui.pane_width; + assert!(width < 320.0, "the pane keeps its default width, not {width}"); + // The cards stay compact: the second one starts near the top, not below a stretched first. + let second = h.get_by_label("Resolved one").rect(); + assert!(second.top() < 450.0, "the second comment sits near the top, at {second:?}"); + + // Drag the pane's left edge: right narrows it, left widens it. + let drag = |h: &mut egui_kittest::Harness<'_, SlideApp>, dx: f32| { + let start = egui::pos2(1200.0 - h.state().ui.pane_width - 1.0, 500.0); + let end = start + egui::vec2(dx, 0.0); + h.event(egui::Event::PointerMoved(start)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: start, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + for i in 1..=6 { + h.event(egui::Event::PointerMoved(start + (end - start) * (i as f32 / 6.0))); + h.run_steps(1); + } + h.event(egui::Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(10); + h.state().ui.pane_width + }; + let narrow = drag(&mut h, 50.0); + assert!(narrow < width - 40.0, "dragging right narrows the pane: {width} -> {narrow}"); + let wide = drag(&mut h, -150.0); + assert!(wide > narrow + 140.0, "dragging left widens the pane: {narrow} -> {wide}"); +} From 9e59b5f2238f386a44c0324e3dbb8690d19c7bd3 Mon Sep 17 00:00:00 2001 From: Leonardo Date: Mon, 2 Nov 2026 17:31:10 -0300 Subject: [PATCH 16/79] fix(render): size a group's own tile and a picture shape's own tile in points too Review of #1693 found two more places that drew a tiled picture fill in the shape's own (child-space) units inside a scaled group. A group nested in a scaled group stored its own tiled fill unconverted, so the members that take it (grpFill) got oversized tiles. The owning group now converts the fill with per_point_xy when it stores it, so inheriting members, directly or through a chain of grpFill groups, get the converted tile. A picture shape (p:pic) whose blip fill is a tile passed its own fill straight to paint::picture. It now goes through paint::tiled_picture_per_point, which tiled_per_point also uses. Tests: a group's tile passed to its members and a picture shape's own tile, each in outer groups scaled 1, 0.25, 1600 and 2x0.5; both fail without the change. Docs: the Picture and texture fill checklist row is re-assessed (remains P) with its status line and revision history, and the ROADMAP progress log row mentions both cases. Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 2 +- crates/render/src/paint.rs | 14 ++++++-- crates/render/src/renderer.rs | 6 ++-- crates/render/src/tests_wild.rs | 62 +++++++++++++++++++++++++++++++++ docs/parity-checklist.md | 5 ++- 5 files changed, 82 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2b472291e..3a1889a8c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -148,7 +148,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| -| 2026-11-02 | Tiled picture fills in groups: a tile (`a:blipFill` with `a:tile`) keeps its size, offset and anchor in slide points whatever the group's child-coordinate scale, uniform or stretched unevenly, nested or flipped, and tiles with `rotWithShape="0"` keep their size in points at any zoom, as PowerPoint draws them (measured on its own PNG export of a synthetic probe deck: every grouped case within ~3/255 of the ungrouped control's own difference from PowerPoint, which is ~13/255 from resampling). Before, a group whose child space was ~1800 times smaller than its box showed one flat texel of the texture (a Venn diagram with paper-textured circles and a hatched intersection) | +| 2026-11-02 | Tiled picture fills in groups: a tile (`a:blipFill` with `a:tile`) keeps its size, offset and anchor in slide points whatever the group's child-coordinate scale, uniform or stretched unevenly, nested or flipped (also a group's own tile fill passed to its members, and a picture shape's own tile), and tiles with `rotWithShape="0"` keep their size in points at any zoom, as PowerPoint draws them (measured on its own PNG export of a synthetic probe deck: every grouped case within ~3/255 of the ungrouped control's own difference from PowerPoint, which is ~13/255 from resampling). Before, a group whose child space was ~1800 times smaller than its box showed one flat texel of the texture (a Venn diagram with paper-textured circles and a hatched intersection) | | 2026-10-31 | No Fill shape interiors allow selection and editing of underlying objects, even when a quick style remains; hit-testing uses the renderer’s fill resolution. Outlines, rotation, text targets and actual fills keep their hit behaviour (#1621) | | 2026-10-30 | Print-preview tests use the URL emitted by egui and its exact file path; previews are explicitly removed before the macOS process exits | | 2026-10-30 | `slide.actions` lists shape click and hover actions and text-run hyperlinks (including table cells and grouped shapes) for audit; listing never runs an action. The UI pane is a follow-up | diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index 3c4b1db54..74d111236 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -274,13 +274,21 @@ pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, b /// returned as it is. pub fn tiled_per_point(fill: &Fill, per_point: (f64, f64)) -> std::borrow::Cow<'_, Fill> { let Fill::Picture(pf) = fill else { return std::borrow::Cow::Borrowed(fill) }; - let PictureMode::Tile { tx, ty, sx, sy, flip, align } = &pf.mode else { return std::borrow::Cow::Borrowed(fill) }; + match tiled_picture_per_point(pf, per_point) { + std::borrow::Cow::Borrowed(_) => std::borrow::Cow::Borrowed(fill), + std::borrow::Cow::Owned(p) => std::borrow::Cow::Owned(Fill::Picture(p)), + } +} + +/// [`tiled_per_point`] for a picture fill itself (a picture shape's own fill is not a [`Fill`]). +pub fn tiled_picture_per_point(pf: &PictureFill, per_point: (f64, f64)) -> std::borrow::Cow<'_, PictureFill> { + let PictureMode::Tile { tx, ty, sx, sy, flip, align } = &pf.mode else { return std::borrow::Cow::Borrowed(pf) }; let (kx, ky) = per_point; if !(kx.is_finite() && ky.is_finite() && kx > 1e-12 && ky > 1e-12) || ((kx - 1.0).abs() < 1e-9 && (ky - 1.0).abs() < 1e-9) { - return std::borrow::Cow::Borrowed(fill); + return std::borrow::Cow::Borrowed(pf); } let mode = PictureMode::Tile { tx: tx * kx, ty: ty * ky, sx: sx * kx, sy: sy * ky, flip: flip.clone(), align: align.clone() }; - std::borrow::Cow::Owned(Fill::Picture(PictureFill { mode, ..pf.clone() })) + std::borrow::Cow::Owned(PictureFill { mode, ..pf.clone() }) } /// Where the first tile of a tiled picture fill goes in `bounds`: its corner, edge middle or diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs index 916f508e6..699a938f2 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -436,7 +436,8 @@ impl Renderer { // A group's own fill (a group filled with its parent's passes that on). let own = match &s.fill { Some(Fill::Group) | None => self.group_fill.clone(), - Some(fl) => Some((fl.clone(), m, Rect::new(0.0, 0.0, x.w, x.h))), + // A tile is given in points; this group's own space may be scaled by its parents. + Some(fl) => Some((paint::tiled_per_point(fl, self.per_point_xy(m, depth)).into_owned(), m, Rect::new(0.0, 0.0, x.w, x.h))), }; let outer = std::mem::replace(&mut self.group_fill, own); // A group's outer shadow is cast by everything in it, as PowerPoint draws it: the @@ -763,7 +764,8 @@ impl Renderer { let bounds = Rect::new(0.0, 0.0, w, h); ctx.push_clip_layer(outline); let t = f.opts.gif_times.iter().find(|g| g.0 == s.id).map(|g| g.1); - paint::picture(ctx, f.pres, &paint::resolved(rctx, pf, None), bounds, m, t); + let pf = paint::tiled_picture_per_point(pf, pt_xy); + paint::picture(ctx, f.pres, &paint::resolved(rctx, &pf, None), bounds, m, t); ctx.pop_layer(); } // An object DeckCraft doesn't model (an embedded OLE object, an unknown graphic): its diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index 96a546f0c..c81e481c5 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -506,6 +506,68 @@ fn a_tile_keeps_its_size_in_a_group_with_scaled_child_coordinates() { } } +/// A slide with `shape` (built for a box of `w` x `h` child units) in a group at (100, 100) that is +/// 240 x 60 pt on the slide and scaled `kx` x `ky` against its child space, drawn at 1 px per point. +fn in_a_scaled_group(kx: f64, ky: f64, p: Presentation, shape: impl FnOnce(f64, f64) -> Shape) -> Image { + let (w, h) = (240.0 / kx, 60.0 / ky); + let group = Shape { + id: ShapeId(21), + xfrm: Some(Xfrm::new(100.0, 100.0, 240.0, 60.0)), + kind: ShapeKind::Group { children: vec![shape(w, h)], child: Xfrm::new(0.0, 0.0, w, h) }, + ..Default::default() + }; + let mut p = p; + std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![group]; + render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }) +} + +#[test] +fn a_tile_a_group_passes_to_its_members_keeps_its_size_inside_a_scaled_group() { + use deckcraft_model::style::{PictureFill, PictureMode}; + // The group that owns the tiled fill sits inside another group whose child space is scaled: the + // members that take it (`grpFill`) get tiles of 60 x 30 pt, as with no outer group. + let blue = Rgba::rgb(0, 0, 255); + for (kx, ky) in [(1.0, 1.0), (0.25, 0.25), (1600.0, 1600.0), (2.0, 0.5)] { + let mut p = Presentation::default(); + let media = p.add_media("t.png", "image/png", half_red_half_blue()).expect("media"); + let mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 10.0, sy: 10.0, flip: "none".into(), align: "tl".into() }; + let img = in_a_scaled_group(kx, ky, p, |w, h| { + let member = + Shape { id: ShapeId(23), xfrm: Some(Xfrm::new(0.0, 0.0, w, h)), fill: Some(Fill::Group), line: no_line(), ..Default::default() }; + Shape { + id: ShapeId(22), + xfrm: Some(Xfrm::new(0.0, 0.0, w, h)), + fill: Some(Fill::Picture(PictureFill { media, mode, ..Default::default() })), + kind: ShapeKind::Group { children: vec![member], child: Xfrm::new(0.0, 0.0, w, h) }, + ..Default::default() + } + }); + let (a, b, c) = (img.pixel(115, 115), img.pixel(145, 115), img.pixel(175, 115)); + assert!(close(a, RED, 30) && close(b, blue, 30) && close(c, RED, 30), "outer group {kx} x {ky}: {a:?} {b:?} {c:?}"); + } +} + +#[test] +fn a_picture_shapes_own_tile_keeps_its_size_inside_a_scaled_group() { + use deckcraft_model::style::{PictureFill, PictureMode}; + // A picture (`p:pic`) whose blip fill is a tile rather than a stretch. + let blue = Rgba::rgb(0, 0, 255); + for (kx, ky) in [(1.0, 1.0), (0.25, 0.25), (1600.0, 1600.0), (2.0, 0.5)] { + let mut p = Presentation::default(); + let media = p.add_media("t.png", "image/png", half_red_half_blue()).expect("media"); + let mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 10.0, sy: 10.0, flip: "none".into(), align: "tl".into() }; + let img = in_a_scaled_group(kx, ky, p, |w, h| Shape { + id: ShapeId(22), + xfrm: Some(Xfrm::new(0.0, 0.0, w, h)), + kind: ShapeKind::Picture { fill: PictureFill { media, mode, ..Default::default() } }, + line: no_line(), + ..Default::default() + }); + let (a, b, c) = (img.pixel(115, 115), img.pixel(145, 115), img.pixel(175, 115)); + assert!(close(a, RED, 30) && close(b, blue, 30) && close(c, RED, 30), "group {kx} x {ky}: {a:?} {b:?} {c:?}"); + } +} + #[test] fn an_upright_tile_in_a_turned_shape_keeps_its_size_in_points_at_any_zoom() { use deckcraft_model::style::{PictureFill, PictureMode}; diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 606f1228d..8af3745ea 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -2,7 +2,9 @@ > **Generated:** 2026-10-14 (bullets/numbering remain D after empty-line marker review; summary by `cargo xtask parity`; rows re-scored by the 2026-10-14 community PR wave, duplicate Thesaurus, AutoCorrect, Artistic effects and Corrections rows folded; Preview in editor re-scored 2026-10-13; earlier rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4; video/animated GIF row re-scored 2026-10-11, #171) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last updated:** 2026-11-01 · Locked-shape and no-outline regressions added for #1621; fill row re-assessed: remains D; No Fill hit-testing now follows resolved appearance. Scores unchanged. +> **Last updated:** 2026-11-02 · Tiled picture fills keep their size in scaled groups and upright tiles (#1693); Picture and texture fill row re-assessed: remains P, since only how tiles are sized changed. Scores unchanged. +> +> **Previously (2026-11-01):** Locked-shape and no-outline regressions added for #1621; fill row re-assessed: remains D; No Fill hit-testing now follows resolved appearance. Scores unchanged. The summary between ` **Weighted breadth parity: 84%** over 191 features. @@ -240,4 +242,5 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Date | Change | Summary | |---|---|---| +| 2026-11-02 | trivial | Tiled picture fills keep their size in scaled groups and upright tiles (#1693); Picture and texture fill row remains P and summary scores are unchanged | | 2026-10-31 | trivial | No Fill interior hit-testing follows resolved fill (#1621); fill row remains D and summary scores are unchanged | From e90e7f6c03afbf7a3694e88521aff6be5df53ba0 Mon Sep 17 00:00:00 2001 From: Leonardo Date: Mon, 2 Nov 2026 17:46:33 -0300 Subject: [PATCH 17/79] fix(render): size the tile of a picture's shadow and reflection in points too Review of #1693 found that cast_effects built the picture content that a shadow, a glow and a reflection are drawn from without converting a tiled fill to points, so inside a scaled group the reflection of a tiled picture showed child-space tiles while its face showed the right ones. The content now goes through paint::tiled_picture_per_point with per_point_xy, as the face does. Test: a tiled picture's reflection in groups scaled 0.25, 1600 and 2x0.5 shows the same colours as in an unscaled group; it fails without the change. Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 2 +- crates/render/src/renderer.rs | 8 ++++++-- crates/render/src/tests_wild.rs | 30 ++++++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3a1889a8c..050c3958b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -148,7 +148,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| -| 2026-11-02 | Tiled picture fills in groups: a tile (`a:blipFill` with `a:tile`) keeps its size, offset and anchor in slide points whatever the group's child-coordinate scale, uniform or stretched unevenly, nested or flipped (also a group's own tile fill passed to its members, and a picture shape's own tile), and tiles with `rotWithShape="0"` keep their size in points at any zoom, as PowerPoint draws them (measured on its own PNG export of a synthetic probe deck: every grouped case within ~3/255 of the ungrouped control's own difference from PowerPoint, which is ~13/255 from resampling). Before, a group whose child space was ~1800 times smaller than its box showed one flat texel of the texture (a Venn diagram with paper-textured circles and a hatched intersection) | +| 2026-11-02 | Tiled picture fills in groups: a tile (`a:blipFill` with `a:tile`) keeps its size, offset and anchor in slide points whatever the group's child-coordinate scale, uniform or stretched unevenly, nested or flipped (also a group's own tile fill passed to its members, and a picture shape's own tile, with its shadow and reflection), and tiles with `rotWithShape="0"` keep their size in points at any zoom, as PowerPoint draws them (measured on its own PNG export of a synthetic probe deck: every grouped case within ~3/255 of the ungrouped control's own difference from PowerPoint, which is ~13/255 from resampling). Before, a group whose child space was ~1800 times smaller than its box showed one flat texel of the texture (a Venn diagram with paper-textured circles and a hatched intersection) | | 2026-10-31 | No Fill shape interiors allow selection and editing of underlying objects, even when a quick style remains; hit-testing uses the renderer’s fill resolution. Outlines, rotation, text targets and actual fills keep their hit behaviour (#1621) | | 2026-10-30 | Print-preview tests use the URL emitted by egui and its exact file path; previews are explicitly removed before the macOS process exits | | 2026-10-30 | `slide.actions` lists shape click and hover actions and text-run hyperlinks (including table cells and grouped shapes) for audit; listing never runs an action. The UI pane is a follow-up | diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs index 699a938f2..e7cd5ff35 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -668,9 +668,13 @@ impl Renderer { let line = &d.line; // Effects below the shape. if let Some(e) = &effects { - // A picture (or picture fill) casts its shadow from its pixels, not its frame. + // A picture (or picture fill) casts its shadow from its pixels, not its frame. A tile + // is sized in points on the slide, as the face's is, and so is its shadow and reflection. let content = match (&s.kind, &fill) { - (ShapeKind::Picture { fill: pf }, _) | (_, Some(Fill::Picture(pf))) => Some(paint::resolved(rctx, pf, None).into_owned()), + (ShapeKind::Picture { fill: pf }, _) | (_, Some(Fill::Picture(pf))) => { + let pf = paint::tiled_picture_per_point(pf, self.per_point_xy(m, depth)); + Some(paint::resolved(rctx, &pf, None).into_owned()) + } _ => None, }; // What casts the shadow: the fill as see-through as it is, and the outline. diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index c81e481c5..987781f15 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -568,6 +568,36 @@ fn a_picture_shapes_own_tile_keeps_its_size_inside_a_scaled_group() { } } +#[test] +fn a_tiled_pictures_reflection_keeps_the_tile_size_inside_a_scaled_group() { + use deckcraft_model::style::{PictureFill, PictureMode, Reflection}; + // The reflection mirrors the picture's pixels, so its tiles must be the face's, 60 x 30 pt, + // whatever the group's child scale: just below the box it shows the same colours. + let reflected = |kx: f64, ky: f64| { + let mut p = Presentation::default(); + let media = p.add_media("t.png", "image/png", half_red_half_blue()).expect("media"); + let mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 10.0, sy: 10.0, flip: "none".into(), align: "tl".into() }; + let rf = Reflection { blur: 0.0, start_alpha: 1.0, end_alpha: 0.0, end_pos: 0.5, dist: 0.0 }; + let img = in_a_scaled_group(kx, ky, p, |w, h| Shape { + id: ShapeId(22), + xfrm: Some(Xfrm::new(0.0, 0.0, w, h)), + kind: ShapeKind::Picture { fill: PictureFill { media, mode, ..Default::default() } }, + line: no_line(), + effects: Some(deckcraft_model::Effects { reflection: Some(rf), ..Default::default() }), + ..Default::default() + }); + [img.pixel(115, 163), img.pixel(145, 163), img.pixel(175, 163)] + }; + let plain = reflected(1.0, 1.0); + assert!(plain[0][0] > plain[0][2] && plain[1][2] > plain[1][0] && plain[2][0] > plain[2][2], "red, blue, red in the reflection: {plain:?}"); + for (kx, ky) in [(0.25, 0.25), (1600.0, 1600.0), (2.0, 0.5)] { + let got = reflected(kx, ky); + for (g, e) in got.iter().zip(&plain) { + assert!(close(*g, Rgba::rgb(e[0], e[1], e[2]), 40), "child scale {kx} x {ky}: {got:?} against {plain:?}"); + } + } +} + #[test] fn an_upright_tile_in_a_turned_shape_keeps_its_size_in_points_at_any_zoom() { use deckcraft_model::style::{PictureFill, PictureMode}; From f2803102c1abb1cc65681f7a21522b3ba7a125a9 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:41:17 -0600 Subject: [PATCH 18/79] Correct animation option labels --- crates/ui-egui/src/ribbon/animations.rs | 37 +++++++++++++++++++------ 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/ribbon/animations.rs b/crates/ui-egui/src/ribbon/animations.rs index 6d35afdcb..d43801603 100644 --- a/crates/ui-egui/src/ribbon/animations.rs +++ b/crates/ui-egui/src/ribbon/animations.rs @@ -228,9 +228,12 @@ fn animation_option_label(effect: &str, option: &str) -> String { "rb" => Some("From Bottom-Right"), _ => None, }; - if matches!(effect, "fly" | "flyOut" | "wipe" | "wipeOut" | "peek" | "peekOut") + if matches!(effect, "fly" | "flyOut" | "wipe" | "wipeOut" | "peek" | "peekOut" | "crawl" | "crawlOut") && let Some(label) = direction { + if matches!(effect, "flyOut" | "peekOut" | "crawlOut") { + return label.replacen("From", "To", 1); + } return label.into(); } @@ -245,14 +248,14 @@ fn animation_option_label(effect: &str, option: &str) -> String { ("shape" | "shapeOut", o) if o.ends_with("Out") => format!("{} Out", title_first(shape_option_name(o))), ("wheel" | "wheelOut", "1") => "1 Spoke".into(), ("wheel" | "wheelOut", n) => format!("{n} Spokes"), - ("box" | "boxOut" | "circle" | "diamond", "in") => format!("{} In", title_first(effect.trim_end_matches("Out"))), - ("box" | "boxOut" | "circle" | "diamond", "out") => format!("{} Out", title_first(effect.trim_end_matches("Out"))), + ("box" | "boxOut" | "circle" | "diamond" | "diamondOut", "in") => format!("{} In", title_first(effect.trim_end_matches("Out"))), + ("box" | "boxOut" | "circle" | "diamond" | "diamondOut", "out") => format!("{} Out", title_first(effect.trim_end_matches("Out"))), ("zoom" | "zoomOut", "in") => "Zoom In".into(), ("zoom" | "zoomOut", "out") => "Zoom Out".into(), - (_, "lu") => "From Top-Left".into(), - (_, "ru") => "From Top-Right".into(), - (_, "ld") => "From Bottom-Left".into(), - (_, "rd") => "From Bottom-Right".into(), + ("strips" | "stripsOut", "lu") => "Left Up".into(), + ("strips" | "stripsOut", "ru") => "Right Up".into(), + ("strips" | "stripsOut", "ld") => "Left Down".into(), + ("strips" | "stripsOut", "rd") => "Right Down".into(), (_, "horz") => "Horizontal".into(), (_, "vert") => "Vertical".into(), (_, "across") => "Across".into(), @@ -301,11 +304,29 @@ mod animation_option_label_tests { } } assert_eq!(animation_option_label("fly", "lb"), "From Bottom-Left"); - assert_eq!(animation_option_label("flyOut", "rt"), "From Top-Right"); + assert_eq!(animation_option_label("flyOut", "rt"), "To Top-Right"); + assert_eq!(animation_option_label("flyOut", "b"), "To Bottom"); + assert_eq!(animation_option_label("peekOut", "l"), "To Left"); assert_eq!(animation_option_label("wipe", "b"), "From Bottom"); assert_eq!(animation_option_label("wheel", "1"), "1 Spoke"); assert_eq!(animation_option_label("split", "vertOut"), "Vertical Out"); assert_eq!(animation_option_label("shape", "circleIn"), "Circle In"); assert_eq!(animation_option_label("diamond", "out"), "Diamond Out"); + assert_eq!(animation_option_label("diamondOut", "in"), "Diamond In"); + assert_eq!(animation_option_label("diamondOut", "out"), "Diamond Out"); + assert_eq!(animation_option_label("strips", "lu"), "Left Up"); + assert_eq!(animation_option_label("strips", "ru"), "Right Up"); + assert_eq!(animation_option_label("strips", "ld"), "Left Down"); + assert_eq!(animation_option_label("strips", "rd"), "Right Down"); + assert_eq!(animation_option_label("crawl", "b"), "From Bottom"); + assert_eq!(animation_option_label("crawl", "l"), "From Left"); + assert_eq!(animation_option_label("crawl", "t"), "From Top"); + assert_eq!(animation_option_label("crawl", "r"), "From Right"); + assert_eq!(animation_option_label("crawlOut", "l"), "To Left"); + assert_eq!(animation_option_label("crawlOut", "r"), "To Right"); + assert_eq!(animation_option_label("plus", "in"), "In"); + assert_eq!(animation_option_label("plus", "out"), "Out"); + assert_eq!(animation_option_label("changeFontSize", "increase"), "Increase"); + assert_eq!(animation_option_label("changeFontSize", "decrease"), "Decrease"); } } From 3cfd4c5e6e14bcbda200cb962bc439878aec8d95 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:47:58 -0600 Subject: [PATCH 19/79] Parse percentage-form bullet sizes --- crates/pptx/src/read/dml.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index 372502018..300773a57 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -27,6 +27,12 @@ fn bullet_size_points(value: Option<&str>) -> Option { value.and_then(|v| v.trim().parse::().ok()).filter(|v| (100..=400_000).contains(v)).map(|v| v as f64 / 100.0) } +fn bullet_size_percent(value: Option<&str>) -> Option { + let value = value?.trim(); + let (value, scale) = if let Some(value) = value.strip_suffix('%') { (value.trim(), 100.0) } else { (value, 100_000.0) }; + value.parse::().ok().filter(|v| v.is_finite()).map(|v| v / scale) +} + /// 1/60 000 degree → degrees. pub fn ang(e: &El, name: &str) -> Option { e.f64(name).map(|v| v / 60_000.0) @@ -461,7 +467,7 @@ pub fn ppr(imp: &mut Imp, part: &Part, p: &El) -> (Option, ParaProps, Option "buSzPct" => { pp.bullet_size_points = None; pp.bullet_size_follow_text = None; - pp.bullet_size = pct(e, "val").map(|v| v.clamp(0.25, 4.0)); + pp.bullet_size = bullet_size_percent(e.attr("val")).map(|v| v.clamp(0.25, 4.0)); } "buSzPts" => { pp.bullet_size = None; @@ -716,6 +722,14 @@ mod tests { assert_eq!(bullet_size_points(Some("1400")), Some(14.0)); } + #[test] + fn bullet_percentage_size_accepts_numeric_and_percent_forms() { + assert_eq!(bullet_size_percent(Some("100000")), Some(1.0)); + assert_eq!(bullet_size_percent(Some("100%")), Some(1.0)); + assert_eq!(bullet_size_percent(Some(" 125% ")), Some(1.25)); + assert_eq!(bullet_size_percent(Some("invalid")), None); + } + #[test] fn miter_limit_accepts_both_percentage_forms_with_schema_bounds() { assert_eq!(parse_miter_limit("200000"), Some(200_000)); From b9eaaeb23b011b7a1e96568a4101f6d0d3cc13b9 Mon Sep 17 00:00:00 2001 From: Zach Berry <1112351+zberry@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:03:02 -0600 Subject: [PATCH 20/79] Test: hidden media controls are written as PowerPoint's p14 extension The round trip now also checks the written presProps.xml: inside p:showPr, the extension uri and p14:showMediaCtrls with its namespace declared on the element; the default writes nothing. Co-Authored-By: Claude Opus 5.5 --- crates/pptx/tests/it/roundtrip.rs | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/crates/pptx/tests/it/roundtrip.rs b/crates/pptx/tests/it/roundtrip.rs index c61e6e433..521956a06 100644 --- a/crates/pptx/tests/it/roundtrip.rs +++ b/crates/pptx/tests/it/roundtrip.rs @@ -1970,11 +1970,30 @@ fn default_durations_match_powerpoint() { } } -/// Show media controls is PowerPoint's `p14:showMediaCtrls` extension inside `p:showPr`. +/// Show media controls is PowerPoint's `p14:showMediaCtrls` extension inside `p:showPr`: off is +/// written there, under its extension uri and with the p14 namespace declared on the element; +/// on (the default) writes nothing. #[test] fn hidden_media_controls_survive_save_and_reopen() { + let props = |p: &Presentation| { + let bytes = deckcraft_pptx::export(p).expect("export"); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip"); + let mut x = String::new(); + std::io::Read::read_to_string(&mut zip.by_name("ppt/presProps.xml").expect("presProps"), &mut x).expect("read"); + x + }; let mut p = Presentation::default(); p.show.show_media_controls = false; + let xml = props(&p); + let show = xml.split("").next()).expect("p:showPr"); + assert!( + show.contains( + "" + ), + "{xml}" + ); assert!(!round(&p).show.show_media_controls); - assert!(round(&Presentation::default()).show.show_media_controls); + let default = Presentation::default(); + assert!(!props(&default).contains("showMediaCtrls")); + assert!(round(&default).show.show_media_controls); } From 58c24883c7a1ae98b0829c4fc9af4b7a326b78c6 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:14:15 +0100 Subject: [PATCH 21/79] Reject png/jpeg targets in file.save instead of writing zip bytes Found while testing crates/engine/src/cmd/file.rs:489 (save, with save_bytes at :341-349 and format_for_path at :440) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/file.rs | 1 + crates/engine/src/tests.rs | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 0d52e6358..53009f51f 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -345,6 +345,7 @@ pub fn save_bytes(doc: &Presentation, format: &str) -> Result> { "rtf" => Ok(deckcraft_format::rtf::slides_to_rtf(doc).into_bytes()), "pdf" => deckcraft_pdf::export(doc, &Default::default()).map_err(|e| EngineError::Other(e.to_string())), "gif" => gif_bytes(doc, &Value::Null), + "png" | "jpeg" => Err(EngineError::Other("saving a presentation as a single image isn't supported; use file.export".into())), _ => deckcraft_format::save(doc).map_err(|e| EngineError::Other(e.to_string())), } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 68de13418..12b13e680 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6458,3 +6458,19 @@ fn master_body_placeholder_format_keeps_per_level_formatting() { assert_eq!((level(&s, 0).run.size, level(&s, 1).run.size), (Some(28.0), Some(24.0))); assert_eq!(shown(&s), vec![Some(28.0), Some(24.0)]); } + +#[test] +fn save_to_image_path_is_an_error_not_zip_bytes() { + let dir = std::env::temp_dir().join(format!("deckcraft-save-img-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + for (cmd, name) in [("file.save", "a.png"), ("file.saveAs", "a.jpg"), ("file.save", "a.jpeg")] { + let mut s = Session::new(); + sample::open_sample(&mut s).unwrap(); + let path = dir.join(name).to_string_lossy().to_string(); + let before = s.doc().unwrap().path.clone(); + assert!(s.execute(cmd, &json!({"path": path})).is_err(), "{cmd} {name} should fail"); + assert!(!std::path::Path::new(&path).exists(), "{name} must not be written"); + assert_eq!(s.doc().unwrap().path, before); + } + let _ = std::fs::remove_dir_all(&dir); +} From b66ba2e10a54cb6e561adffbd41b76662a15c62c Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:42:44 -0600 Subject: [PATCH 22/79] Preserve centered anchoring on table cells --- crates/model/src/table.rs | 1 + crates/pptx/src/read/shapes.rs | 1 + crates/pptx/src/write/shapes.rs | 1 + crates/pptx/tests/it/roundtrip.rs | 18 ++++++++++++++++++ 4 files changed, 21 insertions(+) diff --git a/crates/model/src/table.rs b/crates/model/src/table.rs index 0705d1a08..e5a5c2f33 100644 --- a/crates/model/src/table.rs +++ b/crates/model/src/table.rs @@ -23,6 +23,7 @@ pub struct Cell { pub h_merge: bool, pub v_merge: bool, pub vertical: Option, + pub anchor_ctr: Option, } impl Cell { diff --git a/crates/pptx/src/read/shapes.rs b/crates/pptx/src/read/shapes.rs index 6d8acc4ad..6b19f29b1 100644 --- a/crates/pptx/src/read/shapes.rs +++ b/crates/pptx/src/read/shapes.rs @@ -805,6 +805,7 @@ fn cell(imp: &mut Imp, part: &Part, tc: &El) -> Cell { ]); } c.anchor = pr.attr("anchor").and_then(Anchor::from_xml); + c.anchor_ctr = pr.bool("anchorCtr"); c.vertical = pr.attr("vert").and_then(TextDir::from_xml); for (i, n) in ["lnL", "lnR", "lnT", "lnB"].iter().enumerate() { if let Some(ln) = pr.child(n) diff --git a/crates/pptx/src/write/shapes.rs b/crates/pptx/src/write/shapes.rs index 87200a890..58af6ef75 100644 --- a/crates/pptx/src/write/shapes.rs +++ b/crates/pptx/src/write/shapes.rs @@ -746,6 +746,7 @@ fn table(w: &mut W, x: &mut Exp, o: &mut Out, t: &Table) { other => other.xml(), }), ); + a = a.b("anchorCtr", c.anchor_ctr); w.open("a:tcPr", a); for (tag, l) in [ ("a:lnL", &c.borders[0]), diff --git a/crates/pptx/tests/it/roundtrip.rs b/crates/pptx/tests/it/roundtrip.rs index f651fc5c6..3a4115b96 100644 --- a/crates/pptx/tests/it/roundtrip.rs +++ b/crates/pptx/tests/it/roundtrip.rs @@ -468,6 +468,24 @@ fn text_and_formatting_round_trip() { assert_eq!(run.text, "x=1"); } +#[test] +fn centered_cell_anchor_survives_two_pptx_round_trips() { + let mut p = Presentation::default(); + let mut slide = new_slide(&mut p, "Cell anchor"); + let mut table_shape = shape(&mut p, "Table", 10.0, 10.0, 200.0, 80.0); + let mut table = Table::new(1, 1, 200.0, 80.0); + table.rows[0].cells[0].anchor_ctr = Some(true); + table_shape.kind = ShapeKind::Table(table); + slide.shapes.push(table_shape); + p.slides.push(Arc::new(slide)); + + let once = round(&p); + let twice = round(&once); + let slide = twice.slides.last().expect("added slide"); + let ShapeKind::Table(table) = &by_name(slide, "Table").kind else { panic!("table") }; + assert_eq!(table.rows[0].cells[0].anchor_ctr, Some(true)); +} + #[test] fn shapes_fills_lines_effects_round_trip() { let p = rich_deck(); From 44b133a3c9a2e56b0b8e9a2e5bc8a13966b0f14a Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:45:18 -0600 Subject: [PATCH 23/79] Preserve text overflow settings in PPTX --- crates/model/src/resolve.rs | 2 ++ crates/model/src/table.rs | 1 + crates/model/src/text.rs | 31 +++++++++++++++++++++++++++++++ crates/pptx/src/read/dml.rs | 2 ++ crates/pptx/src/read/shapes.rs | 1 + crates/pptx/src/write/dml.rs | 4 +++- crates/pptx/src/write/shapes.rs | 1 + crates/pptx/tests/it/roundtrip.rs | 27 ++++++++++++++++++++++++++- 8 files changed, 67 insertions(+), 2 deletions(-) diff --git a/crates/model/src/resolve.rs b/crates/model/src/resolve.rs index 8d87229f3..a310bccbf 100644 --- a/crates/model/src/resolve.rs +++ b/crates/model/src/resolve.rs @@ -249,6 +249,8 @@ pub fn body(ctx: &Ctx, shape: &Shape) -> BodyProps { rtl_col: Some(false), upright: Some(false), space_first_last: Some(false), + vert_overflow: None, + horz_overflow: None, }); b } diff --git a/crates/model/src/table.rs b/crates/model/src/table.rs index 0705d1a08..a8031493a 100644 --- a/crates/model/src/table.rs +++ b/crates/model/src/table.rs @@ -23,6 +23,7 @@ pub struct Cell { pub h_merge: bool, pub v_merge: bool, pub vertical: Option, + pub horz_overflow: Option, } impl Cell { diff --git a/crates/model/src/text.rs b/crates/model/src/text.rs index 362f43939..1839ef30a 100644 --- a/crates/model/src/text.rs +++ b/crates/model/src/text.rs @@ -121,6 +121,35 @@ pub struct BodyProps { pub upright: Option, /// Space before the first paragraph and after the last count too (`spcFirstLastPara`). pub space_first_last: Option, + pub vert_overflow: Option, + pub horz_overflow: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TextOverflow { + Overflow, + Clip, + Ellipsis, +} + +impl TextOverflow { + pub fn xml(self) -> &'static str { + match self { + Self::Overflow => "overflow", + Self::Clip => "clip", + Self::Ellipsis => "ellipsis", + } + } + + pub fn from_xml(value: &str) -> Option { + Some(match value { + "overflow" => Self::Overflow, + "clip" => Self::Clip, + "ellipsis" => Self::Ellipsis, + _ => return None, + }) + } } impl BodyProps { @@ -144,6 +173,8 @@ impl BodyProps { self.rtl_col = self.rtl_col.or(p.rtl_col); self.upright = self.upright.or(p.upright); self.space_first_last = self.space_first_last.or(p.space_first_last); + self.vert_overflow = self.vert_overflow.or(p.vert_overflow); + self.horz_overflow = self.horz_overflow.or(p.horz_overflow); } } diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index a2ed6abe7..e24d7c94b 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -358,6 +358,8 @@ pub fn body_pr(b: &El) -> BodyProps { rtl_col: b.bool("rtlCol"), upright: b.bool("upright"), space_first_last: b.bool("spcFirstLastPara"), + vert_overflow: b.attr("vertOverflow").and_then(deckcraft_model::text::TextOverflow::from_xml), + horz_overflow: b.attr("horzOverflow").and_then(deckcraft_model::text::TextOverflow::from_xml), ..Default::default() }; for e in b.elements() { diff --git a/crates/pptx/src/read/shapes.rs b/crates/pptx/src/read/shapes.rs index 6d8acc4ad..f5497b8c3 100644 --- a/crates/pptx/src/read/shapes.rs +++ b/crates/pptx/src/read/shapes.rs @@ -805,6 +805,7 @@ fn cell(imp: &mut Imp, part: &Part, tc: &El) -> Cell { ]); } c.anchor = pr.attr("anchor").and_then(Anchor::from_xml); + c.horz_overflow = pr.attr("horzOverflow").and_then(deckcraft_model::text::TextOverflow::from_xml); c.vertical = pr.attr("vert").and_then(TextDir::from_xml); for (i, n) in ["lnL", "lnR", "lnT", "lnB"].iter().enumerate() { if let Some(ln) = pr.child(n) diff --git a/crates/pptx/src/write/dml.rs b/crates/pptx/src/write/dml.rs index 320810958..e355601bf 100644 --- a/crates/pptx/src/write/dml.rs +++ b/crates/pptx/src/write/dml.rs @@ -468,7 +468,9 @@ pub fn body_pr(w: &mut W, b: &BodyProps) { .o("anchor", b.anchor.map(|a| a.xml())) .b("anchorCtr", b.anchor_ctr) .b("upright", b.upright) - .b("spcFirstLastPara", b.space_first_last); + .b("spcFirstLastPara", b.space_first_last) + .o("vertOverflow", b.vert_overflow.map(|v| v.xml())) + .o("horzOverflow", b.horz_overflow.map(|v| v.xml())); let warp = b.warp.as_deref().filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric())); if warp.is_none() && b.autofit.is_none() { w.empty("a:bodyPr", a); diff --git a/crates/pptx/src/write/shapes.rs b/crates/pptx/src/write/shapes.rs index 87200a890..f18bbf466 100644 --- a/crates/pptx/src/write/shapes.rs +++ b/crates/pptx/src/write/shapes.rs @@ -746,6 +746,7 @@ fn table(w: &mut W, x: &mut Exp, o: &mut Out, t: &Table) { other => other.xml(), }), ); + a = a.o("horzOverflow", c.horz_overflow.map(|v| v.xml())); w.open("a:tcPr", a); for (tag, l) in [ ("a:lnL", &c.borders[0]), diff --git a/crates/pptx/tests/it/roundtrip.rs b/crates/pptx/tests/it/roundtrip.rs index f651fc5c6..3832d2c6c 100644 --- a/crates/pptx/tests/it/roundtrip.rs +++ b/crates/pptx/tests/it/roundtrip.rs @@ -12,7 +12,7 @@ use deckcraft_model::style::{ }; use deckcraft_model::table::Table; use deckcraft_model::text::{ - Action, Align, Anchor, AutoFit, BodyProps, Bullet, Caps, Hyperlink, Paragraph, Run, RunKind, RunProps, Spacing, Strike, TextBody, + Action, Align, Anchor, AutoFit, BodyProps, Bullet, Caps, Hyperlink, Paragraph, Run, RunKind, RunProps, Spacing, Strike, TextBody, TextOverflow, }; use deckcraft_model::{ Background, Comment, CustomPath, CustomShow, Geom, InkStroke, MediaClip, Presentation, Section, Shape, ShapeId, ShapeKind, Slide, SlideId, @@ -468,6 +468,31 @@ fn text_and_formatting_round_trip() { assert_eq!(run.text, "x=1"); } +#[test] +fn text_overflow_policies_survive_two_pptx_round_trips() { + let mut p = Presentation::default(); + let mut slide = new_slide(&mut p, "Text overflow"); + let mut text = shape(&mut p, "Text", 10.0, 10.0, 200.0, 80.0); + let mut body = TextBody::from_text("policy"); + body.body.vert_overflow = Some(TextOverflow::Clip); + body.body.horz_overflow = Some(TextOverflow::Ellipsis); + text.text = Some(body); + let mut table_shape = shape(&mut p, "Table", 10.0, 100.0, 200.0, 80.0); + let mut table = Table::new(1, 1, 200.0, 80.0); + table.rows[0].cells[0].horz_overflow = Some(TextOverflow::Clip); + table_shape.kind = ShapeKind::Table(table); + slide.shapes = vec![text, table_shape]; + p.slides.push(Arc::new(slide)); + + let twice = round(&round(&p)); + let slide = twice.slides.last().expect("added slide"); + let text = by_name(slide, "Text").text.as_ref().expect("text body"); + assert_eq!(text.body.vert_overflow, Some(TextOverflow::Clip)); + assert_eq!(text.body.horz_overflow, Some(TextOverflow::Ellipsis)); + let ShapeKind::Table(table) = &by_name(slide, "Table").kind else { panic!("table") }; + assert_eq!(table.rows[0].cells[0].horz_overflow, Some(TextOverflow::Clip)); +} + #[test] fn shapes_fills_lines_effects_round_trip() { let p = rich_deck(); From 0d06481c341d27c0bea2090cc230582cee3a74ba Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:47:50 -0600 Subject: [PATCH 24/79] Preserve compatible line spacing in PPTX --- crates/model/src/resolve.rs | 1 + crates/model/src/text.rs | 2 ++ crates/pptx/src/read/dml.rs | 1 + crates/pptx/src/write/dml.rs | 3 ++- crates/pptx/tests/it/roundtrip.rs | 21 +++++++++++++++++++++ 5 files changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/model/src/resolve.rs b/crates/model/src/resolve.rs index 8d87229f3..51f50b82d 100644 --- a/crates/model/src/resolve.rs +++ b/crates/model/src/resolve.rs @@ -249,6 +249,7 @@ pub fn body(ctx: &Ctx, shape: &Shape) -> BodyProps { rtl_col: Some(false), upright: Some(false), space_first_last: Some(false), + compat_ln_spc: None, }); b } diff --git a/crates/model/src/text.rs b/crates/model/src/text.rs index 362f43939..0a7598f6e 100644 --- a/crates/model/src/text.rs +++ b/crates/model/src/text.rs @@ -121,6 +121,7 @@ pub struct BodyProps { pub upright: Option, /// Space before the first paragraph and after the last count too (`spcFirstLastPara`). pub space_first_last: Option, + pub compat_ln_spc: Option, } impl BodyProps { @@ -144,6 +145,7 @@ impl BodyProps { self.rtl_col = self.rtl_col.or(p.rtl_col); self.upright = self.upright.or(p.upright); self.space_first_last = self.space_first_last.or(p.space_first_last); + self.compat_ln_spc = self.compat_ln_spc.or(p.compat_ln_spc); } } diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index a2ed6abe7..8ef3a68ba 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -358,6 +358,7 @@ pub fn body_pr(b: &El) -> BodyProps { rtl_col: b.bool("rtlCol"), upright: b.bool("upright"), space_first_last: b.bool("spcFirstLastPara"), + compat_ln_spc: b.bool("compatLnSpc"), ..Default::default() }; for e in b.elements() { diff --git a/crates/pptx/src/write/dml.rs b/crates/pptx/src/write/dml.rs index 320810958..4de05d46f 100644 --- a/crates/pptx/src/write/dml.rs +++ b/crates/pptx/src/write/dml.rs @@ -468,7 +468,8 @@ pub fn body_pr(w: &mut W, b: &BodyProps) { .o("anchor", b.anchor.map(|a| a.xml())) .b("anchorCtr", b.anchor_ctr) .b("upright", b.upright) - .b("spcFirstLastPara", b.space_first_last); + .b("spcFirstLastPara", b.space_first_last) + .b("compatLnSpc", b.compat_ln_spc); let warp = b.warp.as_deref().filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric())); if warp.is_none() && b.autofit.is_none() { w.empty("a:bodyPr", a); diff --git a/crates/pptx/tests/it/roundtrip.rs b/crates/pptx/tests/it/roundtrip.rs index f651fc5c6..3c741f303 100644 --- a/crates/pptx/tests/it/roundtrip.rs +++ b/crates/pptx/tests/it/roundtrip.rs @@ -468,6 +468,27 @@ fn text_and_formatting_round_trip() { assert_eq!(run.text, "x=1"); } +#[test] +fn compatible_line_spacing_mode_survives_two_pptx_round_trips() { + let mut p = Presentation::default(); + let mut slide = new_slide(&mut p, "Compatible line spacing"); + let mut enabled = shape(&mut p, "Enabled", 10.0, 10.0, 200.0, 80.0); + let mut enabled_body = TextBody::from_text("enabled"); + enabled_body.body.compat_ln_spc = Some(true); + enabled.text = Some(enabled_body); + let mut disabled = shape(&mut p, "Disabled", 10.0, 100.0, 200.0, 80.0); + let mut disabled_body = TextBody::from_text("disabled"); + disabled_body.body.compat_ln_spc = Some(false); + disabled.text = Some(disabled_body); + slide.shapes = vec![enabled, disabled]; + p.slides.push(Arc::new(slide)); + + let twice = round(&round(&p)); + let slide = twice.slides.last().expect("added slide"); + assert_eq!(by_name(slide, "Enabled").text.as_ref().expect("text").body.compat_ln_spc, Some(true)); + assert_eq!(by_name(slide, "Disabled").text.as_ref().expect("text").body.compat_ln_spc, Some(false)); +} + #[test] fn shapes_fills_lines_effects_round_trip() { let p = rich_deck(); From d00ef08faeb9a84bf733e55a2569bc171ae92dc1 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:50:20 -0600 Subject: [PATCH 25/79] Preserve normalized character height in PPTX --- crates/model/src/text.rs | 4 +++- crates/pptx/src/read/dml.rs | 1 + crates/pptx/src/write/dml.rs | 3 ++- crates/pptx/tests/it/roundtrip.rs | 33 +++++++++++++++++++++++++++++++ 4 files changed, 39 insertions(+), 2 deletions(-) diff --git a/crates/model/src/text.rs b/crates/model/src/text.rs index 362f43939..c83d8020f 100644 --- a/crates/model/src/text.rs +++ b/crates/model/src/text.rs @@ -434,6 +434,7 @@ pub struct RunProps { pub soft_edge: Option, /// Spelling/grammar errors are not flagged. pub no_proof: Option, + pub normalize_height: Option, } impl RunProps { @@ -467,7 +468,8 @@ impl RunProps { glow, reflection, soft_edge, - no_proof + no_proof, + normalize_height ); } } diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index a2ed6abe7..3b03e3bb3 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -548,6 +548,7 @@ pub fn rpr(imp: &mut Imp, part: &Part, r: &El) -> RunProps { spacing: r.f64("spc").map(|s| (s / 100.0).clamp(-4000.0, 4000.0)), kern: r.f64("kern").map(|k| (k / 100.0).clamp(0.0, 4000.0)), no_proof: r.bool("noProof"), + normalize_height: r.bool("normalizeH"), ..Default::default() }; for e in r.elements() { diff --git a/crates/pptx/src/write/dml.rs b/crates/pptx/src/write/dml.rs index 320810958..23579350e 100644 --- a/crates/pptx/src/write/dml.rs +++ b/crates/pptx/src/write/dml.rs @@ -649,7 +649,8 @@ pub fn rpr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, r: &RunProps) { ) .o("spc", r.spacing.map(|s| (s.clamp(-4000.0, 4000.0) * 100.0).round() as i64)) .o("baseline", r.baseline.map(|b| pct(b.clamp(-10.0, 10.0)))) - .b("noProof", r.no_proof); + .b("noProof", r.no_proof) + .b("normalizeH", r.normalize_height); let has_fx = r.shadow.is_some() || r.inner_shadow.is_some() || r.glow.is_some() || r.reflection.is_some() || r.soft_edge.is_some(); let has_kids = r.outline.is_some() || r.fill.is_some() diff --git a/crates/pptx/tests/it/roundtrip.rs b/crates/pptx/tests/it/roundtrip.rs index f651fc5c6..65cc5aa7d 100644 --- a/crates/pptx/tests/it/roundtrip.rs +++ b/crates/pptx/tests/it/roundtrip.rs @@ -468,6 +468,39 @@ fn text_and_formatting_round_trip() { assert_eq!(run.text, "x=1"); } +#[test] +fn normalized_character_height_survives_two_pptx_round_trips() { + let mut p = Presentation::default(); + let mut slide = new_slide(&mut p, "Normalized height"); + let mut enabled = shape(&mut p, "Enabled", 10.0, 10.0, 200.0, 80.0); + let mut enabled_body = TextBody::from_text("enabled"); + enabled_body.paragraphs[0].runs[0].props.normalize_height = Some(true); + enabled_body.paragraphs[0].runs[0].props.size = Some(18.0); + enabled_body.paragraphs[0].runs[0].props.caps = Some(Caps::Small); + enabled_body.paragraphs[0].runs[0].props.spacing = Some(1.5); + enabled.text = Some(enabled_body); + let mut disabled = shape(&mut p, "Disabled", 10.0, 100.0, 200.0, 80.0); + let mut disabled_body = TextBody::from_text("disabled"); + disabled_body.paragraphs[0].runs[0].props.normalize_height = Some(false); + disabled.text = Some(disabled_body); + let mut absent = shape(&mut p, "Absent", 10.0, 190.0, 200.0, 80.0); + absent.text = Some(TextBody::from_text("absent")); + slide.shapes = vec![enabled, disabled, absent]; + p.slides.push(Arc::new(slide)); + + let twice = round(&round(&p)); + let slide = twice.slides.last().expect("added slide"); + let run = &by_name(slide, "Enabled").text.as_ref().expect("text").paragraphs[0].runs[0]; + assert_eq!(run.props.normalize_height, Some(true)); + assert_eq!(run.props.size, Some(18.0)); + assert_eq!(run.props.caps, Some(Caps::Small)); + assert_eq!(run.props.spacing, Some(1.5)); + let run = &by_name(slide, "Disabled").text.as_ref().expect("text").paragraphs[0].runs[0]; + assert_eq!(run.props.normalize_height, Some(false)); + let run = &by_name(slide, "Absent").text.as_ref().expect("text").paragraphs[0].runs[0]; + assert_eq!(run.props.normalize_height, None); +} + #[test] fn shapes_fills_lines_effects_round_trip() { let p = rich_deck(); From bb417bf98a931048b05c948eca8bd4b959926b57 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:53:40 -0600 Subject: [PATCH 26/79] Preserve Thai distributed paragraph alignment --- crates/engine/src/cmd/format/paragraph.rs | 5 ++++- crates/model/src/text.rs | 5 ++++- crates/pptx/tests/it/roundtrip.rs | 21 +++++++++++++++++++++ crates/text/src/layout.rs | 6 +++--- 4 files changed, 32 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/format/paragraph.rs b/crates/engine/src/cmd/format/paragraph.rs index f036068f6..331fe15be 100644 --- a/crates/engine/src/cmd/format/paragraph.rs +++ b/crates/engine/src/cmd/format/paragraph.rs @@ -14,6 +14,7 @@ pub(super) fn align(s: &mut Session, p: &Value) -> Result { "justify" | "just" => Align::Justify, "justifyLow" | "justLow" => Align::JustLow, "distributed" | "dist" => Align::Distributed, + "thaiDist" => Align::ThaiDistributed, _ => Align::Left, }; align_to(s, a) @@ -184,7 +185,8 @@ fn parse_align(cmd: &str, s: &str) -> Result { "justify" | "just" => Align::Justify, "justLow" => Align::JustLow, "distributed" | "dist" => Align::Distributed, - _ => return Err(bad(cmd, "`align` must be left, center, right, justify, justLow or distributed")), + "thaiDist" => Align::ThaiDistributed, + _ => return Err(bad(cmd, "`align` must be left, center, right, justify, justLow, distributed or thaiDist")), }) } @@ -196,6 +198,7 @@ fn align_name(a: Align) -> &'static str { Align::Justify => "justify", Align::JustLow => "justLow", Align::Distributed => "distributed", + Align::ThaiDistributed => "thaiDist", } } diff --git a/crates/model/src/text.rs b/crates/model/src/text.rs index 362f43939..fc12de151 100644 --- a/crates/model/src/text.rs +++ b/crates/model/src/text.rs @@ -157,6 +157,7 @@ pub enum Align { /// Justified with kashida (tatweel) elongation for Arabic (`justLow`). JustLow, Distributed, + ThaiDistributed, } impl Align { @@ -168,6 +169,7 @@ impl Align { Align::Justify => "just", Align::JustLow => "justLow", Align::Distributed => "dist", + Align::ThaiDistributed => "thaiDist", } } pub fn from_xml(s: &str) -> Option { @@ -175,7 +177,8 @@ impl Align { "l" => Align::Left, "ctr" => Align::Center, "r" => Align::Right, - "just" | "thaiDist" => Align::Justify, + "just" => Align::Justify, + "thaiDist" => Align::ThaiDistributed, "justLow" => Align::JustLow, "dist" => Align::Distributed, _ => return None, diff --git a/crates/pptx/tests/it/roundtrip.rs b/crates/pptx/tests/it/roundtrip.rs index f651fc5c6..fa1a23a71 100644 --- a/crates/pptx/tests/it/roundtrip.rs +++ b/crates/pptx/tests/it/roundtrip.rs @@ -468,6 +468,27 @@ fn text_and_formatting_round_trip() { assert_eq!(run.text, "x=1"); } +#[test] +fn thai_distributed_alignment_survives_two_pptx_round_trips() { + assert_eq!(Align::from_xml("thaiDist"), Some(Align::ThaiDistributed)); + assert_eq!(Align::ThaiDistributed.xml(), "thaiDist"); + assert_eq!(Align::from_xml("just"), Some(Align::Justify)); + + let mut p = Presentation::default(); + let mut slide = new_slide(&mut p, "Thai distributed alignment"); + let mut text = shape(&mut p, "Text", 10.0, 10.0, 200.0, 80.0); + let mut body = TextBody::from_text("one two three"); + body.paragraphs[0].props.align = Some(Align::ThaiDistributed); + text.text = Some(body); + slide.shapes.push(text); + p.slides.push(Arc::new(slide)); + + let twice = round(&round(&p)); + let slide = twice.slides.last().expect("added slide"); + let text = by_name(slide, "Text").text.as_ref().expect("text body"); + assert_eq!(text.paragraphs[0].props.align, Some(Align::ThaiDistributed)); +} + #[test] fn shapes_fills_lines_effects_round_trip() { let p = rich_deck(); diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index c0422815e..c52b840a2 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -418,7 +418,7 @@ fn justify_line(p: &ParaShaped, rl: &RawLine, adv: &mut [f64], align: Align, sla filled = true; } } - if stretch && !filled && matches!(align, Align::Justify | Align::JustLow) { + if stretch && !filled && matches!(align, Align::Justify | Align::JustLow | Align::ThaiDistributed) { let spaces = line_cells.iter().filter(|c| c.ch == ' ').count(); if spaces > 0 { for (a, c) in adv.iter_mut().zip(line_cells) { @@ -450,8 +450,8 @@ fn line_left(align: Align, filled: bool, rtl: bool, bx: (f64, f64), line_w: f64, Align::Center => box_l + (box_r - box_l - line_w) / 2.0, Align::Distributed if n <= 1 => box_l + slack / 2.0, // Justified lines that aren't stretched (the last one) sit at the start edge. - Align::Justify | Align::JustLow | Align::Distributed if rtl => box_r - line_w, - Align::Justify | Align::JustLow | Align::Distributed => box_l, + Align::Justify | Align::JustLow | Align::Distributed | Align::ThaiDistributed if rtl => box_r - line_w, + Align::Justify | Align::JustLow | Align::Distributed | Align::ThaiDistributed => box_l, } } From 0f5364ee9f655021ab23a5b4503d8ca4f5d65071 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:05:35 -0600 Subject: [PATCH 27/79] picture: use displayed group size for compression --- ROADMAP.md | 1 + crates/engine/src/cmd/compress.rs | 140 ++++++++++++++++++++++++++++-- docs/parity-checklist.md | 3 +- 3 files changed, 134 insertions(+), 10 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2c8c6571e..e1d1b7727 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -149,6 +149,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-11-02 | Picture compression measures displayed dimensions through nested group transforms; checklist score unchanged | | 2026-11-02 | File › About replaces the unused Account page, with version, project links, contributor/model credits and appearance preferences | | 2026-10-31 | No Fill shape interiors allow selection and editing of underlying objects, even when a quick style remains; hit-testing uses the renderer’s fill resolution. Outlines, rotation, text targets and actual fills keep their hit behaviour (#1621) | | 2026-10-30 | Print-preview tests use the URL emitted by egui and its exact file path; previews are explicitly removed before the macOS process exits | diff --git a/crates/engine/src/cmd/compress.rs b/crates/engine/src/cmd/compress.rs index 5708efbb3..1f795967f 100644 --- a/crates/engine/src/cmd/compress.rs +++ b/crates/engine/src/cmd/compress.rs @@ -116,16 +116,31 @@ fn pack(data: &[u8], crop: [f64; 4], w_pt: f64, h_pt: f64, ppi: f64, delete_crop Some(Packed { bytes, content_type, crop: new_crop }) } -/// Picture shapes in `shapes` (groups included) that `want` accepts. -fn pictures_mut<'a>(shapes: &'a mut [Shape], want: &dyn Fn(ShapeId) -> bool, out: &mut Vec<&'a mut Shape>, depth: usize) { +/// Pictures accepted by `want`, with width and height measured in slide space through group transforms. +fn pictures_mut<'a>( + shapes: &'a mut [Shape], + want: &dyn Fn(ShapeId) -> bool, + out: &mut Vec<(&'a mut Shape, f64, f64)>, + to_slide: deckcraft_geom::Affine, + depth: usize, +) { if depth > 64 { return; } for sh in shapes { - if matches!(sh.kind, ShapeKind::Picture { .. }) && want(sh.id) { - out.push(sh); - } else if let Some(ch) = sh.children_mut() { - pictures_mut(ch, want, out, depth + 1); + let Some(xfrm) = sh.xfrm else { continue }; + let local_to_slide = to_slide * xfrm.affine(); + if matches!(&sh.kind, ShapeKind::Picture { .. }) && want(sh.id) { + let origin = local_to_slide * deckcraft_geom::Point::ZERO; + let across = local_to_slide * deckcraft_geom::Point::new(xfrm.w, 0.0); + let down = local_to_slide * deckcraft_geom::Point::new(0.0, xfrm.h); + out.push((sh, (across - origin).hypot(), (down - origin).hypot())); + continue; + } + if let ShapeKind::Group { children, child } = &mut sh.kind { + let child_rect = deckcraft_geom::Rect::new(child.x, child.y, child.x + child.w, child.y + child.h); + let child_to_slide = to_slide * deckcraft_geom::group_child_affine(&xfrm, child_rect); + pictures_mut(children, want, out, child_to_slide, depth + 1); } } } @@ -155,10 +170,9 @@ fn compress(s: &mut Session, p: &Value) -> Result { let Some(slide) = doc.slides.get(i).cloned() else { continue }; let mut slide = slide.as_ref().clone(); let mut pics = vec![]; - pictures_mut(&mut slide.shapes, &want, &mut pics, 0); + pictures_mut(&mut slide.shapes, &want, &mut pics, deckcraft_geom::Affine::IDENTITY, 0); let mut touched = false; - for sh in pics { - let (w, h) = sh.xfrm.map(|x| (x.w, x.h)).unwrap_or((0.0, 0.0)); + for (sh, w, h) in pics { let ShapeKind::Picture { fill } = &mut sh.kind else { continue }; let PictureFill { media, crop, .. } = fill.clone(); let key = (media.0, crop.map(f64::to_bits), w.to_bits(), h.to_bits()); @@ -232,6 +246,114 @@ mod tests { assert!(pack(b"", [0.0; 4], 72.0, 72.0, 150.0, true).is_none()); } + #[test] + fn grouped_pictures_use_their_displayed_size() { + let mut s = Session::with_new(); + let original = png(600, 600); + s.edit(|doc, _| { + let media = doc.add_media("grouped.png", "image/png", original.clone()).ok_or_else(|| bad("test", "media"))?; + let picture = Shape { + id: ShapeId(70_101), + xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0)), + kind: ShapeKind::Picture { fill: PictureFill { media, ..Default::default() } }, + ..Default::default() + }; + let nested = Shape { + id: ShapeId(70_102), + xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0)), + kind: ShapeKind::Group { children: vec![picture], child: deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0) }, + ..Default::default() + }; + let group = Shape { + id: ShapeId(70_103), + xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 720.0, 720.0)), + kind: ShapeKind::Group { children: vec![nested], child: deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0) }, + ..Default::default() + }; + let slide = doc.slides.first_mut().ok_or_else(|| bad("test", "slide"))?; + Arc::make_mut(slide).shapes.push(group); + Ok(()) + }) + .unwrap(); + + let result = s.execute("picture.compress", &json!({"all": true, "ppi": 96, "deleteCropped": false})).unwrap(); + assert_eq!(result["pictures"], 0, "a 600 px image displayed at 720×720 pt is already below the requested sampling density"); + assert!( + s.doc().unwrap().doc.media.iter().any(|m| m.data.as_slice() == original.as_slice()), + "the under-target original media must remain untouched" + ); + } + + #[test] + fn anisotropic_nested_group_scaling_controls_sampling_dimensions() { + let mut s = Session::with_new(); + let original = png(2000, 1000); + s.edit(|doc, _| { + let media = doc.add_media("scaled-group.png", "image/png", original).ok_or_else(|| bad("test", "media"))?; + let picture = Shape { + id: ShapeId(70_111), + xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0)), + kind: ShapeKind::Picture { fill: PictureFill { media, ..Default::default() } }, + ..Default::default() + }; + let nested = Shape { + id: ShapeId(70_112), + xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0)), + kind: ShapeKind::Group { children: vec![picture], child: deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0) }, + ..Default::default() + }; + let group = Shape { + id: ShapeId(70_113), + xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 720.0, 360.0)), + kind: ShapeKind::Group { children: vec![nested], child: deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0) }, + ..Default::default() + }; + let slide = doc.slides.first_mut().ok_or_else(|| bad("test", "slide"))?; + Arc::make_mut(slide).shapes.push(group); + Ok(()) + }) + .unwrap(); + + let result = s.execute("picture.compress", &json!({"all": true, "ppi": 96, "deleteCropped": false})).unwrap(); + assert_eq!(result["pictures"], 1); + let slide = s.doc().unwrap().doc.slides.first().unwrap(); + let picture = slide.shapes.iter().find(|sh| sh.id == ShapeId(70_113)).unwrap(); + let ShapeKind::Group { children, .. } = &picture.kind else { panic!("outer group expected") }; + let ShapeKind::Group { children, .. } = &children[0].kind else { panic!("nested group expected") }; + let ShapeKind::Picture { fill } = &children[0].kind else { panic!("picture expected") }; + let media = s.doc().unwrap().doc.media(fill.media).unwrap(); + let image = image::load_from_memory(&media.data).unwrap(); + assert_eq!((image.width(), image.height()), (960, 480)); + } + + #[test] + fn ungrouped_picture_still_compresses_and_deletes_its_crop() { + let mut s = Session::with_new(); + let original = png(800, 800); + s.edit(|doc, _| { + let media = doc.add_media("cropped.png", "image/png", original).ok_or_else(|| bad("test", "media"))?; + let slide = doc.slides.first_mut().ok_or_else(|| bad("test", "slide"))?; + Arc::make_mut(slide).shapes.push(Shape { + id: ShapeId(70_104), + xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0)), + kind: ShapeKind::Picture { fill: PictureFill { media, crop: [0.5, 0.0, 0.0, 0.0], ..Default::default() } }, + ..Default::default() + }); + Ok(()) + }) + .unwrap(); + + let result = s.execute("picture.compress", &json!({"all": true, "ppi": 96, "deleteCropped": true})).unwrap(); + assert_eq!(result["pictures"], 1); + let slide = s.doc().unwrap().doc.slides.first().unwrap(); + let picture = slide.shapes.iter().find(|sh| sh.id == ShapeId(70_104)).unwrap(); + let ShapeKind::Picture { fill } = &picture.kind else { panic!("picture expected") }; + assert_eq!(fill.crop, [0.0; 4]); + let media = s.doc().unwrap().doc.media(fill.media).unwrap(); + let image = image::load_from_memory(&media.data).unwrap(); + assert_eq!((image.width(), image.height()), (96, 192)); + } + #[test] fn pack_deletes_cropped_areas_or_keeps_them() { let src = png(400, 400); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 606f1228d..46db13009 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -2,7 +2,7 @@ > **Generated:** 2026-10-14 (bullets/numbering remain D after empty-line marker review; summary by `cargo xtask parity`; rows re-scored by the 2026-10-14 community PR wave, duplicate Thesaurus, AutoCorrect, Artistic effects and Corrections rows folded; Preview in editor re-scored 2026-10-13; earlier rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4; video/animated GIF row re-scored 2026-10-11, #171) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last updated:** 2026-11-01 · Locked-shape and no-outline regressions added for #1621; fill row re-assessed: remains D; No Fill hit-testing now follows resolved appearance. Scores unchanged. +> **Last updated:** 2026-11-02 · Picture compression now measures displayed dimensions through nested group transforms; Compress pictures remains D. Scores unchanged. The summary between ` **Weighted breadth parity: 84%** over 191 features. @@ -240,4 +240,5 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Date | Change | Summary | |---|---|---| +| 2026-11-02 | trivial | Picture compression uses displayed dimensions through nested group transforms; Compress pictures remains D and summary scores are unchanged | | 2026-10-31 | trivial | No Fill interior hit-testing follows resolved fill (#1621); fill row remains D and summary scores are unchanged | From e4a0c2971b5aeac761cc74e0c41ec3da5710ac1b Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:07:16 -0600 Subject: [PATCH 28/79] Keep Thai distributed text rendering outside this change --- crates/text/src/layout.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index c52b840a2..87b89194e 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -418,7 +418,7 @@ fn justify_line(p: &ParaShaped, rl: &RawLine, adv: &mut [f64], align: Align, sla filled = true; } } - if stretch && !filled && matches!(align, Align::Justify | Align::JustLow | Align::ThaiDistributed) { + if stretch && !filled && matches!(align, Align::Justify | Align::JustLow) { let spaces = line_cells.iter().filter(|c| c.ch == ' ').count(); if spaces > 0 { for (a, c) in adv.iter_mut().zip(line_cells) { From ba3b57c756d57f21874e9a3ed075cbd3ce195b52 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 2 Nov 2026 23:07:24 -0500 Subject: [PATCH 29/79] file.export: a perPage that isn't a count is refused, not read as 6 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/file.rs | 2 +- crates/engine/src/tests.rs | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index dc9f1a44e..7857535c0 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -360,7 +360,7 @@ pub fn pdf_options(p: &Value, count: usize) -> Result o.layout = match str_param(p, "layout").unwrap_or("slides") { "notes" => deckcraft_pdf::PageLayout::Notes, "handouts" => { - let per_page = usize_param(p, "perPage").unwrap_or(6); + let per_page = opt_usize_param(p, "perPage", "file.export")?.unwrap_or(6); if ![1, 2, 3, 4, 6, 9].contains(&per_page) { return Err(bad("file.export", format!("perPage {per_page} isn't one of 1, 2, 3, 4, 6, 9"))); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e8038c9a2..1c8e7a8ac 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2421,6 +2421,10 @@ fn pdf_options_reject_unsupported_per_page() { let e = cmd::file::pdf_options(&json!({"layout": "handouts", "perPage": n}), 1).unwrap_err(); assert!(e.to_string().contains("1, 2, 3, 4, 6, 9"), "perPage {n}: {e}"); } + // Not a count at all: refused too, instead of falling back to 6. + for v in [json!(-1), json!(5.5), json!("6")] { + assert!(cmd::file::pdf_options(&json!({"layout": "handouts", "perPage": v}), 1).is_err(), "perPage {v}"); + } } #[test] From fb3b857f53d3d8e371d0b06cc073d8751f7b6091 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 2 Nov 2026 23:07:29 -0500 Subject: [PATCH 30/79] file.save: an explicit jpg format is refused too Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/file.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 53009f51f..06ed36e3c 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -345,7 +345,7 @@ pub fn save_bytes(doc: &Presentation, format: &str) -> Result> { "rtf" => Ok(deckcraft_format::rtf::slides_to_rtf(doc).into_bytes()), "pdf" => deckcraft_pdf::export(doc, &Default::default()).map_err(|e| EngineError::Other(e.to_string())), "gif" => gif_bytes(doc, &Value::Null), - "png" | "jpeg" => Err(EngineError::Other("saving a presentation as a single image isn't supported; use file.export".into())), + "png" | "jpeg" | "jpg" => Err(EngineError::Other("saving a presentation as a single image isn't supported; use file.export".into())), _ => deckcraft_format::save(doc).map_err(|e| EngineError::Other(e.to_string())), } } From 97050a353b91a63c2800bcb186d90398b6cecf58 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 2 Nov 2026 23:07:34 -0500 Subject: [PATCH 31/79] Bullets: an own bullet size still clears an inherited follow-text flag (as #1589 did) Co-Authored-By: Claude Opus 5.5 --- crates/model/src/text.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/model/src/text.rs b/crates/model/src/text.rs index 25c29074f..ca992c38a 100644 --- a/crates/model/src/text.rs +++ b/crates/model/src/text.rs @@ -288,7 +288,7 @@ impl ParaProps { self.bullet_color.clone_from(&p.bullet_color); } } - if self.bullet_size_follow_text == Some(false) { + if self.bullet_size_follow_text == Some(false) || self.bullet_size.is_some() || self.bullet_size_points.is_some() { self.bullet_size_follow_text = None; } if self.bullet_size.is_none() && self.bullet_size_points.is_none() && self.bullet_size_follow_text.is_none() { From 6cdbee50a3518d33a8f0855830cedbfb4a9931bc Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:20:52 -0600 Subject: [PATCH 32/79] Reuse custom table styles with slides --- ROADMAP.md | 4 +- crates/engine/src/cmd/reuse.rs | 196 ++++++++++++++++++++++++++++++++- docs/file-format-parity.md | 5 +- docs/parity-checklist.md | 5 +- 4 files changed, 204 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2c8c6571e..76e5c4284 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-02 · **Change:** trivial (File › About replaces Account; product links, credits and appearance retained; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-03 · **Change:** trivial (Reuse Slides brings along referenced custom table styles, remapping conflicting style ids; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-01 · **Change:** trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (progress log: read-only `slide.actions` lists shape and table text actions; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-27 · **Change:** trivial (progress log: Set Up Slide Show ▸ Keep mouse cursor visible, #1445; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -150,6 +150,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| | 2026-11-02 | File › About replaces the unused Account page, with version, project links, contributor/model credits and appearance preferences | +| 2026-11-03 | Reuse Slides brings along referenced custom table styles, including grouped tables, and remaps conflicting style ids without changing destination tables | | 2026-10-31 | No Fill shape interiors allow selection and editing of underlying objects, even when a quick style remains; hit-testing uses the renderer’s fill resolution. Outlines, rotation, text targets and actual fills keep their hit behaviour (#1621) | | 2026-10-30 | Print-preview tests use the URL emitted by egui and its exact file path; previews are explicitly removed before the macOS process exits | | 2026-10-30 | `slide.actions` lists shape click and hover actions and text-run hyperlinks (including table cells and grouped shapes) for audit; listing never runs an action. The UI pane is a follow-up | @@ -321,6 +322,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-11-03 | trivial | Reuse Slides brings referenced custom table styles along; scores unchanged | | 2026-11-02 | trivial | File › About replaces Account; product links, credits and appearance retained | | 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged | | 2026-10-30 | trivial | Progress log: read-only slide action inspection; parity scores unchanged | diff --git a/crates/engine/src/cmd/reuse.rs b/crates/engine/src/cmd/reuse.rs index 91cf7c930..8ab5732fe 100644 --- a/crates/engine/src/cmd/reuse.rs +++ b/crates/engine/src/cmd/reuse.rs @@ -6,9 +6,10 @@ //! renumbered with animations, triggers and connector ends following them. Links to another //! reused slide follow its copy. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; +use deckcraft_model::table::TableStyleDef; use deckcraft_model::text::{Action, Hyperlink, RunProps, TextBody}; use deckcraft_model::{LayoutId, Master, MasterId, MediaId, Presentation, ShapeId, ShapeKind, Slide, SlideId}; use serde_json::{Value, json}; @@ -152,6 +153,88 @@ fn matching_layout(doc: &Presentation, name: &str, kind: deckcraft_model::Layout .unwrap_or_default() } +fn collect_table_styles(shapes: &[deckcraft_model::Shape], styles: &mut Vec, seen: &mut HashSet, depth: usize) { + if depth > 64 { + return; + } + for shape in shapes { + if let ShapeKind::Table(table) = &shape.kind + && seen.insert(table.style.to_ascii_lowercase()) + { + styles.push(table.style.clone()); + } + collect_table_styles(shape.children(), styles, seen, depth + 1); + } +} + +fn remap_table_styles(shapes: &mut [deckcraft_model::Shape], styles: &HashMap, depth: usize) { + if depth > 64 { + return; + } + for shape in shapes { + if let ShapeKind::Table(table) = &mut shape.kind + && let Some(style) = styles.get(&table.style.to_ascii_lowercase()) + { + table.style = style.clone(); + } + if let Some(children) = shape.children_mut() { + remap_table_styles(children, styles, depth + 1); + } + } +} + +fn same_table_style(a: &TableStyleDef, b: &TableStyleDef) -> bool { + a.name == b.name && a.parts == b.parts && a.background == b.background && a.raw == b.raw +} + +/// Copy only definitions used on selected slides. Conflicting GUIDs receive a fresh GUID so +/// existing destination tables continue to resolve to their original definitions. +fn import_table_styles(cmd: &str, doc: &mut Presentation, src: &Presentation, used: &[String]) -> Result> { + let mut map = HashMap::new(); + let mut source_defs = HashMap::new(); + let mut source_ids = HashSet::new(); + for (index, style) in src.table_styles.iter().enumerate() { + let key = style.id.to_ascii_lowercase(); + source_defs.entry(key.clone()).or_insert(index); + source_ids.insert(key); + } + let mut destination_defs = HashMap::new(); + for (index, style) in doc.table_styles.iter().enumerate() { + destination_defs.entry(style.id.to_ascii_lowercase()).or_insert(index); + } + for id in used { + let key = id.to_ascii_lowercase(); + if map.contains_key(&key) { + continue; + } + let Some(source_index) = source_defs.get(&key).copied() else { continue }; + let Some(source) = src.table_styles.get(source_index) else { continue }; + let destination = destination_defs.get(&key).and_then(|index| doc.table_styles.get(*index)); + let mut imported = source.clone(); + if let Some(existing) = destination { + if same_table_style(existing, source) { + map.insert(key, existing.id.clone()); + continue; + } + let candidate = loop { + let n = doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?; + let candidate = format!("{{00000000-0000-4000-8000-{n:012X}}}"); + let key = candidate.to_ascii_lowercase(); + if !destination_defs.contains_key(&key) && !source_ids.contains(&key) { + break candidate; + } + }; + imported.id.clone_from(&candidate); + map.insert(key, candidate); + } else { + map.insert(key, imported.id.clone()); + } + destination_defs.insert(imported.id.to_ascii_lowercase(), doc.table_styles.len()); + doc.table_styles.push(imported); + } + Ok(map) +} + fn reuse(s: &mut Session, p: &Value) -> Result { let (name, bytes) = super::insert::media_bytes(p, "slide.reuse")?; let src = super::file::open_presentation(&name, &bytes)?; @@ -178,6 +261,14 @@ fn reuse(s: &mut Session, p: &Value) -> Result { let at = usize_param(p, "at"); s.edit(|doc, sel| { let at = at.unwrap_or(sel.slide + 1).min(doc.slides.len()); + let mut used_table_styles = Vec::new(); + let mut seen_table_styles = HashSet::new(); + for &i in &picked { + if let Some(slide) = src.slides.get(i) { + collect_table_styles(&slide.shapes, &mut used_table_styles, &mut seen_table_styles, 0); + } + } + let table_style_map = import_table_styles("slide.reuse", doc, &src, &used_table_styles)?; // Media: what the slides use is copied under fresh ids. let mut media_map: HashMap = HashMap::new(); // Keep Source Formatting: bring each source master used, once, with fresh ids. @@ -201,6 +292,7 @@ fn reuse(s: &mut Session, p: &Value) -> Result { let Some(from) = src.slides.get(i) else { continue }; copy_media("slide.reuse", doc, &src, &media_refs::slide_media(from), &mut media_map)?; let mut sl: Slide = from.as_ref().clone(); + remap_table_styles(&mut sl.shapes, &table_style_map, 0); media_refs::visit_mut::slide(&mut sl, &mut remap(&media_map)); sl.id = SlideId(doc.alloc_id().ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?); let map = renumber("slide.reuse", doc, &mut sl.shapes)?; @@ -285,12 +377,114 @@ fn copy_media(cmd: &str, doc: &mut Presentation, src: &Presentation, used: &[Med #[cfg(test)] mod tests { use deckcraft_model::style::{Artistic, Fill, PictureFill}; + use deckcraft_model::table::TableStyleDef; use deckcraft_model::text::{Action, Bullet, Hyperlink, TextBody}; use deckcraft_model::{Background, MediaClip, Shape, ShapeKind, defaults}; use serde_json::json; use super::*; + const CUSTOM_STYLE: &str = "{11111111-2222-4333-8444-555555555555}"; + + fn styled_table_presentation(id: &str, color: &str) -> Presentation { + let mut p = deckcraft_model::defaults::new_presentation(None); + let raw = format!( + r#""# + ); + p.table_styles.push(TableStyleDef { id: id.into(), name: "Owned".into(), raw, ..Default::default() }); + let table = deckcraft_model::Table { style: id.into(), ..deckcraft_model::Table::new(1, 1, 100.0, 24.0) }; + let child = deckcraft_model::Shape { id: ShapeId(p.alloc_id().expect("shape id")), kind: ShapeKind::Table(table), ..Default::default() }; + let group = deckcraft_model::Shape { + id: ShapeId(p.alloc_id().expect("group id")), + kind: ShapeKind::Group { children: vec![child], child: Default::default() }, + ..Default::default() + }; + let slide = p.slides.first_mut().expect("default slide"); + Arc::make_mut(slide).shapes.push(group); + p + } + + fn first_table_style(shapes: &[deckcraft_model::Shape]) -> Option { + for shape in shapes { + if let ShapeKind::Table(table) = &shape.kind { + return Some(table.style.clone()); + } + if let Some(style) = first_table_style(shape.children()) { + return Some(style); + } + } + None + } + + #[test] + fn reused_grouped_table_styles_survive_native_and_pptx_round_trips_and_undo() { + for (name, bytes) in [ + ("source.deckcraft", deckcraft_format::save(&styled_table_presentation(CUSTOM_STYLE, "FF0000")).expect("native source")), + ("source.pptx", deckcraft_pptx::export(&styled_table_presentation(CUSTOM_STYLE, "FF0000")).expect("pptx source")), + ] { + let data = super::super::base64_encode(&bytes); + let mut session = Session::with_new(); + session.execute("slide.reuse", &json!({"data": data, "name": name, "slides": [0], "keepFormatting": true})).expect("reuse"); + let doc = &session.doc().expect("doc").doc; + assert_eq!(doc.table_styles.iter().filter(|style| style.id == CUSTOM_STYLE).count(), 1); + assert!(doc.table_styles.iter().any(|style| style.id == CUSTOM_STYLE && style.raw.contains("FF0000"))); + assert_eq!(first_table_style(&doc.slides.get(1).expect("reused slide").shapes).as_deref(), Some(CUSTOM_STYLE)); + + session.execute("edit.undo", &json!({})).expect("undo reuse"); + let doc = &session.doc().expect("doc").doc; + assert!(doc.table_styles.iter().all(|style| style.id != CUSTOM_STYLE)); + session.execute("edit.redo", &json!({})).expect("redo reuse"); + let bytes = crate::cmd::file::save_bytes(&session.doc().expect("doc").doc, "deckcraft").expect("save destination"); + let reopened = crate::cmd::file::open_presentation("destination.deckcraft", &bytes).expect("reopen destination"); + assert!(reopened.table_styles.iter().any(|style| style.id == CUSTOM_STYLE)); + assert_eq!(first_table_style(&reopened.slides.get(1).expect("reused slide").shapes).as_deref(), Some(CUSTOM_STYLE)); + } + } + + #[test] + fn reused_table_style_definitions_are_deduplicated_or_remapped_on_collision() { + let source = styled_table_presentation(CUSTOM_STYLE, "FF0000"); + let source_bytes = deckcraft_format::save(&source).expect("save source"); + + let mut identical = Session::with_new(); + identical + .edit(|doc, _| { + doc.table_styles.push(source.table_styles.first().cloned().unwrap_or_default()); + Ok(()) + }) + .expect("seed identical definition"); + identical + .execute("slide.reuse", &json!({"data": super::super::base64_encode(&source_bytes), "name": "source.deckcraft", "slides": [0]})) + .expect("reuse identical definition"); + let doc = &identical.doc().expect("doc").doc; + assert_eq!(doc.table_styles.iter().filter(|style| style.id == CUSTOM_STYLE).count(), 1); + assert_eq!(first_table_style(&doc.slides.get(1).expect("reused slide").shapes).as_deref(), Some(CUSTOM_STYLE)); + + let mut conflicting = Session::with_new(); + conflicting + .edit(|doc, _| { + let mut own = source.table_styles.first().cloned().unwrap_or_default(); + own.raw = own.raw.replace("FF0000", "0000FF"); + doc.table_styles.push(own); + let table = deckcraft_model::Table { style: CUSTOM_STYLE.into(), ..deckcraft_model::Table::new(1, 1, 100.0, 24.0) }; + let id = ShapeId(doc.alloc_id().expect("destination table id")); + if let Some(slide) = doc.slides.first_mut() { + Arc::make_mut(slide).shapes.push(Shape { id, kind: ShapeKind::Table(table), ..Default::default() }); + } + Ok(()) + }) + .expect("seed conflicting definition"); + conflicting + .execute("slide.reuse", &json!({"data": super::super::base64_encode(&source_bytes), "name": "source.deckcraft", "slides": [0]})) + .expect("reuse conflicting definition"); + let doc = &conflicting.doc().expect("doc").doc; + assert_eq!(first_table_style(&doc.slides.first().expect("destination slide").shapes).as_deref(), Some(CUSTOM_STYLE)); + let imported_style = first_table_style(&doc.slides.get(1).expect("reused slide").shapes).expect("grouped table style"); + assert_ne!(imported_style, CUSTOM_STYLE); + assert!(doc.table_styles.iter().any(|style| style.id == imported_style && style.raw.contains("FF0000"))); + assert!(doc.table_styles.iter().any(|style| style.id == CUSTOM_STYLE && style.raw.contains("0000FF"))); + } + /// Every kind of media a slide can hold comes along, each pointing at its own bytes: the /// artistic-effect original, a background picture, poster, sounds and picture bullets too. #[test] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 329a69f89..87887beda 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: seven owned PPTX corpus files, embedded fonts, EMF/WMF clip regions, threaded comments (#70), chart trendlines and workbooks (#335), SmartArt written as a diagram, tags and custom XML kept, picture export DPI; duplicated rows and headers folded; previous: trivial, lines in scaled groups, #233) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-03 · **Change:** trivial (Reuse Slides transfers referenced custom table-style definitions and remaps conflicting ids; native/PPTX reuse regressions) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every format PowerPoint reads or writes, DeckCraft's support, fidelity and how it is tested. Gaps are itemized in [gaps.md](gaps.md#file-formats). Dimension score: **~58% ready** @@ -87,7 +87,7 @@ the round trip even though DeckCraft doesn't model it. | 3-D: `scene3d`, `sp3d` | kept | kept | A bevel preset with no `sp3d` of its own is written back on shapes and theme effect styles. Bevels, cameras, face lighting and extrusion sides draw approximately | | Text warp (`prstTxWarp`) | yes | yes | All 40 presets rendered (`crates/geom/src/warp.rs`); adjust values (`avLst`) round-trip | | Pictures with crop, recolour, duotone, alpha, luminance | yes | yes | | -| Tables with styles | yes | yes | Merges and both diagonal borders round-trip | +| Tables with styles | yes | yes | Merges and both diagonal borders round-trip; Reuse Slides transfers definitions referenced by source tables, including grouped tables, with conflicting ids remapped | | Charts (`c:chartSpace`) | yes | yes | Original XML kept in `Chart.raw`. Trendlines (type, order or period, forecast, intercept, equation and R²) are read, written and drawn. A combo chart's secondary value axis is read and is not written back when the chart is rebuilt. An unchanged chart also keeps its embedded workbook and the other parts its relationships point at, under fresh `keptN` names so they never replace a part DeckCraft writes (#335); editing the chart drops that workbook instead of writing a stale one | | Charts (`cx:` chartex: waterfall, funnel, treemap, sunburst, histogram, Pareto, box) | yes | yes | Unchanged parts kept, with their embedded workbook (#335); edited and new ones written from the model with a plain-chart fallback. Treemap and sunburst hierarchy levels kept; region maps kept, drawn as tiles (no map geometry) | | SmartArt (`dgm`) | the drawing part as a group (text laid out on its `dsp:txXfrm` rectangle), and the outline from the data model (Text Pane) | as a diagram: data model from the outline; layout, quick style and colour parts (the file's own kept until the layout is switched); the group as the cached drawing | DeckCraft's layout definitions are simple; a diagram without a cached drawing isn't drawn. SmartArt kept only as XML, without its relationship parts, still loses the drawing and data parts (#203) | @@ -119,6 +119,7 @@ the round trip even though DeckCraft doesn't model it. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | trivial | Reuse Slides transfers referenced custom table styles from native and PPTX sources; grouped tables, undo, native save/reopen and style-id conflicts are covered | | 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged | | 2026-10-15 | trivial | Native `.deckcraft` files serialize a nonempty chart `kept` sidecar (relationships, part names, content types and bytes). Older files without it still load; empty sidecars are omitted. Headless PPTX → edit → native save/reopen → PPTX checks keep the workbook and drawing bytes; editing chart data still drops the stale workbook | | 2026-10-15 | trivial | PPTX interactive timing sequences keep later On Click steps and paragraph click builds (`trigger_steps.rs`); mixed starts and independent buttons survive import/edit/save/reopen | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 606f1228d..b29dbd582 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -2,7 +2,7 @@ > **Generated:** 2026-10-14 (bullets/numbering remain D after empty-line marker review; summary by `cargo xtask parity`; rows re-scored by the 2026-10-14 community PR wave, duplicate Thesaurus, AutoCorrect, Artistic effects and Corrections rows folded; Preview in editor re-scored 2026-10-13; earlier rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4; video/animated GIF row re-scored 2026-10-11, #171) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last updated:** 2026-11-01 · Locked-shape and no-outline regressions added for #1621; fill row re-assessed: remains D; No Fill hit-testing now follows resolved appearance. Scores unchanged. +> **Last updated:** 2026-11-03 · Reuse Slides now transfers referenced custom table styles and safely remaps conflicts; feature status and summary scores unchanged. The summary between ` **Weighted breadth parity: 84%** over 191 features. @@ -70,7 +70,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Files | Export video (MP4) / animated GIF (GIF: one frame per slide, no transitions; no video) | P2 | P | | Files | Print (slides, handouts, notes, outline) | P1 | M | | Files | Password protection | P2 | M | -| Files | Reuse slides from another file | P1 | P | +| Files | Reuse slides from another file (including referenced custom table styles) | P1 | P | | Files | Drag and drop files onto the window | P1 | D | | Files | OpenDocument .odp open/save | P2 | M | | Slides | New slide by layout | P0 | D | @@ -240,4 +240,5 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Date | Change | Summary | |---|---|---| +| 2026-11-03 | trivial | Reuse Slides transfers referenced custom table styles; row remains P because other reuse behavior is still partial, summary scores unchanged | | 2026-10-31 | trivial | No Fill interior hit-testing follows resolved fill (#1621); fill row remains D and summary scores are unchanged | From 73bd23158949faf486a9cd1d223609a79fb41d62 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:32:19 -0600 Subject: [PATCH 33/79] D709: remove dangling animations from merged shapes --- crates/engine/src/cmd/merge.rs | 43 +++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/merge.rs b/crates/engine/src/cmd/merge.rs index 7db4a9e33..f67a2ae49 100644 --- a/crates/engine/src/cmd/merge.rs +++ b/crates/engine/src/cmd/merge.rs @@ -246,8 +246,17 @@ fn merge(s: &mut Session, p: &Value) -> Result { list.insert(at + k, sh); } list.retain(|x| !ids.contains(&x.id)); - sel.shapes = new_ids.clone(); let _ = list; + if sel.target == crate::Target::Slides + && let Some(sl) = doc.slides.get_mut(sel.slide) + { + let sl = std::sync::Arc::make_mut(sl); + sl.animations.retain(|a| !ids.contains(&a.shape)); + for a in sl.animations.iter_mut().filter(|a| a.trigger.is_some_and(|t| ids.contains(&t))) { + a.trigger = None; + } + } + sel.shapes = new_ids.clone(); Ok(()) })?; Ok(json!({"ids": new_ids.iter().map(|i| i.0).collect::>()})) @@ -338,4 +347,36 @@ mod tests { assert!(matches!(out.kind, ShapeKind::Shape) && out.text.is_none()); assert!(matches!(out.fill, Some(deckcraft_model::style::Fill::Solid { .. })), "{:?}", out.fill); } + + #[test] + fn merge_cleans_animation_targets_and_triggers_for_every_operation_and_undo_restores_them() { + for op in ["union", "combine", "fragment", "intersect", "subtract"] { + let mut s = Session::with_new(); + let first = s.execute("shape.insert", &json!({"preset": "rect", "rect": [0, 0, 100, 100]})).expect("first shape")["id"] + .as_u64() + .expect("id") as u32; + let second = s.execute("shape.insert", &json!({"preset": "rect", "rect": [50, 0, 100, 100]})).expect("second shape")["id"] + .as_u64() + .expect("id") as u32; + let unrelated = s.execute("shape.insert", &json!({"preset": "rect", "rect": [200, 0, 100, 100]})).expect("unrelated shape")["id"] + .as_u64() + .expect("id") as u32; + s.execute("animation.add", &json!({"effect": "fade", "ids": [second]})).expect("operand animation"); + s.execute("animation.add", &json!({"effect": "spin", "ids": [unrelated]})).expect("unrelated animation"); + s.execute("animation.timing", &json!({"index": 1, "trigger": first})).expect("trigger animation"); + + s.execute("shape.merge", &json!({"op": op, "ids": [first, second]})).expect("merge"); + let animations = &s.doc().expect("doc").doc.slides[0].animations; + assert_eq!(animations.len(), 1, "{op} removed animations for merged operands"); + assert_eq!(animations[0].shape.0, unrelated, "{op} preserved the unrelated effect"); + assert_eq!(animations[0].trigger, None, "{op} cleared the removed shape trigger"); + + s.execute("edit.undo", &json!({})).expect("undo merge"); + let animations = &s.doc().expect("doc").doc.slides[0].animations; + assert_eq!(animations.len(), 2, "{op} undo restored removed effects"); + assert_eq!(animations[0].shape.0, second); + assert_eq!(animations[1].shape.0, unrelated); + assert_eq!(animations[1].trigger.map(|id| id.0), Some(first)); + } + } } From 1e5b78de37485cf5fe643dcde091f3095e8ba90f Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:32:41 -0600 Subject: [PATCH 34/79] D702: prune deleted slides from custom shows --- ROADMAP.md | 1 + crates/engine/src/cmd/slide.rs | 42 ++++++++++++++++++++++++++++++++++ docs/slideshow-parity.md | 5 ++-- 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b653af403..09e986b84 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -149,6 +149,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-11-02 | Deleting slides removes their IDs from every custom show; the cleanup is undoable and survives native save/reopen | | 2026-11-02 | File › About replaces the unused Account page, with version, project links, contributor/model credits and appearance preferences | | 2026-11-02 | Tiled picture fills in groups: a tile (`a:blipFill` with `a:tile`) keeps its size, offset and anchor in slide points whatever the group's child-coordinate scale, uniform or stretched unevenly, nested or flipped (also a group's own tile fill passed to its members, and a picture shape's own tile, with its shadow and reflection), and tiles with `rotWithShape="0"` keep their size in points at any zoom, as PowerPoint draws them (measured on its own PNG export of a synthetic probe deck: every grouped case within ~3/255 of the ungrouped control's own difference from PowerPoint, which is ~13/255 from resampling). Before, a group whose child space was ~1800 times smaller than its box showed one flat texel of the texture (a Venn diagram with paper-textured circles and a hatched intersection) | | 2026-10-31 | No Fill shape interiors allow selection and editing of underlying objects, even when a quick style remains; hit-testing uses the renderer’s fill resolution. Outlines, rotation, text targets and actual fills keep their hit behaviour (#1621) | diff --git a/crates/engine/src/cmd/slide.rs b/crates/engine/src/cmd/slide.rs index e150a2659..198f1bc6c 100644 --- a/crates/engine/src/cmd/slide.rs +++ b/crates/engine/src/cmd/slide.rs @@ -267,6 +267,9 @@ fn delete(s: &mut Session, p: &Value) -> Result { for sec in &mut doc.sections { sec.slides.retain(|x| !ids.contains(x)); } + for show in &mut doc.custom_shows { + show.slides.retain(|x| !ids.contains(x)); + } sel.slide = first.min(doc.slides.len().saturating_sub(1)); sel.slides = doc.slides.get(sel.slide).map(|x| vec![x.id]).unwrap_or_default(); sel.shapes.clear(); @@ -777,6 +780,45 @@ mod tests { (sel.slide, sel.slides.iter().map(|x| x.0).collect()) } + #[test] + fn deleting_slide_prunes_every_custom_show_in_one_undo_step() { + let (mut s, ids) = deck(3); + let shows = vec![ + deckcraft_model::CustomShow { + name: "First".into(), + slides: vec![deckcraft_model::SlideId(ids[0]), deckcraft_model::SlideId(ids[1]), deckcraft_model::SlideId(ids[1])], + }, + deckcraft_model::CustomShow { name: "Second".into(), slides: vec![deckcraft_model::SlideId(ids[1]), deckcraft_model::SlideId(ids[2])] }, + deckcraft_model::CustomShow { name: "Empty after delete".into(), slides: vec![deckcraft_model::SlideId(ids[1])] }, + ]; + s.edit(|doc, _| { + doc.custom_shows = shows; + Ok(()) + }) + .expect("set custom shows"); + + s.execute("slide.delete", &json!({"index": 1})).expect("delete slide"); + let shows = &s.doc().expect("doc").doc.custom_shows; + assert_eq!(shows[0].slides, [deckcraft_model::SlideId(ids[0])]); + assert_eq!(shows[1].slides, [deckcraft_model::SlideId(ids[2])]); + assert!(shows[2].slides.is_empty()); + + s.execute("edit.undo", &json!({})).expect("undo deletion"); + assert_eq!(order(&s), ids); + assert_eq!( + s.doc().expect("doc").doc.custom_shows[0].slides, + [deckcraft_model::SlideId(ids[0]), deckcraft_model::SlideId(ids[1]), deckcraft_model::SlideId(ids[1])] + ); + assert_eq!(s.doc().expect("doc").doc.custom_shows[1].slides, [deckcraft_model::SlideId(ids[1]), deckcraft_model::SlideId(ids[2])]); + + s.execute("edit.redo", &json!({})).expect("redo deletion"); + let bytes = deckcraft_format::save(&s.doc().expect("doc").doc).expect("save deck"); + let reopened = deckcraft_format::load(&bytes).expect("reopen deck"); + assert_eq!(reopened.custom_shows[0].slides, [deckcraft_model::SlideId(ids[0])]); + assert_eq!(reopened.custom_shows[1].slides, [deckcraft_model::SlideId(ids[2])]); + assert!(reopened.custom_shows[2].slides.is_empty()); + } + #[test] fn move_from_to_moves_one_slide() { let (mut s, ids) = deck(4); diff --git a/docs/slideshow-parity.md b/docs/slideshow-parity.md index 5b10c2028..137960531 100644 --- a/docs/slideshow-parity.md +++ b/docs/slideshow-parity.md @@ -1,6 +1,6 @@ # Slide show and presenter view: parity with PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-27 · **Change:** minor (Set Up Slide Show ▸ Keep mouse cursor visible, #1445; previous: minor, Ctrl+wheel zooms the show around the pointer, #1286; previous: major, community PR wave 2026-10-14: interactive decks, laser pointer, See All Slides and zoom, go-to-slide number, custom show playback, presenter timer, clock, notes size and keyboard, Show Media Controls, swipe; breadth 68%; duplicated rows and headers folded; previous: major, created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-02 · **Change:** minor (deleting slides removes them from custom shows; previous: minor, Set Up Slide Show ▸ Keep mouse cursor visible, #1445; previous: minor, Ctrl+wheel zooms the show around the pointer, #1286; previous: major, community PR wave 2026-10-14: interactive decks, laser pointer, See All Slides and zoom, go-to-slide number, custom show playback, presenter timer, clock, notes size and keyboard, Show Media Controls, swipe; breadth 68%; duplicated rows and headers folded; previous: major, created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Presenting is the job PowerPoint is hired for. This doc covers the show window, presenter view, rehearsal and recording. Transition and animation playback are in @@ -33,7 +33,7 @@ ready** (estimated), **10–16 h** to parity. | Pen and highlighter ink | yes | pen only (Cmd+P pen, Cmd+A arrow, E erases all ink) | No highlighter, per-stroke eraser, ink colours, or keep/discard annotations prompt | | Laser pointer | yes | yes in the show | Cmd/Ctrl+L and the on-screen button draw the show's laser colour and leave no ink. The presenter window does not move that dot | | Subtitles and live captions | yes (cloud speech) | no | AI dimension | -| Custom shows | yes | yes: Show plays that list in order and does not change Set Up; Set Up Slide Show can keep one for Play from Start | Hidden slides in the list are skipped | +| Custom shows | yes | yes: Show plays that list in order and does not change Set Up; Set Up Slide Show can keep one for Play from Start | Hidden slides in the list are skipped; deleting a slide removes its ID from every custom show | | Set Up Show: loop until Esc, kiosk (browsed at a kiosk), show without animation/narration, pen colour, slides range | yes | yes, most options | Keep mouse cursor visible (DeckCraft's own, #1445) stops the show hiding a still pointer after 2.5 s, for decks with clickable choices; it is a user preference (`ui.json`, `showKeepCursor`), since PPTX has no place for it | | Use rehearsed timings | yes | yes | | @@ -90,6 +90,7 @@ Reading View in a window with navigation: present (checklist row done). | 2026-10-14 | trivial | Show Media Controls hides the show's playback bar and GIF button | | 2026-10-14 | minor | Typing a slide number during the show shows it, and Enter goes there. Esc clears the number before it leaves the show. Checklist breadth stays 66% | | 2026-10-14 | minor | A custom show plays only its slides, from the Custom Shows dialog, from Set Up Slide Show, or from a click action. Checklist breadth stays 66% | +| 2026-11-02 | minor | Deleting a slide removes it from every custom show; custom-show cleanup is undoable and survives native save/reopen | | 2026-10-14 | minor | Presenter view pauses and resets its timer (T, R) and changes notes size (A-/A+, brackets). The clock shows local time. Second-display placement and swap stay with #433. Checklist breadth stays 66% | | 2026-10-14 | minor | See All Slides (G) and click-to-zoom (Z) during the show. Presenter view opens that grid and sets black, white and zoom. Checklist breadth stays 66% | | 2026-10-24 | minor | Ctrl+wheel (Cmd+wheel on a Mac) zooms the show in and out around the pointer, up to 4×; the plain wheel still turns the page (#1286) | From 328b4ed274f170031d0d41e7eab171275f74a276 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:41:44 -0600 Subject: [PATCH 35/79] Preserve RTL table direction --- ROADMAP.md | 3 ++- crates/format/src/tests.rs | 32 ++++++++++++++++++++++++++ crates/model/src/table.rs | 4 ++++ crates/pptx/src/lib.rs | 40 +++++++++++++++++++++++++++++++++ crates/pptx/src/read/shapes.rs | 1 + crates/pptx/src/write/shapes.rs | 3 ++- docs/file-format-parity.md | 7 +++--- 7 files changed, 85 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b653af403..b653a6031 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-02 · **Change:** trivial (File › About replaces Account; product links, credits and appearance retained; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-02 · **Change:** trivial (PPTX and native saves retain optional RTL table direction; feature scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-01 · **Change:** trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (progress log: read-only `slide.actions` lists shape and table text actions; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-27 · **Change:** trivial (progress log: Set Up Slide Show ▸ Keep mouse cursor visible, #1445; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -149,6 +149,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-11-02 | Native and PPTX saves retain optional table right-to-left direction, including explicit false and omission; two-save round-trip tests keep labeled columns in order | | 2026-11-02 | File › About replaces the unused Account page, with version, project links, contributor/model credits and appearance preferences | | 2026-11-02 | Tiled picture fills in groups: a tile (`a:blipFill` with `a:tile`) keeps its size, offset and anchor in slide points whatever the group's child-coordinate scale, uniform or stretched unevenly, nested or flipped (also a group's own tile fill passed to its members, and a picture shape's own tile, with its shadow and reflection), and tiles with `rotWithShape="0"` keep their size in points at any zoom, as PowerPoint draws them (measured on its own PNG export of a synthetic probe deck: every grouped case within ~3/255 of the ungrouped control's own difference from PowerPoint, which is ~13/255 from resampling). Before, a group whose child space was ~1800 times smaller than its box showed one flat texel of the texture (a Venn diagram with paper-textured circles and a hatched intersection) | | 2026-10-31 | No Fill shape interiors allow selection and editing of underlying objects, even when a quick style remains; hit-testing uses the renderer’s fill resolution. Outlines, rotation, text targets and actual fills keep their hit behaviour (#1621) | diff --git a/crates/format/src/tests.rs b/crates/format/src/tests.rs index dcd156367..b2dca353a 100644 --- a/crates/format/src/tests.rs +++ b/crates/format/src/tests.rs @@ -48,6 +48,38 @@ fn native_save_keeps_chart_relationships_and_payloads() { } } +#[test] +fn native_table_direction_and_column_text_survive_two_saves() { + use deckcraft_model::table::{Cell, Table}; + use deckcraft_model::{Shape, ShapeId, ShapeKind}; + use std::sync::Arc; + + for rtl in [Some(true), Some(false), None] { + let mut p = defaults::new_presentation(None); + let mut table = Table::new(1, 2, 200.0, 30.0); + table.rtl = rtl; + table.rows[0].cells[0] = Cell::new("Left column"); + table.rows[0].cells[1] = Cell::new("Right column"); + let shape = Shape { id: ShapeId(p.alloc_id().expect("id")), kind: ShapeKind::Table(table), ..Default::default() }; + Arc::make_mut(&mut p.slides[0]).shapes.push(shape); + let mut q = load(&save(&p).expect("save")).expect("load"); + for _ in 0..2 { + let table = q.slides[0] + .shapes + .iter() + .find_map(|s| match &s.kind { + ShapeKind::Table(table) => Some(table), + _ => None, + }) + .expect("table shape"); + assert_eq!(table.rtl, rtl); + assert_eq!(table.rows[0].cells[0].text.paragraphs[0].runs[0].text, "Left column"); + assert_eq!(table.rows[0].cells[1].text.paragraphs[0].runs[0].text, "Right column"); + q = load(&save(&q).expect("save again")).expect("load again"); + } + } +} + /// The same package without the entries `drop` names. fn without(bytes: &[u8], drop: &str) -> Vec { let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); diff --git a/crates/model/src/table.rs b/crates/model/src/table.rs index 0705d1a08..ecf5600f6 100644 --- a/crates/model/src/table.rs +++ b/crates/model/src/table.rs @@ -55,6 +55,9 @@ pub struct Table { pub last_col: bool, pub band_row: bool, pub band_col: bool, + /// Whether columns are ordered right-to-left (`a:tblPr/@rtl`). + #[serde(skip_serializing_if = "Option::is_none")] + pub rtl: Option, /// `a:tblPr` fill: a fill behind the whole table (over the style's own background). #[serde(skip_serializing_if = "Option::is_none")] pub background: Option, @@ -72,6 +75,7 @@ impl Default for Table { last_col: false, band_row: true, band_col: false, + rtl: None, background: None, } } diff --git a/crates/pptx/src/lib.rs b/crates/pptx/src/lib.rs index be08c680f..3c488da2e 100644 --- a/crates/pptx/src/lib.rs +++ b/crates/pptx/src/lib.rs @@ -175,3 +175,43 @@ mod import_theme_tests { assert!(crate::import_theme(&out.finish().unwrap().into_inner()).is_err()); } } + +#[cfg(test)] +mod table_direction_tests { + use deckcraft_model::table::{Cell, Table}; + use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind, defaults}; + use std::sync::Arc; + + fn table(p: &Presentation) -> &Table { + p.slides[0] + .shapes + .iter() + .find_map(|shape| match &shape.kind { + ShapeKind::Table(table) => Some(table), + _ => None, + }) + .expect("table shape") + } + + #[test] + fn table_direction_and_column_order_survive_two_pptx_saves() { + for rtl in [Some(true), Some(false), None] { + let mut p = defaults::new_presentation(None); + let mut t = Table::new(1, 2, 200.0, 30.0); + t.rtl = rtl; + t.rows[0].cells[0] = Cell::new("First column"); + t.rows[0].cells[1] = Cell::new("Second column"); + let shape = Shape { id: ShapeId(p.alloc_id().expect("id")), kind: ShapeKind::Table(t), ..Default::default() }; + Arc::make_mut(&mut p.slides[0]).shapes.push(shape); + + let mut roundtrip = crate::import(&crate::export(&p).expect("export")).expect("import"); + for _ in 0..2 { + let t = table(&roundtrip); + assert_eq!(t.rtl, rtl); + assert_eq!(t.rows[0].cells[0].text.paragraphs[0].runs[0].text, "First column"); + assert_eq!(t.rows[0].cells[1].text.paragraphs[0].runs[0].text, "Second column"); + roundtrip = crate::import(&crate::export(&roundtrip).expect("export again")).expect("import again"); + } + } + } +} diff --git a/crates/pptx/src/read/shapes.rs b/crates/pptx/src/read/shapes.rs index 6d8acc4ad..04f8216d3 100644 --- a/crates/pptx/src/read/shapes.rs +++ b/crates/pptx/src/read/shapes.rs @@ -757,6 +757,7 @@ fn table(imp: &mut Imp, part: &Part, tbl: &El) -> Table { t.last_col = pr.bool("lastCol").unwrap_or(false); t.band_row = pr.bool("bandRow").unwrap_or(false); t.band_col = pr.bool("bandCol").unwrap_or(false); + t.rtl = pr.bool("rtl"); if let Some(id) = pr.child("tableStyleId").map(|e| e.text()) { // A style the file defines is drawn from its definition (by its GUID). // DeckCraft's own styles (written with GUIDs derived from their ids) map back to them. diff --git a/crates/pptx/src/write/shapes.rs b/crates/pptx/src/write/shapes.rs index 87200a890..278899373 100644 --- a/crates/pptx/src/write/shapes.rs +++ b/crates/pptx/src/write/shapes.rs @@ -711,7 +711,8 @@ fn table(w: &mut W, x: &mut Exp, o: &mut Out, t: &Table) { .t("lastRow", t.last_row) .t("lastCol", t.last_col) .t("bandRow", t.band_row) - .t("bandCol", t.band_col), + .t("bandCol", t.band_col) + .b("rtl", t.rtl), ); if let Some(f) = &t.background { dml::fill(w, x, o, f); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 329a69f89..24e154b4e 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: seven owned PPTX corpus files, embedded fonts, EMF/WMF clip regions, threaded comments (#70), chart trendlines and workbooks (#335), SmartArt written as a diagram, tags and custom XML kept, picture export DPI; duplicated rows and headers folded; previous: trivial, lines in scaled groups, #233) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-02 · **Change:** minor (optional table RTL direction survives native and two PPTX saves; scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every format PowerPoint reads or writes, DeckCraft's support, fidelity and how it is tested. Gaps are itemized in [gaps.md](gaps.md#file-formats). Dimension score: **~58% ready** @@ -23,7 +23,7 @@ PowerPoint opens and saves; Viewer = opens only). | `.ppt` / `.pot` / `.pps` 97–2003 binary | Editor | no | no | Owner decision; import only would be enough | none | | `.odp` OpenDocument | Editor | no | no | | none | | `.thmx` Office theme | Viewer | theme only (Design › Browse for Themes…) | no | Browse for Themes takes a `.thmx`'s colours, fonts and effects; a `.pptx` or `.potx` brings its slide master and layouts as well, and slides move to the matching layouts (#807). A `.thmx`'s own master and layouts aren't read yet | `crates/pptx/src/lib.rs` (`import_theme_tests`), `crates/engine/src/cmd/design/browse_theme_tests.rs` | -| `.deckcraft` native (zip + JSON) | n/a | yes (also `.slidecraft`) | yes | Lossless for the DeckCraft model, forward compatible | `crates/format` tests | +| `.deckcraft` native (zip + JSON) | n/a | yes (also `.slidecraft`) | yes | Lossless for the DeckCraft model, forward compatible; optional table RTL direction survives two saves | `crates/format` tests | | PDF | export | no | yes | Slides, notes pages, handouts (1–9); real-text layer with embedded subsets, links, bookmarks. Slide artwork is a raster image at print resolution, not vector | `crates/pdf` tests | | Outline `.rtf` / `.txt` | export (RTF), import (outline) | `.txt` and `.rtf` outline; `.md` as Markdown (headings are slides, list items bullets) (open, drop, `slide.fromOutline`) | `.txt` and `.rtf` outline | — | format + engine tests | | PNG / JPEG slide images | export | n/a | yes (`file.export`, `deckcraft-cli render`) | Matches the editor's renderer. PNG `pHYs` and JPEG JFIF density are `scale` × 72 dpi (default scale 2 is 144 dpi), so Preview shows the slide's real size. PNG can leave the slide background out (`transparentBackground`, Export… “Transparent background”, `deckcraft-cli render --transparent`), so only the slide's content is opaque (#784) | render, engine, UI and CLI tests | @@ -87,7 +87,7 @@ the round trip even though DeckCraft doesn't model it. | 3-D: `scene3d`, `sp3d` | kept | kept | A bevel preset with no `sp3d` of its own is written back on shapes and theme effect styles. Bevels, cameras, face lighting and extrusion sides draw approximately | | Text warp (`prstTxWarp`) | yes | yes | All 40 presets rendered (`crates/geom/src/warp.rs`); adjust values (`avLst`) round-trip | | Pictures with crop, recolour, duotone, alpha, luminance | yes | yes | | -| Tables with styles | yes | yes | Merges and both diagonal borders round-trip | +| Tables with styles | yes | yes | Merges, both diagonal borders and optional RTL column direction round-trip; column text order survives two saves | | Charts (`c:chartSpace`) | yes | yes | Original XML kept in `Chart.raw`. Trendlines (type, order or period, forecast, intercept, equation and R²) are read, written and drawn. A combo chart's secondary value axis is read and is not written back when the chart is rebuilt. An unchanged chart also keeps its embedded workbook and the other parts its relationships point at, under fresh `keptN` names so they never replace a part DeckCraft writes (#335); editing the chart drops that workbook instead of writing a stale one | | Charts (`cx:` chartex: waterfall, funnel, treemap, sunburst, histogram, Pareto, box) | yes | yes | Unchanged parts kept, with their embedded workbook (#335); edited and new ones written from the model with a plain-chart fallback. Treemap and sunburst hierarchy levels kept; region maps kept, drawn as tiles (no map geometry) | | SmartArt (`dgm`) | the drawing part as a group (text laid out on its `dsp:txXfrm` rectangle), and the outline from the data model (Text Pane) | as a diagram: data model from the outline; layout, quick style and colour parts (the file's own kept until the layout is switched); the group as the cached drawing | DeckCraft's layout definitions are simple; a diagram without a cached drawing isn't drawn. SmartArt kept only as XML, without its relationship parts, still loses the drawing and data parts (#203) | @@ -119,6 +119,7 @@ the round trip even though DeckCraft doesn't model it. | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | Optional table RTL direction is read and written as `a:tblPr/@rtl`; native and two PPTX saves preserve true, false and absent values with distinguishable column text | | 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged | | 2026-10-15 | trivial | Native `.deckcraft` files serialize a nonempty chart `kept` sidecar (relationships, part names, content types and bytes). Older files without it still load; empty sidecars are omitted. Headless PPTX → edit → native save/reopen → PPTX checks keep the workbook and drawing bytes; editing chart data still drops the stale workbook | | 2026-10-15 | trivial | PPTX interactive timing sequences keep later On Click steps and paragraph click builds (`trigger_steps.rs`); mixed starts and independent buttons survive import/edit/save/reopen | From bf60dbb773c3100a543d7ffa64b1003a1fcdb9c7 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:41:55 -0600 Subject: [PATCH 36/79] Fit reused slide content to destination size --- ROADMAP.md | 3 +- crates/engine/src/cmd/reuse.rs | 229 ++++++++++++++++++++++++++++++++- docs/gaps.md | 5 +- docs/parity-checklist.md | 4 +- docs/target-app-parity.md | 5 +- 5 files changed, 236 insertions(+), 10 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b653af403..44d67db08 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-02 · **Change:** trivial (File › About replaces Account; product links, credits and appearance retained; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-02 · **Change:** trivial (reused slides scale to the destination size, including kept masters, layouts, groups, tables and text; reuse remains partial because picker thumbnails are not implemented) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-01 · **Change:** trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (progress log: read-only `slide.actions` lists shape and table text actions; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-27 · **Change:** trivial (progress log: Set Up Slide Show ▸ Keep mouse cursor visible, #1445; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -149,6 +149,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-11-02 | Reused slides scale their geometry and content dimensions to the destination slide, including kept source masters and layouts; the reuse picker still lacks thumbnails | | 2026-11-02 | File › About replaces the unused Account page, with version, project links, contributor/model credits and appearance preferences | | 2026-11-02 | Tiled picture fills in groups: a tile (`a:blipFill` with `a:tile`) keeps its size, offset and anchor in slide points whatever the group's child-coordinate scale, uniform or stretched unevenly, nested or flipped (also a group's own tile fill passed to its members, and a picture shape's own tile, with its shadow and reflection), and tiles with `rotWithShape="0"` keep their size in points at any zoom, as PowerPoint draws them (measured on its own PNG export of a synthetic probe deck: every grouped case within ~3/255 of the ungrouped control's own difference from PowerPoint, which is ~13/255 from resampling). Before, a group whose child space was ~1800 times smaller than its box showed one flat texel of the texture (a Venn diagram with paper-textured circles and a hatched intersection) | | 2026-10-31 | No Fill shape interiors allow selection and editing of underlying objects, even when a quick style remains; hit-testing uses the renderer’s fill resolution. Outlines, rotation, text targets and actual fills keep their hit behaviour (#1621) | diff --git a/crates/engine/src/cmd/reuse.rs b/crates/engine/src/cmd/reuse.rs index 91cf7c930..765e4a81b 100644 --- a/crates/engine/src/cmd/reuse.rs +++ b/crates/engine/src/cmd/reuse.rs @@ -9,13 +9,151 @@ use std::collections::HashMap; use std::sync::Arc; -use deckcraft_model::text::{Action, Hyperlink, RunProps, TextBody}; +use deckcraft_model::text::{Action, Hyperlink, ListStyle, RunProps, Spacing, TextBody}; use deckcraft_model::{LayoutId, Master, MasterId, MediaId, Presentation, ShapeId, ShapeKind, Slide, SlideId}; use serde_json::{Value, json}; use super::{CommandSpec, NO_ID_SPACE, bad, bool_or, cmd, has_doc, usize_param}; use crate::{Result, Session, media_refs}; +/// Scale physical measurements in imported content to the destination slide's coordinate space. +/// Text has one size, so it uses the geometric mean of the horizontal and vertical scale. +fn scale_text(t: &mut TextBody, sx: f64, sy: f64) { + let unit = (sx * sy).sqrt(); + for v in [&mut t.body.inset_l, &mut t.body.inset_r, &mut t.body.col_spacing].into_iter().flatten() { + *v *= sx; + } + for v in [&mut t.body.inset_t, &mut t.body.inset_b].into_iter().flatten() { + *v *= sy; + } + scale_list(&mut t.list_style, sx, unit); + for p in &mut t.paragraphs { + for v in [&mut p.props.margin_left, &mut p.props.margin_right, &mut p.props.indent, &mut p.props.default_tab].into_iter().flatten() { + *v *= sx; + } + if let Some(stops) = &mut p.props.tabs { + for stop in stops { + stop.pos *= sx; + } + } + for spacing in [&mut p.props.line_spacing, &mut p.props.space_before, &mut p.props.space_after] { + if let Some(Spacing::Pts(v)) = spacing { + *v *= unit; + } + } + if let Some(v) = &mut p.props.bullet_size_points { + *v *= unit; + } + scale_run(&mut p.def_props, unit); + scale_run(&mut p.end_props, unit); + for r in &mut p.runs { + scale_run(&mut r.props, unit); + } + } +} + +fn scale_list(list: &mut ListStyle, sx: f64, unit: f64) { + for level in list.levels.iter_mut().flatten() { + for v in + [&mut level.para.margin_left, &mut level.para.margin_right, &mut level.para.indent, &mut level.para.default_tab].into_iter().flatten() + { + *v *= sx; + } + for spacing in [&mut level.para.line_spacing, &mut level.para.space_before, &mut level.para.space_after] { + if let Some(Spacing::Pts(v)) = spacing { + *v *= unit; + } + } + if let Some(v) = &mut level.para.bullet_size_points { + *v *= unit; + } + scale_run(&mut level.run, unit); + } +} + +fn scale_run(run: &mut RunProps, factor: f64) { + for v in [&mut run.size, &mut run.spacing, &mut run.kern, &mut run.soft_edge].into_iter().flatten() { + *v *= factor; + } +} + +fn scale_shapes(shapes: &mut [deckcraft_model::Shape], sx: f64, sy: f64, depth: usize) { + if depth > 64 { + return; + } + let unit = (sx * sy).sqrt(); + for shape in shapes { + if let Some(x) = &mut shape.xfrm { + x.x *= sx; + x.w *= sx; + x.y *= sy; + x.h *= sy; + } + if let Some(text) = &mut shape.text { + scale_text(text, sx, sy); + } + match &mut shape.kind { + ShapeKind::Table(table) => { + for col in &mut table.cols { + *col *= sx; + } + for row in &mut table.rows { + row.height *= sy; + for cell in &mut row.cells { + if let Some(margins) = &mut cell.margins { + if let Some(v) = margins.get_mut(0) { + *v *= sx; + } + if let Some(v) = margins.get_mut(1) { + *v *= sx; + } + if let Some(v) = margins.get_mut(2) { + *v *= sy; + } + if let Some(v) = margins.get_mut(3) { + *v *= sy; + } + } + scale_text(&mut cell.text, sx, sy); + } + } + } + ShapeKind::Group { children, child } => { + child.x *= sx; + child.w *= sx; + child.y *= sy; + child.h *= sy; + scale_shapes(children, sx, sy, depth + 1); + } + _ => {} + } + if let Some(effects) = &mut shape.effects { + for shadow in [&mut effects.outer_shadow, &mut effects.inner_shadow].into_iter().flatten() { + shadow.blur *= unit; + shadow.dist *= unit; + } + if let Some(glow) = &mut effects.glow { + glow.radius *= unit; + } + if let Some(reflection) = &mut effects.reflection { + reflection.blur *= unit; + reflection.dist *= unit; + } + } + } +} + +fn scale_master(master: &mut Master, sx: f64, sy: f64) { + scale_shapes(&mut master.shapes, sx, sy, 0); + let unit = (sx * sy).sqrt(); + scale_list(&mut master.title_style, sx, unit); + scale_list(&mut master.body_style, sx, unit); + scale_list(&mut master.other_style, sx, unit); + for layout in &mut master.layouts { + scale_shapes(&mut layout.shapes, sx, sy, 0); + } +} + pub fn specs() -> Vec { vec![ cmd!( @@ -176,8 +314,14 @@ fn reuse(s: &mut Session, p: &Value) -> Result { } let keep = bool_or(p, "keepFormatting", false); let at = usize_param(p, "at"); + let source_size = src.slide_size; s.edit(|doc, sel| { let at = at.unwrap_or(sel.slide + 1).min(doc.slides.len()); + let scale = |source: f64, destination: f64| { + if source.is_finite() && source > 0.0 && destination.is_finite() && destination > 0.0 { destination / source } else { 1.0 } + }; + let sx = scale(source_size.width, doc.slide_size.width); + let sy = scale(source_size.height, doc.slide_size.height); // Media: what the slides use is copied under fresh ids. let mut media_map: HashMap = HashMap::new(); // Keep Source Formatting: bring each source master used, once, with fresh ids. @@ -190,7 +334,8 @@ fn reuse(s: &mut Session, p: &Value) -> Result { continue; } masters_done.push(m.id); - let m = import_master("slide.reuse", doc, &src, m, &mut media_map, &mut layout_map)?; + let mut m = import_master("slide.reuse", doc, &src, m, &mut media_map, &mut layout_map)?; + scale_master(&mut m, sx, sy); doc.masters.push(Arc::new(m)); } } @@ -201,6 +346,7 @@ fn reuse(s: &mut Session, p: &Value) -> Result { let Some(from) = src.slides.get(i) else { continue }; copy_media("slide.reuse", doc, &src, &media_refs::slide_media(from), &mut media_map)?; let mut sl: Slide = from.as_ref().clone(); + scale_shapes(&mut sl.shapes, sx, sy, 0); media_refs::visit_mut::slide(&mut sl, &mut remap(&media_map)); sl.id = SlideId(doc.alloc_id().ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?); let map = renumber("slide.reuse", doc, &mut sl.shapes)?; @@ -284,13 +430,90 @@ fn copy_media(cmd: &str, doc: &mut Presentation, src: &Presentation, used: &[Med #[cfg(test)] mod tests { + use deckcraft_geom::{Size, Xfrm}; use deckcraft_model::style::{Artistic, Fill, PictureFill}; use deckcraft_model::text::{Action, Bullet, Hyperlink, TextBody}; - use deckcraft_model::{Background, MediaClip, Shape, ShapeKind, defaults}; + use deckcraft_model::{Background, MediaClip, Shape, ShapeKind, Table, defaults}; use serde_json::json; use super::*; + #[test] + fn reused_slide_content_fits_destination_size() { + let mut src = defaults::new_presentation(None); + src.slide_size = Size::new(960.0, 540.0); + let slide_id = src.slides.first().map(|s| s.id).unwrap_or_default(); + if let Some(slide) = src.slides.first_mut().map(Arc::make_mut) { + let mut text = TextBody::from_text("scaled"); + if let Some(run) = text.paragraphs.first_mut().and_then(|p| p.runs.first_mut()) { + run.props.size = Some(24.0); + } + slide.shapes = vec![ + Shape { xfrm: Some(Xfrm::new(800.0, 400.0, 200.0, 100.0)), ..Default::default() }, + Shape { + xfrm: Some(Xfrm::new(40.0, 50.0, 400.0, 200.0)), + text: Some(text), + kind: ShapeKind::Table(Table::new(2, 2, 400.0, 40.0)), + ..Default::default() + }, + Shape { + xfrm: Some(Xfrm::new(100.0, 80.0, 300.0, 150.0)), + kind: ShapeKind::Group { + child: Xfrm::new(0.0, 0.0, 300.0, 150.0), + children: vec![Shape { xfrm: Some(Xfrm::new(200.0, 100.0, 80.0, 40.0)), ..Default::default() }], + }, + ..Default::default() + }, + ]; + } + if let Some(master) = src.masters.first_mut().map(Arc::make_mut) { + master.shapes = vec![Shape { xfrm: Some(Xfrm::new(800.0, 400.0, 100.0, 50.0)), ..Default::default() }]; + if let Some(layout) = master.layouts.first_mut() { + layout.shapes = vec![Shape { xfrm: Some(Xfrm::new(700.0, 300.0, 100.0, 50.0)), ..Default::default() }]; + } + } + let bytes = deckcraft_format::save(&src).expect("source save"); + let data = super::super::base64_encode(&bytes); + let mut session = Session::with_new(); + session + .edit(|doc, _| { + doc.slide_size = Size::new(480.0, 270.0); + Ok(()) + }) + .expect("destination size"); + session.execute("slide.reuse", &json!({"data": data, "name": "source.deckcraft", "slides": [0], "keepFormatting": true})).expect("reuse"); + + let doc = &session.doc().expect("doc").doc; + let reused = doc.slides.get(1).expect("reused slide"); + let first = reused.shapes.first().and_then(|s| s.xfrm).expect("shape transform"); + assert_eq!(first, Xfrm::new(400.0, 200.0, 100.0, 50.0)); + let table_shape = reused.shapes.get(1).expect("table"); + let table_box = table_shape.xfrm.expect("table transform"); + assert_eq!(table_box, Xfrm::new(20.0, 25.0, 200.0, 100.0)); + assert!(matches!(&table_shape.kind, ShapeKind::Table(_))); + let ShapeKind::Table(table) = &table_shape.kind else { return }; + assert_eq!(table.cols, vec![100.0, 100.0]); + assert_eq!(table.rows.first().map(|r| r.height), Some(20.0)); + let font_size = table_shape.text.as_ref().and_then(|t| t.paragraphs.first()).and_then(|p| p.runs.first()).and_then(|r| r.props.size); + assert_eq!(font_size, Some(12.0)); + let group = reused.shapes.get(2).expect("group"); + assert!(matches!(&group.kind, ShapeKind::Group { .. })); + let ShapeKind::Group { children, child } = &group.kind else { return }; + assert_eq!(group.xfrm, Some(Xfrm::new(50.0, 40.0, 150.0, 75.0))); + assert_eq!(*child, Xfrm::new(0.0, 0.0, 150.0, 75.0)); + assert_eq!(children.first().and_then(|s| s.xfrm), Some(Xfrm::new(100.0, 50.0, 40.0, 20.0))); + + let imported_master = doc.masters.last().expect("imported master"); + assert_eq!(imported_master.shapes.first().and_then(|s| s.xfrm), Some(Xfrm::new(400.0, 200.0, 50.0, 25.0))); + assert_eq!(imported_master.layouts.first().and_then(|l| l.shapes.first()).and_then(|s| s.xfrm), Some(Xfrm::new(350.0, 150.0, 50.0, 25.0))); + assert_ne!(reused.id, slide_id); + + let saved = deckcraft_format::save(&doc.clone()).expect("destination save"); + let reopened = crate::cmd::file::open_presentation("destination.deckcraft", &saved).expect("destination reopen"); + let reopened_shape = reopened.slides.get(1).and_then(|s| s.shapes.first()).and_then(|s| s.xfrm); + assert_eq!(reopened_shape, Some(Xfrm::new(400.0, 200.0, 100.0, 50.0))); + } + /// Every kind of media a slide can hold comes along, each pointing at its own bytes: the /// artistic-effect original, a background picture, poster, sounds and picture bullets too. #[test] diff --git a/docs/gaps.md b/docs/gaps.md index 1fefa5d9d..b37a5ed63 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DeckCraft falls short of PowerPoint -> **Last reviewed:** 2026-10-30 · **Last updated:** 2026-11-01 · **Change:** trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-30 · **Last updated:** 2026-11-02 · **Change:** trivial (reused slide contents now scale to the destination; picker thumbnails remain a gap) · **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 @@ -284,7 +284,7 @@ and starts at that effect. The pane has an advanced timeline with draggable bars ### Smaller missing rows Zoom: live thumbnails, the zoom-in animation and the native `p166:sldZm` element (3–5 h; `insert.zoom` makes linked pictures with Return to Zoom), reuse slides pane with thumbnails -(1–2 h; `slide.reuseList` picks which slides of a file to insert, #1103), laser pointer from the presenter window (1–2 h), +(1–2 h; `slide.reuseList` picks which slides of a file to insert, and reused content now fits the destination size), laser pointer from the presenter window (1–2 h), artistic effects (3–6 h for the other 15 of 22; 7 approximated and round-tripped), remove background (6–10 h; needs a model or a classical matte), insert screenshot (2–3 h), record audio/screen and record slide show (8–12 h), compare presentations beyond accepting and rejecting each revision, or all (#850; per-object accept and reject, markers on the slide: 2–4 h), password protection (4–6 h, ECMA-376 @@ -368,6 +368,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-11-02 | trivial | Reused slide geometry and content scale to the destination; picker thumbnails remain the reuse gap | | 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged | | 2026-10-30 | minor | Slide-show click targets use each shape’s own animation state; scale and rotation test added | | 2026-10-28 | trivial | Automatic lists now run independently of Replace text as you type; proofing gap description updated | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 8af3745ea..ea3147927 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -2,7 +2,7 @@ > **Generated:** 2026-10-14 (bullets/numbering remain D after empty-line marker review; summary by `cargo xtask parity`; rows re-scored by the 2026-10-14 community PR wave, duplicate Thesaurus, AutoCorrect, Artistic effects and Corrections rows folded; Preview in editor re-scored 2026-10-13; earlier rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4; video/animated GIF row re-scored 2026-10-11, #171) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last updated:** 2026-11-02 · Tiled picture fills keep their size in scaled groups and upright tiles (#1693); Picture and texture fill row re-assessed: remains P, since only how tiles are sized changed. Scores unchanged. +> **Last updated:** 2026-11-02 · Reused slides fit their geometry, tables and text to the destination size; reuse remains partial because the picker has no thumbnails. Scores unchanged. > > **Previously (2026-11-01):** Locked-shape and no-outline regressions added for #1621; fill row re-assessed: remains D; No Fill hit-testing now follows resolved appearance. Scores unchanged. @@ -72,7 +72,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Files | Export video (MP4) / animated GIF (GIF: one frame per slide, no transitions; no video) | P2 | P | | Files | Print (slides, handouts, notes, outline) | P1 | M | | Files | Password protection | P2 | M | -| Files | Reuse slides from another file | P1 | P | +| Files | Reuse slides from another file (content fits destination size; picker thumbnails remain) | P1 | P | | Files | Drag and drop files onto the window | P1 | D | | Files | OpenDocument .odp open/save | P2 | M | | Slides | New slide by layout | P0 | D | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 33b631882..df8dd8a9e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DeckCraft vs Microsoft PowerPoint: parity assessment -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (no break after `?`, `!`, `}`, `+` inside a word; previous: trivial, Animations shortfall: preview from the selected effect is in; before that: major, community PR wave 2026-10-14: breadth 84% over 191 features (P0 92%, P1 82%, P2 55%) after `cargo xtask parity`; area table and What works today folded to one row or bullet each; 320 engine commands; ready ~55% and stage alpha unchanged; 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-11-02 · **Change:** trivial (reused slide contents fit the destination size; thumbnails remain; parity estimates unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) The authoritative answer to "how close is DeckCraft to PowerPoint, and how much work is left?". [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) itemizes every shortfall; @@ -179,7 +179,7 @@ Features row of the dimension table keeps ~62% until the next full re-measure). | Area | Weight | Breadth (measured) | Ready (est.) | Hours | Main shortfalls | Doc | |---|---:|---:|---:|---|---|---| | Application shell | 5 | 81% | ~55% | 8–14 | No native macOS menu bar, contextual tabs and multiple-window handling thinner than PowerPoint's | [ui-parity.md](ui-parity.md) | -| Files (commands) | 4 | 75% | ~50% | see file formats | Print, passwords, legacy and macro formats, video export; reuse slides is partial | [file-format-parity.md](file-format-parity.md) | +| Files (commands) | 4 | 75% | ~50% | see file formats | Print, passwords, legacy and macro formats, video export; reuse content now fits destination size, but the picker still lacks thumbnails | [file-format-parity.md](file-format-parity.md) | | Slides and sections | 6 | 92% | ~85% | 3–5 | Zoom (summary/section/slide) is partial, copying slides between decks keeps formatting only partly | [parity-checklist.md](parity-checklist.md) | | Views | 4 | 98% | ~80% | 3–6 | Arrange All (Grid Options and New Window are in) | [ui-parity.md](ui-parity.md) | | Masters and themes | 7 | 92% | ~70% | 8–14 | Notes/Handout master view polish, effect-scheme depth, Designer suggestions | [parity-checklist.md](parity-checklist.md) | @@ -288,6 +288,7 @@ scored done (16 open issues). | Date | Change | Summary | |---|---|---| +| 2026-11-02 | trivial | Reused slide content scales to the destination; picker thumbnails remain; parity estimates unchanged | | 2026-10-22 | trivial | Animations shortfall: preview from the selected effect (Play From) is in. Percentages unchanged | | 2026-10-17 | trivial | A word no longer breaks after `?`, `!`, `}` or `+` (a URL's query wraps at the edge as in PowerPoint 2016 for Windows), as it already didn't after `/` and `\`. Headline percentages unchanged | | 2026-10-16 | trivial | Arrange shortfall: Align to Slide and Align Selected Objects are in the Align menu. Percentages unchanged | From a0956fee13ab3c32eb2403493e01798e0c94dee6 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:42:25 -0600 Subject: [PATCH 37/79] Preserve alternate run language --- ROADMAP.md | 2 ++ crates/format/src/tests.rs | 25 +++++++++++++++++++++++++ crates/model/src/text.rs | 24 ++++++++++++++++++++++++ crates/pptx/src/read/dml.rs | 1 + crates/pptx/src/write/dml.rs | 1 + crates/pptx/tests/it/roundtrip.rs | 26 ++++++++++++++++++++++++++ docs/file-format-parity.md | 5 +++-- docs/gaps.md | 5 +++-- 8 files changed, 85 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b653af403..512244d9a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -149,6 +149,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-11-02 | Text runs preserve alternate-language metadata independently from the main language through native and PPTX saves | | 2026-11-02 | File › About replaces the unused Account page, with version, project links, contributor/model credits and appearance preferences | | 2026-11-02 | Tiled picture fills in groups: a tile (`a:blipFill` with `a:tile`) keeps its size, offset and anchor in slide points whatever the group's child-coordinate scale, uniform or stretched unevenly, nested or flipped (also a group's own tile fill passed to its members, and a picture shape's own tile, with its shadow and reflection), and tiles with `rotWithShape="0"` keep their size in points at any zoom, as PowerPoint draws them (measured on its own PNG export of a synthetic probe deck: every grouped case within ~3/255 of the ungrouped control's own difference from PowerPoint, which is ~13/255 from resampling). Before, a group whose child space was ~1800 times smaller than its box showed one flat texel of the texture (a Venn diagram with paper-textured circles and a hatched intersection) | | 2026-10-31 | No Fill shape interiors allow selection and editing of underlying objects, even when a quick style remains; hit-testing uses the renderer’s fill resolution. Outlines, rotation, text targets and actual fills keep their hit behaviour (#1621) | @@ -322,6 +323,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-11-02 | trivial | Progress log: run alternate-language metadata round-trips through native and PPTX formats; product parity scores unchanged | | 2026-11-02 | trivial | File › About replaces Account; product links, credits and appearance retained | | 2026-11-02 | trivial | Progress log: tiled picture fills keep their size in scaled groups. Scores and milestone names unchanged | | 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged | diff --git a/crates/format/src/tests.rs b/crates/format/src/tests.rs index dcd156367..5525d5a45 100644 --- a/crates/format/src/tests.rs +++ b/crates/format/src/tests.rs @@ -11,6 +11,31 @@ fn chart_sidecars_are_optional_for_older_native_files() { assert_eq!(serde_json::from_value::(json).expect("empty sidecar"), chart); } +#[test] +fn alternate_run_language_survives_two_native_saves_and_absence() { + use deckcraft_model::text::{Paragraph, Run, RunProps, TextBody}; + let mut p = defaults::new_presentation(None); + Arc::make_mut(&mut p.slides[0]).shapes[0].text = Some(TextBody { + paragraphs: vec![Paragraph { + runs: vec![ + Run::with("bonjour", RunProps { lang: Some("en-US".into()), alt_lang: Some("fr-FR".into()), ..Default::default() }), + Run::with(" plain", RunProps { lang: Some("en-US".into()), ..Default::default() }), + ], + ..Default::default() + }], + ..Default::default() + }); + let mut q = load(&save(&p).expect("save")).expect("load"); + for _ in 0..2 { + let runs = &q.slides[0].shapes[0].text.as_ref().expect("text").paragraphs[0].runs; + assert_eq!(runs[0].props.lang.as_deref(), Some("en-US")); + assert_eq!(runs[0].props.alt_lang.as_deref(), Some("fr-FR")); + assert_eq!(runs[1].props.lang.as_deref(), Some("en-US")); + assert_eq!(runs[1].props.alt_lang, None); + q = load(&save(&q).expect("save again")).expect("load again"); + } +} + #[test] fn native_save_keeps_chart_relationships_and_payloads() { use deckcraft_model::chart::{Chart, ChartKept, ChartPart, ChartRel, ChartType}; diff --git a/crates/model/src/text.rs b/crates/model/src/text.rs index ca992c38a..395526295 100644 --- a/crates/model/src/text.rs +++ b/crates/model/src/text.rs @@ -456,6 +456,8 @@ pub struct RunProps { /// Action on mouse over (Insert ▸ Action ▸ Mouse Over on selected text, `a:hlinkMouseOver`). pub hover: Option, pub lang: Option, + #[serde(deserialize_with = "bounded_language")] + pub alt_lang: Option, pub shadow: Option, /// `a:innerShdw` on the run. Outer shadow stays in `shadow`. pub inner_shadow: Option, @@ -467,6 +469,13 @@ pub struct RunProps { pub no_proof: Option, } +fn bounded_language<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + Option::::deserialize(deserializer).map(|lang| lang.map(|value| value.chars().take(256).collect())) +} + impl RunProps { pub fn inherit(&mut self, p: &RunProps) { macro_rules! take { @@ -493,6 +502,7 @@ impl RunProps { link, hover, lang, + alt_lang, shadow, inner_shadow, glow, @@ -695,6 +705,20 @@ mod tests { use super::*; use deckcraft_color::Rgba; + #[test] + fn alternate_language_inherits_and_native_input_is_bounded() { + let mut child = RunProps::default(); + child.inherit(&RunProps { lang: Some("en-US".into()), alt_lang: Some("fr-FR".into()), ..Default::default() }); + assert_eq!(child.lang.as_deref(), Some("en-US")); + assert_eq!(child.alt_lang.as_deref(), Some("fr-FR")); + + let oversized = "x".repeat(300); + let json = serde_json::json!({"altLang": oversized}); + let props: RunProps = serde_json::from_value(json).expect("run props"); + assert_eq!(props.alt_lang.as_deref().map(str::len), Some(256)); + assert_eq!(serde_json::from_value::(serde_json::json!({})).expect("old run props"), RunProps::default()); + } + #[test] fn explicit_bullet_follow_text_resets_block_inheritance() { let parent = ParaProps { diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index 300773a57..ebf73563e 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -553,6 +553,7 @@ fn typeface(e: &El) -> Option { pub fn rpr(imp: &mut Imp, part: &Part, r: &El) -> RunProps { let mut p = RunProps { lang: r.attr("lang").map(String::from), + alt_lang: r.attr("altLang").map(|lang| lang.chars().take(256).collect()), size: r.f64("sz").map(|s| (s / 100.0).clamp(1.0, 4000.0)), bold: r.bool("b"), italic: r.bool("i"), diff --git a/crates/pptx/src/write/dml.rs b/crates/pptx/src/write/dml.rs index 84b30c837..ec483341a 100644 --- a/crates/pptx/src/write/dml.rs +++ b/crates/pptx/src/write/dml.rs @@ -629,6 +629,7 @@ fn typeface(w: &mut W, tag: &str, f: &Option) { pub fn rpr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, r: &RunProps) { let a = A::new() .o("lang", r.lang.as_deref().filter(|l| !l.is_empty())) + .o("altLang", r.alt_lang.as_deref().filter(|l| !l.is_empty())) .o("sz", r.size.map(|s| (s.clamp(1.0, 4000.0) * 100.0).round() as i64)) .b("b", r.bold) .b("i", r.italic) diff --git a/crates/pptx/tests/it/roundtrip.rs b/crates/pptx/tests/it/roundtrip.rs index 1ed97f546..9251b325c 100644 --- a/crates/pptx/tests/it/roundtrip.rs +++ b/crates/pptx/tests/it/roundtrip.rs @@ -1527,6 +1527,32 @@ fn literal_line_ends_in_run_text_read_as_breaks_and_survive_save() { assert_eq!(runs_of(&round(&a), "one"), want, "save and reopen keep the runs"); } +#[test] +fn alternate_run_language_survives_pptx_saves_and_absence() { + let mut p = defaults::new_presentation(None); + let first = p.slides[0].id; + let slide = p.slide_mut(first).expect("slide"); + slide.shapes[0].text = Some(TextBody { + paragraphs: vec![Paragraph { + runs: vec![ + Run::with("bonjour", RunProps { lang: Some("en-US".into()), alt_lang: Some("fr-FR".into()), ..Default::default() }), + Run::with(" plain", RunProps { lang: Some("en-US".into()), ..Default::default() }), + ], + ..Default::default() + }], + ..Default::default() + }); + let mut q = round(&p); + for _ in 0..2 { + let runs = &q.slides[0].shapes[0].text.as_ref().expect("text").paragraphs[0].runs; + assert_eq!(runs[0].props.lang.as_deref(), Some("en-US")); + assert_eq!(runs[0].props.alt_lang.as_deref(), Some("fr-FR")); + assert_eq!(runs[1].props.lang.as_deref(), Some("en-US")); + assert_eq!(runs[1].props.alt_lang, None); + q = round(&q); + } +} + #[test] fn color_pulse_keeps_its_effect_colour_and_duration() { let mut p = defaults::new_presentation(None); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 329a69f89..da0c7778f 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: seven owned PPTX corpus files, embedded fonts, EMF/WMF clip regions, threaded comments (#70), chart trendlines and workbooks (#335), SmartArt written as a diagram, tags and custom XML kept, picture export DPI; duplicated rows and headers folded; previous: trivial, lines in scaled groups, #233) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-02 · **Change:** minor (run alternate-language metadata round-trips through native and PPTX formats; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: seven owned PPTX corpus files, embedded fonts, EMF/WMF clip regions, threaded comments (#70), chart trendlines and workbooks (#335), SmartArt written as a diagram, tags and custom XML kept, picture export DPI; duplicated rows and headers folded; previous: trivial, lines in scaled groups, #233) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every format PowerPoint reads or writes, DeckCraft's support, fidelity and how it is tested. Gaps are itemized in [gaps.md](gaps.md#file-formats). Dimension score: **~58% ready** @@ -82,7 +82,7 @@ the round trip even though DeckCraft doesn't model it. | Shapes: preset geometry (~180 presets), custom geometry, groups, connectors | yes | yes | Presets re-derived in `crates/geom/src/preset.rs` (and `preset/extra.rs`). Outlines, dashes, arrowheads and ink inside a group, and text, keep their size in points whatever the group's child-coordinate scale (#233). | | Fills: solid, gradient, picture (stretch/tile/crop), pattern, group | yes | yes | | | Lines: dash, compound, caps, joins, arrowheads | yes | yes | | -| Text: runs, paragraphs, bullets, numbering, levels, autofit, columns, vertical, RTL, script fonts, fields, hyperlinks | yes | yes | Round-trip tests incl. `justLow`, date fields and paragraph `defRPr` (the defaults a placeholder's runs inherit, including a link). A click or hover action keeps its sound, `endSnd`, and a custom show's "Show and return". Gradient and outline on a run, autofit, columns, vertical and RTL text, tab stops and line spacing round-trip (`corpus_style.rs`) | +| Text: runs, paragraphs, bullets, numbering, levels, autofit, columns, vertical, RTL, script fonts, fields, hyperlinks | yes | yes | Run `lang` and `altLang` are independently retained through native and PPTX saves; other round-trip tests cover `justLow`, date fields and paragraph `defRPr` (the defaults a placeholder's runs inherit, including a link). A click or hover action keeps its sound, `endSnd`, and a custom show's "Show and return". Gradient and outline on a run, autofit, columns, vertical and RTL text, tab stops and line spacing round-trip (`corpus_style.rs`) | | Effects: shadow, glow, soft edges, reflection | yes | yes | On a run, inner shadow, reflection and soft edges are kept along with the outer shadow and glow | | 3-D: `scene3d`, `sp3d` | kept | kept | A bevel preset with no `sp3d` of its own is written back on shapes and theme effect styles. Bevels, cameras, face lighting and extrusion sides draw approximately | | Text warp (`prstTxWarp`) | yes | yes | All 40 presets rendered (`crates/geom/src/warp.rs`); adjust values (`avLst`) round-trip | @@ -119,6 +119,7 @@ the round trip even though DeckCraft doesn't model it. | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | Text runs preserve alternate-language metadata in native and PPTX round trips | | 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged | | 2026-10-15 | trivial | Native `.deckcraft` files serialize a nonempty chart `kept` sidecar (relationships, part names, content types and bytes). Older files without it still load; empty sidecars are omitted. Headless PPTX → edit → native save/reopen → PPTX checks keep the workbook and drawing bytes; editing chart data still drops the stale workbook | | 2026-10-15 | trivial | PPTX interactive timing sequences keep later On Click steps and paragraph click builds (`trigger_steps.rs`); mixed starts and independent buttons survive import/edit/save/reopen | diff --git a/docs/gaps.md b/docs/gaps.md index 1fefa5d9d..2d4fdff7b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DeckCraft falls short of PowerPoint -> **Last reviewed:** 2026-10-30 · **Last updated:** 2026-11-01 · **Change:** trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-02 · **Change:** trivial (text run alternate-language metadata now survives native and PPTX saves; per-run language editing and proofing gaps remain; scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every known shortfall, one entry each, ranked by how much it stops someone who presents with PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in @@ -368,6 +368,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-11-02 | trivial | Run alternate-language metadata now survives native and PPTX saves; UI and proofing gaps remain; scores unchanged | | 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged | | 2026-10-30 | minor | Slide-show click targets use each shape’s own animation state; scale and rotation test added | | 2026-10-28 | trivial | Automatic lists now run independently of Replace text as you type; proofing gap description updated | @@ -427,7 +428,7 @@ Detail in [localization-parity.md](localization-parity.md). | 2026-10-13 | minor | AutoCorrect options: case rules, fractions, ellipsis, a larger built-in list and your own entries (`text.autocorrect`, Tools ▸ AutoCorrect) | | 2026-10-14 | trivial | Notes spelling menu, Review › Language with Do Not Check Spelling, repeated words | | 2026-10-14 | trivial | Spelling squiggles in table cells and notes, and on the web | -| 2026-10-14 | minor | Spelling squiggles while typing, Add to Dictionary, per-run languages and compounds | +| 2026-10-14 | minor | Spelling squiggles while typing, Add to Dictionary, per-run language editing and compounds; run language metadata now round-trips | | 2026-10-14 | minor | Spelling checks with installed Hunspell dictionaries and suggests corrections | | 2026-10-13 | minor | Animation preview plays the current slide on the editor canvas; the pane gap keeps the timeline, preview from the selected effect, and the remaining effects | | 2026-10-14 | minor | A two-finger twist rotates the selected shapes. Pinch-zoom is still open | From b61c5f11bdec184374c5f2232a938c6ec7a09965 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:45:51 -0600 Subject: [PATCH 38/79] D716: preserve reflection transforms on save --- ROADMAP.md | 1 + crates/engine/src/cmd/picture_style.rs | 9 ++++- crates/engine/src/cmd/shape/appearance.rs | 15 +++++-- crates/engine/src/tests.rs | 3 +- crates/format/src/lib.rs | 3 ++ crates/format/src/tests.rs | 48 +++++++++++++++++++++++ crates/model/src/style.rs | 33 +++++++++++++++- crates/pptx/src/read/dml.rs | 9 +++++ crates/pptx/src/write/dml.rs | 13 ++++-- crates/pptx/tests/it/corpus_style.rs | 2 +- crates/pptx/tests/it/corpus_themes.rs | 19 ++++++++- crates/render/src/tests_effect_holes.rs | 4 +- crates/render/src/tests_wild.rs | 4 +- docs/file-format-parity.md | 3 +- docs/gaps.md | 3 +- docs/parity-checklist.md | 3 +- 16 files changed, 152 insertions(+), 20 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b653af403..f894d6eec 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -150,6 +150,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| | 2026-11-02 | File › About replaces the unused Account page, with version, project links, contributor/model credits and appearance preferences | +| 2026-11-02 | PPTX reflection effects keep their authored start position, affine scale/skew, direction and fade, alignment, and rotation flag through native and PPTX saves | | 2026-11-02 | Tiled picture fills in groups: a tile (`a:blipFill` with `a:tile`) keeps its size, offset and anchor in slide points whatever the group's child-coordinate scale, uniform or stretched unevenly, nested or flipped (also a group's own tile fill passed to its members, and a picture shape's own tile, with its shadow and reflection), and tiles with `rotWithShape="0"` keep their size in points at any zoom, as PowerPoint draws them (measured on its own PNG export of a synthetic probe deck: every grouped case within ~3/255 of the ungrouped control's own difference from PowerPoint, which is ~13/255 from resampling). Before, a group whose child space was ~1800 times smaller than its box showed one flat texel of the texture (a Venn diagram with paper-textured circles and a hatched intersection) | | 2026-10-31 | No Fill shape interiors allow selection and editing of underlying objects, even when a quick style remains; hit-testing uses the renderer’s fill resolution. Outlines, rotation, text targets and actual fills keep their hit behaviour (#1621) | | 2026-10-30 | Print-preview tests use the URL emitted by egui and its exact file path; previews are explicitly removed before the macOS process exits | diff --git a/crates/engine/src/cmd/picture_style.rs b/crates/engine/src/cmd/picture_style.rs index 3a7572172..690191f71 100644 --- a/crates/engine/src/cmd/picture_style.rs +++ b/crates/engine/src/cmd/picture_style.rs @@ -135,7 +135,14 @@ pub fn apply(sh: &mut Shape, id: &str) -> bool { let e = Effects { outer_shadow: lk.shadow.map(shadow), soft_edge: lk.soft_edge, - reflection: lk.reflection.then_some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0 }), + reflection: lk.reflection.then_some(Reflection { + blur: 0.5, + start_alpha: 0.5, + end_alpha: 0.0, + end_pos: 0.35, + dist: 0.0, + ..Default::default() + }), bevel: lk.bevel.map(str::to_string), ..Default::default() }; diff --git a/crates/engine/src/cmd/shape/appearance.rs b/crates/engine/src/cmd/shape/appearance.rs index 24bfbabf8..4189deaa1 100644 --- a/crates/engine/src/cmd/shape/appearance.rs +++ b/crates/engine/src/cmd/shape/appearance.rs @@ -371,9 +371,9 @@ pub(super) fn effects(s: &mut Session, p: &Value) -> Result { let soft = p.get("softEdges").map(|v| v.as_f64().filter(|x| *x > 0.0).map(|x| x.min(100.0))); let refl = str_param(p, "reflection").map(|r| match r { "none" => None, - "half" => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.55, dist: 4.0 }), - "full" => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.9, dist: 4.0 }), - _ => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0 }), + "half" => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.55, dist: 4.0, ..Default::default() }), + "full" => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.9, dist: 4.0, ..Default::default() }), + _ => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0, ..Default::default() }), }); let three_d = p.get("threeD").filter(|v| v.is_object()); let bevel = str_param(p, "bevel").map(|b| if b == "none" { None } else { Some(b.to_string()) }); @@ -467,7 +467,14 @@ pub(super) fn effects(s: &mut Session, p: &Value) -> Result { e.glow = (g.radius > 0.0).then_some(g); } if let Some(v) = &refl_props { - let mut r = e.reflection.take().unwrap_or(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0 }); + let mut r = e.reflection.take().unwrap_or(Reflection { + blur: 0.5, + start_alpha: 0.5, + end_alpha: 0.0, + end_pos: 0.35, + dist: 0.0, + ..Default::default() + }); if let Some(t) = num(v, "transparency") { r.start_alpha = 1.0 - t.clamp(0.0, 1.0); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4e75e8b4c..e37f9d135 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5362,7 +5362,8 @@ fn opening_a_file_caps_absurd_effect_sizes() { e.soft_edge = Some(f64::NAN); let sw = e.outer_shadow.as_mut().unwrap(); (sw.blur, sw.dist, sw.sx) = (huge, huge, huge); - *e.reflection.as_mut().unwrap() = Reflection { blur: huge, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.5, dist: huge }; + *e.reflection.as_mut().unwrap() = + Reflection { blur: huge, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.5, dist: huge, ..Default::default() }; } let glow = Some(Glow { color: deckcraft_model::style::ColorRef::rgb(deckcraft_color::Rgba::BLACK), radius: huge }); let runs: Vec<&mut deckcraft_model::text::Run> = match &mut sh.kind { diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 7610398e6..1d8181cbb 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -453,6 +453,9 @@ fn sane_shadow(s: &mut deckcraft_model::style::Shadow) { fn sane_reflection(r: &mut deckcraft_model::style::Reflection) { r.blur = effect_size(r.blur, MAX_REFLECTION); r.dist = effect_size(r.dist, MAX_REFLECTION); + if r.align.as_deref().is_some_and(|v| !matches!(v, "tl" | "t" | "tr" | "l" | "ctr" | "r" | "bl" | "b" | "br")) { + r.align = None; + } } /// Run properties a list style hands down to the text that inherits it. diff --git a/crates/format/src/tests.rs b/crates/format/src/tests.rs index dcd156367..7a3ee2a29 100644 --- a/crates/format/src/tests.rs +++ b/crates/format/src/tests.rs @@ -208,6 +208,54 @@ fn a_file_saved_with_non_finite_numbers_reopens() { assert_eq!(load(&save(&back).unwrap()).unwrap(), back); } +#[test] +fn reflection_transform_survives_two_native_saves_and_absent_fields_stay_absent() { + use deckcraft_model::style::Reflection; + use deckcraft_model::{Effects, Shape}; + let mut p = Presentation::default(); + let reflection = Reflection { + blur: 1.0, + start_alpha: 0.5, + end_alpha: 0.0, + end_pos: 0.75, + dist: 2.0, + st_pos: Some(20_000), + fade_dir: Some(0), + sx: Some(75_000), + sy: Some(-50_000), + kx: Some(600_000), + align: Some("bl".into()), + rotate_with_shape: Some(false), + ..Default::default() + }; + let shape = Shape { effects: Some(Effects { reflection: Some(reflection.clone()), ..Default::default() }), ..Default::default() }; + Arc::make_mut(&mut p.slides[0]).shapes.push(shape); + let once = load(&save(&p).expect("save")).expect("load"); + let twice = load(&save(&once).expect("save again")).expect("load again"); + assert_eq!(twice.slides[0].shapes.last().and_then(|s| s.effects.as_ref()).and_then(|e| e.reflection.as_ref()), Some(&reflection)); + + let absent = Reflection { + blur: 1.0, + start_alpha: 1.0, + end_alpha: 0.0, + end_pos: 1.0, + dist: 0.0, + st_pos: None, + dir: None, + fade_dir: None, + sx: None, + sy: None, + kx: None, + ky: None, + align: None, + rotate_with_shape: None, + }; + let shape = Shape { effects: Some(Effects { reflection: Some(absent.clone()), ..Default::default() }), ..Default::default() }; + Arc::make_mut(&mut p.slides[0]).shapes = vec![shape]; + let reopened = load(&save(&p).expect("save absent values")).expect("load absent values"); + assert_eq!(reopened.slides[0].shapes.last().and_then(|s| s.effects.as_ref()).and_then(|e| e.reflection.as_ref()), Some(&absent)); +} + /// Files written before [`finite::Finite`] (plain serde_json: NaN as `null`) open too, /// including a `null` inside a tagged enum, where serde reports only the enclosing object. #[test] diff --git a/crates/model/src/style.rs b/crates/model/src/style.rs index 3b81d3ae5..565b21aa1 100644 --- a/crates/model/src/style.rs +++ b/crates/model/src/style.rs @@ -459,13 +459,44 @@ pub struct Glow { } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(default, rename_all = "camelCase")] pub struct Reflection { pub blur: f64, pub start_alpha: f64, pub end_alpha: f64, pub end_pos: f64, pub dist: f64, + /// DrawingML reflection transform values retain their native integer units. + pub st_pos: Option, + pub dir: Option, + pub fade_dir: Option, + pub sx: Option, + pub sy: Option, + pub kx: Option, + pub ky: Option, + pub align: Option, + pub rotate_with_shape: Option, +} + +impl Default for Reflection { + fn default() -> Self { + Self { + blur: 0.0, + start_alpha: 0.0, + end_alpha: 0.0, + end_pos: 0.0, + dist: 0.0, + st_pos: None, + dir: Some(5_400_000), + fade_dir: None, + sx: None, + sy: Some(-100_000), + kx: None, + ky: None, + align: Some("bl".into()), + rotate_with_shape: Some(false), + } + } } #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index 300773a57..4ee1dbe9f 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -322,6 +322,15 @@ pub fn effects(el: &El) -> Effects { end_alpha: pct(e, "endA").unwrap_or(0.0), end_pos: pct(e, "endPos").unwrap_or(1.0), dist: pt(e, "dist").unwrap_or(0.0), + st_pos: e.i64("stPos").and_then(|v| i32::try_from(v).ok()), + dir: e.i64("dir").and_then(|v| i32::try_from(v).ok()), + fade_dir: e.i64("fadeDir").and_then(|v| i32::try_from(v).ok()), + sx: e.i64("sx").and_then(|v| i32::try_from(v).ok()), + sy: e.i64("sy").and_then(|v| i32::try_from(v).ok()), + kx: e.i64("kx").and_then(|v| i32::try_from(v).ok()), + ky: e.i64("ky").and_then(|v| i32::try_from(v).ok()), + align: e.attr("algn").filter(|v| matches!(*v, "tl" | "t" | "tr" | "l" | "ctr" | "r" | "bl" | "b" | "br")).map(str::to_owned), + rotate_with_shape: e.bool("rotWithShape"), }) } _ => {} diff --git a/crates/pptx/src/write/dml.rs b/crates/pptx/src/write/dml.rs index 84b30c837..1af1d9627 100644 --- a/crates/pptx/src/write/dml.rs +++ b/crates/pptx/src/write/dml.rs @@ -357,10 +357,15 @@ pub fn effects(w: &mut W, fx: &Effects) { .a("endA", pct(r.end_alpha.clamp(0.0, 1.0))) .a("endPos", pct(r.end_pos.clamp(0.0, 1.0))) .a("dist", emu(r.dist).max(0)) - .a("dir", 5_400_000) - .a("sy", -100_000) - .a("algn", "bl") - .a("rotWithShape", "0"), + .o("stPos", r.st_pos) + .o("dir", r.dir) + .o("fadeDir", r.fade_dir) + .o("sx", r.sx) + .o("sy", r.sy) + .o("kx", r.kx) + .o("ky", r.ky) + .o("algn", r.align.as_deref()) + .b("rotWithShape", r.rotate_with_shape), ); } if let Some(r) = fx.soft_edge { diff --git a/crates/pptx/tests/it/corpus_style.rs b/crates/pptx/tests/it/corpus_style.rs index bc3ea2fa1..0a8cacb73 100644 --- a/crates/pptx/tests/it/corpus_style.rs +++ b/crates/pptx/tests/it/corpus_style.rs @@ -34,7 +34,7 @@ fn glow() -> Glow { } fn reflection() -> Reflection { - Reflection { blur: 2.0, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.75, dist: 6.0 } + Reflection { blur: 2.0, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.75, dist: 6.0, ..Default::default() } } fn gradient(angle: f64) -> Fill { diff --git a/crates/pptx/tests/it/corpus_themes.rs b/crates/pptx/tests/it/corpus_themes.rs index 744b27ba0..5c69c2b95 100644 --- a/crates/pptx/tests/it/corpus_themes.rs +++ b/crates/pptx/tests/it/corpus_themes.rs @@ -103,7 +103,7 @@ fn effects_and_links() -> Vec { "Reflected", "", 8636000, - r#""#, + r#""#, r#"Hover me"#, ); let slide = format!( @@ -134,6 +134,11 @@ fn effects_and_links_survive_a_round_trip() { assert_eq!(fx(get("Soft edged")).soft_edge, Some(5.0)); let r = fx(get("Reflected")).reflection.expect("reflection"); assert!((r.end_pos - 0.555).abs() < 1e-6 && (r.dist - 4.0).abs() < 1e-6, "{r:?}"); + assert_eq!( + (r.st_pos, r.dir, r.fade_dir, r.sx, r.sy, r.kx, r.ky), + (Some(20_000), Some(5_400_000), Some(0), Some(75_000), Some(-50_000), Some(600_000), Some(-300_000)) + ); + assert_eq!((r.align.as_deref(), r.rotate_with_shape), (Some("bl"), Some(false))); let shape = get("Shadowed"); assert!(matches!(shape.click.as_ref().map(|h| (&h.action, h.highlight_click)), Some((Action::EndShow, true)))); assert!( @@ -173,7 +178,17 @@ fn effects_and_links_survive_resave(a: &Presentation) { assert_eq!(ha, hb, "text hover of {n}"); } let out = deckcraft_pptx::export(&b).expect("export"); - assert!(part(&out, "ppt/slides/slide1.xml").contains("hlinkMouseOver")); + let xml = part(&out, "ppt/slides/slide1.xml"); + assert!(xml.contains("hlinkMouseOver")); + let c = deckcraft_pptx::import(&out).expect("second reopen"); + assert_eq!(fx(shapes_named(&b, 0)("Reflected")).reflection, fx(shapes_named(&c, 0)("Reflected")).reflection); + assert!( + xml.contains("stPos=\"20000\"") + && xml.contains("fadeDir=\"0\"") + && xml.contains("sx=\"75000\"") + && xml.contains("sy=\"-50000\"") + && xml.contains("kx=\"600000\"") + ); } /// Two masters with their own themes (colours, fonts) and layouts, slides on each. diff --git a/crates/render/src/tests_effect_holes.rs b/crates/render/src/tests_effect_holes.rs index fdddf60fd..61f182520 100644 --- a/crates/render/src/tests_effect_holes.rs +++ b/crates/render/src/tests_effect_holes.rs @@ -44,7 +44,9 @@ fn scene(shape: &str, effect: Effect) -> Presentation { }) } Effect::Glow => effects.glow = Some(Glow { color: ColorRef::rgb(Rgba::rgb(255, 0, 0)), radius: 8.0 }), - Effect::Reflection => effects.reflection = Some(Reflection { blur: 0.0, start_alpha: 1.0, end_alpha: 0.0, end_pos: 1.0, dist: 10.0 }), + Effect::Reflection => { + effects.reflection = Some(Reflection { blur: 0.0, start_alpha: 1.0, end_alpha: 0.0, end_pos: 1.0, dist: 10.0, ..Default::default() }) + } } let slide = std::sync::Arc::make_mut(&mut p.slides[0]); slide.background = Some(deckcraft_model::Background::Fill { fill: Fill::solid(ColorRef::rgb(Rgba::WHITE)) }); diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index 987781f15..fb25c7f0b 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -577,7 +577,7 @@ fn a_tiled_pictures_reflection_keeps_the_tile_size_inside_a_scaled_group() { let mut p = Presentation::default(); let media = p.add_media("t.png", "image/png", half_red_half_blue()).expect("media"); let mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 10.0, sy: 10.0, flip: "none".into(), align: "tl".into() }; - let rf = Reflection { blur: 0.0, start_alpha: 1.0, end_alpha: 0.0, end_pos: 0.5, dist: 0.0 }; + let rf = Reflection { blur: 0.0, start_alpha: 1.0, end_alpha: 0.0, end_pos: 0.5, dist: 0.0, ..Default::default() }; let img = in_a_scaled_group(kx, ky, p, |w, h| Shape { id: ShapeId(22), xfrm: Some(Xfrm::new(0.0, 0.0, w, h)), @@ -935,7 +935,7 @@ fn a_picture_reflects_its_pixels() { let mut p = picture_deck(None); let s = std::sync::Arc::make_mut(&mut p.slides[0]); if let Some(sh) = s.shapes.first_mut() { - let rf = deckcraft_model::style::Reflection { blur: 0.0, start_alpha: 1.0, end_alpha: 0.0, end_pos: 0.5, dist: 0.0 }; + let rf = deckcraft_model::style::Reflection { blur: 0.0, start_alpha: 1.0, end_alpha: 0.0, end_pos: 0.5, dist: 0.0, ..Default::default() }; sh.effects = Some(deckcraft_model::Effects { reflection: Some(rf), ..Default::default() }); } let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 329a69f89..0c8f5518b 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: seven owned PPTX corpus files, embedded fonts, EMF/WMF clip regions, threaded comments (#70), chart trendlines and workbooks (#335), SmartArt written as a diagram, tags and custom XML kept, picture export DPI; duplicated rows and headers folded; previous: trivial, lines in scaled groups, #233) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-02 · **Change:** trivial (PPTX reflection transforms and fade geometry survive resaves; renderer parity remains partial; previous: minor, native chart payloads stored in bounded ZIP members) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every format PowerPoint reads or writes, DeckCraft's support, fidelity and how it is tested. Gaps are itemized in [gaps.md](gaps.md#file-formats). Dimension score: **~58% ready** @@ -120,6 +120,7 @@ the round trip even though DeckCraft doesn't model it. | Date | Change | Summary | |---|---|---| | 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged | +| 2026-11-02 | trivial | Reflection start position, affine scale/skew, direction and fade, alignment and rotation attributes survive native and PPTX saves; rendering remains partial | | 2026-10-15 | trivial | Native `.deckcraft` files serialize a nonempty chart `kept` sidecar (relationships, part names, content types and bytes). Older files without it still load; empty sidecars are omitted. Headless PPTX → edit → native save/reopen → PPTX checks keep the workbook and drawing bytes; editing chart data still drops the stale workbook | | 2026-10-15 | trivial | PPTX interactive timing sequences keep later On Click steps and paragraph click builds (`trigger_steps.rs`); mixed starts and independent buttons survive import/edit/save/reopen | | 2026-10-15 | minor | A `.pptx` slide that can't be read is reported instead of dropped in silence: `file.open` and `file.openBytes` return `lostSlides`, the app shows a message and opens a repaired copy (Save asks for a new name), and `deckcraft-cli` warns on stderr (#203) | diff --git a/docs/gaps.md b/docs/gaps.md index 1fefa5d9d..140c8a89b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DeckCraft falls short of PowerPoint -> **Last reviewed:** 2026-10-30 · **Last updated:** 2026-11-01 · **Change:** trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-30 · **Last updated:** 2026-11-02 · **Change:** trivial (PPTX reflection transforms and fade geometry survive resaves; rendering remains partial) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every known shortfall, one entry each, ranked by how much it stops someone who presents with PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in @@ -368,6 +368,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-11-02 | trivial | PPTX reflection geometry is retained through native and PPTX resaves; no rendering parity claim | | 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged | | 2026-10-30 | minor | Slide-show click targets use each shape’s own animation state; scale and rotation test added | | 2026-10-28 | trivial | Automatic lists now run independently of Replace text as you type; proofing gap description updated | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 8af3745ea..cc5a2d7e0 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -2,7 +2,7 @@ > **Generated:** 2026-10-14 (bullets/numbering remain D after empty-line marker review; summary by `cargo xtask parity`; rows re-scored by the 2026-10-14 community PR wave, duplicate Thesaurus, AutoCorrect, Artistic effects and Corrections rows folded; Preview in editor re-scored 2026-10-13; earlier rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4; video/animated GIF row re-scored 2026-10-11, #171) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last updated:** 2026-11-02 · Tiled picture fills keep their size in scaled groups and upright tiles (#1693); Picture and texture fill row re-assessed: remains P, since only how tiles are sized changed. Scores unchanged. +> **Last updated:** 2026-11-02 · Reflection transforms and fade geometry now survive native and PPTX resaves; the row remains P because rendering is still partial. No score change. > > **Previously (2026-11-01):** Locked-shape and no-outline regressions added for #1621; fill row re-assessed: remains D; No Fill hit-testing now follows resolved appearance. Scores unchanged. @@ -242,5 +242,6 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Date | Change | Summary | |---|---|---| +| 2026-11-02 | trivial | Reflection transform/fade serialization retained; its P score stays because rendering remains partial | | 2026-11-02 | trivial | Tiled picture fills keep their size in scaled groups and upright tiles (#1693); Picture and texture fill row remains P and summary scores are unchanged | | 2026-10-31 | trivial | No Fill interior hit-testing follows resolved fill (#1621); fill row remains D and summary scores are unchanged | From 82ec8137dc39f29b0f51b18630b409d655066fce Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 07:39:27 -0500 Subject: [PATCH 39/79] Format: bound native load work for the manifest, failed media reads and layout checks (#1704) - presentation.json gets its own 256 MiB inflation limit instead of the 2 GiB per-entry allowance, and is parsed in place: only a file that needs its null-number repair is copied. - A media member that fails decoding or its CRC is charged for the bytes it inflated, so later members cannot reuse the shared media budget. - repair() and Presentation::validate() check slide layouts against a set built once instead of scanning every layout per slide. --- crates/format/src/chart_parts_tests.rs | 18 +++---- crates/format/src/lib.rs | 68 +++++++++++++++++++++----- crates/format/src/tests.rs | 59 ++++++++++++++++++++-- crates/model/src/lib.rs | 4 +- 4 files changed, 122 insertions(+), 27 deletions(-) diff --git a/crates/format/src/chart_parts_tests.rs b/crates/format/src/chart_parts_tests.rs index df74a183c..89b20e8b6 100644 --- a/crates/format/src/chart_parts_tests.rs +++ b/crates/format/src/chart_parts_tests.rs @@ -85,14 +85,14 @@ fn chart_parts_share_media_byte_and_item_budgets() { let mut p = fixture(vec![7; 600]); p.add_media("a.bin", "application/octet-stream", vec![8; 600]).unwrap(); let bytes = save(&p).unwrap(); - assert!(load_within(&bytes, Budget { total: 1200, items: 2 }).is_ok()); - assert!(load_within(&bytes, Budget { total: 1199, items: 2 }).is_err(), "chart payloads share the cumulative media budget"); - assert!(load_within(&bytes, Budget { total: 1200, items: 1 }).is_err(), "chart parts share the media item budget"); + assert!(load_within(&bytes, Budget::media(1200, 2)).is_ok()); + assert!(load_within(&bytes, Budget::media(1199, 2)).is_err(), "chart payloads share the cumulative media budget"); + assert!(load_within(&bytes, Budget::media(1200, 1)).is_err(), "chart parts share the media item budget"); let duplicated = rewrite_manifest(&bytes, |manifest| { let media = manifest["presentation"]["media"].as_array_mut().unwrap(); media.push(media[0].clone()); }); - assert!(load_within(&duplicated, Budget { total: 1200, items: 2 }).is_err(), "duplicate media IDs still count as listed items"); + assert!(load_within(&duplicated, Budget::media(1200, 2)).is_err(), "duplicate media IDs still count as listed items"); } #[test] @@ -104,9 +104,9 @@ fn legacy_inline_chart_bytes_are_budgeted_and_preserved() { part.remove("nativeEntry"); part.insert("bytes".into(), json!(vec![9_u8; 600])); }); - assert_eq!(load_within(&legacy, Budget { total: 1200, items: 2 }).unwrap().0, p); - assert!(load_within(&legacy, Budget { total: 1199, items: 2 }).is_err()); - assert!(load_within(&legacy, Budget { total: 1200, items: 1 }).is_err()); + assert_eq!(load_within(&legacy, Budget::media(1200, 2)).unwrap().0, p); + assert!(load_within(&legacy, Budget::media(1199, 2)).is_err()); + assert!(load_within(&legacy, Budget::media(1200, 1)).is_err()); } #[test] @@ -207,7 +207,7 @@ fn corrupt_chart_members_stop_before_reusing_the_inflation_budget() { } // A failed CRC may already have inflated bytes. Continuing with the same remaining // budget would let each corrupt member consume that work budget again. - let result = load_within(&bytes, Budget { total: 10, items: 2 }); + let result = load_within(&bytes, Budget::media(10, 2)); assert!(result.is_err(), "two corrupt 10-byte members must not reuse a 10-byte budget"); assert!(result.unwrap_err().to_string().contains("chart-parts/0")); } @@ -228,7 +228,7 @@ fn duplicate_physical_zip_names_are_indexed_and_inflated_once() { assert_eq!(replaced, 2); let z = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap(); assert_eq!(z.file_names().filter(|n| n.starts_with("chart-parts/")).count(), 1); - let (q, damaged) = load_within(&bytes, Budget { total: 10, items: 2 }).unwrap(); + let (q, damaged) = load_within(&bytes, Budget::media(10, 2)).unwrap(); assert_eq!(damaged.len(), 1, "the second reference is missing, not inflated again"); let ShapeKind::Group { children, .. } = &q.slides[0].shapes[0].kind else { panic!("group") }; let ShapeKind::Chart(chart) = &children[0].kind else { panic!("chart") }; diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 7610398e6..eabe30345 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -29,12 +29,24 @@ const MAX_ENTRY: u64 = 2 << 30; const MAX_TOTAL_MEDIA: u64 = 4 << 30; /// Media items and chart sidecar parts one file may list. const MAX_MEDIA_ITEMS: usize = 100_000; +/// Largest `presentation.json` we will inflate. The manifest holds no media bytes, so it is far +/// smaller than a media entry may be; inflating and parsing one is what dominates memory. +const MAX_MANIFEST: u64 = 256 << 20; /// What one load may inflate. #[derive(Clone, Copy)] struct Budget { total: u64, items: usize, + manifest: u64, +} + +#[cfg(test)] +impl Budget { + /// A media budget with the default manifest limit. + fn media(total: u64, items: usize) -> Self { + Budget { total, items, manifest: MAX_MANIFEST } + } } #[derive(Debug, thiserror::Error)] @@ -107,6 +119,7 @@ pub fn sniff(bytes: &[u8]) -> bool { bytes.starts_with(b"PK") && [MIMETYPE, LEGACY_MIMETYPE].iter().any(|m| bytes.windows(m.len()).take(200).any(|w| w == m.as_bytes())) } +#[cfg(test)] fn read_entry(z: &mut zip::ZipArchive>, name: &str) -> Result> { read_entry_max(z, name, MAX_ENTRY) } @@ -114,13 +127,30 @@ fn read_entry(z: &mut zip::ZipArchive>, name: &str) -> Result>, name: &str, max: u64) -> Result> { + read_entry_counted(z, name, max, &mut 0) +} + +/// [`read_entry_max`] within `left` (and [`MAX_ENTRY`]), charging `left` for every byte inflated, +/// including those inflated before a decode or CRC error, so a member that fails late still +/// spends the shared budget it used. +fn read_entry_charged(z: &mut zip::ZipArchive>, name: &str, left: &mut u64) -> Result> { + let mut spent = 0; + let out = read_entry_counted(z, name, (*left).min(MAX_ENTRY), &mut spent); + *left = left.saturating_sub(spent); + out +} + +/// [`read_entry_max`], adding the bytes inflated to `spent` whether or not the read succeeds. +fn read_entry_counted(z: &mut zip::ZipArchive>, name: &str, max: u64, spent: &mut u64) -> Result> { let f = z.by_name(name).map_err(|e| FormatError::Damaged(format!("{name}: {e}")))?; if f.size() > max { return Err(FormatError::Damaged(format!("{name} is too large"))); } let mut out = Vec::with_capacity(f.size().min(64 << 20) as usize); let mut f = f.take(max.saturating_add(1)); - f.read_to_end(&mut out).map_err(|e| FormatError::Damaged(format!("{name}: {e}")))?; + let read = f.read_to_end(&mut out); + *spent = spent.saturating_add(out.len() as u64); + read.map_err(|e| FormatError::Damaged(format!("{name}: {e}")))?; if out.len() as u64 > max { return Err(FormatError::Damaged(format!("{name} is too large"))); } @@ -137,12 +167,15 @@ pub fn load(bytes: &[u8]) -> Result { /// A chart member that is present but fails decoding, CRC or its entry limit refuses the file. /// Linked media saved without its bytes isn't damage. pub fn load_checked(bytes: &[u8]) -> Result<(Presentation, Vec)> { - load_within(bytes, Budget { total: MAX_TOTAL_MEDIA, items: MAX_MEDIA_ITEMS }) + load_within(bytes, Budget { total: MAX_TOTAL_MEDIA, items: MAX_MEDIA_ITEMS, manifest: MAX_MANIFEST }) } fn load_within(bytes: &[u8], budget: Budget) -> Result<(Presentation, Vec)> { let mut z = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| FormatError::NotOurs(e.to_string()))?; - let json = read_entry(&mut z, "presentation.json").map_err(|_| FormatError::NotOurs("no presentation.json".into()))?; + if z.index_for_name("presentation.json").is_none() { + return Err(FormatError::NotOurs("no presentation.json".into())); + } + let json = read_entry_max(&mut z, "presentation.json", budget.manifest)?; let m = parse_manifest(&json)?; if m.format != "deckcraft" { return Err(FormatError::NotOurs(m.format)); @@ -164,11 +197,9 @@ fn load_within(bytes: &[u8], budget: Budget) -> Result<(Presentation, Vec left) { return Err(FormatError::Damaged("embedded media is larger than DeckCraft can open".into())); } - match read_entry_max(&mut z, &name, left.min(MAX_ENTRY)) { - Ok(data) => { - left -= data.len() as u64; - item.data = Arc::new(data); - } + // A failed read is charged for what it inflated, so later members can't reuse that budget. + match read_entry_charged(&mut z, &name, &mut left) { + Ok(data) => item.data = Arc::new(data), Err(_) if linked_only => {} Err(e) => damaged.push(e.to_string()), } @@ -188,9 +219,11 @@ pub fn repair(p: &mut Presentation) { } p.fix_next_id(); let first_layout = p.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id).unwrap_or_default(); + // Indexed once: a per-slide scan of every layout is slides × layouts work. + let layouts: std::collections::HashSet<_> = p.masters.iter().flat_map(|m| m.layouts.iter().map(|l| l.id)).collect(); let mut seen = std::collections::HashSet::new(); for i in 0..p.slides.len() { - let needs_layout = p.slides.get(i).is_some_and(|s| p.layout(s.layout).is_none()); + let needs_layout = p.slides.get(i).is_some_and(|s| !layouts.contains(&s.layout)); let dup = p.slides.get(i).is_some_and(|s| !seen.insert(s.id)); if needs_layout || dup { // An exhausted id space leaves a duplicate slide as it is: the engine refuses to @@ -331,16 +364,20 @@ fn failure(src: &[u8]) -> std::result::Result Result { - let mut src = json.to_vec(); - let mut last = match failure(&src) { + // Parse the inflated text as it is: only a file that fails on a `null` number is copied for + // patching (the copy is bounded by `MAX_MANIFEST`). + let mut last = match failure(json) { Ok(m) => return Ok(m), Err(f) => f, }; + if !is_null_number(&last.0) { + return Err(FormatError::Damaged(last.0)); + } + let mut src = json.to_vec(); let mut budget = MAX_REPARSE_BYTES; 'fixes: for _ in 0..MAX_NULL_FIXES { let (msg, pos) = &last; - let number = msg.starts_with("invalid type: null, expected f") || msg.starts_with("invalid type: null, expected a number"); - if !number { + if !is_null_number(msg) { break; } let end = offset(&src, pos.0, pos.1).unwrap_or(src.len()); @@ -374,6 +411,11 @@ fn parse_manifest(json: &[u8]) -> Result { Err(FormatError::Damaged(last.0)) } +/// Is this parse error a `null` where a number belongs (a NaN or infinity serde_json wrote)? +fn is_null_number(msg: &str) -> bool { + msg.starts_with("invalid type: null, expected f") || msg.starts_with("invalid type: null, expected a number") +} + /// Byte offset of a 1-based `line`:`column`. fn offset(src: &[u8], line: usize, column: usize) -> Option { let mut start = 0; diff --git a/crates/format/src/tests.rs b/crates/format/src/tests.rs index dcd156367..46c7f7e96 100644 --- a/crates/format/src/tests.rs +++ b/crates/format/src/tests.rs @@ -93,9 +93,60 @@ fn media_budget_is_cumulative() { p.add_media("b.bin", "application/octet-stream", vec![2; 600]); let bytes = save(&p).unwrap(); // Exactly at the limit opens; one byte under is refused, though each item fits. - assert!(load_within(&bytes, Budget { total: 1200, items: 2 }).is_ok()); - assert!(load_within(&bytes, Budget { total: 1199, items: 2 }).is_err()); - assert!(load_within(&bytes, Budget { total: 1200, items: 1 }).is_err()); + assert!(load_within(&bytes, Budget::media(1200, 2)).is_ok()); + assert!(load_within(&bytes, Budget::media(1199, 2)).is_err()); + assert!(load_within(&bytes, Budget::media(1200, 1)).is_err()); +} + +#[test] +fn corrupt_media_is_charged_for_what_it_inflated() { + let mut p = Presentation::default(); + p.add_media("a.png", "image/png", b"PICTURE-AAAAAAAAAAAA".to_vec()); + p.add_media("b.png", "image/png", b"PICTURE-BBBBBBBBBBBB".to_vec()); + let mut bytes = save(&p).unwrap(); + // Corrupt both stored members, so each fails its CRC only after inflating all its bytes. + for tag in [b"PICTURE-AAAA", b"PICTURE-BBBB"] { + let at = bytes.windows(tag.len()).position(|w| w == tag).unwrap(); + bytes[at] ^= 0xff; + } + // Each member fits the budget on its own; the second must not reuse what the first spent. + let result = load_within(&bytes, Budget::media(20, 2)); + assert!(result.is_err(), "two corrupt 20-byte members must not share a 20-byte budget"); + // With room for both, the file opens and reports both. + let (_, damaged) = load_within(&bytes, Budget::media(40, 2)).unwrap(); + assert_eq!(damaged.len(), 2, "{damaged:?}"); +} + +#[test] +fn oversized_manifest_is_refused_before_inflating() { + let bytes = save(&Presentation::default()).unwrap(); + let mut z = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap(); + let size = z.by_name("presentation.json").unwrap().size(); + assert!(load_within(&bytes, Budget { total: 0, items: 0, manifest: size }).is_ok()); + let err = load_within(&bytes, Budget { total: 0, items: 0, manifest: size - 1 }).unwrap_err(); + assert!(err.to_string().contains("too large"), "{err}"); +} + +#[test] +fn many_slides_on_missing_layouts_are_repaired() { + let mut p = Presentation::default(); + let template = p.slides[0].clone(); + let good = template.layout; + p.slides = (0..2000u32) + .map(|i| { + let mut s = (*template).clone(); + s.id = deckcraft_model::SlideId(10_000 + i); + if i % 2 == 1 { + s.layout = deckcraft_model::LayoutId(900_000 + i); + } + Arc::new(s) + }) + .collect(); + p.next_id = 20_000; + repair(&mut p); + assert!(p.validate().is_empty(), "{:?}", p.validate().first()); + assert!(p.slides.iter().all(|s| p.layout(s.layout).is_some())); + assert_eq!(p.slides[0].layout, good); } #[test] @@ -124,7 +175,7 @@ fn repeated_media_ids_are_read_once() { } let bytes = out.finish().unwrap().into_inner(); // 51 references to one 1000-byte member fit a 1000-byte budget: it's inflated once. - let q = load_within(&bytes, Budget { total: 1000, items: 51 }).unwrap().0; + let q = load_within(&bytes, Budget::media(1000, 51)).unwrap().0; assert_eq!(q.media.len(), 1); assert_eq!(q.media[0].data.len(), 1000); // And the deck saves again. diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 1289e47ab..8547bc60c 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -1112,11 +1112,13 @@ impl Presentation { pub fn validate(&self) -> Vec { let mut out = vec![]; let mut seen = std::collections::HashSet::new(); + // Indexed once: a per-slide scan of every layout is slides × layouts work. + let layouts: std::collections::HashSet<_> = self.masters.iter().flat_map(|m| m.layouts.iter().map(|l| l.id)).collect(); for s in &self.slides { if !seen.insert(s.id) { out.push(format!("duplicate slide id {}", s.id)); } - if self.layout(s.layout).is_none() { + if !layouts.contains(&s.layout) { out.push(format!("slide {} uses missing layout {}", s.id, s.layout)); } let mut ids = std::collections::HashSet::new(); From 751ac26d280f59eaf6be64690465eb562b141f7b Mon Sep 17 00:00:00 2001 From: Naseq Date: Tue, 3 Nov 2026 17:57:57 +0430 Subject: [PATCH 40/79] fix(pptx): place effects by their parent time node delays (fixes #1744) --- crates/pptx/src/read/timing.rs | 115 +++++++++++++++++++++-- crates/pptx/tests/it/main.rs | 1 + crates/pptx/tests/it/parent_delays.rs | 128 ++++++++++++++++++++++++++ 3 files changed, 238 insertions(+), 6 deletions(-) create mode 100644 crates/pptx/tests/it/parent_delays.rs diff --git a/crates/pptx/src/read/timing.rs b/crates/pptx/src/read/timing.rs index 9c748328c..f3166d622 100644 --- a/crates/pptx/src/read/timing.rs +++ b/crates/pptx/src/read/timing.rs @@ -53,25 +53,125 @@ pub fn transition(imp: &mut Imp, part: &Part, t: &El) -> Option { struct Found<'a> { ctn: &'a El, trigger: Option, + /// Click group the effect sits in: a new one starts at every time node whose start is `indefinite`. + group: usize, + /// Sum of the start delays of the time nodes between the click group and the effect, in ms. + offset: u32, } -fn collect<'a>(e: &'a El, trigger: Option, out: &mut Vec>, depth: usize) { +/// Where `collect` is in the time tree: the click group and the delay accumulated inside it. +#[derive(Clone, Copy)] +struct At { + trigger: Option, + group: usize, + offset: u32, +} + +fn collect<'a>(e: &'a El, at: At, groups: &mut usize, out: &mut Vec>, depth: usize) { if depth > 64 || out.len() > 10_000 { return; } for c in e.elements() { if c.is("cTn") && c.has_attr("presetClass") { - out.push(Found { ctn: c, trigger }); + out.push(Found { ctn: c, trigger: at.trigger, group: at.group, offset: at.offset }); continue; } - let mut trig = trigger; + let mut at = at; if c.is("seq") && let Some(ctn) = c.child("cTn") && ctn.attr("nodeType") == Some("interactiveSeq") { - trig = ctn.path(&["stCondLst", "cond", "tgtEl", "spTgt"]).and_then(|s| s.u32("spid")); + at.trigger = ctn.path(&["stCondLst", "cond", "tgtEl", "spTgt"]).and_then(|s| s.u32("spid")); + } + if c.is("cTn") + && let Some(cond) = c.path(&["stCondLst", "cond"]) + { + if cond.attr("delay").map(str::trim) == Some("indefinite") { + *groups += 1; + at.group = *groups; + at.offset = 0; + } else { + at.offset = at.offset.saturating_add(ms_attr(cond, "delay").unwrap_or(0)); + } + } + collect(c, at, groups, out, depth + 1); + } +} + +/// How long an effect holds back an After Previous one, as the show timeline counts it. +fn span_ms(a: &Animation) -> u32 { + let iterations = if a.repeat == u32::MAX || a.repeat <= 1 { 1 } else { a.repeat.min(1_000) }; + a.duration_ms.saturating_mul(if a.auto_reverse { 2 } else { 1 }).saturating_mul(iterations) +} + +/// Start times (ms after the click) that the flat effect list plays at, effect after effect. +fn flat_starts(anims: &[&Animation]) -> Vec { + let (mut begin, mut end) = (0u32, 0u32); + anims + .iter() + .map(|a| { + if a.start == AnimStart::AfterPrevious { + begin = end; + } + let start = begin.saturating_add(a.delay_ms); + end = end.max(start.saturating_add(span_ms(a))); + start + }) + .collect() +} + +/// PowerPoint places each effect at the delays of all its enclosing time nodes, so a file may list +/// effects out of time order (a countdown written first, then effects at 5 s placed only by their +/// parent's delay). The flat list only knows "after/with previous" plus the effect's own delay, so +/// a click group listed out of time order, whose flat reading disagrees with the file, is sorted by +/// start time and its starts and delays rewritten to land every effect where PowerPoint plays it. +/// Every other group is left untouched. +fn place_by_parent_delays(out: &mut [(Animation, u32, String, bool)], at: &[(Option, usize, u32)]) { + let mut groups: Vec> = vec![]; + let mut slot: std::collections::HashMap<(Option, usize), usize> = std::collections::HashMap::new(); + for (i, &(t, g, _)) in at.iter().enumerate() { + let n = *slot.entry((t, g)).or_insert_with(|| { + groups.push(vec![]); + groups.len() - 1 + }); + groups[n].push(i); + } + for idx in groups { + // More than one click in a group means the tree isn't one click per group: leave it. + if idx.iter().skip(1).any(|&i| out[i].0.start == AnimStart::OnClick) { + continue; + } + let abs: Vec = idx.iter().map(|&i| at[i].2.saturating_add(out[i].0.delay_ms)).collect(); + // Only a file that lists effects out of time order needs this: files PowerPoint or DeckCraft + // save are in order, and their reading stays exactly as before. + if abs.is_sorted() { + continue; + } + let flat = flat_starts(&idx.iter().map(|&i| &out[i].0).collect::>()); + if flat == abs { + continue; + } + let mut order: Vec = (0..idx.len()).collect(); + order.sort_by_key(|&k| abs[k]); + let mut sorted: Vec<(Animation, u32, String, bool)> = order.iter().map(|&k| out[idx[k]].clone()).collect(); + let (mut begin, mut end) = (0u32, 0u32); + for (n, (&k, item)) in order.iter().zip(sorted.iter_mut()).enumerate() { + let a = &mut item.0; + let t = abs[k]; + if n == 0 && a.start == AnimStart::OnClick { + a.delay_ms = t; + } else if a.start == AnimStart::AfterPrevious && t >= end { + begin = end; + a.delay_ms = t - end; + } else { + a.start = AnimStart::WithPrevious; + a.delay_ms = t.saturating_sub(begin); + } + end = end.max(t.saturating_add(span_ms(a))); + } + for (&i, item) in idx.iter().zip(sorted) { + out[i] = item; } - collect(c, trig, out, depth + 1); } } @@ -135,7 +235,7 @@ pub fn animations(imp: &mut Imp, part: &Part, ctx: &IdCtx, timing: &El, shapes: let _ = (imp, part); let Some(tn) = timing.child("tnLst") else { return vec![] }; let mut found = vec![]; - collect(tn, None, &mut found, 0); + collect(tn, At { trigger: None, group: 0, offset: 0 }, &mut 0, &mut found, 0); // Paragraph builds (`bldP build="p"`) by (spid, grpId). let by_para: Vec<(u32, String)> = timing .child("bldLst") @@ -147,6 +247,7 @@ pub fn animations(imp: &mut Imp, part: &Part, ctx: &IdCtx, timing: &El, shapes: }) .unwrap_or_default(); let mut out: Vec<(Animation, u32, String, bool)> = vec![]; + let mut at: Vec<(Option, usize, u32)> = vec![]; for f in found { let c = f.ctn; let class = AnimClass::from_xml(c.attr("presetClass").unwrap_or("entr")); @@ -212,7 +313,9 @@ pub fn animations(imp: &mut Imp, part: &Part, ctx: &IdCtx, timing: &El, shapes: let grp = c.attr("grpId").unwrap_or("0").to_string(); let build = paragraph.is_some() && by_para.iter().any(|(s, g)| *s == file_id && *g == grp); out.push((a, file_id, grp, build)); + at.push((f.trigger, f.group, f.offset)); } + place_by_parent_delays(&mut out, &at); // A paragraph build's effects, one per paragraph, become one by-paragraph animation only when // the timeline plays that back exactly: PowerPoint lets each paragraph keep its own start and // effect, and other shapes' effects can sit between them (#844). diff --git a/crates/pptx/tests/it/main.rs b/crates/pptx/tests/it/main.rs index df7065cb8..71a8d377b 100644 --- a/crates/pptx/tests/it/main.rs +++ b/crates/pptx/tests/it/main.rs @@ -18,6 +18,7 @@ mod media_timing; mod notes; mod ole; mod ole_vml; +mod parent_delays; mod placeholders; mod roundtrip; mod sample; diff --git a/crates/pptx/tests/it/parent_delays.rs b/crates/pptx/tests/it/parent_delays.rs new file mode 100644 index 000000000..9dcaa8518 --- /dev/null +++ b/crates/pptx/tests/it/parent_delays.rs @@ -0,0 +1,128 @@ +//! PowerPoint places an effect at the sum of its enclosing time nodes' delays, so a file can list +//! effects out of time order. The BNI meeting-deck generator writes a 1 s countdown chain first, +//! then "swap to the visual at 5 s" effects placed only by their parent `p:par` delay: read in file +//! order, those all fired together when the countdown ended. + +use std::io::{Cursor, Read, Write}; + +use deckcraft_geom::Xfrm; +use deckcraft_model::anim::AnimStart; +use deckcraft_model::{Shape, ShapeId, defaults}; + +/// An "appear"/"disappear" effect (preset 1) in its own `p:par`, as PowerPoint writes it. +fn eff(id: u32, class: &str, node: &str, delay: u32, spid: u32) -> String { + let vis = if class == "entr" { "visible" } else { "hidden" }; + format!( + r#"style.visibility"#, + id + 1000 + ) +} + +/// A group `p:par` starting `delay` ms into the click, holding `inner`. +fn group(id: u32, delay: u32, inner: &str) -> String { + format!( + r#"{inner}"# + ) +} + +fn import_with_timing(build: impl Fn(&[u32]) -> String) -> Vec<(String, AnimStart, u32)> { + let mut p = defaults::new_presentation(None); + let shapes: Vec = ["Three", "Two", "One", "Visual"] + .into_iter() + .map(|name| Shape { + id: ShapeId(p.alloc_id().expect("id")), + name: name.into(), + xfrm: Some(Xfrm::new(10.0, 10.0, 100.0, 50.0)), + ..Default::default() + }) + .collect(); + std::sync::Arc::make_mut(&mut p.slides[0]).shapes = shapes; + let mut z = zip::ZipArchive::new(Cursor::new(deckcraft_pptx::export(&p).expect("export"))).expect("zip"); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + for i in 0..z.len() { + let mut f = z.by_index(i).expect("entry"); + let name = f.name().to_string(); + let mut d = vec![]; + f.read_to_end(&mut d).expect("read"); + if name == "ppt/slides/slide1.xml" { + let xml = String::from_utf8(d).expect("utf8"); + let spids: Vec = ["Three", "Two", "One", "Visual"] + .iter() + .map(|n| { + let at = xml.find(&format!(r#"name="{n}""#)).expect("shape written"); + let open = xml[..at].rfind("{}"#, + build(&spids) + ); + let xml = match (xml.find(""), xml.find("")) { + (Some(a), Some(b)) => format!("{}{}{}", &xml[..a], timing, &xml[b + "".len()..]), + _ => xml.replacen("", &format!("{timing}"), 1), + }; + d = xml.into_bytes(); + } + out.start_file(name, zip::write::SimpleFileOptions::default()).expect("start"); + out.write_all(&d).expect("write"); + } + let q = deckcraft_pptx::import(&out.finish().expect("finish").into_inner()).expect("import"); + let s = &q.slides[0]; + s.animations + .iter() + .map(|a| { + let name = s.shapes.iter().find(|x| x.id == a.shape).map(|x| x.name.clone()).unwrap_or_default(); + (name, a.start, a.delay_ms) + }) + .collect() +} + +/// Start (ms after the click) of each effect when the flat list is played effect after effect. +fn flat(effects: &[(String, AnimStart, u32)]) -> Vec<(String, u32)> { + let (mut begin, mut end) = (0u32, 0u32); + effects + .iter() + .map(|(n, start, delay)| { + if *start == AnimStart::AfterPrevious { + begin = end; + } + let t = begin + delay; + end = end.max(t); + (n.clone(), t) + }) + .collect() +} + +#[test] +fn effects_placed_by_parent_delay_play_when_powerpoint_plays_them() { + // Click: "Three" goes, then "Two" after 1 s, "One" after 1 s more (a countdown chain), then — + // written last — the visual appears at 1 s, placed only by its parent's delay. + let fx = import_with_timing(|s| { + [ + group(10, 0, &eff(11, "exit", "clickEffect", 0, s[0])), + group(20, 0, &eff(21, "exit", "afterEffect", 1000, s[1])), + group(30, 1000, &eff(31, "exit", "afterEffect", 1000, s[2])), + group(40, 1000, &eff(41, "entr", "afterEffect", 0, s[3])), + ] + .concat() + }); + let got = flat(&fx); + let at = |n: &str| got.iter().find(|(x, _)| x == n).map(|(_, t)| *t); + assert_eq!((at("Three"), at("Two"), at("Visual"), at("One")), (Some(0), Some(1000), Some(1000), Some(2000)), "{fx:?}"); +} + +#[test] +fn a_timeline_already_in_order_is_read_unchanged() { + let fx = import_with_timing(|s| { + [ + group(10, 0, &[eff(11, "exit", "clickEffect", 0, s[0]), eff(12, "entr", "withEffect", 500, s[3])].concat()), + group(20, 500, &eff(21, "exit", "afterEffect", 1000, s[1])), + ] + .concat() + }); + let starts: Vec<_> = fx.iter().map(|(n, st, d)| (n.as_str(), *st, *d)).collect(); + assert_eq!(starts, [("Three", AnimStart::OnClick, 0), ("Visual", AnimStart::WithPrevious, 500), ("Two", AnimStart::AfterPrevious, 1000)]); +} From 4df4ea6c9f367845b18283a413d8cddad1b67c23 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 3 Nov 2026 08:41:57 -0500 Subject: [PATCH 41/79] Text: Thai distributed paragraphs still stretch like justified ones --- crates/text/src/layout.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 87b89194e..c52b840a2 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -418,7 +418,7 @@ fn justify_line(p: &ParaShaped, rl: &RawLine, adv: &mut [f64], align: Align, sla filled = true; } } - if stretch && !filled && matches!(align, Align::Justify | Align::JustLow) { + if stretch && !filled && matches!(align, Align::Justify | Align::JustLow | Align::ThaiDistributed) { let spaces = line_cells.iter().filter(|c| c.ch == ' ').count(); if spaces > 0 { for (a, c) in adv.iter_mut().zip(line_cells) { From 67c1d78948f3fe1322f7df25305fa139eb0a94e1 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 3 Nov 2026 08:47:09 -0500 Subject: [PATCH 42/79] PPTX: horzOverflow never reads or writes ellipsis (vertical only in ST_TextHorzOverflowType) --- crates/model/src/text.rs | 10 ++++++++++ crates/pptx/src/read/dml.rs | 2 +- crates/pptx/src/read/shapes.rs | 2 +- crates/pptx/src/write/dml.rs | 2 +- crates/pptx/src/write/shapes.rs | 2 +- crates/pptx/tests/it/roundtrip.rs | 23 +++++++++++++++++++++-- 6 files changed, 35 insertions(+), 6 deletions(-) diff --git a/crates/model/src/text.rs b/crates/model/src/text.rs index 1839ef30a..d53d0472b 100644 --- a/crates/model/src/text.rs +++ b/crates/model/src/text.rs @@ -150,6 +150,16 @@ impl TextOverflow { _ => return None, }) } + + /// A `horzOverflow` value: `ST_TextHorzOverflowType` has only `overflow` and `clip`. + pub fn from_horz_xml(value: &str) -> Option { + Self::from_xml(value).filter(|v| *v != Self::Ellipsis) + } + + /// The `horzOverflow` attribute to write, leaving out `ellipsis`, which is vertical only. + pub fn horz_xml(v: Option) -> Option<&'static str> { + v.filter(|v| *v != Self::Ellipsis).map(Self::xml) + } } impl BodyProps { diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index e24d7c94b..98e9d5c96 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -359,7 +359,7 @@ pub fn body_pr(b: &El) -> BodyProps { upright: b.bool("upright"), space_first_last: b.bool("spcFirstLastPara"), vert_overflow: b.attr("vertOverflow").and_then(deckcraft_model::text::TextOverflow::from_xml), - horz_overflow: b.attr("horzOverflow").and_then(deckcraft_model::text::TextOverflow::from_xml), + horz_overflow: b.attr("horzOverflow").and_then(deckcraft_model::text::TextOverflow::from_horz_xml), ..Default::default() }; for e in b.elements() { diff --git a/crates/pptx/src/read/shapes.rs b/crates/pptx/src/read/shapes.rs index f5497b8c3..864a3e029 100644 --- a/crates/pptx/src/read/shapes.rs +++ b/crates/pptx/src/read/shapes.rs @@ -805,7 +805,7 @@ fn cell(imp: &mut Imp, part: &Part, tc: &El) -> Cell { ]); } c.anchor = pr.attr("anchor").and_then(Anchor::from_xml); - c.horz_overflow = pr.attr("horzOverflow").and_then(deckcraft_model::text::TextOverflow::from_xml); + c.horz_overflow = pr.attr("horzOverflow").and_then(deckcraft_model::text::TextOverflow::from_horz_xml); c.vertical = pr.attr("vert").and_then(TextDir::from_xml); for (i, n) in ["lnL", "lnR", "lnT", "lnB"].iter().enumerate() { if let Some(ln) = pr.child(n) diff --git a/crates/pptx/src/write/dml.rs b/crates/pptx/src/write/dml.rs index e355601bf..ec6224d7a 100644 --- a/crates/pptx/src/write/dml.rs +++ b/crates/pptx/src/write/dml.rs @@ -470,7 +470,7 @@ pub fn body_pr(w: &mut W, b: &BodyProps) { .b("upright", b.upright) .b("spcFirstLastPara", b.space_first_last) .o("vertOverflow", b.vert_overflow.map(|v| v.xml())) - .o("horzOverflow", b.horz_overflow.map(|v| v.xml())); + .o("horzOverflow", deckcraft_model::text::TextOverflow::horz_xml(b.horz_overflow)); let warp = b.warp.as_deref().filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric())); if warp.is_none() && b.autofit.is_none() { w.empty("a:bodyPr", a); diff --git a/crates/pptx/src/write/shapes.rs b/crates/pptx/src/write/shapes.rs index f18bbf466..847e741dc 100644 --- a/crates/pptx/src/write/shapes.rs +++ b/crates/pptx/src/write/shapes.rs @@ -746,7 +746,7 @@ fn table(w: &mut W, x: &mut Exp, o: &mut Out, t: &Table) { other => other.xml(), }), ); - a = a.o("horzOverflow", c.horz_overflow.map(|v| v.xml())); + a = a.o("horzOverflow", deckcraft_model::text::TextOverflow::horz_xml(c.horz_overflow)); w.open("a:tcPr", a); for (tag, l) in [ ("a:lnL", &c.borders[0]), diff --git a/crates/pptx/tests/it/roundtrip.rs b/crates/pptx/tests/it/roundtrip.rs index 3832d2c6c..51670caa0 100644 --- a/crates/pptx/tests/it/roundtrip.rs +++ b/crates/pptx/tests/it/roundtrip.rs @@ -475,7 +475,7 @@ fn text_overflow_policies_survive_two_pptx_round_trips() { let mut text = shape(&mut p, "Text", 10.0, 10.0, 200.0, 80.0); let mut body = TextBody::from_text("policy"); body.body.vert_overflow = Some(TextOverflow::Clip); - body.body.horz_overflow = Some(TextOverflow::Ellipsis); + body.body.horz_overflow = Some(TextOverflow::Overflow); text.text = Some(body); let mut table_shape = shape(&mut p, "Table", 10.0, 100.0, 200.0, 80.0); let mut table = Table::new(1, 1, 200.0, 80.0); @@ -488,11 +488,30 @@ fn text_overflow_policies_survive_two_pptx_round_trips() { let slide = twice.slides.last().expect("added slide"); let text = by_name(slide, "Text").text.as_ref().expect("text body"); assert_eq!(text.body.vert_overflow, Some(TextOverflow::Clip)); - assert_eq!(text.body.horz_overflow, Some(TextOverflow::Ellipsis)); + assert_eq!(text.body.horz_overflow, Some(TextOverflow::Overflow)); let ShapeKind::Table(table) = &by_name(slide, "Table").kind else { panic!("table") }; assert_eq!(table.rows[0].cells[0].horz_overflow, Some(TextOverflow::Clip)); } +#[test] +fn horizontal_ellipsis_is_not_written_because_only_vertical_overflow_has_it() { + let mut p = Presentation::default(); + let mut slide = new_slide(&mut p, "Horizontal ellipsis"); + let mut text = shape(&mut p, "Text", 10.0, 10.0, 200.0, 80.0); + let mut body = TextBody::from_text("policy"); + body.body.vert_overflow = Some(TextOverflow::Ellipsis); + body.body.horz_overflow = Some(TextOverflow::Ellipsis); + text.text = Some(body); + slide.shapes = vec![text]; + p.slides.push(Arc::new(slide)); + + let once = round(&p); + let slide = once.slides.last().expect("added slide"); + let text = by_name(slide, "Text").text.as_ref().expect("text body"); + assert_eq!(text.body.vert_overflow, Some(TextOverflow::Ellipsis)); + assert_eq!(text.body.horz_overflow, None); +} + #[test] fn shapes_fills_lines_effects_round_trip() { let p = rich_deck(); From 6546136a4217e1b3ba67e28e7aaf03bc0f04db34 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 3 Nov 2026 08:49:18 -0500 Subject: [PATCH 43/79] PPTX timing: a sorted click group keeps its click on the effect that now plays first --- crates/pptx/src/read/timing.rs | 5 ++++- crates/pptx/tests/it/parent_delays.rs | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/pptx/src/read/timing.rs b/crates/pptx/src/read/timing.rs index f3166d622..1b6aa8b1b 100644 --- a/crates/pptx/src/read/timing.rs +++ b/crates/pptx/src/read/timing.rs @@ -151,6 +151,8 @@ fn place_by_parent_delays(out: &mut [(Animation, u32, String, bool)], at: &[(Opt if flat == abs { continue; } + // The group's click stays on whichever effect now plays first. + let had_click = out[idx[0]].0.start == AnimStart::OnClick; let mut order: Vec = (0..idx.len()).collect(); order.sort_by_key(|&k| abs[k]); let mut sorted: Vec<(Animation, u32, String, bool)> = order.iter().map(|&k| out[idx[k]].clone()).collect(); @@ -158,7 +160,8 @@ fn place_by_parent_delays(out: &mut [(Animation, u32, String, bool)], at: &[(Opt for (n, (&k, item)) in order.iter().zip(sorted.iter_mut()).enumerate() { let a = &mut item.0; let t = abs[k]; - if n == 0 && a.start == AnimStart::OnClick { + if n == 0 && had_click { + a.start = AnimStart::OnClick; a.delay_ms = t; } else if a.start == AnimStart::AfterPrevious && t >= end { begin = end; diff --git a/crates/pptx/tests/it/parent_delays.rs b/crates/pptx/tests/it/parent_delays.rs index 9dcaa8518..8d9f25c61 100644 --- a/crates/pptx/tests/it/parent_delays.rs +++ b/crates/pptx/tests/it/parent_delays.rs @@ -126,3 +126,20 @@ fn a_timeline_already_in_order_is_read_unchanged() { let starts: Vec<_> = fx.iter().map(|(n, st, d)| (n.as_str(), *st, *d)).collect(); assert_eq!(starts, [("Three", AnimStart::OnClick, 0), ("Visual", AnimStart::WithPrevious, 500), ("Two", AnimStart::AfterPrevious, 1000)]); } + +#[test] +fn a_delayed_click_effect_sorted_after_a_with_effect_keeps_the_click_on_the_group() { + // The click effect waits 500 ms; a with-effect in the same click plays at once, and a later + // effect is placed only by its parent's delay. Sorted, the with-effect plays first and takes + // the click, so the group still waits for one. + let fx = import_with_timing(|s| { + [ + group(10, 0, &[eff(11, "exit", "clickEffect", 500, s[0]), eff(12, "entr", "withEffect", 0, s[3])].concat()), + group(20, 1000, &eff(21, "exit", "afterEffect", 0, s[1])), + ] + .concat() + }); + let starts: Vec<_> = fx.iter().map(|(n, st, _)| (n.as_str(), *st)).collect(); + assert_eq!(starts[0], ("Visual", AnimStart::OnClick), "{fx:?}"); + assert_eq!(starts.iter().filter(|(_, st)| *st == AnimStart::OnClick).count(), 1, "{fx:?}"); +} From c7b91ff2ec150fdf8263cadf07cceb51bca3b060 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 3 Nov 2026 08:50:28 -0500 Subject: [PATCH 44/79] PPTX: reflection angles, skews and start position are written inside the schema's ranges --- crates/pptx/src/write/dml.rs | 13 ++++++++----- crates/pptx/tests/it/roundtrip.rs | 23 +++++++++++++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/crates/pptx/src/write/dml.rs b/crates/pptx/src/write/dml.rs index 1af1d9627..3705cc93a 100644 --- a/crates/pptx/src/write/dml.rs +++ b/crates/pptx/src/write/dml.rs @@ -357,13 +357,16 @@ pub fn effects(w: &mut W, fx: &Effects) { .a("endA", pct(r.end_alpha.clamp(0.0, 1.0))) .a("endPos", pct(r.end_pos.clamp(0.0, 1.0))) .a("dist", emu(r.dist).max(0)) - .o("stPos", r.st_pos) - .o("dir", r.dir) - .o("fadeDir", r.fade_dir) + // Kept from a file as read: held to the schema's ranges so a stray value can't + // make PowerPoint repair the file (ST_PositiveFixedAngle, ST_FixedAngle, + // ST_PositiveFixedPercentage). + .o("stPos", r.st_pos.map(|v| v.clamp(0, 100_000))) + .o("dir", r.dir.map(|v| v.rem_euclid(21_600_000))) + .o("fadeDir", r.fade_dir.map(|v| v.rem_euclid(21_600_000))) .o("sx", r.sx) .o("sy", r.sy) - .o("kx", r.kx) - .o("ky", r.ky) + .o("kx", r.kx.map(|v| v.clamp(-5_399_999, 5_399_999))) + .o("ky", r.ky.map(|v| v.clamp(-5_399_999, 5_399_999))) .o("algn", r.align.as_deref()) .b("rotWithShape", r.rotate_with_shape), ); diff --git a/crates/pptx/tests/it/roundtrip.rs b/crates/pptx/tests/it/roundtrip.rs index 1ed97f546..36330460b 100644 --- a/crates/pptx/tests/it/roundtrip.rs +++ b/crates/pptx/tests/it/roundtrip.rs @@ -2094,3 +2094,26 @@ fn default_durations_match_powerpoint() { assert_eq!(a.4, ms, "{effect}"); } } + +#[test] +fn reflection_angles_and_skews_out_of_range_are_written_inside_the_schema() { + let mut p = Presentation::default(); + let mut slide = new_slide(&mut p, "Reflection ranges"); + let mut s = shape(&mut p, "Reflected", 10.0, 10.0, 200.0, 80.0); + s.effects = Some(deckcraft_model::style::Effects { + reflection: Some(deckcraft_model::style::Reflection { + st_pos: Some(150_000), + dir: Some(-5), + fade_dir: Some(21_600_000), + kx: Some(9_000_000), + ky: Some(-9_000_000), + ..Default::default() + }), + ..Default::default() + }); + slide.shapes.push(s); + p.slides.push(Arc::new(slide)); + + let r = round(&p).slides.last().and_then(|sl| by_name(sl, "Reflected").effects.clone()).and_then(|fx| fx.reflection).expect("reflection"); + assert_eq!((r.st_pos, r.dir, r.fade_dir, r.kx, r.ky), (Some(100_000), Some(21_599_995), Some(0), Some(5_399_999), Some(-5_399_999))); +} From a6e2b64875d173c0aab39bafd815ae74f074eba7 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 3 Nov 2026 08:51:44 -0500 Subject: [PATCH 45/79] Reuse: a remapped table style's own XML carries its new id; checklist row name kept --- crates/engine/src/cmd/reuse.rs | 8 +++++++- docs/parity-checklist.md | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/reuse.rs b/crates/engine/src/cmd/reuse.rs index 8ab5732fe..d8eb487ff 100644 --- a/crates/engine/src/cmd/reuse.rs +++ b/crates/engine/src/cmd/reuse.rs @@ -224,6 +224,8 @@ fn import_table_styles(cmd: &str, doc: &mut Presentation, src: &Presentation, us break candidate; } }; + // The definition's own XML names it too, and PPTX export writes that XML as is. + imported.raw = imported.raw.replacen(&format!("styleId=\"{}\"", source.id), &format!("styleId=\"{candidate}\""), 1); imported.id.clone_from(&candidate); map.insert(key, candidate); } else { @@ -481,7 +483,11 @@ mod tests { assert_eq!(first_table_style(&doc.slides.first().expect("destination slide").shapes).as_deref(), Some(CUSTOM_STYLE)); let imported_style = first_table_style(&doc.slides.get(1).expect("reused slide").shapes).expect("grouped table style"); assert_ne!(imported_style, CUSTOM_STYLE); - assert!(doc.table_styles.iter().any(|style| style.id == imported_style && style.raw.contains("FF0000"))); + assert!( + doc.table_styles.iter().any(|style| style.id == imported_style + && style.raw.contains("FF0000") + && style.raw.contains(&format!("styleId=\"{imported_style}\""))) + ); assert!(doc.table_styles.iter().any(|style| style.id == CUSTOM_STYLE && style.raw.contains("0000FF"))); } diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index e08249ec8..18b60ad7d 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -74,7 +74,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Files | Export video (MP4) / animated GIF (GIF: one frame per slide, no transitions; no video) | P2 | P | | Files | Print (slides, handouts, notes, outline) | P1 | M | | Files | Password protection | P2 | M | -| Files | Reuse slides from another file (including referenced custom table styles) | P1 | P | +| Files | Reuse slides from another file | P1 | P | | Files | Drag and drop files onto the window | P1 | D | | Files | OpenDocument .odp open/save | P2 | M | | Slides | New slide by layout | P0 | D | From 9f68e8e2cce47ba84acabf2489b59850d1056167 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 3 Nov 2026 08:52:48 -0500 Subject: [PATCH 46/79] Reuse: a non-finite size ratio leaves content unscaled; cell text scaling tested; doc history kept --- ROADMAP.md | 2 +- crates/engine/src/cmd/reuse.rs | 14 ++++++++++++-- docs/gaps.md | 2 +- docs/parity-checklist.md | 4 +++- 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 44d67db08..046ed84f3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-02 · **Change:** trivial (reused slides scale to the destination size, including kept masters, layouts, groups, tables and text; reuse remains partial because picker thumbnails are not implemented) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-02 · **Change:** trivial (reused slides scale to the destination size, including kept masters, layouts, groups, tables and text; reuse remains partial because picker thumbnails are not implemented; previous: File › About replaces Account) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-01 · **Change:** trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (progress log: read-only `slide.actions` lists shape and table text actions; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-27 · **Change:** trivial (progress log: Set Up Slide Show ▸ Keep mouse cursor visible, #1445; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) diff --git a/crates/engine/src/cmd/reuse.rs b/crates/engine/src/cmd/reuse.rs index 765e4a81b..b12da34d2 100644 --- a/crates/engine/src/cmd/reuse.rs +++ b/crates/engine/src/cmd/reuse.rs @@ -318,7 +318,8 @@ fn reuse(s: &mut Session, p: &Value) -> Result { s.edit(|doc, sel| { let at = at.unwrap_or(sel.slide + 1).min(doc.slides.len()); let scale = |source: f64, destination: f64| { - if source.is_finite() && source > 0.0 && destination.is_finite() && destination > 0.0 { destination / source } else { 1.0 } + let ratio = destination / source; + if source > 0.0 && destination > 0.0 && ratio.is_finite() && ratio > 0.0 { ratio } else { 1.0 } }; let sx = scale(source_size.width, doc.slide_size.width); let sy = scale(source_size.height, doc.slide_size.height); @@ -453,7 +454,14 @@ mod tests { Shape { xfrm: Some(Xfrm::new(40.0, 50.0, 400.0, 200.0)), text: Some(text), - kind: ShapeKind::Table(Table::new(2, 2, 400.0, 40.0)), + kind: ShapeKind::Table({ + let mut table = Table::new(2, 2, 400.0, 40.0); + table.rows[0].cells[0].text = TextBody::from_text("cell"); + if let Some(run) = table.rows[0].cells[0].text.paragraphs.first_mut().and_then(|p| p.runs.first_mut()) { + run.props.size = Some(18.0); + } + table + }), ..Default::default() }, Shape { @@ -496,6 +504,8 @@ mod tests { assert_eq!(table.rows.first().map(|r| r.height), Some(20.0)); let font_size = table_shape.text.as_ref().and_then(|t| t.paragraphs.first()).and_then(|p| p.runs.first()).and_then(|r| r.props.size); assert_eq!(font_size, Some(12.0)); + let cell_size = table.rows[0].cells[0].text.paragraphs.first().and_then(|p| p.runs.first()).and_then(|r| r.props.size); + assert_eq!(cell_size, Some(9.0)); let group = reused.shapes.get(2).expect("group"); assert!(matches!(&group.kind, ShapeKind::Group { .. })); let ShapeKind::Group { children, child } = &group.kind else { return }; diff --git a/docs/gaps.md b/docs/gaps.md index b37a5ed63..1729e56fb 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -284,7 +284,7 @@ and starts at that effect. The pane has an advanced timeline with draggable bars ### Smaller missing rows Zoom: live thumbnails, the zoom-in animation and the native `p166:sldZm` element (3–5 h; `insert.zoom` makes linked pictures with Return to Zoom), reuse slides pane with thumbnails -(1–2 h; `slide.reuseList` picks which slides of a file to insert, and reused content now fits the destination size), laser pointer from the presenter window (1–2 h), +(1–2 h; `slide.reuseList` picks which slides of a file to insert, #1103; reused content now fits the destination size), laser pointer from the presenter window (1–2 h), artistic effects (3–6 h for the other 15 of 22; 7 approximated and round-tripped), remove background (6–10 h; needs a model or a classical matte), insert screenshot (2–3 h), record audio/screen and record slide show (8–12 h), compare presentations beyond accepting and rejecting each revision, or all (#850; per-object accept and reject, markers on the slide: 2–4 h), password protection (4–6 h, ECMA-376 diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index ea3147927..4d25e2ab5 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -4,6 +4,8 @@ > **Last updated:** 2026-11-02 · Reused slides fit their geometry, tables and text to the destination size; reuse remains partial because the picker has no thumbnails. Scores unchanged. > +> **Previously (2026-11-02):** Tiled picture fills keep their size in scaled groups and upright tiles (#1693); Picture and texture fill row remains P. Scores unchanged. +> > **Previously (2026-11-01):** Locked-shape and no-outline regressions added for #1621; fill row re-assessed: remains D; No Fill hit-testing now follows resolved appearance. Scores unchanged. The summary between ` @@ -72,7 +74,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Files | Export video (MP4) / animated GIF (GIF: one frame per slide, no transitions; no video) | P2 | P | | Files | Print (slides, handouts, notes, outline) | P1 | M | | Files | Password protection | P2 | M | -| Files | Reuse slides from another file (content fits destination size; picker thumbnails remain) | P1 | P | +| Files | Reuse slides from another file | P1 | P | | Files | Drag and drop files onto the window | P1 | D | | Files | OpenDocument .odp open/save | P2 | M | | Slides | New slide by layout | P0 | D | From aa13eeb45e0f84afc8ee7dc803fe2c4471def478 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 3 Nov 2026 08:54:48 -0500 Subject: [PATCH 47/79] Integration: community PR wave 10 (one bodyPr attribute chain, reuse imports, doc headers folded) Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 5 ++--- crates/engine/src/cmd/reuse.rs | 1 - crates/pptx/src/write/dml.rs | 3 +-- docs/file-format-parity.md | 8 ++------ docs/gaps.md | 4 +--- docs/parity-checklist.md | 7 +------ 6 files changed, 7 insertions(+), 21 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d8f863903..9315be8a6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,9 +2,8 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-02 · **Change:** trivial (PPTX and native saves retain optional RTL table direction; feature scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-03 · **Change:** trivial (Reuse Slides brings along referenced custom table styles, remapping conflicting style ids; previous: tiled picture fills keep their size in scaled groups and upright tiles (#1693); parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-02 · **Change:** trivial (reused slides scale to the destination size, including kept masters, layouts, groups, tables and text; reuse remains partial because picker thumbnails are not implemented; previous: File › About replaces Account) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-03 · **Change:** trivial (community PR wave 10: PPTX and native saves keep table cell centred anchoring, text overflow, compatible line spacing, normalised character height, Thai distributed alignment, RTL table direction, alternate run language and reflection transforms; imported effects placed by parent time-node delays; Reuse Slides fits the destination size and brings custom table styles; deleted slides leave custom shows; merged shapes drop their animations; Compress Pictures measures grouped pictures at displayed size; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-02 · **Change:** trivial (File › About replaces Account; product links, credits and appearance retained; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-01 · **Change:** trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (progress log: read-only `slide.actions` lists shape and table text actions; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-27 · **Change:** trivial (progress log: Set Up Slide Show ▸ Keep mouse cursor visible, #1445; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) diff --git a/crates/engine/src/cmd/reuse.rs b/crates/engine/src/cmd/reuse.rs index da8b2f25f..c85d5dc0f 100644 --- a/crates/engine/src/cmd/reuse.rs +++ b/crates/engine/src/cmd/reuse.rs @@ -10,7 +10,6 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; use deckcraft_model::table::TableStyleDef; -use deckcraft_model::text::{Action, Hyperlink, RunProps, TextBody}; use deckcraft_model::text::{Action, Hyperlink, ListStyle, RunProps, Spacing, TextBody}; use deckcraft_model::{LayoutId, Master, MasterId, MediaId, Presentation, ShapeId, ShapeKind, Slide, SlideId}; use serde_json::{Value, json}; diff --git a/crates/pptx/src/write/dml.rs b/crates/pptx/src/write/dml.rs index 8d2727cea..6f99323c4 100644 --- a/crates/pptx/src/write/dml.rs +++ b/crates/pptx/src/write/dml.rs @@ -477,8 +477,7 @@ pub fn body_pr(w: &mut W, b: &BodyProps) { .b("anchorCtr", b.anchor_ctr) .b("upright", b.upright) .b("spcFirstLastPara", b.space_first_last) - .b("compatLnSpc", b.compat_ln_spc); - .b("spcFirstLastPara", b.space_first_last) + .b("compatLnSpc", b.compat_ln_spc) .o("vertOverflow", b.vert_overflow.map(|v| v.xml())) .o("horzOverflow", deckcraft_model::text::TextOverflow::horz_xml(b.horz_overflow)); let warp = b.warp.as_deref().filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric())); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 85c65e1c6..20d4b4d8e 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,9 +1,6 @@ # File-format parity with PowerPoint -> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-02 · **Change:** minor (optional table RTL direction survives native and two PPTX saves; scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-02 · **Change:** minor (run alternate-language metadata round-trips through native and PPTX formats; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: seven owned PPTX corpus files, embedded fonts, EMF/WMF clip regions, threaded comments (#70), chart trendlines and workbooks (#335), SmartArt written as a diagram, tags and custom XML kept, picture export DPI; duplicated rows and headers folded; previous: trivial, lines in scaled groups, #233) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-02 · **Change:** trivial (PPTX reflection transforms and fade geometry survive resaves; renderer parity remains partial; previous: minor, native chart payloads stored in bounded ZIP members) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-03 · **Change:** trivial (Reuse Slides transfers referenced custom table-style definitions and remaps conflicting ids; native/PPTX reuse regressions) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-03 · **Change:** minor (cell anchorCtr, vertOverflow/horzOverflow, compatLnSpc, normalizeH, thaiDist, table rtl, run altLang and reflection transforms round-trip; Reuse Slides brings referenced custom table styles; previous: minor (native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: seven owned PPTX corpus files, embedded fonts, EMF/WMF clip regions, threaded comments (#70), chart trendlines and workbooks (#335), SmartArt written as a diagram, tags and custom XML kept, picture export DPI; duplicated rows and headers folded; previous: trivial, lines in scaled groups, #233)) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every format PowerPoint reads or writes, DeckCraft's support, fidelity and how it is tested. Gaps are itemized in [gaps.md](gaps.md#file-formats). Dimension score: **~58% ready** @@ -90,8 +87,7 @@ the round trip even though DeckCraft doesn't model it. | 3-D: `scene3d`, `sp3d` | kept | kept | A bevel preset with no `sp3d` of its own is written back on shapes and theme effect styles. Bevels, cameras, face lighting and extrusion sides draw approximately | | Text warp (`prstTxWarp`) | yes | yes | All 40 presets rendered (`crates/geom/src/warp.rs`); adjust values (`avLst`) round-trip | | Pictures with crop, recolour, duotone, alpha, luminance | yes | yes | | -| Tables with styles | yes | yes | Merges, both diagonal borders and optional RTL column direction round-trip; column text order survives two saves | -| Tables with styles | yes | yes | Merges and both diagonal borders round-trip; Reuse Slides transfers definitions referenced by source tables, including grouped tables, with conflicting ids remapped | +| Tables with styles | yes | yes | Merges, both diagonal borders and optional RTL column direction round-trip; Reuse Slides transfers definitions referenced by source tables, including grouped tables, with conflicting ids remapped | | Charts (`c:chartSpace`) | yes | yes | Original XML kept in `Chart.raw`. Trendlines (type, order or period, forecast, intercept, equation and R²) are read, written and drawn. A combo chart's secondary value axis is read and is not written back when the chart is rebuilt. An unchanged chart also keeps its embedded workbook and the other parts its relationships point at, under fresh `keptN` names so they never replace a part DeckCraft writes (#335); editing the chart drops that workbook instead of writing a stale one | | Charts (`cx:` chartex: waterfall, funnel, treemap, sunburst, histogram, Pareto, box) | yes | yes | Unchanged parts kept, with their embedded workbook (#335); edited and new ones written from the model with a plain-chart fallback. Treemap and sunburst hierarchy levels kept; region maps kept, drawn as tiles (no map geometry) | | SmartArt (`dgm`) | the drawing part as a group (text laid out on its `dsp:txXfrm` rectangle), and the outline from the data model (Text Pane) | as a diagram: data model from the outline; layout, quick style and colour parts (the file's own kept until the layout is switched); the group as the cached drawing | DeckCraft's layout definitions are simple; a diagram without a cached drawing isn't drawn. SmartArt kept only as XML, without its relationship parts, still loses the drawing and data parts (#203) | diff --git a/docs/gaps.md b/docs/gaps.md index 5430e87b3..aba64198a 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,8 +1,6 @@ # Where DeckCraft falls short of PowerPoint -> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-02 · **Change:** trivial (text run alternate-language metadata now survives native and PPTX saves; per-run language editing and proofing gaps remain; scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last reviewed:** 2026-10-30 · **Last updated:** 2026-11-02 · **Change:** trivial (PPTX reflection transforms and fade geometry survive resaves; rendering remains partial) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last reviewed:** 2026-10-30 · **Last updated:** 2026-11-02 · **Change:** trivial (reused slide contents now scale to the destination; picker thumbnails remain a gap) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-30 · **Last updated:** 2026-11-03 · **Change:** trivial (run alternate language and reflection transforms survive saves; reused slide contents scale to the destination, picker thumbnails remain a gap; previous: trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged)) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every known shortfall, one entry each, ranked by how much it stops someone who presents with PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index d835b7efa..852ebac30 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -2,16 +2,11 @@ > **Generated:** 2026-10-14 (bullets/numbering remain D after empty-line marker review; summary by `cargo xtask parity`; rows re-scored by the 2026-10-14 community PR wave, duplicate Thesaurus, AutoCorrect, Artistic effects and Corrections rows folded; Preview in editor re-scored 2026-10-13; earlier rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4; video/animated GIF row re-scored 2026-10-11, #171) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last updated:** 2026-11-02 · Reflection transforms and fade geometry now survive native and PPTX resaves; the row remains P because rendering is still partial. No score change. -> **Last updated:** 2026-11-03 · Reuse Slides transfers referenced custom table styles; feature status and summary scores unchanged. -> -> **Previously (2026-11-02):** Tiled picture fills keep their size in scaled groups and upright tiles (#1693); Picture and texture fill row remains P. Scores unchanged. -> **Last updated:** 2026-11-02 · Reused slides fit their geometry, tables and text to the destination size; reuse remains partial because the picker has no thumbnails. Scores unchanged. +> **Last updated:** 2026-11-03 · Community PR wave 10: reflection transforms survive resaves (row stays P, rendering partial); Reuse Slides fits the destination size and brings referenced custom table styles (row stays P, picker has no thumbnails); Compress Pictures measures grouped pictures at displayed size (stays D). Scores unchanged. > > **Previously (2026-11-02):** Tiled picture fills keep their size in scaled groups and upright tiles (#1693); Picture and texture fill row remains P. Scores unchanged. > > **Previously (2026-11-01):** Locked-shape and no-outline regressions added for #1621; fill row re-assessed: remains D; No Fill hit-testing now follows resolved appearance. Scores unchanged. -> **Last updated:** 2026-11-02 · Picture compression now measures displayed dimensions through nested group transforms; Compress pictures remains D. Scores unchanged. The summary between ` **Weighted breadth parity: 84%** over 191 features. From 32f069eb84dcbe4a454c191f9aa22214f1974399 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 13:44:00 -0500 Subject: [PATCH 48/79] AV1, HEVC: count repeated-frame outputs incrementally and drop inherited entry-point storage (#1763) * av1: count repeated-frame outputs as they are added * hevc: give each slice segment its own entry-point list instead of keeping the copied one's allocation --- crates/av1/src/decoder.rs | 5 ++++- crates/hevc/src/slice.rs | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/av1/src/decoder.rs b/crates/av1/src/decoder.rs index 43abce22b..d463493a1 100644 --- a/crates/av1/src/decoder.rs +++ b/crates/av1/src/decoder.rs @@ -425,6 +425,8 @@ impl Decoder { fn parse_obus(&mut self, data: &[u8], out: &mut Vec<(Output, usize, u32)>) -> Result<()> { let mut tu = 0usize; + // Repeated-frame outputs in `out`, counted as they are added rather than recounted per OBU. + let mut existing = out.iter().filter(|(o, _, _)| matches!(o, Output::Existing { .. })).count(); let mut pos = 0; while pos < data.len() { let obu = parse_obu_unit(data, pos)?; @@ -444,9 +446,10 @@ impl Decoder { } OBU_FRAME_HEADER_T | OBU_REDUNDANT_FRAME_HEADER_T | OBU_FRAME_T => { if let Some(o) = self.frame_obu(&obu)? { - if out.iter().filter(|(o, _, _)| matches!(o, Output::Existing { .. })).count() >= 32 { + if existing >= 32 { return Err(Error::Invalid("too many frame outputs in chunk")); } + existing += usize::from(matches!(o, Output::Existing { .. })); out.push((o, tu, spatial_id)); } } diff --git a/crates/hevc/src/slice.rs b/crates/hevc/src/slice.rs index aadf6159f..3d6233fb6 100644 --- a/crates/hevc/src/slice.rs +++ b/crates/hevc/src/slice.rs @@ -326,7 +326,9 @@ impl SliceHeader { /// entry_point_offset list (tiles / wavefront substreams). fn parse_entry_points(&mut self, r: &mut BitReader, pps: &Pps, sps: &Sps) -> Result<()> { - self.entry_points.clear(); + // A fresh list, not `clear()`: a dependent segment starts as a copy of its independent + // segment's header, and clearing would keep that copy's allocation for every segment. + self.entry_points = Vec::new(); if pps.tiles_enabled || pps.entropy_coding_sync { let n = r.read_ue()?; ensure!(n <= sps.pic_width_in_ctbs() * sps.pic_height_in_ctbs(), "num_entry_point_offsets out of range"); From 1eec8b266cbab49efdb5aecb8fa46a9b574688d6 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 14:14:24 -0500 Subject: [PATCH 49/79] model: group box charts and stack sums in one pass, share hierarchy labels, cap number formats (#1760) --- crates/model/src/chart.rs | 123 +++++++++++++++++++++++++++---------- crates/model/src/numfmt.rs | 13 ++++ 2 files changed, 102 insertions(+), 34 deletions(-) diff --git a/crates/model/src/chart.rs b/crates/model/src/chart.rs index 1b300359c..9cebb5a2f 100644 --- a/crates/model/src/chart.rs +++ b/crates/model/src/chart.rs @@ -758,17 +758,20 @@ impl Chart { } fn stacked_value_range(&self, n: usize) -> (f64, f64) { - let mut lo: f64 = 0.0; - let mut hi: f64 = 0.0; - for i in 0..n { - let (mut p, mut m) = (0.0, 0.0); - for s in &self.series { - let v = s.values.get(i).copied().flatten().unwrap_or(0.0); - if v >= 0.0 { p += v } else { m += v } + // Sums per category, from the values the series hold (an absent one adds nothing). + let (mut pos, mut neg) = (vec![0.0f64; n], vec![0.0f64; n]); + for s in &self.series { + for (i, v) in s.values.iter().enumerate().take(n) { + let v = v.unwrap_or(0.0); + if v >= 0.0 { + pos[i] += v; + } else { + neg[i] += v; + } } - hi = hi.max(p); - lo = lo.min(m); } + let hi = pos.iter().fold(0.0f64, |a, &p| a.max(p)); + let lo = neg.iter().fold(0.0f64, |a, &m| a.min(m)); (lo, hi) } @@ -1348,40 +1351,50 @@ impl Chart { /// The box chart's groups (distinct category labels in order of appearance; one unnamed /// group without categories) and, per series, each group's summary (None when it has no /// points). + /// + /// Each value is put in its group's bucket once (groups are found by name in a map), and the + /// series x groups result is held to [`MAX_BOX_CELLS`]: groups past it are left out. pub fn box_groups(&self) -> (Vec, Vec>>) { + let max_groups = (MAX_BOX_CELLS / self.series.len().max(1)).max(1); let mut groups: Vec = Vec::new(); + let mut index: std::collections::HashMap<&str, usize> = std::collections::HashMap::new(); for c in &self.categories { - if !groups.contains(c) { + if !index.contains_key(c.as_str()) && groups.len() < max_groups { + index.insert(c, groups.len()); groups.push(c.clone()); } } if groups.is_empty() { groups.push(String::new()); + index.insert("", 0); } + // A value past the categories belongs to the unnamed group, when there is one. + let past_end = index.get("").copied(); let stats = self .series .iter() .map(|s| { - groups - .iter() - .map(|g| { - let pts: Vec = s - .values - .iter() - .enumerate() - .filter(|(i, _)| self.categories.get(*i).map_or(g.is_empty() || self.categories.is_empty(), |c| c == g)) - .filter_map(|(_, v)| *v) - .filter(|v| v.is_finite()) - .collect(); - box_stats_by(pts, self.inclusive_quartiles) - }) - .collect() + let mut buckets: Vec> = vec![Vec::new(); groups.len()]; + for (i, v) in s.values.iter().enumerate() { + let Some(v) = v.filter(|v| v.is_finite()) else { continue }; + let g = match self.categories.get(i) { + Some(c) => index.get(c.as_str()).copied(), + None => past_end, + }; + if let Some(b) = g.and_then(|g| buckets.get_mut(g)) { + b.push(v); + } + } + buckets.into_iter().map(|pts| box_stats_by(pts, self.inclusive_quartiles)).collect() }) .collect(); (groups, stats) } } +/// Most series x group summaries [`Chart::box_groups`] makes. +pub const MAX_BOX_CELLS: usize = 1_000_000; + /// Widens the (min, max) extent `ext` to include `v`. fn widen(ext: &mut (f64, f64), v: f64) { ext.0 = ext.0.min(v); @@ -1454,33 +1467,41 @@ impl Chart { let n = self.categories.len().max(s.values.len()); let levels: Vec<&Vec> = self.category_levels.iter().take(MAX_LEVELS).collect(); // Each leaf's path from the top level down, blanks filled from the row above. - let mut last: Vec = vec![String::new(); levels.len()]; - let paths: Vec> = (0..n) + // Labels are shared, not copied: an outer label given once stands for every leaf under it. + use std::rc::Rc; + let empty: Rc = Rc::from(""); + let mut last: Vec> = vec![empty.clone(); levels.len()]; + let paths: Vec>> = (0..n) .map(|i| { let mut path = Vec::with_capacity(levels.len() + 1); for (d, lv) in levels.iter().enumerate().rev() { let l = lv.get(i).map(|x| x.trim()).unwrap_or(""); if !l.is_empty() { - last[d] = l.to_string(); + last[d] = Rc::from(l); // A new outer label starts fresh inner groups. for inner in last.iter_mut().take(d) { - inner.clear(); + *inner = empty.clone(); } } path.push(last[d].clone()); } - path.push(self.categories.get(i).cloned().unwrap_or_default()); + path.push(self.categories.get(i).map_or_else(|| empty.clone(), |c| Rc::from(c.as_str()))); path }) .collect(); let value = |i: usize| s.values.get(i).copied().flatten().filter(|v| v.is_finite()).unwrap_or(0.0).max(0.0); - fn build(rows: &[(usize, &[String])], value: &dyn Fn(usize) -> f64) -> Vec { + fn build(rows: &[(usize, &[std::rc::Rc])], value: &dyn Fn(usize) -> f64) -> Vec { let mut out: Vec = Vec::new(); let mut i = 0; while i < rows.len() { let (idx, path) = rows[i]; if path.len() <= 1 { - out.push(TreeNode { name: path.first().cloned().unwrap_or_default(), value: value(idx), leaf: Some(idx), children: vec![] }); + out.push(TreeNode { + name: path.first().map(|p| p.to_string()).unwrap_or_default(), + value: value(idx), + leaf: Some(idx), + children: vec![], + }); i += 1; continue; } @@ -1489,14 +1510,14 @@ impl Chart { while j < rows.len() && rows[j].1.len() > 1 && &rows[j].1[0] == head { j += 1; } - let sub: Vec<(usize, &[String])> = rows[i..j].iter().map(|(k, p)| (*k, &p[1..])).collect(); + let sub: Vec<(usize, &[std::rc::Rc])> = rows[i..j].iter().map(|(k, p)| (*k, &p[1..])).collect(); let children = build(&sub, value); - out.push(TreeNode { name: head.clone(), value: children.iter().map(|c| c.value).sum(), leaf: None, children }); + out.push(TreeNode { name: head.to_string(), value: children.iter().map(|c| c.value).sum(), leaf: None, children }); i = j; } out } - let rows: Vec<(usize, &[String])> = paths.iter().enumerate().map(|(i, p)| (i, p.as_slice())).collect(); + let rows: Vec<(usize, &[Rc])> = paths.iter().enumerate().map(|(i, p)| (i, p.as_slice())).collect(); build(&rows, &value) } } @@ -1607,6 +1628,40 @@ mod chartex_tests { assert!(c.category_tree().iter().all(|n| n.leaf.is_some())); } + #[test] + fn large_box_hierarchy_and_stacked_charts_take_linear_work() { + // 50,000 distinct categories in one series: grouped through a map, one pass. + let n = 50_000; + let mut c = Chart::sample(ChartType::BoxWhisker); + c.series.truncate(1); + c.categories = (0..n).map(|i| format!("g{i}")).collect(); + c.series[0].values = (0..n).map(|i| Some(i as f64)).collect(); + let (groups, stats) = c.box_groups(); + assert_eq!((groups.len(), stats[0].len()), (n, n)); + assert!(stats[0].iter().all(Option::is_some)); + // 2,000 series cap the groups so the result stays within MAX_BOX_CELLS. + c.series = vec![c.series[0].clone(); 2000]; + let (groups, stats) = c.box_groups(); + assert_eq!(groups.len(), MAX_BOX_CELLS / 2000); + assert!(stats.iter().all(|s| s.len() == groups.len())); + // One long outer label over 50,000 leaves is shared, not copied per leaf. + let mut t = Chart::sample(ChartType::Treemap); + t.series.truncate(1); + t.categories = (0..n).map(|i| format!("c{i}")).collect(); + t.series[0].values = (0..n).map(|_| Some(1.0)).collect(); + let mut outer = vec![String::new(); n]; + outer[0] = "o".repeat(1 << 16); + t.category_levels = vec![outer]; + let tree = t.category_tree(); + assert_eq!(tree.len(), 1); + assert_eq!(tree[0].children.len(), n); + // Stacked sums over many sparse series. + let mut st = Chart::sample(ChartType::StackedBar); + st.categories = (0..n).map(|i| i.to_string()).collect(); + st.series = (0..2000).map(|k| Series { values: vec![Some(1.0); if k == 0 { n } else { 1 }], ..Default::default() }).collect(); + assert!(st.value_range().1 >= 2000.0, "{:?}", st.value_range()); + } + #[test] fn box_groups_follow_category_labels() { let c = Chart::sample(ChartType::BoxWhisker); diff --git a/crates/model/src/numfmt.rs b/crates/model/src/numfmt.rs index 5d9f2b587..b5db9667c 100644 --- a/crates/model/src/numfmt.rs +++ b/crates/model/src/numfmt.rs @@ -143,9 +143,14 @@ fn group(digits: &str, sep: char) -> String { out } +/// Longest number format applied, as in Excel (255 characters); a longer one formats as +/// General, so a chart cannot make every label expand a huge format. +pub const MAX_CODE_LEN: usize = 255; + /// `v` formatted with the Excel number format `code` in language `lang` (axis labels: the text /// section of a format doesn't apply). pub fn format(code: &str, v: f64, lang: Option<&str>) -> String { + let code = if code.len() > MAX_CODE_LEN * 4 || code.chars().count() > MAX_CODE_LEN { "General" } else { code }; let (sep, dec) = marks(lang); if !v.is_finite() { return String::new(); @@ -299,6 +304,14 @@ mod tests { const RUB: &str = r#"_-* #\ ##0\ "₽"_-;\-* #\ ##0\ "₽"_-;_-* "-"??\ "₽"_-;_-@_-"#; + #[test] + fn an_overlong_format_is_general() { + let code = "0".repeat(100_000); + assert_eq!(format(&code, 12.5, None), format("General", 12.5, None)); + let ok = "0".repeat(MAX_CODE_LEN); + assert_eq!(format(&ok, 7.0, None).len(), MAX_CODE_LEN); + } + #[test] fn scientific_subnormal() { assert_eq!(format("0.00E+00", 5e-324, None), "4.94E-324"); From 19dceffd0bf3cbc79f9aaaf23fe3e05932fb434d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 14:38:07 -0500 Subject: [PATCH 50/79] pptx: index media, layouts, sections and table styles on export (#1761) --- crates/pptx/src/tables.rs | 19 +++++++++----- crates/pptx/src/write.rs | 39 +++++++++++++++++++++++----- crates/pptx/src/write/dml.rs | 6 ++--- crates/pptx/src/write/shapes.rs | 8 +++--- crates/pptx/src/write/timing.rs | 2 +- crates/pptx/tests/it/export_scale.rs | 31 ++++++++++++++++++++++ crates/pptx/tests/it/main.rs | 1 + 7 files changed, 85 insertions(+), 21 deletions(-) create mode 100644 crates/pptx/tests/it/export_scale.rs diff --git a/crates/pptx/src/tables.rs b/crates/pptx/src/tables.rs index 67d25d624..2f627796a 100644 --- a/crates/pptx/src/tables.rs +++ b/crates/pptx/src/tables.rs @@ -210,13 +210,20 @@ pub fn table_styles_xml(used: &[String], defs: &[deckcraft_model::table::TableSt let mut w = W::new(); w.open("a:tblStyleLst", A::new().a("xmlns:a", NS_A).a("def", "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}")); let names = deckcraft_model::table::table_styles(); - let mut done: Vec<&str> = vec![]; + // Indexes instead of scans: written ids (as given and ASCII lower-case), and the file's + // definitions by lower-case id (the first wins). + let mut done: std::collections::HashSet<&str> = std::collections::HashSet::new(); + let mut done_lower: std::collections::HashSet = std::collections::HashSet::new(); + let mut def_of: std::collections::HashMap = std::collections::HashMap::new(); + for d in defs { + def_of.entry(d.id.to_ascii_lowercase()).or_insert(d); + } for s in used { - if done.contains(&s.as_str()) { + if !done.insert(s.as_str()) { continue; } - done.push(s); - if let Some(d) = defs.iter().find(|d| d.id.eq_ignore_ascii_case(s)) + done_lower.insert(s.to_ascii_lowercase()); + if let Some(d) = def_of.get(&s.to_ascii_lowercase()) && crate::xml::parse(d.raw.as_bytes()).is_ok_and(|x| x.root.is("tblStyle")) { w.raw(&d.raw); @@ -231,11 +238,11 @@ pub fn table_styles_xml(used: &[String], defs: &[deckcraft_model::table::TableSt } // Styles the file defined but no table uses still belong to the deck. for d in defs { - if done.iter().any(|s| s.eq_ignore_ascii_case(&d.id)) { + if done_lower.contains(&d.id.to_ascii_lowercase()) { continue; } if crate::xml::parse(d.raw.as_bytes()).is_ok_and(|x| x.root.is("tblStyle")) { - done.push(&d.id); + done_lower.insert(d.id.to_ascii_lowercase()); w.raw(&d.raw); } } diff --git a/crates/pptx/src/write.rs b/crates/pptx/src/write.rs index 5d3e60e92..3d80adef1 100644 --- a/crates/pptx/src/write.rs +++ b/crates/pptx/src/write.rs @@ -53,6 +53,8 @@ pub struct Exp<'a> { pub p: &'a Presentation, pub pkg: PackageOut, media_parts: HashMap, + /// Each media id's item in `p.media` (the first with the id, as `Presentation::media`). + media_at: HashMap, n_image: u32, n_media: u32, n_chart: u32, @@ -63,6 +65,8 @@ pub struct Exp<'a> { n_kept: u32, pub slide_parts: HashMap, pub table_styles: Vec, + /// The ids in `table_styles`, for checking membership without a scan. + pub table_style_set: std::collections::HashSet, placeholder: Option, } @@ -119,12 +123,17 @@ fn ext_for(content_type: &str, name: &str, data: &[u8]) -> String { if !e.is_empty() && e.len() <= 5 && e.chars().all(|c| c.is_ascii_alphanumeric()) { e } else { "bin".into() } } -impl Exp<'_> { +impl<'a> Exp<'a> { + /// The media item `id`, found through the index rather than a scan of every item. + pub fn media(&self, id: MediaId) -> Option<&'a deckcraft_model::MediaItem> { + let p: &'a Presentation = self.p; + self.media_at.get(&id).and_then(|&i| p.media.get(i)) + } fn media_part(&mut self, id: MediaId) -> Option { if let Some(p) = self.media_parts.get(&id) { return Some(p.clone()); } - let m = self.p.media(id)?; + let m = self.media(id)?; if m.data.is_empty() { return None; } @@ -147,7 +156,7 @@ impl Exp<'_> { } /// A relationship from `o` to a media item (embedded part, or external link). pub fn media_rel(&mut self, o: &mut Out, id: MediaId, kind: &str) -> Option { - let m = self.p.media(id)?; + let m = self.media(id)?; if m.data.is_empty() { let link = m.link.clone()?; return Some(o.rels.add(kind, &link, true)); @@ -156,7 +165,7 @@ impl Exp<'_> { Some(o.rels.add(kind, &opc::relative(&o.name, &part), false)) } pub fn media_rel_new(&mut self, o: &mut Out, id: MediaId, kind: &str) -> Option { - let m = self.p.media(id)?; + let m = self.media(id)?; if m.data.is_empty() { return None; } @@ -489,7 +498,8 @@ fn valid_date(s: &str) -> bool { /// A copy of the presentation with every slide pointing at a layout that exists. fn sanitized(p: &Presentation) -> std::borrow::Cow<'_, Presentation> { let mut need = p.masters.is_empty() || p.masters.iter().any(|m| m.layouts.is_empty()); - need |= p.slides.iter().any(|s| p.layout(s.layout).is_none()); + let layouts: std::collections::HashSet<_> = p.masters.iter().flat_map(|m| m.layouts.iter().map(|l| l.id)).collect(); + need |= p.slides.iter().any(|s| !layouts.contains(&s.layout)); if !need { return std::borrow::Cow::Borrowed(p); } @@ -532,6 +542,13 @@ pub fn export(p: &Presentation, main_ct: &str) -> Result, PptxError> { p, pkg: PackageOut::default(), media_parts: HashMap::new(), + media_at: { + let mut m = HashMap::new(); + for (i, item) in p.media.iter().enumerate() { + m.entry(item.id).or_insert(i); + } + m + }, n_image: 0, n_media: 0, n_chart: 0, @@ -541,6 +558,7 @@ pub fn export(p: &Presentation, main_ct: &str) -> Result, PptxError> { n_kept: 0, slide_parts: HashMap::new(), table_styles: vec![], + table_style_set: Default::default(), placeholder: None, }; for (i, s) in p.slides.iter().enumerate() { @@ -959,9 +977,16 @@ fn section_slides(p: &Presentation) -> Vec<(String, Vec)> { if out.is_empty() { return out; } + // Each slide's first section, found once: a section may name a slide many times. + let mut first: HashMap = HashMap::new(); + for (k, sec) in p.sections.iter().enumerate() { + for id in &sec.slides { + first.entry(*id).or_insert(k); + } + } let mut cur = 0usize; for (i, s) in p.slides.iter().enumerate() { - if let Some(k) = p.sections.iter().position(|sec| sec.slides.contains(&s.id)) + if let Some(&k) = first.get(&s.id) && k >= cur { cur = k; @@ -1027,7 +1052,7 @@ fn write_embedded_fonts(x: &mut Exp, o: &mut Out) -> Vec { if let Some(rid) = seen_part.get(&id) { return Some(rid.clone()); } - let m = x.p.media(id)?; + let m = x.media(id)?; if m.data.is_empty() || m.data.len() > 32 * 1024 * 1024 { return None; } diff --git a/crates/pptx/src/write/dml.rs b/crates/pptx/src/write/dml.rs index 6f99323c4..85041596b 100644 --- a/crates/pptx/src/write/dml.rs +++ b/crates/pptx/src/write/dml.rs @@ -100,7 +100,7 @@ pub fn fill(w: &mut W, x: &mut Exp, o: &mut Out, f: &Fill) { pub fn blip_fill(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, pf: &PictureFill) { // An image that lives outside the package is a linked picture: `a:blip` names it through // `r:link`; `r:embed` may only reference a part inside the package. - let linked = x.p.media(pf.media).is_some_and(|m| m.data.is_empty() && m.link.is_some()); + let linked = x.media(pf.media).is_some_and(|m| m.data.is_empty() && m.link.is_some()); let rid = x.media_rel(o, pf.media, "image").unwrap_or_else(|| x.placeholder_rel(o)); w.open(tag, A::new().a("rotWithShape", if pf.upright { "0" } else { "1" })); let adj = &pf.adjust; @@ -449,7 +449,7 @@ pub fn hyperlink(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, h: &Hyperlink) return; }; w.open(tag, attrs); - let name = x.p.media(sound_id).map(|m| sound_label(&m.name)).unwrap_or_else(|| "sound.wav".into()); + let name = x.media(sound_id).map(|m| sound_label(&m.name)).unwrap_or_else(|| "sound.wav".into()); w.empty("a:snd", A::new().a("r:embed", sound_rid).a("name", name)); w.close(tag); } @@ -601,7 +601,7 @@ pub fn ppr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, level: Option, p: Some(Bullet::Picture { media }) => match x.media_rel(o, *media, "image") { Some(rid) => { w.open0("a:buBlip"); - let a = if x.p.media(*media).is_some_and(|m| m.data.is_empty() && m.link.is_some()) { + let a = if x.media(*media).is_some_and(|m| m.data.is_empty() && m.link.is_some()) { A::new().a("r:link", &rid) } else { A::new().a("r:embed", &rid) diff --git a/crates/pptx/src/write/shapes.rs b/crates/pptx/src/write/shapes.rs index 717f95ea1..3a9ba82d3 100644 --- a/crates/pptx/src/write/shapes.rs +++ b/crates/pptx/src/write/shapes.rs @@ -446,7 +446,7 @@ pub fn shape(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, depth } fn model_rel(x: &mut Exp, o: &mut Out, media: deckcraft_model::MediaId) -> Option { - if x.p.media(media).is_none_or(|m| m.data.is_empty()) { + if x.media(media).is_none_or(|m| m.data.is_empty()) { return None; } x.media_rel(o, media, crate::opc::RT_MODEL3D) @@ -479,7 +479,7 @@ fn write_model( return; }; let id = cx.id_for(s); - let poster = poster.filter(|p| x.p.media(*p).is_some_and(|m| !m.data.is_empty())); + let poster = poster.filter(|p| x.media(*p).is_some_and(|m| !m.data.is_empty())); let Some(poster) = poster else { write_model_part(w, x, o, id, s, &rid); return; @@ -700,7 +700,7 @@ fn table(w: &mut W, x: &mut Exp, o: &mut Out, t: &Table) { t.normalize(); w.open0("a:tbl"); let (guid, _) = crate::tables::guid_for_style(&t.style); - if !x.table_styles.contains(&t.style) { + if x.table_style_set.insert(t.style.clone()) { x.table_styles.push(t.style.clone()); } w.open( @@ -884,7 +884,7 @@ fn opaque( p.name = label.to_string(); } match preview { - Some(m) if x.p.media(m).is_some() => { + Some(m) if x.media(m).is_some() => { p.kind = ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { media: m, ..Default::default() } }; p.ph = None; shape_with_id(w, x, o, cx, &p, id); diff --git a/crates/pptx/src/write/timing.rs b/crates/pptx/src/write/timing.rs index 0f3dc8ee2..f2f3f2736 100644 --- a/crates/pptx/src/write/timing.rs +++ b/crates/pptx/src/write/timing.rs @@ -16,7 +16,7 @@ pub fn transition(w: &mut W, x: &mut Exp, o: &mut Out, t: &Transition) { 601..=900 => "med", _ => "slow", }; - let sound = t.sound.and_then(|m| x.media_rel(o, m, "audio").map(|r| (r, x.p.media(m).map(|i| i.name.clone()).unwrap_or_default()))); + let sound = t.sound.and_then(|m| x.media_rel(o, m, "audio").map(|r| (r, x.media(m).map(|i| i.name.clone()).unwrap_or_default()))); let snd = |w: &mut W| { if let Some((r, name)) = &sound { w.open0("p:sndAc"); diff --git a/crates/pptx/tests/it/export_scale.rs b/crates/pptx/tests/it/export_scale.rs new file mode 100644 index 000000000..80e06b1d1 --- /dev/null +++ b/crates/pptx/tests/it/export_scale.rs @@ -0,0 +1,31 @@ +//! Export looks media, layouts, sections and table styles up by id: a deck with many of each and +//! many references to the last ones exports in time linear in its size. + +use std::sync::Arc; + +use deckcraft_model::style::PictureFill; +use deckcraft_model::{Section, Shape, ShapeId, ShapeKind, defaults}; + +#[test] +fn many_media_sections_and_table_styles_export() { + let mut p = defaults::new_presentation(None); + // 5,000 media items; 5,000 pictures of the last one. + let mut last = None; + for i in 0..5_000u32 { + last = p.add_media(&format!("m{i}.png"), "image/png", i.to_le_bytes().to_vec()); + } + let last = last.unwrap(); + let pic = |k: u32| Shape { + id: ShapeId(100_000 + k), + kind: ShapeKind::Picture { fill: PictureFill { media: last, ..Default::default() } }, + ..Default::default() + }; + Arc::make_mut(&mut p.slides[0]).shapes = (0..5_000).map(pic).collect(); + // A section naming the slide 50,000 times, after 2,000 empty sections. + let id = p.slides[0].id; + p.sections = (0..2_000).map(|i| Section { name: format!("s{i}"), slides: vec![] }).collect(); + p.sections.push(Section { name: "last".into(), slides: vec![id; 50_000] }); + let bytes = deckcraft_pptx::export(&p).expect("export"); + let q = deckcraft_pptx::import(&bytes).expect("import"); + assert_eq!(q.slides[0].shapes.iter().filter(|s| matches!(s.kind, ShapeKind::Picture { .. })).count(), 5_000); +} diff --git a/crates/pptx/tests/it/main.rs b/crates/pptx/tests/it/main.rs index 71a8d377b..3f2026c66 100644 --- a/crates/pptx/tests/it/main.rs +++ b/crates/pptx/tests/it/main.rs @@ -9,6 +9,7 @@ mod corpus_kinds; mod corpus_media; mod corpus_style; mod corpus_themes; +mod export_scale; mod fills; mod kept_parts; mod locks; From 54e24ab86884239a5d2208fe2c16c2cfd5cfea3d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 19:36:05 -0500 Subject: [PATCH 51/79] Review: replace each whole word in one pass (#1793) --- crates/engine/src/cmd/review.rs | 60 ++++++++++++++++++++++++++++++--- 1 file changed, 55 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 6a18db56f..92637741f 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -421,23 +421,30 @@ fn add_to_dictionary(_s: &mut Session, p: &Value) -> Result { /// Replace whole-word `from` with `to` in `text`; how many were replaced. fn replace_word(text: &mut String, from: &str, to: &str, all: bool) -> usize { let is_word = |c: char| c.is_alphanumeric() || c == '\'' || c == '’'; - let mut n = 0; - let mut at = 0; + // The result is built in one pass; the text is not edited in place for every match. + let mut out = String::new(); + let (mut n, mut at, mut copied) = (0, 0, 0); while let Some(i) = text.get(at..).and_then(|t| t.find(from)).map(|i| i + at) { let end = i + from.len(); - let before = text.get(..i).and_then(|t| t.chars().next_back()).is_some_and(is_word); + // The character before the match as the edited text would have it. + let before = text.get(copied..i).and_then(|t| t.chars().next_back()).or_else(|| out.chars().next_back()).is_some_and(is_word); let after = text.get(end..).and_then(|t| t.chars().next()).is_some_and(is_word); if before || after { at = end; continue; } - text.replace_range(i..end, to); + out.push_str(text.get(copied..i).unwrap_or_default()); + out.push_str(to); n += 1; - at = i + to.len(); + (at, copied) = (end, end); if !all { break; } } + if n > 0 { + out.push_str(text.get(copied..).unwrap_or_default()); + *text = out; + } n } @@ -764,6 +771,49 @@ fn end_compare(s: &mut Session, _: &Value) -> Result { #[cfg(test)] mod tests { + /// The replacement as it ran before: edited in place for every match. + fn replace_word_in_place(text: &mut String, from: &str, to: &str, all: bool) -> usize { + let is_word = |c: char| c.is_alphanumeric() || c == '\'' || c == '’'; + let mut n = 0; + let mut at = 0; + while let Some(i) = text.get(at..).and_then(|t| t.find(from)).map(|i| i + at) { + let end = i + from.len(); + let before = text.get(..i).and_then(|t| t.chars().next_back()).is_some_and(is_word); + let after = text.get(end..).and_then(|t| t.chars().next()).is_some_and(is_word); + if before || after { + at = end; + continue; + } + text.replace_range(i..end, to); + n += 1; + at = i + to.len(); + if !all { + break; + } + } + n + } + + #[test] + fn replace_word_matches_the_in_place_version() { + let cases = [ + ("a a b a", "a", "x", true), + ("aa a. a", "a", "bb", true), + ("a-a -a-", "-", "ab", true), + ("foo. foo.", "foo.", "", true), + ("it's it", "it", "so", true), + ("x.x. x.", "x.", "y", true), + ("a a a", "a", "b", false), + ("none", "z", "q", true), + ("é é", "é", "e", true), + ]; + for (text, from, to, all) in cases { + let (mut a, mut b) = (text.to_string(), text.to_string()); + let (na, nb) = (replace_word(&mut a, from, to, all), replace_word_in_place(&mut b, from, to, all)); + assert_eq!((na, a), (nb, b), "{text:?} {from:?} -> {to:?}"); + } + } + use super::*; use serde_json::json; From 0107186cee878b399ddbd0eac27899db3cabfd00 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 19:46:09 -0500 Subject: [PATCH 52/79] Engine: check the temporary file's path against the write root (#1792) --- crates/engine/src/io.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 38f4de8d2..9a98f62a0 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -48,6 +48,8 @@ pub fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> { // A short fixed-size name: appending to the target's name could exceed the file name limit. let tmp_name = format!(".deckcraft.{}.{}.saving", std::process::id(), COUNTER.fetch_add(1, Ordering::Relaxed)); let tmp = target_path.with_file_name(tmp_name); + // The sibling must pass the same check: when `path` is the write root itself it lands outside. + crate::file_access::check_write(&tmp.to_string_lossy())?; match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) { Ok(f) => break (tmp, f), Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists && attempts < 16 => attempts += 1, @@ -135,4 +137,16 @@ mod tests { let _ = std::fs::remove_file(&file); } + + #[test] + fn write_file_to_the_write_root_itself_creates_no_sibling_outside_it() { + let base = std::env::temp_dir().join(format!("deckcraft_io_root_test_{}", std::process::id())); + let root = base.join("root"); + std::fs::create_dir_all(&root).unwrap(); + let roots = crate::file_access::AutomationRoots::new(&[], std::slice::from_ref(&root)).unwrap().unwrap(); + let res = crate::file_access::confine(Some(&roots), || write_file(root.to_str().unwrap(), b"x")); + assert!(res.unwrap_err().contains("outside the write root")); + assert_eq!(std::fs::read_dir(&base).unwrap().count(), 1, "only the root is there"); + let _ = std::fs::remove_dir_all(&base); + } } From 42108d0ee75ac621d8849938be744be47e06c17c Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 19:53:44 -0500 Subject: [PATCH 53/79] PPTX export: index animation targets, cache build roles, serialize run properties once per text run (#1788) * PPTX export: index animation targets, cache a paragraph's build roles, serialize run properties once per text run * address review: warn once when a by-paragraph build is truncated --- crates/pptx/src/write/dml.rs | 14 +++++++++-- crates/pptx/src/write/timing.rs | 44 ++++++++++++++++++++++++++++++--- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/crates/pptx/src/write/dml.rs b/crates/pptx/src/write/dml.rs index 85041596b..8acc648d0 100644 --- a/crates/pptx/src/write/dml.rs +++ b/crates/pptx/src/write/dml.rs @@ -762,17 +762,27 @@ pub fn text_body(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, tb: &TextBody, } fn run_text(w: &mut W, x: &mut Exp, o: &mut Out, text: &str, props: &RunProps) { + // The run's properties are the same for every piece and line break: serialize them once. + let mut rpr_xml: Option = None; + let mut put_rpr = |w: &mut W| match &rpr_xml { + Some(xml) => w.s.push_str(xml), + None => { + let start = w.s.len(); + rpr(w, x, o, "a:rPr", props); + rpr_xml = Some(w.s[start..].to_string()); + } + }; for (i, piece) in text.split(['\n', '\u{b}']).enumerate() { if i > 0 { w.open0("a:br"); - rpr(w, x, o, "a:rPr", props); + put_rpr(w); w.close("a:br"); } if piece.is_empty() && i > 0 { continue; } w.open0("a:r"); - rpr(w, x, o, "a:rPr", props); + put_rpr(w); w.elt("a:t", piece); w.close("a:r"); } diff --git a/crates/pptx/src/write/timing.rs b/crates/pptx/src/write/timing.rs index f2f3f2736..6376e3ca0 100644 --- a/crates/pptx/src/write/timing.rs +++ b/crates/pptx/src/write/timing.rs @@ -1,7 +1,7 @@ //! Transitions and the animation timing tree. use deckcraft_model::anim::{AnimClass, AnimStart, Animation, BuildRole, TextBuild, Transition, build_filled, paragraph_build_roles}; -use deckcraft_model::{Shape, ShapeId, ShapeKind, find_shape}; +use deckcraft_model::{Shape, ShapeId, ShapeKind}; use super::shapes::IdMap; use super::{Exp, Out}; @@ -577,6 +577,21 @@ fn click_pars(w: &mut W, x: &mut Exp, ids: &mut Ids, clicks: &Groups, interactiv } } +/// Most effects one slide's timing writes; animations past it are left out. +const MAX_EFFECTS: usize = 100_000; + +/// The shapes of the tree by id, the first in document order for a repeated id (as `find_shape` +/// finds it), so each animation resolves its shape without a tree walk. +fn shape_index<'a>(shapes: &'a [Shape], depth: usize, out: &mut std::collections::HashMap) { + if depth > 64 { + return; + } + for s in shapes { + out.entry(s.id).or_insert(s); + shape_index(s.children(), depth + 1, out); + } +} + /// Write `p:timing` for a slide's animations. pub fn timing(w: &mut W, x: &mut Exp, anims: &[Animation], shapes: &[Shape], idmap: &IdMap) { let mut grp_count: std::collections::HashMap = Default::default(); @@ -585,16 +600,31 @@ pub fn timing(w: &mut W, x: &mut Exp, anims: &[Animation], shapes: &[Shape], idm let mut bld: Vec<(u32, u32, bool)> = vec![]; // The last paragraph effect written: (shape, class, trigger, grpId). let mut prev_para: Option<(ShapeId, AnimClass, Option, u32)> = None; + let mut by_id = std::collections::HashMap::new(); + shape_index(shapes, 0, &mut by_id); + let mut roles_of: std::collections::HashMap> = Default::default(); + let mut effect_count = 0usize; + let mut warned = false; for a in anims.iter().take(10_000) { + if effect_count >= MAX_EFFECTS { + if !warned { + log::warn!("pptx: more than {MAX_EFFECTS} animation effects; the rest are left out"); + } + break; + } let Some(spid) = idmap.get(a.shape) else { log::warn!("pptx: animation on missing shape {} skipped", a.shape); continue; }; - let Some(shape) = find_shape(shapes, a.shape) else { continue }; + let Some(&shape) = by_id.get(&a.shape) else { continue }; let text_sp = matches!(shape.kind, ShapeKind::Shape) && shape.text.is_some(); let is_text_sp = matches!(shape.kind, ShapeKind::Shape) && shape.text.as_ref().is_some_and(|t| !t.is_empty()); let text = shape.text.as_ref().filter(|_| a.text_build == TextBuild::ByParagraph && is_text_sp); - let roles = text.map(paragraph_build_roles).unwrap_or_default(); + let no_roles = Vec::new(); + let roles = match text { + Some(t) => roles_of.entry(a.shape).or_insert_with(|| paragraph_build_roles(t)), + None => &no_roles, + }; let by_para = roles.contains(&BuildRole::Top); // PowerPoint keeps a shape's consecutive paragraph effects in one build (`grpId`) marked // `build="p"`; the reader turns them back into a by-paragraph build only if that plays them @@ -632,11 +662,19 @@ pub fn timing(w: &mut W, x: &mut Exp, anims: &[Animation], shapes: &[Shape], idm _ => continue, }; let Ok(pi) = u32::try_from(i) else { break }; + if effect_count + effs.len() >= MAX_EFFECTS { + if !warned { + log::warn!("pptx: more than {MAX_EFFECTS} animation effects; the rest are left out"); + warned = true; + } + break; + } effs.push(Eff { a, spid, start, paragraph: Some(pi), grp }); } } else { effs.push(Eff { a, spid, start: a.start, paragraph: a.paragraph, grp }); } + effect_count += effs.len(); if text_sp && !joins { bld.push((spid, grp, by_para || para_effect)); } From b7d57bacab02c2b8d2d69903337ae681f931e58d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 19:54:06 -0500 Subject: [PATCH 54/79] Text edit: splice inserted paragraphs once and paste runs without rescanning (#1787) --- crates/model/src/edit.rs | 149 +++++++++++++++++++++++++++++++++------ 1 file changed, 128 insertions(+), 21 deletions(-) diff --git a/crates/model/src/edit.rs b/crates/model/src/edit.rs index 3e21e3bf8..8f79d9c4c 100644 --- a/crates/model/src/edit.rs +++ b/crates/model/src/edit.rs @@ -78,45 +78,57 @@ pub fn insert(body: &mut TextBody, pos: Pos, text: &str, props: Option if body.paragraphs.is_empty() { body.paragraphs.push(Paragraph::default()); } - let (mut pi, mut ch) = clamp(body, pos); - let props = props.unwrap_or_else(|| props_at(body, (pi, ch))); + let (pi0, mut ch) = clamp(body, pos); + let props = props.unwrap_or_else(|| props_at(body, (pi0, ch))); let text = text.replace("\r\n", "\n").replace('\r', "\n"); + // Work on the paragraph being edited apart from the body, so each new paragraph is pushed to a + // list and the body's vector is spliced once instead of shifted for every `\n`. + let Some(slot) = body.paragraphs.get_mut(pi0) else { return (pi0, ch) }; + let mut cur = std::mem::take(slot); + let mut done: Vec = Vec::new(); let mut first = true; for part in text.split('\n') { if !first { - // Split paragraph pi at ch. - let Some(p) = body.paragraphs.get_mut(pi) else { break }; - let idx = split_at(p, ch); - let tail: Vec = p.runs.drain(idx..).collect(); - let newp = - Paragraph { level: p.level, props: p.props.clone(), runs: tail, def_props: p.def_props.clone(), end_props: p.end_props.clone() }; - p.end_props = props.clone(); - body.paragraphs.insert(pi + 1, newp); - pi += 1; + // Split the current paragraph at ch. + let idx = split_at(&mut cur, ch); + let tail: Vec = cur.runs.drain(idx..).collect(); + let newp = Paragraph { + level: cur.level, + props: cur.props.clone(), + runs: tail, + def_props: cur.def_props.clone(), + end_props: cur.end_props.clone(), + }; + cur.end_props = props.clone(); + done.push(std::mem::replace(&mut cur, newp)); ch = 0; } first = false; + // The run index just after the last insertion: the next one lands there with no rescan. + let mut next: Option = None; for (k, seg) in part.split('\u{b}').enumerate() { - let Some(p) = body.paragraphs.get_mut(pi) else { break }; if k > 0 { - let idx = split_at(p, ch); - p.runs.insert(idx, Run { text: String::new(), props: props.clone(), kind: RunKind::Break }); + let idx = next.unwrap_or_else(|| split_at(&mut cur, ch)); + cur.runs.insert(idx, Run { text: String::new(), props: props.clone(), kind: RunKind::Break }); ch += 1; + next = Some(idx + 1); } if seg.is_empty() { continue; } - let idx = split_at(p, ch); - p.runs.insert(idx, Run::with(seg, props.clone())); + let idx = next.unwrap_or_else(|| split_at(&mut cur, ch)); + cur.runs.insert(idx, Run::with(seg, props.clone())); ch += seg.chars().count(); + next = Some(idx + 1); } - if let Some(p) = body.paragraphs.get_mut(pi) { - p.normalize(); - if p.runs.is_empty() { - p.end_props = props.clone(); - } + cur.normalize(); + if cur.runs.is_empty() { + cur.end_props = props.clone(); } } + let pi = pi0 + done.len(); + done.push(cur); + body.paragraphs.splice(pi0..=pi0, done); (pi, ch) } @@ -252,8 +264,16 @@ pub fn slice(body: &TextBody, a: Pos, b: Pos) -> TextBody { /// Insert a formatted body at `pos` (paste): first paragraph merges into the current one. pub fn insert_body(body: &mut TextBody, pos: Pos, src: &TextBody) -> Pos { let mut caret = clamp(body, pos); + // Plain text runs go straight in at the run index after the previous one and the paragraph is + // normalized once, not rescanned and merged for every source run. + let mut next: Option = None; + let mut dirty = false; for (k, sp) in src.paragraphs.iter().enumerate() { if k > 0 { + if let (true, Some(p)) = (dirty, body.paragraphs.get_mut(caret.0)) { + p.normalize(); + } + (next, dirty) = (None, false); caret = insert(body, caret, "\n", None); if let Some(p) = body.paragraphs.get_mut(caret.0) { p.level = sp.level; @@ -261,6 +281,22 @@ pub fn insert_body(body: &mut TextBody, pos: Pos, src: &TextBody) -> Pos { } } for r in &sp.runs { + if r.kind == RunKind::Text + && !r.text.is_empty() + && !r.text.contains(['\n', '\r', '\u{b}']) + && let Some(p) = body.paragraphs.get_mut(caret.0) + { + let idx = next.unwrap_or_else(|| split_at(p, caret.1)); + p.runs.insert(idx, Run::with(&r.text, r.props.clone())); + caret.1 += r.text.chars().count(); + (next, dirty) = (Some(idx + 1), true); + continue; + } + // Anything else takes the general path, after the pending runs are merged. + if let (true, Some(p)) = (dirty, body.paragraphs.get_mut(caret.0)) { + p.normalize(); + } + (next, dirty) = (None, false); match r.kind { RunKind::Text => caret = insert(body, caret, &r.text, Some(r.props.clone())), RunKind::Break => caret = insert(body, caret, "\u{b}", Some(r.props.clone())), @@ -274,6 +310,9 @@ pub fn insert_body(body: &mut TextBody, pos: Pos, src: &TextBody) -> Pos { } } } + if let (true, Some(p)) = (dirty, body.paragraphs.get_mut(caret.0)) { + p.normalize(); + } caret } @@ -568,6 +607,74 @@ mod tests { assert_eq!(caret, (0, 5)); } + /// The paste as it ran before the fast path: one `insert` (and normalize) per source run. + fn insert_body_by_runs(body: &mut TextBody, pos: Pos, src: &TextBody) -> Pos { + let mut caret = clamp(body, pos); + for (k, sp) in src.paragraphs.iter().enumerate() { + if k > 0 { + caret = insert(body, caret, "\n", None); + if let Some(p) = body.paragraphs.get_mut(caret.0) { + p.level = sp.level; + p.props = sp.props.clone(); + } + } + for r in &sp.runs { + match r.kind { + RunKind::Text => caret = insert(body, caret, &r.text, Some(r.props.clone())), + RunKind::Break => caret = insert(body, caret, "\u{b}", Some(r.props.clone())), + _ => { + if let Some(p) = body.paragraphs.get_mut(caret.0) { + let idx = split_at(p, caret.1); + p.runs.insert(idx, r.clone()); + caret.1 += r.char_len(); + } + } + } + } + } + caret + } + + #[test] + fn paste_matches_inserting_run_by_run() { + let bold = |t: &str| Run::with(t, RunProps { bold: Some(true), ..Default::default() }); + let mut src = body(""); + src.paragraphs = vec![ + Paragraph { + runs: vec![Run::with("ab", RunProps::default()), Run::with("cd", RunProps::default()), bold("ef"), bold("g")], + ..Default::default() + }, + Paragraph { + runs: vec![ + bold("h"), + Run { text: String::new(), props: RunProps::default(), kind: RunKind::Break }, + Run::with("i", RunProps::default()), + ], + level: 1, + ..Default::default() + }, + Paragraph { runs: vec![Run::with("jk", RunProps::default())], ..Default::default() }, + ]; + for at in [0, 1, 3, 6] { + let mut dest = body("XYZabc"); + format(&mut dest, (0, 2), (0, 4), &|p| p.italic = Some(true)); + let mut want = dest.clone(); + let want_caret = insert_body_by_runs(&mut want, (0, at), &src); + let caret = insert_body(&mut dest, (0, at), &src); + assert_eq!(caret, want_caret, "caret at {at}"); + assert_eq!(format!("{:?}", dest.paragraphs), format!("{:?}", want.paragraphs), "paste at {at}"); + } + } + + #[test] + fn multiline_insert_splits_into_paragraphs() { + let mut b = body("abcd"); + let c = insert(&mut b, (0, 2), "x\ny\u{b}z\n\nw", None); + assert_eq!(c, (3, 1)); + let texts: Vec = b.paragraphs.iter().map(Paragraph::text).collect(); + assert_eq!(texts, ["abx", "y\u{b}z", "", "wcd"]); + } + #[test] fn words_and_moves() { let b = body("Hello brave world"); From a72a5167d0fddf51a4b915e8a08e0d028ea8b72f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 19:59:29 -0500 Subject: [PATCH 55/79] Morph: pick the next curve to split from a heap, not a full scan (#1782) --- crates/geom/src/morph.rs | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/geom/src/morph.rs b/crates/geom/src/morph.rs index 411da5f93..154db5e87 100644 --- a/crates/geom/src/morph.rs +++ b/crates/geom/src/morph.rs @@ -126,6 +126,21 @@ fn split_curve(c: &[Point], n: usize, out: &mut Vec) { out.extend([p1, p2, p3]); } +/// A piece length ordered totally, so it can sit in a heap. +#[derive(PartialEq)] +struct Share(f64); +impl Eq for Share {} +impl PartialOrd for Share { + fn partial_cmp(&self, o: &Self) -> Option { + Some(self.cmp(o)) + } +} +impl Ord for Share { + fn cmp(&self, o: &Self) -> std::cmp::Ordering { + self.0.total_cmp(&o.0) + } +} + /// Brings a contour to `n` curves. Unlike manim (which splits every curve equally often), the extra /// splits go to the longest pieces, so a long side gets more points than a short one. fn refine(c: &Contour, n: usize) -> Contour { @@ -136,11 +151,15 @@ fn refine(c: &Contour, n: usize) -> Contour { let curves: Vec<&[Point]> = (0..have).filter_map(|k| c.pts.get(3 * k..3 * k + 4)).collect(); let len: Vec = curves.iter().map(|q| curve_length(q) + 1e-9).collect(); let mut parts = vec![1usize; curves.len()]; + // The curve with the longest piece gets the next split (the lowest index on a tie); a heap keeps + // this O(n log n) instead of rescanning every curve for each split. + let mut heap: std::collections::BinaryHeap<(Share, std::cmp::Reverse)> = + len.iter().enumerate().map(|(k, l)| (Share(*l), std::cmp::Reverse(k))).collect(); for _ in 0..n - have { - let share = |k: usize| len.get(k).copied().unwrap_or(0.0) / parts.get(k).copied().unwrap_or(1) as f64; - let best = (1..curves.len()).fold(0, |b, k| if share(k) > share(b) { k } else { b }); + let Some((_, std::cmp::Reverse(best))) = heap.pop() else { break }; if let Some(p) = parts.get_mut(best) { *p += 1; + heap.push((Share(len.get(best).copied().unwrap_or(0.0) / *p as f64), std::cmp::Reverse(best))); } } let mut out = Vec::with_capacity(1 + 3 * n); @@ -420,6 +439,16 @@ mod tests { assert_eq!(contours(&to_path(&cs)), cs); } + #[test] + fn refine_many_curves_finishes_at_the_asked_count() { + let mut c = Contour { closed: false, pts: vec![Point::ZERO] }; + for i in 1..=20_000 { + line_to(&mut c.pts, Point::new(f64::from(i), f64::from(i % 7))); + } + let r = refine(&c, 40_000); + assert_eq!(r.curves(), 40_000); + } + #[test] fn refine_splits_the_longest_curves_and_keeps_the_shape() { let c = Contour { closed: false, pts: vec![Point::ZERO, Point::new(1.0, 0.0), Point::new(2.0, 0.0), Point::new(3.0, 0.0)] }; From 0233d2906424f024a89041f1f95c28c89eca20d9 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 19:59:51 -0500 Subject: [PATCH 56/79] Spelling: bound a word's language and index results by offset (#1767) * engine: bound a word's language and index spelling results by offset * engine: pass the test run's text by value --- crates/engine/src/spell.rs | 50 ++++++++++++++++++++++++++++++++------ 1 file changed, 42 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/spell.rs b/crates/engine/src/spell.rs index 2dda5c6f4..0eeaa6757 100644 --- a/crates/engine/src/spell.rs +++ b/crates/engine/src/spell.rs @@ -700,15 +700,29 @@ pub struct Word { pub repeated: Option, } +/// Longest language tag a word is checked in. BCP 47 tags in documents are a few bytes; a longer +/// value from a file is no language we have a dictionary for, and copying it into every word of +/// a paragraph would multiply it. +const MAX_LANG_LEN: usize = 64; + /// The words of `p`, each with its run's language (`lang` on the run, else `default`). Words /// with digits are skipped; apostrophes inside a word belong to it; hyphenated words are checked -/// part by part. +/// part by part. A language over [`MAX_LANG_LEN`] counts as none. pub fn paragraph_words(p: &deckcraft_model::Paragraph, default: &str) -> Vec { let mut chars: Vec<(char, usize)> = vec![]; for (ri, r) in p.runs.iter().enumerate() { chars.extend(r.content().chars().map(|c| (c, ri))); } - let lang_of = |ri: usize| p.runs.get(ri).and_then(|r| r.props.lang.clone()).filter(|l| !l.is_empty()).unwrap_or_else(|| default.to_string()); + let default = if default.len() > MAX_LANG_LEN { "" } else { default }; + let lang_of = |ri: usize| { + p.runs + .get(ri) + .and_then(|r| r.props.lang.as_deref()) + .filter(|l| !l.is_empty()) + .map(|l| if l.len() > MAX_LANG_LEN { "" } else { l }) + .unwrap_or(default) + .to_string() + }; // Combining marks (decomposed accents) belong to the letter before them. let inside = |c: char| c.is_alphanumeric() || deckcraft_model::words::is_combining_mark(c) || c == '\'' || c == '’' || c == '-'; let mut out = vec![]; @@ -791,15 +805,18 @@ pub fn misspelt(p: &deckcraft_model::Paragraph, default: &str, ignore: &[String] .chain(repeated(p, default)) .collect::>() .into_iter() - .fold(vec![], |mut v: Vec, w| { + .fold((vec![], HashMap::new()), |(mut v, mut at): (Vec, HashMap), w| { // A repeated misspelt word is reported once, as repeated. - if let Some(i) = v.iter().position(|x| x.start == w.start) { - v[i] = w; - } else { - v.push(w); + match at.get(&w.start).and_then(|&i| v.get_mut(i)) { + Some(x) => *x = w, + None => { + at.insert(w.start, v.len()); + v.push(w); + } } - v + (v, at) }) + .0 } /// Use `d` for `lang` from now on in this process (a dictionary the app loaded itself). @@ -953,6 +970,23 @@ SFX D Y 1\nSFX D 0 ed/S .\n"; assert_eq!(w, vec!["check", "done"]); } + #[test] + fn a_long_paragraph_with_a_huge_language_is_checked_in_linear_work() { + use deckcraft_model::{Paragraph, Run, RunProps}; + // 100,000 copies of one word under a 1 MB language value: every word after the first is + // a repeat, and no word carries a copy of the value. + let lang = "x".repeat(1 << 20); + let p = + Paragraph { runs: vec![Run::with("the ".repeat(100_000), RunProps { lang: Some(lang), ..Default::default() })], ..Default::default() }; + let words = paragraph_words(&p, "en-US"); + assert_eq!(words.len(), 100_000); + assert!(words.iter().all(|w| w.lang.is_empty())); + let bad = misspelt(&p, "en-US", &[]); + assert_eq!(bad.len(), 99_999); + assert!(bad.iter().all(|w| w.repeated.is_some())); + assert!(paragraph_words(&Paragraph::new("word"), &"y".repeat(100)).iter().all(|w| w.lang.is_empty())); + } + #[test] fn long_and_numeric_flags_and_latin1() { let d = Dictionary::parse(b"FLAG long\nSFX Aa Y 1\nSFX Aa 0 s .\n", b"1\ncat/Aa\n"); From 872cdbf33136545699bdfa280dbbebbb6cbd9557 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 20:04:49 -0500 Subject: [PATCH 57/79] pptx: take nested math text once and check an object's part for relationships once (#1762) --- crates/pptx/src/read.rs | 5 +++++ crates/pptx/src/read/dml.rs | 31 ++++++++++++++++++++++++++----- crates/pptx/src/read/shapes.rs | 3 ++- 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/crates/pptx/src/read.rs b/crates/pptx/src/read.rs index 288e497f8..2056d53d7 100644 --- a/crates/pptx/src/read.rs +++ b/crates/pptx/src/read.rs @@ -46,6 +46,9 @@ pub struct Imp<'a> { pub tag_bytes: usize, /// The tag budget ran out and the warning was given. pub tags_capped: bool, + /// Whether a part has relationships of its own, by normalized part name: looked up once + /// however many embedded objects point at the part. + pub has_rels: HashMap, } impl Imp<'_> { @@ -446,6 +449,7 @@ pub fn import_theme(bytes: &[u8]) -> Result<(deckcraft_model::theme::Theme, Vec< tags_by_part: HashMap::new(), tag_bytes: 0, tags_capped: false, + has_rels: HashMap::new(), }; let part = Part::new(&pkg, &theme_path, &doc); let theme = theme::read_theme(&mut imp, &part, &doc.root); @@ -487,6 +491,7 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec), PptxE tags_by_part: HashMap::new(), tag_bytes: 0, tags_capped: false, + has_rels: HashMap::new(), }; // Slide ids first so hyperlinks anywhere can point at slides. let slide_ids = register_slides(&mut imp, &docs, &slide_entries, &mut lost_slides); diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index 2d82fa36a..c8902e4c7 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -664,12 +664,22 @@ fn push_text_runs(para: &mut Paragraph, props: RunProps, text: &str) { /// Appends an Office Math (a14:m) run: its plain text and its OMML. fn push_math_run(para: &mut Paragraph, part: &Part, e: &El) { if para.runs.len() < MAX_PARAGRAPH_RUNS { - let mut text = String::new(); - let mut ts = vec![]; - e.find_all("t", &mut ts); - for t in ts { - text.push_str(&t.text()); + // The text of the outermost `t` elements: a `t` nested in another is already part of + // the outer one's text, and taking it again would copy it once per nesting level. + fn outer_t(e: &El, depth: usize, text: &mut String) { + if depth > crate::xml::MAX_DEPTH { + return; + } + for c in e.elements() { + if c.is("t") { + text.push_str(&c.text()); + } else { + outer_t(c, depth + 1, text); + } + } } + let mut text = String::new(); + outer_t(e, 0, &mut text); let omml = e.elements().map(|c| part.keep_inner(c)).collect::(); para.runs.push(Run { text, props: RunProps::default(), kind: RunKind::Math { omml } }); } @@ -714,6 +724,17 @@ mod tests { use super::*; use crate::xml::parse; + #[test] + fn nested_math_text_is_taken_once() { + // 200 `m:t` nested in each other around one word: the word, not 200 copies of it. + let x = format!(r#"{}word{}"#, "".repeat(200), "".repeat(200)); + let d = parse(x.as_bytes()).unwrap(); + let part = Part { name: "ppt/slides/slide1.xml".into(), rels: Default::default(), ns: vec![], inner_ns: vec![] }; + let mut para = Paragraph::default(); + push_math_run(&mut para, &part, &d.root); + assert_eq!(para.runs[0].text, "word"); + } + #[test] fn colors_and_transforms() { let d = parse( diff --git a/crates/pptx/src/read/shapes.rs b/crates/pptx/src/read/shapes.rs index b3e3d48e6..3230db100 100644 --- a/crates/pptx/src/read/shapes.rs +++ b/crates/pptx/src/read/shapes.rs @@ -475,7 +475,8 @@ fn frame(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> S .into_iter() .map(|id| { let rel = part.rels.get(&id).filter(|r| !r.external)?; - if !imp.pkg.rels(&rel.target).items.is_empty() { + let pkg = imp.pkg; + if *imp.has_rels.entry(crate::opc::norm(&rel.target)).or_insert_with(|| !pkg.rels(&rel.target).items.is_empty()) { return None; } let (kind, target) = (rel.ty.clone(), rel.target.clone()); From d2a8be197d1e5674f6fcf2193e38f8a97898380a Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 20:05:11 -0500 Subject: [PATCH 58/79] geom: measure each follow band once when mapping warped lines (#1758) --- crates/geom/src/warp.rs | 28 +++++++++++++++++++--------- crates/geom/src/warp/tests.rs | 17 +++++++++++++++++ 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/crates/geom/src/warp.rs b/crates/geom/src/warp.rs index d3035d512..81376d7d7 100644 --- a/crates/geom/src/warp.rs +++ b/crates/geom/src/warp.rs @@ -928,11 +928,25 @@ impl Warp { k_fill = 1.0; } zoom = zoom.max(k_fill); + // Per band, measured once: its widest line and its first and last lines' baselines. + let mut stats: Vec> = vec![None; nb]; + for (l, &b) in lines.iter().zip(&band_of) { + let w = l.extent.1 - l.extent.0; + if let Some(slot) = stats.get_mut(b) { + match slot { + Some((widest, _, last)) => { + *widest = widest.max(w); + *last = l.ref_y; + } + None => *slot = Some((0.0f64.max(w), l.ref_y, l.ref_y)), + } + } + } let mut maps = Vec::with_capacity(n); let mut offsets = Vec::new(); for (i, l) in lines.iter().enumerate() { let b = band_of.get(i).copied().unwrap_or(0); - let in_band = |j: &usize| band_of.get(*j) == Some(&b); + let band = stats.get(b).copied().flatten(); match self.bands.get(b) { Some(Band::Fill { .. }) | None => { let (y0, y1) = band_y.get(b).copied().flatten().unwrap_or((0.0, 1.0)); @@ -941,16 +955,12 @@ impl Warp { } Some(Band::Follow { path, anchor }) => { let len = path.length(); - let widest = (0..n).filter(in_band).filter_map(|j| lines.get(j)).map(|l| l.extent.1 - l.extent.0).fold(0.0, f64::max); + let widest = band.map_or(0.0, |s| s.0); let k = if widest > len && widest > 1e-9 { len / widest } else { 1.0 }; - let members: Vec<&WarpLine> = (0..n).filter(in_band).filter_map(|j| lines.get(j)).collect(); let ref_y = match anchor { - LineAnchor::Last => members.last().map(|l| l.ref_y), - LineAnchor::First => members.first().map(|l| l.ref_y), - LineAnchor::Middle => match (members.first(), members.last()) { - (Some(a), Some(z)) => Some((a.ref_y + z.ref_y) / 2.0), - _ => None, - }, + LineAnchor::Last => band.map(|s| s.2), + LineAnchor::First => band.map(|s| s.1), + LineAnchor::Middle => band.map(|s| (s.1 + s.2) / 2.0), } .unwrap_or(l.ref_y); // Lines off the guide run on a parallel curve at their natural length. diff --git a/crates/geom/src/warp/tests.rs b/crates/geom/src/warp/tests.rs index c85b2b0ae..c4aa1bbd0 100644 --- a/crates/geom/src/warp/tests.rs +++ b/crates/geom/src/warp/tests.rs @@ -319,3 +319,20 @@ fn arch_and_inflate_handles_follow_the_pointer() { assert!(more > adjust_defaults("textInflate")[0], "{more}"); assert!((more - 20_000.0).abs() < 1.0, "{more}"); } + +#[test] +fn many_lines_on_a_follow_band_are_mapped_from_one_measurement() { + // 50,000 lines on an arch: each line's scale and anchor come from the band measured once. + let w = warp("textArchUp", &[], rect()).unwrap(); + let inner = Rect::new(7.2, 3.6, W - 7.2, H - 3.6); + let lines: Vec = (0..50_000) + .map(|i| { + let y = i as f64 * 0.01; + WarpLine { ink: Some(Rect::new(190.0, y, 230.0, y + 1.0)), extent: (190.0, 230.0), ref_y: y + 1.0 } + }) + .collect(); + let m = w.mapper(&lines, inner); + // The last line sits on the arc, as with two lines. + let last = lines.len() - 1; + assert!(m.map(last, Point::new(210.0, lines[last].ref_y)).y.abs() < 0.05); +} From 65d5c28512a4fb452e6d99d6d265eacfdb29bcc5 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 20:05:32 -0500 Subject: [PATCH 59/79] Text: sort a paragraph's tab stops once and find the next by binary search (#1757) * text: sort a paragraph's tab stops once and find the next by binary search * address review: skip the tab-stop lookup for a NaN position --- crates/text/src/breaking.rs | 41 ++++++++++++++++++++++++++----------- crates/text/src/tests.rs | 10 +++++++++ 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/crates/text/src/breaking.rs b/crates/text/src/breaking.rs index bb764b64c..9a6028876 100644 --- a/crates/text/src/breaking.rs +++ b/crates/text/src/breaking.rs @@ -260,12 +260,15 @@ pub(crate) struct Tab { align: TabAlign, } +/// The paragraph's tab stops in position order (a stop at no finite position is left out), so +/// [`tab_advance`] finds the next one by binary search instead of a scan per tab. pub(crate) fn tabs_of(props: &ParaProps) -> Vec { - props + let mut tabs: Vec = props .tabs .as_ref() .map(|t| { t.iter() + .filter(|s| s.pos.is_finite()) .map(|s| Tab { pos: s.pos, align: match s.align.as_str() { @@ -277,7 +280,9 @@ pub(crate) fn tabs_of(props: &ParaProps) -> Vec { }) .collect() }) - .unwrap_or_default() + .unwrap_or_default(); + tabs.sort_by(|a, b| a.pos.total_cmp(&b.pos)); + tabs } /// Width of the text after the tab at cell `i` up to the next tab or line break, and the width of @@ -301,18 +306,30 @@ pub(crate) fn tab_segment(cells: &[Cell], i: usize) -> (f64, f64) { /// (`seg`: its width, and its width before the decimal point) as the stop aligns it but never /// moving back, as PowerPoint does; past the last stop, to the next default stop. pub(crate) fn tab_advance(x: f64, stops: &[Tab], default_tab: f64, seg: (f64, f64)) -> f64 { - for s in stops { - if s.pos > x + 0.5 { - let to = s.pos - x; - return match s.align { - TabAlign::Left => to, - TabAlign::Right => (to - seg.0).max(0.0), - TabAlign::Center => (to - seg.0 / 2.0).max(0.0), - TabAlign::Decimal => (to - seg.1).max(0.0), - }; - } + // `stops` is in position order (`tabs_of`): the next stop is the first past x. + // A NaN `x` (a NaN spacing upstream) skips the lookup and takes the finite default-stop path. + if let Some(s) = stops.get(stops.partition_point(|s| s.pos <= x + 0.5)).filter(|_| !x.is_nan()) { + let to = s.pos - x; + return match s.align { + TabAlign::Left => to, + TabAlign::Right => (to - seg.0).max(0.0), + TabAlign::Center => (to - seg.0 / 2.0).max(0.0), + TabAlign::Decimal => (to - seg.1).max(0.0), + }; } let d = if default_tab > 1.0 { default_tab } else { 72.0 }; let next = ((x / d).floor() + 1.0) * d; (next - x).max(1.0) } + +#[cfg(test)] +mod tab_tests { + use super::*; + + #[test] + fn a_nan_position_takes_the_finite_default_stop() { + let stops = [Tab { pos: 100.0, align: TabAlign::Left }]; + assert!(tab_advance(f64::NAN, &stops, 72.0, (0.0, 0.0)).is_finite()); + assert_eq!(tab_advance(10.0, &stops, 72.0, (0.0, 0.0)), 90.0); + } +} diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 7dbdbe4f0..a747af045 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1416,6 +1416,16 @@ fn tab_stops_align_the_text_after_them() { assert!((l.lines[0].edges[4].0 - x0(&l) - 72.0).abs() < 0.01); } +#[test] +fn many_tabs_over_many_stops_lay_out_by_position() { + // 20,000 stops given out of order, and 2,000 tabs: each tab finds the next stop by position. + let mut stops: Vec<(f64, &str)> = (1..=20_000).rev().map(|k| (k as f64 * 0.05 + 30.0, "l")).collect(); + stops.push((f64::NAN, "l")); + let text = "a\t".repeat(2_000); + let l = tabbed(&text, &stops, 1.0e6); + assert!((l.lines[0].edges[2].0 - l.inner.x0 - 30.05).abs() < 0.01, "{:?}", &l.lines[0].edges[..3]); +} + #[test] fn a_tab_past_the_end_moves_to_the_next_line() { // PowerPoint: "abcdefghij\tk\tl\tm…" in a narrow box puts the tab that doesn't fit on the next From c6b7de43bc4e69d8a654156bdc7c8c4c47d5508a Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 20:05:53 -0500 Subject: [PATCH 60/79] engine: use sets for slide-range expansion, added slide selection and repeated-word offsets (#1756) --- crates/engine/src/cmd/file.rs | 13 ++++++++++++- crates/engine/src/cmd/review.rs | 2 +- crates/engine/src/cmd/slide.rs | 3 ++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 8943eb9a3..4f8580c86 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -397,6 +397,7 @@ pub fn pdf_options(p: &Value, count: usize) -> Result pub fn parse_slide_range(text: &str, count: usize) -> Result> { let invalid = || bad("file.export", format!("“{text}” isn't a valid slide range: use numbers and ranges such as 1-3, 5")); let mut out: Vec = vec![]; + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); for part in text.split([',', ';']).map(str::trim).filter(|p| !p.is_empty()) { let (a, b) = match part.split_once('-') { Some((a, b)) => (a.trim(), b.trim()), @@ -412,7 +413,7 @@ pub fn parse_slide_range(text: &str, count: usize) -> Result> { return Err(bad("file.export", format!("the presentation has {count} slides; “{part}” is outside them"))); } for i in lo - 1..hi { - if !out.contains(&i) { + if seen.insert(i) { out.push(i); } } @@ -776,6 +777,16 @@ mod tests { use super::{parse_slide_range, write_file}; + #[test] + fn broad_and_repeated_slide_ranges_expand_once() { + // 200 copies of a 100,000-slide range: each index once, in order, without a scan per index. + let text = vec!["1-100000"; 200].join(","); + let v = parse_slide_range(&text, 100_000).unwrap(); + assert_eq!(v.len(), 100_000); + assert!(v.windows(2).all(|w| w[0] < w[1])); + assert_eq!(parse_slide_range("3, 1-3, 2", 3).unwrap(), vec![2, 0, 1]); + } + #[test] fn slide_range_rejects_plus_sign() { assert!(parse_slide_range("+2", 3).is_err()); diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 92637741f..47cf1ff24 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -367,7 +367,7 @@ fn spelling(s: &mut Session, p: &Value) -> Result { } out.push(v); } - let rep: Vec = crate::spell::repeated(para, &default).iter().map(|w| w.start).collect(); + let rep: std::collections::HashSet = crate::spell::repeated(para, &default).iter().map(|w| w.start).collect(); for w in crate::spell::paragraph_words(para, &default) { if ignore.contains(&w.text.to_lowercase()) || rep.contains(&w.start) { continue; diff --git a/crates/engine/src/cmd/slide.rs b/crates/engine/src/cmd/slide.rs index 198f1bc6c..f15cf32fa 100644 --- a/crates/engine/src/cmd/slide.rs +++ b/crates/engine/src/cmd/slide.rs @@ -408,8 +408,9 @@ fn select_slides(s: &mut Session, p: &Value) -> Result { }; s.select(|doc, sel| { if add { + let mut have: std::collections::HashSet<_> = sel.slides.iter().copied().collect(); for i in &ids { - if !sel.slides.contains(i) { + if have.insert(*i) { sel.slides.push(*i); } } From f5511d93b60ab0e1803fca825c97b079d56d2462 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 20:12:03 -0500 Subject: [PATCH 61/79] Compare: index slide, shape and media lookups and compare each picture pair once (#1752) * engine: index review.compare lookups and compare each picture pair once * engine: keep the compare test's inserted slides empty --- crates/engine/src/compare.rs | 130 ++++++++++++++++++++++++++++++----- 1 file changed, 112 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/compare.rs b/crates/engine/src/compare.rs index bf8ccf4ac..cb89d08f5 100644 --- a/crates/engine/src/compare.rs +++ b/crates/engine/src/compare.rs @@ -121,9 +121,10 @@ pub fn diff(cur: &Presentation, rev: &Presentation, by_position: bool) -> Vec = pairs.iter().map(|p| p.0).collect(); let rev_matched: HashSet = pairs.iter().map(|p| p.1).collect(); let mut out = vec![]; + let mut media = Media::new(cur, rev); for (i, j) in &pairs { let (Some(a), Some(b)) = (cur.slides.get(*i), rev.slides.get(*j)) else { continue }; - let details = slide_details(cur, a, rev, b); + let details = slide_details(cur, a, rev, b, &mut media); if !details.is_empty() { out.push(Change { kind: ChangeKind::Changed, slide: *i, number: i.saturating_add(1), only_in: None, revised: Some(*j), details }); } @@ -140,10 +141,18 @@ pub fn diff(cur: &Presentation, rev: &Presentation, by_position: bool) -> Vec = HashMap::new(); + for p in &pairs { + rev_to_cur.entry(p.1).or_insert(p.0); + } + // After the open slide that matches the nearest matched revised slide before it. + let mut at = 0; for (j, s) in rev.slides.iter().enumerate() { + if let Some(i) = rev_to_cur.get(&j) { + at = i + 1; + } if !rev_matched.contains(&j) { - // After the open slide that matches the revised slide before it. - let at = (0..j).rev().find_map(|k| pairs.iter().find(|p| p.1 == k).map(|p| p.0 + 1)).unwrap_or(0); out.push(Change { kind: ChangeKind::Inserted, slide: at, @@ -174,8 +183,34 @@ fn shape_label(s: &Shape) -> String { if s.name.trim().is_empty() { format!("Shape {}", s.id.0) } else { s.name.clone() } } -fn media_bytes(p: &Presentation, id: MediaId) -> Option<&[u8]> { - p.media(id).map(|m| m.data.as_slice()) +/// Both decks' media bytes by id, and which picture pairs were already compared: a deck can +/// show one picture many times, and its bytes are compared once per pair. +struct Media<'a> { + cur: HashMap, + rev: HashMap, + same: HashMap<(MediaId, MediaId), bool>, +} + +impl<'a> Media<'a> { + fn new(cur: &'a Presentation, rev: &'a Presentation) -> Self { + // The first item with an id wins, as in `Presentation::media`. + let index = |p: &'a Presentation| { + let mut m = HashMap::new(); + for x in &p.media { + m.entry(x.id).or_insert(x.data.as_slice()); + } + m + }; + Media { cur: index(cur), rev: index(rev), same: HashMap::new() } + } + /// Whether the pictures (by media id, `None` for a shape that is not a picture) differ. + fn differ(&mut self, a: Option, b: Option) -> bool { + let (ba, bb) = (a.and_then(|x| self.cur.get(&x)), b.and_then(|y| self.rev.get(&y))); + match (a, b) { + (Some(x), Some(y)) if ba.is_some() && bb.is_some() => !*self.same.entry((x, y)).or_insert_with(|| ba == bb), + _ => ba.is_some() || bb.is_some(), + } + } } /// A shape without its ids (shape and media), for comparing across decks. @@ -204,7 +239,7 @@ fn turn(ctx: Option<&Ctx>, s: &Shape) -> (f64, bool, bool) { drawn_box(ctx, s).map_or((0.0, false, false), |x| (x.rot, x.flip_h, x.flip_v)) } -fn shape_details(cp: &Presentation, ca: Option<&Ctx>, a: &Shape, rp: &Presentation, cb: Option<&Ctx>, b: &Shape, out: &mut Vec) { +fn shape_details(ca: Option<&Ctx>, a: &Shape, cb: Option<&Ctx>, b: &Shape, media: &mut Media, out: &mut Vec) { let label = shape_label(b); let text = |s: &Shape| s.text.as_ref().map(|t| t.text()).unwrap_or_default(); let mut what = vec![]; @@ -234,11 +269,11 @@ fn shape_details(cp: &Presentation, ca: Option<&Ctx>, a: &Shape, rp: &Presentati if a.fill != b.fill || a.line != b.line || a.style != b.style || a.effects != b.effects { what.push("formatting"); } - let pic = |p: &Presentation, s: &Shape| match &s.kind { - ShapeKind::Picture { fill } => media_bytes(p, fill.media).map(|b| b.to_vec()), + let pic = |s: &Shape| match &s.kind { + ShapeKind::Picture { fill } => Some(fill.media), _ => None, }; - if pic(cp, a) != pic(rp, b) { + if media.differ(pic(a), pic(b)) { what.push("picture"); } // Anything the checks above do not cover (alt text, hidden, a link…) is reported too, also @@ -265,21 +300,32 @@ fn shape_details(cp: &Presentation, ca: Option<&Ctx>, a: &Shape, rp: &Presentati } } -fn slide_details(cp: &Presentation, a: &Slide, rp: &Presentation, b: &Slide) -> Vec { +fn slide_details(cp: &Presentation, a: &Slide, rp: &Presentation, b: &Slide, media: &mut Media) -> Vec { let mut out = vec![]; let mut used: HashSet = HashSet::new(); let (ca, cb) = (Ctx::for_slide(cp, a), Ctx::for_slide(rp, b)); + // The revised shapes by id (first wins) and by name (in order, with a cursor past used ones). + let mut by_id: HashMap = HashMap::new(); + let mut by_name: HashMap<&str, (Vec, usize)> = HashMap::new(); + for (k, x) in b.shapes.iter().enumerate() { + by_id.entry(x.id).or_insert(k); + if !x.name.is_empty() { + by_name.entry(x.name.as_str()).or_default().0.push(k); + } + } for sa in &a.shapes { - let k = b - .shapes - .iter() - .position(|x| x.id == sa.id) - .or_else(|| b.shapes.iter().enumerate().position(|(k, x)| !used.contains(&k) && x.name == sa.name && !x.name.is_empty())); + let k = by_id.get(&sa.id).copied().or_else(|| { + let (ks, next) = by_name.get_mut(sa.name.as_str())?; + while ks.get(*next).is_some_and(|k| used.contains(k)) { + *next += 1; + } + ks.get(*next).copied() + }); match k { Some(k) if !used.contains(&k) => { used.insert(k); if let Some(sb) = b.shapes.get(k) { - shape_details(cp, ca.as_ref(), sa, rp, cb.as_ref(), sb, &mut out); + shape_details(ca.as_ref(), sa, cb.as_ref(), sb, media, &mut out); } } _ => out.push(format!("{} deleted (only in the deck)", shape_label(sa))), @@ -334,19 +380,34 @@ pub fn adopt(doc: &mut Presentation, rev: &Presentation, slide: &Slide, replacin let mut sl = slide.clone(); // Media. let mut media_map: HashMap = HashMap::new(); + // Both decks' media by id (the first item with an id wins, as in `Presentation::media`), + // looked up once per reference instead of searched. + let mut rev_at: HashMap = HashMap::new(); + for (i, m) in rev.media.iter().enumerate() { + rev_at.entry(m.id).or_insert(i); + } + let mut doc_at: HashMap = HashMap::new(); + for (i, m) in doc.media.iter().enumerate() { + doc_at.entry(m.id).or_insert(i); + } for id in crate::media_refs::slide_media(&sl) { - let Some(m) = rev.media(id) else { continue }; - match doc.media(id) { + let Some(m) = rev_at.get(&id).and_then(|&i| rev.media.get(i)) else { continue }; + match doc_at.get(&id).and_then(|&i| doc.media.get(i)) { Some(x) if x.data == m.data => {} None => { // Keeps the revision's id, so later ids must start past it. + doc_at.insert(id, doc.media.len()); doc.media.push(m.clone()); doc.next_id = doc.next_id.max(id.0); } Some(_) => { + let before = doc.media.len(); let new = doc .add_media(&m.name, &m.content_type, m.data.as_ref().clone()) .ok_or_else(|| crate::cmd::bad("review.accept", crate::cmd::NO_ID_SPACE))?; + if doc.media.len() > before { + doc_at.entry(new).or_insert(before); + } media_map.insert(id, new); } } @@ -519,6 +580,39 @@ mod tests { assert_eq!(s.doc().unwrap().doc.slides.len(), n, "comparing alone changes nothing"); } + #[test] + fn many_inserted_slides_and_same_named_pictures_compare_by_index() { + use std::sync::Arc; + + use deckcraft_model::style::PictureFill; + use deckcraft_model::{Shape, ShapeId, ShapeKind, SlideId, defaults}; + + // One slide of 3,000 pictures of one image, all named "Box"; the revision has the same + // pictures under other ids (matched by name, in order) and 3,000 slides inserted after it. + let mut cur = defaults::new_presentation(None); + let img = cur.add_media("a.png", "image/png", vec![7; 4096]).unwrap(); + let mut rev = cur.clone(); + let pic = |id: u32| Shape { + id: ShapeId(id), + name: "Box".into(), + xfrm: Some(deckcraft_model::Xfrm::new(10.0, 10.0, 50.0, 50.0)), + kind: ShapeKind::Picture { fill: PictureFill { media: img, ..Default::default() } }, + ..Default::default() + }; + Arc::make_mut(&mut cur.slides[0]).shapes = (0..3000).map(|k| pic(100_000 + k)).collect(); + Arc::make_mut(&mut rev.slides[0]).shapes = (0..3000).map(|k| pic(200_000 + k)).collect(); + let mut base = (*rev.slides[0]).clone(); + base.shapes.clear(); + for k in 0..3000 { + let mut s = base.clone(); + s.id = SlideId(300_000 + k); + rev.slides.push(Arc::new(s)); + } + let changes = super::diff(&cur, &rev, false); + assert_eq!(changes.len(), 3000, "only the inserted slides differ"); + assert!(changes.iter().all(|c| c.kind == super::ChangeKind::Inserted && c.slide == 1)); + } + /// A deck with one slide: a table whose cell has a picture fill, a picture background and a /// transition sound. `tag` starts the bytes of each. fn media_deck(tag: &str) -> deckcraft_model::Presentation { From 3c3d504eb15243a6517d1a1d8b841082a2edef9b Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 20:12:24 -0500 Subject: [PATCH 62/79] PPTX: read a notes or comments part for the first slide that names it (#1741) * pptx: read a notes or comments part for the first slide that names it * address review: keep modern-comments precedence for already-owned parts --- crates/pptx/src/read.rs | 37 +++++++++++++++++-------------- crates/pptx/tests/it/comments.rs | 27 ++++++++++++++++++++++ crates/pptx/tests/it/malformed.rs | 31 ++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 17 deletions(-) diff --git a/crates/pptx/src/read.rs b/crates/pptx/src/read.rs index 2056d53d7..4ee3c4d32 100644 --- a/crates/pptx/src/read.rs +++ b/crates/pptx/src/read.rs @@ -668,52 +668,55 @@ fn read_slides( let pkg = imp.pkg; let fallback_layout = p.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id).unwrap_or_default(); let mut file_slide_ids: HashMap = HashMap::new(); - let mut notes_by_target: HashMap = HashMap::new(); + // A notes or comments part belongs to one slide; one shared by many must not be read (and + // copied) once per slide that names it. + let mut owned_parts: HashSet = HashSet::new(); for (file_id, path, id) in slide_ids { let Some(doc) = docs.get(&opc::norm(path)) else { continue }; let part = Part::new(pkg, path, doc); let layout = part.rels.first_of("slideLayout").and_then(|r| layout_by_part.get(&opc::norm(&r.target)).copied()).unwrap_or(fallback_layout); let mut slide = read_slide(imp, &part, doc, *id, layout); - if let Some(notes) = slide_notes(imp, &part, &mut notes_by_target) { + if let Some(notes) = slide_notes(imp, &part, &mut owned_parts) { slide.notes = notes; } - read_slide_comments(pkg, pres_part, &part, &mut slide); + read_slide_comments(pkg, pres_part, &part, &mut slide, &mut owned_parts); file_slide_ids.insert(*file_id, *id); p.slides.push(Arc::new(slide)); } file_slide_ids } -/// The slide's notes text, read once per notes part. -fn slide_notes(imp: &mut Imp, part: &Part, cache: &mut HashMap) -> Option { +/// The slide's notes text. A notes part is the notes of the first slide that names it. +fn slide_notes(imp: &mut Imp, part: &Part, owned: &mut HashSet) -> Option { let n = part.rels.first_of("notesSlide")?; - let key = opc::norm(&n.target); - if let Some(cached) = cache.get(&key) { - return Some(cached.clone()); + if !owned.insert(opc::norm(&n.target)) { + log::warn!("pptx: notes part {} is named by more than one slide; kept on the first", n.target); + return None; } let pkg = imp.pkg; let nd = load(pkg, &n.target)?; let np = Part::new(pkg, &n.target, &nd); - let notes = read_notes(imp, &np, &nd.root); - cache.insert(key, notes.clone()); - Some(notes) + Some(read_notes(imp, &np, &nd.root)) } /// Comments. A modern part and the legacy part can describe the same threads (PowerPoint /// writes both): the modern one wins, its flattened legacy copy is skipped. -fn read_slide_comments(pkg: &Package, pres_part: &Part, part: &Part, slide: &mut Slide) { - let mut parts: Vec<(Doc, bool)> = vec![]; +/// Each comments part is read once, for the first slide that names it. +fn read_slide_comments(pkg: &Package, pres_part: &Part, part: &Part, slide: &mut Slide, owned: &mut HashSet) { + let mut parts: Vec<(Doc, bool, bool)> = vec![]; for c in part.rels.all_of("comments") { + // An already-owned part is not re-read, but its kind still decides modern precedence. + let fresh = owned.insert(opc::norm(&c.target)); if let Some(cd) = pkg.xml(&c.target) { // The content type names the modern part; a package that types it only through a // Default entry instead of an Override is still modern when its root is p188. let modern = pkg.content_type(&c.target).as_deref() == Some(crate::write::CT_COMMENTS_MODERN) || cd.root.prefix() == "p188"; - parts.push((cd, modern)); + parts.push((cd, modern, fresh)); } } - let has_modern = parts.iter().any(|(_, m)| *m); - for (cd, modern) in parts { - if has_modern && !modern { + let has_modern = parts.iter().any(|(_, m, _)| *m); + for (cd, modern, fresh) in parts { + if !fresh || (has_modern && !modern) { continue; } slide.comments.extend(read_comments(pkg, pres_part, &cd.root, modern)); diff --git a/crates/pptx/tests/it/comments.rs b/crates/pptx/tests/it/comments.rs index 9713b0f42..9b0e85bde 100644 --- a/crates/pptx/tests/it/comments.rs +++ b/crates/pptx/tests/it/comments.rs @@ -146,6 +146,33 @@ fn comments_on_several_slides_round_trip() { assert_eq!(q.slides[2].comments[0].text, "Slide three"); } +/// A slide that names a modern part another slide already owns, plus its own legacy part, must +/// not import the legacy copy: modern precedence holds even when the modern part is not re-read. +#[test] +fn owned_modern_part_still_suppresses_later_legacy() { + let mut p = deck(); + let layout = p.slides[0].layout; + let s2 = defaults::new_slide(&mut p, layout).expect("slide"); + p.slides.push(Arc::new(s2)); + Arc::make_mut(&mut p.slides[1]).comments = vec![Comment { author: "Cara".into(), text: "Slide two".into(), ..Default::default() }]; + let bytes = deckcraft_pptx::export(&p).expect("export"); + let rels2 = part(&bytes, "ppt/slides/_rels/slide2.xml.rels").replace("modernComment2.xml", "modernComment1.xml"); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).expect("zip"); + let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for i in 0..z.len() { + let mut f = z.by_index(i).expect("entry"); + out.start_file(f.name(), zip::write::SimpleFileOptions::default()).expect("start"); + if f.name() == "ppt/slides/_rels/slide2.xml.rels" { + std::io::Write::write_all(&mut out, rels2.as_bytes()).expect("write"); + } else { + std::io::copy(&mut f, &mut out).expect("copy"); + } + } + let q = deckcraft_pptx::import(&out.finish().expect("finish").into_inner()).expect("import"); + assert_eq!(q.slides[0].comments.len(), 2); + assert!(q.slides[1].comments.is_empty(), "{:?}", q.slides[1].comments); +} + /// A legacy-only package (what older tools read and write) still imports: 1/8 pt positions, /// flattened replies. #[test] diff --git a/crates/pptx/tests/it/malformed.rs b/crates/pptx/tests/it/malformed.rs index 774a5ee92..a07493486 100644 --- a/crates/pptx/tests/it/malformed.rs +++ b/crates/pptx/tests/it/malformed.rs @@ -287,3 +287,34 @@ fn unreadable_slide_is_reported() { assert_eq!(lost, vec!["ppt/slides/slide2.xml".to_string()]); assert_eq!(deckcraft_pptx::import(&broken).expect("import").slides.len(), 1); } + +/// Many slides naming one notes part: it is read for the first slide only, not copied onto every +/// slide that names it. +#[test] +fn a_shared_notes_part_is_read_once() { + use std::sync::Arc; + let mut p = defaults::new_presentation(None); + let layout = p.slides[0].layout; + for _ in 0..3 { + let s = defaults::new_slide(&mut p, layout).expect("slide"); + p.slides.push(Arc::new(s)); + } + for (i, s) in p.slides.iter_mut().enumerate() { + Arc::make_mut(s).notes = deckcraft_model::text::TextBody::from_text(&format!("notes {i}")); + } + let mut es = entries(&deckcraft_pptx::export(&p).expect("export")); + for (n, d) in es.iter_mut() { + if n.starts_with("ppt/slides/_rels/") { + let x = String::from_utf8(d.clone()).expect("utf8"); + let x = x + .replace("notesSlide2.xml", "notesSlide1.xml") + .replace("notesSlide3.xml", "notesSlide1.xml") + .replace("notesSlide4.xml", "notesSlide1.xml"); + *d = x.into_bytes(); + } + } + let q = deckcraft_pptx::import(&rezip(&es)).expect("import"); + assert_eq!(q.slides.len(), 4); + assert_eq!(q.slides[0].notes.text(), "notes 0"); + assert!(q.slides[1..].iter().all(|s| s.notes.text().is_empty()), "a shared notes part is not copied"); +} From 996bb42ff51a5f0d513fde4bd3a25512474009e5 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 20:12:47 -0500 Subject: [PATCH 63/79] pptx: index relationships by id on read and by target on write (#1739) --- crates/pptx/src/opc.rs | 36 +++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/crates/pptx/src/opc.rs b/crates/pptx/src/opc.rs index 20a53bcb0..459a7334c 100644 --- a/crates/pptx/src/opc.rs +++ b/crates/pptx/src/opc.rs @@ -171,6 +171,7 @@ fn rels_of(doc: &xml::Doc, part: &str) -> Rels { let external = r.attr("TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("external")); let target_raw = target.to_string(); let target = if external { target_raw.clone() } else { resolve(&base, target) }; + out.by_id.entry(id.to_string()).or_insert(out.items.len()); out.items.push(Rel { id: id.to_string(), kind: ty.rsplit('/').next().unwrap_or(ty).to_string(), @@ -211,11 +212,13 @@ pub struct Rel { #[derive(Clone, Debug, Default)] pub struct Rels { pub items: Vec, + /// Id → index of its first relationship in `items`. + by_id: HashMap, } impl Rels { pub fn get(&self, id: &str) -> Option<&Rel> { - self.items.iter().find(|r| r.id == id) + self.by_id.get(id).and_then(|&i| self.items.get(i)) } pub fn target(&self, id: &str) -> Option<&str> { self.get(id).map(|r| r.target.as_str()) @@ -339,23 +342,29 @@ pub fn relative(from: &str, to: &str) -> String { #[derive(Default)] pub struct RelsOut { pub items: Vec<(String, String, String, bool)>, + /// (type, target, external) → id of the relationship [`RelsOut::add`] made for it. + added: HashMap<(String, String, bool), String>, } impl RelsOut { /// Add (or reuse) a relationship; `kind` is a full type URI or a suffix of the standard base. pub fn add(&mut self, kind: &str, target: &str, external: bool) -> String { let ty = if kind.starts_with("http") { kind.to_string() } else { format!("{RT_BASE}{kind}") }; - if let Some((id, ..)) = self.items.iter().find(|(_, t, tg, e)| *t == ty && tg == target && *e == external) { + let key = (ty, target.to_string(), external); + if let Some(id) = self.added.get(&key) { return id.clone(); } let id = format!("rId{}", self.items.len() + 1); - self.items.push((id.clone(), ty, target.to_string(), external)); + self.items.push((id.clone(), key.0.clone(), key.1.clone(), external)); + self.added.insert(key, id.clone()); id } /// Always add a new relationship even if an identical one exists. pub fn add_new(&mut self, kind: &str, target: &str, external: bool) -> String { let ty = if kind.starts_with("http") { kind.to_string() } else { format!("{RT_BASE}{kind}") }; let id = format!("rId{}", self.items.len() + 1); + // `add` reuses the first relationship for a target, whichever call made it. + self.added.entry((ty.clone(), target.to_string(), external)).or_insert_with(|| id.clone()); self.items.push((id.clone(), ty, target.to_string(), external)); id } @@ -500,6 +509,27 @@ mod tests { assert!(p.get("e").is_some()); } + #[test] + fn many_relationships_are_found_by_index_and_first_id_wins() { + let n = 50_000; + let mut x = String::from(r#""#); + for i in 0..n { + x.push_str(&format!(r#""#)); + } + x.push_str(&format!(r#""#)); + let rels = rels_from_bytes(x.as_bytes(), "ppt/slides/slide1.xml"); + assert_eq!(rels.target("rId0"), Some("ppt/media/image0.png"), "the first of a repeated id wins"); + assert!((0..n).all(|i| rels.target(&format!("rId{i}")) == Some(&*format!("ppt/media/image{i}.png")))); + + let mut out = RelsOut::default(); + let ids: Vec = (0..n).map(|i| out.add("hyperlink", &format!("https://example.com/{i}"), true)).collect(); + assert_eq!(out.add("hyperlink", "https://example.com/7", true), ids[7], "an identical target reuses its id"); + assert_eq!(out.add("hyperlink", "https://example.com/7", false), format!("rId{}", n + 1), "internal is distinct"); + let fresh = out.add_new("image", "../media/a.png", false); + assert_ne!(out.add_new("image", "../media/a.png", false), fresh); + assert_eq!(out.add("image", "../media/a.png", false), fresh, "add reuses the first add_new made"); + } + #[test] fn backslash_zip_entry_names_are_found() { let z = zip_of(&[("PPT\\Slides\\Slide1.xml", 3)]); From f5fbadde63b4f34efd292932d4ecbbe8b54fdae8 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 20:38:10 -0500 Subject: [PATCH 64/79] Render: evict the least recently used animation instead of clearing the GIF cache (#1802) --- crates/render/src/images.rs | 50 ++++++++++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 6 deletions(-) diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index 0e86be8ce..59a274b14 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -37,7 +37,8 @@ struct Cache { bad_order: VecDeque, bad_bytes: usize, /// GIFs by bytes address and adjustments (`None`: not animated); the bytes stay alive with them. - anims: HashMap<(usize, u64), (Arc>, Option>)>, + /// Decoded animations by (bytes, adjustments), each with the clock of its last use. + anims: HashMap<(usize, u64), (Arc>, Option>, u64)>, } /// Longest side, total decoded size and frame count kept for one animated GIF; the largest canvas @@ -432,16 +433,33 @@ fn anim(bytes: &Arc>, adj: &PictureAdjust) -> Option> { return None; } let key = (Arc::as_ptr(bytes) as usize, adjust_key(adj)); - if let Some(a) = with(|c| c.anims.get(&key).map(|e| e.1.clone())) { + let hit = with(|c| { + c.clock += 1; + let clock = c.clock; + c.anims.get_mut(&key).map(|e| { + e.2 = clock; + e.1.clone() + }) + }); + if let Some(a) = hit { return a; } let a = decode_anim(bytes, adj).map(Arc::new); with(|c| { - let held: usize = c.anims.values().filter_map(|e| e.1.as_ref()).map(|a| a.bytes()).sum(); - if held + a.as_ref().map_or(0, |a| a.bytes()) > GIF_CACHE_BUDGET || c.anims.len() >= 64 { - c.anims.clear(); + // Make room by dropping the least recently used animations, not every one of them, so a + // slide whose animations overflow the cache does not decode all of them again each frame. + let new = a.as_ref().map_or(0, |a| a.bytes()); + loop { + let held: usize = c.anims.values().filter_map(|e| e.1.as_ref()).map(|a| a.bytes()).sum(); + if held + new <= GIF_CACHE_BUDGET && c.anims.len() < 64 { + break; + } + let Some(oldest) = c.anims.iter().min_by_key(|(_, e)| e.2).map(|(k, _)| *k) else { break }; + c.anims.remove(&oldest); } - c.anims.insert(key, (bytes.clone(), a.clone())); + c.clock += 1; + let clock = c.clock; + c.anims.insert(key, (bytes.clone(), a.clone(), clock)); }); a } @@ -535,6 +553,26 @@ mod tests { out.into_inner() } + /// A two-frame GIF whose first pixel is `n`, so each one has its own bytes. + fn gif(n: u8) -> Arc> { + use image::codecs::gif::GifEncoder; + let frame = |c: u8| image::Frame::new(image::RgbaImage::from_pixel(2, 2, image::Rgba([n, c, 0, 255]))); + let mut out = Vec::new(); + GifEncoder::new(&mut out).encode_frames([frame(0), frame(255)]).expect("encode"); + Arc::new(out) + } + + #[test] + fn a_full_animation_cache_drops_the_oldest_entry_not_all_of_them() { + let adj = PictureAdjust::default(); + let gifs: Vec<_> = (0..70).map(gif).collect(); + for g in &gifs { + assert!(anim(g, &adj).is_some()); + } + let (len, newest) = with(|c| (c.anims.len(), c.anims.contains_key(&(Arc::as_ptr(&gifs[69]) as usize, adjust_key(&adj))))); + assert!(newest && len >= 32, "{len} cached"); + } + #[test] fn rgba_conversion_is_refused_past_the_cap() { let png = grey_png(100, 50); From 2dfb07f2c478ba1c09922aadee74c1cecb53dcdb Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 20:38:31 -0500 Subject: [PATCH 65/79] Thumbnails: index section starts once (#1800) --- crates/ui-egui/src/panels/thumbs.rs | 40 ++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/thumbs.rs b/crates/ui-egui/src/panels/thumbs.rs index 15bf9d373..946391c35 100644 --- a/crates/ui-egui/src/panels/thumbs.rs +++ b/crates/ui-egui/src/panels/thumbs.rs @@ -112,6 +112,21 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { } } +/// Slide index -> the first section starting there, built once instead of searched per slide. +fn section_starts(doc: &deckcraft_model::Presentation) -> std::collections::HashMap { + let mut index_of = std::collections::HashMap::new(); + for (i, s) in doc.slides.iter().enumerate() { + index_of.entry(s.id).or_insert(i); + } + let mut starts = std::collections::HashMap::new(); + for (si, s) in doc.sections.iter().enumerate() { + if let Some(&i) = s.slides.first().and_then(|id| index_of.get(id)) { + starts.entry(i).or_insert(si); + } + } + starts +} + /// Render the scrollable thumbnail list, scrolling the current slide into view when it changes. fn list(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); @@ -135,11 +150,10 @@ fn list(app: &mut SlideApp, ui: &mut Ui) { let ppp = ui.ctx().pixels_per_point(); let mut budget = 3u32; let mut rects = vec![]; - let section_starts: Vec<(usize, usize)> = - doc.sections.iter().enumerate().filter_map(|(si, s)| s.slides.first().and_then(|id| doc.slide_index(*id)).map(|i| (i, si))).collect(); + let section_starts = section_starts(&doc); let mut need_more = false; for i in 0..doc.slides.len() { - if let Some((_, si)) = section_starts.iter().find(|(start, _)| *start == i) + if let Some(si) = section_starts.get(&i) && let Some(sec) = doc.sections.get(*si) { ui.add_space(4.0); @@ -434,6 +448,26 @@ mod tests { h } + #[test] + fn section_starts_map_each_first_slide_to_its_first_section() { + let mut s = Session::with_new(); + for _ in 0..3 { + s.execute("slide.new", &json!({})).unwrap(); + } + let mut doc = (*s.active().unwrap().doc).clone(); + let ids: Vec<_> = doc.slides.iter().map(|sl| sl.id).collect(); + doc.sections = vec![ + deckcraft_model::Section { name: "a".into(), slides: vec![ids[0], ids[1]] }, + deckcraft_model::Section { name: "dup".into(), slides: vec![ids[0]] }, + deckcraft_model::Section { name: "c".into(), slides: vec![ids[2]] }, + deckcraft_model::Section { name: "empty".into(), slides: vec![] }, + ]; + let starts = section_starts(&doc); + assert_eq!(starts.get(&0), Some(&0), "the first section starting at a slide wins"); + assert_eq!(starts.get(&2), Some(&2)); + assert_eq!(starts.len(), 2); + } + /// Vertical scroll offset of the thumbnail pane, as recorded each frame by `show`. fn thumbs_scroll(h: &Harness<'_, SlideApp>) -> f32 { h.state().ui.thumbs_scroll From 9d59fa96d3651c9404f1c11c11e47df1fc3062cd Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 20:38:53 -0500 Subject: [PATCH 66/79] Text, morph: share a run's gradient between glyph runs, match morph tokens by text (#1784) --- crates/render/src/morph_text.rs | 32 ++++++++++++++++++++++---------- crates/text/src/run.rs | 2 +- crates/text/src/style.rs | 4 ++-- 3 files changed, 25 insertions(+), 13 deletions(-) diff --git a/crates/render/src/morph_text.rs b/crates/render/src/morph_text.rs index 391ec0ba3..19c83cdb2 100644 --- a/crates/render/src/morph_text.rs +++ b/crates/render/src/morph_text.rs @@ -76,16 +76,12 @@ fn tokens(l: &TextLayout, chars: bool) -> (Vec, Vec<(usize, usize)>) { /// For each token of `b`, the token of `a` with the same text, in reading order, each used once. fn matches(a: &[Token], b: &[Token]) -> Vec> { - let mut used = vec![false; a.len()]; - b.iter() - .map(|tb| { - let i = a.iter().enumerate().position(|(i, ta)| !used.get(i).copied().unwrap_or(true) && ta.key == tb.key)?; - if let Some(u) = used.get_mut(i) { - *u = true; - } - Some(i) - }) - .collect() + // Unused tokens of `a` by text, in reading order, so each lookup is a pop rather than a scan. + let mut free: std::collections::HashMap<&str, std::collections::VecDeque> = std::collections::HashMap::new(); + for (i, ta) in a.iter().enumerate() { + free.entry(ta.key.as_str()).or_default().push_back(i); + } + b.iter().map(|tb| free.get_mut(tb.key.as_str())?.pop_front()).collect() } fn layout(ctx: &Ctx, s: &Shape, x: Xfrm, fields: &dyn Fields) -> Option { @@ -228,3 +224,19 @@ pub(crate) fn draw( pub(crate) fn token_keys(l: &TextLayout, chars: bool) -> Vec { tokens(l, chars).0.into_iter().map(|t| t.key).collect() } + +#[cfg(test)] +mod match_tests { + use super::*; + + fn tok(k: &str) -> Token { + Token { key: k.into(), glyphs: Vec::new(), origin: Point::ZERO, run: 0 } + } + + #[test] + fn each_source_token_is_used_once_in_reading_order() { + let a: Vec = ["a", "b", "a"].map(tok).into(); + let b: Vec = ["a", "a", "a", "b", "c"].map(tok).into(); + assert_eq!(matches(&a, &b), vec![Some(0), Some(2), None, Some(1), None]); + } +} diff --git a/crates/text/src/run.rs b/crates/text/src/run.rs index d7e9dbeea..56e6ead7c 100644 --- a/crates/text/src/run.rs +++ b/crates/text/src/run.rs @@ -22,7 +22,7 @@ pub struct GlyphRun { pub link: bool, /// Opacity of the fill on top of `color`'s own alpha (0 for no fill). pub alpha: f64, - pub gradient: Option, + pub gradient: Option>, /// Shadow and glow behind the glyphs (`a:rPr/a:effectLst`). pub effects: Option, /// East Asian vertical text (`eaVert`): each glyph stands upright, turned a quarter turn diff --git a/crates/text/src/style.rs b/crates/text/src/style.rs index e1289c520..b61359faf 100644 --- a/crates/text/src/style.rs +++ b/crates/text/src/style.rs @@ -46,7 +46,7 @@ pub(crate) struct Style { pub(crate) highlight: Option, pub(crate) outline: Option<(Rgba, f64)>, pub(crate) link: bool, - pub(crate) gradient: Option, + pub(crate) gradient: Option>, pub(crate) effects: Option, /// "Regular", "Bold"… for the slot and fallback faces. pub(crate) style_name: &'static str, @@ -136,7 +136,7 @@ pub(crate) fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option (Rgba::TRANSPARENT, 0.0, None), _ => (resolve::text_color(ctx, r), 1.0, None), From a790fdbc2080e524b2ea5f7e18eda4cc3e787598 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 20:39:14 -0500 Subject: [PATCH 67/79] Render: find a glyph's line by bisection for gradient and WordArt text (#1783) --- crates/render/src/text_draw.rs | 112 ++++++++++++++++++++++++++++++++- crates/render/src/warp.rs | 10 +-- 2 files changed, 115 insertions(+), 7 deletions(-) diff --git a/crates/render/src/text_draw.rs b/crates/render/src/text_draw.rs index 3fe96d694..d2400f19f 100644 --- a/crates/render/src/text_draw.rs +++ b/crates/render/src/text_draw.rs @@ -105,6 +105,7 @@ pub fn draw_layout(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, tr: pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, tr: Rect, m: Affine, paras: &[(usize, ParaState)]) { let para = |i: usize| paras.iter().find(|(p, _)| *p == i).map(|(_, s)| *s).unwrap_or_default(); let m = text_transform(m, tr, l.rotation); + let lines_of = LineIndex::new(l); let deco = |ctx: &mut RenderContext, d: &deckcraft_text::Deco| { let ps = para(d.para); if !ps.visible || ps.opacity <= 0.001 { @@ -144,7 +145,7 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, draw_glyph(ctx, run, *gid, at, ps.opacity); continue; }; - let li = line_at(l, run.para, *y); + let li = lines_of.line_at(run.para, *y); if !grads.iter().any(|(i, _)| *i == li) { let scheme = deckcraft_color::ColorScheme { name: String::new(), colors: [Rgba::BLACK; 12] }; let stops = g.stops.iter().map(|s| (s.pos, s.color.resolve(&scheme, None))).collect(); @@ -191,8 +192,78 @@ pub(crate) fn fill_deco(ctx: &mut RenderContext, d: &deckcraft_text::Deco) { } } -/// The line of paragraph `para` holding a glyph on baseline `y` (the nearest baseline). -fn line_at(l: &deckcraft_text::TextLayout, para: usize, y: f64) -> usize { +/// Finds the line of a paragraph holding a glyph on baseline `y` without scanning the paragraph's +/// lines for every glyph. A paragraph whose lines are ordered top to bottom (every layout the text +/// crate produces) is searched by bisection; any other falls back to a scan with the same answer. +pub(crate) struct LineIndex<'a> { + l: &'a deckcraft_text::TextLayout, + /// Per paragraph: its lines' tops, bottoms and baselines all rise with the line index. + ordered: Vec, +} + +impl<'a> LineIndex<'a> { + pub(crate) fn new(l: &'a deckcraft_text::TextLayout) -> Self { + let ordered = l + .para_lines + .iter() + .map(|&(first, count)| { + let end = first.saturating_add(count).min(l.lines.len()); + l.lines.get(first..end).is_some_and(|ls| { + ls.iter().all(|a| a.top <= a.bottom) + && ls.windows(2).all(|w| w[0].top <= w[1].top && w[0].bottom <= w[1].bottom && w[0].baseline <= w[1].baseline) + }) + }) + .collect(); + LineIndex { l, ordered } + } + + /// The nearest line baseline to `y` among `lines` (the first of equals), as an index into `lines`. + fn nearest(lines: &[deckcraft_text::LineInfo], y: f64) -> Option { + let i = lines.partition_point(|li| li.baseline < y); + let best = match (i.checked_sub(1), lines.get(i)) { + (Some(p), Some(n)) => { + if (lines[p].baseline - y).abs() <= (n.baseline - y).abs() { + p + } else { + i + } + } + (Some(p), None) => p, + (None, Some(_)) => i, + (None, None) => return None, + }; + let b = lines[best].baseline; + Some(lines.partition_point(|li| li.baseline < b)) + } + + /// The paragraph's lines when they are ordered, with the index of the first. + fn ordered_lines(&self, para: usize) -> Option<(usize, &[deckcraft_text::LineInfo])> { + let &(first, count) = self.l.para_lines.get(para)?; + let end = first.saturating_add(count).min(self.l.lines.len()); + (self.ordered[para] && first < end).then(|| (first, &self.l.lines[first..end])) + } + + /// The line of paragraph `para` holding a glyph on baseline `y` (the nearest baseline). + pub(crate) fn line_at(&self, para: usize, y: f64) -> usize { + if let Some((first, ls)) = self.ordered_lines(para) { + return Self::nearest(ls, y).map_or(0, |i| first + i); + } + line_at_scan(self.l, para, y) + } + + /// The first line of paragraph `para` whose box holds `y`, else the nearest baseline's line. + pub(crate) fn line_of(&self, para: usize, y: f64) -> Option { + let Some((first, ls)) = self.ordered_lines(para) else { return crate::warp::line_of_scan(self.l, para, y) }; + let i = ls.partition_point(|li| li.bottom + 0.01 < y); + if ls.get(i).is_some_and(|li| y >= li.top - 0.01) { + return Some(first + i); + } + Self::nearest(ls, y).map(|i| first + i) + } +} + +/// The line of paragraph `para` holding a glyph on baseline `y` (the nearest baseline), by scanning. +fn line_at_scan(l: &deckcraft_text::TextLayout, para: usize, y: f64) -> usize { if let Some(&(first, count)) = l.para_lines.get(para) { if count == 0 { return 0; @@ -318,3 +389,38 @@ fn draw_glyph_with( ctx.stroke_path(&path); } } + +#[cfg(test)] +mod line_index_tests { + use super::*; + use deckcraft_text::{LineInfo, TextLayout}; + + fn layout(ordered: bool) -> TextLayout { + let line = |para, top: f64| LineInfo { para, top, baseline: top + 8.0, bottom: top + 10.0, ..Default::default() }; + let mut lines: Vec = (0..40).map(|i| line(usize::from(i >= 25), f64::from(i) * 10.0)).collect(); + if !ordered { + lines.swap(3, 7); + } + TextLayout { lines, para_lines: vec![(0, 25), (25, 15)], ..Default::default() } + } + + #[test] + fn bisection_matches_the_scan() { + for ordered in [true, false] { + let l = layout(ordered); + let idx = LineIndex::new(l_ref(&l)); + assert_eq!(idx.ordered, vec![ordered, true]); + for para in 0..2 { + for k in -20..450 { + let y = f64::from(k) * 0.9; + assert_eq!(idx.line_at(para, y), line_at_scan(&l, para, y), "line_at {para} {y}"); + assert_eq!(idx.line_of(para, y), crate::warp::line_of_scan(&l, para, y), "line_of {para} {y}"); + } + } + } + } + + fn l_ref(l: &TextLayout) -> &TextLayout { + l + } +} diff --git a/crates/render/src/warp.rs b/crates/render/src/warp.rs index 58caa2ead..72fcbbbe5 100644 --- a/crates/render/src/warp.rs +++ b/crates/render/src/warp.rs @@ -9,13 +9,14 @@ use deckcraft_text::{GlyphRun, TextLayout}; use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as _}; use vello_cpu::{RenderContext, peniko}; +use crate::text_draw::LineIndex; use crate::{ParaState, color, glyph, scale_of, text_transform}; /// Most points one warped glyph may have (a hostile size or zoom can't explode the work). const MAX_POINTS: usize = 40_000; -/// The line of `l` a glyph of paragraph `para` on baseline `y` belongs to. -fn line_of(l: &TextLayout, para: usize, y: f64) -> Option { +/// The line of `l` a glyph of paragraph `para` on baseline `y` belongs to, by scanning. +pub(crate) fn line_of_scan(l: &TextLayout, para: usize, y: f64) -> Option { if let Some(&(first, count)) = l.para_lines.get(para) { if count == 0 { return None; @@ -110,6 +111,7 @@ pub(crate) fn draw(ctx: &mut RenderContext, l: &TextLayout, tr: Rect, m: Affine, let para = |i: usize| paras.iter().find(|(p, _)| *p == i).map(|(_, s)| *s).unwrap_or_default(); let m = text_transform(m, tr, l.rotation); let n = l.lines.len(); + let lines_of = LineIndex::new(l); let mut ink: Vec> = vec![None; n]; let mut off: Vec> = vec![None; n]; let mut items: Vec = Vec::new(); @@ -117,7 +119,7 @@ pub(crate) fn draw(ctx: &mut RenderContext, l: &TextLayout, tr: Rect, m: Affine, let k = run.size / run.face.upem.max(1.0); let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; for (gid, x, y) in &run.glyphs { - let Some(li) = line_of(l, run.para, *y) else { continue }; + let Some(li) = lines_of.line_of(run.para, *y) else { continue }; if let Some(o) = off.get_mut(li) { *o = Some(o.map_or(ref_offset(run), |v| v.max(ref_offset(run)))); } @@ -156,7 +158,7 @@ pub(crate) fn draw(ctx: &mut RenderContext, l: &TextLayout, tr: Rect, m: Affine, return; } let y = (d.rect.y0 + d.rect.y1) / 2.0; - let Some(li) = line_of(l, d.para, y) else { return }; + let Some(li) = lines_of.line_of(d.para, y) else { return }; ctx.set_transform(m * Affine::translate(ps.offset)); ctx.set_paint(color(d.color, ps.opacity)); for r in d.pieces() { From 69c91bb2651b80a71b4a3bba55ccba17fd4e9485 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 20:39:36 -0500 Subject: [PATCH 68/79] Render: index table style, skip list, chart point fills and waterfall bars; count each dash (#1774) * render: look up a table's style once, keep the skip list as a set, count each dash * render: index chart point fills, scatter x presence and waterfall bars once per chart --- crates/render/src/chart.rs | 33 +++++++++++++++++++++++++-------- crates/render/src/placed.rs | 4 +++- crates/render/src/renderer.rs | 28 +++++++++++++++++++++++++++- crates/render/src/stroke.rs | 3 ++- crates/render/src/tests_wild.rs | 33 +++++++++++++++++++++++++++++++++ 5 files changed, 90 insertions(+), 11 deletions(-) diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index d13f65223..53794987a 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -1,5 +1,7 @@ //! Charts: title, plot area with axes and gridlines, series marks, legend. +use std::collections::HashMap; + use deckcraft_color::{ColorTransform, Rgba, SchemeSlot}; use deckcraft_model::Shape; use deckcraft_model::chart::{Chart, ChartType, TrendlineType, axis_scale}; @@ -85,9 +87,9 @@ fn bar_outline(ctx: &Ctx, s: &deckcraft_model::chart::Series) -> Option<(Rgba, f /// Paint of one bar: a `` fill wins, and a single-series chart that varies colours by point uses one accent per point /// (not waterfalls, which are never read from a ``). Pattern fills draw as patterns and /// `a:noFill` leaves the bar empty, as in PowerPoint. -fn bar_paint(ctx: &Ctx, c: &Chart, si: usize, pi: usize, s: &deckcraft_model::chart::Series) -> Paint { - if let Some((_, f)) = s.point_fills.iter().find(|(k, _)| *k == pi) { - return paint_of(ctx, Some(f), series_fill(ctx, c, si, Some(s))); +fn bar_paint(ctx: &Ctx, c: &Chart, si: usize, pi: usize, s: &deckcraft_model::chart::Series, fills: Option<&HashMap>) -> Paint { + if let Some(f) = fills.and_then(|m| m.get(&pi)) { + return paint_of(ctx, Some(*f), series_fill(ctx, c, si, Some(s))); } if c.vary_colors && c.series.len() == 1 && s.fill.is_none() && c.kind != ChartType::Waterfall { return Paint::Color(series_color(ctx, c, pi)); @@ -883,6 +885,8 @@ fn draw_lines(ctx: &mut RenderContext, draw: &mut LineDrawing<'_, '_>) { let col = if area_kind { series_fill(rctx, c, si, Some(s)) } else { series_line(rctx, c, si, s) }; let series_stats = s.error_bars.as_ref().map(|_| deckcraft_model::chart::SeriesStats::of(&s.values)); let mut pts = vec![]; + // Whether the series has any x value, found once rather than per point. + let any_x = s.x_values.iter().flatten().next().is_some(); for i in 0..ncat { let Some(v) = s.values.get(i).copied().flatten() else { continue }; let v = if stacked { @@ -896,11 +900,11 @@ fn draw_lines(ctx: &mut RenderContext, draw: &mut LineDrawing<'_, '_>) { }; let x = match kind { ChartType::Scatter | ChartType::Bubble => { - match (s.x_values.get(i).copied().flatten().and_then(to_x), s.x_values.iter().flatten().next()) { + match (s.x_values.get(i).copied().flatten().and_then(to_x), any_x) { (Some(x), _) => x, // No x values at all: the points are numbered 1, 2, 3... - (None, None) => to_x(i as f64 + 1.0).unwrap_or(plot.x0 + band * (slot(i) + 0.5)), - (None, Some(_)) => continue, + (None, false) => to_x(i as f64 + 1.0).unwrap_or(plot.x0 + band * (slot(i) + 0.5)), + (None, true) => continue, } } _ => plot.x0 + band * (slot(i) + 0.5), @@ -970,6 +974,19 @@ fn draw_bars(ctx: &mut RenderContext, draw: &BarDrawing<'_, '_>) -> Vec<(f64, f6 let group = bar + step * (n - 1.0); // Error bars need each series' mean and spread: worked out once, not per point. let stats: Vec = bars.iter().map(|(_, s)| deckcraft_model::chart::SeriesStats::of(&s.values)).collect(); + // Each series' per-point fills by point (the first for a point wins), not searched per bar. + let point_fills: Vec> = bars + .iter() + .map(|(_, s)| { + let mut m = HashMap::new(); + for (k, f) in &s.point_fills { + m.entry(*k).or_insert(f); + } + m + }) + .collect(); + // A waterfall's running bars, worked out once for all its categories. + let waterfall_bars = if waterfall { c.waterfall_bars() } else { Vec::new() }; for i in 0..ncat { let mut pos_acc = 0.0; let mut neg_acc = 0.0; @@ -983,7 +1000,7 @@ fn draw_bars(ctx: &mut RenderContext, draw: &BarDrawing<'_, '_>) -> Vec<(f64, f6 let v = s.values.get(i).copied().flatten().unwrap_or(0.0) / total; let start = band * slot(i) + (band - group) / 2.0 + if stacked { 0.0 } else { step * bi as f64 }; let (a, b) = if waterfall { - c.waterfall_bars().get(i).copied().unwrap_or_default() + waterfall_bars.get(i).copied().unwrap_or_default() } else if stacked { if v >= 0.0 { let r = (pos_acc, pos_acc + v); @@ -1015,7 +1032,7 @@ fn draw_bars(ctx: &mut RenderContext, draw: &BarDrawing<'_, '_>) -> Vec<(f64, f6 }; Paint::Color(series_color(rctx, c, slot)) } else { - bar_paint(rctx, c, *si, i, s) + bar_paint(rctx, c, *si, i, s, point_fills.get(bi)) }; fill_with(ctx, &paint, r.abs(), m); if !waterfall && let Some((oc, ow)) = bar_outline(rctx, s).filter(|o| o.1 > 0.0) { diff --git a/crates/render/src/placed.rs b/crates/render/src/placed.rs index 4dc9e1082..2af8152be 100644 --- a/crates/render/src/placed.rs +++ b/crates/render/src/placed.rs @@ -131,9 +131,11 @@ fn place(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, parent: } ShapeKind::Table(t) => { // Cell text, laid out exactly as the table draws it. + // The table's style is looked up once, not once per cell. + let def = table::resolve_style(rctx, t); for (r, c, rect) in table::cell_rects(t, &table::row_heights(rctx, t, fields)) { let Some(cell) = t.cell(r, c).filter(|cell| !cell.text.is_empty()) else { continue }; - let body = table::cell_body(rctx, t, r, c, cell); + let body = table::cell_body_with_def(rctx, def, t, r, c, cell); let tmp = Shape::table_cell(body.clone()); place_text(out, rctx, fields, s.id, &tmp, &body, rect, m); } diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs index e7cd5ff35..0b66b30e6 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -33,6 +33,10 @@ pub struct Renderer { /// Visits spent on outer-shadow silhouette passes, kept apart so they cannot use up the budget /// of the normal drawing that follows. shadow_work: usize, + /// `RenderOpts::skip` as a set, with the slice (address, length) it was built from: the + /// slice can be as long as the slide, and is tested for every shape drawn. Forgotten when a + /// frame starts, so another frame's slice at the same address is never mistaken for it. + skip: Option<(usize, usize, std::collections::HashSet)>, } impl Default for Renderer { @@ -48,6 +52,7 @@ impl Default for Renderer { in_shadow_pass: false, render_work: 0, shadow_work: 0, + skip: None, } } } @@ -163,6 +168,7 @@ impl Renderer { pub(crate) fn slide(&mut self, pres: &Presentation, slide: &Slide, index: usize, opts: &RenderOpts) -> Image { self.render_work = 0; self.shadow_work = 0; + self.skip = None; self.in_shadow_pass = false; let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, slide)); let f = Frame { pres, opts, fields: Self::fields(pres, index, opts) }; @@ -178,6 +184,7 @@ impl Renderer { pub(crate) fn slide_part(&mut self, pres: &Presentation, slide: &Slide, index: usize, part: SlidePart, opts: &RenderOpts) -> Image { self.render_work = 0; self.shadow_work = 0; + self.skip = None; self.in_shadow_pass = false; let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, slide)); let f = Frame { pres, opts, fields: Self::fields(pres, index, opts) }; @@ -212,6 +219,7 @@ impl Renderer { let mix = if mix.is_finite() { mix.clamp(0.0, 1.0) } else { 1.0 }; self.render_work = 0; self.shadow_work = 0; + self.skip = None; self.in_shadow_pass = false; let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, old.0) || needs_filters(pres, new.0)); let (Some(co), Some(cn)) = (Ctx::for_slide(pres, old.0), Ctx::for_slide(pres, new.0)) else { return self.finish(ctx, w, h) }; @@ -260,6 +268,7 @@ impl Renderer { pub(crate) fn layout(&mut self, pres: &Presentation, mi: usize, li: Option, opts: &RenderOpts) -> Image { self.render_work = 0; self.shadow_work = 0; + self.skip = None; self.in_shadow_pass = false; let (w, h) = output_size(pres, opts); let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }); @@ -393,8 +402,25 @@ impl Renderer { geo } + /// Whether `id` is in `skip`, looked up in a set built once per frame and slice. + fn skipped(&mut self, skip: &[ShapeId], id: ShapeId) -> bool { + if skip.is_empty() { + return false; + } + let key = (skip.as_ptr() as usize, skip.len()); + match &self.skip { + Some((p, n, set)) if (*p, *n) == key => set.contains(&id), + _ => { + let set: std::collections::HashSet = skip.iter().copied().collect(); + let hit = set.contains(&id); + self.skip = Some((key.0, key.1, set)); + hit + } + } + } + fn shape_at(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, parent: Affine, depth: usize) { - if depth > 32 || (s.hidden && !f.opts.show_hidden) || f.opts.skip.contains(&s.id) { + if depth > 32 || (s.hidden && !f.opts.show_hidden) || self.skipped(f.opts.skip, s.id) { return; } let work = if self.in_shadow_pass { &mut self.shadow_work } else { &mut self.render_work }; diff --git a/crates/render/src/stroke.rs b/crates/render/src/stroke.rs index 8310cf0c9..fc1403f6b 100644 --- a/crates/render/src/stroke.rs +++ b/crates/render/src/stroke.rs @@ -191,7 +191,8 @@ pub(crate) fn limit_dash_count(path: &BezPath, pat: Vec) -> Vec { return pat; } let len: f64 = path.segments().map(|s| s.arclen(1e-2)).sum(); - let dashes = len / period; + // Every dash of the pattern counts, not each repeat of it: a custom pattern can hold many. + let dashes = len / period * pat.len().div_ceil(2) as f64; if !len.is_finite() { return vec![]; } diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index fb25c7f0b..2da29cb37 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -990,4 +990,37 @@ fn a_tiny_dash_on_a_huge_shape_draws_without_running_out_of_memory() { p.line_to((4.0e6, 0.0)); let pat = limit_dash_count(&p, vec![1.0, 1.0]); assert!((4.0e6 / (pat[0] + pat[1]) - MAX_DASHES).abs() < 1.0, "{pat:?}"); + // A custom pattern of 1,000 dashes counts each of them: the limit is on dashes drawn. + let pat = limit_dash_count(&p, vec![1.0; 2000]); + let period: f64 = pat.iter().sum(); + assert!((4.0e6 / period * 1000.0 - MAX_DASHES).abs() < 1.0, "{}", period); +} + +/// A bar chart with 20,000 points, each with its own fill, and a sparse scatter of the same size: +/// the per-point fills and the scatter's "has any x" are looked up, not searched per point. +#[test] +fn many_point_fills_and_sparse_scatter_x_render() { + use deckcraft_model::ShapeKind; + use deckcraft_model::chart::{Chart, ChartType}; + use deckcraft_model::style::Fill; + let n = 20_000; + let chart = |kind: ChartType| { + let mut c = Chart::sample(kind); + c.series.truncate(1); + c.categories = (0..n).map(|i| i.to_string()).collect(); + c.series[0].values = (0..n).map(|i| Some(i as f64)).collect(); + c + }; + let mut bars = chart(ChartType::Column); + bars.series[0].point_fills = (0..n).rev().map(|i| (i, Fill::solid(ColorRef::rgb(Rgba::rgb(200, 0, 0))))).collect(); + let mut scatter = chart(ChartType::Scatter); + scatter.series[0].x_values = (0..n).map(|i| (i + 1 == n).then_some(1.0)).collect(); + let shape = |id: u32, c: Chart| Shape { + id: ShapeId(id), + xfrm: Some(Xfrm::new(10.0, 10.0, 400.0, 300.0)), + kind: ShapeKind::Chart(Box::new(c)), + ..Default::default() + }; + let img = render(vec![shape(2, bars), shape(3, scatter)]); + assert!(img.width > 0); } From 23ac4f7efdbd1e538bd9a7869584d4adeb9039b0 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 20:39:57 -0500 Subject: [PATCH 69/79] Fonts: bound font-controlled work in enumeration, outlines and carets (#1701) * Fonts: bound font-controlled work in enumeration, outlines and carets An embedded font could make each lookup do far more than real fonts need: - enumerating a collection read up to 256 faces times 1024 named instances, each then built with its own shaper; now 4096 per file across all faces, with a set for the duplicate-style check instead of a scan; - composite and VARC glyphs could expand one dense outline without limit, and the outline cache only counted entries; outlines now stop at 32768 path elements (drawing nothing past it) and the cache is also bounded by the elements it holds; - the 64-caret limit applied after filtering, so a ligature lookup could read every caret record; it now applies to the records read. * address review: bound thread-local glyph cache by path elements --- crates/fonts/src/fallback.rs | 7 +- crates/fonts/src/fontdb.rs | 159 +++++++++++++++++++++++++++++---- crates/render/src/text_draw.rs | 18 ++-- 3 files changed, 161 insertions(+), 23 deletions(-) diff --git a/crates/fonts/src/fallback.rs b/crates/fonts/src/fallback.rs index de4b2b0a6..8d54453f8 100644 --- a/crates/fonts/src/fallback.rs +++ b/crates/fonts/src/fallback.rs @@ -227,6 +227,9 @@ impl FontDb { } } +/// Most caret records read for one ligature glyph. +const MAX_LIGATURE_CARETS: usize = 64; + /// Caret positions inside a ligature glyph from GDEF `LigCaretList`, in font units from the /// glyph's origin (sorted ascending). Empty when the font has none for `gid`. pub fn ligature_carets(face: &FontFace, gid: u32) -> Vec { @@ -236,9 +239,12 @@ pub fn ligature_carets(face: &FontFace, gid: u32) -> Vec { let Ok(cov) = list.coverage() else { return Vec::new() }; let Some(idx) = cov.get(skrifa::GlyphId::new(gid)) else { return Vec::new() }; let Ok(lig) = list.lig_glyphs().get(idx as usize) else { return Vec::new() }; + // The limit applies to the records read, not to the carets kept: a font can list thousands of + // records it then has to be read through for every ligature laid out. let mut out: Vec = lig .caret_values() .iter() + .take(MAX_LIGATURE_CARETS) .filter_map(|cv| cv.ok()) .filter_map(|cv| match cv { skrifa::raw::tables::gdef::CaretValue::Format1(f) => Some(f.coordinate() as i32), @@ -246,7 +252,6 @@ pub fn ligature_carets(face: &FontFace, gid: u32) -> Vec { // Format 2 is a contour point index; outlines aren't consulted here. skrifa::raw::tables::gdef::CaretValue::Format2(_) => None, }) - .take(64) .collect(); out.sort_unstable(); out diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 2e2f35299..c1db0b356 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -368,7 +368,7 @@ struct CatalogEntry { /// Process-wide font database. pub struct FontDb { faces: RwLock>>, - outlines: Mutex>>, + outlines: Mutex, #[cfg(not(target_arch = "wasm32"))] catalog: RwLock>, /// The folders the system font scan reads (the platform's font folders for [`FontDb::global`]). @@ -439,6 +439,38 @@ const SYSTEM_FALLBACKS: &[&str] = &[ static NEXT_ID: AtomicU32 = AtomicU32::new(1); const OUTLINE_CACHE_MAX: usize = 50_000; +/// Most path elements one glyph outline may expand to. Composite and `VARC` glyphs can draw a +/// shared outline many times over; a glyph past this draws nothing rather than holding megabytes. +const MAX_OUTLINE_ELEMENTS: usize = 1 << 15; +/// Most path elements the outline cache holds across all glyphs (about 230 MB at most) before it +/// starts over: distinct glyph ids can each expand one dense outline. +const OUTLINE_CACHE_MAX_ELEMENTS: usize = 1 << 22; + +/// Glyph outlines by (face, glyph), with the path elements they hold. +#[derive(Default)] +struct OutlineCache { + paths: HashMap<(u32, u32), Arc>, + elements: usize, +} + +impl OutlineCache { + fn get(&self, key: &(u32, u32)) -> Option> { + self.paths.get(key).cloned() + } + + fn insert(&mut self, key: (u32, u32), path: Arc) { + let n = path.elements().len(); + if let Some(old) = self.paths.remove(&key) { + self.elements = self.elements.saturating_sub(old.elements().len()); + } + if self.paths.len() >= OUTLINE_CACHE_MAX || self.elements.saturating_add(n) > OUTLINE_CACHE_MAX_ELEMENTS { + self.paths.clear(); + self.elements = 0; + } + self.paths.insert(key, path); + self.elements = self.elements.saturating_add(n); + } +} fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option { ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty())) @@ -694,6 +726,10 @@ const MAX_COLLECTION_FACES: u32 = 256; const MAX_COLLECTION_ENTRIES_SCANNED: u32 = 16_384; /// Maximum named instances read from a single face. const MAX_NAMED_INSTANCES_PER_FACE: usize = 1024; +/// Maximum faces (named instances counted separately) enumerated from one font file, across all +/// of a collection's faces: the per-collection and per-face limits multiply, and each face found +/// is then built with its own shaper. +const MAX_ENUMERATED_FACES: usize = 4096; fn distinct_face_indices(entries: impl IntoIterator) -> Vec { let mut offsets = std::collections::HashSet::new(); @@ -730,21 +766,30 @@ fn enumerate_faces(data: &[u8]) -> Vec { Err(_) => Vec::new(), }; let mut out = Vec::new(); + // Named instances read and plain faces kept, across the whole file. + let mut budget = MAX_ENUMERATED_FACES; for i in indices { + if budget == 0 { + break; + } let Ok(f) = skrifa::FontRef::from_index(data, i) else { continue }; let Some((family, style)) = face_names(&f) else { continue }; let axes = f.axes(); let mut named = Vec::new(); + let mut seen = std::collections::HashSet::new(); let instances = f.named_instances(); for ni in instances.iter().take(capped_named_instance_count(instances.len())) { + let Some(left) = budget.checked_sub(1) else { break }; + budget = left; let Some(style) = name(&f, &[ni.subfamily_name_id()]) else { continue }; - if named.iter().any(|(_, s, _): &(u32, String, _)| s.eq_ignore_ascii_case(&style)) { + if !seen.insert(style.to_ascii_lowercase()) { continue; } let coords: Vec<([u8; 4], f32)> = axes.iter().zip(ni.user_coords()).map(|(a, v)| (a.tag().to_be_bytes(), v)).collect(); named.push((i, style, coords)); } if named.is_empty() { + budget = budget.saturating_sub(1); out.push((i, family, style, Vec::new())); } else { out.extend(named.into_iter().map(|(i, s, c)| (i, family.clone(), s, c))); @@ -905,7 +950,7 @@ impl FontDb { } Self { faces: RwLock::new(faces), - outlines: Mutex::new(HashMap::new()), + outlines: Mutex::new(OutlineCache::default()), #[cfg(not(target_arch = "wasm32"))] catalog: RwLock::new(Vec::new()), font_dirs, @@ -1513,41 +1558,72 @@ impl FontDb { pub fn outline(&self, face: &FontFace, gid: u32) -> Arc { let key = (face.id, gid); if let Some(p) = self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) { - return p.clone(); + return p; } - let mut pen = FlipPen(BezPath::new()); + let mut pen = FlipPen::new(MAX_OUTLINE_ELEMENTS); if let Some(f) = face.skrifa() && let Some(g) = f.outline_glyphs().get(GlyphId::new(gid)) { let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), face.location()), &mut pen); } - let p = Arc::new(pen.0); - let mut cache = self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - if cache.len() >= OUTLINE_CACHE_MAX { - cache.clear(); - } - cache.insert(key, p.clone()); + let p = Arc::new(pen.finish()); + self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(key, p.clone()); p } } -struct FlipPen(BezPath); +/// Collects a glyph outline, y flipped, up to `limit` path elements. Past the limit it ignores +/// what follows and [`FlipPen::finish`] gives an empty path: a cut-off outline would fill wrongly. +struct FlipPen { + path: BezPath, + limit: usize, + over: bool, +} + +impl FlipPen { + fn new(limit: usize) -> Self { + FlipPen { path: BezPath::new(), limit, over: false } + } + + /// Room for one more element? + fn room(&mut self) -> bool { + if !self.over && self.path.elements().len() >= self.limit { + self.over = true; + self.path = BezPath::new(); + } + !self.over + } + + fn finish(self) -> BezPath { + if self.over { BezPath::new() } else { self.path } + } +} impl OutlinePen for FlipPen { fn move_to(&mut self, x: f32, y: f32) { - self.0.move_to((x as f64, -y as f64)); + if self.room() { + self.path.move_to((x as f64, -y as f64)); + } } fn line_to(&mut self, x: f32, y: f32) { - self.0.line_to((x as f64, -y as f64)); + if self.room() { + self.path.line_to((x as f64, -y as f64)); + } } fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) { - self.0.quad_to((cx0 as f64, -cy0 as f64), (x as f64, -y as f64)); + if self.room() { + self.path.quad_to((cx0 as f64, -cy0 as f64), (x as f64, -y as f64)); + } } fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) { - self.0.curve_to((cx0 as f64, -cy0 as f64), (cx1 as f64, -cy1 as f64), (x as f64, -y as f64)); + if self.room() { + self.path.curve_to((cx0 as f64, -cy0 as f64), (cx1 as f64, -cy1 as f64), (x as f64, -y as f64)); + } } fn close(&mut self) { - self.0.close_path(); + if self.room() { + self.path.close_path(); + } } } @@ -1575,6 +1651,55 @@ mod font_dir_tests { assert_eq!(capped_named_instance_count(MAX_NAMED_INSTANCES_PER_FACE + 1), MAX_NAMED_INSTANCES_PER_FACE); } + #[test] + fn faces_of_one_file_are_bounded_across_the_collection() { + // The per-collection and per-face limits together would allow far more. + assert!((MAX_COLLECTION_FACES as usize) * MAX_NAMED_INSTANCES_PER_FACE > 16 * MAX_ENUMERATED_FACES); + for data in bundled() { + assert!(enumerate_faces(data).len() <= MAX_ENUMERATED_FACES); + } + } + + #[test] + fn an_outline_past_the_element_limit_draws_nothing() { + let mut pen = FlipPen::new(8); + pen.move_to(0.0, 0.0); + pen.line_to(1.0, 1.0); + pen.close(); + assert_eq!(pen.finish().elements().len(), 3); + // A shared outline drawn over and over (composite and VARC glyphs). + let mut pen = FlipPen::new(8); + for i in 0..1_000_000 { + pen.move_to(0.0, 0.0); + pen.line_to(i as f32, 1.0); + pen.close(); + } + assert_eq!(pen.path.elements().len(), 0); + assert!(pen.finish().elements().is_empty()); + } + + #[test] + fn outline_cache_is_bounded_by_elements() { + let mut path = BezPath::new(); + path.move_to((0.0, 0.0)); + for i in 1..MAX_OUTLINE_ELEMENTS { + path.line_to((i as f64, 0.0)); + } + let path = Arc::new(path); + let mut cache = OutlineCache::default(); + let glyphs = 4 * OUTLINE_CACHE_MAX_ELEMENTS / MAX_OUTLINE_ELEMENTS; + for gid in 0..glyphs as u32 { + cache.insert((1, gid), path.clone()); + assert!(cache.elements <= OUTLINE_CACHE_MAX_ELEMENTS); + assert_eq!(cache.elements, cache.paths.values().map(|p| p.elements().len()).sum::()); + } + assert!(cache.get(&(1, glyphs as u32 - 1)).is_some()); + // Replacing an entry does not count it twice. + let before = cache.elements; + cache.insert((1, glyphs as u32 - 1), path.clone()); + assert_eq!(cache.elements, before); + } + #[test] fn xdg_data_dirs_contribute_their_fonts_folders() { let home = Path::new("/home/ann"); diff --git a/crates/render/src/text_draw.rs b/crates/render/src/text_draw.rs index d2400f19f..d9788a4a9 100644 --- a/crates/render/src/text_draw.rs +++ b/crates/render/src/text_draw.rs @@ -35,22 +35,30 @@ pub(crate) fn prompt_body(rctx: &Ctx, s: &Shape, kind: PhType) -> TextBody { t } +/// Most path elements this thread's glyph cache retains. Its entries are shared with the font +/// cache, but they stay alive here after that cache evicts them, so they are bounded by elements. +const GLYPH_CACHE_MAX_ELEMENTS: usize = 1 << 22; + thread_local! { - static GLYPH_CACHE: std::cell::RefCell>> = std::cell::RefCell::new(HashMap::new()); + /// The cached outlines, and the path elements they hold. + static GLYPH_CACHE: std::cell::RefCell<(HashMap<(u32, u32), Arc>, usize)> = std::cell::RefCell::new((HashMap::new(), 0)); } pub(crate) fn glyph(face: &deckcraft_fonts::FontFace, gid: u32) -> Arc { let key = (face.id(), gid); - if let Some(p) = GLYPH_CACHE.with(|c| c.borrow().get(&key).cloned()) { + if let Some(p) = GLYPH_CACHE.with(|c| c.borrow().0.get(&key).cloned()) { return p; } let p = FontDb::global().outline(face, gid); GLYPH_CACHE.with(|c| { let mut c = c.borrow_mut(); - if c.len() > 200_000 { - c.clear(); + let n = p.elements().len(); + if c.0.len() > 200_000 || c.1.saturating_add(n) > GLYPH_CACHE_MAX_ELEMENTS { + c.0.clear(); + c.1 = 0; } - c.insert(key, p.clone()); + c.1 += n; + c.0.insert(key, p.clone()); }); p } From 50187449c20d58d8b014a3eb04b967ef3cc687b2 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:52:46 +0100 Subject: [PATCH 70/79] Render tests: import place_slide_with so the date test compiles (#1153) --- crates/render/src/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 05306f66c..540a8500a 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1,7 +1,7 @@ use super::{ Ctx, Fields, Fill, Image, Opts, PathMorph, Point, RENDERER, Rect, Region, RenderOpts, Renderer, Rgba, ShapeState, SlideFields, TextMorph, crop_dest, dpi_for_scale, gif_frame, image_dpi, images, morph_path, morph_text, needs_filters, parse_path, pasteboard_region, place_slide, - render_blend, render_shapes, render_slide, render_slide_drawn, slide_extent, text_transform, + place_slide_with, render_blend, render_shapes, render_slide, render_slide_drawn, slide_extent, text_transform, }; use deckcraft_model::chart::{ChartType, Trendline, TrendlineType}; use deckcraft_model::style::ColorRef; From 51d19baa0cbe35f259bf5e24e38f1f59b2b2ef2c Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 4 Nov 2026 13:56:48 -0500 Subject: [PATCH 71/79] Ogg, xtask: check packet ranges against the source first; stats skips directory links (#1796) --- crates/ogg/src/lib.rs | 5 +++++ crates/ogg/src/tests.rs | 10 ++++++++++ xtask/src/stats.rs | 8 +++++++- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/ogg/src/lib.rs b/crates/ogg/src/lib.rs index 77c081d52..719c96698 100644 --- a/crates/ogg/src/lib.rs +++ b/crates/ogg/src/lib.rs @@ -415,6 +415,11 @@ impl CaptureScanner { } fn read_parts(src: &(impl ByteSource + ?Sized), parts: &[(u64, u32)]) -> Result> { + // Ranges come from a scan of some source; check them against this one before sizing a buffer. + let len = src.len(); + if parts.iter().any(|&(o, n)| o.checked_add(u64::from(n)).is_none_or(|end| end > len)) { + return Err(Error::Invalid("packet range lies outside the source".into())); + } let mut v = Vec::with_capacity(parts.iter().map(|p| p.1 as usize).sum()); for &(o, n) in parts { let a = v.len(); diff --git a/crates/ogg/src/tests.rs b/crates/ogg/src/tests.rs index 480c1cda2..8f9673089 100644 --- a/crates/ogg/src/tests.rs +++ b/crates/ogg/src/tests.rs @@ -274,3 +274,13 @@ fn a_budget_too_small_to_search_is_spent() { let f = opus_file(6, 312, 0); assert_eq!(scanner.find(&f, 0, f.len() as u64).unwrap(), None); } + +#[test] +fn read_packet_checks_its_ranges_against_the_source_before_allocating() { + let f = opus_file(10, 312, 500); + let o = open(&f).unwrap(); + let s = o.stream_of(Codec::Opus).unwrap(); + // The last packet lies past the end of a source cut short: an error, not a read of missing bytes. + let short = &f[..f.len() / 2]; + assert!(matches!(o.read_packet(&short, s, 9), Err(Error::Invalid(_)))); +} diff --git a/xtask/src/stats.rs b/xtask/src/stats.rs index f40b1300a..20cd5725f 100644 --- a/xtask/src/stats.rs +++ b/xtask/src/stats.rs @@ -18,7 +18,13 @@ fn walk(dir: &Path, c: &mut Counts) { let p = e.path(); let name = e.file_name(); let name = name.to_string_lossy(); - if p.is_dir() { + // `file_type` does not follow links, so a directory link is not walked (and aliases are + // not counted twice). + let Ok(kind) = e.file_type() else { continue }; + if kind.is_symlink() { + continue; + } + if kind.is_dir() { // Nested fuzz workspaces and build output are not part of the crate. if name != "target" && name != "fuzz" && !name.starts_with('.') { walk(&p, c); From 07dc2210a1554b8186624e88b383bcc8bc0c9b19 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 4 Nov 2026 14:09:46 -0500 Subject: [PATCH 72/79] Repair: bound the notes page size like a slide (#1832) --- crates/format/src/lib.rs | 5 +++++ crates/format/src/tests.rs | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 6f345e11c..97caafdb3 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -247,6 +247,11 @@ pub fn repair(p: &mut Presentation) { if !(p.notes_size.width.is_finite() && p.notes_size.width > 0.0 && p.notes_size.height.is_finite() && p.notes_size.height > 0.0) { p.notes_size = Presentation::default().notes_size; } + // A notes page is bounded like a slide: canvas and handout drawing scale with it. + p.notes_size = deckcraft_model::Size::new( + p.notes_size.width.clamp(*SLIDE_SIDE.start(), *SLIDE_SIDE.end()), + p.notes_size.height.clamp(*SLIDE_SIDE.start(), *SLIDE_SIDE.end()), + ); sane_numbers(p); } diff --git a/crates/format/src/tests.rs b/crates/format/src/tests.rs index aa692d03f..ccda612f0 100644 --- a/crates/format/src/tests.rs +++ b/crates/format/src/tests.rs @@ -451,6 +451,13 @@ fn degenerate_slide_sizes_are_repaired() { } } +#[test] +fn repair_bounds_the_notes_page_size() { + let mut p = Presentation { notes_size: deckcraft_model::Size::new(1e12, 0.01), ..Default::default() }; + repair(&mut p); + assert_eq!((p.notes_size.width, p.notes_size.height), (4032.0, 72.0)); +} + /// A file whose slide size is null, a string or missing a side (written by hand or by /// another tool; NaN is also saved as null) opens with the default size rather than failing /// as damaged. From 80f784d21011dc71bfe20abe0bbc144e1713fae9 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 4 Nov 2026 14:36:13 -0500 Subject: [PATCH 73/79] Fonts: ignore variation tags the font has no axis for (#1795) --- crates/fonts/src/fontdb.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index c1db0b356..19f190445 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -1260,9 +1260,8 @@ impl FontDb { fn instance(&self, family: &str, style: &str) -> Option> { let open = style.find('{')?; let base = self.find(family, style[..open].trim())?; - if base.skrifa().is_none_or(|f| f.axes().is_empty()) { - return Some(base); - } + let Some(font) = base.skrifa().filter(|f| !f.axes().is_empty()) else { return Some(base) }; + let axis_tags: Vec<[u8; 4]> = font.axes().iter().map(|a| a.tag().to_be_bytes()).collect(); let mut coords = base.coords.clone(); for kv in style[open + 1..].trim_end_matches('}').split(',') { let (k, v) = kv.split_once(':')?; @@ -1272,6 +1271,11 @@ impl FontDb { return None; } let tag = [k[0], k[1], k[2], k[3]]; + // A tag the font has no axis for changes nothing; keeping it would grow the list with + // every distinct tag in the style text. + if !axis_tags.contains(&tag) { + continue; + } match coords.iter_mut().find(|(t, _)| *t == tag) { Some(c) => c.1 = v, None => coords.push((tag, v)), From 9d36b5931cf93b36605eb5c8173260a2a346ccf5 Mon Sep 17 00:00:00 2001 From: Mex <44814932+MMexicann@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:15:59 +0100 Subject: [PATCH 74/79] Ogg: include recovered tail packets in Opus duration (#1862) * Ogg: include recovered tail packets in Opus duration Keep the existing missing-granule recovery cursor in step with the packet ends it assigns. Add a regression for a partially and entirely unanchored stream; existing valid end-trimming coverage remains unchanged. Validation: rustc --edition=2024 --test crates/ogg/src/lib.rs: all 12 tests pass. Scoped rustfmt check passes. Full cargo --offline xtask ci is blocked before build by the uncached pinned egui checkout. * Ogg: record recovery duration correction --- ROADMAP.md | 4 ++++ crates/ogg/src/lib.rs | 1 + crates/ogg/src/tests.rs | 21 +++++++++++++++++++++ 3 files changed, 26 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 9315be8a6..37ef74b55 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,8 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away +> **Last reviewed:** 2026-11-04 · **Last updated:** 2026-11-04 · **Change:** trivial (Ogg Opus recovery includes trailing packets without granules in its reported duration; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) + > **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-03 · **Change:** trivial (community PR wave 10: PPTX and native saves keep table cell centred anchoring, text overflow, compatible line spacing, normalised character height, Thai distributed alignment, RTL table direction, alternate run language and reflection transforms; imported effects placed by parent time-node delays; Reuse Slides fits the destination size and brings custom table styles; deleted slides leave custom shows; merged shapes drop their animations; Compress Pictures measures grouped pictures at displayed size; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-02 · **Change:** trivial (File › About replaces Account; product links, credits and appearance retained; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-01 · **Change:** trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -150,6 +152,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-11-04 | Ogg Opus recovery keeps trailing packets without granules in the total duration, matching their recovered positions; valid end trimming stays covered | | 2026-11-02 | Native and PPTX saves retain optional table right-to-left direction, including explicit false and omission; two-save round-trip tests keep labeled columns in order | | 2026-11-02 | Text runs preserve alternate-language metadata independently from the main language through native and PPTX saves | | 2026-11-02 | Picture compression measures displayed dimensions through nested group transforms; checklist score unchanged | @@ -330,6 +333,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-11-04 | trivial | Ogg Opus recovery duration includes its recovered tail; parity scores unchanged | | 2026-11-02 | trivial | Progress log: run alternate-language metadata round-trips through native and PPTX formats; product parity scores unchanged | | 2026-11-03 | trivial | Reuse Slides brings referenced custom table styles along; scores unchanged | | 2026-11-02 | trivial | File › About replaces Account; product links, credits and appearance retained | diff --git a/crates/ogg/src/lib.rs b/crates/ogg/src/lib.rs index 719c96698..0261ee0e6 100644 --- a/crates/ogg/src/lib.rs +++ b/crates/ogg/src/lib.rs @@ -513,6 +513,7 @@ impl OpusTiming { c = timing_add(c, i64::from(durations[k]))?; ends[k] = Some(c); } + cursor = Some(c); break; } let Some(g) = stream.packets[j].granule else { break }; diff --git a/crates/ogg/src/tests.rs b/crates/ogg/src/tests.rs index 8f9673089..728e618bd 100644 --- a/crates/ogg/src/tests.rs +++ b/crates/ogg/src/tests.rs @@ -183,6 +183,27 @@ fn opus_toc_durations() { assert_eq!(opus_packet_samples([0xF8, 0], 0), None); } +#[test] +fn opus_timing_includes_packets_after_the_last_known_granule() { + let f = opus_file(6, 312, 0); + let o = open(&f).unwrap(); + let mut stream = o.streams[0].clone(); + // Missing granules on complete-packet pages are non-conforming. The existing recovery + // fallback still assigns their packet positions; total must include the same samples. + stream.packets.last_mut().unwrap().granule = None; + let timing = OpusTiming::of(&stream, 312).unwrap(); + assert_eq!(timing.starts, vec![-312, 648, 1608, 2568, 3528, 4488]); + assert_eq!(timing.total, 6 * 960 - 312); + + // With no usable granule, every complete packet still contributes its TOC duration. + for packet in &mut stream.packets { + packet.granule = None; + } + let timing = OpusTiming::of(&stream, 312).unwrap(); + assert_eq!(timing.starts, vec![-312, 648, 1608, 2568, 3528, 4488]); + assert_eq!(timing.total, 6 * 960 - 312); +} + #[test] fn opus_timing_rejects_a_granule_that_overflows() { let packet = |granule: i64| Stream { From 65ddd8ad517e15032195059c73f54c690e5d2dbf Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:55:43 +0000 Subject: [PATCH 75/79] Apply review fixes - crates/render/src/placed.rs --- crates/render/src/placed.rs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/crates/render/src/placed.rs b/crates/render/src/placed.rs index bde0fa157..483db41bc 100644 --- a/crates/render/src/placed.rs +++ b/crates/render/src/placed.rs @@ -160,16 +160,6 @@ fn place(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, parent: fn place_text(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, owner: ShapeId, s: &Shape, body: &TextBody, tr: Rect, m: Affine) { let layout = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields, prompt_color: None, no_shrink: false }); let m = crate::text_transform(m, tr, layout.rotation); - // Text hyperlinks: one box per line segment of a linked run. - for run in layout.runs.iter().filter(|r| r.link && !r.glyphs.is_empty()) { - let Some(link) = run_link(body, run.para, run.chars.0) else { continue }; - let Some(line) = layout.lines.iter().find(|l| l.para == run.para && l.start <= run.chars.0 && run.chars.0 <= l.end) else { continue }; - // The run's visual extent (right-to-left runs run from right to left). - let first = run.glyphs.first().map(|g| g.1).unwrap_or(0.0); - let (x0, x1) = line.span(run.chars.0, run.chars.1).unwrap_or((first, first)); - let r = Rect::new(x0, line.top, x1, line.bottom); - out.links.push(PlacedLink { rect: m.transform_rect_bbox(r), link, shape: owner, text: true }); - } // Field runs carry their saved text (`‹#›`); the layout drew their values, so the body that // goes with it (character offsets, text layer) must hold the same values. let mut placed = body.clone(); @@ -185,6 +175,16 @@ fn place_text(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, owner: ShapeId } } } + // Text hyperlinks: one box per line segment of a linked run. + for run in layout.runs.iter().filter(|r| r.link && !r.glyphs.is_empty()) { + let Some(link) = run_link(&placed, run.para, run.chars.0) else { continue }; + let Some(line) = layout.lines.iter().find(|l| l.para == run.para && l.start <= run.chars.0 && run.chars.0 <= l.end) else { continue }; + // The run's visual extent (right-to-left runs run from right to left). + let first = run.glyphs.first().map(|g| g.1).unwrap_or(0.0); + let (x0, x1) = line.span(run.chars.0, run.chars.1).unwrap_or((first, first)); + let r = Rect::new(x0, line.top, x1, line.bottom); + out.links.push(PlacedLink { rect: m.transform_rect_bbox(r), link, shape: owner, text: true }); + } out.texts.push(PlacedText { layout, transform: m, body: placed }); } From 31abd2d819094b7e3b4a70746e7f804f4695ab0d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 4 Nov 2026 17:45:40 -0500 Subject: [PATCH 76/79] Pass the Linux signing key to the release workflow (#1868) --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 09b477f38..b156fa50d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,3 +45,4 @@ jobs: AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} + LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }} From fc18a78e65bc7ef125ee65c0a3279bc0793b7eb5 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Wed, 4 Nov 2026 17:09:50 -0600 Subject: [PATCH 77/79] comment: reject malformed navigation indices (#1547) --- crates/engine/src/cmd/review.rs | 36 ++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 47cf1ff24..351ca29dc 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -219,7 +219,7 @@ fn neighbour(s: &mut Session, p: &Value, forward: bool) -> Result { let cmd = if forward { "comment.next" } else { "comment.prev" }; let (threads, slide, index) = { let st = s.doc()?; - (comment_threads(&st.doc), usize_param(p, "slide").unwrap_or(st.selection.slide), usize_param(p, "index")) + (comment_threads(&st.doc), usize_param(p, "slide").unwrap_or(st.selection.slide), super::opt_usize_param(p, "index", cmd)?) }; if threads.is_empty() { return Err(bad(cmd, "no comments")); @@ -862,6 +862,40 @@ mod tests { assert_eq!(here["text"], "on first"); } + #[test] + fn malformed_comment_navigation_index_does_not_change_selection() { + let mut s = Session::with_new(); + s.execute("comment.add", &json!({"text": "first"})).unwrap(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [10.0, 10.0, 40.0, 40.0]})).unwrap(); + s.execute("slide.new", &json!({})).unwrap(); + s.execute("comment.add", &json!({"text": "second"})).unwrap(); + s.execute("slide.go", &json!({"index": 0})).unwrap(); + let shape = s.doc().unwrap().doc.slides.first().and_then(|slide| slide.shapes.first()).map(|shape| shape.id).unwrap(); + s.doc_mut().unwrap().selection.shapes = vec![shape]; + + for (command, value) in [ + ("comment.next", json!(-1)), + ("comment.next", json!(1.5)), + ("comment.next", json!("bad")), + ("comment.prev", json!(-1)), + ("comment.prev", json!(1.5)), + ("comment.prev", json!("bad")), + ] { + let before = s.doc().unwrap().selection.clone(); + let error = s.execute(command, &json!({"index": value})).unwrap_err(); + assert!(error.to_string().contains("valid index"), "{command} {value}: {error}"); + assert_eq!(s.doc().unwrap().selection, before, "{command} {value}"); + } + + let zero = s.execute("comment.next", &json!({"slide": 0, "index": 0})).unwrap(); + assert_eq!(zero["slide"], 1); + assert_eq!(zero["index"], 0); + s.execute("slide.go", &json!({"index": 0})).unwrap(); + let omitted = s.execute("comment.next", &json!({})).unwrap(); + assert_eq!(omitted["slide"], 0); + assert_eq!(omitted["index"], 0); + } + #[test] fn timestamps_are_iso_utc() { assert_eq!(iso_utc(0), "1970-01-01T00:00:00Z"); From 85de6ca5d76356ebaf3367ad319bd2fdbb0c0155 Mon Sep 17 00:00:00 2001 From: ".CooCoo" Date: Wed, 4 Nov 2026 15:11:29 -0800 Subject: [PATCH 78/79] Shape Fill: the gradient help says which kinds rotateWithShape keeps upright (#1843) With rotateWithShape false, the renderer lays linear and radial gradients out upright on the slide, while rectangular and path gradients still turn with the shape. The shape.fill params doc now says so. --- crates/engine/src/cmd/shape.rs | 2 +- crates/engine/src/cmd/shape/appearance.rs | 3 ++- crates/engine/src/tests.rs | 3 +++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs index 08ed9be64..a447a20b9 100644 --- a/crates/engine/src/cmd/shape.rs +++ b/crates/engine/src/cmd/shape.rs @@ -103,7 +103,7 @@ pub fn specs() -> Vec { "Shape Fill", ["Shape Format", "Shape Styles"], None, - "{color? | none?: true | background?: true (the slide background shows through; shapes and text boxes only) | gradient?: {stops: [[pos 0..1, color]], kind?: linear|radial|rectangular|path, angle?: deg (linear), focus?: [l, t, r, b] fractions (radial, rectangular and path: where the first stop sits), rotateWithShape?: bool} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill; a group also formats its members", + "{color? | none?: true | background?: true (the slide background shows through; shapes and text boxes only) | gradient?: {stops: [[pos 0..1, color]], kind?: linear|radial|rectangular|path, angle?: deg (linear), focus?: [l, t, r, b] fractions (radial, rectangular and path: where the first stop sits), rotateWithShape?: bool (false keeps linear and radial gradients upright on the slide; rectangular and path gradients turn with the shape when drawn)} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill; a group also formats its members", has_selection, fill ), diff --git a/crates/engine/src/cmd/shape/appearance.rs b/crates/engine/src/cmd/shape/appearance.rs index 4189deaa1..c515d2b2c 100644 --- a/crates/engine/src/cmd/shape/appearance.rs +++ b/crates/engine/src/cmd/shape/appearance.rs @@ -74,7 +74,8 @@ pub(super) fn change(s: &mut Session, p: &Value) -> Result { /// `{stops: [[pos 0–1, color], …], kind?: linear|radial|rectangular|path, angle?: deg (linear), /// focus?: [l, t, r, b] fractions (radial/rectangular/path: where the first stop sits), -/// rotateWithShape?}`. +/// rotateWithShape?}`. The renderer keeps only linear and radial gradients upright when +/// `rotateWithShape` is false; rectangular and path gradients turn with the shape. pub(crate) fn gradient_param(v: &Value) -> Option { let stops: Vec = v .get("stops")? diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e37f9d135..12fac5efc 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4321,6 +4321,9 @@ fn shape_fill_documents_each_gradient_key_and_kind() { let params = cmd::parse_params(find_command("shape.fill").unwrap().params).unwrap(); let about = params.iter().find(|p| p.name == "gradient").unwrap().about; assert_eq!(cmd::param_keys(about).unwrap(), ["stops", "kind", "angle", "focus", "rotateWithShape"]); + // The renderer keeps only linear and radial gradients upright (`paint::fill_path`). + let rotate = cmd::parse_params(about).unwrap().into_iter().find(|p| p.name == "rotateWithShape").unwrap().about; + assert!(rotate.contains("rectangular and path gradients turn with the shape"), "{rotate}"); for kind in ["linear", "radial", "rectangular", "path"] { assert!(about.contains(kind), "{kind}: {about}"); let g = cmd::shape::gradient_param(&json!({ From f321416b34ded8c85b9d8e6a6fec822af212aa48 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:52:05 +0100 Subject: [PATCH 79/79] Make Previous/Next work in the web presenter popup Fixes #1864 Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/show/tests.rs | 6 ++++++ crates/ui-egui/src/show/ui.rs | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/show/tests.rs b/crates/ui-egui/src/show/tests.rs index 1dd6b09ef..b948efde2 100644 --- a/crates/ui-egui/src/show/tests.rs +++ b/crates/ui-egui/src/show/tests.rs @@ -1173,6 +1173,12 @@ fn remote_presenter_uses_the_audience_state_and_closes_with_the_show() { h.run_steps(2); assert_eq!(h.state().show.as_ref().unwrap().state.slide, slide); } + // #1864: the web popup's Previous/Next buttons send the DOM key names. + for (name, slide) in [("ArrowRight", 1), ("ArrowLeft", 0)] { + bridge.borrow_mut().commands.push(Command::Key { name: name.into(), command: false, shift: false, alt: false, ctrl: false }); + h.run_steps(2); + assert_eq!(h.state().show.as_ref().unwrap().state.slide, slide, "{name}"); + } bridge.borrow_mut().commands.push(Command::End); h.run_steps(2); assert!(h.state().show.is_none()); diff --git a/crates/ui-egui/src/show/ui.rs b/crates/ui-egui/src/show/ui.rs index 072714a31..226459755 100644 --- a/crates/ui-egui/src/show/ui.rs +++ b/crates/ui-egui/src/show/ui.rs @@ -94,7 +94,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { show.zoom_arm = false; } crate::presenter::Command::Key { name, command, shift, alt, ctrl } => { - if let Some(key) = egui::Key::ALL.iter().copied().find(|key| key.name() == name) { + if let Some(key) = egui::Key::from_name(&name) { let modifiers = egui::Modifiers { command, shift, alt, ctrl, mac_cmd: command && !ctrl }; out.keep &= handle_keys(&mut show, &doc, now, vec![(key, modifiers)], false).0; }