Map PPTX tables and placed images to shared models

This commit is contained in:
Dylan M. Taylor committed 2026-11-13 02:29:45 -05:00
1 parent 304f93dda8
commit 0be5fcb01a
6 files changed
+190 -16

No files matched your search

+23 -4
View File
@@ -80,12 +80,31 @@ 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]) {
fn apply_native_shapes(
shapes: &mut [craft_pptx::Shape],
native: &[craft_pptx::Shape],
native_media: &[(u32, String)],
media: &[craft_pptx::MediaItem],
) {
for shape in shapes {
if let Some(mapped) = native.iter().find(|mapped| mapped.id.0 == shape.id.0) {
*shape = mapped.clone();
let mut mapped = mapped.clone();
let media_name = native_media.iter().find(|(id, _)| *id == mapped.id.0).map(|(_, name)| name);
let has_media = if let (Some(name), craft_pptx::ShapeKind::Picture { fill }) = (media_name, &mut mapped.kind) {
if let Some(item) = media.iter().find(|item| item.name == *name) {
fill.media = item.id;
true
} else {
false
}
} else {
true
};
if has_media {
*shape = mapped;
}
} else if let craft_pptx::ShapeKind::Group { children, .. } = &mut shape.kind {
apply_native_shapes(children, native);
apply_native_shapes(children, native, native_media, media);
}
}
}
@@ -143,7 +162,7 @@ pub fn export_pptx(doc: &Document, cache: &Cache, opts: &PptxOptions) -> Result<
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);
apply_native_shapes(&mut slide.shapes, &source.native_shapes, &source.native_media, &presentation.media);
}
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
@@ -29,6 +29,8 @@ pub struct Slide {
pub name: String,
/// Shapes that have a direct DesignCraft-to-Presentation model mapping.
pub native_shapes: Vec<craft_pptx::Shape>,
/// Media part names keyed by the id of a native picture shape.
pub native_media: Vec<(u32, String)>,
}
/// A media part (`ppt/media/<name>`).
+55 -8
View File
@@ -207,6 +207,7 @@ pub struct Exporter<'a> {
pub media: Vec<Media>,
/// Native PresentationML mappings for model elements the exporter can express directly.
pub native_shapes: Vec<craft_pptx::Shape>,
pub native_media: Vec<(u32, String)>,
/// Asset → (picture media name, SVG media name).
media_of: HashMap<AssetId, Option<(String, Option<String>)>>,
pub fonts: Fonts,
@@ -232,6 +233,7 @@ impl<'a> Exporter<'a> {
warnings: Vec::new(),
media: Vec::new(),
native_shapes: Vec::new(),
native_media: Vec::new(),
media_of: HashMap::new(),
fonts: Fonts::default(),
out: String::new(),
@@ -265,6 +267,7 @@ impl<'a> Exporter<'a> {
let bounds = page.bounds();
self.out.clear();
self.native_shapes.clear();
self.native_media.clear();
self.next_id = 2;
self.rels.clear();
self.rel_of.clear();
@@ -309,6 +312,7 @@ impl<'a> Exporter<'a> {
rels: std::mem::take(&mut self.rels),
name: page_name,
native_shapes: std::mem::take(&mut self.native_shapes),
native_media: std::mem::take(&mut self.native_media),
})
}
@@ -540,6 +544,17 @@ impl<'a> Exporter<'a> {
Some(srgb(c.to_rgb(), alpha))
}
/// An sRGB color in the shared PresentationML model, with DesignCraft tint and opacity applied.
pub(crate) fn model_color(&self, swatch: &str, tint: f32, alpha: f32) -> Option<craft_pptx::ColorRef> {
if let Some(gradient) = resolve_gradient(&self.doc.swatches, swatch) {
let (_, color, gradient_alpha) = gradient.expanded_stops().into_iter().next()?;
let [r, g, b, a] = color.to_rgba8(gradient_alpha * alpha);
return Some(craft_pptx::ColorRef::rgb(craft_pptx::Rgba::rgba(r, g, b, a)));
}
let [r, g, b, a] = self.doc.resolve_color(swatch, tint)?.to_rgba8(alpha);
Some(craft_pptx::ColorRef::rgb(craft_pptx::Rgba::rgba(r, g, b, a)))
}
pub fn fill(&mut self, f: &designcraft_doc::Fill, closed: bool, opacity: f32) -> String {
if f.is_none() || !closed {
return "<a:noFill/>".into();
@@ -761,16 +776,22 @@ impl<'a> Exporter<'a> {
let frame = it.inner_bounds();
let img = g.xf.transform_rect_bbox(Rect::new(0.0, 0.0, g.size.0, g.size.1));
let axis = b.abs() < 1e-9 && c.abs() < 1e-9 && a > 0.0 && d > 0.0;
let (geo, crop) = if axis && img.width() > 0.0 && img.height() > 0.0 {
let (geo, crop, crop_values) = if axis && img.width() > 0.0 && img.height() > 0.0 {
// The frame's geometry with the image cropped (or padded) to the frame's box.
let crop_values = [
(frame.x0 - img.x0) / img.width(),
(frame.y0 - img.y0) / img.height(),
(img.x1 - frame.x1) / img.width(),
(img.y1 - frame.y1) / img.height(),
];
let crop = format!(
"<a:srcRect l=\"{}\" t=\"{}\" r=\"{}\" b=\"{}\"/>",
pct((frame.x0 - img.x0) / img.width()),
pct((frame.y0 - img.y0) / img.height()),
pct((img.x1 - frame.x1) / img.width()),
pct((img.y1 - frame.y1) / img.height())
pct(crop_values[0]),
pct(crop_values[1]),
pct(crop_values[2]),
pct(crop_values[3])
);
(geometry(it, m), crop)
(geometry(it, m), crop, crop_values)
} else {
// Rotated, skewed or flipped inside its frame: the image's own box, uncropped, and
// the frame drawn on its own.
@@ -779,10 +800,10 @@ 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"), model: craft_pptx::Geom::preset("rect") }, String::new())
(Geometry { xfrm, xml: preset("rect"), model: craft_pptx::Geom::preset("rect") }, String::new(), [0.0; 4])
};
let rid = self.rel(format!("../media/{name}"));
let svg_ext = match svg {
let svg_ext = match svg.as_ref() {
Some(s) => {
let sid = self.rel(format!("../media/{s}"));
format!(
@@ -796,6 +817,32 @@ impl<'a> Exporter<'a> {
let line = if axis { self.line(&it.stroke, opacity) } else { "<a:ln><a:noFill/></a:ln>".into() };
let fx = self.effects(it, opacity);
let id = self.id();
if axis && svg.is_none() && it.effects == designcraft_doc::Effects::default() && it.fill.is_none() && it.stroke.is_none() {
let mut fill = craft_pptx::style::PictureFill { media: craft_pptx::MediaId(0), crop: crop_values, ..Default::default() };
if opacity < 0.999 {
fill.alpha = Some(f64::from(opacity));
}
self.native_shapes.push(craft_pptx::Shape {
id: craft_pptx::ShapeId(id),
name: name_of(it, id),
xfrm: Some(craft_pptx::Xfrm {
x: geo.xfrm.rect.x0,
y: geo.xfrm.rect.y0,
w: geo.xfrm.rect.width(),
h: geo.xfrm.rect.height(),
rot: geo.xfrm.rot,
flip_h: geo.xfrm.flip_h,
flip_v: geo.xfrm.flip_v,
}),
kind: craft_pptx::ShapeKind::Picture { fill },
geom: geo.model.clone(),
fill: Some(craft_pptx::Fill::None),
line: Some(craft_pptx::Line::none()),
descr: it.alt_text.clone(),
..Default::default()
});
self.native_media.push((id, name.clone()));
}
let _ = write!(
self.out,
"<p:pic><p:nvPicPr><p:cNvPr id=\"{id}\" name=\"{}\"{}/><p:cNvPicPr><a:picLocks noChangeAspect=\"1\"/></p:cNvPicPr><p:nvPr/></p:nvPicPr>\
+106
View File
@@ -101,6 +101,49 @@ fn border(ex: &mut Exporter, tag: &str, s: Option<&StrokeSeg>, k: f64, opacity:
)
}
fn model_border(ex: &Exporter, s: Option<&StrokeSeg>, k: f64, opacity: f32) -> craft_pptx::Line {
let Some(stroke) = s.filter(|s| s.stroke.weight > 0.0) else { return craft_pptx::Line::none() };
let Some(color) = ex.model_color(&stroke.stroke.color, stroke.stroke.tint, opacity) else { return craft_pptx::Line::none() };
let mut line = craft_pptx::Line::solid(color, stroke.stroke.weight * k);
line.cap = Some(craft_pptx::style::LineCap::Flat);
line.dash = match stroke.stroke.kind {
StrokeType::Dashed { .. } => Some(craft_pptx::style::Dash::Preset { name: "dash".into() }),
StrokeType::Dotted => Some(craft_pptx::style::Dash::Preset { name: "sysDot".into() }),
_ => None,
};
line
}
fn model_cell_text(ex: &mut Exporter, c: &PlacedCell, k: f64, opacity: f32) -> (craft_pptx::TextBody, [f64; 4]) {
let Some(frame) = c.text.frames.first() else {
let mut text = craft_pptx::TextBody::from_text("");
if let Some(paragraph) = text.paragraphs.first_mut() {
paragraph.end_props.lang = Some("en-US".into());
}
return (text, [0.0; 4]);
};
let segments = crate::text::segments(ex, &c.text, frame, &c.source, &[]);
let mut all = segments.first().cloned().unwrap_or_default();
for segment in segments.iter().skip(1) {
all.lines.extend(segment.lines.iter().cloned());
all.x1 = all.x1.max(segment.x1);
}
if all.lines.is_empty() {
let mut text = craft_pptx::TextBody::from_text("");
if let Some(paragraph) = text.paragraphs.first_mut() {
paragraph.end_props.lang = Some("en-US".into());
}
return (text, [0.0; 4]);
}
let bounds = crate::text::seg_rect(&all);
let bounds = Rect::new(bounds.x0 + c.origin.x, bounds.y0 + c.origin.y, bounds.x1 + c.origin.x, bounds.y1 + c.origin.y);
let margins =
[(bounds.x0 - c.rect.x0).max(0.0), (bounds.y0 - c.rect.y0).max(0.0), (c.rect.x1 - bounds.x1).max(0.0), (c.rect.y1 - bounds.y1).max(0.0)];
let mut body = crate::text::model_body(ex, &all, k, opacity, [0.0; 4]);
body.body = Default::default();
(body, margins)
}
fn table(ex: &mut Exporter, t: &TableFrag, m: Affine, k: f64, opacity: f32) {
if t.cells.is_empty() {
return;
@@ -145,6 +188,69 @@ fn table(ex: &mut Exporter, t: &TableFrag, m: Affine, k: f64, opacity: f32) {
}
let edges_of = EdgeIndex::new(&t.strokes);
let id = ex.id();
let mut model = craft_pptx::table::Table {
cols: xs.windows(2).map(|w| (w[1] - w[0]) * k).collect(),
rows: ys
.windows(2)
.map(|w| craft_pptx::table::Row { height: (w[1] - w[0]) * k, cells: vec![craft_pptx::table::Cell::default(); nc] })
.collect(),
style: String::new(),
first_row: false,
first_col: false,
last_row: false,
last_col: false,
band_row: false,
band_col: false,
..Default::default()
};
for row in 0..nr {
for col in 0..nc {
let at = row * nc + col;
let Some(cell) = model.rows.get_mut(row).and_then(|r| r.cells.get_mut(col)) else { continue };
let (horizontal, vertical) = covered.get(at).copied().unwrap_or((false, false));
if horizontal || vertical {
cell.h_merge = horizontal;
cell.v_merge = vertical;
continue;
}
let (grid_span, row_span) = span.get(at).copied().unwrap_or((1, 1));
cell.grid_span = u32::try_from(grid_span).unwrap_or(u32::MAX);
cell.row_span = u32::try_from(row_span).unwrap_or(u32::MAX);
let Some(placed) = origin.get(at).copied().flatten() else {
cell.fill = Some(craft_pptx::Fill::None);
cell.margins = Some([0.0; 4]);
continue;
};
let (text, [left, top, right, bottom]) = model_cell_text(ex, placed, k, opacity);
cell.text = text;
cell.margins = Some([left * k, right * k, top * k, bottom * k]);
cell.fill = Some(
placed
.fill
.as_ref()
.and_then(|(swatch, tint)| ex.model_color(swatch, *tint, opacity))
.map_or(craft_pptx::Fill::None, craft_pptx::Fill::solid),
);
let bounds = placed.rect;
let edge = |side: char| {
let (a, b) = match side {
'L' => ((bounds.x0, bounds.y0), (bounds.x0, bounds.y1)),
'R' => ((bounds.x1, bounds.y0), (bounds.x1, bounds.y1)),
'T' => ((bounds.x0, bounds.y0), (bounds.x1, bounds.y0)),
_ => ((bounds.x0, bounds.y1), (bounds.x1, bounds.y1)),
};
edges_of.stroke(a, b)
};
cell.borders = ['L', 'R', 'T', 'B'].map(|side| Some(model_border(ex, edge(side), k, opacity)));
}
}
ex.native_shapes.push(craft_pptx::Shape {
id: craft_pptx::ShapeId(id),
name: format!("Table {id}"),
xfrm: Some(craft_pptx::Xfrm::new(xfrm.rect.x0, xfrm.rect.y0, xfrm.rect.width(), xfrm.rect.height())),
kind: craft_pptx::ShapeKind::Table(model),
..Default::default()
});
let mut s = format!(
"<p:graphicFrame><p:nvGraphicFramePr><p:cNvPr id=\"{id}\" name=\"Table {id}\"/><p:cNvGraphicFramePr><a:graphicFrameLocks noGrp=\"1\"/></p:cNvGraphicFramePr><p:nvPr/></p:nvGraphicFramePr>\
<p:xfrm><a:off x=\"{}\" y=\"{}\"/><a:ext cx=\"{}\" cy=\"{}\"/></p:xfrm><a:graphic><a:graphicData uri=\"http://schemas.openxmlformats.org/drawingml/2006/table\"><a:tbl><a:tblPr/><a:tblGrid>",
+1 -1
View File
@@ -412,7 +412,7 @@ fn body(ex: &mut Exporter, seg: &Segment, r: Rect, k: f64, opacity: f32, insets:
}
/// The model equivalent of the composed text represented by `body`.
fn model_body(ex: &mut Exporter, seg: &Segment, k: f64, opacity: f32, insets: [f64; 4]) -> craft_pptx::TextBody {
pub(crate) fn model_body(ex: &mut Exporter, seg: &Segment, k: f64, opacity: f32, insets: [f64; 4]) -> craft_pptx::TextBody {
fn color(ex: &Exporter, swatch: &str, tint: f32, opacity: f32) -> Option<craft_pptx::ColorRef> {
let color = if let Some(gradient) = resolve_gradient(&ex.doc.swatches, swatch) {
let (_, color, alpha) = gradient.expanded_stops().into_iter().next()?;
+3 -3
View File
@@ -529,9 +529,9 @@ fn degenerate_dash_patterns_draw_solid_and_fill_types_close_table_corners() {
let (fid, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 336.0, 400.0), lid, "", ParaFormat::default()).unwrap();
d.item_mut(fid).unwrap().stroke = designcraft_doc::Stroke { weight: 2.0, kind: fine.clone(), ..Default::default() };
let mut t = designcraft_doc::Table::new(1, 2, 2, 0, 0, 200.0);
for r in 0..2 {
t.rows[r].height = 60.0;
t.rows[r].mode = designcraft_doc::RowHeightMode::Exactly;
for row in &mut t.rows {
row.height = 60.0;
row.mode = designcraft_doc::RowHeightMode::Exactly;
}
let stroke = |kind| designcraft_doc::CellStroke { weight: 8.0, kind, gap_color: "C=100 M=0 Y=0 K=0".into(), ..Default::default() };
for cell in &mut t.cells {