diff --git a/ROADMAP.md b/ROADMAP.md index 690f22921..e8ef8316d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -152,6 +152,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 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 | 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 eabe30345..6f345e11c 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -495,6 +495,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 5ac998691..aa692d03f 100644 --- a/crates/format/src/tests.rs +++ b/crates/format/src/tests.rs @@ -316,6 +316,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 ebf73563e..7f863e2c1 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 ec483341a..e928d692d 100644 --- a/crates/pptx/src/write/dml.rs +++ b/crates/pptx/src/write/dml.rs @@ -357,10 +357,18 @@ 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"), + // 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.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), ); } 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/pptx/tests/it/roundtrip.rs b/crates/pptx/tests/it/roundtrip.rs index 8178f3be6..9a4d0a311 100644 --- a/crates/pptx/tests/it/roundtrip.rs +++ b/crates/pptx/tests/it/roundtrip.rs @@ -2138,3 +2138,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))); +} 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 b0fd8200b..928c95b91 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -2,6 +2,7 @@ > **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) 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** @@ -123,6 +124,7 @@ the round trip even though DeckCraft doesn't model it. | 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-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-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 2d4fdff7b..5a8716254 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,7 @@ # 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) 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 @@ -369,6 +370,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-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 |