mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-14 14:12:52 +00:00
An owned corpus of those deck kinds survives open, save and reopen. The check found the handout master — printed header, footer, date and slide number — was dropped on save, so import and export now keep that part.
868 lines
34 KiB
Rust
868 lines
34 KiB
Rust
//! PPTX export: [`Presentation`] → package.
|
|
|
|
mod chart;
|
|
pub(crate) mod dml;
|
|
mod shapes;
|
|
mod theme;
|
|
mod timing;
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
|
|
use deckcraft_geom::Xfrm;
|
|
use deckcraft_model::text::{Paragraph, TextBody};
|
|
use deckcraft_model::{
|
|
Background, Comment, Layout, LayoutId, Master, MediaId, PhType, Placeholder, Presentation, Shape, ShapeId, Slide, SlideId, defaults,
|
|
};
|
|
|
|
use crate::PptxError;
|
|
use crate::opc::{self, NS_A, NS_P, NS_P14, NS_R, PackageOut, RelsOut};
|
|
use crate::xml::{A, W};
|
|
|
|
const CT_PRES: &str = "application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml";
|
|
const CT_SLIDE: &str = "application/vnd.openxmlformats-officedocument.presentationml.slide+xml";
|
|
const CT_LAYOUT: &str = "application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml";
|
|
const CT_MASTER: &str = "application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml";
|
|
const CT_NOTES_MASTER: &str = "application/vnd.openxmlformats-officedocument.presentationml.notesMaster+xml";
|
|
const CT_HANDOUT_MASTER: &str = "application/vnd.openxmlformats-officedocument.presentationml.handoutMaster+xml";
|
|
const CT_NOTES: &str = "application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml";
|
|
const CT_THEME: &str = "application/vnd.openxmlformats-officedocument.theme+xml";
|
|
const CT_PRES_PROPS: &str = "application/vnd.openxmlformats-officedocument.presentationml.presProps+xml";
|
|
const CT_VIEW_PROPS: &str = "application/vnd.openxmlformats-officedocument.presentationml.viewProps+xml";
|
|
const CT_TABLE_STYLES: &str = "application/vnd.openxmlformats-officedocument.presentationml.tableStyles+xml";
|
|
const CT_CHART: &str = "application/vnd.openxmlformats-officedocument.drawingml.chart+xml";
|
|
const CT_COMMENTS: &str = "application/vnd.openxmlformats-officedocument.presentationml.comments+xml";
|
|
const CT_AUTHORS: &str = "application/vnd.openxmlformats-officedocument.presentationml.commentAuthors+xml";
|
|
const CT_CORE: &str = "application/vnd.openxmlformats-package.core-properties+xml";
|
|
const CT_APP: &str = "application/vnd.openxmlformats-officedocument.extended-properties+xml";
|
|
|
|
/// Export state.
|
|
pub struct Exp<'a> {
|
|
pub p: &'a Presentation,
|
|
pub pkg: PackageOut,
|
|
media_parts: HashMap<MediaId, String>,
|
|
n_image: u32,
|
|
n_media: u32,
|
|
n_chart: u32,
|
|
pub slide_parts: HashMap<SlideId, String>,
|
|
pub table_styles: Vec<String>,
|
|
placeholder: Option<String>,
|
|
}
|
|
|
|
/// A part being written: its name and relationships.
|
|
pub struct Out {
|
|
pub name: String,
|
|
pub rels: RelsOut,
|
|
}
|
|
|
|
impl Out {
|
|
fn new(name: &str) -> Self {
|
|
Out { name: name.to_string(), rels: RelsOut::default() }
|
|
}
|
|
}
|
|
|
|
fn ext_for(content_type: &str, name: &str, data: &[u8]) -> String {
|
|
let by_ct = match content_type {
|
|
"image/png" => "png",
|
|
"image/jpeg" | "image/jpg" => "jpeg",
|
|
"image/gif" => "gif",
|
|
"image/bmp" => "bmp",
|
|
"image/tiff" => "tiff",
|
|
"image/svg+xml" => "svg",
|
|
"image/x-emf" | "image/emf" => "emf",
|
|
"image/x-wmf" | "image/wmf" => "wmf",
|
|
"image/webp" => "webp",
|
|
"video/mp4" => "mp4",
|
|
"video/quicktime" => "mov",
|
|
"video/x-ms-wmv" => "wmv",
|
|
"video/x-msvideo" => "avi",
|
|
"video/webm" => "webm",
|
|
"audio/mpeg" => "mp3",
|
|
"audio/wav" | "audio/x-wav" | "audio/wave" => "wav",
|
|
"audio/mp4" | "audio/x-m4a" => "m4a",
|
|
"audio/x-ms-wma" => "wma",
|
|
"audio/ogg" => "ogg",
|
|
"audio/flac" => "flac",
|
|
_ => "",
|
|
};
|
|
if !by_ct.is_empty() {
|
|
return by_ct.to_string();
|
|
}
|
|
if data.starts_with(b"\x89PNG") {
|
|
return "png".into();
|
|
}
|
|
if data.starts_with(&[0xFF, 0xD8]) {
|
|
return "jpeg".into();
|
|
}
|
|
if data.starts_with(b"GIF8") {
|
|
return "gif".into();
|
|
}
|
|
let e = name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()).unwrap_or_default();
|
|
if !e.is_empty() && e.len() <= 5 && e.chars().all(|c| c.is_ascii_alphanumeric()) { e } else { "bin".into() }
|
|
}
|
|
|
|
impl Exp<'_> {
|
|
fn media_part(&mut self, id: MediaId) -> Option<String> {
|
|
if let Some(p) = self.media_parts.get(&id) {
|
|
return Some(p.clone());
|
|
}
|
|
let m = self.p.media(id)?;
|
|
if m.data.is_empty() {
|
|
return None;
|
|
}
|
|
let ext = ext_for(&m.content_type, &m.name, &m.data);
|
|
let ct = if m.content_type.is_empty() || !m.content_type.contains('/') {
|
|
crate::mime_for(&format!("x.{ext}")).to_string()
|
|
} else {
|
|
m.content_type.clone()
|
|
};
|
|
let name = if ct.starts_with("image/") {
|
|
self.n_image += 1;
|
|
format!("ppt/media/image{}.{ext}", self.n_image)
|
|
} else {
|
|
self.n_media += 1;
|
|
format!("ppt/media/media{}.{ext}", self.n_media)
|
|
};
|
|
self.pkg.binary(&name, &ct, m.data.as_ref().clone());
|
|
self.media_parts.insert(id, name.clone());
|
|
Some(name)
|
|
}
|
|
/// A relationship from `o` to a media item (embedded part, or external link).
|
|
pub fn media_rel(&mut self, o: &mut Out, id: MediaId, kind: &str) -> Option<String> {
|
|
let m = self.p.media(id)?;
|
|
if m.data.is_empty() {
|
|
let link = m.link.clone()?;
|
|
return Some(o.rels.add(kind, &link, true));
|
|
}
|
|
let part = self.media_part(id)?;
|
|
Some(o.rels.add(kind, &opc::relative(&o.name, &part), false))
|
|
}
|
|
pub fn media_rel_new(&mut self, o: &mut Out, id: MediaId, kind: &str) -> Option<String> {
|
|
let m = self.p.media(id)?;
|
|
if m.data.is_empty() {
|
|
return None;
|
|
}
|
|
let part = self.media_part(id)?;
|
|
Some(o.rels.add(kind, &opc::relative(&o.name, &part), false))
|
|
}
|
|
/// A tiny transparent PNG for pictures whose image is missing.
|
|
pub fn placeholder_rel(&mut self, o: &mut Out) -> String {
|
|
let part = match &self.placeholder {
|
|
Some(p) => p.clone(),
|
|
None => {
|
|
self.n_image += 1;
|
|
let name = format!("ppt/media/image{}.png", self.n_image);
|
|
self.pkg.binary(&name, "image/png", crate::png::transparent_1x1());
|
|
self.placeholder = Some(name.clone());
|
|
name
|
|
}
|
|
};
|
|
o.rels.add("image", &opc::relative(&o.name, &part), false)
|
|
}
|
|
pub fn chart_rel(&mut self, o: &mut Out, c: &deckcraft_model::Chart) -> String {
|
|
self.n_chart += 1;
|
|
let name = format!("ppt/charts/chart{}.xml", self.n_chart);
|
|
self.pkg.xml_part(&name, CT_CHART, chart::chart_xml(c));
|
|
o.rels.add_new("chart", &opc::relative(&o.name, &name), false)
|
|
}
|
|
}
|
|
|
|
fn root_open(w: &mut W, tag: &str, extra: A) {
|
|
let mut a = A::new().a("xmlns:a", NS_A).a("xmlns:r", NS_R).a("xmlns:p", NS_P);
|
|
a.v.extend(extra.v);
|
|
w.open(tag, a);
|
|
}
|
|
|
|
fn background(w: &mut W, x: &mut Exp, o: &mut Out, bg: &Background) {
|
|
w.open0("p:bg");
|
|
match bg {
|
|
Background::Fill { fill } => {
|
|
w.open0("p:bgPr");
|
|
match fill {
|
|
deckcraft_model::Fill::Background | deckcraft_model::Fill::Group => w.empty0("a:noFill"),
|
|
f => dml::fill(w, x, o, f),
|
|
}
|
|
w.empty0("a:effectLst");
|
|
w.close("p:bgPr");
|
|
}
|
|
Background::Ref { idx, color } => {
|
|
let idx = if (1..=3).contains(idx) || (1001..=1003).contains(idx) { *idx } else { 1001 };
|
|
w.open("p:bgRef", A::new().a("idx", idx));
|
|
dml::color(w, color);
|
|
w.close("p:bgRef");
|
|
}
|
|
}
|
|
w.close("p:bg");
|
|
}
|
|
|
|
fn csld(w: &mut W, x: &mut Exp, o: &mut Out, name: &str, bg: Option<&Background>, list: &[Shape]) -> shapes::IdMap {
|
|
w.open("p:cSld", A::new().o("name", (!name.is_empty()).then_some(name)));
|
|
if let Some(bg) = bg {
|
|
background(w, x, o, bg);
|
|
}
|
|
w.open0("p:spTree");
|
|
shapes::tree_header(w);
|
|
let mut ids = shapes::IdMap::build(list);
|
|
{
|
|
let mut cx = shapes::Ctx { ids: &mut ids, written: Default::default() };
|
|
shapes::shapes(w, x, o, &mut cx, list, 0);
|
|
}
|
|
w.close("p:spTree");
|
|
w.close("p:cSld");
|
|
ids
|
|
}
|
|
|
|
const CLR_KEYS: [(&str, &str); 12] = [
|
|
("bg1", "lt1"),
|
|
("tx1", "dk1"),
|
|
("bg2", "lt2"),
|
|
("tx2", "dk2"),
|
|
("accent1", "accent1"),
|
|
("accent2", "accent2"),
|
|
("accent3", "accent3"),
|
|
("accent4", "accent4"),
|
|
("accent5", "accent5"),
|
|
("accent6", "accent6"),
|
|
("hlink", "hlink"),
|
|
("folHlink", "folHlink"),
|
|
];
|
|
|
|
fn clr_map(w: &mut W, map: &[(String, String)]) {
|
|
w.empty("p:clrMap", clr_map_attrs(map));
|
|
}
|
|
|
|
/// `p:clrMapOvr`: the layout's or slide's own colour map, else `a:masterClrMapping`.
|
|
fn clr_map_ovr(w: &mut W, map: Option<&[(String, String)]>) {
|
|
w.open0("p:clrMapOvr");
|
|
match map {
|
|
Some(m) => w.empty("a:overrideClrMapping", clr_map_attrs(m)),
|
|
None => w.empty0("a:masterClrMapping"),
|
|
}
|
|
w.close("p:clrMapOvr");
|
|
}
|
|
|
|
/// The twelve colour map attributes, each a theme slot (the default for anything else).
|
|
fn clr_map_attrs(map: &[(String, String)]) -> A {
|
|
let mut a = A::new();
|
|
for (k, d) in CLR_KEYS {
|
|
let v = map
|
|
.iter()
|
|
.find(|(mk, _)| mk == k)
|
|
.map(|(_, v)| v.as_str())
|
|
.filter(|v| deckcraft_color::SchemeSlot::from_xml(v).is_some_and(|s| deckcraft_color::SchemeSlot::THEME.contains(&s)))
|
|
.unwrap_or(d);
|
|
a = a.a(k, v);
|
|
}
|
|
a
|
|
}
|
|
|
|
fn ext_list(w: &mut W, raw: Option<&str>) {
|
|
let Some(raw) = raw.filter(|r| !r.trim().is_empty()) else { return };
|
|
// Only keep well-formed extension elements.
|
|
let Ok(d) = crate::xml::parse(format!("<x>{raw}</x>").as_bytes()) else { return };
|
|
let exts: Vec<String> = d.root.elements().filter(|e| e.is("ext") && e.has_attr("uri")).map(|e| e.to_xml()).collect();
|
|
if exts.is_empty() {
|
|
return;
|
|
}
|
|
w.open0("p:extLst");
|
|
for e in exts {
|
|
w.raw(&e);
|
|
}
|
|
w.close("p:extLst");
|
|
}
|
|
|
|
/// Strip an `<p:extLst>` wrapper (masters/layouts keep the whole element).
|
|
fn ext_children(raw: Option<&str>) -> Option<String> {
|
|
let raw = raw?;
|
|
let d = crate::xml::parse(raw.as_bytes()).ok()?;
|
|
if d.root.is("extLst") {
|
|
let ns = d.ns_decls();
|
|
return Some(d.root.elements().map(|e| crate::read::keep_xml(e, &ns)).collect());
|
|
}
|
|
Some(raw.to_string())
|
|
}
|
|
|
|
fn notes_master_shapes(size: deckcraft_geom::Size) -> Vec<Shape> {
|
|
let (w, h) = (size.width, size.height);
|
|
let mk = |id: u32, name: &str, kind: PhType, idx: u32, x: f64, y: f64, ww: f64, hh: f64| Shape {
|
|
id: ShapeId(id),
|
|
name: name.into(),
|
|
xfrm: Some(Xfrm::new(x, y, ww, hh)),
|
|
ph: Some(Placeholder { kind, idx, size: (kind != PhType::SlideImage).then(|| "quarter".to_string()), ..Default::default() }),
|
|
text: (kind != PhType::SlideImage).then(TextBody::default),
|
|
..Default::default()
|
|
};
|
|
let mut img = mk(4, "Slide Image Placeholder 3", PhType::SlideImage, 2, w * 0.125, h * 0.125, w * 0.75, h * 0.375);
|
|
img.fill = Some(deckcraft_model::Fill::None);
|
|
img.line = Some(deckcraft_model::Line::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::BLACK), 1.0));
|
|
let mut body = mk(5, "Notes Placeholder 4", PhType::Body, 3, w * 0.1, h * 0.53, w * 0.8, h * 0.36);
|
|
if let Some(t) = body.text.as_mut() {
|
|
t.paragraphs = vec![Paragraph::new("Click to edit Master text styles")];
|
|
}
|
|
vec![
|
|
mk(2, "Header Placeholder 1", PhType::Header, 0, 0.0, 0.0, w * 0.43, h * 0.05),
|
|
mk(3, "Date Placeholder 2", PhType::Date, 1, w * 0.57, 0.0, w * 0.43, h * 0.05),
|
|
img,
|
|
body,
|
|
mk(6, "Footer Placeholder 5", PhType::Footer, 4, 0.0, h * 0.95, w * 0.43, h * 0.05),
|
|
mk(7, "Slide Number Placeholder 6", PhType::SlideNum, 5, w * 0.57, h * 0.95, w * 0.43, h * 0.05),
|
|
]
|
|
}
|
|
|
|
fn notes_style() -> deckcraft_model::text::ListStyle {
|
|
let mut ls = deckcraft_model::text::ListStyle::default();
|
|
for i in 0..9u8 {
|
|
ls.set(
|
|
i,
|
|
deckcraft_model::text::LevelStyle {
|
|
para: deckcraft_model::text::ParaProps {
|
|
margin_left: Some(36.0 * i as f64),
|
|
align: Some(deckcraft_model::text::Align::Left),
|
|
..Default::default()
|
|
},
|
|
run: deckcraft_model::text::RunProps {
|
|
size: Some(12.0),
|
|
font: Some("+mn-lt".into()),
|
|
fill: Some(deckcraft_model::Fill::solid(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Tx1))),
|
|
..Default::default()
|
|
},
|
|
},
|
|
);
|
|
}
|
|
ls
|
|
}
|
|
|
|
fn valid_date(s: &str) -> bool {
|
|
let b = s.as_bytes();
|
|
b.len() >= 10
|
|
&& b.iter().take(4).all(|c| c.is_ascii_digit())
|
|
&& b.get(4) == Some(&b'-')
|
|
&& s.chars().all(|c| c.is_ascii_alphanumeric() || "-:.+TZ".contains(c))
|
|
}
|
|
|
|
/// A copy of the presentation with every slide pointing at a layout that exists.
|
|
fn sanitized(p: &Presentation) -> std::borrow::Cow<'_, Presentation> {
|
|
let mut need = p.masters.is_empty() || p.masters.iter().any(|m| m.layouts.is_empty());
|
|
need |= p.slides.iter().any(|s| p.layout(s.layout).is_none());
|
|
if !need {
|
|
return std::borrow::Cow::Borrowed(p);
|
|
}
|
|
let mut q = p.clone();
|
|
if q.masters.is_empty() {
|
|
let (m, next) = defaults::build_master(q.slide_size, Default::default(), q.next_id.max(1));
|
|
q.next_id = next;
|
|
q.masters.push(Arc::new(m));
|
|
}
|
|
for m in q.masters.iter_mut() {
|
|
if m.layouts.is_empty() {
|
|
let id = LayoutId(q.next_id.saturating_add(1));
|
|
q.next_id = id.0;
|
|
Arc::make_mut(m).layouts.push(Layout {
|
|
id,
|
|
name: "Blank".into(),
|
|
kind: deckcraft_model::LayoutType::Blank,
|
|
show_master_shapes: true,
|
|
..Default::default()
|
|
});
|
|
}
|
|
}
|
|
let first = q.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id).unwrap_or_default();
|
|
let ok: Vec<LayoutId> = q.masters.iter().flat_map(|m| m.layouts.iter().map(|l| l.id)).collect();
|
|
for s in q.slides.iter_mut() {
|
|
if !ok.contains(&s.layout) {
|
|
Arc::make_mut(s).layout = first;
|
|
}
|
|
}
|
|
std::borrow::Cow::Owned(q)
|
|
}
|
|
|
|
pub fn export(p: &Presentation) -> Result<Vec<u8>, PptxError> {
|
|
let p = sanitized(p);
|
|
let p: &Presentation = &p;
|
|
let mut x = Exp {
|
|
p,
|
|
pkg: PackageOut::default(),
|
|
media_parts: HashMap::new(),
|
|
n_image: 0,
|
|
n_media: 0,
|
|
n_chart: 0,
|
|
slide_parts: HashMap::new(),
|
|
table_styles: vec![],
|
|
placeholder: None,
|
|
};
|
|
for (i, s) in p.slides.iter().enumerate() {
|
|
x.slide_parts.insert(s.id, format!("ppt/slides/slide{}.xml", i + 1));
|
|
}
|
|
let mut pres = Out::new("ppt/presentation.xml");
|
|
|
|
// Masters, themes and layouts.
|
|
let mut master_rids = vec![];
|
|
let mut layout_parts: HashMap<LayoutId, String> = HashMap::new();
|
|
let mut n_layout = 0u32;
|
|
let mut big_id: u64 = 2_147_483_648;
|
|
for (mi, m) in p.masters.iter().enumerate() {
|
|
let mname = format!("ppt/slideMasters/slideMaster{}.xml", mi + 1);
|
|
let tname = format!("ppt/theme/theme{}.xml", mi + 1);
|
|
let mut mo = Out::new(&mname);
|
|
let mut lrids = vec![];
|
|
for l in &m.layouts {
|
|
n_layout += 1;
|
|
let lname = format!("ppt/slideLayouts/slideLayout{n_layout}.xml");
|
|
layout_parts.insert(l.id, lname.clone());
|
|
let rid = mo.rels.add("slideLayout", &opc::relative(&mname, &lname), false);
|
|
lrids.push(rid);
|
|
let mut lo = Out::new(&lname);
|
|
lo.rels.add("slideMaster", &opc::relative(&lname, &mname), false);
|
|
let xml = layout_xml(&mut x, &mut lo, l);
|
|
x.pkg.xml_part(&lname, CT_LAYOUT, xml);
|
|
x.pkg.rels(&lname, &lo.rels);
|
|
}
|
|
mo.rels.add("theme", &opc::relative(&mname, &tname), false);
|
|
let mut to = Out::new(&tname);
|
|
let txml = theme::theme_xml(&mut x, &mut to, &m.theme);
|
|
x.pkg.xml_part(&tname, CT_THEME, txml);
|
|
x.pkg.rels(&tname, &to.rels);
|
|
let master_id = big_id;
|
|
big_id += 1;
|
|
let mut layout_ids = vec![];
|
|
for rid in lrids {
|
|
layout_ids.push((big_id, rid));
|
|
big_id += 1;
|
|
}
|
|
let xml = master_xml(&mut x, &mut mo, m, &layout_ids);
|
|
x.pkg.xml_part(&mname, CT_MASTER, xml);
|
|
x.pkg.rels(&mname, &mo.rels);
|
|
master_rids.push((master_id, pres.rels.add("slideMaster", &opc::relative("ppt/presentation.xml", &mname), false)));
|
|
}
|
|
|
|
// Notes master when any slide has notes.
|
|
let has_notes = p.slides.iter().any(|s| !s.notes.is_empty());
|
|
let notes_master = "ppt/notesMasters/notesMaster1.xml";
|
|
let mut notes_master_rid = None;
|
|
if has_notes {
|
|
let tname = format!("ppt/theme/theme{}.xml", p.masters.len() + 1);
|
|
let mut no = Out::new(notes_master);
|
|
no.rels.add("theme", &opc::relative(notes_master, &tname), false);
|
|
let theme = p.notes_master.as_ref().map(|m| m.theme.clone()).or_else(|| p.masters.first().map(|m| m.theme.clone())).unwrap_or_default();
|
|
let mut to = Out::new(&tname);
|
|
let txml = theme::theme_xml(&mut x, &mut to, &theme);
|
|
x.pkg.xml_part(&tname, CT_THEME, txml);
|
|
x.pkg.rels(&tname, &to.rels);
|
|
let mut w = W::new();
|
|
root_open(&mut w, "p:notesMaster", A::new());
|
|
let bg = Background::Ref { idx: 1001, color: deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Bg1) };
|
|
csld(&mut w, &mut x, &mut no, "", Some(&bg), ¬es_master_shapes(p.notes_size));
|
|
clr_map(&mut w, &[]);
|
|
dml::list_style(&mut w, &mut x, &mut no, "p:notesStyle", ¬es_style());
|
|
w.close("p:notesMaster");
|
|
x.pkg.xml_part(notes_master, CT_NOTES_MASTER, w.finish());
|
|
x.pkg.rels(notes_master, &no.rels);
|
|
notes_master_rid = Some(pres.rels.add("notesMaster", &opc::relative("ppt/presentation.xml", notes_master), false));
|
|
}
|
|
|
|
// Handout master. Its header, footer, date and slide-number text live on this part; a deck
|
|
// that has never had one does not gain an empty master on save.
|
|
let handout_path = "ppt/handoutMasters/handoutMaster1.xml";
|
|
let mut handout_rid = None;
|
|
if let Some(hm) = p.handout_master.as_ref() {
|
|
let tindex = p.masters.len() + 1 + usize::from(notes_master_rid.is_some());
|
|
let tname = format!("ppt/theme/theme{tindex}.xml");
|
|
let mut ho = Out::new(handout_path);
|
|
ho.rels.add("theme", &opc::relative(handout_path, &tname), false);
|
|
let mut to = Out::new(&tname);
|
|
let txml = theme::theme_xml(&mut x, &mut to, &hm.theme);
|
|
x.pkg.xml_part(&tname, CT_THEME, txml);
|
|
x.pkg.rels(&tname, &to.rels);
|
|
let mut w = W::new();
|
|
root_open(&mut w, "p:handoutMaster", A::new());
|
|
csld(&mut w, &mut x, &mut ho, &hm.name, hm.background.as_ref(), &hm.shapes);
|
|
clr_map(&mut w, &hm.color_map);
|
|
w.close("p:handoutMaster");
|
|
x.pkg.xml_part(handout_path, CT_HANDOUT_MASTER, w.finish());
|
|
x.pkg.rels(handout_path, &ho.rels);
|
|
handout_rid = Some(pres.rels.add("handoutMaster", &opc::relative("ppt/presentation.xml", handout_path), false));
|
|
}
|
|
|
|
// Comment authors.
|
|
let mut authors: Vec<(String, String, u32)> = vec![];
|
|
let mut n_comments = 0u32;
|
|
|
|
// Slides.
|
|
let mut slide_rids = vec![];
|
|
for (i, s) in p.slides.iter().enumerate() {
|
|
let sname = format!("ppt/slides/slide{}.xml", i + 1);
|
|
let mut so = Out::new(&sname);
|
|
if let Some(lp) = layout_parts.get(&s.layout) {
|
|
so.rels.add("slideLayout", &opc::relative(&sname, lp), false);
|
|
}
|
|
if !s.notes.is_empty() {
|
|
let nname = format!("ppt/notesSlides/notesSlide{}.xml", i + 1);
|
|
so.rels.add("notesSlide", &opc::relative(&sname, &nname), false);
|
|
let mut no = Out::new(&nname);
|
|
no.rels.add("notesMaster", &opc::relative(&nname, notes_master), false);
|
|
no.rels.add("slide", &opc::relative(&nname, &sname), false);
|
|
let xml = notes_xml(&mut x, &mut no, &s.notes);
|
|
x.pkg.xml_part(&nname, CT_NOTES, xml);
|
|
x.pkg.rels(&nname, &no.rels);
|
|
}
|
|
if !s.comments.is_empty() {
|
|
n_comments += 1;
|
|
let cname = format!("ppt/comments/comment{n_comments}.xml");
|
|
so.rels.add("comments", &opc::relative(&sname, &cname), false);
|
|
x.pkg.xml_part(&cname, CT_COMMENTS, comments_xml(&s.comments, &mut authors));
|
|
}
|
|
let xml = slide_xml(&mut x, &mut so, s);
|
|
x.pkg.xml_part(&sname, CT_SLIDE, xml);
|
|
x.pkg.rels(&sname, &so.rels);
|
|
slide_rids.push(pres.rels.add("slide", &opc::relative("ppt/presentation.xml", &sname), false));
|
|
}
|
|
if !authors.is_empty() {
|
|
let name = "ppt/commentAuthors.xml";
|
|
let mut w = W::new();
|
|
root_open(&mut w, "p:cmAuthorLst", A::new());
|
|
for (i, (n, ini, last)) in authors.iter().enumerate() {
|
|
w.empty("p:cmAuthor", A::new().a("id", i).a("name", n).a("initials", ini).a("lastIdx", last).a("clrIdx", i % 8));
|
|
}
|
|
w.close("p:cmAuthorLst");
|
|
x.pkg.xml_part(name, CT_AUTHORS, w.finish());
|
|
pres.rels.add("commentAuthors", "commentAuthors.xml", false);
|
|
}
|
|
|
|
// Presentation-level parts.
|
|
let theme1 = "theme/theme1.xml";
|
|
pres.rels.add("theme", theme1, false);
|
|
pres.rels.add("presProps", "presProps.xml", false);
|
|
pres.rels.add("viewProps", "viewProps.xml", false);
|
|
pres.rels.add("tableStyles", "tableStyles.xml", false);
|
|
x.pkg.xml_part("ppt/presProps.xml", CT_PRES_PROPS, pres_props_xml(p));
|
|
x.pkg.xml_part("ppt/viewProps.xml", CT_VIEW_PROPS, view_props_xml());
|
|
let ts = crate::tables::table_styles_xml(&x.table_styles, &p.table_styles);
|
|
x.pkg.xml_part("ppt/tableStyles.xml", CT_TABLE_STYLES, ts);
|
|
let pxml = presentation_xml(&mut x, &mut pres, &master_rids, notes_master_rid.as_deref(), handout_rid.as_deref(), &slide_rids);
|
|
x.pkg.xml_part("ppt/presentation.xml", CT_PRES, pxml);
|
|
x.pkg.rels("ppt/presentation.xml", &pres.rels);
|
|
|
|
// Document properties and package relationships.
|
|
x.pkg.xml_part("docProps/core.xml", CT_CORE, core_xml(p));
|
|
x.pkg.xml_part("docProps/app.xml", CT_APP, app_xml(p));
|
|
let mut root = RelsOut::default();
|
|
root.add("officeDocument", "ppt/presentation.xml", false);
|
|
root.add(opc::RT_CORE, "docProps/core.xml", false);
|
|
root.add("extended-properties", "docProps/app.xml", false);
|
|
x.pkg.rels("", &root);
|
|
x.pkg.finish()
|
|
}
|
|
|
|
fn layout_xml(x: &mut Exp, o: &mut Out, l: &Layout) -> Vec<u8> {
|
|
let mut w = W::new();
|
|
root_open(
|
|
&mut w,
|
|
"p:sldLayout",
|
|
A::new().o("showMasterSp", (!l.show_master_shapes).then_some("0")).a("type", l.kind.xml()).t("preserve", l.preserve),
|
|
);
|
|
csld(&mut w, x, o, &l.name, l.background.as_ref(), &l.shapes);
|
|
clr_map_ovr(&mut w, l.color_map.as_deref());
|
|
ext_list(&mut w, ext_children(l.raw_ext.as_deref()).as_deref());
|
|
w.close("p:sldLayout");
|
|
w.finish()
|
|
}
|
|
|
|
fn master_xml(x: &mut Exp, o: &mut Out, m: &Master, layouts: &[(u64, String)]) -> Vec<u8> {
|
|
let mut w = W::new();
|
|
root_open(&mut w, "p:sldMaster", A::new().t("preserve", m.preserve));
|
|
csld(&mut w, x, o, &m.name, m.background.as_ref(), &m.shapes);
|
|
clr_map(&mut w, &m.color_map);
|
|
w.open0("p:sldLayoutIdLst");
|
|
for (id, rid) in layouts {
|
|
w.empty("p:sldLayoutId", A::new().a("id", id).a("r:id", rid));
|
|
}
|
|
w.close("p:sldLayoutIdLst");
|
|
w.open0("p:txStyles");
|
|
let (dt, db, dother) = defaults::master_styles();
|
|
let pick = |s: &deckcraft_model::text::ListStyle, d: deckcraft_model::text::ListStyle| if s.is_empty() { d } else { s.clone() };
|
|
dml::list_style(&mut w, x, o, "p:titleStyle", &pick(&m.title_style, dt));
|
|
dml::list_style(&mut w, x, o, "p:bodyStyle", &pick(&m.body_style, db));
|
|
dml::list_style(&mut w, x, o, "p:otherStyle", &pick(&m.other_style, dother));
|
|
w.close("p:txStyles");
|
|
ext_list(&mut w, ext_children(m.raw_ext.as_deref()).as_deref());
|
|
w.close("p:sldMaster");
|
|
w.finish()
|
|
}
|
|
|
|
fn slide_xml(x: &mut Exp, o: &mut Out, s: &Slide) -> Vec<u8> {
|
|
let mut w = W::new();
|
|
root_open(&mut w, "p:sld", A::new().o("showMasterSp", (!s.show_master_shapes).then_some("0")).o("show", s.hidden.then_some("0")));
|
|
let ids = csld(&mut w, x, o, &s.name, s.background.as_ref(), &s.shapes);
|
|
clr_map_ovr(&mut w, s.color_map.as_deref());
|
|
if let Some(t) = &s.transition {
|
|
timing::transition(&mut w, x, o, t);
|
|
}
|
|
if !s.animations.is_empty() {
|
|
timing::timing(&mut w, x, &s.animations, &s.shapes, &ids);
|
|
}
|
|
ext_list(&mut w, s.raw_ext.as_deref());
|
|
w.close("p:sld");
|
|
w.finish()
|
|
}
|
|
|
|
fn notes_xml(x: &mut Exp, o: &mut Out, notes: &TextBody) -> Vec<u8> {
|
|
let mut w = W::new();
|
|
root_open(&mut w, "p:notes", A::new());
|
|
let img = Shape {
|
|
id: ShapeId(2),
|
|
name: "Slide Image Placeholder 1".into(),
|
|
ph: Some(Placeholder { kind: PhType::SlideImage, ..Default::default() }),
|
|
..Default::default()
|
|
};
|
|
let mut tb = notes.clone();
|
|
tb.body = Default::default();
|
|
let body = Shape {
|
|
id: ShapeId(3),
|
|
name: "Notes Placeholder 2".into(),
|
|
ph: Some(Placeholder { kind: PhType::Body, idx: 1, ..Default::default() }),
|
|
text: Some(tb),
|
|
..Default::default()
|
|
};
|
|
csld(&mut w, x, o, "", None, &[img, body]);
|
|
w.open0("p:clrMapOvr");
|
|
w.empty0("a:masterClrMapping");
|
|
w.close("p:clrMapOvr");
|
|
w.close("p:notes");
|
|
w.finish()
|
|
}
|
|
|
|
fn comments_xml(list: &[Comment], authors: &mut Vec<(String, String, u32)>) -> Vec<u8> {
|
|
let mut w = W::new();
|
|
root_open(&mut w, "p:cmLst", A::new());
|
|
let mut flat: Vec<&Comment> = vec![];
|
|
for c in list.iter().take(10_000) {
|
|
flat.push(c);
|
|
for r in c.replies.iter().take(1000) {
|
|
flat.push(r);
|
|
}
|
|
}
|
|
for c in flat {
|
|
let author = if c.author.is_empty() { "Author".to_string() } else { c.author.clone() };
|
|
let ai = match authors.iter().position(|(n, _, _)| *n == author) {
|
|
Some(i) => i,
|
|
None => {
|
|
let ini = if c.initials.is_empty() { author.chars().filter(|c| c.is_alphabetic()).take(2).collect() } else { c.initials.clone() };
|
|
authors.push((author.clone(), ini, 0));
|
|
authors.len() - 1
|
|
}
|
|
};
|
|
let idx = match authors.get_mut(ai) {
|
|
Some(a) => {
|
|
a.2 += 1;
|
|
a.2
|
|
}
|
|
None => 1,
|
|
};
|
|
let dt = if valid_date(&c.date) { c.date.clone() } else { "2024-01-01T00:00:00.000".into() };
|
|
w.open("p:cm", A::new().a("authorId", ai).a("dt", dt).a("idx", idx));
|
|
let pos = |v: f64| if v.is_finite() { (v * 8.0).round().clamp(0.0, 1.0e9) as i64 } else { 0 };
|
|
w.empty("p:pos", A::new().a("x", pos(c.x)).a("y", pos(c.y)));
|
|
w.elt("p:text", &c.text);
|
|
w.close("p:cm");
|
|
}
|
|
w.close("p:cmLst");
|
|
w.finish()
|
|
}
|
|
|
|
/// Sections with every slide in exactly one section, in slide order.
|
|
fn section_slides(p: &Presentation) -> Vec<(String, Vec<usize>)> {
|
|
let mut out: Vec<(String, Vec<usize>)> = p.sections.iter().map(|s| (s.name.clone(), vec![])).collect();
|
|
if out.is_empty() {
|
|
return out;
|
|
}
|
|
let mut cur = 0usize;
|
|
for (i, s) in p.slides.iter().enumerate() {
|
|
if let Some(k) = p.sections.iter().position(|sec| sec.slides.contains(&s.id))
|
|
&& k >= cur
|
|
{
|
|
cur = k;
|
|
}
|
|
if let Some(sec) = out.get_mut(cur) {
|
|
sec.1.push(i);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
fn presentation_xml(x: &mut Exp, o: &mut Out, masters: &[(u64, String)], notes: Option<&str>, handout: Option<&str>, slides: &[String]) -> Vec<u8> {
|
|
let p = x.p;
|
|
let mut w = W::new();
|
|
let first = (p.first_slide_number != 1).then_some(p.first_slide_number.min(9999));
|
|
root_open(&mut w, "p:presentation", A::new().a("saveSubsetFonts", 1).o("firstSlideNum", first));
|
|
w.open0("p:sldMasterIdLst");
|
|
for (id, rid) in masters {
|
|
w.empty("p:sldMasterId", A::new().a("id", id).a("r:id", rid));
|
|
}
|
|
w.close("p:sldMasterIdLst");
|
|
if let Some(n) = notes {
|
|
w.open0("p:notesMasterIdLst");
|
|
w.empty("p:notesMasterId", A::new().a("r:id", n));
|
|
w.close("p:notesMasterIdLst");
|
|
}
|
|
if let Some(h) = handout {
|
|
w.open0("p:handoutMasterIdLst");
|
|
w.empty("p:handoutMasterId", A::new().a("r:id", h));
|
|
w.close("p:handoutMasterIdLst");
|
|
}
|
|
if !slides.is_empty() {
|
|
w.open0("p:sldIdLst");
|
|
for (i, rid) in slides.iter().enumerate() {
|
|
w.empty("p:sldId", A::new().a("id", 256 + i).a("r:id", rid));
|
|
}
|
|
w.close("p:sldIdLst");
|
|
}
|
|
let clamp = |v: f64| dml::emu(v).clamp(914_400, 51_206_400);
|
|
w.empty("p:sldSz", A::new().a("cx", clamp(p.slide_size.width)).a("cy", clamp(p.slide_size.height)));
|
|
w.empty("p:notesSz", A::new().a("cx", dml::emu_pos(p.notes_size.width).max(1)).a("cy", dml::emu_pos(p.notes_size.height).max(1)));
|
|
if !p.custom_shows.is_empty() {
|
|
w.open0("p:custShowLst");
|
|
for (i, cs) in p.custom_shows.iter().enumerate() {
|
|
w.open("p:custShow", A::new().a("name", &cs.name).a("id", i));
|
|
w.open0("p:sldLst");
|
|
for sid in &cs.slides {
|
|
if let Some(part) = x.slide_parts.get(sid).cloned() {
|
|
let rid = o.rels.add("slide", &opc::relative("ppt/presentation.xml", &part), false);
|
|
w.empty("p:sld", A::new().a("r:id", rid));
|
|
}
|
|
}
|
|
w.close("p:sldLst");
|
|
w.close("p:custShow");
|
|
}
|
|
w.close("p:custShowLst");
|
|
}
|
|
let dts = if p.default_text_style.is_empty() { defaults::default_text_style() } else { p.default_text_style.clone() };
|
|
dml::list_style(&mut w, x, o, "p:defaultTextStyle", &dts);
|
|
let sections = section_slides(p);
|
|
let raw = p.raw_ext.as_deref().filter(|r| !r.trim().is_empty());
|
|
if !sections.is_empty() || raw.is_some() {
|
|
let mut inner = W::frag();
|
|
if !sections.is_empty() {
|
|
inner.open("p:ext", A::new().a("uri", crate::SECTION_EXT_URI));
|
|
inner.open("p14:sectionLst", A::new().a("xmlns:p14", NS_P14));
|
|
for (i, (name, list)) in sections.iter().enumerate() {
|
|
inner.open("p14:section", A::new().a("name", name).a("id", crate::tables::guid(&format!("section:{i}:{name}"))));
|
|
inner.open0("p14:sldIdLst");
|
|
for si in list {
|
|
inner.empty("p14:sldId", A::new().a("id", 256 + si));
|
|
}
|
|
inner.close("p14:sldIdLst");
|
|
inner.close("p14:section");
|
|
}
|
|
inner.close("p14:sectionLst");
|
|
inner.close("p:ext");
|
|
}
|
|
if let Some(r) = raw {
|
|
inner.raw(r);
|
|
}
|
|
ext_list(&mut w, Some(&inner.s));
|
|
}
|
|
w.close("p:presentation");
|
|
w.finish()
|
|
}
|
|
|
|
fn pres_props_xml(p: &Presentation) -> Vec<u8> {
|
|
let s = &p.show;
|
|
let mut w = W::new();
|
|
root_open(&mut w, "p:presentationPr", A::new());
|
|
w.open(
|
|
"p:showPr",
|
|
A::new()
|
|
.t("loop", s.loop_until_esc)
|
|
.a("showNarration", if s.without_narration { 0 } else { 1 })
|
|
.o("showAnimation", s.without_animation.then_some("0"))
|
|
.o("useTimings", (!s.use_timings).then_some("0")),
|
|
);
|
|
match s.show_type.as_str() {
|
|
"browsed" => w.empty("p:browse", A::new()),
|
|
"kiosk" => w.empty("p:kiosk", A::new()),
|
|
_ => w.empty0("p:present"),
|
|
}
|
|
let custom = s.custom_show.as_ref().and_then(|n| p.custom_shows.iter().position(|c| c.name == *n));
|
|
match (custom, s.range) {
|
|
(Some(i), _) => w.empty("p:custShow", A::new().a("id", i)),
|
|
(None, Some((a, b))) => w.empty("p:sldRg", A::new().a("st", a.max(1)).a("end", b.max(a).max(1))),
|
|
_ => w.empty0("p:sldAll"),
|
|
}
|
|
w.open0("p:penClr");
|
|
w.val("a:srgbClr", s.pen_color.hex());
|
|
w.close("p:penClr");
|
|
w.close("p:showPr");
|
|
w.close("p:presentationPr");
|
|
w.finish()
|
|
}
|
|
|
|
fn view_props_xml() -> Vec<u8> {
|
|
let mut w = W::new();
|
|
root_open(&mut w, "p:viewPr", A::new());
|
|
w.open0("p:normalViewPr");
|
|
w.empty("p:restoredLeft", A::new().a("sz", 15620));
|
|
w.empty("p:restoredTop", A::new().a("sz", 94660));
|
|
w.close("p:normalViewPr");
|
|
w.empty("p:gridSpacing", A::new().a("cx", 76200).a("cy", 76200));
|
|
w.close("p:viewPr");
|
|
w.finish()
|
|
}
|
|
|
|
fn core_xml(p: &Presentation) -> Vec<u8> {
|
|
let pr = &p.props;
|
|
let mut w = W::new();
|
|
w.open(
|
|
"cp:coreProperties",
|
|
A::new()
|
|
.a("xmlns:cp", "http://schemas.openxmlformats.org/package/2006/metadata/core-properties")
|
|
.a("xmlns:dc", "http://purl.org/dc/elements/1.1/")
|
|
.a("xmlns:dcterms", "http://purl.org/dc/terms/")
|
|
.a("xmlns:dcmitype", "http://purl.org/dc/dcmitype/")
|
|
.a("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance"),
|
|
);
|
|
for (tag, v) in [
|
|
("dc:title", &pr.title),
|
|
("dc:subject", &pr.subject),
|
|
("dc:creator", &pr.author),
|
|
("cp:keywords", &pr.keywords),
|
|
("dc:description", &pr.comments),
|
|
("cp:category", &pr.category),
|
|
("cp:lastModifiedBy", &pr.last_modified_by),
|
|
] {
|
|
if !v.is_empty() {
|
|
w.elt(tag, v);
|
|
}
|
|
}
|
|
if pr.revision > 0 {
|
|
w.elt("cp:revision", &pr.revision.to_string());
|
|
}
|
|
for (tag, v) in [("dcterms:created", &pr.created), ("dcterms:modified", &pr.modified)] {
|
|
if valid_date(v) {
|
|
w.open(tag, A::new().a("xsi:type", "dcterms:W3CDTF"));
|
|
w.text(v);
|
|
w.close(tag);
|
|
}
|
|
}
|
|
w.close("cp:coreProperties");
|
|
w.finish()
|
|
}
|
|
|
|
fn app_xml(p: &Presentation) -> Vec<u8> {
|
|
let mut w = W::new();
|
|
w.open(
|
|
"Properties",
|
|
A::new()
|
|
.a("xmlns", "http://schemas.openxmlformats.org/officeDocument/2006/extended-properties")
|
|
.a("xmlns:vt", "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"),
|
|
);
|
|
w.elt("Application", "DeckCraft");
|
|
w.elt("Slides", &p.slides.len().to_string());
|
|
w.elt("Notes", &p.slides.iter().filter(|s| !s.notes.is_empty()).count().to_string());
|
|
w.elt("HiddenSlides", &p.slides.iter().filter(|s| s.hidden).count().to_string());
|
|
if !p.props.company.is_empty() {
|
|
w.elt("Company", &p.props.company);
|
|
}
|
|
w.close("Properties");
|
|
w.finish()
|
|
}
|