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 1/2] 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 b653af40..f894d6ee 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 3a757217..690191f7 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 24bfbabf..4189deaa 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 4e75e8b4..e37f9d13 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 7610398e..1d8181cb 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 dcd15636..7a3ee2a2 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 3b81d3ae..565b21aa 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 300773a5..4ee1dbe9 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 84b30c83..1af1d962 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 bc3ea2fa..0a8cacb7 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 744b27ba..5c69c2b9 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 fdddf60f..61f18252 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 987781f1..fb25c7f0 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 329a69f8..0c8f5518 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 1fefa5d9..140c8a89 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 8af3745e..cc5a2d7e 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 c7b91ff2ec150fdf8263cadf07cceb51bca3b060 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 3 Nov 2026 08:50:28 -0500 Subject: [PATCH 2/2] 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 1af1d962..3705cc93 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 1ed97f54..36330460 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))); +}