mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-26 11:36:58 -06:00
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:
@@ -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).
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
Reference in New Issue
Block a user