From cdaeb2fbdef6432dd7dc709402bd7e3ee2010e88 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 07:27:56 -0400 Subject: [PATCH] Pptx/Render: picture fills with rotWithShape=0 stay upright A shape's picture fill marked not to rotate with the shape turned and flipped with it (rotWithShape was neither kept nor written: always 1). PowerPoint keeps such a picture upright, stretched (or tiled) over the box the rotated shape covers on the slide and clipped to the shape. Now read, written (PictureFill::upright) and drawn so. Wild synthetic set: picture fills on rotated/flipped shapes 12.89 -> 3.87. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/model/src/style.rs | 4 ++++ crates/pptx/src/read/dml.rs | 2 +- crates/pptx/src/write/dml.rs | 2 +- crates/pptx/tests/fills.rs | 21 +++++++++++++++++++++ crates/pptx/tests/roundtrip.rs | 1 + crates/render/src/paint.rs | 13 +++++++++++++ crates/render/src/tests_wild.rs | 27 +++++++++++++++++++++++++++ 7 files changed, 68 insertions(+), 2 deletions(-) diff --git a/crates/model/src/style.rs b/crates/model/src/style.rs index b1b2a53..efb30e4 100644 --- a/crates/model/src/style.rs +++ b/crates/model/src/style.rs @@ -139,6 +139,10 @@ pub struct PictureFill { /// 0–1 opacity of the picture. pub alpha: Option, pub adjust: PictureAdjust, + /// `rotWithShape="0"`: the picture keeps to the slide's axes when its shape is rotated or + /// flipped. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub upright: bool, } /// Picture corrections and colour (Picture Format ▸ Corrections / Color / Transparency). diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index b0f7a80..0aee935 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -125,7 +125,7 @@ pub fn blip_fill(imp: &mut Imp, part: &Part, bf: &El) -> Option { let blip = bf.child("blip"); let media = blip.and_then(|b| media_ref(imp, part, b)); let media = media?; - let mut pf = PictureFill { media, ..Default::default() }; + let mut pf = PictureFill { media, upright: !bf.bool("rotWithShape").unwrap_or(true), ..Default::default() }; if let Some(b) = blip { for e in b.elements() { match e.local() { diff --git a/crates/pptx/src/write/dml.rs b/crates/pptx/src/write/dml.rs index 2aa8693..16a069f 100644 --- a/crates/pptx/src/write/dml.rs +++ b/crates/pptx/src/write/dml.rs @@ -96,7 +96,7 @@ pub fn fill(w: &mut W, x: &mut Exp, o: &mut Out, f: &Fill) { /// `a:blipFill` / `p:blipFill`. pub fn blip_fill(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, pf: &PictureFill) { let rid = x.media_rel(o, pf.media, "image").unwrap_or_else(|| x.placeholder_rel(o)); - w.open(tag, A::new().a("rotWithShape", "1")); + w.open(tag, A::new().a("rotWithShape", if pf.upright { "0" } else { "1" })); let adj = &pf.adjust; let has_kids = pf.alpha.is_some() || adj.brightness != 0.0 diff --git a/crates/pptx/tests/fills.rs b/crates/pptx/tests/fills.rs index 98e6d1b..e5955c2 100644 --- a/crates/pptx/tests/fills.rs +++ b/crates/pptx/tests/fills.rs @@ -228,3 +228,24 @@ fn black_and_white_and_inverted_alpha_pictures_round_trip() { other => panic!("{other:?}"), } } + +#[test] +fn an_upright_picture_fill_round_trips() { + let bytes = deck(); + let edited = edit_slides(&bytes, |x| x.replacen("rotWithShape=\"1\"", "rotWithShape=\"0\"", 1)); + let q = deckcraft_pptx::import(&edited).expect("import"); + let s = q.slides[0].shapes.iter().find(|s| s.name == "Textured").expect("shape"); + let Some(Fill::Picture(pf)) = &s.fill else { panic!("{:?}", s.fill) }; + assert!(pf.upright); + let again = deckcraft_pptx::export(&q).expect("export"); + let r = deckcraft_pptx::import(&again).expect("import"); + let s = r.slides[0].shapes.iter().find(|s| s.name == "Textured").expect("shape"); + assert!(matches!(&s.fill, Some(Fill::Picture(pf)) if pf.upright)); + // Rotating with the shape is the default. + assert!(matches!(textured_fill(&bytes), Some(Fill::Picture(pf)) if !pf.upright)); +} + +fn textured_fill(bytes: &[u8]) -> Option { + let q = deckcraft_pptx::import(bytes).expect("import"); + q.slides[0].shapes.iter().find(|s| s.name == "Textured").and_then(|s| s.fill.clone()) +} diff --git a/crates/pptx/tests/roundtrip.rs b/crates/pptx/tests/roundtrip.rs index ed5cb42..db6d0be 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -231,6 +231,7 @@ pub fn rich_deck() -> Presentation { mode: PictureMode::Stretch { fill_rect: [0.0; 4] }, alpha: Some(0.5), adjust: PictureAdjust { brightness: 0.2, contrast: -0.1, grayscale: true, ..Default::default() }, + upright: false, }, }; let mut tiled = shape(&mut p, "Tiled", 350.0, 120.0, 200.0, 150.0); diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index 82976bc..8011f1b 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -82,6 +82,19 @@ pub fn fill_path( ctx.fill_path(path); } Fill::Picture(pf) => { + // `rotWithShape="0"`: upright on the slide, stretched over the box the turned shape + // covers, clipped to it. + let c = m.as_coeffs(); + let turned = c[1].abs() > 1e-9 || c[2].abs() > 1e-9 || c[0] < 0.0 || c[3] < 0.0; + if pf.upright && turned && m.determinant().abs() > 1e-12 { + let on_slide = m.transform_rect_bbox(bounds); + 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); + ctx.pop_layer(); + return; + } ctx.push_clip_layer(path); picture(ctx, pres, &resolved(rctx, pf, ph), bounds, m, None); ctx.pop_layer(); diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index 66e73ef..fd3013a 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -683,3 +683,30 @@ fn stars_and_right_arrows_have_sharp_corners_by_default() { assert!(close(img.pixel(95, 95), WHITE, 30), "{:?}", img.pixel(95, 95)); assert!(deckcraft_geom::preset::mitred_by_default("rightArrow") && !deckcraft_geom::preset::mitred_by_default("leftArrow")); } + +#[test] +fn an_upright_picture_fill_stays_upright_in_a_turned_shape() { + use deckcraft_model::style::PictureFill; + let blue = Rgba::rgb(0, 0, 255); + for upright in [false, true] { + let mut p = Presentation::default(); + let media = p.add_media("t.png", "image/png", half_red_half_blue()); + // Half red (left) half blue picture in a 200 × 200 square turned a quarter (and so + // covering the same square). + let sh = Shape { + id: ShapeId(30), + xfrm: Some(Xfrm { rot: 90.0, ..Xfrm::new(100.0, 100.0, 200.0, 200.0) }), + fill: Some(Fill::Picture(PictureFill { media, upright, ..Default::default() })), + line: no_line(), + ..Default::default() + }; + std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh]; + let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + let (left, top) = (img.pixel(110, 200), img.pixel(200, 110)); + if upright { + assert!(close(left, RED, 30) && close(img.pixel(290, 200), blue, 30), "upright: red left {left:?}"); + } else { + assert!(close(top, RED, 30), "turned with the shape: red at the top {top:?}"); + } + } +}