From 0be5fcb01a0da092c4c98ef8858aebe917a942b9 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 02:29:45 -0500 Subject: [PATCH] Map PPTX tables and placed images to shared models --- crates/pptx/src/lib.rs | 27 ++++++++-- crates/pptx/src/package.rs | 2 + crates/pptx/src/slide.rs | 63 +++++++++++++++++++--- crates/pptx/src/table.rs | 106 +++++++++++++++++++++++++++++++++++++ crates/pptx/src/text.rs | 2 +- crates/render/src/tests.rs | 6 +-- 6 files changed, 190 insertions(+), 16 deletions(-) diff --git a/crates/pptx/src/lib.rs b/crates/pptx/src/lib.rs index 04b11ac58..629c47dcc 100644 --- a/crates/pptx/src/lib.rs +++ b/crates/pptx/src/lib.rs @@ -80,12 +80,31 @@ pub enum PptxError { pub type Result = std::result::Result; -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 }) diff --git a/crates/pptx/src/package.rs b/crates/pptx/src/package.rs index 3b4b7774b..1dfa42557 100644 --- a/crates/pptx/src/package.rs +++ b/crates/pptx/src/package.rs @@ -29,6 +29,8 @@ pub struct Slide { pub name: String, /// Shapes that have a direct DesignCraft-to-Presentation model mapping. pub native_shapes: Vec, + /// Media part names keyed by the id of a native picture shape. + pub native_media: Vec<(u32, String)>, } /// A media part (`ppt/media/`). diff --git a/crates/pptx/src/slide.rs b/crates/pptx/src/slide.rs index c9f864523..ed17d641d 100644 --- a/crates/pptx/src/slide.rs +++ b/crates/pptx/src/slide.rs @@ -207,6 +207,7 @@ pub struct Exporter<'a> { pub media: Vec, /// Native PresentationML mappings for model elements the exporter can express directly. pub native_shapes: Vec, + pub native_media: Vec<(u32, String)>, /// Asset → (picture media name, SVG media name). media_of: HashMap)>>, 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 { + 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 "".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!( "", - 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 { "".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, "\ diff --git a/crates/pptx/src/table.rs b/crates/pptx/src/table.rs index b4a04d96d..37a36f91e 100644 --- a/crates/pptx/src/table.rs +++ b/crates/pptx/src/table.rs @@ -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!( "\ ", diff --git a/crates/pptx/src/text.rs b/crates/pptx/src/text.rs index a03991729..6854d4a29 100644 --- a/crates/pptx/src/text.rs +++ b/crates/pptx/src/text.rs @@ -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 { let color = if let Some(gradient) = resolve_gradient(&ex.doc.swatches, swatch) { let (_, color, alpha) = gradient.expanded_stops().into_iter().next()?; diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 9e3d74e8b..4284d10c4 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -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 {