Compare commits

..
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 f033c3f81d PPTX: a picture fill with no stretch or tile tiles
A shape or background blipFill with neither a:stretch nor a:tile (as some
tools write it, tdf#128596) was stretched across the shape; PowerPoint
tiles the picture at its own size.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 16:02:32 -04:00
5 changed files with 88 additions and 161 deletions
+6
View File
@@ -77,6 +77,12 @@ pub fn fill_el(imp: &mut Imp, part: &Part, f: &El) -> Option<Fill> {
"solidFill" => Fill::Solid { color: color(f).unwrap_or(ColorRef::rgb(Rgba::BLACK)) },
"gradFill" => Fill::Gradient(gradient(f)),
"blipFill" => match blip_fill(imp, part, f) {
// A picture filling a shape or background with neither `a:stretch` nor `a:tile` is
// tiled at its own size, as PowerPoint draws it.
Some(mut p) if f.child("stretch").is_none() && f.child("tile").is_none() => {
p.mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 1.0, sy: 1.0, flip: "none".into(), align: "tl".into() };
Fill::Picture(p)
}
Some(p) => Fill::Picture(p),
None => Fill::None,
},
+1 -87
View File
@@ -14,7 +14,7 @@ use std::sync::Arc;
use deckcraft_geom::{Size, emu_to_pt};
use deckcraft_model::text::TextBody;
use deckcraft_model::{
Background, Comment, CustomShow, Geom, HeaderFooter, Layout, LayoutId, LayoutType, Master, MasterId, MediaId, MediaItem, PhType, Presentation,
Background, Comment, CustomShow, HeaderFooter, Layout, LayoutId, LayoutType, Master, MasterId, MediaId, MediaItem, PhType, Presentation,
Properties, Section, ShowSettings, Slide, SlideId, defaults,
};
@@ -387,7 +387,6 @@ pub fn import(bytes: &[u8]) -> Result<Presentation, PptxError> {
p.props.company = d.root.child("Company").map(|c| c.text()).unwrap_or_default();
}
inherit_placeholder_geometry(&mut p);
p.header_footer = header_footer(&p);
p.media = std::mem::take(&mut imp.media);
p.next_id = imp.next;
@@ -420,91 +419,6 @@ pub fn color_map(cm: &El) -> Vec<(String, String)> {
cm.attrs.iter().filter(|(k, _)| !k.starts_with("xmlns")).map(|(k, v)| (k.clone(), v.clone())).collect()
}
/// Placeholders read without a geometry (an empty custom geometry, see `shapes::sp`) take the
/// 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.
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).
for m in &mut p.masters {
let m = Arc::make_mut(m);
walk_shapes_mut(&mut m.shapes, &mut |s| {
if unset(&s.geom) {
s.geom = Geom::default();
}
});
}
let mut layout_geoms: Vec<(LayoutId, usize, Geom)> = vec![];
for m in &p.masters {
for l in &m.layouts {
let ctx = deckcraft_model::resolve::Ctx::for_layout(p, m, l);
for (i, s) in l.shapes.iter().enumerate() {
if unset(&s.geom) {
let (_, mp) = deckcraft_model::resolve::parents(&ctx, s);
layout_geoms.push((l.id, i, mp.map(|m| m.geom.clone()).filter(|g| !unset(g)).unwrap_or_default()));
}
}
}
}
for m in &mut p.masters {
let m = Arc::make_mut(m);
for l in &mut m.layouts {
for (id, i, g) in layout_geoms.iter().filter(|(id, _, _)| *id == l.id) {
let _ = id;
if let Some(s) = l.shapes.get_mut(*i) {
s.geom = g.clone();
}
}
}
}
let mut slide_geoms: Vec<(usize, usize, Geom)> = vec![];
for (si, sl) in p.slides.iter().enumerate() {
let Some(ctx) = deckcraft_model::resolve::Ctx::for_slide(p, sl) else { continue };
for (i, s) in sl.shapes.iter().enumerate() {
if unset(&s.geom) {
let (lp, mp) = deckcraft_model::resolve::parents(&ctx, s);
let g = lp.map(|l| l.geom.clone()).filter(|g| !unset(g)).or_else(|| mp.map(|m| m.geom.clone()).filter(|g| !unset(g)));
slide_geoms.push((si, i, g.unwrap_or_default()));
}
}
}
for (si, i, g) in slide_geoms {
if let Some(sl) = p.slides.get_mut(si)
&& let Some(s) = Arc::make_mut(sl).shapes.get_mut(i)
{
s.geom = g;
}
}
// Placeholders inside groups (rare) and anything still unset: rectangles.
for sl in &mut p.slides {
walk_shapes_mut(&mut Arc::make_mut(sl).shapes, &mut |s| {
if unset(&s.geom) {
s.geom = Geom::default();
}
});
}
for m in &mut p.masters {
for l in &mut Arc::make_mut(m).layouts {
walk_shapes_mut(&mut l.shapes, &mut |s| {
if unset(&s.geom) {
s.geom = Geom::default();
}
});
}
}
}
/// Every shape, groups included.
fn walk_shapes_mut(shapes: &mut [deckcraft_model::Shape], f: &mut dyn FnMut(&mut deckcraft_model::Shape)) {
for s in shapes {
f(s);
if let deckcraft_model::ShapeKind::Group { children, .. } = &mut s.kind {
walk_shapes_mut(children, f);
}
}
}
fn background(imp: &mut Imp, part: &Part, csld: &El) -> Option<Background> {
let bg = csld.child("bg")?;
if let Some(pr) = bg.child("bgPr") {
-10
View File
@@ -180,15 +180,6 @@ fn preset_geom(pg: &El) -> Geom {
Geom::Preset { name, adj }
}
/// A placeholder whose `spPr` names no geometry takes its layout's (or master's) placeholder's:
/// marked here with an empty custom geometry, filled in once the layouts are read
/// (`inherit_placeholder_geometry`).
fn mark_inherited_geometry(s: &mut Shape, pr: Option<&El>) {
if s.ph.is_some() && pr.is_none_or(|pr| pr.child("prstGeom").is_none() && pr.child("custGeom").is_none()) {
s.geom = Geom::Custom { paths: vec![] };
}
}
/// `spPr` → transform, geometry, fill, line, effects.
fn sp_pr(imp: &mut Imp, part: &Part, s: &mut Shape, pr: &El) {
if let Some(x) = pr.child("xfrm") {
@@ -238,7 +229,6 @@ fn sp(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El) -> Shape {
if let Some(pr) = e.child("spPr") {
sp_pr(imp, part, &mut s, pr);
}
mark_inherited_geometry(&mut s, e.child("spPr"));
if e.bool("useBgFill") == Some(true) {
s.fill = Some(Fill::Background);
}
+81
View File
@@ -0,0 +1,81 @@
//! Fills read from decks written by other tools.
use std::io::{Cursor, Read, Write};
use deckcraft_geom::Xfrm;
use deckcraft_model::style::{Fill, PictureFill, PictureMode};
use deckcraft_model::{Shape, ShapeId, defaults};
/// `bytes` with `edit` applied to every slide part.
fn edit_slides(bytes: &[u8], edit: impl Fn(String) -> String) -> Vec<u8> {
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default();
for i in 0..z.len() {
let mut f = z.by_index(i).expect("entry");
let mut d = vec![];
f.read_to_end(&mut d).expect("read");
let name = f.name().to_string();
if name.starts_with("ppt/slides/slide") && name.ends_with(".xml") {
d = edit(String::from_utf8(d).expect("utf8")).into_bytes();
}
out.start_file(name, o).expect("start");
out.write_all(&d).expect("write");
}
out.finish().expect("finish").into_inner()
}
fn png() -> Vec<u8> {
let mut out = Cursor::new(vec![]);
image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(4, 4, image::Rgb([10, 200, 30])))
.write_to(&mut out, image::ImageFormat::Png)
.expect("png");
out.into_inner()
}
/// A deck with one shape filled with a stretched picture, exported.
fn deck() -> Vec<u8> {
let mut p = defaults::new_presentation(None);
let media = p.add_media("tile.png", "image/png", png());
let id = ShapeId(p.alloc_id());
let first = p.slides[0].id;
p.slide_mut(first).expect("slide").shapes.push(Shape {
id,
name: "Textured".into(),
xfrm: Some(Xfrm::new(10.0, 10.0, 200.0, 100.0)),
fill: Some(Fill::Picture(PictureFill { media, ..Default::default() })),
..Default::default()
});
deckcraft_pptx::export(&p).expect("export")
}
fn textured_mode(bytes: &[u8]) -> PictureMode {
let q = deckcraft_pptx::import(bytes).expect("import");
let s = q.slides[0].shapes.iter().find(|s| s.name == "Textured").expect("shape");
match &s.fill {
Some(Fill::Picture(pf)) => pf.mode.clone(),
other => panic!("{other:?}"),
}
}
#[test]
fn a_shape_picture_fill_without_stretch_or_tile_tiles() {
// tdf#128596: `<a:blipFill><a:blip/></a:blipFill>` on a shape, as some tools write it;
// PowerPoint tiles the picture at its own size.
let bare = edit_slides(&deck(), |s| {
let s = regex_free_strip(&s, "<a:stretch>", "</a:stretch>");
assert!(!s.contains("<a:stretch"), "the test deck lost its stretch");
s
});
assert!(matches!(textured_mode(&bare), PictureMode::Tile { sx, sy, .. } if sx == 1.0 && sy == 1.0));
// An explicit stretch is still a stretch.
assert!(matches!(textured_mode(&deck()), PictureMode::Stretch { .. }));
}
/// `s` without the first `open`…`close` span inside a `blipFill`.
fn regex_free_strip(s: &str, open: &str, close: &str) -> String {
let Some(b) = s.find("<a:blipFill") else { return s.to_string() };
let Some(i) = s[b..].find(open).map(|i| i + b) else { return s.to_string() };
let Some(j) = s[i..].find(close).map(|j| j + i + close.len()) else { return s.to_string() };
format!("{}{}", &s[..i], &s[j..])
}
-64
View File
@@ -1,64 +0,0 @@
//! Placeholder inheritance in decks written by PowerPoint.
use std::io::{Cursor, Read, Write};
use deckcraft_model::{Geom, defaults};
/// `bytes` with `edit` applied to every slide part.
fn edit_slides(bytes: &[u8], edit: impl Fn(String) -> String) -> Vec<u8> {
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default();
for i in 0..z.len() {
let mut f = z.by_index(i).expect("entry");
let mut d = vec![];
f.read_to_end(&mut d).expect("read");
let name = f.name().to_string();
if name.starts_with("ppt/slides/slide") && name.ends_with(".xml") {
d = edit(String::from_utf8(d).expect("utf8")).into_bytes();
}
out.start_file(name, o).expect("start");
out.write_all(&d).expect("write");
}
out.finish().expect("finish").into_inner()
}
#[test]
fn a_placeholder_without_geometry_takes_its_layouts() {
// PowerPoint leaves a slide placeholder's spPr empty and draws it with the layout
// placeholder's geometry (a picture placeholder cut into a shape keeps the shape).
let mut p = defaults::new_presentation(None);
let layout = p.slides[0].layout;
let m = std::sync::Arc::make_mut(&mut p.masters[0]);
let l = m.layouts.iter_mut().find(|l| l.id == layout).expect("layout");
for s in &mut l.shapes {
if s.ph.is_some() {
s.geom = Geom::Preset { name: "ellipse".into(), adj: vec![] };
}
}
let bytes = deckcraft_pptx::export(&p).expect("export");
// Strip the slide's own geometry, as PowerPoint writes inheriting placeholders.
let bare = edit_slides(&bytes, |s| {
let mut s = s;
while let Some(i) = s.find("<a:prstGeom") {
let Some(j) = s[i..].find("</a:prstGeom>").map(|j| j + i + "</a:prstGeom>".len()) else { break };
s.replace_range(i..j, "");
}
s
});
let q = deckcraft_pptx::import(&bare).expect("import");
let phs: Vec<_> = q.slides[0].shapes.iter().filter(|s| s.ph.is_some()).collect();
assert!(!phs.is_empty());
for s in phs {
assert!(matches!(&s.geom, Geom::Preset { name, .. } if name == "ellipse"), "{:?}", s.geom);
}
// A placeholder with its own geometry keeps it.
let first = p.slides[0].id;
for s in &mut p.slide_mut(first).expect("slide").shapes {
if s.ph.is_some() {
s.geom = Geom::Preset { name: "triangle".into(), adj: vec![] };
}
}
let q = deckcraft_pptx::import(&deckcraft_pptx::export(&p).expect("export")).expect("import");
assert!(q.slides[0].shapes.iter().filter(|s| s.ph.is_some()).all(|s| matches!(&s.geom, Geom::Preset { name, .. } if name == "triangle")));
}