Merge PR #654 from NikitkaRa: PPTX: shape text anchors at the top when the file gives no anchor; write ctr for centred shapes

This commit is contained in:
Brandon Thomas
2026-10-14 14:24:17 -04:00
3 changed files with 131 additions and 1 deletions
+48
View File
@@ -657,6 +657,7 @@ pub fn import(bytes: &[u8]) -> Result<Presentation, PptxError> {
p.kept_parts = kept_parts(&pkg, &pres_path);
inherit_placeholder_geometry(&mut p);
default_text_anchor(&mut p);
p.header_footer = header_footer(&p);
p.embedded_fonts = read_embedded_fonts(&mut imp, &pres_part, pres_el);
p.media = std::mem::take(&mut imp.media);
@@ -748,6 +749,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<deckcraft_model::text::Anchor> {
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).
+9 -1
View File
@@ -646,7 +646,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");
}
+74
View File
@@ -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<u8> {
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<Option<Anchor>> {
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(
"<a:objectDefaults/>",
r#"<a:objectDefaults><a:spDef><a:spPr/><a:bodyPr rtlCol="0" anchor="ctr"/><a:lstStyle/></a:spDef></a:objectDefaults>"#,
)
} 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:?}");
}