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) {