From aefd54d28e5b7a7c661e611f87b48379ff70a096 Mon Sep 17 00:00:00 2001 From: Neil Cawse Date: Sat, 7 Nov 2026 18:14:24 +0000 Subject: [PATCH 1/4] pptx: preserve 3-D scene and extrusion XML on text bodies and groups --- crates/model/src/resolve.rs | 1 + crates/model/src/text.rs | 6 ++++++ crates/pptx/src/read/dml.rs | 8 +++++++- crates/pptx/src/read/shapes.rs | 4 ++++ crates/pptx/src/write/dml.rs | 6 +++++- crates/pptx/src/write/shapes.rs | 13 ++++++++++++- crates/pptx/tests/it/corpus_style.rs | 27 +++++++++++++++++++++++++++ 7 files changed, 62 insertions(+), 3 deletions(-) diff --git a/crates/model/src/resolve.rs b/crates/model/src/resolve.rs index 30a9d738a..cad10dd04 100644 --- a/crates/model/src/resolve.rs +++ b/crates/model/src/resolve.rs @@ -314,6 +314,7 @@ pub fn body(ctx: &Ctx, shape: &Shape) -> BodyProps { compat_ln_spc: None, vert_overflow: None, horz_overflow: None, + raw3d: None, }); b } diff --git a/crates/model/src/text.rs b/crates/model/src/text.rs index f8f1be062..be13cf14b 100644 --- a/crates/model/src/text.rs +++ b/crates/model/src/text.rs @@ -124,6 +124,9 @@ pub struct BodyProps { pub compat_ln_spc: Option, pub vert_overflow: Option, pub horz_overflow: Option, + /// Unmodelled 3-D XML on the text body (`a:scene3d`, `a:sp3d`, `a:flatTx`). + #[serde(skip_serializing_if = "Option::is_none")] + pub raw3d: Option, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -187,6 +190,9 @@ impl BodyProps { self.compat_ln_spc = self.compat_ln_spc.or(p.compat_ln_spc); self.vert_overflow = self.vert_overflow.or(p.vert_overflow); self.horz_overflow = self.horz_overflow.or(p.horz_overflow); + if self.raw3d.is_none() { + self.raw3d.clone_from(&p.raw3d); + } } } diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index c8902e4c7..c4e8e3b38 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -629,7 +629,13 @@ pub fn list_style(imp: &mut Imp, part: &Part, ls: &El) -> ListStyle { } pub fn text_body(imp: &mut Imp, part: &Part, tx: &El) -> TextBody { - let body = tx.child("bodyPr").map(body_pr).unwrap_or_default(); + let mut body = tx.child("bodyPr").map(body_pr).unwrap_or_default(); + if let Some(b) = tx.child("bodyPr") { + let raw3d: String = b.elements().filter(|c| matches!(c.local(), "scene3d" | "sp3d" | "flatTx")).map(|c| part.keep_inner(c)).collect(); + if !raw3d.is_empty() { + body.raw3d = Some(raw3d); + } + } let list_style = tx.child("lstStyle").map(|l| list_style(imp, part, l)).unwrap_or_default(); let paragraphs = tx.children_named("p").take(100_000).map(|p| paragraph(imp, part, p)).collect(); TextBody { body, list_style, paragraphs } diff --git a/crates/pptx/src/read/shapes.rs b/crates/pptx/src/read/shapes.rs index 3230db100..2710706a5 100644 --- a/crates/pptx/src/read/shapes.rs +++ b/crates/pptx/src/read/shapes.rs @@ -395,6 +395,10 @@ fn grp(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> Sha if let Some(fx) = pr.child("effectLst") { s.effects = Some(dml::effects(fx)); } + let raw: String = pr.elements().filter(|c| c.is("scene3d")).map(|c| part.keep_inner(c)).collect(); + if !raw.is_empty() { + s.effects.get_or_insert_with(Effects::default).raw3d = Some(raw); + } } let children = sp_tree(imp, part, ctx, e, depth + 1); let child = child.or_else(|| s.xfrm.map(|x| Xfrm::new(x.x, x.y, x.w, x.h))).unwrap_or_default(); diff --git a/crates/pptx/src/write/dml.rs b/crates/pptx/src/write/dml.rs index 8acc648d0..d58745ef3 100644 --- a/crates/pptx/src/write/dml.rs +++ b/crates/pptx/src/write/dml.rs @@ -481,7 +481,8 @@ pub fn body_pr(w: &mut W, b: &BodyProps) { .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())); - if warp.is_none() && b.autofit.is_none() { + let raw3d = b.raw3d.as_deref().filter(|s| !s.is_empty()); + if warp.is_none() && b.autofit.is_none() && raw3d.is_none() { w.empty("a:bodyPr", a); return; } @@ -515,6 +516,9 @@ pub fn body_pr(w: &mut W, b: &BodyProps) { } None => {} } + if let Some(raw) = raw3d { + w.raw(raw); + } w.close("a:bodyPr"); } diff --git a/crates/pptx/src/write/shapes.rs b/crates/pptx/src/write/shapes.rs index 3a9ba82d3..0d0c4778a 100644 --- a/crates/pptx/src/write/shapes.rs +++ b/crates/pptx/src/write/shapes.rs @@ -334,7 +334,18 @@ pub fn shape(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, depth dml::fill(w, x, o, f); } if let Some(fx) = &s.effects { - dml::effects(w, fx); + if fx.fill_overlay.is_some() + || fx.glow.is_some() + || fx.inner_shadow.is_some() + || fx.outer_shadow.is_some() + || fx.reflection.is_some() + || fx.soft_edge.is_some() + { + dml::effects(w, fx); + } + if let Some(raw) = &fx.raw3d { + w.raw(raw); + } } w.close("p:grpSpPr"); shapes(w, x, o, cx, children, depth + 1); diff --git a/crates/pptx/tests/it/corpus_style.rs b/crates/pptx/tests/it/corpus_style.rs index 0a8cacb73..8b4126e44 100644 --- a/crates/pptx/tests/it/corpus_style.rs +++ b/crates/pptx/tests/it/corpus_style.rs @@ -239,6 +239,33 @@ fn theme_and_bevel_survive(once: &Presentation, twice: &Presentation) { let raw = bevel.raw3d.as_deref().unwrap_or(""); assert!(raw.contains("bevelT") && raw.contains("angle") && raw.contains("scene3d"), "{raw}"); assert_eq!(by_name(twice, "Bevel").effects.as_ref().and_then(|e| e.raw3d.as_deref()), Some(raw)); + + let text_3d = + ""; + let mut p3 = defaults::new_presentation(None); + let mut sh = shape(&mut p3, "Text3D", 10.0, 10.0, 100.0, 40.0); + sh.text = Some(TextBody { + body: BodyProps { raw3d: Some(text_3d.into()), ..Default::default() }, + paragraphs: vec![Paragraph::new("3D Text")], + ..Default::default() + }); + let grp_3d = ""; + let mut grp = shape(&mut p3, "Group3D", 10.0, 60.0, 100.0, 40.0); + grp.effects = Some(Effects { raw3d: Some(grp_3d.into()), ..Default::default() }); + grp.kind = ShapeKind::Group { children: vec![sh], child: Xfrm::new(10.0, 10.0, 100.0, 40.0) }; + let layout = defaults::layout_of_kind(&p3, deckcraft_model::LayoutType::Blank).unwrap_or_default(); + let mut s3 = defaults::new_slide(&mut p3, layout).expect("slide"); + s3.shapes.push(grp); + p3.slides.push(Arc::new(s3)); + let r3 = round(&p3); + let r3_twice = round(&r3); + let g3 = by_name(&r3, "Group3D"); + let g3_raw = g3.effects.as_ref().and_then(|e| e.raw3d.as_deref()).unwrap_or(""); + let t3_raw = g3.children()[0].text.as_ref().and_then(|t| t.body.raw3d.as_deref()).unwrap_or(""); + assert!(g3_raw.contains("isometricTopUp"), "{g3_raw}"); + assert!(t3_raw.contains("orthographicFront") && t3_raw.contains("extrusionH=\"12700\""), "{t3_raw}"); + assert_eq!(by_name(&r3_twice, "Group3D").effects.as_ref().and_then(|e| e.raw3d.as_deref()), Some(g3_raw)); + assert_eq!(by_name(&r3_twice, "Group3D").children()[0].text.as_ref().and_then(|t| t.body.raw3d.as_deref()), Some(t3_raw)); } fn connector_glue_survives(p: &Presentation, once: &Presentation, twice: &Presentation) { From 424f575ca5529e2b378d07dfdfc3ca28c6135252 Mon Sep 17 00:00:00 2001 From: Neil Cawse Date: Sun, 8 Nov 2026 05:26:33 +0000 Subject: [PATCH 2/4] fix(pptx): bound and validate body/group raw3d XML on read/write and expand 3-D tests --- crates/pptx/src/read/dml.rs | 10 +++++-- crates/pptx/src/read/shapes.rs | 8 +++--- crates/pptx/src/write/dml.rs | 17 +++++++++++- crates/pptx/src/write/shapes.rs | 2 +- crates/pptx/tests/it/corpus_style.rs | 40 +++++++++++++++++++++++++--- 5 files changed, 67 insertions(+), 10 deletions(-) diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index c4e8e3b38..eb05ea4fe 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -631,8 +631,14 @@ pub fn list_style(imp: &mut Imp, part: &Part, ls: &El) -> ListStyle { pub fn text_body(imp: &mut Imp, part: &Part, tx: &El) -> TextBody { let mut body = tx.child("bodyPr").map(body_pr).unwrap_or_default(); if let Some(b) = tx.child("bodyPr") { - let raw3d: String = b.elements().filter(|c| matches!(c.local(), "scene3d" | "sp3d" | "flatTx")).map(|c| part.keep_inner(c)).collect(); - if !raw3d.is_empty() { + let mut raw3d = String::new(); + if let Some(sc) = b.child("scene3d") { + raw3d.push_str(&part.keep_inner(sc)); + } + if let Some(sp) = b.child("sp3d").or_else(|| b.child("flatTx")) { + raw3d.push_str(&part.keep_inner(sp)); + } + if !raw3d.is_empty() && raw3d.len() <= 64 * 1024 { body.raw3d = Some(raw3d); } } diff --git a/crates/pptx/src/read/shapes.rs b/crates/pptx/src/read/shapes.rs index 2710706a5..e1368b427 100644 --- a/crates/pptx/src/read/shapes.rs +++ b/crates/pptx/src/read/shapes.rs @@ -395,9 +395,11 @@ fn grp(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> Sha if let Some(fx) = pr.child("effectLst") { s.effects = Some(dml::effects(fx)); } - let raw: String = pr.elements().filter(|c| c.is("scene3d")).map(|c| part.keep_inner(c)).collect(); - if !raw.is_empty() { - s.effects.get_or_insert_with(Effects::default).raw3d = Some(raw); + if let Some(sc) = pr.child("scene3d") { + let raw = part.keep_inner(sc); + if !raw.is_empty() && raw.len() <= 64 * 1024 { + s.effects.get_or_insert_with(Effects::default).raw3d = Some(raw); + } } } let children = sp_tree(imp, part, ctx, e, depth + 1); diff --git a/crates/pptx/src/write/dml.rs b/crates/pptx/src/write/dml.rs index d58745ef3..d818c8e6b 100644 --- a/crates/pptx/src/write/dml.rs +++ b/crates/pptx/src/write/dml.rs @@ -461,6 +461,21 @@ fn sound_label(name: &str) -> String { if label.is_empty() { "sound.wav".into() } else { label } } +/// Validate an unmodelled 3-D fragment (`a:scene3d`, `a:sp3d`, `a:flatTx`) before writing raw XML. +pub fn valid_raw3d<'a>(raw: &'a str, allowed: &[&str]) -> Option<&'a str> { + if raw.is_empty() || raw.len() > 64 * 1024 { + return None; + } + let wrap = format!("{raw}", crate::opc::NS_A); + let doc = crate::xml::parse(wrap.as_bytes()).ok()?; + let els: Vec<_> = doc.root.elements().collect(); + (!els.is_empty() + && els.len() <= allowed.len() + && doc.root.children.len() == els.len() + && els.iter().all(|e| e.prefix() == "a" && allowed.contains(&e.local()) && !crate::read::has_rel_refs(e))) + .then_some(raw) +} + pub fn body_pr(w: &mut W, b: &BodyProps) { let a = A::new() .o("rot", b.rot.map(|r| (r * 60_000.0).round().clamp(-21_600_000.0, 21_600_000.0) as i64)) @@ -481,7 +496,7 @@ pub fn body_pr(w: &mut W, b: &BodyProps) { .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())); - let raw3d = b.raw3d.as_deref().filter(|s| !s.is_empty()); + let raw3d = b.raw3d.as_deref().and_then(|s| valid_raw3d(s, &["scene3d", "sp3d", "flatTx"])); if warp.is_none() && b.autofit.is_none() && raw3d.is_none() { w.empty("a:bodyPr", a); return; diff --git a/crates/pptx/src/write/shapes.rs b/crates/pptx/src/write/shapes.rs index 0d0c4778a..2140211c7 100644 --- a/crates/pptx/src/write/shapes.rs +++ b/crates/pptx/src/write/shapes.rs @@ -343,7 +343,7 @@ pub fn shape(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, depth { dml::effects(w, fx); } - if let Some(raw) = &fx.raw3d { + if let Some(raw) = fx.raw3d.as_deref().and_then(|s| dml::valid_raw3d(s, &["scene3d"])) { w.raw(raw); } } diff --git a/crates/pptx/tests/it/corpus_style.rs b/crates/pptx/tests/it/corpus_style.rs index 8b4126e44..e64e2f5e6 100644 --- a/crates/pptx/tests/it/corpus_style.rs +++ b/crates/pptx/tests/it/corpus_style.rs @@ -239,9 +239,13 @@ fn theme_and_bevel_survive(once: &Presentation, twice: &Presentation) { let raw = bevel.raw3d.as_deref().unwrap_or(""); assert!(raw.contains("bevelT") && raw.contains("angle") && raw.contains("scene3d"), "{raw}"); assert_eq!(by_name(twice, "Bevel").effects.as_ref().and_then(|e| e.raw3d.as_deref()), Some(raw)); +} +#[test] +fn text_and_group_3d_survive() { let text_3d = ""; + let flat_tx = ""; let mut p3 = defaults::new_presentation(None); let mut sh = shape(&mut p3, "Text3D", 10.0, 10.0, 100.0, 40.0); sh.text = Some(TextBody { @@ -249,23 +253,53 @@ fn theme_and_bevel_survive(once: &Presentation, twice: &Presentation) { paragraphs: vec![Paragraph::new("3D Text")], ..Default::default() }); + let mut sh_flat = shape(&mut p3, "FlatTx3D", 10.0, 55.0, 100.0, 40.0); + sh_flat.text = Some(TextBody { + body: BodyProps { raw3d: Some(flat_tx.into()), ..Default::default() }, + paragraphs: vec![Paragraph::new("Flat Text")], + ..Default::default() + }); let grp_3d = ""; - let mut grp = shape(&mut p3, "Group3D", 10.0, 60.0, 100.0, 40.0); + let mut grp = shape(&mut p3, "Group3D", 10.0, 60.0, 100.0, 80.0); grp.effects = Some(Effects { raw3d: Some(grp_3d.into()), ..Default::default() }); - grp.kind = ShapeKind::Group { children: vec![sh], child: Xfrm::new(10.0, 10.0, 100.0, 40.0) }; + grp.kind = ShapeKind::Group { children: vec![sh, sh_flat], child: Xfrm::new(10.0, 10.0, 100.0, 80.0) }; let layout = defaults::layout_of_kind(&p3, deckcraft_model::LayoutType::Blank).unwrap_or_default(); let mut s3 = defaults::new_slide(&mut p3, layout).expect("slide"); s3.shapes.push(grp); p3.slides.push(Arc::new(s3)); - let r3 = round(&p3); + + let bytes = deckcraft_pptx::export(&p3).expect("export"); + let slide_xml = { + use std::io::Read; + let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).expect("zip"); + let mut s = String::new(); + z.by_name("ppt/slides/slide2.xml").expect("slide2").read_to_string(&mut s).expect("utf8"); + s + }; + assert!(!slide_xml.contains(""), "Group3D with only raw3d must not emit empty effectLst: {slide_xml}"); + assert!(slide_xml.contains(""), "{slide_xml}"); + + let r3 = deckcraft_pptx::import(&bytes).expect("import"); let r3_twice = round(&r3); let g3 = by_name(&r3, "Group3D"); let g3_raw = g3.effects.as_ref().and_then(|e| e.raw3d.as_deref()).unwrap_or(""); let t3_raw = g3.children()[0].text.as_ref().and_then(|t| t.body.raw3d.as_deref()).unwrap_or(""); + let flat_raw = g3.children()[1].text.as_ref().and_then(|t| t.body.raw3d.as_deref()).unwrap_or(""); assert!(g3_raw.contains("isometricTopUp"), "{g3_raw}"); assert!(t3_raw.contains("orthographicFront") && t3_raw.contains("extrusionH=\"12700\""), "{t3_raw}"); + assert!(flat_raw.contains("flatTx"), "{flat_raw}"); assert_eq!(by_name(&r3_twice, "Group3D").effects.as_ref().and_then(|e| e.raw3d.as_deref()), Some(g3_raw)); assert_eq!(by_name(&r3_twice, "Group3D").children()[0].text.as_ref().and_then(|t| t.body.raw3d.as_deref()), Some(t3_raw)); + assert_eq!(by_name(&r3_twice, "Group3D").children()[1].text.as_ref().and_then(|t| t.body.raw3d.as_deref()), Some(flat_raw)); + + // BodyProps::inherit propagates parent raw3d only when child raw3d is None. + let parent_body = BodyProps { raw3d: Some(text_3d.into()), ..Default::default() }; + let mut child_body = BodyProps::default(); + child_body.inherit(&parent_body); + assert_eq!(child_body.raw3d.as_deref(), Some(text_3d)); + let mut override_body = BodyProps { raw3d: Some(flat_tx.into()), ..Default::default() }; + override_body.inherit(&parent_body); + assert_eq!(override_body.raw3d.as_deref(), Some(flat_tx)); } fn connector_glue_survives(p: &Presentation, once: &Presentation, twice: &Presentation) { From 220b52dfdb1f7a8b9aa05031a72103fc1c54c7f7 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:43:41 +0000 Subject: [PATCH 3/4] Apply review fixes - docs/file-format-parity.md --- docs/file-format-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 20d4b4d8e..70b74f7e1 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -84,7 +84,7 @@ the round trip even though DeckCraft doesn't model it. | Lines: dash, compound, caps, joins, arrowheads | yes | yes | | | 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 | +| 3-D: `scene3d`, `sp3d` | kept | kept | A bevel preset with no `sp3d` of its own is written back on shapes and theme effect styles. `scene3d`, `sp3d` and `flatTx` on a text body (`a:bodyPr`) and `scene3d` on a group (`p:grpSpPr`) are kept as raw XML too. 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; Reuse Slides transfers definitions referenced by source tables, including grouped tables, with conflicting ids remapped | From 809c44654de372bf08670aeb7b91c823ac0d8a0f Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:59:15 +0000 Subject: [PATCH 4/4] Apply review fixes - crates/pptx/src/write/shapes.rs --- crates/pptx/src/write/shapes.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/pptx/src/write/shapes.rs b/crates/pptx/src/write/shapes.rs index 2140211c7..80eabbcf2 100644 --- a/crates/pptx/src/write/shapes.rs +++ b/crates/pptx/src/write/shapes.rs @@ -343,7 +343,9 @@ pub fn shape(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, depth { dml::effects(w, fx); } - if let Some(raw) = fx.raw3d.as_deref().and_then(|s| dml::valid_raw3d(s, &["scene3d"])) { + if let Some(raw) = + fx.raw3d.as_deref().map(|s| s.find("