Map simple PPTX shapes to shared presentation models

This commit is contained in:
Dylan M. Taylor committed 2026-11-13 02:18:53 -05:00
1 parent 916749b2d0
commit 2a6918255e
3 files changed
+138 -6

No files matched your search

+15 -1
View File
@@ -80,6 +80,16 @@ pub enum PptxError {
pub type Result<T> = std::result::Result<T, PptxError>;
fn apply_native_shapes(shapes: &mut [craft_pptx::Shape], native: &[craft_pptx::Shape]) {
for shape in shapes {
if let Some(mapped) = native.iter().find(|mapped| mapped.id.0 == shape.id.0) {
*shape = mapped.clone();
} else if let craft_pptx::ShapeKind::Group { children, .. } = &mut shape.kind {
apply_native_shapes(children, native);
}
}
}
/// Export `doc` as a PowerPoint presentation.
pub fn export_pptx(doc: &Document, cache: &Cache, opts: &PptxOptions) -> Result<ExportReport> {
let count = doc.page_count();
@@ -130,7 +140,11 @@ pub fn export_pptx(doc: &Document, cache: &Cache, opts: &PptxOptions) -> Result<
author: opts.author.as_deref().unwrap_or(""),
created: opts.created,
})?;
let presentation = craft_pptx::import(&mapped, &craft_pptx::Plain).map_err(|e| PptxError::Write(e.to_string()))?;
let mut presentation = craft_pptx::import(&mapped, &craft_pptx::Plain).map_err(|e| PptxError::Write(e.to_string()))?;
for (source, slide) in slides.iter().zip(&mut presentation.slides) {
let slide = std::sync::Arc::make_mut(slide);
apply_native_shapes(&mut slide.shapes, &source.native_shapes);
}
let bytes = craft_pptx::export(&presentation, &craft_pptx::Plain).map_err(|e| PptxError::Write(e.to_string()))?;
Ok(ExportReport { bytes, slides: slides.len(), warnings: ex.warnings })
}
+2
View File
@@ -27,6 +27,8 @@ pub struct Slide {
pub shapes: String,
pub rels: Vec<Rel>,
pub name: String,
/// Shapes that have a direct DesignCraft-to-Presentation model mapping.
pub native_shapes: Vec<craft_pptx::Shape>,
}
/// A media part (`ppt/media/<name>`).
+121 -5
View File
@@ -105,6 +105,7 @@ pub fn box_of(inner: Rect, m: Affine) -> Option<(XfrmBox, Decomposed)> {
pub struct Geometry {
pub xfrm: XfrmBox,
pub xml: String,
pub model: craft_pptx::Geom,
}
fn custom_geometry(bp: &BezPath, w: f64, h: f64, filled: bool) -> String {
@@ -171,23 +172,31 @@ pub fn geometry(it: &Item, m: Affine) -> Geometry {
let inner = bp.bounding_box();
if let Some((xfrm, dm)) = box_of(inner, m) {
if it.corners.is_none() && is_rect(&bp, inner) {
return Geometry { xfrm, xml: preset("rect") };
return Geometry { xfrm, xml: preset("rect"), model: craft_pptx::Geom::preset("rect") };
}
if it.shape == Shape::Oval && it.corners.is_none() && closed {
return Geometry { xfrm, xml: preset("ellipse") };
return Geometry { xfrm, xml: preset("ellipse"), model: craft_pptx::Geom::preset("ellipse") };
}
// The path in the box's own space: scaled (not flipped: the box flips), from its corner.
let (kx, ky) = (dm.sx.abs(), dm.sy.abs());
let mut local = bp.clone();
local.apply_affine(Affine::scale_non_uniform(kx, ky) * Affine::translate(-inner.origin().to_vec2()));
return Geometry { xfrm, xml: custom_geometry(&local, xfrm.rect.width(), xfrm.rect.height(), closed) };
return Geometry {
xfrm,
xml: custom_geometry(&local, xfrm.rect.width(), xfrm.rect.height(), closed),
model: craft_pptx::Geom::Custom { paths: vec![craft_pptx::CustomPath::from_bez(&local, xfrm.rect.width(), xfrm.rect.height(), closed)] },
};
}
// Sheared: the transformed path in slide space.
let mut pb = bp;
pb.apply_affine(m);
let b = pb.bounding_box();
pb.apply_affine(Affine::translate(-b.origin().to_vec2()));
Geometry { xfrm: XfrmBox::plain(b), xml: custom_geometry(&pb, b.width(), b.height(), closed) }
Geometry {
xfrm: XfrmBox::plain(b),
xml: custom_geometry(&pb, b.width(), b.height(), closed),
model: craft_pptx::Geom::Custom { paths: vec![craft_pptx::CustomPath::from_bez(&pb, b.width(), b.height(), closed)] },
}
}
pub struct Exporter<'a> {
@@ -196,6 +205,8 @@ pub struct Exporter<'a> {
pub opts: &'a PptxOptions,
pub warnings: Vec<String>,
pub media: Vec<Media>,
/// Native PresentationML mappings for model elements the exporter can express directly.
pub native_shapes: Vec<craft_pptx::Shape>,
/// Asset → (picture media name, SVG media name).
media_of: HashMap<AssetId, Option<(String, Option<String>)>>,
pub fonts: Fonts,
@@ -220,6 +231,7 @@ impl<'a> Exporter<'a> {
opts,
warnings: Vec::new(),
media: Vec::new(),
native_shapes: Vec::new(),
media_of: HashMap::new(),
fonts: Fonts::default(),
out: String::new(),
@@ -252,6 +264,7 @@ impl<'a> Exporter<'a> {
let page = sp.pages.get(pi).ok_or(PptxError::BadPage(abs + 1))?;
let bounds = page.bounds();
self.out.clear();
self.native_shapes.clear();
self.next_id = 2;
self.rels.clear();
self.rel_of.clear();
@@ -295,6 +308,7 @@ impl<'a> Exporter<'a> {
shapes: std::mem::take(&mut self.out),
rels: std::mem::take(&mut self.rels),
name: page_name,
native_shapes: std::mem::take(&mut self.native_shapes),
})
}
@@ -390,6 +404,11 @@ impl<'a> Exporter<'a> {
return;
}
let id = self.id();
if text.is_none()
&& let Some(shape) = self.native_shape(it, &g, id, opacity)
{
self.native_shapes.push(shape);
}
let txbox = if text.is_some() && it.fill.is_none() && it.stroke.is_none() { " txBox=\"1\"" } else { "" };
let _ = write!(
self.out,
@@ -402,6 +421,103 @@ impl<'a> Exporter<'a> {
);
}
/// A shape whose complete appearance fits the shared PresentationML model.
fn native_shape(&self, it: &Item, geometry: &Geometry, id: u32, opacity: f32) -> Option<craft_pptx::Shape> {
if it.effects != designcraft_doc::Effects::default() || it.fill.overprint || it.stroke.overprint || it.stroke.gap_overprint {
return None;
}
let fill = if it.fill.is_none() || !it.path.is_closed() {
craft_pptx::Fill::None
} else {
if resolve_gradient(&self.doc.swatches, &it.fill.swatch).is_some() {
return None;
}
match self.doc.resolve_color(&it.fill.swatch, it.fill.tint) {
Some(color) => {
let [r, g, b, a] = color.to_rgba8(opacity);
craft_pptx::Fill::solid(craft_pptx::ColorRef::rgb(craft_pptx::Rgba::rgba(r, g, b, a)))
}
None => craft_pptx::Fill::None,
}
};
let line = if it.stroke.is_none() {
craft_pptx::Line::none()
} else {
let stroke = &it.stroke;
let supported_dash = matches!(&stroke.kind, StrokeType::Solid | StrokeType::Dotted | StrokeType::Dashed { .. });
if stroke.start != Arrowhead::None
|| stroke.end != Arrowhead::None
|| stroke.gradient_angle.is_some()
|| stroke.gradient_vector.is_some()
|| !supported_dash
{
return None;
}
if resolve_gradient(&self.doc.swatches, &stroke.swatch).is_some() {
return None;
}
let color = self.doc.resolve_color(&stroke.swatch, stroke.tint)?;
let [r, g, b, a] = color.to_rgba8(opacity);
let mut line = craft_pptx::Line::solid(craft_pptx::ColorRef::rgb(craft_pptx::Rgba::rgba(r, g, b, a)), stroke.weight);
line.cap = Some(match stroke.cap {
Cap::Butt => craft_pptx::style::LineCap::Flat,
Cap::Round => craft_pptx::style::LineCap::Round,
Cap::Projecting => craft_pptx::style::LineCap::Square,
});
line.join = Some(match stroke.join {
Join::Miter => craft_pptx::style::LineJoin::Miter,
Join::Round => craft_pptx::style::LineJoin::Round,
Join::Bevel => craft_pptx::style::LineJoin::Bevel,
});
line.align = Some(match stroke.align {
StrokeAlign::Inside => craft_pptx::style::LineAlign::Inside,
StrokeAlign::Center | StrokeAlign::Outside => craft_pptx::style::LineAlign::Center,
});
if stroke.join == Join::Miter && (stroke.miter_limit - 4.0).abs() > 0.001 {
return None;
}
match &stroke.kind {
StrokeType::Solid => {}
StrokeType::Dotted => line.dash = Some(craft_pptx::style::Dash::Preset { name: "sysDot".into() }),
StrokeType::Dashed { pattern } => {
if pattern.iter().any(|length| *length > 0.0) {
let mut pattern = pattern.clone();
if pattern.len() % 2 == 1 {
pattern.extend(pattern.clone());
}
let pairs = pattern
.chunks(2)
.filter_map(|pair| pair.first().zip(pair.get(1)))
.map(|(dash, gap)| (*dash / stroke.weight, *gap / stroke.weight))
.collect();
line.dash = Some(craft_pptx::style::Dash::Custom { pattern: pairs });
}
}
_ => return None,
}
line
};
let xfrm = craft_pptx::Xfrm {
x: geometry.xfrm.rect.x0,
y: geometry.xfrm.rect.y0,
w: geometry.xfrm.rect.width(),
h: geometry.xfrm.rect.height(),
rot: geometry.xfrm.rot,
flip_h: geometry.xfrm.flip_h,
flip_v: geometry.xfrm.flip_v,
};
Some(craft_pptx::Shape {
id: craft_pptx::ShapeId(id),
name: name_of(it, id),
xfrm: Some(xfrm),
geom: geometry.model.clone(),
fill: Some(fill),
line: Some(line),
descr: it.alt_text.clone(),
..Default::default()
})
}
/// An sRGB colour element for a swatch and tint, `None` for [None] and unknown swatches.
pub fn color(&self, swatch: &str, tint: f32, alpha: f32) -> Option<String> {
if let Some(g) = resolve_gradient(&self.doc.swatches, swatch) {
@@ -652,7 +768,7 @@ impl<'a> Exporter<'a> {
let gi = Rect::new(0.0, 0.0, g.size.0, g.size.1);
let gm = m * g.xf;
let xfrm = box_of(gi, gm).map(|(x, _)| x).unwrap_or_else(|| XfrmBox::plain(gm.transform_rect_bbox(gi)));
(Geometry { xfrm, xml: preset("rect") }, String::new())
(Geometry { xfrm, xml: preset("rect"), model: craft_pptx::Geom::preset("rect") }, String::new())
};
let rid = self.rel(format!("../media/{name}"));
let svg_ext = match svg {