pptx: preserve 3-D scene and extrusion XML on text bodies and groups

This commit is contained in:
Neil Cawse
2026-11-08 05:24:59 +00:00
parent 25809faf1a
commit aefd54d28e
7 changed files with 62 additions and 3 deletions
+1
View File
@@ -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
}
+6
View File
@@ -124,6 +124,9 @@ pub struct BodyProps {
pub compat_ln_spc: Option<bool>,
pub vert_overflow: Option<TextOverflow>,
pub horz_overflow: Option<TextOverflow>,
/// Unmodelled 3-D XML on the text body (`a:scene3d`, `a:sp3d`, `a:flatTx`).
#[serde(skip_serializing_if = "Option::is_none")]
pub raw3d: Option<String>,
}
#[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);
}
}
}
+7 -1
View File
@@ -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 }
+4
View File
@@ -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();
+5 -1
View File
@@ -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");
}
+12 -1
View File
@@ -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);
+27
View File
@@ -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 =
"<a:scene3d><a:camera prst=\"orthographicFront\"/><a:lightRig rig=\"threePt\" dir=\"t\"/></a:scene3d><a:sp3d extrusionH=\"12700\"/>";
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 = "<a:scene3d><a:camera prst=\"isometricTopUp\"/><a:lightRig rig=\"threePt\" dir=\"t\"/></a:scene3d>";
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) {