mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-31 22:16:59 -06:00
723 lines
25 KiB
Rust
723 lines
25 KiB
Rust
//! Shape trees: autoshapes, pictures, groups, connectors, tables, charts, media, ink and kept objects.
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
|
|
use deckcraft_geom::Xfrm;
|
|
use deckcraft_model::style::Fill;
|
|
use deckcraft_model::table::Table;
|
|
use deckcraft_model::{Geom, PhType, Shape, ShapeId, ShapeKind, ShapeStyle, walk};
|
|
|
|
use super::dml::{self, emu, emu_pos};
|
|
use super::{Exp, Out};
|
|
use crate::opc::NS_MC;
|
|
use crate::xml::{A, W};
|
|
|
|
/// cNvPr ids for one part.
|
|
pub struct IdMap {
|
|
pub map: HashMap<ShapeId, u32>,
|
|
next: u32,
|
|
}
|
|
|
|
impl IdMap {
|
|
pub fn build(shapes: &[Shape]) -> Self {
|
|
let mut map = HashMap::new();
|
|
let mut used = HashSet::new();
|
|
let mut max = 1u32;
|
|
walk(shapes, &mut |s, _| {
|
|
if s.id.0 >= 2 && s.id.0 < i32::MAX as u32 {
|
|
max = max.max(s.id.0);
|
|
}
|
|
});
|
|
let mut next = max;
|
|
walk(shapes, &mut |s, _| {
|
|
let id = if s.id.0 >= 2 && s.id.0 < i32::MAX as u32 && !used.contains(&s.id.0) {
|
|
s.id.0
|
|
} else {
|
|
next = next.saturating_add(1);
|
|
next
|
|
};
|
|
used.insert(id);
|
|
map.entry(s.id).or_insert(id);
|
|
});
|
|
IdMap { map, next }
|
|
}
|
|
pub fn get(&self, id: ShapeId) -> Option<u32> {
|
|
self.map.get(&id).copied()
|
|
}
|
|
pub fn fresh(&mut self) -> u32 {
|
|
self.next = self.next.saturating_add(1);
|
|
self.next
|
|
}
|
|
}
|
|
|
|
pub struct Ctx<'a> {
|
|
pub ids: &'a mut IdMap,
|
|
/// The shape id written for the shape currently being written (duplicate model ids get fresh ones).
|
|
pub written: HashSet<u32>,
|
|
}
|
|
|
|
impl Ctx<'_> {
|
|
fn id_for(&mut self, s: &Shape) -> u32 {
|
|
let id = self.ids.get(s.id).unwrap_or(0);
|
|
if id >= 2 && self.written.insert(id) {
|
|
return id;
|
|
}
|
|
let f = self.ids.fresh();
|
|
self.written.insert(f);
|
|
f
|
|
}
|
|
}
|
|
|
|
fn preset_name(name: &str) -> &str {
|
|
if name == "textBox" || name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric()) { "rect" } else { name }
|
|
}
|
|
|
|
/// Non-adjust shape guides some presets carry in their `avLst` (ECMA-376 default values).
|
|
fn extra_guides(name: &str) -> &'static [(&'static str, i64)] {
|
|
match name {
|
|
"star5" | "pentagon" => &[("hf", 105_146), ("vf", 110_557)],
|
|
"star7" | "heptagon" => &[("hf", 102_572), ("vf", 105_210)],
|
|
"star6" => &[("hf", 115_470)],
|
|
"star10" => &[("hf", 105_146)],
|
|
"hexagon" => &[("vf", 115_470)],
|
|
"decagon" => &[("vf", 105_146)],
|
|
_ => &[],
|
|
}
|
|
}
|
|
|
|
pub fn geometry(w: &mut W, g: &Geom) {
|
|
match g {
|
|
Geom::Preset { name, adj } => {
|
|
let name = preset_name(name);
|
|
w.open("a:prstGeom", A::new().a("prst", name));
|
|
if adj.is_empty() {
|
|
w.empty0("a:avLst");
|
|
} else {
|
|
// PowerPoint wants the complete guide list once any is given.
|
|
let defaults = deckcraft_geom::preset::info(name).map(|i| i.defaults).unwrap_or(&[]);
|
|
let mut vals: Vec<f64> = adj.iter().take(16).copied().collect();
|
|
while vals.len() < defaults.len() {
|
|
vals.push(defaults.get(vals.len()).copied().unwrap_or(0.0));
|
|
}
|
|
w.open0("a:avLst");
|
|
let single = if defaults.is_empty() { vals.len() == 1 } else { defaults.len() == 1 };
|
|
for (i, v) in vals.iter().enumerate() {
|
|
let n = if single && i == 0 { "adj".to_string() } else { format!("adj{}", i + 1) };
|
|
let v = if v.is_finite() { v.round().clamp(-2.0e9, 2.0e9) as i64 } else { 0 };
|
|
w.empty("a:gd", A::new().a("name", n).a("fmla", format!("val {v}")));
|
|
}
|
|
for (n, v) in extra_guides(name) {
|
|
w.empty("a:gd", A::new().a("name", *n).a("fmla", format!("val {v}")));
|
|
}
|
|
w.close("a:avLst");
|
|
}
|
|
w.close("a:prstGeom");
|
|
}
|
|
Geom::Custom { paths } => {
|
|
if paths.is_empty() {
|
|
w.open("a:prstGeom", A::new().a("prst", "rect"));
|
|
w.empty0("a:avLst");
|
|
w.close("a:prstGeom");
|
|
} else {
|
|
crate::custgeom::write_cust_geom(w, paths);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cnvpr(w: &mut W, x: &mut Exp, o: &mut Out, id: u32, s: &Shape) {
|
|
let a = A::new()
|
|
.a("id", id)
|
|
.a("name", if s.name.is_empty() { format!("Shape {id}") } else { s.name.clone() })
|
|
.o("descr", (!s.descr.is_empty()).then_some(s.descr.as_str()))
|
|
.t("hidden", s.hidden)
|
|
.o("title", (!s.title.is_empty()).then_some(s.title.as_str()));
|
|
if s.click.is_none() && s.hover.is_none() && !s.decorative {
|
|
w.empty("p:cNvPr", a);
|
|
return;
|
|
}
|
|
w.open("p:cNvPr", a);
|
|
if let Some(h) = &s.click {
|
|
dml::hyperlink(w, x, o, "a:hlinkClick", h);
|
|
}
|
|
if let Some(h) = &s.hover {
|
|
dml::hyperlink(w, x, o, "a:hlinkHover", h);
|
|
}
|
|
if s.decorative {
|
|
w.open0("a:extLst");
|
|
w.open("a:ext", A::new().a("uri", "{C183D7F6-B498-43B3-948B-1728B52AA6E4}"));
|
|
w.empty("adec:decorative", A::new().a("xmlns:adec", "http://schemas.microsoft.com/office/drawing/2017/decorative").a("val", "1"));
|
|
w.close("a:ext");
|
|
w.close("a:extLst");
|
|
}
|
|
w.close("p:cNvPr");
|
|
}
|
|
|
|
fn nv_pr(w: &mut W, s: &Shape) {
|
|
match &s.ph {
|
|
Some(ph) => {
|
|
w.open0("p:nvPr");
|
|
let ty = match ph.kind {
|
|
PhType::Obj => None,
|
|
k => Some(k.xml()),
|
|
};
|
|
let sz = ph.size.as_deref().filter(|z| matches!(*z, "full" | "half" | "quarter"));
|
|
w.empty(
|
|
"p:ph",
|
|
A::new()
|
|
.o("type", ty)
|
|
.o("orient", ph.vertical.then_some("vert"))
|
|
.o("sz", sz)
|
|
.o("idx", (ph.idx != 0).then_some(ph.idx))
|
|
.t("hasCustomPrompt", ph.has_custom_prompt),
|
|
);
|
|
w.close("p:nvPr");
|
|
}
|
|
None => w.empty0("p:nvPr"),
|
|
}
|
|
}
|
|
|
|
fn locks(w: &mut W, tag: &str, s: &Shape, extra: A) {
|
|
let a = extra.t("noGrp", s.ph.is_some()).t("noMove", s.locked).t("noResize", s.locked);
|
|
if a.v.is_empty() {
|
|
return;
|
|
}
|
|
w.empty(tag, a);
|
|
}
|
|
|
|
fn style(w: &mut W, st: &ShapeStyle) {
|
|
w.open0("p:style");
|
|
for (tag, (idx, c)) in [("a:lnRef", &st.line_ref), ("a:fillRef", &st.fill_ref), ("a:effectRef", &st.effect_ref)] {
|
|
w.open(tag, A::new().a("idx", idx));
|
|
dml::color(w, c);
|
|
w.close(tag);
|
|
}
|
|
let idx = match st.font_ref.0.as_str() {
|
|
"major" | "minor" | "none" => st.font_ref.0.as_str(),
|
|
_ => "minor",
|
|
};
|
|
match &st.font_ref.1 {
|
|
Some(c) => {
|
|
w.open("a:fontRef", A::new().a("idx", idx));
|
|
dml::color(w, c);
|
|
w.close("a:fontRef");
|
|
}
|
|
None => w.empty("a:fontRef", A::new().a("idx", idx)),
|
|
}
|
|
w.close("p:style");
|
|
}
|
|
|
|
/// `p:spPr` contents (also used for pictures and connectors).
|
|
fn sp_pr(w: &mut W, x: &mut Exp, o: &mut Out, s: &Shape, geom: bool) {
|
|
w.open0("p:spPr");
|
|
if let Some(xf) = &s.xfrm {
|
|
dml::xfrm(w, "a:xfrm", xf, None);
|
|
}
|
|
if geom && (s.xfrm.is_some() || s.ph.is_none() || !matches!(&s.geom, Geom::Preset { name, adj } if name == "rect" && adj.is_empty())) {
|
|
geometry(w, &s.geom);
|
|
}
|
|
if let Some(f) = &s.fill {
|
|
dml::fill(w, x, o, f);
|
|
}
|
|
if let Some(l) = &s.line {
|
|
dml::line(w, x, o, "a:ln", l);
|
|
}
|
|
if let Some(fx) = &s.effects {
|
|
dml::effects(w, fx);
|
|
if let Some(raw) = &fx.raw3d {
|
|
w.raw(raw);
|
|
}
|
|
}
|
|
w.close("p:spPr");
|
|
}
|
|
|
|
/// Write the children of a shape tree.
|
|
pub fn shapes(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, list: &[Shape], depth: usize) {
|
|
if depth > 32 {
|
|
return;
|
|
}
|
|
for s in list {
|
|
shape(w, x, o, cx, s, depth);
|
|
}
|
|
}
|
|
|
|
pub fn shape(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, depth: usize) {
|
|
match &s.kind {
|
|
ShapeKind::Shape => {
|
|
if s.text.as_ref().is_some_and(dml::has_math) {
|
|
// Equations: OMML for readers that know it, plain text otherwise.
|
|
w.open("mc:AlternateContent", A::new().a("xmlns:mc", NS_MC));
|
|
w.open("mc:Choice", A::new().a("xmlns:a14", crate::opc::NS_A14).a("Requires", "a14"));
|
|
let id = cx.id_for(s);
|
|
sp(w, x, o, id, s, true);
|
|
w.close("mc:Choice");
|
|
w.open0("mc:Fallback");
|
|
sp(w, x, o, id, s, false);
|
|
w.close("mc:Fallback");
|
|
w.close("mc:AlternateContent");
|
|
} else {
|
|
let id = cx.id_for(s);
|
|
sp(w, x, o, id, s, false);
|
|
}
|
|
}
|
|
ShapeKind::Picture { fill } => {
|
|
let id = cx.id_for(s);
|
|
w.open0("p:pic");
|
|
w.open0("p:nvPicPr");
|
|
cnvpr(w, x, o, id, s);
|
|
w.open0("p:cNvPicPr");
|
|
locks(w, "a:picLocks", s, A::new().a("noChangeAspect", "1"));
|
|
w.close("p:cNvPicPr");
|
|
nv_pr(w, s);
|
|
w.close("p:nvPicPr");
|
|
dml::blip_fill(w, x, o, "p:blipFill", fill);
|
|
let mut s2 = s.clone();
|
|
if matches!(s2.fill, Some(Fill::Picture(_) | Fill::Background | Fill::Group)) {
|
|
s2.fill = None;
|
|
}
|
|
sp_pr(w, x, o, &s2, true);
|
|
if let Some(st) = &s.style {
|
|
style(w, st);
|
|
}
|
|
w.close("p:pic");
|
|
}
|
|
ShapeKind::Group { children, child } => {
|
|
let id = cx.id_for(s);
|
|
w.open0("p:grpSp");
|
|
w.open0("p:nvGrpSpPr");
|
|
cnvpr(w, x, o, id, s);
|
|
w.open0("p:cNvGrpSpPr");
|
|
locks(w, "a:grpSpLocks", s, A::new());
|
|
w.close("p:cNvGrpSpPr");
|
|
nv_pr(w, s);
|
|
w.close("p:nvGrpSpPr");
|
|
w.open0("p:grpSpPr");
|
|
let xf = s.xfrm.unwrap_or(*child);
|
|
dml::xfrm(w, "a:xfrm", &xf, Some(child));
|
|
if let Some(f) = &s.fill {
|
|
dml::fill(w, x, o, f);
|
|
}
|
|
if let Some(fx) = &s.effects {
|
|
dml::effects(w, fx);
|
|
}
|
|
w.close("p:grpSpPr");
|
|
shapes(w, x, o, cx, children, depth + 1);
|
|
w.close("p:grpSp");
|
|
}
|
|
ShapeKind::Connector { start, end } => {
|
|
let id = cx.id_for(s);
|
|
w.open0("p:cxnSp");
|
|
w.open0("p:nvCxnSpPr");
|
|
cnvpr(w, x, o, id, s);
|
|
let st = start.and_then(|(sid, site)| cx.ids.get(sid).map(|i| (i, site)));
|
|
let en = end.and_then(|(sid, site)| cx.ids.get(sid).map(|i| (i, site)));
|
|
if st.is_none() && en.is_none() && !s.locked {
|
|
w.empty0("p:cNvCxnSpPr");
|
|
} else {
|
|
w.open0("p:cNvCxnSpPr");
|
|
locks(w, "a:cxnSpLocks", s, A::new());
|
|
if let Some((i, site)) = st {
|
|
w.empty("a:stCxn", A::new().a("id", i).a("idx", site));
|
|
}
|
|
if let Some((i, site)) = en {
|
|
w.empty("a:endCxn", A::new().a("id", i).a("idx", site));
|
|
}
|
|
w.close("p:cNvCxnSpPr");
|
|
}
|
|
nv_pr(w, s);
|
|
w.close("p:nvCxnSpPr");
|
|
let mut s2 = s.clone();
|
|
if matches!(s2.fill, Some(Fill::Background)) {
|
|
s2.fill = None;
|
|
}
|
|
if s2.geom.preset_name() == Some("rect") {
|
|
s2.geom = Geom::preset("line");
|
|
}
|
|
sp_pr(w, x, o, &s2, true);
|
|
if let Some(st) = &s.style {
|
|
style(w, st);
|
|
}
|
|
w.close("p:cxnSp");
|
|
}
|
|
ShapeKind::Table(t) => {
|
|
let id = cx.id_for(s);
|
|
frame_open(w, x, o, id, s);
|
|
w.open("a:graphicData", A::new().a("uri", "http://schemas.openxmlformats.org/drawingml/2006/table"));
|
|
table(w, x, o, t);
|
|
w.close("a:graphicData");
|
|
frame_close(w);
|
|
}
|
|
ShapeKind::Chart(c) => {
|
|
let id = cx.id_for(s);
|
|
let rid = x.chart_rel(o, c);
|
|
frame_open(w, x, o, id, s);
|
|
w.open("a:graphicData", A::new().a("uri", "http://schemas.openxmlformats.org/drawingml/2006/chart"));
|
|
w.empty("c:chart", A::new().a("xmlns:c", crate::opc::NS_C).a("r:id", rid));
|
|
w.close("a:graphicData");
|
|
frame_close(w);
|
|
}
|
|
ShapeKind::Media(m) => {
|
|
let id = cx.id_for(s);
|
|
media(w, x, o, id, s, m);
|
|
}
|
|
ShapeKind::Ink { strokes } => {
|
|
// Ink becomes a group of freeform lines.
|
|
let id = cx.id_for(s);
|
|
let xf = s.xfrm.unwrap_or_default();
|
|
w.open0("p:grpSp");
|
|
w.open0("p:nvGrpSpPr");
|
|
cnvpr(w, x, o, id, s);
|
|
w.empty0("p:cNvGrpSpPr");
|
|
w.empty0("p:nvPr");
|
|
w.close("p:nvGrpSpPr");
|
|
w.open0("p:grpSpPr");
|
|
dml::xfrm(w, "a:xfrm", &xf, Some(&xf));
|
|
w.close("p:grpSpPr");
|
|
for (i, st) in strokes.iter().enumerate().take(10_000) {
|
|
let Some(stroke) = ink_shape(st, i) else { continue };
|
|
let sid = cx.ids.fresh();
|
|
sp(w, x, o, sid, &stroke, false);
|
|
}
|
|
w.close("p:grpSp");
|
|
}
|
|
ShapeKind::Opaque { xml, preview, label } => opaque(w, x, o, cx, s, xml, *preview, label),
|
|
}
|
|
}
|
|
|
|
fn ink_shape(st: &deckcraft_model::InkStroke, i: usize) -> Option<Shape> {
|
|
let pts: Vec<(f64, f64)> = st.points.iter().filter(|p| p.0.is_finite() && p.1.is_finite()).map(|p| (p.0, p.1)).collect();
|
|
let (x0, y0) = pts.iter().fold((f64::MAX, f64::MAX), |a, p| (a.0.min(p.0), a.1.min(p.1)));
|
|
let (x1, y1) = pts.iter().fold((f64::MIN, f64::MIN), |a, p| (a.0.max(p.0), a.1.max(p.1)));
|
|
if pts.len() < 2 {
|
|
return None;
|
|
}
|
|
let (w, h) = ((x1 - x0).max(1.0), (y1 - y0).max(1.0));
|
|
let mut d = String::new();
|
|
for (k, (px, py)) in pts.iter().enumerate() {
|
|
d.push_str(if k == 0 { "M " } else { "L " });
|
|
d.push_str(&format!("{:.2} {:.2} ", px - x0, py - y0));
|
|
}
|
|
let mut c = deckcraft_model::style::ColorRef::rgb(st.color.with_alpha(255));
|
|
if st.highlighter {
|
|
c = c.with(deckcraft_color::ColorTransform::Alpha(50_000));
|
|
}
|
|
Some(Shape {
|
|
id: ShapeId(0),
|
|
name: format!("Ink {}", i + 1),
|
|
xfrm: Some(Xfrm::new(x0, y0, w, h)),
|
|
geom: Geom::Custom { paths: vec![deckcraft_model::CustomPath { w, h, d, fill: deckcraft_geom::preset::FillMode::None, stroke: true }] },
|
|
fill: Some(Fill::None),
|
|
line: Some(deckcraft_model::Line {
|
|
cap: Some(deckcraft_model::style::LineCap::Round),
|
|
join: Some(deckcraft_model::style::LineJoin::Round),
|
|
..deckcraft_model::Line::solid(c, st.width.max(0.25))
|
|
}),
|
|
..Default::default()
|
|
})
|
|
}
|
|
|
|
fn sp(w: &mut W, x: &mut Exp, o: &mut Out, id: u32, s: &Shape, math: bool) {
|
|
w.open("p:sp", A::new().t("useBgFill", matches!(s.fill, Some(Fill::Background))));
|
|
w.open0("p:nvSpPr");
|
|
cnvpr(w, x, o, id, s);
|
|
let mut cnv = A::new();
|
|
if s.text_box {
|
|
cnv = cnv.a("txBox", "1");
|
|
}
|
|
let lock = s.ph.is_some() || s.locked;
|
|
if lock {
|
|
w.open("p:cNvSpPr", cnv);
|
|
locks(w, "a:spLocks", s, A::new());
|
|
w.close("p:cNvSpPr");
|
|
} else {
|
|
w.empty("p:cNvSpPr", cnv);
|
|
}
|
|
nv_pr(w, s);
|
|
w.close("p:nvSpPr");
|
|
let tmp;
|
|
let sref = if matches!(s.fill, Some(Fill::Background)) {
|
|
let mut c = s.clone();
|
|
c.fill = None;
|
|
tmp = c;
|
|
&tmp
|
|
} else {
|
|
s
|
|
};
|
|
sp_pr(w, x, o, sref, true);
|
|
if let Some(st) = &s.style {
|
|
style(w, st);
|
|
}
|
|
if let Some(tb) = &s.text {
|
|
dml::text_body(w, x, o, "p:txBody", tb, math);
|
|
}
|
|
w.close("p:sp");
|
|
}
|
|
|
|
fn frame_open(w: &mut W, x: &mut Exp, o: &mut Out, id: u32, s: &Shape) {
|
|
w.open0("p:graphicFrame");
|
|
w.open0("p:nvGraphicFramePr");
|
|
cnvpr(w, x, o, id, s);
|
|
w.open0("p:cNvGraphicFramePr");
|
|
w.empty("a:graphicFrameLocks", A::new().a("noGrp", "1").t("noMove", s.locked).t("noResize", s.locked));
|
|
w.close("p:cNvGraphicFramePr");
|
|
nv_pr(w, s);
|
|
w.close("p:nvGraphicFramePr");
|
|
let xf = s.xfrm.unwrap_or_default();
|
|
let mut xf0 = xf;
|
|
xf0.rot = 0.0;
|
|
xf0.flip_h = false;
|
|
xf0.flip_v = false;
|
|
dml::xfrm(w, "p:xfrm", &xf0, None);
|
|
w.open0("a:graphic");
|
|
}
|
|
|
|
fn frame_close(w: &mut W) {
|
|
w.close("a:graphic");
|
|
w.close("p:graphicFrame");
|
|
}
|
|
|
|
fn table(w: &mut W, x: &mut Exp, o: &mut Out, t: &Table) {
|
|
let mut t = t.clone();
|
|
t.normalize();
|
|
w.open0("a:tbl");
|
|
let (guid, _) = crate::tables::guid_for_style(&t.style);
|
|
if !x.table_styles.contains(&t.style) {
|
|
x.table_styles.push(t.style.clone());
|
|
}
|
|
w.open(
|
|
"a:tblPr",
|
|
A::new()
|
|
.t("firstRow", t.first_row)
|
|
.t("firstCol", t.first_col)
|
|
.t("lastRow", t.last_row)
|
|
.t("lastCol", t.last_col)
|
|
.t("bandRow", t.band_row)
|
|
.t("bandCol", t.band_col),
|
|
);
|
|
w.elt("a:tableStyleId", &guid);
|
|
w.close("a:tblPr");
|
|
w.open0("a:tblGrid");
|
|
for c in &t.cols {
|
|
w.empty("a:gridCol", A::new().a("w", emu_pos(*c)));
|
|
}
|
|
w.close("a:tblGrid");
|
|
for row in &t.rows {
|
|
w.open("a:tr", A::new().a("h", emu_pos(row.height)));
|
|
for c in &row.cells {
|
|
w.open(
|
|
"a:tc",
|
|
A::new()
|
|
.o("gridSpan", (c.grid_span > 1).then_some(c.grid_span))
|
|
.o("rowSpan", (c.row_span > 1).then_some(c.row_span))
|
|
.t("hMerge", c.h_merge)
|
|
.t("vMerge", c.v_merge),
|
|
);
|
|
dml::text_body(w, x, o, "a:txBody", &c.text, false);
|
|
let mut a = A::new();
|
|
if let Some(m) = c.margins {
|
|
a = a.a("marL", emu(m[0])).a("marR", emu(m[1])).a("marT", emu(m[2])).a("marB", emu(m[3]));
|
|
}
|
|
a = a.o("vert", c.vertical.map(|v| v.xml())).o(
|
|
"anchor",
|
|
c.anchor.map(|a| match a {
|
|
deckcraft_model::text::Anchor::Justified | deckcraft_model::text::Anchor::Distributed => "t",
|
|
other => other.xml(),
|
|
}),
|
|
);
|
|
w.open("a:tcPr", a);
|
|
for (tag, l) in [
|
|
("a:lnL", &c.borders[0]),
|
|
("a:lnR", &c.borders[1]),
|
|
("a:lnT", &c.borders[2]),
|
|
("a:lnB", &c.borders[3]),
|
|
("a:lnTlToBr", &c.diag_down),
|
|
("a:lnBlToTr", &c.diag_up),
|
|
] {
|
|
if let Some(l) = l {
|
|
dml::line(w, x, o, tag, l);
|
|
}
|
|
}
|
|
if let Some(f) = &c.fill {
|
|
match f {
|
|
Fill::Background => {}
|
|
f => dml::fill(w, x, o, f),
|
|
}
|
|
}
|
|
w.close("a:tcPr");
|
|
w.close("a:tc");
|
|
}
|
|
w.close("a:tr");
|
|
}
|
|
w.close("a:tbl");
|
|
}
|
|
|
|
fn media(w: &mut W, x: &mut Exp, o: &mut Out, id: u32, s: &Shape, m: &deckcraft_model::MediaClip) {
|
|
let kind = if m.video { "video" } else { "audio" };
|
|
let link = x.media_rel(o, m.media, kind);
|
|
let embed = x.media_rel_new(o, m.media, crate::opc::RT_MEDIA);
|
|
w.open0("p:pic");
|
|
w.open0("p:nvPicPr");
|
|
let mut s2 = s.clone();
|
|
if s2.click.is_none() {
|
|
s2.click = Some(deckcraft_model::text::Hyperlink {
|
|
action: deckcraft_model::text::Action::PlayMedia,
|
|
tooltip: String::new(),
|
|
highlight_click: false,
|
|
});
|
|
}
|
|
cnvpr(w, x, o, id, &s2);
|
|
w.open0("p:cNvPicPr");
|
|
w.empty("a:picLocks", A::new().a("noChangeAspect", "1"));
|
|
w.close("p:cNvPicPr");
|
|
w.open0("p:nvPr");
|
|
if let Some(ph) = &s.ph {
|
|
let _ = ph;
|
|
}
|
|
let tag = if m.video { "a:videoFile" } else { "a:audioFile" };
|
|
if let Some(l) = &link {
|
|
w.empty(tag, A::new().a("r:link", l));
|
|
}
|
|
if let Some(e) = &embed {
|
|
w.open0("p:extLst");
|
|
w.open("p:ext", A::new().a("uri", "{DAA4B4D4-6D71-4841-9C94-3DE7FCFB9230}"));
|
|
w.open("p14:media", A::new().a("xmlns:p14", crate::opc::NS_P14).a("r:embed", e));
|
|
if m.trim_start_ms > 0 || m.trim_end_ms > 0 {
|
|
w.empty(
|
|
"p14:trim",
|
|
A::new().o("st", (m.trim_start_ms > 0).then_some(m.trim_start_ms)).o("end", (m.trim_end_ms > 0).then_some(m.trim_end_ms)),
|
|
);
|
|
}
|
|
if m.fade_in_ms > 0 || m.fade_out_ms > 0 {
|
|
w.empty("p14:fade", A::new().o("in", (m.fade_in_ms > 0).then_some(m.fade_in_ms)).o("out", (m.fade_out_ms > 0).then_some(m.fade_out_ms)));
|
|
}
|
|
if !m.bookmarks.is_empty() {
|
|
w.open0("p14:bmkLst");
|
|
for (n, t) in m.bookmarks.iter().take(1000) {
|
|
w.empty("p14:bmk", A::new().a("name", n).a("time", t));
|
|
}
|
|
w.close("p14:bmkLst");
|
|
}
|
|
w.close("p14:media");
|
|
w.close("p:ext");
|
|
w.close("p:extLst");
|
|
}
|
|
w.close("p:nvPr");
|
|
w.close("p:nvPicPr");
|
|
let poster = deckcraft_model::style::PictureFill { media: m.poster.unwrap_or_default(), ..Default::default() };
|
|
dml::blip_fill(w, x, o, "p:blipFill", &poster);
|
|
let mut s3 = s.clone();
|
|
s3.fill = None;
|
|
sp_pr(w, x, o, &s3, true);
|
|
w.close("p:pic");
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn opaque(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, xml: &str, preview: Option<deckcraft_model::MediaId>, label: &str) {
|
|
// Kept XML that references no relationships can be written back as it was.
|
|
if let Ok(doc) = crate::xml::parse(xml.as_bytes())
|
|
&& !crate::read::has_rel_refs(&doc.root)
|
|
&& matches!(doc.root.local(), "graphicFrame" | "sp" | "grpSp" | "cxnSp")
|
|
{
|
|
let mut root = doc.root;
|
|
let id = cx.id_for(s);
|
|
set_cnvpr_id(&mut root, id);
|
|
w.raw(&root.to_xml());
|
|
return;
|
|
}
|
|
log::warn!("pptx: object '{label}' written as its preview picture");
|
|
let id = cx.id_for(s);
|
|
let mut p = s.clone();
|
|
if p.name.is_empty() {
|
|
p.name = label.to_string();
|
|
}
|
|
match preview {
|
|
Some(m) if x.p.media(m).is_some() => {
|
|
p.kind = ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { media: m, ..Default::default() } };
|
|
p.ph = None;
|
|
shape_with_id(w, x, o, cx, &p, id);
|
|
}
|
|
_ => {
|
|
p.kind = ShapeKind::Shape;
|
|
p.ph = None;
|
|
p.geom = Geom::preset("rect");
|
|
p.fill = Some(Fill::None);
|
|
p.text = None;
|
|
sp(w, x, o, id, &p, false);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn shape_with_id(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, id: u32) {
|
|
// Pictures only: reuse the given id.
|
|
if let ShapeKind::Picture { fill } = &s.kind {
|
|
w.open0("p:pic");
|
|
w.open0("p:nvPicPr");
|
|
cnvpr(w, x, o, id, s);
|
|
w.open0("p:cNvPicPr");
|
|
w.empty("a:picLocks", A::new().a("noChangeAspect", "1"));
|
|
w.close("p:cNvPicPr");
|
|
w.empty0("p:nvPr");
|
|
w.close("p:nvPicPr");
|
|
dml::blip_fill(w, x, o, "p:blipFill", fill);
|
|
let mut s2 = s.clone();
|
|
s2.fill = None;
|
|
sp_pr(w, x, o, &s2, true);
|
|
w.close("p:pic");
|
|
} else {
|
|
shape(w, x, o, cx, s, 0);
|
|
}
|
|
}
|
|
|
|
fn set_cnvpr_id(el: &mut crate::xml::El, id: u32) {
|
|
fn rec(e: &mut crate::xml::El, id: u32, depth: usize) -> bool {
|
|
if depth > 8 {
|
|
return false;
|
|
}
|
|
if e.is("cNvPr") {
|
|
e.set_attr("id", &id.to_string());
|
|
return true;
|
|
}
|
|
e.elements_mut().any(|c| rec(c, id, depth + 1))
|
|
}
|
|
rec(el, id, 0);
|
|
}
|
|
|
|
/// The fixed group header every shape tree starts with.
|
|
pub fn tree_header(w: &mut W) {
|
|
w.open0("p:nvGrpSpPr");
|
|
w.empty("p:cNvPr", A::new().a("id", 1).a("name", ""));
|
|
w.empty0("p:cNvGrpSpPr");
|
|
w.empty0("p:nvPr");
|
|
w.close("p:nvGrpSpPr");
|
|
w.open0("p:grpSpPr");
|
|
w.open0("a:xfrm");
|
|
w.empty("a:off", A::new().a("x", 0).a("y", 0));
|
|
w.empty("a:ext", A::new().a("cx", 0).a("cy", 0));
|
|
w.empty("a:chOff", A::new().a("x", 0).a("y", 0));
|
|
w.empty("a:chExt", A::new().a("cx", 0).a("cy", 0));
|
|
w.close("a:xfrm");
|
|
w.close("p:grpSpPr");
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// PowerPoint asks to repair a `star5` whose guide list lacks `hf`/`vf` (found black-box).
|
|
#[test]
|
|
fn preset_guides_are_complete() {
|
|
let mut w = W::frag();
|
|
geometry(&mut w, &Geom::Preset { name: "star5".into(), adj: vec![20000.0] });
|
|
assert_eq!(
|
|
w.s,
|
|
r#"<a:prstGeom prst="star5"><a:avLst><a:gd name="adj" fmla="val 20000"/><a:gd name="hf" fmla="val 105146"/><a:gd name="vf" fmla="val 110557"/></a:avLst></a:prstGeom>"#
|
|
);
|
|
let mut w = W::frag();
|
|
geometry(&mut w, &Geom::Preset { name: "rightArrow".into(), adj: vec![10000.0] });
|
|
assert!(w.s.contains(r#"name="adj1" fmla="val 10000""#) && w.s.contains(r#"name="adj2" fmla="val 50000""#), "{}", w.s);
|
|
let mut w = W::frag();
|
|
geometry(&mut w, &Geom::Preset { name: "textBox".into(), adj: vec![] });
|
|
assert_eq!(w.s, r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>"#);
|
|
}
|
|
}
|