From 0b66323765ad08556ba7454628b057fe02e01f5b Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 04:03:35 +0300 Subject: [PATCH] PPTX: shape text without an anchor sits at the top (or the theme's spDef anchor); write ctr for the model's middle default --- crates/pptx/src/read/mod.rs | 48 +++++++++++++++++++++ crates/pptx/src/write/shapes.rs | 10 ++++- crates/pptx/tests/anchor.rs | 74 +++++++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 crates/pptx/tests/anchor.rs diff --git a/crates/pptx/src/read/mod.rs b/crates/pptx/src/read/mod.rs index 057614eb..cc8a6f93 100644 --- a/crates/pptx/src/read/mod.rs +++ b/crates/pptx/src/read/mod.rs @@ -398,6 +398,7 @@ pub fn import(bytes: &[u8]) -> Result { } inherit_placeholder_geometry(&mut p); + default_text_anchor(&mut p); p.header_footer = header_footer(&p); p.media = std::mem::take(&mut imp.media); p.next_id = imp.next; @@ -434,6 +435,53 @@ pub fn color_map(cm: &El) -> Vec<(String, String)> { /// geometry of the layout or master placeholder they inherit from, as PowerPoint draws them: a /// layout's picture placeholder cut into a shape keeps that shape on the slide. Anything left /// is a rectangle. +/// The anchor the theme's shape defaults (`a:objectDefaults/a:spDef/a:bodyPr`) give new shapes +/// text, if any. +fn theme_shape_anchor(t: &deckcraft_model::Theme) -> Option { + let raw = t.raw_extra.as_deref()?; + let sp = &raw[raw.find("spDef")?..]; + let sp = &sp[..sp.find("/a:spDef").or_else(|| sp.find("txDef")).unwrap_or(sp.len())]; + let b = &sp[sp.find("bodyPr")?..]; + let b = &b[..b.find('>').unwrap_or(b.len())]; + let v = &b[b.find("anchor=\"")? + 8..]; + deckcraft_model::text::Anchor::from_xml(&v[..v.find('"')?]) +} + +/// A shape's text without an anchor of its own sits at the top, as the standard has it (or where +/// the theme's shape defaults put it). The model's own default for shapes is the middle, so a +/// top or bottom anchor is made explicit here and a middle one left unset (placeholders and text +/// boxes keep inheriting). +fn default_text_anchor(p: &mut Presentation) { + fn fix(shapes: &mut [deckcraft_model::Shape], a: deckcraft_model::text::Anchor) { + walk_shapes_mut(shapes, &mut |s| { + if s.ph.is_none() + && !s.text_box + && !matches!(s.kind, deckcraft_model::ShapeKind::Table(_)) + && let Some(t) = &mut s.text + { + // Middle is the model's own default for shapes, so it is kept as unset. + let got = t.body.anchor.unwrap_or(a); + t.body.anchor = (got != deckcraft_model::text::Anchor::Middle).then_some(got); + } + }); + } + let top = deckcraft_model::text::Anchor::Top; + let anchors: Vec<_> = p.masters.iter().map(|m| (m.id, theme_shape_anchor(&m.theme).unwrap_or(top))).collect(); + for m in &mut p.masters { + let a = anchors.iter().find(|(id, _)| *id == m.id).map(|x| x.1).unwrap_or(top); + let m = Arc::make_mut(m); + fix(&mut m.shapes, a); + for l in &mut m.layouts { + fix(&mut l.shapes, a); + } + } + let per_slide: Vec<_> = + p.slides.iter().map(|s| p.master_for(s).and_then(|(m, _)| anchors.iter().find(|(id, _)| *id == m.id)).map(|x| x.1).unwrap_or(top)).collect(); + for (s, a) in p.slides.iter_mut().zip(per_slide) { + fix(&mut Arc::make_mut(s).shapes, a); + } +} + fn inherit_placeholder_geometry(p: &mut Presentation) { let unset = |g: &Geom| matches!(g, Geom::Custom { paths } if paths.is_empty()); // Masters first, then layouts (from their masters), then slides (from layouts and masters). diff --git a/crates/pptx/src/write/shapes.rs b/crates/pptx/src/write/shapes.rs index 8b2ea35d..aca29ac8 100644 --- a/crates/pptx/src/write/shapes.rs +++ b/crates/pptx/src/write/shapes.rs @@ -464,7 +464,15 @@ fn sp(w: &mut W, x: &mut Exp, o: &mut Out, id: u32, s: &Shape, math: bool) { style(w, st); } if let Some(tb) = &s.text { - dml::text_body(w, x, o, "p:txBody", tb, math); + // A shape's text without its own anchor sits in the middle in the model; a file without + // one would put it at the top, so say so. + if s.ph.is_none() && !s.text_box && tb.body.anchor.is_none() { + let mut tb = tb.clone(); + tb.body.anchor = Some(deckcraft_model::text::Anchor::Middle); + dml::text_body(w, x, o, "p:txBody", &tb, math); + } else { + dml::text_body(w, x, o, "p:txBody", tb, math); + } } w.close("p:sp"); } diff --git a/crates/pptx/tests/anchor.rs b/crates/pptx/tests/anchor.rs new file mode 100644 index 00000000..72af2436 --- /dev/null +++ b/crates/pptx/tests/anchor.rs @@ -0,0 +1,74 @@ +//! Text anchoring of shapes without an `anchor` of their own: the model centres a shape's text, +//! a file without one puts it at the top (or where the theme's shape defaults say). + +use std::io::{Cursor, Read, Write}; + +use deckcraft_model::text::Anchor; +use deckcraft_model::{Presentation, ShapeKind}; +use zip::write::SimpleFileOptions; + +fn sample() -> Presentation { + let mut s = deckcraft_engine::Session::new(); + deckcraft_engine::sample::open_sample(&mut s).expect("sample"); + s.doc().expect("doc").doc.as_ref().clone() +} + +/// Every part of `bytes` with `edit` applied to the XML ones. +fn rewrite(bytes: &[u8], edit: impl Fn(&str, String) -> String) -> Vec { + let mut src = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + for i in 0..src.len() { + let mut f = src.by_index(i).expect("entry"); + let name = f.name().to_string(); + let mut data = Vec::new(); + f.read_to_end(&mut data).expect("read"); + if name.ends_with(".xml") { + data = edit(&name, String::from_utf8(data).expect("utf8")).into_bytes(); + } + out.start_file(name, SimpleFileOptions::default()).expect("start"); + out.write_all(&data).expect("write"); + } + out.finish().expect("finish").into_inner() +} + +/// Anchors of the shapes with text that aren't placeholders, text boxes or tables. +fn anchors(p: &Presentation) -> Vec> { + p.slides + .iter() + .flat_map(|s| s.shapes.iter()) + .filter(|s| s.ph.is_none() && !s.text_box && !matches!(s.kind, ShapeKind::Table(_))) + .filter_map(|s| s.text.as_ref().map(|t| t.body.anchor)) + .collect() +} + +#[test] +fn shapes_keep_their_middle_anchor_and_files_without_one_anchor_at_the_top() { + let p = sample(); + assert!(anchors(&p).contains(&None), "the sample has shapes with the model's default anchor"); + let bytes = deckcraft_pptx::export(&p).expect("export"); + + // Written explicitly, so PowerPoint (which reads a missing anchor as top) centres them too. + let back = deckcraft_pptx::import(&bytes).expect("import"); + let a = anchors(&back); + assert_eq!(a, anchors(&p), "the middle anchor survives (as the model's default)"); + + // The same file without the anchors: their text sits at the top. + let bare = rewrite(&bytes, |n, x| if n.starts_with("ppt/slides/slide") { x.replace(r#" anchor="ctr""#, "") } else { x }); + let a = anchors(&deckcraft_pptx::import(&bare).expect("import")); + assert!(a.iter().all(|x| *x == Some(Anchor::Top)), "{a:?}"); + + // ...unless the theme's shape defaults centre them, as Office's themes do. + let themed = rewrite(&bare, |n, x| { + if n.starts_with("ppt/theme/") { + x.replace( + "", + r#""#, + ) + } else { + x + } + }); + let a = anchors(&deckcraft_pptx::import(&themed).expect("import")); + assert!(a.iter().all(|x| x.is_none() || *x == Some(Anchor::Middle)), "{a:?}"); + assert!(a.iter().filter(|x| x.is_none()).count() >= 3, "{a:?}"); +}