From d6e3a6c4388d502fbd48644ae0664bd5ac206d09 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 21:53:02 -0500 Subject: [PATCH] craft-pptx: the writer (from DeckCraft) (#1065) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add craft-drawingml: units, colours, preset shapes, custom geometry and text warps The parts of DrawingML that need no document model, moved from DeckCraft (60f91f78): crates/color, crates/geom (preset, warp, Xfrm and the EMU conversions) and crates/pptx/src/custgeom.rs with CustomPath from crates/model. The code is unchanged except that custgeom reads craft-xml trees and writes to a craft-xml Writer instead of DeckCraft's own XML helper, and matches elements by local name. The 59 tests of the moved code come with it; six more cover the custom geometry limits. * drawingml: evaluate a custom geometry's guide lists once per coordinate space read_cust_geom rebuilt Guides and evaluated avLst and gdLst again for every path: up to 1,000 paths times up to 10,000 guides, each name copied and hashed. Guides are now cached per path coordinate space (w, h). At most 16 spaces and 4 MiB of guide names are kept per geometry; paths in further spaces are left out. The first space is always kept, and a geometry without guides is not capped. Ported from storytold/deckcraft#1789. A list counts as having guides only when it has gd children, as that PR's review asked. * drawingml: the model types for fills, lines, text, tables, charts and diagrams Moved from DeckCraft (60f91f78) crates/model/src: style.rs, text.rs, table.rs, chart.rs, numfmt.rs and trendline.rs, plus Diagram, DiagramNode and the MediaId and SlideId newtypes from lib.rs. The files are unchanged except that deckcraft_color:: is crate::color:: and Fill and TableBorder allow clippy::large_enum_variant. Their 60 tests come with them. Readers and writers follow. * drawingml: the theme model (colour, font and format schemes) Moved from DeckCraft crates/model/src/theme.rs at 60f91f78: FontSet, ScriptFont, FontScheme, FormatScheme, Theme and default_format. The app's theme gallery, its variants and its own default theme stay in the app. Theme::default() is a plain theme built in place, so nothing here depends on a gallery. * drawingml: readers and writers for fills, lines, text, charts, themes, table styles and diagrams Moved from DeckCraft crates/pptx/src at 60f91f78: read/dml.rs, write/dml.rs, read/chart.rs, read/chartex.rs, write/chart.rs, read/theme.rs, write/theme.rs, tables.rs and dgm.rs, onto craft-xml. Package lookups (Imp, Part, Exp, Out) are the caller's part::Source and part::Sink. The chart frame budget and kept-part capture stay with the presentation reader. * drawingml: what WordCraft and GridCraft read that DeckCraft's copy did not Every ST_PresetColorVal name has its colour, in any case and with the dk/lt/med abbreviations; a sysClr without lastClr takes the usual value of its name; a Strict percentage (50%) reads anywhere a percentage does; a chart series keeps its c:f formulas and the writer uses them. * craft-drawingml: extra named theme colours and explicit tint rows Ported from the format part of storytold/deckcraft#1746 by @jagjordi: ColorScheme::{extra, tints, computed_tints}, ExtraColor, GalleryVariant, variants_of, and the a:clrScheme extension that keeps them in a file. * drawingml xmlx: copy only used, bounded namespace declarations onto kept fragments and mc branches From storytold/deckcraft#1740 (988a4920), which landed after the extraction commit 60f91f78. * Add craft-pptx: the PresentationML model (from DeckCraft) Presentation, masters, layouts, slides, shapes, placeholders, media, comments, sections, show settings, properties, embedded fonts, kept parts and drawing guides from DeckCraft's crates/model/src/lib.rs, and transitions and animations from anim.rs. The DrawingML types come from craft-drawingml. The placeholder matching from DeckCraft's resolve.rs moves as placeholder::parents, without the rest of resolve. Presentation::default() is now an empty 16:9 deck with no masters; the starting deck stays with the application. * craft-pptx: the reader (from DeckCraft), on craft-opc and craft-drawingml DeckCraft's crates/pptx reader (read.rs, read/shapes.rs and the chart frame code of read/chart.rs) moved onto craft-opc's Package and craft-xml trees, with the DrawingML readers of craft-drawingml behind a part::Source over the import state. What DeckCraft took from its own defaults (a master for a file without one, the theme of a master that names none) comes from a caller-supplied Defaults; Plain is the neutral one. Placeholder geometry inheritance uses placeholder::parents. Transitions, animations, notes and comments follow in later changes. * craft-pptx: drop DeckCraft issue numbers from reader comments * craft-pptx: link the package budgets from the tag budget's doc * craft-pptx: the writer (from DeckCraft) DeckCraft's crates/pptx writer (write.rs, write/shapes.rs and the kept chart parts of write/chart.rs) moved onto craft-opc's PackageOut and craft-xml's Writer, with craft-drawingml's DrawingML writers behind a part::Sink over the export state. What DeckCraft took from its own defaults (a master for a deck without one, master and default text styles, its name in docProps/app.xml and in table style names) comes from Defaults. export and export_as take the defaults. Transitions, animations, notes slides and comments follow in later changes. --------- Co-authored-by: Jordi Altayó --- crates/pptx/README.md | 14 +- crates/pptx/src/lib.rs | 37 +- crates/pptx/src/write.rs | 1108 +++++++++++++++++++++++++++++++ crates/pptx/src/write/chart.rs | 93 +++ crates/pptx/src/write/shapes.rs | 1051 +++++++++++++++++++++++++++++ crates/pptx/tests/write.rs | 204 ++++++ 6 files changed, 2503 insertions(+), 4 deletions(-) create mode 100644 crates/pptx/src/write.rs create mode 100644 crates/pptx/src/write/chart.rs create mode 100644 crates/pptx/src/write/shapes.rs create mode 100644 crates/pptx/tests/write.rs diff --git a/crates/pptx/README.md b/crates/pptx/README.md index 01850bc06..01de066bb 100644 --- a/crates/pptx/README.md +++ b/crates/pptx/README.md @@ -32,5 +32,15 @@ readable one, and the theme of a master that names none. `Plain` has no applicat `import_theme` reads the theme of a `.thmx`, or of a presentation's first master. `sniff` tells a package from other bytes. -Not read yet (later changes): transitions and animations, speaker notes, comments. The writer -comes later too. +## Writing + +`export(&presentation, &defaults)` writes a package PowerPoint opens without repair; `export_as` +writes a `.potx` or `.ppsx` instead. Masters, layouts and slides with their shapes, pictures, +media, tables and table styles, charts (an unchanged chart's workbook and other parts written +back), SmartArt, 3-D models, ink (as freeform lines), objects kept as XML (with their parts), +notes and handout masters, sections, custom shows, show settings, drawing guides, embedded fonts, +tags, kept parts and document properties. A slide whose layout is missing goes on the first one; +a presentation with no master gets the caller's. `Defaults` also gives the text styles written +where a master or the presentation has none, and the program name for `docProps/app.xml`. + +Not read or written yet (later changes): transitions and animations, speaker notes, comments. diff --git a/crates/pptx/src/lib.rs b/crates/pptx/src/lib.rs index fdcbba3c3..bd47542ca 100644 --- a/crates/pptx/src/lib.rs +++ b/crates/pptx/src/lib.rs @@ -15,6 +15,7 @@ pub mod anim; mod package; pub mod placeholder; mod read; +mod write; pub use package::{LIMITS, MAX_ENTRIES, MAX_INFLATE_WORK, MAX_PART, MAX_TOTAL, MAX_XML_PART}; pub use read::MAX_TAG_BYTES; @@ -50,8 +51,10 @@ pub enum Error { Xml(String), } -/// What the caller supplies where a file leaves something out: the reader asks for a master when a -/// file has none it can read, and for the theme of a master that names none. +/// What the caller supplies where a file or a presentation leaves something out. The reader asks +/// for a master when a file has none it can read, and for the theme of a master that names none; +/// the writer for a master when the presentation has none, and for the text styles a master or +/// the presentation leaves empty. pub trait Defaults { /// A slide master for slides of `size`, with at least one layout. Its ids (master, layouts, /// shapes) are above `after`; returns the master and the largest id it used. @@ -60,6 +63,19 @@ pub trait Defaults { fn theme(&self) -> Theme { Theme::default() } + /// The title, body and other text styles written for a master whose own are empty. + fn master_styles(&self) -> (text::ListStyle, text::ListStyle, text::ListStyle) { + Default::default() + } + /// The presentation's default text style, written when its own is empty. + fn default_text_style(&self) -> text::ListStyle { + text::ListStyle::default() + } + /// The program writing the file: `Application` in `docProps/app.xml`, and the first word of + /// the names of the table styles the writer defines. + fn application(&self) -> &str { + "craft-pptx" + } } /// [`Defaults`] with nothing of an application's: a master with one blank layout and the plain @@ -107,6 +123,23 @@ pub fn import_theme(bytes: &[u8]) -> Result<(Theme, Vec), Error> { read::import_theme(bytes) } +/// Writes a `.pptx` package that PowerPoint opens without repair. `defaults` fills in what the +/// presentation leaves out (see [`Defaults`]). +pub fn export(p: &Presentation, defaults: &dyn Defaults) -> Result, Error> { + write::export(p, defaults, write::CT_PRES) +} + +/// Writes a package for `ext`: `potx` (template) and `ppsx` (slide show) differ from `pptx` only in +/// the main part's content type. +pub fn export_as(p: &Presentation, defaults: &dyn Defaults, ext: &str) -> Result, Error> { + let ct = match ext { + "potx" => write::CT_TEMPLATE, + "ppsx" => write::CT_SHOW, + _ => write::CT_PRES, + }; + write::export(p, defaults, ct) +} + /// Content type for a file name's extension. pub(crate) fn mime_for(name: &str) -> &'static str { let ext = name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()).unwrap_or_default(); diff --git a/crates/pptx/src/write.rs b/crates/pptx/src/write.rs new file mode 100644 index 000000000..f5c3113de --- /dev/null +++ b/crates/pptx/src/write.rs @@ -0,0 +1,1108 @@ +//! PPTX export: [`Presentation`] → package. + +pub(crate) mod chart; +mod shapes; + +use std::collections::HashMap; +use std::sync::Arc; + +use craft_drawingml::Xfrm; +use craft_drawingml::part::Sink; +use craft_drawingml::write as dml; +use craft_drawingml::xmlx::{self, A, TagExt}; +use craft_opc::{PackageOut, RelsOut}; +use craft_xml::{Local, Writer}; + +use crate::text::{ListStyle, Paragraph, TextBody}; +use crate::theme::Theme; +use crate::{Background, Defaults, Error, Layout, LayoutId, Master, MediaId, PhType, Placeholder, Presentation, Shape, ShapeId, Slide, SlideId}; + +pub(crate) const NS_A: &str = craft_drawingml::ns::NS_A; +pub(crate) const NS_R: &str = craft_drawingml::ns::NS_R; +pub(crate) const NS_P: &str = "http://schemas.openxmlformats.org/presentationml/2006/main"; +pub(crate) const NS_MC: &str = "http://schemas.openxmlformats.org/markup-compatibility/2006"; +pub(crate) const NS_P14: &str = "http://schemas.microsoft.com/office/powerpoint/2010/main"; +pub(crate) const RT_MEDIA: &str = "http://schemas.microsoft.com/office/2007/relationships/media"; +pub(crate) const RT_MODEL3D: &str = "http://schemas.microsoft.com/office/2017/06/relationships/model3d"; +const RT_CORE: &str = "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties"; + +pub(crate) const CT_PRES: &str = "application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"; +pub(crate) const CT_TEMPLATE: &str = "application/vnd.openxmlformats-officedocument.presentationml.template.main+xml"; +pub(crate) const CT_SHOW: &str = "application/vnd.openxmlformats-officedocument.presentationml.slideshow.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_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_TAGS: &str = "application/vnd.openxmlformats-officedocument.presentationml.tags+xml"; +const CT_CHART: &str = "application/vnd.openxmlformats-officedocument.drawingml.chart+xml"; +const CT_CHARTEX: &str = "application/vnd.ms-office.chartex+xml"; +const RT_CHARTEX: &str = "http://schemas.microsoft.com/office/2014/relationships/chartEx"; +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 defaults: &'a dyn Defaults, + pub pkg: PackageOut, + media_parts: HashMap, + /// Each media id's item in `p.media` (the first with the id, as `Presentation::media`). + media_at: HashMap, + n_image: u32, + n_media: u32, + n_chart: u32, + n_diagram: u32, + n_tags: u32, + n_chartex: u32, + /// Parts kept from an unchanged chart (`ppt/…/keptN.ext`). + n_kept: u32, + pub slide_parts: HashMap, + pub table_styles: Vec, + /// The ids in `table_styles`, for checking membership without a scan. + pub table_style_set: std::collections::HashSet, + placeholder: Option, +} + +/// 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() } + } +} + +/// The part being written, as the DrawingML writers see it. +pub(crate) struct Snk<'s, 'a>(pub &'s mut Exp<'a>, pub &'s mut Out); + +impl Sink for Snk<'_, '_> { + fn part_name(&self) -> &str { + &self.1.name + } + fn rel(&mut self, kind: &str, target: &str, external: bool) -> String { + self.1.rels.add(kind, target, external) + } + fn media_rel(&mut self, id: MediaId, kind: &str) -> Option { + self.0.media_rel(self.1, id, kind) + } + fn placeholder_rel(&mut self) -> String { + self.0.placeholder_rel(self.1) + } + fn media_linked(&self, id: MediaId) -> bool { + self.0.media(id).is_some_and(|m| m.data.is_empty() && m.link.is_some()) + } + fn media_name(&self, id: MediaId) -> Option { + self.0.media(id).map(|m| m.name.clone()) + } + fn slide_rel(&mut self, slide: SlideId) -> Option { + let target = self.0.slide_parts.get(&slide)?; + Some(self.1.rels.add("slide", &craft_opc::relative(&self.1.name, target), false)) + } + fn custom_show_index(&self, name: &str) -> Option { + self.0.p.custom_shows.iter().position(|c| c.name == name) + } +} + +/// A 1×1 transparent PNG, written for pictures whose image is missing. +const TRANSPARENT_PNG: [u8; 73] = [ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x10, 0x49, 0x44, 0x41, 0x54, 0x78, 0x01, 0x01, 0x05, 0x00, 0xFA, 0xFF, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05, 0x00, 0x01, 0x64, 0x78, 0x95, 0x38, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, + 0x82, +]; + +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", + "model/gltf-binary" => "glb", + _ => "", + }; + 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<'a> Exp<'a> { + /// The media item `id`, found through the index rather than a scan of every item. + pub fn media(&self, id: MediaId) -> Option<&'a crate::MediaItem> { + let p: &'a Presentation = self.p; + self.media_at.get(&id).and_then(|&i| p.media.get(i)) + } + fn media_part(&mut self, id: MediaId) -> Option { + if let Some(p) = self.media_parts.get(&id) { + return Some(p.clone()); + } + let m = self.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 { + let m = self.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, &craft_opc::relative(&o.name, &part), false)) + } + pub fn media_rel_new(&mut self, o: &mut Out, id: MediaId, kind: &str) -> Option { + let m = self.media(id)?; + if m.data.is_empty() { + return None; + } + let part = self.media_part(id)?; + Some(o.rels.add(kind, &craft_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", TRANSPARENT_PNG.to_vec()); + self.placeholder = Some(name.clone()); + name + } + }; + o.rels.add("image", &craft_opc::relative(&o.name, &part), false) + } + /// The number of the next SmartArt graphic's parts. + pub fn next_diagram(&mut self) -> u32 { + self.n_diagram += 1; + self.n_diagram + } + /// A new `ppt/tags/tagN.xml` part holding `tags`, related from `o`. + pub fn tags_rel(&mut self, o: &mut Out, tags: &Tags) -> String { + self.n_tags += 1; + let name = format!("ppt/tags/tag{}.xml", self.n_tags); + let mut w = Writer::document(); + root_open(&mut w, "p:tagLst", A::new()); + for (n, v) in tags.iter().take(10_000) { + w.tag("p:tag").attr("name", n).attr("val", v).empty(); + } + w.close(); + self.pkg.xml_part(&name, CT_TAGS, w.into_bytes()); + o.rels.add_new("tags", &craft_opc::relative(&o.name, &name), false) + } + /// An unchanged Office 2016 chart's `chartEx` part and its relationship id. + pub fn chartex_rel(&mut self, o: &mut Out, c: &crate::Chart) -> Option { + let (xml, preserved) = chart::chartex_part(c)?; + self.n_chartex += 1; + let name = format!("ppt/charts/chartEx{}.xml", self.n_chartex); + self.pkg.xml_part(&name, CT_CHARTEX, xml); + if preserved { + chart::write_kept(&mut self.pkg, &name, c, &mut self.n_kept); + } else if !c.kept.parts.is_empty() { + log::warn!("pptx: chart data changed; the embedded workbook was not written back"); + } + Some(o.rels.add_new(RT_CHARTEX, &craft_opc::relative(&o.name, &name), false)) + } + + pub fn chart_rel(&mut self, o: &mut Out, c: &crate::Chart) -> String { + self.n_chart += 1; + let name = format!("ppt/charts/chart{}.xml", self.n_chart); + let (xml, preserved) = chart::chart_xml(c); + self.pkg.xml_part(&name, CT_CHART, xml); + if preserved { + chart::write_kept(&mut self.pkg, &name, c, &mut self.n_kept); + } else if !c.kept.parts.is_empty() { + log::warn!("pptx: chart data changed; the embedded workbook was not written back"); + } + o.rels.add_new("chart", &craft_opc::relative(&o.name, &name), false) + } +} + +/// `p:custDataLst` pointing at a tags part, when there are tags. +pub fn cust_data(w: &mut Writer, x: &mut Exp, o: &mut Out, tags: &Tags) { + if tags.is_empty() { + return; + } + let id = x.tags_rel(o, tags); + w.open("p:custDataLst", &[]); + w.tag("p:tags").attr("r:id", id).empty(); + w.close(); +} + +fn root_open(w: &mut Writer, tag: &str, extra: A) { + w.tag(tag).attr("xmlns:a", NS_A).attr("xmlns:r", NS_R).attr("xmlns:p", NS_P).list(extra).open(); +} + +fn background(w: &mut Writer, x: &mut Exp, o: &mut Out, bg: &Background) { + w.open("p:bg", &[]); + match bg { + Background::Fill { fill } => { + w.open("p:bgPr", &[]); + match fill { + crate::Fill::Background | crate::Fill::Group => w.empty("a:noFill", &[]), + f => dml::fill(w, &mut Snk(x, o), f), + } + w.empty("a:effectLst", &[]); + w.close(); + } + Background::Ref { idx, color } => { + let idx = if (1..=3).contains(idx) || (1001..=1003).contains(idx) { *idx } else { 1001 }; + w.tag("p:bgRef").attr("idx", idx).open(); + dml::color(w, color); + w.close(); + } + } + w.close(); +} + +type Tags = [(String, String)]; + +fn csld(w: &mut Writer, x: &mut Exp, o: &mut Out, name: &str, bg: Option<&Background>, list: &[Shape], tags: &Tags) -> shapes::IdMap { + w.tag("p:cSld").attr_opt("name", (!name.is_empty()).then_some(name)).open(); + if let Some(bg) = bg { + background(w, x, o, bg); + } + w.open("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(); + cust_data(w, x, o, tags); + w.close(); + 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 Writer, map: &[(String, String)]) { + w.tag("p:clrMap").list(clr_map_attrs(map)).empty(); +} + +/// `p:clrMapOvr`: the layout's or slide's own colour map, else `a:masterClrMapping`. +fn clr_map_ovr(w: &mut Writer, map: Option<&[(String, String)]>) { + w.open("p:clrMapOvr", &[]); + match map { + Some(m) => w.tag("a:overrideClrMapping").list(clr_map_attrs(m)).empty(), + None => w.empty("a:masterClrMapping", &[]), + } + w.close(); +} + +/// 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| craft_drawingml::color::SchemeSlot::from_xml(v).is_some_and(|s| craft_drawingml::color::SchemeSlot::THEME.contains(&s))) + .unwrap_or(d); + a = a.a(k, v); + } + a +} + +fn ext_list(w: &mut Writer, 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::package::parse(format!("{raw}").as_bytes()) else { return }; + let exts: Vec = d.root.elements().filter(|e| e.is(Local("ext")) && e.attr_local("uri").is_some()).map(|e| e.to_xml()).collect(); + if exts.is_empty() { + return; + } + w.open("p:extLst", &[]); + for e in exts { + w.raw(&e); + } + w.close(); +} + +/// Strip an `` wrapper (masters/layouts keep the whole element). +fn ext_children(raw: Option<&str>) -> Option { + let raw = raw?; + let d = crate::package::parse(raw.as_bytes()).ok()?; + if d.root.is(Local("extLst")) { + let ns = d.ns_decls(); + return Some(d.root.elements().map(|e| xmlx::keep_xml(e, &ns)).collect()); + } + Some(raw.to_string()) +} + +fn notes_master_shapes(size: crate::Size) -> Vec { + 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(crate::Fill::None); + img.line = Some(crate::Line::solid(crate::ColorRef::rgb(craft_drawingml::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), + ] +} + +/// What the notes master should be on save. A master we read is written back, including its +/// header, footer, date and slide-number text. A deck that has never had one gets the placeholders +/// we generate. +struct NotesMasterWrite { + shapes: Vec, + background: Option, + color_map: Vec<(String, String)>, + style: ListStyle, + theme: Option, +} + +fn notes_master_write(p: &Presentation) -> Option { + let nm = p.notes_master.as_deref(); + let kept = nm.filter(|m| !m.shapes.is_empty()); + let has_notes = p.slides.iter().any(|s| !s.notes.is_empty()); + if !has_notes && nm.is_none() { + return None; + } + let default_bg = || Background::Ref { idx: 1001, color: crate::ColorRef::scheme(craft_drawingml::color::SchemeSlot::Bg1) }; + Some(match kept { + Some(m) => NotesMasterWrite { + shapes: m.shapes.clone(), + background: Some(m.background.clone().unwrap_or_else(default_bg)), + color_map: m.color_map.clone(), + style: if m.body_style.is_empty() { notes_style() } else { m.body_style.clone() }, + theme: Some(m.theme.clone()), + }, + // A master without placeholders of its own gets PowerPoint's defaults, keeping its + // background and colour map. + None => NotesMasterWrite { + shapes: notes_master_shapes(p.notes_size), + background: Some(nm.and_then(|m| m.background.clone()).unwrap_or_else(default_bg)), + color_map: nm.map(|m| m.color_map.clone()).unwrap_or_default(), + style: notes_style(), + theme: None, + }, + }) +} + +fn notes_style() -> ListStyle { + let mut ls = crate::text::ListStyle::default(); + for i in 0..9u8 { + ls.set( + i, + crate::text::LevelStyle { + para: crate::text::ParaProps { margin_left: Some(36.0 * i as f64), align: Some(crate::text::Align::Left), ..Default::default() }, + run: crate::text::RunProps { + size: Some(12.0), + font: Some("+mn-lt".into()), + fill: Some(crate::Fill::solid(crate::ColorRef::scheme(craft_drawingml::color::SchemeSlot::Tx1))), + ..Default::default() + }, + }, + ); + } + ls +} + +/// A plausible xsd:dateTime (`YYYY-MM-DD[THH:MM:SS(.frac)?(Z|±HH:MM)?]`): the ranges are +/// checked, so a model value like `2025-13-45T99:99` falls back to the default instead of +/// reaching a required `dt`/`created` attribute. +fn valid_date(s: &str) -> bool { + let b = s.as_bytes(); + let n = |r: std::ops::Range| s.get(r).and_then(|v| v.parse::().ok()); + let (Some(_), Some(mo), Some(d)) = (n(0..4), n(5..7), n(8..10)) else { return false }; + if b.get(4) != Some(&b'-') || b.get(7) != Some(&b'-') || !(1..=12).contains(&mo) || !(1..=31).contains(&d) { + return false; + } + match b.get(10) { + None => true, + Some(b'T') => match (n(11..13), n(14..16), n(17..19)) { + (Some(h), Some(mi), Some(sec)) => h <= 23 && mi <= 59 && sec <= 60 && b.get(13) == Some(&b':') && b.get(16) == Some(&b':'), + _ => false, + }, + Some(_) => false, + } +} + +/// A copy of the presentation with every slide pointing at a layout that exists. +fn sanitized<'p>(p: &'p Presentation, defaults: &dyn Defaults) -> std::borrow::Cow<'p, Presentation> { + let mut need = p.masters.is_empty() || p.masters.iter().any(|m| m.layouts.is_empty()); + let layouts: std::collections::HashSet<_> = p.masters.iter().flat_map(|m| m.layouts.iter().map(|l| l.id)).collect(); + need |= p.slides.iter().any(|s| !layouts.contains(&s.layout)); + if !need { + return std::borrow::Cow::Borrowed(p); + } + let mut q = p.clone(); + if q.masters.is_empty() { + let (m, next) = defaults.master(q.slide_size, 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() { + // An exhausted id space exports without the synthesized layout rather than with a + // duplicated one. + let Some(raw) = q.next_id.max(1).checked_add(1) else { continue }; + let id = LayoutId(raw); + q.next_id = raw; + Arc::make_mut(m).layouts.push(Layout { + id, + name: "Blank".into(), + kind: crate::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 = 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, defaults: &dyn Defaults, main_ct: &str) -> Result, Error> { + let p = sanitized(p, defaults); + let p: &Presentation = &p; + let mut x = Exp { + p, + defaults, + pkg: PackageOut::default(), + media_parts: HashMap::new(), + media_at: { + let mut m = HashMap::new(); + for (i, item) in p.media.iter().enumerate() { + m.entry(item.id).or_insert(i); + } + m + }, + n_image: 0, + n_media: 0, + n_chart: 0, + n_diagram: 0, + n_tags: 0, + n_chartex: 0, + n_kept: 0, + slide_parts: HashMap::new(), + table_styles: vec![], + table_style_set: Default::default(), + 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 = 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", &craft_opc::relative(&mname, &lname), false); + lrids.push(rid); + let mut lo = Out::new(&lname); + lo.rels.add("slideMaster", &craft_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", &craft_opc::relative(&mname, &tname), false); + let mut to = Out::new(&tname); + let txml = craft_drawingml::theme::theme_xml(&mut Snk(&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", &craft_opc::relative("ppt/presentation.xml", &mname), false))); + } + + // Notes master when a slide has notes, or the file already has one (its header and footer + // live on the master, not on the notes slides). + let notes_master = "ppt/notesMasters/notesMaster1.xml"; + let mut notes_master_rid = None; + if let Some(nm) = notes_master_write(p) { + let tname = format!("ppt/theme/theme{}.xml", p.masters.len() + 1); + let mut no = Out::new(notes_master); + no.rels.add("theme", &craft_opc::relative(notes_master, &tname), false); + let theme = nm.theme.or_else(|| p.masters.first().map(|m| m.theme.clone())).unwrap_or_default(); + let mut to = Out::new(&tname); + let txml = craft_drawingml::theme::theme_xml(&mut Snk(&mut x, &mut to), &theme); + x.pkg.xml_part(&tname, CT_THEME, txml); + x.pkg.rels(&tname, &to.rels); + let mut w = Writer::document(); + root_open(&mut w, "p:notesMaster", A::new()); + csld(&mut w, &mut x, &mut no, "", nm.background.as_ref(), &nm.shapes, &[]); + clr_map(&mut w, &nm.color_map); + dml::list_style(&mut w, &mut Snk(&mut x, &mut no), "p:notesStyle", &nm.style); + w.close(); + x.pkg.xml_part(notes_master, CT_NOTES_MASTER, w.into_bytes()); + x.pkg.rels(notes_master, &no.rels); + notes_master_rid = Some(pres.rels.add("notesMaster", &craft_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", &craft_opc::relative(handout_path, &tname), false); + let mut to = Out::new(&tname); + let txml = craft_drawingml::theme::theme_xml(&mut Snk(&mut x, &mut to), &hm.theme); + x.pkg.xml_part(&tname, CT_THEME, txml); + x.pkg.rels(&tname, &to.rels); + let mut w = Writer::document(); + 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(); + x.pkg.xml_part(handout_path, CT_HANDOUT_MASTER, w.into_bytes()); + x.pkg.rels(handout_path, &ho.rels); + handout_rid = Some(pres.rels.add("handoutMaster", &craft_opc::relative("ppt/presentation.xml", handout_path), false)); + } + + // 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", &craft_opc::relative(&sname, lp), false); + } + 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", &craft_opc::relative("ppt/presentation.xml", &sname), 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(p)); + let ts = craft_drawingml::table::styles::table_styles_xml(&x.table_styles, &p.table_styles, defaults.application()); + 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", main_ct, pxml); + // Kept parts never replace a part the writer makes. + let kept: Vec<_> = p + .kept_parts + .iter() + .filter(|k| { + let n = k.name.to_ascii_lowercase(); + !n.is_empty() + && !n.contains("..") + && !n.starts_with("ppt/") + && !n.starts_with("docprops/core") + && !n.starts_with("docprops/app") + && !n.starts_with("[content_types]") + && n != "_rels/.rels" + }) + .collect(); + for k in kept.iter().filter(|k| !k.rel_type.is_empty() && !k.from_root) { + pres.rels.add_new(&k.rel_type, &craft_opc::relative("ppt/presentation.xml", &k.name), false); + } + x.pkg.rels("ppt/presentation.xml", &pres.rels); + for k in &kept { + if k.content_type.is_empty() { + x.pkg.raw(&k.name, k.xml.clone().into_bytes()); + } else { + x.pkg.xml_part(&k.name, &k.content_type, k.xml.clone().into_bytes()); + } + } + + // 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, defaults.application())); + let mut root = RelsOut::default(); + root.add("officeDocument", "ppt/presentation.xml", false); + root.add(RT_CORE, "docProps/core.xml", false); + root.add("extended-properties", "docProps/app.xml", false); + for k in kept.iter().filter(|k| !k.rel_type.is_empty() && k.from_root) { + root.add_new(&k.rel_type, &k.name, false); + } + x.pkg.rels("", &root); + x.pkg.finish().map_err(|e| Error::Write(e.to_string())) +} + +fn layout_xml(x: &mut Exp, o: &mut Out, l: &Layout) -> Vec { + let mut w = Writer::document(); + 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(); + w.into_bytes() +} + +fn master_xml(x: &mut Exp, o: &mut Out, m: &Master, layouts: &[(u64, String)]) -> Vec { + let mut w = Writer::document(); + 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.open("p:sldLayoutIdLst", &[]); + for (id, rid) in layouts { + w.tag("p:sldLayoutId").attr("id", id).attr("r:id", rid).empty(); + } + w.close(); + w.open("p:txStyles", &[]); + let (dt, db, dother) = x.defaults.master_styles(); + let pick = |s: &crate::text::ListStyle, d: crate::text::ListStyle| if s.is_empty() { d } else { s.clone() }; + dml::list_style(&mut w, &mut Snk(x, o), "p:titleStyle", &pick(&m.title_style, dt)); + dml::list_style(&mut w, &mut Snk(x, o), "p:bodyStyle", &pick(&m.body_style, db)); + dml::list_style(&mut w, &mut Snk(x, o), "p:otherStyle", &pick(&m.other_style, dother)); + w.close(); + ext_list(&mut w, ext_children(m.raw_ext.as_deref()).as_deref()); + w.close(); + w.into_bytes() +} + +fn slide_xml(x: &mut Exp, o: &mut Out, s: &Slide) -> Vec { + let mut w = Writer::document(); + root_open(&mut w, "p:sld", A::new().o("showMasterSp", (!s.show_master_shapes).then_some("0")).o("show", s.hidden.then_some("0"))); + csld(&mut w, x, o, &s.name, s.background.as_ref(), &s.shapes, &s.tags); + clr_map_ovr(&mut w, s.color_map.as_deref()); + ext_list(&mut w, s.raw_ext.as_deref()); + w.close(); + w.into_bytes() +} + +/// Sections with every slide in exactly one section, in slide order. +fn section_slides(p: &Presentation) -> Vec<(String, Vec)> { + let mut out: Vec<(String, Vec)> = p.sections.iter().map(|s| (s.name.clone(), vec![])).collect(); + if out.is_empty() { + return out; + } + // Each slide's first section, found once: a section may name a slide many times. + let mut first: HashMap = HashMap::new(); + for (k, sec) in p.sections.iter().enumerate() { + for id in &sec.slides { + first.entry(*id).or_insert(k); + } + } + let mut cur = 0usize; + for (i, s) in p.slides.iter().enumerate() { + if let Some(&k) = first.get(&s.id) + && k >= cur + { + cur = k; + } + if let Some(sec) = out.get_mut(cur) { + sec.1.push(i); + } + } + out +} + +/// One embedded typeface, with the relationship id of each face that had bytes. +struct WrittenFont { + typeface: String, + panose: Option, + pitch_family: Option, + charset: Option, + regular: Option, + bold: Option, + italic: Option, + bold_italic: Option, +} + +impl WrittenFont { + fn any(&self) -> bool { + self.regular.is_some() || self.bold.is_some() || self.italic.is_some() || self.bold_italic.is_some() + } +} + +/// Font parts for `p:embeddedFontLst`. One part per distinct media item, named `ppt/fonts/fontN.fntdata`. +fn write_embedded_fonts(x: &mut Exp, o: &mut Out) -> Vec { + struct Plan { + typeface: String, + panose: Option, + pitch_family: Option, + charset: Option, + regular: Option, + bold: Option, + italic: Option, + bold_italic: Option, + } + let mut plans = Vec::new(); + for font in x.p.embedded_fonts.iter().take(64) { + let typeface = font.typeface.trim(); + if typeface.is_empty() { + continue; + } + plans.push(Plan { + typeface: typeface.chars().take(255).collect(), + panose: font.panose.clone().filter(|s| s.len() == 20 && s.chars().all(|c| c.is_ascii_hexdigit())), + pitch_family: font.pitch_family, + charset: font.charset, + regular: font.regular, + bold: font.bold, + italic: font.italic, + bold_italic: font.bold_italic, + }); + } + let mut seen_part: HashMap = HashMap::new(); + let mut n = 0u32; + let mut rel = |x: &mut Exp, o: &mut Out, id: Option| -> Option { + let id = id?; + if let Some(rid) = seen_part.get(&id) { + return Some(rid.clone()); + } + let m = x.media(id)?; + if m.data.is_empty() || m.data.len() > 32 * 1024 * 1024 { + return None; + } + let bytes = m.data.as_ref().clone(); + n = n.saturating_add(1); + let name = format!("ppt/fonts/font{n}.fntdata"); + x.pkg.binary(&name, "application/x-fontdata", bytes); + let rid = o.rels.add("font", &craft_opc::relative("ppt/presentation.xml", &name), false); + seen_part.insert(id, rid.clone()); + Some(rid) + }; + let mut seen_face = Vec::new(); + let mut out = Vec::new(); + for font in plans { + let written = WrittenFont { + typeface: font.typeface, + panose: font.panose, + pitch_family: font.pitch_family, + charset: font.charset, + regular: rel(x, o, font.regular), + bold: rel(x, o, font.bold), + italic: rel(x, o, font.italic), + bold_italic: rel(x, o, font.bold_italic), + }; + if written.any() && !seen_face.iter().any(|s| s == &written.typeface) { + seen_face.push(written.typeface.clone()); + out.push(written); + } + } + out +} + +fn presentation_xml(x: &mut Exp, o: &mut Out, masters: &[(u64, String)], notes: Option<&str>, handout: Option<&str>, slides: &[String]) -> Vec { + let fonts = write_embedded_fonts(x, o); + let p = x.p; + let mut w = Writer::document(); + 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).t("embedTrueTypeFonts", !fonts.is_empty()).o("firstSlideNum", first)); + w.open("p:sldMasterIdLst", &[]); + for (id, rid) in masters { + w.tag("p:sldMasterId").attr("id", id).attr("r:id", rid).empty(); + } + w.close(); + if let Some(n) = notes { + w.open("p:notesMasterIdLst", &[]); + w.tag("p:notesMasterId").attr("r:id", n).empty(); + w.close(); + } + if let Some(h) = handout { + w.open("p:handoutMasterIdLst", &[]); + w.tag("p:handoutMasterId").attr("r:id", h).empty(); + w.close(); + } + if !slides.is_empty() { + w.open("p:sldIdLst", &[]); + for (i, rid) in slides.iter().enumerate() { + w.tag("p:sldId").attr("id", 256 + i).attr("r:id", rid).empty(); + } + w.close(); + } + let clamp = |v: f64| dml::emu(v).clamp(914_400, 51_206_400); + w.tag("p:sldSz").attr("cx", clamp(p.slide_size.width)).attr("cy", clamp(p.slide_size.height)).empty(); + w.tag("p:notesSz").attr("cx", dml::emu_pos(p.notes_size.width).max(1)).attr("cy", dml::emu_pos(p.notes_size.height).max(1)).empty(); + if !fonts.is_empty() { + w.open("p:embeddedFontLst", &[]); + for f in &fonts { + w.open("p:embeddedFont", &[]); + w.tag("p:font") + .attr("typeface", &f.typeface) + .attr_opt("panose", f.panose.clone()) + .attr_opt("pitchFamily", f.pitch_family) + .attr_opt("charset", f.charset) + .empty(); + for (tag, rid) in [("p:regular", &f.regular), ("p:bold", &f.bold), ("p:italic", &f.italic), ("p:boldItalic", &f.bold_italic)] { + if let Some(rid) = rid { + w.tag(tag).attr("r:id", rid).empty(); + } + } + w.close(); + } + w.close(); + } + if !p.custom_shows.is_empty() { + w.open("p:custShowLst", &[]); + for (i, cs) in p.custom_shows.iter().enumerate() { + w.tag("p:custShow").attr("name", &cs.name).attr("id", i).open(); + w.open("p:sldLst", &[]); + for sid in &cs.slides { + if let Some(part) = x.slide_parts.get(sid).cloned() { + let rid = o.rels.add("slide", &craft_opc::relative("ppt/presentation.xml", &part), false); + w.tag("p:sld").attr("r:id", rid).empty(); + } + } + w.close(); + w.close(); + } + w.close(); + } + cust_data(&mut w, x, o, &p.tags); + let dts = if p.default_text_style.is_empty() { x.defaults.default_text_style() } else { p.default_text_style.clone() }; + dml::list_style(&mut w, &mut Snk(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 = Writer::new(); + if !sections.is_empty() { + inner.tag("p:ext").attr("uri", crate::SECTION_EXT_URI).open(); + inner.tag("p14:sectionLst").attr("xmlns:p14", NS_P14).open(); + for (i, (name, list)) in sections.iter().enumerate() { + inner.tag("p14:section").attr("name", name).attr("id", craft_drawingml::table::styles::guid(&format!("section:{i}:{name}"))).open(); + inner.open("p14:sldIdLst", &[]); + for si in list { + inner.tag("p14:sldId").attr("id", 256 + si).empty(); + } + inner.close(); + inner.close(); + } + inner.close(); + inner.close(); + } + if let Some(r) = raw { + inner.raw(r); + } + ext_list(&mut w, Some(inner.as_str())); + } + w.close(); + w.into_bytes() +} + +fn pres_props_xml(p: &Presentation) -> Vec { + let s = &p.show; + let mut w = Writer::document(); + root_open(&mut w, "p:presentationPr", A::new()); + w.tag("p:showPr") + .attr_opt("loop", (s.loop_until_esc).then_some(1)) + .attr("showNarration", if s.without_narration { 0 } else { 1 }) + .attr_opt("showAnimation", s.without_animation.then_some("0")) + .attr_opt("useTimings", (!s.use_timings).then_some("0")) + .open(); + match s.show_type.as_str() { + "browsed" => w.tag("p:browse").empty(), + "kiosk" => w.tag("p:kiosk").empty(), + _ => w.empty("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.tag("p:custShow").attr("id", i).empty(), + (None, Some((a, b))) => w.tag("p:sldRg").attr("st", a.max(1)).attr("end", b.max(a).max(1)).empty(), + _ => w.empty("p:sldAll", &[]), + } + w.open("p:penClr", &[]); + w.tag("a:srgbClr").attr("val", s.pen_color.hex()).empty(); + w.close(); + if !s.show_media_controls { + // "Show media controls" is PowerPoint's p14 extension; absent means shown. + w.open("p:extLst", &[]); + w.tag("p:ext").attr("uri", "{2FDB2607-1784-4EEB-B798-7EB5836EED8A}").open(); + w.tag("p14:showMediaCtrls").attr("xmlns:p14", NS_P14).attr("val", 0).empty(); + w.close(); + w.close(); + } + w.close(); + w.close(); + w.into_bytes() +} + +fn view_props_xml(p: &Presentation) -> Vec { + let mut w = Writer::document(); + root_open(&mut w, "p:viewPr", A::new()); + w.open("p:normalViewPr", &[]); + w.tag("p:restoredLeft").attr("sz", 15620).empty(); + w.tag("p:restoredTop").attr("sz", 94660).empty(); + w.close(); + if !p.guides.is_empty() { + // Drawing guides; `pos` is in eighths of a point (576 per inch). + w.open("p:slideViewPr", &[]); + w.open("p:cSldViewPr", &[]); + w.open("p:cViewPr", &[]); + w.open("p:scale", &[]); + w.tag("a:sx").attr("n", 100).attr("d", 100).empty(); + w.tag("a:sy").attr("n", 100).attr("d", 100).empty(); + w.close(); + w.tag("p:origin").attr("x", 0).attr("y", 0).empty(); + w.close(); + w.open("p:guideLst", &[]); + for g in p.guides.iter().take(1000) { + let pos = (g.pos * 8.0).round().clamp(-1.0e9, 1.0e9) as i64; + let a = if g.vertical { A::new().a("pos", pos) } else { A::new().a("orient", "horz").a("pos", pos) }; + w.tag("p:guide").list(a).empty(); + } + w.close(); + w.close(); + w.close(); + } + w.tag("p:gridSpacing").attr("cx", 76200).attr("cy", 76200).empty(); + w.close(); + w.into_bytes() +} + +fn core_xml(p: &Presentation) -> Vec { + let pr = &p.props; + let mut w = Writer::document(); + w.tag("cp:coreProperties") + .attr("xmlns:cp", "http://schemas.openxmlformats.org/package/2006/metadata/core-properties") + .attr("xmlns:dc", "http://purl.org/dc/elements/1.1/") + .attr("xmlns:dcterms", "http://purl.org/dc/terms/") + .attr("xmlns:dcmitype", "http://purl.org/dc/dcmitype/") + .attr("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance") + .open(); + 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.leaf(tag, &[], v); + } + } + if pr.revision > 0 { + w.leaf("cp:revision", &[], &pr.revision.to_string()); + } + for (tag, v) in [("dcterms:created", &pr.created), ("dcterms:modified", &pr.modified)] { + if valid_date(v) { + w.tag(tag).attr("xsi:type", "dcterms:W3CDTF").open(); + w.text(v); + w.close(); + } + } + w.close(); + w.into_bytes() +} + +fn app_xml(p: &Presentation, application: &str) -> Vec { + let mut w = Writer::document(); + w.tag("Properties") + .attr("xmlns", "http://schemas.openxmlformats.org/officeDocument/2006/extended-properties") + .attr("xmlns:vt", "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes") + .open(); + w.leaf("Application", &[], application); + w.leaf("Slides", &[], &p.slides.len().to_string()); + w.leaf("Notes", &[], &p.slides.iter().filter(|s| !s.notes.is_empty()).count().to_string()); + w.leaf("HiddenSlides", &[], &p.slides.iter().filter(|s| s.hidden).count().to_string()); + if !p.props.company.is_empty() { + w.leaf("Company", &[], &p.props.company); + } + w.close(); + w.into_bytes() +} diff --git a/crates/pptx/src/write/chart.rs b/crates/pptx/src/write/chart.rs new file mode 100644 index 000000000..b16b56c91 --- /dev/null +++ b/crates/pptx/src/write/chart.rs @@ -0,0 +1,93 @@ +//! Charts: the parts an unchanged chart carried (workbook, styles, colours), written back under +//! fresh names. + +use craft_drawingml::chart::write::sidecar_part; +pub(crate) use craft_drawingml::chart::write::{chart_xml, chartex_part}; + +use crate::chart::Chart; + +/// A fresh name for a kept part: `ppt//keptN.`. The writer generates no `kept…` +/// names and `n` is unique within the package, so a kept part never lands on (or duplicates) a +/// part the writer makes, whatever the source file called it. +fn kept_name(original: &str, n: u32) -> String { + let norm = craft_opc::normalize(original); + let area = norm.split('/').nth(1).unwrap_or("embeddings"); + let area = if matches!(area, "embeddings" | "charts" | "drawings" | "media") { area } else { "embeddings" }; + let file = craft_opc::file_of(&norm); + let ext = file + .rsplit_once('.') + .map(|(_, e)| e) + .filter(|e| !e.is_empty() && e.len() <= 8 && e.chars().all(|c| c.is_ascii_alphanumeric())) + .unwrap_or("bin"); + format!("ppt/{area}/kept{n}.{ext}") +} + +/// Write an unchanged chart's own relationships and the parts they reach. Each kept part is +/// written under a fresh name ([`kept_name`]) and the relationships between kept parts are +/// rewritten to those names; a relationship to anything that was not kept is dropped. +/// `n_kept` numbers kept parts across the package. +pub fn write_kept(pkg: &mut craft_opc::PackageOut, chart_name: &str, c: &Chart, n_kept: &mut u32) { + use std::collections::HashMap; + + use craft_opc::{RelOut, RelsOut, normalize as norm, relative, rels_path}; + + if c.kept.rels.is_empty() { + return; + } + // Original part name (normalized) → the name written. + let mut names: HashMap = HashMap::new(); + for p in c.kept.parts.iter().filter(|p| kept_payload(&p.name)) { + let key = norm(&p.name); + if names.contains_key(&key) { + continue; + } + *n_kept += 1; + let new = kept_name(&p.name, *n_kept); + let ct = if !p.content_type.is_empty() { p.content_type.clone() } else { crate::mime_for(&p.name).to_string() }; + // An override per part: a kept part never sets the package-wide default for its extension. + pkg.xml_part(&new, &ct, p.bytes.clone()); + names.insert(key, new); + } + let mut rels = RelsOut::default(); + for r in &c.kept.rels { + if r.external { + rels.items.push(RelOut { id: r.id.clone(), ty: r.rel_type.clone(), target: r.target.clone(), external: true }); + } else if let Some(new) = names.get(&norm(&r.part)) { + rels.items.push(RelOut { id: r.id.clone(), ty: r.rel_type.clone(), target: relative(chart_name, new), external: false }); + } else { + log::warn!("pptx: chart relationship {} has no kept part; dropped", r.id); + } + } + pkg.rels(chart_name, &rels); + let mut parts_by_norm = std::collections::HashMap::new(); + for p in &c.kept.parts { + parts_by_norm.entry(norm(&p.name)).or_insert(p); + } + let mut seen_payloads = std::collections::HashSet::new(); + // The kept parts' own relationships, pointed at the new names. + for p in c.kept.parts.iter().filter(|p| kept_payload(&p.name)) { + let norm_name = norm(&p.name); + if !seen_payloads.insert(norm_name.clone()) { + continue; + } + let Some(new_source) = names.get(&norm_name) else { continue }; + let rp = norm(&rels_path(&p.name)); + let Some(rels_part) = parts_by_norm.get(&rp) else { continue }; + let mut out = RelsOut::default(); + for r in craft_opc::Rels::parse(&rels_part.bytes, &p.name).list { + if r.external { + out.items.push(RelOut { id: r.id, ty: r.ty, target: r.target_raw, external: true }); + } else if let Some(new) = names.get(&norm(&r.target)) { + out.items.push(RelOut { id: r.id, ty: r.ty, target: relative(new_source, new), external: false }); + } else { + log::warn!("pptx: relationship {} of kept chart part {} has no kept target; dropped", r.id, p.name); + } + } + pkg.rels(new_source, &out); + } +} + +/// A kept part to write itself (relationships parts are rewritten from the relationships). +fn kept_payload(name: &str) -> bool { + !name.to_ascii_lowercase().ends_with(".rels") && sidecar_part(name) +} diff --git a/crates/pptx/src/write/shapes.rs b/crates/pptx/src/write/shapes.rs new file mode 100644 index 000000000..8ee87836f --- /dev/null +++ b/crates/pptx/src/write/shapes.rs @@ -0,0 +1,1051 @@ +//! Shape trees: autoshapes, pictures, groups, connectors, tables, charts, media, ink, 3-D models and kept objects. + +use std::collections::{HashMap, HashSet}; + +use crate::style::Fill; +use crate::table::Table; +use crate::{Geom, PhType, Shape, ShapeId, ShapeKind, ShapeStyle, walk}; +use craft_drawingml::Xfrm; + +use craft_drawingml::write::{self as dml, emu, emu_pos}; +use craft_drawingml::xmlx::{self, A, TagExt}; +use craft_xml::{Local, Writer}; + +use super::{Exp, NS_MC, Out, Snk}; + +/// cNvPr ids for one part. +pub struct IdMap { + pub map: HashMap, + 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 { + 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, +} + +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 Writer, g: &Geom) { + match g { + Geom::Preset { name, adj } => { + let name = preset_name(name); + w.tag("a:prstGeom").attr("prst", name).open(); + if adj.is_empty() { + w.empty("a:avLst", &[]); + } else { + // PowerPoint wants the complete guide list once any is given. + let defaults = craft_drawingml::preset::info(name).map(|i| i.defaults).unwrap_or(&[]); + let mut vals: Vec = adj.iter().take(16).copied().collect(); + while vals.len() < defaults.len() { + vals.push(defaults.get(vals.len()).copied().unwrap_or(0.0)); + } + w.open("a:avLst", &[]); + // A lone guide is `adj`, except in these presets, which name it `adj1` (as PowerPoint + // writes them); PowerPoint won't open a file that renames it. + let adj1 = matches!(name, "bentConnector3" | "curvedConnector3" | "frame" | "mathPlus" | "mathMinus" | "mathMultiply"); + let single = !adj1 && 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.tag("a:gd").attr("name", n).attr("fmla", format!("val {v}")).empty(); + } + for (n, v) in extra_guides(name) { + w.tag("a:gd").attr("name", *n).attr("fmla", format!("val {v}")).empty(); + } + w.close(); + } + w.close(); + } + Geom::Custom { paths } => { + if paths.is_empty() { + w.tag("a:prstGeom").attr("prst", "rect").open(); + w.empty("a:avLst", &[]); + w.close(); + } else { + craft_drawingml::custgeom::write_cust_geom(w, paths); + } + } + } +} + +fn cnvpr(w: &mut Writer, 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.tag("p:cNvPr").list(a).empty(); + return; + } + w.tag("p:cNvPr").list(a).open(); + if let Some(h) = &s.click { + dml::hyperlink(w, &mut Snk(x, o), "a:hlinkClick", h); + } + if let Some(h) = &s.hover { + dml::hyperlink(w, &mut Snk(x, o), "a:hlinkHover", h); + } + if s.decorative { + w.open("a:extLst", &[]); + w.tag("a:ext").attr("uri", "{C183D7F6-B498-43B3-948B-1728B52AA6E4}").open(); + w.tag("adec:decorative").attr("xmlns:adec", "http://schemas.microsoft.com/office/drawing/2017/decorative").attr("val", "1").empty(); + w.close(); + w.close(); + } + w.close(); +} + +fn nv_pr(w: &mut Writer, x: &mut Exp, o: &mut Out, s: &Shape) { + if s.ph.is_none() && s.tags.is_empty() { + w.empty("p:nvPr", &[]); + return; + } + w.open("p:nvPr", &[]); + if let Some(ph) = &s.ph { + 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.tag("p:ph") + .attr_opt("type", ty) + .attr_opt("orient", ph.vertical.then_some("vert")) + .attr_opt("sz", sz) + .attr_opt("idx", (ph.idx != 0).then_some(ph.idx)) + .attr_opt("hasCustomPrompt", (ph.has_custom_prompt).then_some(1)) + .empty(); + } + super::cust_data(w, x, o, &s.tags); + w.close(); +} + +/// Locking attributes. Lock writes the full set PowerPoint writes for the element's kind (and +/// reads back as locked): see `read::shapes::is_locked`. +/// `aspect`: also `noChangeAspect` (pictures). +fn locks(w: &mut Writer, tag: &str, s: &Shape, aspect: bool) { + if !aspect && s.ph.is_none() && !s.locked { + return; + } + let a = A::new().t("noChangeAspect", aspect).t("noGrp", s.ph.is_some() || s.locked); + let a = match tag { + "a:grpSpLocks" => a.t("noUngrp", s.locked).t("noRot", s.locked).t("noMove", s.locked).t("noResize", s.locked), + _ => { + let a = a + .t("noRot", s.locked) + .t("noMove", s.locked) + .t("noResize", s.locked) + .t("noEditPoints", s.locked) + .t("noAdjustHandles", s.locked) + .t("noChangeArrowheads", s.locked) + .t("noChangeShapeType", s.locked); + if tag == "a:picLocks" { a.t("noCrop", s.locked) } else { a } + } + }; + w.tag(tag).list(a).empty(); +} + +fn style(w: &mut Writer, st: &ShapeStyle) { + style_tag(w, st, "p:style"); +} + +fn style_tag(w: &mut Writer, st: &ShapeStyle, tag: &str) { + w.open(tag, &[]); + for (tag, (idx, c)) in [("a:lnRef", &st.line_ref), ("a:fillRef", &st.fill_ref), ("a:effectRef", &st.effect_ref)] { + w.tag(tag).attr("idx", idx).open(); + dml::color(w, c); + w.close(); + } + 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.tag("a:fontRef").attr("idx", idx).open(); + dml::color(w, c); + w.close(); + } + None => w.tag("a:fontRef").attr("idx", idx).empty(), + } + w.close(); +} + +/// `p:spPr` contents (also used for pictures and connectors). +fn sp_pr(w: &mut Writer, x: &mut Exp, o: &mut Out, s: &Shape, geom: bool) { + sp_pr_tag(w, x, o, s, geom, "p:spPr"); +} + +fn sp_pr_tag(w: &mut Writer, x: &mut Exp, o: &mut Out, s: &Shape, geom: bool, tag: &str) { + w.open(tag, &[]); + 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, &mut Snk(x, o), f); + } + if let Some(l) = &s.line { + dml::line(w, &mut Snk(x, o), "a:ln", l); + } + if let Some(fx) = &s.effects { + dml::effects(w, fx); + if let Some(raw) = &fx.raw3d { + w.raw(raw); + } else if let Some(xml) = fx.bevel.as_deref().and_then(dml::bevel_sp3d) { + w.raw(&xml); + } + } + w.close(); +} + +/// Write the children of a shape tree. +pub fn shapes(w: &mut Writer, 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 Writer, 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.tag("mc:AlternateContent").attr("xmlns:mc", NS_MC).open(); + w.tag("mc:Choice").attr("xmlns:a14", craft_drawingml::ns::NS_A14).attr("Requires", "a14").open(); + let id = cx.id_for(s); + sp(w, x, o, id, s, true); + w.close(); + w.open("mc:Fallback", &[]); + sp(w, x, o, id, s, false); + w.close(); + w.close(); + } else { + let id = cx.id_for(s); + sp(w, x, o, id, s, false); + } + } + ShapeKind::Picture { fill } => { + let id = cx.id_for(s); + w.open("p:pic", &[]); + w.open("p:nvPicPr", &[]); + cnvpr(w, x, o, id, s); + w.open("p:cNvPicPr", &[]); + locks(w, "a:picLocks", s, true); + w.close(); + nv_pr(w, x, o, s); + w.close(); + dml::blip_fill(w, &mut Snk(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(); + } + ShapeKind::Group { children, child } if s.diagram.is_some() && depth < 32 => { + if let Some(d) = &s.diagram { + diagram(w, x, o, cx, s, d, children, child); + } + } + ShapeKind::Group { children, child } => { + let id = cx.id_for(s); + w.open("p:grpSp", &[]); + w.open("p:nvGrpSpPr", &[]); + cnvpr(w, x, o, id, s); + w.open("p:cNvGrpSpPr", &[]); + locks(w, "a:grpSpLocks", s, false); + w.close(); + nv_pr(w, x, o, s); + w.close(); + w.open("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, &mut Snk(x, o), f); + } + if let Some(fx) = &s.effects { + if fx.fill_overlay.is_some() + || fx.glow.is_some() + || fx.inner_shadow.is_some() + || fx.outer_shadow.is_some() + || fx.reflection.is_some() + || fx.soft_edge.is_some() + { + dml::effects(w, fx); + } + if let Some(raw) = + fx.raw3d.as_deref().map(|s| s.find(" { + let id = cx.id_for(s); + w.open("p:cxnSp", &[]); + w.open("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.empty("p:cNvCxnSpPr", &[]); + } else { + w.open("p:cNvCxnSpPr", &[]); + locks(w, "a:cxnSpLocks", s, false); + if let Some((i, site)) = st { + w.tag("a:stCxn").attr("id", i).attr("idx", site).empty(); + } + if let Some((i, site)) = en { + w.tag("a:endCxn").attr("id", i).attr("idx", site).empty(); + } + w.close(); + } + nv_pr(w, x, o, s); + w.close(); + 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(); + } + ShapeKind::Table(t) => { + let id = cx.id_for(s); + frame_open(w, x, o, id, s); + w.tag("a:graphicData").attr("uri", "http://schemas.openxmlformats.org/drawingml/2006/table").open(); + table(w, x, o, t); + w.close(); + frame_close(w); + } + ShapeKind::Chart(c) => { + let id = cx.id_for(s); + // An unchanged Office 2016 chart goes back as itself, with a plain chart of the same + // data for readers without `cx1`, as PowerPoint writes it. + let ex = x.chartex_rel(o, c); + if let Some(exid) = &ex { + w.tag("mc:AlternateContent").attr("xmlns:mc", "http://schemas.openxmlformats.org/markup-compatibility/2006").open(); + w.tag("mc:Choice").attr("xmlns:cx1", "http://schemas.microsoft.com/office/drawing/2015/9/8/chartex").attr("Requires", "cx1").open(); + frame_open(w, x, o, id, s); + w.tag("a:graphicData").attr("uri", craft_drawingml::chart::write::NS_CX).open(); + w.tag("cx:chart").attr("xmlns:cx", craft_drawingml::chart::write::NS_CX).attr("r:id", exid).empty(); + w.close(); + frame_close(w); + w.close(); + w.open("mc:Fallback", &[]); + } + let mut plain = (**c).clone(); + plain.raw = plain.raw.filter(|r| !r.contains(craft_drawingml::chart::write::NS_CX)); + if ex.is_some() { + // The kept workbook belongs to the `chartEx` part; the fallback has none. + plain.kept = Default::default(); + } + let rid = x.chart_rel(o, &plain); + frame_open(w, x, o, id, s); + w.tag("a:graphicData").attr("uri", "http://schemas.openxmlformats.org/drawingml/2006/chart").open(); + w.tag("c:chart").attr("xmlns:c", craft_drawingml::ns::NS_C).attr("r:id", rid).empty(); + w.close(); + frame_close(w); + if ex.is_some() { + w.close(); + w.close(); + } + } + ShapeKind::Media(m) => { + let id = cx.id_for(s); + media(w, x, o, id, s, m); + } + ShapeKind::Model3d { media, poster } => write_model(w, x, o, cx, s, *media, *poster), + ShapeKind::Ink { strokes } => { + // Ink becomes a group of freeform lines. + let id = cx.id_for(s); + let xf = s.xfrm.unwrap_or_default(); + w.open("p:grpSp", &[]); + w.open("p:nvGrpSpPr", &[]); + cnvpr(w, x, o, id, s); + w.empty("p:cNvGrpSpPr", &[]); + w.empty("p:nvPr", &[]); + w.close(); + w.open("p:grpSpPr", &[]); + dml::xfrm(w, "a:xfrm", &xf, Some(&xf)); + w.close(); + 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(); + } + ShapeKind::Opaque { xml, preview, label, parts } => opaque(w, x, o, cx, s, xml, *preview, label, parts), + } +} + +fn model_rel(x: &mut Exp, o: &mut Out, media: crate::MediaId) -> Option { + if x.media(media).is_none_or(|m| m.data.is_empty()) { + return None; + } + x.media_rel(o, media, super::RT_MODEL3D) +} + +fn write_model_part(w: &mut Writer, x: &mut Exp, o: &mut Out, id: u32, s: &Shape, rid: &str) { + w.tag("p:contentPart").attr("r:id", rid).open(); + w.open("p:nvContentPartPr", &[]); + cnvpr(w, x, o, id, s); + w.empty("p:cNvContentPartPr", &[]); + w.empty("p:nvPr", &[]); + w.close(); + if let Some(xf) = &s.xfrm { + dml::xfrm(w, "p:xfrm", xf, None); + } + w.close(); +} + +fn write_model(w: &mut Writer, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, media: crate::MediaId, poster: Option) { + let Some(rid) = model_rel(x, o, media) else { + log::warn!("pptx: 3-D model '{}' has no embedded part", s.name); + return; + }; + let id = cx.id_for(s); + let poster = poster.filter(|p| x.media(*p).is_some_and(|m| !m.data.is_empty())); + let Some(poster) = poster else { + write_model_part(w, x, o, id, s, &rid); + return; + }; + // Older readers draw the fallback picture; readers that know `am3d` use the model. + w.tag("mc:AlternateContent").attr("xmlns:mc", NS_MC).open(); + w.tag("mc:Choice").attr("xmlns:am3d", "http://schemas.microsoft.com/office/drawing/2017/model3d").attr("Requires", "am3d").open(); + write_model_part(w, x, o, id, s, &rid); + w.close(); + w.open("mc:Fallback", &[]); + let pic = Shape { + id: ShapeId(0), + name: s.name.clone(), + xfrm: s.xfrm, + kind: ShapeKind::Picture { fill: crate::style::PictureFill { media: poster, ..Default::default() } }, + ..Default::default() + }; + let pid = cx.ids.fresh(); + shape_with_id(w, x, o, cx, &pic, pid); + w.close(); + w.close(); +} + +/// A SmartArt graphic: a graphic frame naming its data, layout, quick style and colour parts, +/// with the group's shapes as the cached drawing PowerPoint shows until it lays the diagram out. +#[allow(clippy::too_many_arguments)] +fn diagram(w: &mut Writer, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, d: &crate::Diagram, children: &[Shape], child: &Xfrm) { + use craft_drawingml::diagram::parts as dgm; + let id = cx.id_for(s); + let n = x.next_diagram(); + let frame = s.xfrm.unwrap_or(*child); + let name = |kind: &str| format!("ppt/diagrams/{kind}{n}.xml"); + // The drawing: the group's shapes in the frame's own coordinates. + let dname = name("drawing"); + let mut dout = Out::new(&dname); + let mut dw = Writer::document(); + dw.tag("dsp:drawing") + .attr("xmlns:dgm", dgm::NS_DGM) + .attr("xmlns:dsp", dgm::NS_DSP) + .attr("xmlns:a", super::NS_A) + .attr("xmlns:r", super::NS_R) + .open(); + dw.open("dsp:spTree", &[]); + dw.open("dsp:nvGrpSpPr", &[]); + dw.tag("dsp:cNvPr").attr("id", 0).attr("name", "").empty(); + dw.empty("dsp:cNvGrpSpPr", &[]); + dw.close(); + dw.empty("dsp:grpSpPr", &[]); + let (sx, sy) = (frame.w / child.w.max(1e-6), frame.h / child.h.max(1e-6)); + let mut k = 0; + let mut flat = vec![]; + walk(children, &mut |c, _| match &c.kind { + ShapeKind::Shape => flat.push(c.clone()), + ShapeKind::Connector { .. } => { + let mut sc = c.clone(); + if sc.geom.preset_name() == Some("rect") { + sc.geom = Geom::preset("line"); + } + flat.push(sc); + } + ShapeKind::Picture { fill } => { + let mut sc = c.clone(); + sc.kind = ShapeKind::Shape; + sc.fill = Some(crate::style::Fill::Picture(fill.clone())); + flat.push(sc); + } + _ => {} + }); + for mut c in flat.into_iter().take(10_000) { + if let Some(xf) = &mut c.xfrm { + *xf = Xfrm { x: (xf.x - child.x) * sx, y: (xf.y - child.y) * sy, w: xf.w * sx, h: xf.h * sy, ..*xf }; + } + k += 1; + dw.tag("dsp:sp").attr("modelId", format!("{{{:08X}-5D1A-4C0A-9E5B-{k:012X}}}", s.id.0)).open(); + dw.open("dsp:nvSpPr", &[]); + dw.tag("dsp:cNvPr").attr("id", 0).attr("name", "").empty(); + dw.empty("dsp:cNvSpPr", &[]); + dw.close(); + sp_pr_tag(&mut dw, x, &mut dout, &c, true, "dsp:spPr"); + if let Some(st) = &c.style { + style_tag(&mut dw, st, "dsp:style"); + } + if let Some(tb) = &c.text { + dml::text_body(&mut dw, &mut Snk(x, &mut dout), "dsp:txBody", tb, false); + } + dw.close(); + } + dw.close(); + dw.close(); + x.pkg.xml_part(&dname, dgm::CT_DRAWING, dw.into_bytes()); + x.pkg.rels(&dname, &dout.rels); + let rel = |o: &mut Out, kind: &str, part: &str| o.rels.add_new(kind, &craft_opc::relative(&o.name, part), false); + let drawing_rel = rel(o, dgm::RT_DRAWING, &dname); + let data = name("data"); + // Node pictures: on the data model's presentation points, through the data part's rels. + let mut mout = Out::new(&data); + let pics: Vec<(usize, String)> = + d.nodes.iter().enumerate().filter_map(|(k, n)| n.picture.and_then(|m| x.media_rel(&mut mout, m, "image")).map(|r| (k, r))).collect(); + x.pkg.xml_part(&data, dgm::CT_DATA, dgm::data_xml(d, s.id.0, &drawing_rel, &pics)); + x.pkg.rels(&data, &mout.rels); + let dm = rel(o, "diagramData", &data); + let layout = name("layout"); + let lo_xml = d.layout_xml.clone().map(String::into_bytes).unwrap_or_else(|| dgm::layout_xml(&d.kind)); + x.pkg.xml_part(&layout, dgm::CT_LAYOUT, lo_xml); + let lo = rel(o, "diagramLayout", &layout); + let style = name("quickStyle"); + x.pkg.xml_part(&style, dgm::CT_STYLE, d.style_xml.clone().map(String::into_bytes).unwrap_or_else(|| dgm::style_xml(&d.style))); + let qs = rel(o, "diagramQuickStyle", &style); + let colors = name("colors"); + x.pkg.xml_part(&colors, dgm::CT_COLORS, d.colors_xml.clone().map(String::into_bytes).unwrap_or_else(|| dgm::colors_xml(&d.colors))); + let cs = rel(o, "diagramColors", &colors); + let mut fs = s.clone(); + fs.xfrm = Some(frame); + frame_open(w, x, o, id, &fs); + w.tag("a:graphicData").attr("uri", "http://schemas.openxmlformats.org/drawingml/2006/diagram").open(); + w.tag("dgm:relIds").attr("xmlns:dgm", dgm::NS_DGM).attr("r:dm", dm).attr("r:lo", lo).attr("r:qs", qs).attr("r:cs", cs).empty(); + w.close(); + frame_close(w); +} + +fn ink_shape(st: &crate::InkStroke, i: usize) -> Option { + 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)); + // PPTX keeps one line width. A pressured stroke exports at its mean sample width; + // a legacy flat `0.5` stroke stays at the pen width. + let legacy = crate::ink_pressure_is_legacy(&st.points); + let width = if legacy { + st.width + } else { + let n = st.points.len().max(1) as f64; + st.points.iter().map(|p| crate::ink_sample_width(st.width, p.2, false)).sum::() / n + }; + 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 = crate::style::ColorRef::rgb(st.color.with_alpha(255)); + if st.highlighter { + c = c.with(craft_drawingml::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![crate::CustomPath { w, h, d, fill: craft_drawingml::preset::FillMode::None, stroke: true }] }, + fill: Some(Fill::None), + line: Some(crate::Line { + cap: Some(crate::style::LineCap::Round), + join: Some(crate::style::LineJoin::Round), + ..crate::Line::solid(c, width.max(0.25)) + }), + ..Default::default() + }) +} + +fn sp(w: &mut Writer, x: &mut Exp, o: &mut Out, id: u32, s: &Shape, math: bool) { + w.tag("p:sp").attr_opt("useBgFill", (matches!(s.fill, Some(Fill::Background))).then_some(1)).open(); + w.open("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.tag("p:cNvSpPr").list(cnv).open(); + locks(w, "a:spLocks", s, false); + w.close(); + } else { + w.tag("p:cNvSpPr").list(cnv).empty(); + } + nv_pr(w, x, o, s); + w.close(); + 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 { + // A shape's text without its own anchor sits in the middle in the model; a file without + // one would put it at the top, so say so. + if s.ph.is_none() && !s.text_box && tb.body.anchor.is_none() { + let mut tb = tb.clone(); + tb.body.anchor = Some(crate::text::Anchor::Middle); + dml::text_body(w, &mut Snk(x, o), "p:txBody", &tb, math); + } else { + dml::text_body(w, &mut Snk(x, o), "p:txBody", tb, math); + } + } + w.close(); +} + +fn frame_open(w: &mut Writer, x: &mut Exp, o: &mut Out, id: u32, s: &Shape) { + w.open("p:graphicFrame", &[]); + w.open("p:nvGraphicFramePr", &[]); + cnvpr(w, x, o, id, s); + w.open("p:cNvGraphicFramePr", &[]); + w.tag("a:graphicFrameLocks") + .attr("noGrp", "1") + .attr_opt("noDrilldown", (s.locked).then_some(1)) + .attr_opt("noMove", (s.locked).then_some(1)) + .attr_opt("noResize", (s.locked).then_some(1)) + .empty(); + w.close(); + nv_pr(w, x, o, s); + w.close(); + 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.open("a:graphic", &[]); +} + +fn frame_close(w: &mut Writer) { + w.close(); + w.close(); +} + +fn table(w: &mut Writer, x: &mut Exp, o: &mut Out, t: &Table) { + let mut t = t.clone(); + t.normalize(); + w.open("a:tbl", &[]); + let (guid, _) = craft_drawingml::table::styles::guid_for_style(&t.style); + if x.table_style_set.insert(t.style.clone()) { + x.table_styles.push(t.style.clone()); + } + w.tag("a:tblPr") + .attr_opt("firstRow", (t.first_row).then_some(1)) + .attr_opt("firstCol", (t.first_col).then_some(1)) + .attr_opt("lastRow", (t.last_row).then_some(1)) + .attr_opt("lastCol", (t.last_col).then_some(1)) + .attr_opt("bandRow", (t.band_row).then_some(1)) + .attr_opt("bandCol", (t.band_col).then_some(1)) + .attr_opt("rtl", (t.rtl).map(u8::from)) + .open(); + if let Some(f) = &t.background { + dml::fill(w, &mut Snk(x, o), f); + } + w.leaf("a:tableStyleId", &[], &guid); + w.close(); + w.open("a:tblGrid", &[]); + for c in &t.cols { + w.tag("a:gridCol").attr("w", emu_pos(*c)).empty(); + } + w.close(); + for row in &t.rows { + w.tag("a:tr").attr("h", emu_pos(row.height)).open(); + for c in &row.cells { + w.tag("a:tc") + .attr_opt("gridSpan", (c.grid_span > 1).then_some(c.grid_span)) + .attr_opt("rowSpan", (c.row_span > 1).then_some(c.row_span)) + .attr_opt("hMerge", (c.h_merge).then_some(1)) + .attr_opt("vMerge", (c.v_merge).then_some(1)) + .open(); + dml::text_body(w, &mut Snk(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 { + crate::text::Anchor::Justified | crate::text::Anchor::Distributed => "t", + other => other.xml(), + }), + ); + a = a.b("anchorCtr", c.anchor_ctr); + a = a.o("horzOverflow", crate::text::TextOverflow::horz_xml(c.horz_overflow)); + w.tag("a:tcPr").list(a).open(); + 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, &mut Snk(x, o), tag, l); + } + } + if let Some(f) = &c.fill { + match f { + Fill::Background => {} + f => dml::fill(w, &mut Snk(x, o), f), + } + } + w.close(); + w.close(); + } + w.close(); + } + w.close(); +} + +fn media(w: &mut Writer, x: &mut Exp, o: &mut Out, id: u32, s: &Shape, m: &crate::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, super::RT_MEDIA); + w.open("p:pic", &[]); + w.open("p:nvPicPr", &[]); + let mut s2 = s.clone(); + if s2.click.is_none() { + s2.click = Some(crate::text::Hyperlink { + action: crate::text::Action::PlayMedia, + tooltip: String::new(), + highlight_click: false, + sound: None, + stop_sound: false, + zoom_return: None, + }); + } + cnvpr(w, x, o, id, &s2); + w.open("p:cNvPicPr", &[]); + w.tag("a:picLocks").attr("noChangeAspect", "1").empty(); + w.close(); + w.open("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.tag(tag).attr("r:link", l).empty(); + } + if let Some(e) = &embed { + w.open("p:extLst", &[]); + w.tag("p:ext").attr("uri", "{DAA4B4D4-6D71-4841-9C94-3DE7FCFB9230}").open(); + w.tag("p14:media").attr("xmlns:p14", super::NS_P14).attr("r:embed", e).open(); + if m.trim_start_ms > 0 || m.trim_end_ms > 0 { + w.tag("p14:trim") + .attr_opt("st", (m.trim_start_ms > 0).then_some(m.trim_start_ms)) + .attr_opt("end", (m.trim_end_ms > 0).then_some(m.trim_end_ms)) + .empty(); + } + if m.fade_in_ms > 0 || m.fade_out_ms > 0 { + w.tag("p14:fade") + .attr_opt("in", (m.fade_in_ms > 0).then_some(m.fade_in_ms)) + .attr_opt("out", (m.fade_out_ms > 0).then_some(m.fade_out_ms)) + .empty(); + } + if !m.bookmarks.is_empty() { + w.open("p14:bmkLst", &[]); + for (n, t) in m.bookmarks.iter().take(1000) { + w.tag("p14:bmk").attr("name", n).attr("time", t).empty(); + } + w.close(); + } + w.close(); + w.close(); + w.close(); + } + w.close(); + w.close(); + let poster = crate::style::PictureFill { media: m.poster.unwrap_or_default(), crop: m.crop, ..Default::default() }; + dml::blip_fill(w, &mut Snk(x, o), "p:blipFill", &poster); + let mut s3 = s.clone(); + s3.fill = None; + sp_pr(w, x, o, &s3, true); + w.close(); +} + +#[allow(clippy::too_many_arguments)] +fn opaque( + w: &mut Writer, + x: &mut Exp, + o: &mut Out, + cx: &mut Ctx, + s: &Shape, + xml: &str, + preview: Option, + label: &str, + parts: &[(String, String, crate::MediaId)], +) { + // Kept XML is written back as it was when every part it references was kept too, with those + // parts re-added under this slide's relationship ids. + if let Ok(doc) = crate::package::parse(xml.as_bytes()) + && matches!(doc.root.local(), "graphicFrame" | "sp" | "grpSp" | "cxnSp") + { + let mut part_map: std::collections::HashMap<&str, (&str, crate::MediaId)> = std::collections::HashMap::new(); + for (id, kind, m) in parts { + part_map.entry(id.as_str()).or_insert((kind.as_str(), *m)); + } + if let Some(ids) = xmlx::rel_refs(&doc.root) + .iter() + .map(|r| { + let (kind, m) = part_map.get(r.as_str())?; + Some((r.clone(), x.media_rel(o, *m, kind)?)) + }) + .collect::>>() + { + let mut root = doc.root; + set_rel_ids(&mut root, &ids, 0); + ole_fallback(&mut root, x, o, preview); + 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.media(m).is_some() => { + p.kind = ShapeKind::Picture { fill: crate::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 Writer, 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.open("p:pic", &[]); + w.open("p:nvPicPr", &[]); + cnvpr(w, x, o, id, s); + w.open("p:cNvPicPr", &[]); + w.tag("a:picLocks").attr("noChangeAspect", "1").empty(); + w.close(); + w.empty("p:nvPr", &[]); + w.close(); + dml::blip_fill(w, &mut Snk(x, o), "p:blipFill", fill); + // As for any picture, a colour or pattern fill shows behind the image (an object's preview + // keeps its fallback picture's 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); + w.close(); + } else { + shape(w, x, o, cx, s, 0); + } +} + +/// A legacy OLE object finds its preview in the slide's VML drawing through `spid`, and PowerPoint +/// won't open the slide without it. We don't write that drawing, so write PowerPoint's own +/// fallback form instead: no `spid`, and the preview as a `p:pic`. +fn ole_fallback(root: &mut craft_xml::El, x: &mut Exp, o: &mut Out, preview: Option) { + let xfrm = root.child(Local("xfrm")).map(|t| { + let at = |e: &str, a: &str| t.child(Local(e)).and_then(|c| c.attr_local(a)).unwrap_or("0").to_string(); + (at("off", "x"), at("off", "y"), at("ext", "cx"), at("ext", "cy")) + }); + let Some(ole) = root + .elements_mut() + .find(|e| e.is(Local("graphic"))) + .and_then(|g| g.elements_mut().find(|e| e.is(Local("graphicData")))) + .and_then(|d| d.elements_mut().find(|e| e.is(Local("oleObj")))) + else { + return; + }; + if ole.child(Local("pic")).is_some() || ole.attr_local("spid").is_none() { + return; + } + ole.attrs.retain(|(k, _)| k != "spid"); + let rel = preview.and_then(|m| x.media_rel(o, m, "image")).unwrap_or_else(|| x.placeholder_rel(o)); + let (ox, oy, cx, cy) = xfrm.unwrap_or_default(); + let pic = format!( + r#""# + ); + if let Ok(d) = crate::package::parse(pic.as_bytes()) { + ole.children.push(craft_xml::Node::El(d.root)); + } +} + +fn set_rel_ids(e: &mut craft_xml::El, ids: &std::collections::HashMap, depth: usize) { + if depth > xmlx::MAX_DEPTH { + return; + } + for (k, v) in &mut e.attrs { + if xmlx::is_rel_attr(k, v) + && let Some(new) = ids.get(v.as_str()) + { + v.clone_from(new); + } + } + for c in e.elements_mut() { + set_rel_ids(c, ids, depth + 1); + } +} + +fn set_cnvpr_id(el: &mut craft_xml::El, id: u32) { + fn rec(e: &mut craft_xml::El, id: u32, depth: usize) -> bool { + if depth > 8 { + return false; + } + if e.is(Local("cNvPr")) { + e.set_attr("id", id); + 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 Writer) { + w.open("p:nvGrpSpPr", &[]); + w.tag("p:cNvPr").attr("id", 1).attr("name", "").empty(); + w.empty("p:cNvGrpSpPr", &[]); + w.empty("p:nvPr", &[]); + w.close(); + w.open("p:grpSpPr", &[]); + w.open("a:xfrm", &[]); + w.tag("a:off").attr("x", 0).attr("y", 0).empty(); + w.tag("a:ext").attr("cx", 0).attr("cy", 0).empty(); + w.tag("a:chOff").attr("x", 0).attr("y", 0).empty(); + w.tag("a:chExt").attr("cx", 0).attr("cy", 0).empty(); + w.close(); + w.close(); +} + +#[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 = Writer::new(); + geometry(&mut w, &Geom::Preset { name: "star5".into(), adj: vec![20000.0] }); + let s = w.finish(); + assert_eq!( + s, + r#""# + ); + let mut w = Writer::new(); + geometry(&mut w, &Geom::Preset { name: "rightArrow".into(), adj: vec![10000.0] }); + let s = w.finish(); + assert!(s.contains(r#"name="adj1" fmla="val 10000""#) && s.contains(r#"name="adj2" fmla="val 50000""#), "{}", s); + let mut w = Writer::new(); + geometry(&mut w, &Geom::Preset { name: "textBox".into(), adj: vec![] }); + let s = w.finish(); + assert_eq!(s, r#""#); + } +} diff --git a/crates/pptx/tests/write.rs b/crates/pptx/tests/write.rs new file mode 100644 index 000000000..bec5fb239 --- /dev/null +++ b/crates/pptx/tests/write.rs @@ -0,0 +1,204 @@ +//! The writer, read back with the reader. + +use std::sync::Arc; + +use craft_pptx::table::{Cell, Table}; +use craft_pptx::{ + ColorRef, Defaults, Geom, MediaId, Plain, Presentation, Rgba, SchemeSlot, Shape, ShapeId, ShapeKind, Size, Slide, SlideId, TextBody, Xfrm, +}; + +/// A deck with the plain master and one slide on its layout. +fn deck() -> Presentation { + let mut p = Presentation::default(); + let (m, last) = Plain.master(p.slide_size, 0); + let layout = m.layouts[0].id; + p.masters.push(Arc::new(m)); + p.next_id = last; + let id = SlideId(p.alloc_id().unwrap()); + p.slides.push(Arc::new(Slide { id, layout, ..Default::default() })); + p +} + +fn add(p: &mut Presentation, mut s: Shape) -> ShapeId { + s.id = ShapeId(p.alloc_id().unwrap()); + let id = s.id; + Arc::make_mut(&mut p.slides[0]).shapes.push(s); + id +} + +fn bytes_of(pptx: &[u8], name: &str) -> Vec { + craft_opc::Package::open(pptx, &craft_pptx::LIMITS).unwrap().get(name).unwrap().to_vec() +} + +fn part(pptx: &[u8], name: &str) -> String { + String::from_utf8(bytes_of(pptx, name)).unwrap() +} + +fn round_trip(p: &Presentation) -> Presentation { + let bytes = craft_pptx::export(p, &Plain).unwrap(); + craft_pptx::import(&bytes, &Plain).unwrap() +} + +#[test] +fn shapes_survive_writing_and_reading() { + let mut p = deck(); + let png = p.add_media("dot.png", "image/png", b"\x89PNG not really".to_vec()).unwrap(); + let mut text = TextBody::from_text("Hello\nWorld"); + text.body.anchor = Some(craft_pptx::text::Anchor::Bottom); + let a = add( + &mut p, + Shape { + name: "Box".into(), + xfrm: Some(Xfrm::new(10.0, 20.0, 200.0, 100.0)), + geom: Geom::Preset { name: "roundRect".into(), adj: vec![25000.0] }, + fill: Some(craft_pptx::Fill::solid(ColorRef::rgb(Rgba::rgb(0x12, 0x34, 0x56)))), + text: Some(text), + descr: "alt".into(), + ..Default::default() + }, + ); + let b = add( + &mut p, + Shape { + name: "Picture".into(), + xfrm: Some(Xfrm::new(300.0, 20.0, 50.0, 50.0)), + kind: ShapeKind::Picture { fill: craft_pptx::style::PictureFill { media: png, ..Default::default() } }, + ..Default::default() + }, + ); + add(&mut p, Shape { name: "Line".into(), kind: ShapeKind::Connector { start: Some((a, 1)), end: Some((b, 3)) }, ..Default::default() }); + let mut t = Table::new(2, 2, 200.0, 30.0); + t.rows[1].cells[1] = Cell::new("B2"); + add(&mut p, Shape { name: "Table".into(), xfrm: Some(Xfrm::new(0.0, 200.0, 200.0, 60.0)), kind: ShapeKind::Table(t), ..Default::default() }); + + let q = round_trip(&p); + assert!(q.validate().is_empty(), "{:?}", q.validate()); + let s = &q.slides[0].shapes; + assert_eq!(s.iter().map(|s| s.name.as_str()).collect::>(), ["Box", "Picture", "Line", "Table"]); + assert_eq!(s[0].xfrm, p.slides[0].shapes[0].xfrm); + assert_eq!(s[0].geom, p.slides[0].shapes[0].geom); + assert_eq!(s[0].fill, p.slides[0].shapes[0].fill); + assert_eq!(s[0].descr, "alt"); + assert_eq!(s[0].text.as_ref().map(|t| (t.text(), t.body.anchor)), Some(("Hello\nWorld".into(), Some(craft_pptx::text::Anchor::Bottom)))); + let ShapeKind::Picture { fill } = &s[1].kind else { panic!("not a picture") }; + assert_eq!(q.media(fill.media).map(|m| m.data.as_slice()), Some(&b"\x89PNG not really"[..])); + assert_eq!(s[2].kind, ShapeKind::Connector { start: Some((s[0].id, 1)), end: Some((s[1].id, 3)) }); + let ShapeKind::Table(t) = &s[3].kind else { panic!("not a table") }; + assert_eq!(t.rows[1].cells[1].text.text(), "B2"); +} + +#[test] +fn a_deck_without_masters_gets_the_callers() { + let mut p = Presentation::default(); + p.slides.push(Arc::new(Slide { id: SlideId(1), ..Default::default() })); + let bytes = craft_pptx::export(&p, &Plain).unwrap(); + let q = craft_pptx::import(&bytes, &Plain).unwrap(); + assert_eq!(q.masters.len(), 1); + assert_eq!(q.slides.len(), 1); + assert_eq!(q.slides[0].layout, q.masters[0].layouts[0].id); +} + +#[test] +fn a_missing_picture_is_written_as_a_transparent_stand_in() { + let mut p = deck(); + add( + &mut p, + Shape { + kind: ShapeKind::Picture { fill: craft_pptx::style::PictureFill { media: MediaId(999), ..Default::default() } }, + ..Default::default() + }, + ); + let bytes = craft_pptx::export(&p, &Plain).unwrap(); + let png = bytes_of(&bytes, "ppt/media/image1.png"); + assert!(png.starts_with(b"\x89PNG\r\n\x1a\n") && png.ends_with(b"IEND\xAE\x42\x60\x82"), "{png:?}"); +} + +/// The caller's name and text styles go into the file. +#[test] +fn the_callers_defaults_are_written() { + struct Acme; + impl Defaults for Acme { + fn master(&self, size: Size, after: u32) -> (craft_pptx::Master, u32) { + Plain.master(size, after) + } + fn default_text_style(&self) -> craft_pptx::text::ListStyle { + let mut ls = craft_pptx::text::ListStyle::default(); + ls.set( + 0, + craft_pptx::text::LevelStyle { run: craft_pptx::text::RunProps { size: Some(26.0), ..Default::default() }, ..Default::default() }, + ); + ls + } + fn application(&self) -> &str { + "Acme" + } + } + let mut p = deck(); + let t = Table { style: "light1-accent2".into(), ..Table::new(1, 1, 100.0, 20.0) }; + add(&mut p, Shape { kind: ShapeKind::Table(t), ..Default::default() }); + let bytes = craft_pptx::export(&p, &Acme).unwrap(); + assert!(part(&bytes, "docProps/app.xml").contains("Acme")); + assert!(part(&bytes, "ppt/tableStyles.xml").contains("styleName=\"Acme "), "{}", part(&bytes, "ppt/tableStyles.xml")); + let q = craft_pptx::import(&bytes, &Plain).unwrap(); + assert_eq!(q.default_text_style.level(0).and_then(|l| l.run.size), Some(26.0)); + assert!(part(&craft_pptx::export(&p, &Plain).unwrap(), "docProps/app.xml").contains("craft-pptx")); +} + +/// From DeckCraft's `crates/pptx/src/lib.rs`. +#[test] +fn table_direction_and_column_order_survive_two_saves() { + for rtl in [Some(true), Some(false), None] { + let mut p = deck(); + let mut t = Table::new(1, 2, 200.0, 30.0); + t.rtl = rtl; + t.rows[0].cells[0] = Cell::new("First column"); + t.rows[0].cells[1] = Cell::new("Second column"); + add(&mut p, Shape { kind: ShapeKind::Table(t), ..Default::default() }); + let mut q = round_trip(&p); + for _ in 0..2 { + let Some(ShapeKind::Table(t)) = q.slides[0].shapes.first().map(|s| &s.kind) else { panic!("no table") }; + assert_eq!(t.rtl, rtl); + assert_eq!(t.rows[0].cells[0].text.paragraphs[0].runs[0].text, "First column"); + assert_eq!(t.rows[0].cells[1].text.paragraphs[0].runs[0].text, "Second column"); + q = round_trip(&q); + } + } +} + +/// From DeckCraft's `crates/pptx/src/lib.rs`: the theme of a written deck, and of a `.thmx` +/// holding that theme part. +#[test] +fn reads_the_theme_of_a_pptx_and_of_a_thmx() { + let mut p = deck(); + let m = Arc::make_mut(&mut p.masters[0]); + m.theme.name = "Harbour".into(); + m.theme.fonts.major.latin = "Lato".into(); + m.theme.colors.set(SchemeSlot::Accent1, Rgba::rgb(0x12, 0x34, 0x56)); + let pptx = craft_pptx::export(&p, &Plain).unwrap(); + let mut out = craft_opc::PackageOut::default(); + out.xml_part( + "theme/theme/theme1.xml", + "application/vnd.openxmlformats-officedocument.theme+xml", + part(&pptx, "ppt/theme/theme1.xml").into_bytes(), + ); + let mut rels = craft_opc::RelsOut::default(); + rels.add("officeDocument", "theme/theme/theme1.xml", false); + out.rels("", &rels); + let thmx = out.finish().unwrap(); + for bytes in [pptx, thmx] { + let (t, _) = craft_pptx::import_theme(&bytes).unwrap(); + assert_eq!(t.name, "Harbour"); + assert_eq!(t.fonts.major.latin, "Lato"); + assert_eq!(t.colors.get(SchemeSlot::Accent1), Rgba::rgb(0x12, 0x34, 0x56)); + } +} + +#[test] +fn templates_and_shows_differ_in_the_main_content_type() { + let p = deck(); + for (ext, ct) in [("potx", "template.main+xml"), ("ppsx", "slideshow.main+xml"), ("pptx", "presentation.main+xml")] { + let bytes = craft_pptx::export_as(&p, &Plain, ext).unwrap(); + assert!(part(&bytes, "[Content_Types].xml").contains(ct), "{ext}"); + assert_eq!(craft_pptx::import(&bytes, &Plain).unwrap().slides.len(), 1); + } +}