mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-14 14:22:39 +00:00
Map PPTX tables and placed images to shared models
This commit is contained in:
6 files changed
+190
-16
No files matched your search
+23
-4
@@ -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 })
|
||||
|
||||
@@ -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>`).
|
||||
|
||||
@@ -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>\
|
||||
|
||||
@@ -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>",
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user