From 34ba6ae4da21b56e9d4ccb13e41db9b091661014 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 21:36:57 -0500 Subject: [PATCH] craft-pptx: the reader (from DeckCraft), on craft-opc and craft-drawingml (#1057) 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 --------- Co-authored-by: Jordi Altayó --- Cargo.lock | 4 + crates/pptx/Cargo.toml | 4 + crates/pptx/README.md | 17 +- crates/pptx/src/lib.rs | 109 +++ crates/pptx/src/package.rs | 90 +++ crates/pptx/src/read.rs | 1216 ++++++++++++++++++++++++++++++++ crates/pptx/src/read/chart.rs | 193 +++++ crates/pptx/src/read/shapes.rs | 848 ++++++++++++++++++++++ crates/pptx/tests/read.rs | 239 +++++++ 9 files changed, 2719 insertions(+), 1 deletion(-) create mode 100644 crates/pptx/src/package.rs create mode 100644 crates/pptx/src/read.rs create mode 100644 crates/pptx/src/read/chart.rs create mode 100644 crates/pptx/src/read/shapes.rs create mode 100644 crates/pptx/tests/read.rs diff --git a/Cargo.lock b/Cargo.lock index 5b22a65a2..2940e7b48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1670,9 +1670,13 @@ name = "craft-pptx" version = "0.1.0" dependencies = [ "craft-drawingml", + "craft-opc", + "craft-xml", "kurbo 0.13.1", + "log", "serde", "serde_json", + "thiserror 2.0.21", ] [[package]] diff --git a/crates/pptx/Cargo.toml b/crates/pptx/Cargo.toml index 623805a1a..8b5c28f82 100644 --- a/crates/pptx/Cargo.toml +++ b/crates/pptx/Cargo.toml @@ -15,3 +15,7 @@ craft-drawingml = { workspace = true } kurbo = { workspace = true, features = ["serde"] } serde = { workspace = true, features = ["rc"] } serde_json = { workspace = true } +craft-opc = { workspace = true } +craft-xml = { workspace = true } +log = "0.4" +thiserror = { workspace = true } diff --git a/crates/pptx/README.md b/crates/pptx/README.md index 42cda2fc8..01850bc06 100644 --- a/crates/pptx/README.md +++ b/crates/pptx/README.md @@ -18,4 +18,19 @@ unique across a presentation; `Presentation::alloc_id` hands out fresh ones and run out. `Presentation::default()` is an empty 16:9 deck with no masters: an application adds its own starting masters and layouts. -The reader and writer come in later changes. +## Reading + +`import(bytes, &defaults)` reads a `.pptx`, `.potx` or `.ppsx` into a `Presentation`, on +`craft-opc` for the package and `craft-drawingml` for the DrawingML. It is lenient: unknown parts +are skipped, a malformed part is logged and left out, and a slide the presentation lists but the +package lacks is left out and named by `import_checked`. Everything is bounded: entries, part and +package sizes, inflate work, XML depth and nodes, shapes per part, group nesting, chart points and +decoded chart bytes, kept chart parts, and add-in tag bytes (`MAX_TAG_BYTES`). + +What the file leaves out comes from the caller's `Defaults`: the master for a file without a +readable one, and the theme of a master that names none. `Plain` has no application's look in it. +`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. diff --git a/crates/pptx/src/lib.rs b/crates/pptx/src/lib.rs index ac935300a..fdcbba3c3 100644 --- a/crates/pptx/src/lib.rs +++ b/crates/pptx/src/lib.rs @@ -12,7 +12,12 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod anim; +mod package; pub mod placeholder; +mod read; + +pub use package::{LIMITS, MAX_ENTRIES, MAX_INFLATE_WORK, MAX_PART, MAX_TOTAL, MAX_XML_PART}; +pub use read::MAX_TAG_BYTES; use std::sync::Arc; @@ -29,6 +34,110 @@ use serde::{Deserialize, Serialize}; pub use anim::{AnimClass, AnimStart, Animation, Transition}; +/// Extension URI of the PowerPoint 2010 section list in `presentation.xml`. +pub(crate) const SECTION_EXT_URI: &str = "{521415D9-36F7-43E2-AB2F-B90AF26B5E84}"; + +/// Why a package could not be read or written. +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("not a PPTX file: {0}")] + NotPptx(String), + #[error("unsupported: {0}")] + Unsupported(String), + #[error("write failed: {0}")] + Write(String), + #[error("malformed XML: {0}")] + 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. +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. + fn master(&self, size: Size, after: u32) -> (Master, u32); + /// The theme of a master whose file names none. + fn theme(&self) -> Theme { + Theme::default() + } +} + +/// [`Defaults`] with nothing of an application's: a master with one blank layout and the plain +/// [`Theme::default`]. +pub struct Plain; + +impl Defaults for Plain { + fn master(&self, _size: Size, after: u32) -> (Master, u32) { + let (master, layout) = (after.saturating_add(1), after.saturating_add(2)); + let blank = Layout { id: LayoutId(layout), name: "Blank".into(), kind: LayoutType::Blank, show_master_shapes: true, ..Default::default() }; + (Master { id: MasterId(master), name: "Office Theme".into(), theme: self.theme(), layouts: vec![blank], ..Default::default() }, layout) + } +} + +/// Does this look like an OOXML package (a ZIP with `[Content_Types].xml`)? +/// +/// The part is looked up in the archive's central directory, not in the leading bytes: writers may +/// store it anywhere in the archive (recent PowerPoint builds put it last, megabytes in). +pub fn sniff(bytes: &[u8]) -> bool { + bytes.starts_with(b"PK") && craft_opc::LazyPackage::open(bytes, &LIMITS).is_ok_and(|p| p.has("[Content_Types].xml")) +} + +/// Reads a `.pptx` / `.potx` / `.ppsx` package leniently: unknown parts are skipped, malformed +/// parts are logged and left out, and every size and depth is bounded. `defaults` fills in what +/// the file leaves out. +pub fn import(bytes: &[u8], defaults: &dyn Defaults) -> Result { + read::import(bytes, defaults) +} + +/// [`import`], plus the part names of slides the file lists but that could not be read and were +/// left out. +pub fn import_checked(bytes: &[u8], defaults: &dyn Defaults) -> Result<(Presentation, Vec), Error> { + read::import_checked(bytes, defaults) +} + +/// [`import`] with a lower presentation-wide chart point budget, so tests reach the over-budget +/// path without a multi-million-point chart. +#[doc(hidden)] +pub fn import_with_chart_budget(bytes: &[u8], defaults: &dyn Defaults, points: usize) -> Result { + read::import_with_chart_points(bytes, defaults, points).map(|(p, _)| p) +} + +/// The theme of a `.thmx` file, or of a `.pptx` / `.potx`'s first master, with its media. +pub fn import_theme(bytes: &[u8]) -> Result<(Theme, Vec), Error> { + read::import_theme(bytes) +} + +/// 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(); + match ext.as_str() { + "png" => "image/png", + "jpg" | "jpeg" | "jpe" => "image/jpeg", + "gif" => "image/gif", + "bmp" => "image/bmp", + "tif" | "tiff" => "image/tiff", + "svg" => "image/svg+xml", + "emf" => "image/x-emf", + "wmf" => "image/x-wmf", + "webp" => "image/webp", + "mp4" | "m4v" => "video/mp4", + "mov" => "video/quicktime", + "wmv" => "video/x-ms-wmv", + "avi" => "video/x-msvideo", + "webm" => "video/webm", + "mp3" => "audio/mpeg", + "wav" => "audio/wav", + "m4a" => "audio/mp4", + "wma" => "audio/x-ms-wma", + "ogg" => "audio/ogg", + "flac" => "audio/flac", + "glb" => "model/gltf-binary", + "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "fntdata" => "application/x-fontdata", + _ => "application/octet-stream", + } +} + macro_rules! id_type { ($(#[$doc:meta])* $name:ident) => { $(#[$doc])* diff --git a/crates/pptx/src/package.rs b/crates/pptx/src/package.rs new file mode 100644 index 000000000..ec74908ba --- /dev/null +++ b/crates/pptx/src/package.rs @@ -0,0 +1,90 @@ +//! The package being read: craft-opc's [`craft_opc::Package`] with the limits and lookups the +//! reader uses. + +use craft_drawingml::xmlx; +use craft_opc::{ContentTypes, Limits, Oversized, Rels}; +use craft_xml::El; + +use crate::Error; + +/// Most bytes one part inflates to. +pub const MAX_PART: u64 = 512 << 20; +/// Most bytes one XML part may have to be parsed. +pub const MAX_XML_PART: usize = 96 << 20; +/// Most bytes kept from the whole package. +pub const MAX_TOTAL: u64 = 2 << 30; +/// Most bytes inflated for the whole package, parts left out included. +pub const MAX_INFLATE_WORK: u64 = 2 * MAX_TOTAL; +/// Most ZIP entries. +pub const MAX_ENTRIES: usize = 50_000; + +/// The package limits: oversized or damaged parts are left out with a warning, and reading stops +/// once [`MAX_INFLATE_WORK`] is spent. +pub const LIMITS: Limits = + Limits { max_entries: MAX_ENTRIES, max_part: MAX_PART, max_total: MAX_TOTAL, max_work: MAX_INFLATE_WORK, oversized: Oversized::Skip }; + +/// A parsed part: its root element. +#[derive(Clone, Debug, Default)] +pub(crate) struct Doc { + pub root: El, +} + +impl Doc { + /// Namespace declarations (`xmlns:x`, `xmlns`) on the root element. + pub fn ns_decls(&self) -> Vec<(String, String)> { + xmlx::ns_decls(&self.root) + } +} + +/// Parses a part with [`xmlx::parse`] (names as written, depth and node limits). +pub(crate) fn parse(bytes: &[u8]) -> Result { + xmlx::parse(bytes).map(|root| Doc { root }).map_err(|e| Error::Xml(e.to_string())) +} + +pub(crate) struct Package { + pkg: craft_opc::Package, + types: ContentTypes, +} + +impl Package { + pub fn open(bytes: &[u8]) -> Result { + let pkg = craft_opc::Package::open(bytes, &LIMITS).map_err(|e| Error::NotPptx(e.to_string()))?; + Ok(Package { types: pkg.content_types(), pkg }) + } + pub fn get(&self, name: &str) -> Option<&[u8]> { + self.pkg.get(name) + } + pub fn get_shared(&self, name: &str) -> Option<&std::sync::Arc>> { + self.pkg.get_shared(name) + } + /// An XML part's bytes, when it is within [`MAX_XML_PART`]. + pub fn xml_bytes(&self, name: &str) -> Option<&[u8]> { + let b = self.get(name)?; + if b.len() > MAX_XML_PART { + log::warn!("pptx: XML part {name} too large"); + return None; + } + Some(b) + } + /// An XML part, parsed; `None` (with a warning) when it is missing, too large or malformed. + pub fn xml(&self, name: &str) -> Option { + match parse(self.xml_bytes(name)?) { + Ok(d) => Some(d), + Err(e) => { + log::warn!("pptx: {name}: {e}"); + None + } + } + } + /// Relationships of a part (`ppt/slides/slide1.xml` → `ppt/slides/_rels/slide1.xml.rels`). + pub fn rels(&self, part: &str) -> Rels { + self.pkg.rels(part) + } + pub fn content_type(&self, part: &str) -> Option { + self.types.of(part).map(str::to_string) + } + /// The part name as stored in the archive. + pub fn original_name(&self, name: &str) -> Option<&str> { + self.pkg.canonical(name) + } +} diff --git a/crates/pptx/src/read.rs b/crates/pptx/src/read.rs new file mode 100644 index 000000000..bb94916f9 --- /dev/null +++ b/crates/pptx/src/read.rs @@ -0,0 +1,1216 @@ +//! PPTX import: package → [`Presentation`]. + +pub(crate) mod chart; +mod shapes; + +use std::cell::Cell; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use craft_drawingml::emu_to_pt; +use craft_drawingml::part::{self, Source}; +use craft_drawingml::read as dml; +use craft_drawingml::xmlx::{self, has_rel_refs, keep_with}; +use craft_opc::Rels; +use craft_xml::{El, Local, value}; + +use crate::package::{Doc, Package}; +use crate::placeholder::{Owner, parents}; +use crate::{ + Background, CustomShow, Defaults, EmbeddedFont, Error, Geom, HeaderFooter, Layout, LayoutId, LayoutType, Master, MasterId, MediaId, MediaItem, + PhType, Presentation, Properties, Section, ShowSettings, Size, Slide, SlideId, +}; + +/// Import state shared by all parts. +pub struct Imp<'a> { + pub pkg: &'a Package, + pub defaults: &'a dyn Defaults, + pub media: Vec, + pub media_by_part: HashMap, + pub next: u32, + pub slide_by_part: HashMap, + pub custom_show_names: HashMap, + /// GUIDs (upper case) of the table styles the file defines. + pub table_style_ids: Vec, + /// Per legacy VML drawing part: shape id → preview picture part (see `shapes::vml_preview`). + pub vml_previews: HashMap>, + /// Aggregate byte count of retained chart sidecars across the presentation. + pub kept_sidecar_bytes: usize, + /// Data slots of the charts decoded so far (see `MAX_PRESENTATION_CHART_POINTS`). + pub chart_points: usize, + /// Points the presentation may hold across modelled charts. + pub chart_points_cap: usize, + /// Cached kept sidecars by normalized chart part name. + pub kept_charts: HashMap, + /// Chart parts whose sidecar capture was refused, with the retained-byte total it started from: + /// the same start gives the same refusal, so the copy is not repeated. + pub rejected_kept: HashMap, + /// Bytes of chart parts decoded so far, rejected ones included (see `MAX_CHART_DECODE_BYTES`). + pub chart_decode_bytes: usize, + /// Add-in tags parts, parsed once each, by normalized part name. + pub tags_by_part: HashMap>, + /// Bytes of tags copied into the model so far (see [`MAX_TAG_BYTES`]). + pub tag_bytes: usize, + /// The tag budget ran out and the warning was given. + pub tags_capped: bool, + /// Whether a part has relationships of its own, by normalized part name: looked up once + /// however many embedded objects point at the part. + pub has_rels: HashMap, +} + +impl<'a> Imp<'a> { + fn new(pkg: &'a Package, defaults: &'a dyn Defaults, next: u32, chart_points_cap: usize) -> Self { + Imp { + pkg, + defaults, + media: vec![], + media_by_part: HashMap::new(), + next, + slide_by_part: HashMap::new(), + custom_show_names: HashMap::new(), + table_style_ids: Vec::new(), + vml_previews: HashMap::new(), + kept_sidecar_bytes: 0, + chart_points: 0, + chart_points_cap, + kept_charts: HashMap::new(), + rejected_kept: HashMap::new(), + chart_decode_bytes: 0, + tags_by_part: HashMap::new(), + tag_bytes: 0, + tags_capped: false, + has_rels: HashMap::new(), + } + } + pub fn alloc(&mut self) -> u32 { + self.next = self.next.saturating_add(1); + self.next + } + /// Media item for an internal part, read once. + pub fn media_for(&mut self, part: &str) -> Option { + let key = craft_opc::normalize(part); + if let Some(m) = self.media_by_part.get(&key) { + return Some(*m); + } + let data = self.pkg.get_shared(part)?.clone(); + let name = craft_opc::file_of(self.pkg.original_name(part).unwrap_or(part)).to_string(); + let content_type = self.pkg.content_type(part).filter(|c| !c.is_empty()).unwrap_or_else(|| crate::mime_for(&name).to_string()); + let id = MediaId(self.alloc()); + self.media.push(MediaItem { id, name, content_type, data, link: None }); + self.media_by_part.insert(key, id); + Some(id) + } + /// Media item for an external link. + pub fn media_link(&mut self, url: &str) -> MediaId { + if let Some(m) = self.media.iter().find(|m| m.link.as_deref() == Some(url)) { + return m.id; + } + let id = MediaId(self.alloc()); + let name = url.rsplit('/').next().unwrap_or(url).to_string(); + let content_type = crate::mime_for(&name).to_string(); + self.media.push(MediaItem { id, name, content_type, data: Arc::new(vec![]), link: Some(url.to_string()) }); + id + } +} + +/// One part being read: its name, relationships and root namespace declarations. +pub struct Part { + pub name: String, + pub rels: Rels, + pub ns: Vec<(String, String)>, + /// Prefixes bound below the root only (on a wrapper such as `mc:Choice`), each to one namespace. + pub inner_ns: Vec<(String, String)>, + /// Bytes of namespace declarations this part may still copy onto kept fragments. + ns_budget: Cell, +} + +impl Part { + pub(crate) fn new(pkg: &Package, name: &str, doc: &Doc) -> Self { + let ns = doc.ns_decls(); + Part { + name: name.to_string(), + rels: pkg.rels(name), + inner_ns: xmlx::inner_decls(&doc.root, &ns), + ns, + ns_budget: Cell::new(xmlx::MAX_COPIED_NS_BYTES), + } + } + /// Serialize an element with the namespace declarations it needs from the part root. + pub fn keep(&self, el: &El) -> String { + keep_with(el, &[&self.ns], Some(&self.ns_budget)) + } + /// Like [`Part::keep`], also declaring prefixes that only an enclosing wrapper binds. + pub fn keep_inner(&self, el: &El) -> String { + keep_with(el, &[&self.ns, &self.inner_ns], Some(&self.ns_budget)) + } +} + +/// The part being read, as the DrawingML readers see it. +pub(crate) struct Src<'s, 'a>(pub &'s mut Imp<'a>, pub &'s Part); + +impl Source for Src<'_, '_> { + fn rel(&self, id: &str) -> Option { + self.1.rels.by_id(id).map(|r| part::Rel { target: r.target.clone(), external: r.external }) + } + fn media(&mut self, target: &str, external: bool) -> Option { + if external { Some(self.0.media_link(target)) } else { self.0.media_for(target) } + } + fn slide(&self, target: &str) -> Option { + self.0.slide_by_part.get(&craft_opc::normalize(target)).copied() + } + fn custom_show(&self, id: &str) -> Option { + self.0.custom_show_names.get(id).cloned() + } + fn keep(&self, el: &El) -> String { + self.1.keep(el) + } + fn keep_inner(&self, el: &El) -> String { + self.1.keep_inner(el) + } +} + +/// A relationship to an embedded 3-D model (`.glb`). +pub(crate) fn is_model3d(rel: &craft_opc::Rel) -> bool { + !rel.external && (rel.kind() == "model3d" || rel.target.to_ascii_lowercase().ends_with(".glb")) +} + +fn choice_has_model3d(el: &El, rels: &Rels) -> bool { + fn rec(el: &El, rels: &Rels, depth: usize) -> bool { + if depth > 16 { + return false; + } + if el.is(Local("contentPart")) + && let Some(id) = el.attr_local("r:id").filter(|s| !s.is_empty()) + && rels.by_id(id).is_some_and(is_model3d) + { + return true; + } + el.elements().any(|c| rec(c, rels, depth + 1)) + } + rec(el, rels, 0) +} + +/// Bytes of tags one import copies into the model, every tags part and reference together: a +/// small compressed tags part listed by many shapes, or many times by one, must not grow without +/// bound. The package budgets ([`MAX_XML_PART`](crate::MAX_XML_PART), [`MAX_TOTAL`](crate::MAX_TOTAL)) bound what is +/// parsed; this bounds what is copied from it. +pub const MAX_TAG_BYTES: usize = 64 << 20; +/// What each copied pair costs besides its text, so empty pairs count too. +const TAG_PAIR_COST: usize = std::mem::size_of::<(String, String)>(); + +/// Add-in tags named by `holder`'s `p:custDataLst`: the name/value pairs of each tags part. +/// Each part is parsed once per import and counted once per holder. Once [`MAX_TAG_BYTES`] is +/// reached, no further tag parts are parsed or cached. +pub fn tags(imp: &mut Imp, part: &Part, holder: &El) -> Vec<(String, String)> { + if imp.tags_capped { + return vec![]; + } + let Some(list) = holder.child(Local("custDataLst")) else { return vec![] }; + let mut out = Vec::new(); + let mut seen = HashSet::new(); + for t in list.children(Local("tags")).take(100) { + if imp.tags_capped { + return out; + } + let Some(target) = t.attr_local("r:id").and_then(|id| part.rels.by_id(id)).map(|r| craft_opc::normalize(&r.target)) else { continue }; + if !seen.insert(target.clone()) { + continue; + } + let parsed = match imp.tags_by_part.get(&target) { + Some(p) => p.clone(), + None => { + if imp.tags_capped { + return out; + } + let p: Arc<[(String, String)]> = imp + .pkg + .xml(&target) + .map(|doc| { + doc.root + .children(Local("tag")) + .take(10_000) + .filter_map(|tag| Some((tag.attr_local("name")?.to_string(), tag.attr_local("val").unwrap_or("").to_string()))) + .collect() + }) + .unwrap_or_default(); + imp.tags_by_part.insert(target, p.clone()); + p + } + }; + for (name, val) in parsed.iter() { + let n = imp.tag_bytes.saturating_add(TAG_PAIR_COST + name.len() + val.len()); + if n > MAX_TAG_BYTES { + if !imp.tags_capped { + log::warn!("pptx: add-in tags over {MAX_TAG_BYTES} bytes; the rest are dropped"); + imp.tags_capped = true; + } + return out; + } + imp.tag_bytes = n; + out.push((name.clone(), val.clone())); + } + } + out +} + +/// Relationship types whose parts are kept verbatim, and whether they hang off the package root. +const KEPT_RELS: [(&str, bool); 3] = [ + ("http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml", false), + ("http://schemas.microsoft.com/office/2020/02/relationships/classificationlabels", true), + ("http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties", true), +]; + +/// A part we may keep: outside `ppt/` and the properties the writer makes itself. +fn keepable(name: &str) -> bool { + let n = name.to_ascii_lowercase(); + !n.is_empty() + && !n.contains("..") + && !n.starts_with("ppt/") + && n != "docprops/core.xml" + && n != "docprops/app.xml" + && !n.starts_with("[content_types]") +} + +/// Custom XML data, sensitivity labels and custom properties, with the parts they relate to +/// (`itemProps`) and their relationships parts, as written. +fn kept_parts(pkg: &Package, pres_path: &str) -> Vec { + use crate::KeptPart; + let mut out: Vec = Vec::new(); + let text = |name: &str| pkg.get(name).and_then(|b| std::str::from_utf8(b).ok()).map(str::to_string); + for (holder, from_root) in [("", true), (pres_path, false)] { + let Some(doc) = pkg.xml(&craft_opc::rels_path(holder)) else { continue }; + let base = craft_opc::dir_of(holder); + for r in doc.root.children(Local("Relationship")) { + let (Some(ty), Some(target)) = (r.attr_local("Type"), r.attr_local("Target")) else { continue }; + if r.attr_local("TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("external")) + || !KEPT_RELS.iter().any(|(t, root)| *t == ty && *root == from_root) + { + continue; + } + let name = craft_opc::resolve(base, target); + if !keepable(&name) || out.iter().any(|k| k.name.eq_ignore_ascii_case(&name)) || out.len() >= 400 { + continue; + } + let Some(xml) = text(&name) else { continue }; + out.push(KeptPart { + content_type: pkg.content_type(&name).unwrap_or_default(), + name: name.clone(), + xml, + rel_type: ty.to_string(), + from_root, + }); + // The part's own relationships (customXml item → itemProps) and their targets. + let rp = craft_opc::rels_path(&name); + if let Some(rels_xml) = text(&rp) { + let mut capped = false; + for sub in pkg.rels(&name).list.iter().filter(|s| !s.external && keepable(&s.target)) { + // Checked before the part is read: repeated relationships to one large part + // must not copy it again each time. + if out.iter().any(|k| k.name.eq_ignore_ascii_case(&sub.target)) { + continue; + } + if out.len() >= 400 { + capped = true; + continue; + } + if let Some(x) = text(&sub.target) { + out.push(KeptPart { + content_type: pkg.content_type(&sub.target).unwrap_or_default(), + name: sub.target.clone(), + xml: x, + ..Default::default() + }); + } + } + if !capped && out.len() < 400 { + out.push(KeptPart { name: rp, xml: rels_xml, ..Default::default() }); + } + } + } + } + out +} + +fn load(pkg: &Package, name: &str) -> Option { + let mut d = pkg.xml(name)?; + let rels = pkg.rels(name); + // A 3-D model choice stays (even when its `Requires` prefix is not otherwise understood), so + // the shape reader can keep the model and its fallback picture. + xmlx::resolve_mc_keeping(&mut d.root, &|ac| ac.children(Local("Choice")).any(|ch| choice_has_model3d(ch, &rels))); + Some(d) +} + +fn max_cnvpr(el: &El, m: &mut u32) { + let v = el.find_all(Local("cNvPr")); + for c in v { + if let Some(id) = c.attr_local("id").and_then(value::to_u32_lossy) + && id < shapes::MAX_KEPT_ID + { + *m = (*m).max(id); + } + } +} + +/// The theme of a `.thmx` theme file (its `officeDocument` is a theme part), or of the first +/// master of a `.pptx` / `.potx`, with the media its format scheme uses (ids from 1). +pub fn import_theme(bytes: &[u8]) -> Result<(crate::theme::Theme, Vec), Error> { + let pkg = Package::open(bytes)?; + let target = pkg.rels("").first("officeDocument").map(|r| r.target.clone()); + let theme_path = match &target { + Some(t) if pkg.xml(t).is_some_and(|d| d.root.is(Local("theme"))) => t.clone(), + _ => { + // A presentation: its first master's theme. + let pres = target.unwrap_or_else(|| "ppt/presentation.xml".into()); + let pres_doc = load(&pkg, &pres).ok_or_else(|| Error::NotPptx("neither a theme nor a presentation".into()))?; + let pres_part = Part::new(&pkg, &pres, &pres_doc); + let master = pres_doc + .root + .child(Local("sldMasterIdLst")) + .and_then(|l| l.children(Local("sldMasterId")).next()) + .and_then(|m| pres_part.rels.target(m.attr_local("r:id").unwrap_or("")).map(String::from)) + .or_else(|| pres_part.rels.all("slideMaster").next().map(|r| r.target.clone())) + .ok_or_else(|| Error::NotPptx("the presentation has no slide master".into()))?; + pkg.rels(&master).first("theme").map(|r| r.target.clone()).ok_or_else(|| Error::NotPptx("the slide master has no theme".into()))? + } + }; + let doc = load(&pkg, &theme_path).ok_or_else(|| Error::NotPptx(format!("missing or unreadable {theme_path}")))?; + if !doc.root.is(Local("theme")) { + return Err(Error::NotPptx(format!("{theme_path} is not a theme part"))); + } + let mut imp = Imp::new(&pkg, &crate::Plain, 0, chart::MAX_PRESENTATION_CHART_POINTS); + let part = Part::new(&pkg, &theme_path, &doc); + let theme = craft_drawingml::theme::read_theme(&mut Src(&mut imp, &part), &doc.root); + Ok((theme, imp.media)) +} + +pub fn import(bytes: &[u8], defaults: &dyn Defaults) -> Result { + import_checked(bytes, defaults).map(|(p, _)| p) +} + +/// [`import`], plus the parts of the slide list the file names but that couldn't be read (a +/// missing or truncated slide): those slides are left out, so a caller can say so. +pub fn import_checked(bytes: &[u8], defaults: &dyn Defaults) -> Result<(Presentation, Vec), Error> { + import_with_chart_points(bytes, defaults, chart::MAX_PRESENTATION_CHART_POINTS) +} + +/// [`import_checked`] with the presentation's chart point budget set (tests lower it). +pub(crate) fn import_with_chart_points(bytes: &[u8], defaults: &dyn Defaults, chart_points_cap: usize) -> Result<(Presentation, Vec), Error> { + let mut lost_slides: Vec = vec![]; + let pkg = Package::open(bytes)?; + let root_rels = pkg.rels(""); + let pres_path = root_rels.first("officeDocument").map(|r| r.target.clone()).unwrap_or_else(|| "ppt/presentation.xml".into()); + let pres_doc = load(&pkg, &pres_path).ok_or_else(|| Error::NotPptx(format!("missing or unreadable {pres_path}")))?; + let pres_el = &pres_doc.root; + if !pres_el.is(Local("presentation")) { + return Err(Error::NotPptx(format!("{pres_path} is not a presentation part"))); + } + let pres_part = Part::new(&pkg, &pres_path, &pres_doc); + + let (master_paths, slide_entries) = master_and_slide_paths(pres_el, &pres_part); + let (docs, max_id) = load_part_docs(&pkg, &master_paths, &slide_entries); + + let mut imp = Imp::new(&pkg, defaults, max_id.max(1), chart_points_cap); + // Slide ids first so hyperlinks anywhere can point at slides. + let slide_ids = register_slides(&mut imp, &docs, &slide_entries, &mut lost_slides); + read_custom_show_names(&mut imp, pres_el); + + let mut p = Presentation::default(); + + read_presentation_settings(&mut imp, &mut p, &pres_part, pres_el); + let layout_by_part = read_masters(&mut imp, &mut p, &master_paths, &docs); + let file_slide_ids = read_slides(&mut imp, &mut p, &docs, &slide_ids, &layout_by_part); + read_sections(&mut p, &pres_part, pres_el, &file_slide_ids); + p.tags = tags(&mut imp, &pres_part, pres_el); + read_custom_shows(&mut p, &imp, &pres_part, pres_el); + read_guides_and_show(&mut p, &imp, &pres_part); + read_notes_master(&mut imp, &mut p, &pres_part); + read_handout_master(&mut imp, &mut p, &pres_part); + read_document_properties(&mut p, &pkg, &root_rels); + + p.kept_parts = kept_parts(&pkg, &pres_path); + + inherit_placeholder_geometry(&mut p); + default_text_anchor(&mut p); + p.header_footer = header_footer(&p); + p.embedded_fonts = read_embedded_fonts(&mut imp, &pres_part, pres_el); + p.media = std::mem::take(&mut imp.media); + p.next_id = imp.next; + p.fix_next_id(); + Ok((p, lost_slides)) +} + +/// Masters, layouts and slides to read, in file order and without duplicates: (master paths, +/// (file slide id, slide path)). +fn master_and_slide_paths(pres_el: &El, pres_part: &Part) -> (Vec, Vec<(u32, String)>) { + let master_paths: Vec = pres_el + .child(Local("sldMasterIdLst")) + .map(|l| { + l.children(Local("sldMasterId")).filter_map(|m| pres_part.rels.target(m.attr_local("r:id").unwrap_or(""))).map(String::from).collect() + }) + .unwrap_or_default(); + let mut master_paths: Vec = + if master_paths.is_empty() { pres_part.rels.all("slideMaster").map(|r| r.target.clone()).collect() } else { master_paths }; + let mut slide_entries: Vec<(u32, String)> = pres_el + .child(Local("sldIdLst")) + .map(|l| { + l.children(Local("sldId")) + .take(10_000) + .filter_map(|s| { + Some(( + s.attr_local("id").and_then(value::to_u32_lossy).unwrap_or(0), + pres_part.rels.target(s.attr_local("r:id").unwrap_or(""))?.to_string(), + )) + }) + .collect() + }) + .unwrap_or_default(); + let mut seen_masters = HashSet::new(); + master_paths.retain(|m| seen_masters.insert(craft_opc::normalize(m))); + let mut seen_slides = HashSet::new(); + slide_entries.retain(|(_, p)| seen_slides.insert(craft_opc::normalize(p))); + (master_paths, slide_entries) +} + +/// Loads every master, layout and slide part once. Returns them by normalized path and the +/// largest shape id found in them. +fn load_part_docs(pkg: &Package, master_paths: &[String], slide_entries: &[(u32, String)]) -> (HashMap, u32) { + let mut all_parts: Vec = master_paths.to_vec(); + let mut seen_layouts = HashSet::new(); + for m in master_paths { + for r in pkg.rels(m).all("slideLayout") { + let key = craft_opc::normalize(&r.target); + if seen_layouts.insert(key) { + all_parts.push(r.target.clone()); + } + } + } + all_parts.extend(slide_entries.iter().map(|(_, p)| p.clone())); + let mut docs: HashMap = HashMap::new(); + let mut max_id = 0u32; + for p in &all_parts { + let key = craft_opc::normalize(p); + if docs.contains_key(&key) { + continue; + } + if let Some(d) = load(pkg, p) { + max_cnvpr(&d.root, &mut max_id); + docs.insert(key, d); + } + } + (docs, max_id) +} + +/// Allocates an id for every readable slide; slides that are missing or unreadable are recorded in +/// `lost_slides` and left out. +fn register_slides( + imp: &mut Imp, + docs: &HashMap, + slide_entries: &[(u32, String)], + lost_slides: &mut Vec, +) -> Vec<(u32, String, SlideId)> { + let mut slide_ids: Vec<(u32, String, SlideId)> = vec![]; + for (file_id, path) in slide_entries { + if !docs.contains_key(&craft_opc::normalize(path)) { + log::warn!("pptx: slide {path} is missing or unreadable; skipped"); + lost_slides.push(path.clone()); + continue; + } + let id = SlideId(imp.alloc()); + imp.slide_by_part.insert(craft_opc::normalize(path), id); + slide_ids.push((*file_id, path.clone(), id)); + } + slide_ids +} + +fn read_custom_show_names(imp: &mut Imp, pres_el: &El) { + if let Some(l) = pres_el.child(Local("custShowLst")) { + for cs in l.children(Local("custShow")) { + if let (Some(id), Some(name)) = (cs.attr_local("id"), cs.attr_local("name")) { + imp.custom_show_names.insert(id.to_string(), name.to_string()); + } + } + } +} + +/// Slide and notes size, first slide number, default text style and table styles. +fn read_presentation_settings(imp: &mut Imp, p: &mut Presentation, pres_part: &Part, pres_el: &El) { + if let Some(sz) = pres_el.child(Local("sldSz")) { + let w = sz.attr_local("cx").and_then(value::to_i64_lossy).map(emu_to_pt).unwrap_or(960.0); + let h = sz.attr_local("cy").and_then(value::to_i64_lossy).map(emu_to_pt).unwrap_or(540.0); + p.slide_size = Size::new(sane_len(w, 960.0), sane_len(h, 540.0)); + } + if let Some(sz) = pres_el.child(Local("notesSz")) { + let w = sz.attr_local("cx").and_then(value::to_i64_lossy).map(emu_to_pt).unwrap_or(540.0); + let h = sz.attr_local("cy").and_then(value::to_i64_lossy).map(emu_to_pt).unwrap_or(720.0); + p.notes_size = Size::new(sane_len(w, 540.0), sane_len(h, 720.0)); + } + p.first_slide_number = pres_el.attr_local("firstSlideNum").and_then(value::to_u32_lossy).unwrap_or(1).min(9999); + if let Some(dts) = pres_el.child(Local("defaultTextStyle")) { + p.default_text_style = dml::list_style(&mut Src(imp, pres_part), dts); + } + if let Some(r) = pres_part.rels.first("tableStyles") + && let Some(d) = load(imp.pkg, &r.target) + { + let part = Part::new(imp.pkg, &r.target, &d); + p.table_styles = table_styles(imp, &part, &d.root); + imp.table_style_ids = p.table_styles.iter().map(|s| s.id.clone()).collect(); + } +} + +/// Reads the slide masters and their layouts; a file without a readable master gets a default one. +fn read_masters(imp: &mut Imp, p: &mut Presentation, master_paths: &[String], docs: &HashMap) -> HashMap { + let mut layout_by_part: HashMap = HashMap::new(); + for mp in master_paths { + let Some(doc) = docs.get(&craft_opc::normalize(mp)) else { + log::warn!("pptx: master {mp} missing; skipped"); + continue; + }; + let master = read_master(imp, mp, doc, docs, &mut layout_by_part); + p.masters.push(Arc::new(master)); + } + if p.masters.is_empty() { + log::warn!("pptx: no readable slide master; using a default one"); + let (m, last) = imp.defaults.master(p.slide_size, imp.next); + imp.next = imp.next.max(last); + p.masters.push(Arc::new(m)); + } + layout_by_part +} + +/// Reads every registered slide. Returns the slide ids by file id. +fn read_slides( + imp: &mut Imp, + p: &mut Presentation, + docs: &HashMap, + slide_ids: &[(u32, String, SlideId)], + layout_by_part: &HashMap, +) -> HashMap { + let pkg = imp.pkg; + let fallback_layout = p.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id).unwrap_or_default(); + let mut file_slide_ids: HashMap = HashMap::new(); + for (file_id, path, id) in slide_ids { + let Some(doc) = docs.get(&craft_opc::normalize(path)) else { continue }; + let part = Part::new(pkg, path, doc); + let layout = + part.rels.first("slideLayout").and_then(|r| layout_by_part.get(&craft_opc::normalize(&r.target)).copied()).unwrap_or(fallback_layout); + let slide = read_slide(imp, &part, doc, *id, layout); + file_slide_ids.insert(*file_id, *id); + p.slides.push(Arc::new(slide)); + } + file_slide_ids +} + +/// The slide's notes text. A notes part is the notes of the first slide that names it. +/// Sections, and the extension elements the model does not understand (kept verbatim). +fn read_sections(p: &mut Presentation, pres_part: &Part, pres_el: &El, file_slide_ids: &HashMap) { + let Some(ext) = pres_el.child(Local("extLst")) else { return }; + if let Some(sl) = ext.find(Local("sectionLst")) { + for s in sl.children(Local("section")).take(10_000) { + let slides = s + .child(Local("sldIdLst")) + .map(|l| { + l.children(Local("sldId")) + .filter_map(|x| x.attr_local("id").and_then(value::to_u32_lossy).and_then(|i| file_slide_ids.get(&i).copied())) + .collect() + }) + .unwrap_or_default(); + p.sections.push(Section { name: s.attr_local("name").unwrap_or("").to_string(), slides }); + } + } + let kept: String = ext + .children(Local("ext")) + .filter(|e| e.attr_local("uri") != Some(crate::SECTION_EXT_URI) && !has_rel_refs(e)) + .map(|e| pres_part.keep(e)) + .collect(); + if !kept.is_empty() { + p.raw_ext = Some(kept); + } +} + +fn read_custom_shows(p: &mut Presentation, imp: &Imp, pres_part: &Part, pres_el: &El) { + let Some(l) = pres_el.child(Local("custShowLst")) else { return }; + for cs in l.children(Local("custShow")) { + let slides = cs + .child(Local("sldLst")) + .map(|l| { + l.children(Local("sld")) + .filter_map(|s| pres_part.rels.target(s.attr_local("r:id").unwrap_or(""))) + .filter_map(|t| imp.slide_by_part.get(&craft_opc::normalize(t)).copied()) + .collect() + }) + .unwrap_or_default(); + p.custom_shows.push(CustomShow { name: cs.attr_local("name").unwrap_or("").to_string(), slides }); + } +} + +/// Drawing guides (eighths of a point) and slide show settings. +fn read_guides_and_show(p: &mut Presentation, imp: &Imp, pres_part: &Part) { + let pkg = imp.pkg; + if let Some(r) = pres_part.rels.first("viewProps") + && let Some(d) = pkg.xml(&r.target) + && let Some(lst) = d.root.find(Local("slideViewPr")).and_then(|v| v.find(Local("guideLst"))) + { + p.guides = lst + .children(Local("guide")) + .filter_map(|g| { + let pos = g.attr_local("pos").and_then(|v| v.parse::().ok()).unwrap_or(0.0) / 8.0; + pos.is_finite().then_some(crate::DrawGuide { vertical: g.attr_local("orient") != Some("horz"), pos }) + }) + .take(1000) + .collect(); + } + if let Some(r) = pres_part.rels.first("presProps") + && let Some(d) = pkg.xml(&r.target) + && let Some(sp) = d.root.child(Local("showPr")) + { + p.show = read_show(sp, &imp.custom_show_names); + } +} + +/// Theme, background, shapes and colour map shared by the notes and handout masters. +fn read_aux_master(imp: &mut Imp, part: &Part, doc: &Doc, m: &mut Master) { + let mut ctx = shapes::IdCtx::default(); + if let Some(csld) = doc.root.child(Local("cSld")) { + m.background = background(imp, part, csld); + if let Some(tree) = csld.child(Local("spTree")) { + m.shapes = shapes::sp_tree(imp, part, &mut ctx, tree, 0); + } + } + if let Some(cm) = doc.root.child(Local("clrMap")) { + m.color_map = color_map(cm); + } +} + +/// Reads the part's own theme into the master, if it has one. +fn read_master_theme(imp: &mut Imp, part: &Part, m: &mut Master) -> bool { + let pkg = imp.pkg; + if let Some(t) = part.rels.first("theme") + && let Some(td) = pkg.xml(&t.target) + { + let tp = Part::new(pkg, &t.target, &td); + m.theme = craft_drawingml::theme::read_theme(&mut Src(imp, &tp), &td.root); + return true; + } + false +} + +fn read_notes_master(imp: &mut Imp, p: &mut Presentation, pres_part: &Part) { + let pkg = imp.pkg; + let Some(r) = pres_part.rels.first("notesMaster") else { return }; + let Some(d) = load(pkg, &r.target) else { return }; + let part = Part::new(pkg, &r.target, &d); + let mut m = Master { id: MasterId(imp.alloc()), name: "Notes Master".into(), theme: imp.defaults.theme(), ..Default::default() }; + read_master_theme(imp, &part, &mut m); + read_aux_master(imp, &part, &d, &mut m); + // Notes text styles are the body's list style; slide masters use title/body/other instead. + if let Some(ns) = d.root.child(Local("notesStyle")) { + m.body_style = dml::list_style(&mut Src(imp, &part), ns); + } + p.notes_master = Some(Arc::new(m)); +} + +/// Handout master (header, footer, date and slide number for printed handouts). +fn read_handout_master(imp: &mut Imp, p: &mut Presentation, pres_part: &Part) { + let pkg = imp.pkg; + let Some(r) = pres_part.rels.first("handoutMaster") else { return }; + let Some(d) = load(pkg, &r.target) else { return }; + let part = Part::new(pkg, &r.target, &d); + let mut m = Master { id: MasterId(imp.alloc()), name: "Handout Master".into(), theme: imp.defaults.theme(), ..Default::default() }; + if !read_master_theme(imp, &part, &mut m) + && let Some(first) = p.masters.first() + { + m.theme = first.theme.clone(); + } + if let Some(n) = d.root.child(Local("cSld")).and_then(|c| c.attr_local("name")).filter(|s| !s.is_empty()) { + m.name = n.to_string(); + } + read_aux_master(imp, &part, &d, &mut m); + p.handout_master = Some(Arc::new(m)); +} + +fn read_document_properties(p: &mut Presentation, pkg: &Package, root_rels: &Rels) { + if let Some(r) = root_rels.first("core-properties") + && let Some(d) = pkg.xml(&r.target) + { + read_core(&d.root, &mut p.props); + } + if let Some(r) = root_rels.first("extended-properties") + && let Some(d) = pkg.xml(&r.target) + { + p.props.company = d.root.child(Local("Company")).map(|c| c.deep_text()).unwrap_or_default(); + } +} + +/// Embedded font faces in one file. Past this, further typefaces are dropped. +const MAX_EMBEDDED_FONTS: usize = 64; +/// One font part. A larger part is skipped; the rest of the file is still read. +const MAX_FONT_BYTES: usize = 32 * 1024 * 1024; + +/// `p:embeddedFontLst`: each typeface and the font parts its four face slots point at. +fn read_embedded_fonts(imp: &mut Imp, pres: &Part, root: &El) -> Vec { + let Some(list) = root.child(Local("embeddedFontLst")) else { return Vec::new() }; + let mut out = Vec::new(); + for e in list.children(Local("embeddedFont")).take(MAX_EMBEDDED_FONTS) { + let Some(font) = e.child(Local("font")) else { continue }; + let Some(typeface) = font.attr_local("typeface").map(str::trim).filter(|t| !t.is_empty()) else { continue }; + let typeface: String = typeface.chars().take(255).collect(); + let slot = |imp: &mut Imp, name: &str| -> Option { + let id = e.child(Local(name))?.attr_local("r:id")?; + let part = pres.rels.target(id)?; + let data = imp.pkg.get(part)?; + if data.is_empty() || data.len() > MAX_FONT_BYTES { + log::warn!("pptx: embedded font {part} skipped ({} bytes)", data.len()); + return None; + } + imp.media_for(part) + }; + let regular = slot(imp, "regular"); + let bold = slot(imp, "bold"); + let italic = slot(imp, "italic"); + let bold_italic = slot(imp, "boldItalic"); + if regular.is_none() && bold.is_none() && italic.is_none() && bold_italic.is_none() { + continue; + } + out.push(EmbeddedFont { + typeface, + panose: panose_attr(font), + pitch_family: byte_attr(font, "pitchFamily"), + charset: byte_attr(font, "charset"), + regular, + bold, + italic, + bold_italic, + }); + } + out +} + +fn byte_attr(el: &El, name: &str) -> Option { + u8::try_from(el.attr_local(name).and_then(value::to_u32_lossy)?).ok() +} + +/// `panose` is 20 hex digits (ST_Panose). Anything else is dropped. +fn panose_attr(el: &El) -> Option { + let v = el.attr_local("panose")?.trim(); + (v.len() == 20 && v.chars().all(|c| c.is_ascii_hexdigit())).then(|| v.to_string()) +} + +fn sane_len(v: f64, dflt: f64) -> f64 { + if v.is_finite() && (1.0..=20_000.0).contains(&v) { v } else { dflt } +} + +/// Does the element (or a descendant) reference a relationship? +pub fn color_map(cm: &El) -> Vec<(String, String)> { + cm.attrs.iter().filter(|(k, _)| !k.starts_with("xmlns")).map(|(k, v)| (k.clone(), v.clone())).collect() +} + +/// A shape's text without an anchor of its own sits at the top, as the standard has it and as +/// PowerPoint draws it. The theme's shape defaults (`a:objectDefaults/a:spDef/a:bodyPr`, centred in +/// Office's themes) are for shapes inserted later, not for the file's own shapes: PowerPoint for +/// Mac draws `` at the top under an `spDef` with `anchor="ctr"`. The model's own default +/// for shapes is the middle, so a top or bottom anchor is made explicit here and a middle one left +/// unset (placeholders and text boxes keep inheriting). +fn default_text_anchor(p: &mut Presentation) { + fn fix(shapes: &mut [crate::Shape]) { + walk_shapes_mut(shapes, &mut |s| { + if s.ph.is_none() + && !s.text_box + && !matches!(s.kind, crate::ShapeKind::Table(_)) + && let Some(t) = &mut s.text + { + // Middle is the model's own default for shapes, so it is kept as unset. + let got = t.body.anchor.unwrap_or(crate::text::Anchor::Top); + t.body.anchor = (got != crate::text::Anchor::Middle).then_some(got); + } + }); + } + for m in &mut p.masters { + let m = Arc::make_mut(m); + fix(&mut m.shapes); + for l in &mut m.layouts { + fix(&mut l.shapes); + } + } + for s in &mut p.slides { + fix(&mut Arc::make_mut(s).shapes); + } +} + +/// Placeholders read without a geometry (an empty custom geometry, see `shapes::sp`) take the +/// geometry of the layout or master placeholder they inherit from, as PowerPoint draws them: a +/// layout's picture placeholder cut into a shape keeps that shape on the slide. Anything left +/// is a rectangle. +fn inherit_placeholder_geometry(p: &mut Presentation) { + let unset = |g: &Geom| matches!(g, Geom::Custom { paths } if paths.is_empty()); + // Masters first, then layouts (from their masters), then slides (from layouts and masters). + for m in &mut p.masters { + let m = Arc::make_mut(m); + walk_shapes_mut(&mut m.shapes, &mut |s| { + if unset(&s.geom) { + s.geom = Geom::default(); + } + }); + } + let mut layout_geoms: Vec<(LayoutId, usize, Geom)> = vec![]; + for m in &p.masters { + for l in &m.layouts { + for (i, s) in l.shapes.iter().enumerate() { + if unset(&s.geom) { + let (_, mp) = parents(m, Some(l), Owner::Layout, s); + layout_geoms.push((l.id, i, mp.map(|m| m.geom.clone()).filter(|g| !unset(g)).unwrap_or_default())); + } + } + } + } + for m in &mut p.masters { + let m = Arc::make_mut(m); + for l in &mut m.layouts { + for (id, i, g) in layout_geoms.iter().filter(|(id, _, _)| *id == l.id) { + let _ = id; + if let Some(s) = l.shapes.get_mut(*i) { + s.geom = g.clone(); + } + } + } + } + let mut slide_geoms: Vec<(usize, usize, Geom)> = vec![]; + for (si, sl) in p.slides.iter().enumerate() { + let Some((m, l)) = p.master_for(sl) else { continue }; + for (i, s) in sl.shapes.iter().enumerate() { + if unset(&s.geom) { + let (lp, mp) = parents(m, l, Owner::Slide, s); + let g = lp.map(|l| l.geom.clone()).filter(|g| !unset(g)).or_else(|| mp.map(|m| m.geom.clone()).filter(|g| !unset(g))); + slide_geoms.push((si, i, g.unwrap_or_default())); + } + } + } + for (si, i, g) in slide_geoms { + if let Some(sl) = p.slides.get_mut(si) + && let Some(s) = Arc::make_mut(sl).shapes.get_mut(i) + { + s.geom = g; + } + } + // Placeholders inside groups (rare) and anything still unset: rectangles. + for sl in &mut p.slides { + walk_shapes_mut(&mut Arc::make_mut(sl).shapes, &mut |s| { + if unset(&s.geom) { + s.geom = Geom::default(); + } + }); + } + for m in &mut p.masters { + for l in &mut Arc::make_mut(m).layouts { + walk_shapes_mut(&mut l.shapes, &mut |s| { + if unset(&s.geom) { + s.geom = Geom::default(); + } + }); + } + } +} + +/// Every shape, groups included. +fn walk_shapes_mut(shapes: &mut [crate::Shape], f: &mut dyn FnMut(&mut crate::Shape)) { + for s in shapes { + f(s); + if let crate::ShapeKind::Group { children, .. } = &mut s.kind { + walk_shapes_mut(children, f); + } + } +} + +/// `a:tblStyleLst`: the table styles the file defines. +fn table_styles(imp: &mut Imp, part: &Part, root: &El) -> Vec { + use crate::table::{TableBorder, TablePart, TablePartStyle, TableStyleDef}; + root.children(Local("tblStyle")) + .take(256) + .filter_map(|st| { + let id = st.attr_local("styleId")?.to_ascii_uppercase(); + let mut def = TableStyleDef { id, name: st.attr_local("styleName").unwrap_or("").to_string(), raw: part.keep(st), ..Default::default() }; + if let Some(bg) = st.child(Local("tblBg")) { + let mut ps = TablePartStyle::default(); + if let Some(f) = bg.elements().find(|e| e.local().ends_with("Fill")) { + ps.fill = dml::fill_el(&mut Src(imp, part), f); + } + if let Some(fr) = bg.child(Local("fillRef")) { + ps.fill_ref = Some(( + fr.attr_local("idx").and_then(value::to_u32_lossy).unwrap_or(0), + dml::color(fr).unwrap_or(crate::ColorRef::scheme(craft_drawingml::color::SchemeSlot::Accent1)), + )); + } + def.background = Some(ps); + } + for el in st.elements() { + let Some(kind) = TablePart::from_xml(el.local()) else { continue }; + let mut ps = TablePartStyle::default(); + if let Some(tx) = el.child(Local("tcTxStyle")) { + ps.text_color = dml::color(tx); + ps.bold = tx.attr_local("b").map(|v| v == "on"); + ps.italic = tx.attr_local("i").map(|v| v == "on"); + } + if let Some(tc) = el.child(Local("tcStyle")) { + if let Some(f) = tc.child(Local("fill")).and_then(|f| f.elements().next()) { + ps.fill = dml::fill_el(&mut Src(imp, part), f); + } + if let Some(fr) = tc.child(Local("fillRef")) { + ps.fill_ref = Some(( + fr.attr_local("idx").and_then(value::to_u32_lossy).unwrap_or(0), + dml::color(fr).unwrap_or(crate::ColorRef::scheme(craft_drawingml::color::SchemeSlot::Accent1)), + )); + } + if let Some(bd) = tc.child(Local("tcBdr")) { + for (i, side) in ["left", "right", "top", "bottom", "insideH", "insideV"].iter().enumerate() { + let Some(b) = bd.child(Local(side)) else { continue }; + let border = if let Some(ln) = b.child(Local("ln")) { + Some(TableBorder::Line { line: dml::line(&mut Src(imp, part), ln) }) + } else { + b.child(Local("lnRef")).map(|r| TableBorder::Ref { + idx: r.attr_local("idx").and_then(value::to_u32_lossy).unwrap_or(0), + color: dml::color(r).unwrap_or(crate::ColorRef::scheme(craft_drawingml::color::SchemeSlot::Accent1)), + }) + }; + if let Some(slot) = ps.borders.get_mut(i) { + *slot = border; + } + } + } + } + def.parts.push((kind, ps)); + } + Some(def) + }) + .collect() +} + +fn background(imp: &mut Imp, part: &Part, csld: &El) -> Option { + let bg = csld.child(Local("bg"))?; + if let Some(pr) = bg.child(Local("bgPr")) { + return Some(Background::Fill { fill: dml::fill_of(&mut Src(imp, part), pr).unwrap_or(crate::Fill::None) }); + } + let r = bg.child(Local("bgRef"))?; + Some(Background::Ref { + idx: r.attr_local("idx").and_then(value::to_u32_lossy).unwrap_or(1001), + color: dml::color(r).unwrap_or(crate::ColorRef::scheme(craft_drawingml::color::SchemeSlot::Bg1)), + }) +} + +fn read_master(imp: &mut Imp, path: &str, doc: &Doc, docs: &HashMap, layout_by_part: &mut HashMap) -> Master { + let pkg = imp.pkg; + let part = Part::new(pkg, path, doc); + let root = &doc.root; + let mut m = Master { + id: MasterId(0), + theme: imp.defaults.theme(), + preserve: root.attr_local("preserve").and_then(value::to_bool).unwrap_or(false), + ..Default::default() + }; + if let Some(t) = part.rels.first("theme") + && let Some(td) = pkg.xml(&t.target) + { + let tp = Part::new(pkg, &t.target, &td); + m.theme = craft_drawingml::theme::read_theme(&mut Src(imp, &tp), &td.root); + } + m.id = MasterId(imp.alloc()); + if let Some(cm) = root.child(Local("clrMap")) { + m.color_map = color_map(cm); + } + if let Some(csld) = root.child(Local("cSld")) { + m.name = csld.attr_local("name").unwrap_or("").to_string(); + m.background = background(imp, &part, csld); + if let Some(tree) = csld.child(Local("spTree")) { + let mut ctx = shapes::IdCtx::default(); + m.shapes = shapes::sp_tree(imp, &part, &mut ctx, tree, 0); + } + } + if m.name.is_empty() { + m.name = m.theme.name.clone(); + } + if let Some(ts) = root.child(Local("txStyles")) { + if let Some(s) = ts.child(Local("titleStyle")) { + m.title_style = dml::list_style(&mut Src(imp, &part), s); + } + if let Some(s) = ts.child(Local("bodyStyle")) { + m.body_style = dml::list_style(&mut Src(imp, &part), s); + } + if let Some(s) = ts.child(Local("otherStyle")) { + m.other_style = dml::list_style(&mut Src(imp, &part), s); + } + } + // Layouts in list order, then any other related layouts. + let mut lpaths: Vec = root + .child(Local("sldLayoutIdLst")) + .map(|l| l.children(Local("sldLayoutId")).filter_map(|x| part.rels.target(x.attr_local("r:id").unwrap_or(""))).map(String::from).collect()) + .unwrap_or_default(); + for r in part.rels.all("slideLayout") { + if !lpaths.iter().any(|x| craft_opc::normalize(x) == craft_opc::normalize(&r.target)) { + lpaths.push(r.target.clone()); + } + } + for lp in lpaths.iter().take(1000) { + let key = craft_opc::normalize(lp); + if layout_by_part.contains_key(&key) { + continue; + } + let Some(ld) = docs.get(&key) else { continue }; + let lpart = Part::new(pkg, lp, ld); + let l = read_layout(imp, &lpart, ld); + layout_by_part.insert(key, l.id); + m.layouts.push(l); + } + if m.layouts.is_empty() { + // A master must have a layout for slides to use. + imp.next = imp.next.saturating_add(1); + m.layouts.push(Layout { + id: LayoutId(imp.next), + name: "Blank".into(), + kind: LayoutType::Blank, + show_master_shapes: true, + ..Default::default() + }); + } + if let Some(ext) = root.child(Local("extLst")).filter(|e| !has_rel_refs(e)) { + m.raw_ext = Some(ext.elements().map(|e| part.keep(e)).collect()); + } + m +} + +fn read_layout(imp: &mut Imp, part: &Part, doc: &Doc) -> Layout { + let root = &doc.root; + let mut l = Layout { + id: LayoutId(imp.alloc()), + kind: LayoutType::from_xml(root.attr_local("type").unwrap_or("cust")), + show_master_shapes: root.attr_local("showMasterSp").and_then(value::to_bool).unwrap_or(true), + preserve: root.attr_local("preserve").and_then(value::to_bool).unwrap_or(false), + ..Default::default() + }; + if let Some(csld) = root.child(Local("cSld")) { + l.name = csld.attr_local("name").unwrap_or("").to_string(); + l.background = background(imp, part, csld); + if let Some(tree) = csld.child(Local("spTree")) { + let mut ctx = shapes::IdCtx::default(); + l.shapes = shapes::sp_tree(imp, part, &mut ctx, tree, 0); + } + } + l.color_map = color_map_override(root); + if let Some(ext) = root.child(Local("extLst")).filter(|e| !has_rel_refs(e)) { + l.raw_ext = Some(ext.elements().map(|e| part.keep(e)).collect()); + } + l +} + +/// A layout's or slide's `clrMapOvr/overrideClrMapping` (None for `masterClrMapping` or none). +fn color_map_override(root: &El) -> Option> { + root.child(Local("clrMapOvr"))?.child(Local("overrideClrMapping")).map(color_map) +} + +fn read_slide(imp: &mut Imp, part: &Part, doc: &Doc, id: SlideId, layout: LayoutId) -> Slide { + let root = &doc.root; + let mut s = Slide { + id, + layout, + hidden: root.attr_local("show").and_then(value::to_bool) == Some(false), + show_master_shapes: root.attr_local("showMasterSp").and_then(value::to_bool).unwrap_or(true), + color_map: color_map_override(root), + ..Default::default() + }; + let mut ctx = shapes::IdCtx::default(); + if let Some(csld) = root.child(Local("cSld")) { + s.name = csld.attr_local("name").unwrap_or("").to_string(); + s.tags = tags(imp, part, csld); + s.background = background(imp, part, csld); + if let Some(tree) = csld.child(Local("spTree")) { + s.shapes = shapes::sp_tree(imp, part, &mut ctx, tree, 0); + } + if let Some(c) = csld.child(Local("controls")) { + let pics = shapes::controls(imp, part, &mut ctx, c); + s.shapes.extend(pics); + } + } + if let Some(ext) = root.child(Local("extLst")).filter(|e| !has_rel_refs(e)) { + s.raw_ext = Some(ext.elements().map(|e| part.keep(e)).collect()); + } + s +} + +fn read_show(sp: &El, shows: &HashMap) -> ShowSettings { + let mut s = ShowSettings { + loop_until_esc: sp.attr_local("loop").and_then(value::to_bool).unwrap_or(false), + without_narration: sp.attr_local("showNarration").and_then(value::to_bool) == Some(false), + without_animation: sp.attr_local("showAnimation").and_then(value::to_bool) == Some(false), + use_timings: sp.attr_local("useTimings").and_then(value::to_bool).unwrap_or(true), + show_media_controls: sp.find(Local("showMediaCtrls")).and_then(|e| e.attr_local("val").and_then(value::to_bool)).unwrap_or(true), + ..Default::default() + }; + if sp.child(Local("browse")).is_some() { + s.show_type = "browsed".into(); + } else if sp.child(Local("kiosk")).is_some() { + s.show_type = "kiosk".into(); + } + if let Some(r) = sp.child(Local("sldRg")) { + s.range = Some(( + r.attr_local("st").and_then(value::to_u32_lossy).unwrap_or(1).max(1), + r.attr_local("end").and_then(value::to_u32_lossy).unwrap_or(1).max(1), + )); + } + if let Some(c) = sp.child(Local("custShow")) { + let id = c.attr_local("id").unwrap_or(""); + s.custom_show = Some(shows.get(id).cloned().unwrap_or_else(|| id.to_string())); + } + if let Some(c) = sp.child(Local("penClr")).and_then(dml::color) + && let crate::ColorBase::Rgb { rgb } = c.base + { + s.pen_color = rgb; + } + s +} + +fn read_core(root: &El, p: &mut Properties) { + for e in root.elements() { + let t = e.deep_text(); + match e.local() { + "title" => p.title = t, + "subject" => p.subject = t, + "creator" => p.author = t, + "keywords" => p.keywords = t, + "description" => p.comments = t, + "category" => p.category = t, + "lastModifiedBy" => p.last_modified_by = t, + "revision" => p.revision = t.trim().parse().unwrap_or(0), + "created" => p.created = t, + "modified" => p.modified = t, + _ => {} + } + } +} + +/// Which footer placeholders the slides show. +fn header_footer(p: &Presentation) -> HeaderFooter { + let mut hf = HeaderFooter::default(); + for s in &p.slides { + for sh in &s.shapes { + match sh.ph_type() { + Some(PhType::SlideNum) => hf.slide_number = true, + Some(PhType::Date) => { + hf.date = true; + if let Some(t) = &sh.text + && !t.paragraphs.iter().flat_map(|p| &p.runs).any(|r| matches!(r.kind, crate::text::RunKind::Field { .. })) + && hf.date_text.is_empty() + { + hf.date_text = t.text(); + } + } + Some(PhType::Footer) => { + hf.footer = true; + if hf.footer_text.is_empty() + && let Some(t) = &sh.text + { + hf.footer_text = t.text(); + } + } + _ => {} + } + } + } + hf +} diff --git a/crates/pptx/src/read/chart.rs b/crates/pptx/src/read/chart.rs new file mode 100644 index 000000000..b5f8de595 --- /dev/null +++ b/crates/pptx/src/read/chart.rs @@ -0,0 +1,193 @@ +//! Charts in graphic frames: the chart part, read within the presentation's budgets, and the parts +//! it points at (workbook, styles, colours) kept to be written back. + +use craft_drawingml::chart::chartex::read_chartex_xml; +use craft_drawingml::chart::read::{MAX_CHART_DECODE_BYTES, point_count, read_chart_xml}; +use craft_drawingml::chart::write::sidecar_part; + +pub(crate) use craft_drawingml::chart::read::MAX_PRESENTATION_CHART_POINTS; + +use crate::chart::Chart; + +/// The chart a graphic frame points at. A chart that would cross the presentation's point or +/// decode limits is not modelled: it is kept as its original part with the relationships it +/// reaches (`verbatim`), so a save writes it back whole instead of dropping it and its workbook. +pub(crate) fn frame_chart(imp: &mut super::Imp, part: &super::Part, rid: &str, uri: &str) -> Option { + let rel = part.rels.by_id(rid)?; + let chartex = uri.ends_with("/chartex"); + let bytes = imp.pkg.xml_bytes(&rel.target)?; + if imp.chart_decode_bytes.saturating_add(bytes.len()) > MAX_CHART_DECODE_BYTES { + log::warn!("pptx: chart {} is past the presentation's chart decode limit and is not kept", rel.target); + return None; + } + imp.chart_decode_bytes += bytes.len(); + let parsed = if imp.chart_points < imp.chart_points_cap { + let c = if chartex { read_chartex_xml(bytes) } else { read_chart_xml(bytes) }?; + let n = point_count(&c); + if imp.chart_points.saturating_add(n) <= imp.chart_points_cap { + imp.chart_points = imp.chart_points.saturating_add(n); + let mut c = c; + c.kept = capture_kept(imp, &rel.target); + return Some(c); + } + Some(c) + } else { + None + }; + // Past the limit: keep the part itself (charged to the sidecar budget) and show an empty chart. + if chartex || imp.kept_sidecar_bytes.saturating_add(bytes.len()) > MAX_KEPT_TOTAL_BYTES { + log::warn!("pptx: chart {} is past the presentation's chart limits and is not kept", rel.target); + return None; + } + imp.kept_sidecar_bytes += bytes.len(); + log::warn!("pptx: chart {} is past the presentation's chart limits; shown empty and written back unchanged", rel.target); + let mut c = parsed.unwrap_or_default(); + c.series.clear(); + c.categories.clear(); + c.category_levels.clear(); + c.raw = Some(String::from_utf8_lossy(bytes).into_owned()); + c.verbatim = true; + c.kept = capture_kept(imp, &rel.target); + Some(c) +} + +const MAX_KEPT_PARTS: usize = 256; +const MAX_KEPT_DEPTH: usize = 8; +pub const MAX_KEPT_TOTAL_BYTES: usize = 32 * 1024 * 1024; + +/// Relationships and parts a chart points at, so a later save can write them back. +pub(crate) fn capture_kept(imp: &mut super::Imp, chart_part: &str) -> crate::chart::ChartKept { + use crate::chart::{ChartKept, ChartRel}; + + let key = craft_opc::normalize(chart_part); + if let Some(cached) = imp.kept_charts.get(&key) { + // Every frame that shows this chart gets its own copy, so each copy is charged like the first. + let bytes: usize = cached.parts.iter().map(part_bytes).sum::() + cached.rels.iter().map(rel_bytes).sum::(); + if imp.kept_sidecar_bytes.saturating_add(bytes) > MAX_KEPT_TOTAL_BYTES { + log::warn!("pptx: chart sidecar retained byte budget exceeded for {chart_part}"); + return ChartKept::default(); + } + imp.kept_sidecar_bytes += bytes; + return cached.clone(); + } + + let charged_before = imp.kept_sidecar_bytes; + if imp.rejected_kept.get(&key) == Some(&charged_before) { + return ChartKept::default(); + } + let mut kept = ChartKept::default(); + // The chart document itself is rewritten; don't store a second copy of it. + let mut seen = std::collections::HashMap::from([(key.clone(), true)]); + for r in imp.pkg.rels(chart_part).list { + if !safe_rel(&r.id, &r.ty, &r.target_raw) { + log::warn!("pptx: skipping chart relationship {}", r.id); + continue; + } + if r.external { + kept.rels.push(ChartRel { id: r.id, rel_type: r.ty, target: r.target_raw, part: String::new(), external: true }); + continue; + } + if copy_part(imp.pkg, &r.target, &mut kept.parts, &mut seen, &mut imp.kept_sidecar_bytes, 0) { + kept.rels.push(ChartRel { id: r.id, rel_type: r.ty, target: r.target_raw, part: r.target, external: false }); + } + } + // The first capture's relationship strings and the parts' names and content types are retained + // too (the part bytes were charged as copied). + let rel_total: usize = + kept.rels.iter().map(rel_bytes).sum::() + kept.parts.iter().map(|p| p.name.len() + p.content_type.len()).sum::(); + if imp.kept_sidecar_bytes.saturating_add(rel_total) > MAX_KEPT_TOTAL_BYTES { + log::warn!("pptx: chart sidecar retained byte budget exceeded for {chart_part}"); + // The dropped parts must not stay charged against later charts. + imp.kept_sidecar_bytes = charged_before; + imp.rejected_kept.insert(key, charged_before); + return ChartKept::default(); + } + imp.kept_sidecar_bytes += rel_total; + imp.kept_charts.insert(key, kept.clone()); + kept +} + +fn safe_rel(id: &str, ty: &str, target: &str) -> bool { + [id, ty, target].iter().all(|s| !s.is_empty() && s.len() <= 4096 && !s.chars().any(|c| c.is_control() || matches!(c, '<' | '>' | '"' | '\''))) +} + +/// `true` when `part` is stored (now or from an earlier relationship). +fn copy_part( + pkg: &crate::package::Package, + part: &str, + out: &mut Vec, + seen: &mut std::collections::HashMap, + retained_bytes: &mut usize, + depth: usize, +) -> bool { + use crate::chart::ChartPart; + + if depth > MAX_KEPT_DEPTH || out.len() >= MAX_KEPT_PARTS { + log::warn!("pptx: chart part closure stopped at {part}"); + return false; + } + let key = craft_opc::normalize(part); + if let Some(ok) = seen.get(&key) { + return *ok; + } + if !sidecar_part(part) { + log::warn!("pptx: chart relationship target {part} is not kept"); + seen.insert(key, false); + return false; + } + let Some(bytes) = pkg.get(part) else { + log::warn!("pptx: chart relationship target {part} is missing"); + seen.insert(key, false); + return false; + }; + let name = pkg.original_name(part).unwrap_or(part).trim_start_matches('/').to_string(); + if !sidecar_part(&name) { + log::warn!("pptx: chart part name {name} is not kept"); + seen.insert(key, false); + return false; + } + let content_type = pkg.content_type(part).unwrap_or_default(); + let stored_bytes = if *retained_bytes + bytes.len() <= MAX_KEPT_TOTAL_BYTES { + *retained_bytes += bytes.len(); + bytes.to_vec() + } else { + log::warn!("pptx: chart sidecar retained byte budget exceeded for {part}"); + Vec::new() + }; + out.push(ChartPart { name, content_type, bytes: stored_bytes, native_entry: None }); + seen.insert(key, true); + let rels_name = craft_opc::rels_path(part); + if sidecar_part(&rels_name) + && seen.get(&craft_opc::normalize(&rels_name)).is_none() + && let Some(rels_bytes) = pkg.get(&rels_name) + { + seen.insert(craft_opc::normalize(&rels_name), true); + let name = pkg.original_name(&rels_name).unwrap_or(&rels_name).trim_start_matches('/').to_string(); + let stored_rels = if *retained_bytes + rels_bytes.len() <= MAX_KEPT_TOTAL_BYTES { + *retained_bytes += rels_bytes.len(); + rels_bytes.to_vec() + } else { + Vec::new() + }; + out.push(ChartPart { + name, + content_type: "application/vnd.openxmlformats-package.relationships+xml".into(), + bytes: stored_rels, + native_entry: None, + }); + let targets: Vec = pkg.rels(part).list.into_iter().filter(|r| !r.external).map(|r| r.target).collect(); + for target in targets { + copy_part(pkg, &target, out, seen, retained_bytes, depth + 1); + } + } + true +} + +/// Bytes of the strings a kept relationship owns. +fn part_bytes(p: &crate::chart::ChartPart) -> usize { + p.bytes.len() + p.name.len() + p.content_type.len() +} + +fn rel_bytes(r: &crate::chart::ChartRel) -> usize { + r.id.len() + r.rel_type.len() + r.target.len() + r.part.len() +} diff --git a/crates/pptx/src/read/shapes.rs b/crates/pptx/src/read/shapes.rs new file mode 100644 index 000000000..8e20a5545 --- /dev/null +++ b/crates/pptx/src/read/shapes.rs @@ -0,0 +1,848 @@ +//! Shape trees: `p:sp`, `p:pic`, `p:grpSp`, `p:cxnSp`, `p:graphicFrame` (tables, charts, SmartArt, +//! OLE and other objects) and `p:contentPart` (3-D models). + +use std::collections::{HashMap, HashSet}; + +use crate::style::{ColorRef, Effects, Fill}; +use crate::table::{Cell, Row, Table}; +use crate::text::{Anchor, TextDir}; +use crate::{Geom, MediaClip, PhType, Placeholder, Shape, ShapeId, ShapeKind, ShapeStyle}; +use craft_drawingml::color::SchemeSlot; +use craft_drawingml::{Xfrm, preset}; + +use craft_drawingml::xmlx; +use craft_xml::{El, Local, value}; + +use super::{Imp, Part, Src, dml}; + +/// File shape ids at or above this are reassigned. +pub const MAX_KEPT_ID: u32 = 1 << 30; +const MAX_TREE_DEPTH: usize = 32; +const MAX_SHAPES: usize = 20_000; + +/// Shape id bookkeeping for one part. +#[derive(Default)] +pub struct IdCtx { + pub used: HashSet, + /// File id → model id. + pub map: HashMap, + /// Always allocate new ids (shapes pulled in from other parts). + pub fresh: bool, + pub count: usize, +} + +impl IdCtx { + fn assign(&mut self, imp: &mut Imp, file_id: Option) -> ShapeId { + if !self.fresh + && let Some(i) = file_id + && (2..MAX_KEPT_ID).contains(&i) + && !self.used.contains(&i) + { + self.used.insert(i); + self.map.insert(i, ShapeId(i)); + return ShapeId(i); + } + let n = imp.alloc(); + self.used.insert(n); + if let Some(i) = file_id { + self.map.entry(i).or_insert(ShapeId(n)); + } + ShapeId(n) + } + pub fn lookup(&self, file_id: u32) -> Option { + self.map.get(&file_id).copied() + } +} + +/// Read the children of an `spTree` / `grpSp`. +pub fn sp_tree(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, tree: &El, depth: usize) -> Vec { + let mut out = vec![]; + if depth > MAX_TREE_DEPTH { + log::warn!("pptx: group nesting too deep in {}", part.name); + return out; + } + for e in tree.elements() { + if ctx.count >= MAX_SHAPES { + log::warn!("pptx: too many shapes in {}", part.name); + break; + } + let s = match e.local() { + "sp" => Some(sp(imp, part, ctx, e)), + "pic" => Some(pic(imp, part, ctx, e)), + "grpSp" => Some(grp(imp, part, ctx, e, depth)), + "cxnSp" => Some(cxn(imp, part, ctx, e)), + "graphicFrame" => Some(frame(imp, part, ctx, e, depth)), + "contentPart" => content_part(imp, part, ctx, e).or_else(|| { + log::warn!("pptx: content part in {} not imported", part.name); + None + }), + "AlternateContent" => read_model_alternate(imp, part, ctx, e), + _ => None, + }; + if let Some(s) = s { + ctx.count += 1; + out.push(s); + } + } + if depth == 0 { + remap_connectors(&mut out, ctx, 0); + } + out +} + +/// ActiveX controls (`p:controls`): each control's picture (`p:pic`), the image PowerPoint shows +/// for it, as a picture above the slide's shapes. Controls without one are left out. +pub fn controls(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, controls: &El) -> Vec { + let mut out = vec![]; + for c in controls.children(Local("control")) { + if ctx.count >= MAX_SHAPES { + break; + } + if let Some(p) = c.child(Local("pic")) { + ctx.count += 1; + out.push(pic(imp, part, ctx, p)); + } + } + out +} + +fn remap_connectors(shapes: &mut [Shape], ctx: &IdCtx, depth: usize) { + if depth > MAX_TREE_DEPTH { + return; + } + for s in shapes.iter_mut() { + match &mut s.kind { + ShapeKind::Connector { start, end } => { + for e in [start, end] { + *e = e.and_then(|(id, site)| ctx.lookup(id.0).map(|m| (m, site))); + } + } + ShapeKind::Group { children, .. } => remap_connectors(children, ctx, depth + 1), + _ => {} + } + } +} + +/// PowerPoint's Lock (right-click > Lock, the Selection pane padlock) writes a full set of locking +/// attributes; it reads an object as locked only when the whole set for its kind is there, so a +/// lone `noMove` or `noResize` (other tools' partial locks) isn't Lock. +fn is_locked(l: &El) -> bool { + let set: &[&str] = match l.local() { + "grpSpLocks" => &["noGrp", "noUngrp", "noRot", "noMove", "noResize"], + "graphicFrameLocks" => &["noGrp", "noDrilldown", "noMove", "noResize"], + _ => &["noGrp", "noRot", "noMove", "noResize", "noEditPoints", "noAdjustHandles", "noChangeArrowheads", "noChangeShapeType"], + }; + set.iter().all(|a| l.attr_local(a).and_then(value::to_bool) == Some(true)) +} + +/// Common non-visual properties (`p:nvSpPr`, `p:nvPicPr`…). +fn base(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, nv: Option<&El>) -> Shape { + let c = nv.and_then(|n| n.child(Local("cNvPr"))); + let mut s = Shape { id: ctx.assign(imp, c.and_then(|c| c.attr_local("id").and_then(value::to_u32_lossy))), ..Default::default() }; + if let Some(c) = c { + s.name = c.attr_local("name").unwrap_or("").to_string(); + s.descr = c.attr_local("descr").unwrap_or("").to_string(); + s.title = c.attr_local("title").unwrap_or("").to_string(); + s.hidden = c.attr_local("hidden").and_then(value::to_bool).unwrap_or(false); + s.click = c.child(Local("hlinkClick")).and_then(|h| dml::hyperlink(&mut Src(imp, part), h)); + s.hover = c.child(Local("hlinkHover")).and_then(|h| dml::hyperlink(&mut Src(imp, part), h)); + s.decorative = c.find(Local("decorative")).and_then(|d| d.attr_local("val").and_then(value::to_bool)).unwrap_or(false); + } + if let Some(nv) = nv { + for e in nv.elements() { + match e.local() { + "cNvSpPr" => { + s.text_box = e.attr_local("txBox").and_then(value::to_bool).unwrap_or(false); + s.locked = e.child(Local("spLocks")).is_some_and(is_locked); + } + "cNvPicPr" | "cNvGrpSpPr" | "cNvCxnSpPr" | "cNvGraphicFramePr" => { + s.locked = e.elements().find(|l| l.local().ends_with("Locks")).is_some_and(is_locked); + } + "nvPr" => { + if let Some(ph) = e.child(Local("ph")) { + s.ph = Some(Placeholder { + kind: ph.attr_local("type").map(PhType::from_xml).unwrap_or(PhType::Obj), + idx: ph.attr_local("idx").and_then(value::to_u32_lossy).unwrap_or(0), + vertical: ph.attr_local("orient") == Some("vert"), + size: ph.attr_local("sz").map(String::from), + has_custom_prompt: ph.attr_local("hasCustomPrompt").and_then(value::to_bool).unwrap_or(false), + }); + } + s.tags = super::tags(imp, part, e); + } + _ => {} + } + } + } + s +} + +fn preset_geom(pg: &El) -> Geom { + let name = pg.attr_local("prst").unwrap_or("rect").to_string(); + let defaults: Vec = preset::info(&name).map(|i| i.defaults.to_vec()).unwrap_or_default(); + let mut adj = defaults; + let mut any = false; + if let Some(av) = pg.child(Local("avLst")) { + for gd in av.children(Local("gd")).take(16) { + let n = gd.attr_local("name").unwrap_or(""); + let idx = if n == "adj" { + 0 + } else if let Some(k) = n.strip_prefix("adj").and_then(|k| k.parse::().ok()).filter(|k| (1..=16).contains(k)) { + k - 1 + } else { + continue; + }; + let Some(v) = + gd.attr_local("fmla").and_then(|f| f.trim().strip_prefix("val")).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()) + else { + continue; + }; + if adj.len() <= idx { + adj.resize(idx + 1, 0.0); + } + if let Some(slot) = adj.get_mut(idx) { + *slot = v; + any = true; + } + } + } + if !any { + adj.clear(); + } + Geom::Preset { name, adj } +} + +/// A placeholder whose `spPr` names no geometry takes its layout's (or master's) placeholder's: +/// marked here with an empty custom geometry, filled in once the layouts are read +/// (`inherit_placeholder_geometry`). +fn mark_inherited_geometry(s: &mut Shape, pr: Option<&El>) { + if s.ph.is_some() && pr.is_none_or(|pr| pr.child(Local("prstGeom")).is_none() && pr.child(Local("custGeom")).is_none()) { + s.geom = Geom::Custom { paths: vec![] }; + } +} + +/// `spPr` → transform, geometry, fill, line, effects. +fn sp_pr(imp: &mut Imp, part: &Part, s: &mut Shape, pr: &El) { + if let Some(x) = pr.child(Local("xfrm")) { + s.xfrm = Some(dml::xfrm(x).0); + } + if let Some(pg) = pr.child(Local("prstGeom")) { + s.geom = preset_geom(pg); + } else if let Some(cg) = pr.child(Local("custGeom")) { + let (w, h) = s.xfrm.map(|x| (craft_drawingml::pt_to_emu(x.w) as f64, craft_drawingml::pt_to_emu(x.h) as f64)).unwrap_or((21600.0, 21600.0)); + let paths = craft_drawingml::custgeom::read_cust_geom(cg, w, h); + if !paths.is_empty() { + s.geom = Geom::Custom { paths }; + } + } + s.fill = dml::fill_of(&mut Src(imp, part), pr); + if let Some(ln) = pr.child(Local("ln")) { + s.line = Some(dml::line(&mut Src(imp, part), ln)); + } + if let Some(fx) = pr.child(Local("effectLst")) { + s.effects = Some(dml::effects(fx)); + } + let raw: String = pr.elements().filter(|c| c.is(Local("scene3d")) || c.is(Local("sp3d"))).map(|c| part.keep(c)).collect(); + if !raw.is_empty() { + let fx = s.effects.get_or_insert_with(Effects::default); + fx.bevel = pr.path(&[Local("sp3d"), Local("bevelT")]).map(|b| b.attr_local("prst").unwrap_or("circle").to_string()); + fx.raw3d = Some(raw); + } +} + +fn style(st: &El) -> ShapeStyle { + let r = |n: &str| { + st.child(Local(n)) + .map(|e| { + ( + e.attr_local("idx").and_then(value::to_u32_lossy).unwrap_or(0).min(1003), + dml::color(e).unwrap_or(ColorRef::scheme(SchemeSlot::Accent1)), + ) + }) + .unwrap_or((0, ColorRef::scheme(SchemeSlot::Accent1))) + }; + let font = st.child(Local("fontRef")); + ShapeStyle { + line_ref: r("lnRef"), + fill_ref: r("fillRef"), + effect_ref: r("effectRef"), + font_ref: (font.and_then(|f| f.attr_local("idx")).unwrap_or("minor").to_string(), font.and_then(dml::color)), + } +} + +fn sp(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El) -> Shape { + let mut s = base(imp, part, ctx, e.child(Local("nvSpPr"))); + if let Some(pr) = e.child(Local("spPr")) { + sp_pr(imp, part, &mut s, pr); + } + mark_inherited_geometry(&mut s, e.child(Local("spPr"))); + if e.attr_local("useBgFill").and_then(value::to_bool) == Some(true) { + s.fill = Some(Fill::Background); + } + s.style = e.child(Local("style")).map(style); + s.text = e.child(Local("txBody")).map(|t| dml::text_body(&mut Src(imp, part), t)); + s +} + +fn ms(v: Option<&str>) -> u32 { + v.and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()).map(|v| v.clamp(0.0, u32::MAX as f64) as u32).unwrap_or(0) +} + +fn find_content_part(el: &El) -> Option<&El> { + fn rec(el: &El, depth: usize) -> Option<&El> { + if depth > 16 { + return None; + } + if el.is(Local("contentPart")) { + return Some(el); + } + el.elements().find_map(|c| rec(c, depth + 1)) + } + rec(el, 0) +} + +/// `p:contentPart` whose relationship is a 3-D model. Other content parts (ink) are left out. +fn content_part(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El) -> Option { + let id = e.attr_local("r:id").filter(|s| !s.is_empty())?; + let rel = part.rels.by_id(id)?; + if !super::is_model3d(rel) { + return None; + } + let target = rel.target.clone(); + let media = imp.media_for(&target).or_else(|| { + log::warn!("pptx: 3-D model part missing in {}", part.name); + None + })?; + let mut s = base(imp, part, ctx, e.child(Local("nvContentPartPr"))); + if let Some(x) = e.child(Local("xfrm")) { + s.xfrm = Some(dml::xfrm(x).0); + } + s.kind = ShapeKind::Model3d { media, poster: None }; + Some(s) +} + +/// A 3-D model wrapped in `mc:AlternateContent`: the model choice, plus the fallback picture. +/// If the model part is missing, the fallback picture is the shape. +fn read_model_alternate(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El) -> Option { + if let Some(content) = e.children(Local("Choice")).find_map(find_content_part) + && let Some(mut s) = content_part(imp, part, ctx, content) + { + let poster = e.child(Local("Fallback")).and_then(|fb| { + let pics = fb.find_all(Local("blip")); + pics.into_iter().find_map(|b| dml::media_ref(&mut Src(imp, part), b)) + }); + if let ShapeKind::Model3d { media, .. } = s.kind { + s.kind = ShapeKind::Model3d { media, poster }; + } + return Some(s); + } + e.child(Local("Fallback")).and_then(|fb| fb.elements().find(|c| c.is(Local("pic"))).map(|p| pic(imp, part, ctx, p))) +} + +fn pic(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El) -> Shape { + let mut s = base(imp, part, ctx, e.child(Local("nvPicPr"))); + if let Some(pr) = e.child(Local("spPr")) { + sp_pr(imp, part, &mut s, pr); + } + // A picture in a picture placeholder is cut into the layout's shape (a circle, say). + mark_inherited_geometry(&mut s, e.child(Local("spPr"))); + s.style = e.child(Local("style")).map(style); + let pf = e.child(Local("blipFill")).and_then(|b| dml::blip_fill(&mut Src(imp, part), b)); + let nvpr = e.path(&[Local("nvPicPr"), Local("nvPr")]); + let vf = nvpr.and_then(|n| n.child(Local("videoFile"))); + let af = nvpr.and_then(|n| n.child(Local("audioFile"))); + let p14 = nvpr.and_then(|n| n.child(Local("extLst"))).and_then(|x| x.find(Local("media"))); + if vf.is_some() || af.is_some() || p14.is_some() { + let media = + p14.and_then(|m| dml::media_ref(&mut Src(imp, part), m)).or_else(|| vf.or(af).and_then(|f| dml::media_ref(&mut Src(imp, part), f))); + if let Some(media) = media { + let mut clip = MediaClip { + media, + video: vf.is_some() || af.is_none(), + poster: pf.as_ref().map(|p| p.media), + crop: pf.as_ref().map(|p| p.crop).unwrap_or_default(), + volume: 1.0, + ..Default::default() + }; + if let Some(m) = p14 { + if let Some(t) = m.child(Local("trim")) { + clip.trim_start_ms = ms(t.attr_local("st")); + clip.trim_end_ms = ms(t.attr_local("end")); + } + if let Some(f) = m.child(Local("fade")) { + clip.fade_in_ms = ms(f.attr_local("in")); + clip.fade_out_ms = ms(f.attr_local("out")); + } + if let Some(l) = m.child(Local("bmkLst")) { + clip.bookmarks = l + .children(Local("bmk")) + .take(1000) + .map(|b| (b.attr_local("name").unwrap_or("").to_string(), ms(b.attr_local("time")))) + .collect(); + } + } + s.kind = ShapeKind::Media(clip); + return s; + } + } + match pf { + Some(fill) => s.kind = ShapeKind::Picture { fill }, + None => log::warn!("pptx: picture {} in {} has no readable image", s.name, part.name), + } + s +} + +fn grp(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> Shape { + let mut s = base(imp, part, ctx, e.child(Local("nvGrpSpPr"))); + let mut child = None; + if let Some(pr) = e.child(Local("grpSpPr")) { + if let Some(x) = pr.child(Local("xfrm")) { + let (xf, ch) = dml::xfrm(x); + s.xfrm = Some(xf); + child = ch; + } + s.fill = dml::fill_of(&mut Src(imp, part), pr); + if let Some(fx) = pr.child(Local("effectLst")) { + s.effects = Some(dml::effects(fx)); + } + if let Some(sc) = pr.child(Local("scene3d")) { + let raw = part.keep_inner(sc); + if !raw.is_empty() && raw.len() <= 64 * 1024 { + s.effects.get_or_insert_with(Effects::default).raw3d = Some(raw); + } + } + } + let children = sp_tree(imp, part, ctx, e, depth + 1); + let child = child.or_else(|| s.xfrm.map(|x| Xfrm::new(x.x, x.y, x.w, x.h))).unwrap_or_default(); + s.kind = ShapeKind::Group { children, child }; + s +} + +fn cxn(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El) -> Shape { + let nv = e.child(Local("nvCxnSpPr")); + let mut s = base(imp, part, ctx, nv); + if let Some(pr) = e.child(Local("spPr")) { + sp_pr(imp, part, &mut s, pr); + } + s.style = e.child(Local("style")).map(style); + let c = nv.and_then(|n| n.child(Local("cNvCxnSpPr"))); + let end = |n: &str| { + c.and_then(|c| c.child(Local(n))).and_then(|x| { + Some((ShapeId(x.attr_local("id").and_then(value::to_u32_lossy)?), x.attr_local("idx").and_then(value::to_u32_lossy).unwrap_or(0))) + }) + }; + s.kind = ShapeKind::Connector { start: end("stCxn"), end: end("endCxn") }; + s +} + +fn frame(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> Shape { + let mut s = base(imp, part, ctx, e.child(Local("nvGraphicFramePr"))); + if let Some(x) = e.child(Local("xfrm")) { + s.xfrm = Some(dml::xfrm(x).0); + } + let gd = e.path(&[Local("graphic"), Local("graphicData")]); + let uri = gd.and_then(|g| g.attr_local("uri")).unwrap_or(""); + if let Some(gd) = gd { + if let Some(tbl) = gd.child(Local("tbl")) { + s.kind = ShapeKind::Table(table(imp, part, tbl)); + return s; + } + if let Some(c) = gd.child(Local("chart")) + && let Some(chart) = super::chart::frame_chart(imp, part, c.attr_local("r:id").unwrap_or(""), uri) + { + s.kind = ShapeKind::Chart(Box::new(chart)); + return s; + } + if uri.ends_with("/diagram") + && let Some(g) = smartart(imp, part, gd, &s, depth) + { + return g; + } + } + // Something we don't model: keep its XML and show a preview picture when it has one. + let preview = { + let pics = e.find_all(Local("blip")); + pics.into_iter().find_map(|b| dml::media_ref(&mut Src(imp, part), b)) + } + .or_else(|| gd.and_then(|g| g.find(Local("oleObj"))).and_then(|o| vml_preview(imp, part, o.attr_local("spid")?))); + // An embedded object's fallback picture (`p:oleObj/p:pic`) can carry a fill and an outline, + // which PowerPoint draws behind and around the preview (old equation objects on coloured + // boxes). The frame's own position stays. + if let Some(pr) = gd.and_then(|g| g.find(Local("oleObj"))).and_then(|o| o.find(Local("pic"))).and_then(|p| p.child(Local("spPr"))) { + if s.fill.is_none() { + s.fill = dml::fill_of(&mut Src(imp, part), pr); + } + if s.line.is_none() { + s.line = pr.child(Local("ln")).map(|ln| dml::line(&mut Src(imp, part), ln)); + } + } + let label = if let Some(o) = gd.and_then(|g| g.find(Local("oleObj"))) { + o.attr_local("progId").or(o.attr_local("name")).unwrap_or("Object").to_string() + } else if uri.ends_with("/diagram") { + "SmartArt".to_string() + } else if uri.contains("chart") { + "Chart".to_string() + } else { + uri.rsplit('/').next().unwrap_or("Object").to_string() + }; + // Keep the parts it points to so a save writes it back whole. A part with relationships + // of its own, or an external link, can't be carried: the save then falls back to the preview. + let parts = xmlx::rel_refs(e) + .into_iter() + .map(|id| { + let rel = part.rels.by_id(&id).filter(|r| !r.external)?; + let pkg = imp.pkg; + if *imp.has_rels.entry(craft_opc::normalize(&rel.target)).or_insert_with(|| !pkg.rels(&rel.target).list.is_empty()) { + return None; + } + let (kind, target) = (rel.ty.clone(), rel.target.clone()); + let media = if rel.kind() == "diagramData" { diagram_data(imp, &target) } else { imp.media_for(&target) }; + Some((id, kind, media?)) + }) + .collect::>>() + .unwrap_or_default(); + s.kind = ShapeKind::Opaque { xml: part.keep(e), preview, label, parts }; + s +} + +/// The preview picture of an old-style embedded object (`p:oleObj spid` with no `p:pic`): the +/// `v:imagedata` of the legacy VML drawing shape with that id, as PowerPoint 2007 wrote them. +fn vml_preview(imp: &mut Imp, part: &Part, spid: &str) -> Option { + for rel in part.rels.list.iter().filter(|r| r.kind() == "vmlDrawing" && !r.external) { + let key = craft_opc::normalize(&rel.target); + if !imp.vml_previews.contains_key(&key) { + let index = vml_index(imp.pkg, &rel.target); + imp.vml_previews.insert(key.clone(), index); + } + let Some(target) = imp.vml_previews.get(&key).and_then(|m| m.get(spid)).cloned() else { continue }; + return imp.media_for(&target); + } + None +} + +/// Every shape id in a VML drawing with a picture, mapped to that picture's part: parsed once per +/// drawing however many objects look it up. +fn vml_index(pkg: &crate::package::Package, vml: &str) -> std::collections::HashMap { + let mut out = std::collections::HashMap::new(); + let Some(doc) = pkg.xml(vml) else { return out }; + let rels = pkg.rels(vml); + let shapes = doc.root.find_all(Local("shape")); + for s in shapes { + let Some(id) = s.attr_local("id") else { continue }; + if out.contains_key(id) { + continue; // the first shape with an id wins, as the old lookup did + } + let Some(img) = s.find(Local("imagedata")) else { continue }; + let Some(rid) = img.attr_local("o:relid").or(img.attr_local("r:id")) else { continue }; + if let Some(r) = rels.by_id(rid).filter(|r| !r.external) { + out.insert(id.to_string(), r.target.clone()); + } + } + out +} + +/// A diagram's data part, without the link to its cached drawing: that link is a slide +/// relationship id, which a save renumbers. Only diagrams whose drawing is missing or empty get +/// here, and those are drawn from their data. +fn diagram_data(imp: &mut Imp, target: &str) -> Option { + let norm_target = craft_opc::normalize(target); + if let Some(&id) = imp.media_by_part.get(&norm_target) { + return Some(id); + } + fn strip(e: &mut El, depth: usize) { + if depth > xmlx::MAX_DEPTH { + return; + } + e.children.retain(|n| !matches!(n, craft_xml::Node::El(c) if c.is(Local("ext")) && c.find(Local("dataModelExt")).is_some())); + for c in e.elements_mut() { + strip(c, depth + 1); + } + } + let mut doc = imp.pkg.xml(target)?; + strip(&mut doc.root, 0); + let xml = format!("\n{}", doc.root.to_xml()); + let id = crate::MediaId(imp.alloc()); + let content_type = imp.pkg.content_type(target)?; + imp.media.push(crate::MediaItem { + id, + name: craft_opc::file_of(target).to_string(), + content_type, + data: std::sync::Arc::new(xml.into_bytes()), + link: None, + }); + imp.media_by_part.insert(norm_target, id); + Some(id) +} + +/// SmartArt: read the pre-drawn shapes of its drawing part as a group. +fn smartart(imp: &mut Imp, part: &Part, gd: &El, frame: &Shape, depth: usize) -> Option { + if depth > 8 { + return None; + } + let rel_ids = gd.child(Local("relIds"))?; + let dm = part.rels.by_id(rel_ids.attr_local("r:dm").unwrap_or(""))?; + let data = imp.pkg.xml(&dm.target)?; + let drawing_rel = + data.root.find(Local("dataModelExt")).and_then(|x| x.attr_local("relId")).and_then(|id| part.rels.by_id(id)).map(|r| r.target.clone())?; + let mut d = imp.pkg.xml(&drawing_rel)?; + xmlx::resolve_mc(&mut d.root); + let dpart = Part::new(imp.pkg, &drawing_rel, &d); + let tree = d.root.child(Local("spTree"))?; + let mut sub = IdCtx { fresh: true, ..Default::default() }; + let children = sp_tree(imp, &dpart, &mut sub, tree, depth + 1); + if children.is_empty() { + return None; + } + let children = smartart_text_rects(imp, &mut sub, tree, children, depth + 1); + let mut g = frame.clone(); + let x = frame.xfrm.unwrap_or_default(); + g.kind = ShapeKind::Group { children, child: Xfrm::new(0.0, 0.0, x.w, x.h) }; + g.diagram = diagram_outline(imp, part, rel_ids, &data.root); + // Node pictures, from the blips of the data model's presentation points. + if let Some(d) = &mut g.diagram { + let rels = imp.pkg.rels(&dm.target); + for (k, rid) in craft_drawingml::diagram::parts::node_pictures(&data.root) { + let Some(target) = rels.by_id(&rid).filter(|r| !r.external).map(|r| r.target.clone()) else { continue }; + let media = imp.media_for(&target); + if let Some(n) = d.nodes.get_mut(k) { + n.picture = media; + } + } + } + Some(g) +} + +/// A SmartArt drawing shape (`dsp:sp`) keeps its text rectangle in `dsp:txXfrm`, apart from the +/// shape's own box: the text of a narrow shape turned by 90° is often laid out across it. Put each +/// shape's text on that rectangle. `shapes` are what [`sp_tree`] read from `tree`, in order. +fn smartart_text_rects(imp: &mut Imp, ctx: &mut IdCtx, tree: &El, shapes: Vec, depth: usize) -> Vec { + if depth > MAX_TREE_DEPTH { + return shapes; + } + let mut out = Vec::with_capacity(shapes.len()); + let mut shapes = shapes.into_iter(); + // The same elements `sp_tree` turns into shapes. + for e in tree.elements().filter(|e| matches!(e.local(), "sp" | "pic" | "grpSp" | "cxnSp" | "graphicFrame")) { + let Some(mut s) = shapes.next() else { break }; + let mut text_box = None; + match (e.local(), &mut s.kind) { + ("grpSp", ShapeKind::Group { children, .. }) => { + let kids = std::mem::take(children); + *children = smartart_text_rects(imp, ctx, e, kids, depth + 1); + } + ("sp", ShapeKind::Shape) => { + if let Some(t) = e.child(Local("txXfrm")) { + text_box = text_on_rect(imp, ctx, &mut s, dml::xfrm(t).0); + } + } + _ => {} + } + out.push(s); + out.extend(text_box); + } + out.extend(shapes); + out +} + +/// Lay out the text of `s` on `t`, a text rectangle in the same space as the shape's box, turned by +/// the shape's rotation plus its own. When that is the shape's text area shrunk by some margin, the +/// shape keeps its text with wider insets. Otherwise (a rectangle reaching outside the shape, or +/// turned against it) the text moves to a new frameless text shape over `s`, which is returned: +/// both export as plain DrawingML that any reader lays out the same way. +fn text_on_rect(imp: &mut Imp, ctx: &mut IdCtx, s: &mut Shape, t: Xfrm) -> Option { + const EPS: f64 = 0.01; + let x = s.xfrm?; + if !x.is_finite() || !t.is_finite() || t.w <= EPS || t.h <= EPS { + return None; + } + let body = s.text.as_mut().filter(|b| !b.is_empty())?; + let turn = |a: f64| a.rem_euclid(360.0); + let upright = |a: f64| turn(a + EPS) < 2.0 * EPS; + let bp = &mut body.body; + if !x.flip_h + && !x.flip_v + && upright(t.rot) + && bp.rot.is_none_or(upright) + && bp.vert.is_none_or(|v| v == TextDir::Horizontal) + && bp.upright != Some(true) + { + // Same orientation as the shape: express the rectangle as insets from the shape's text area. + let c = x.affine().inverse() * t.center(); + let (l, top) = (c.x - t.w / 2.0, c.y - t.h / 2.0); + let (r, b) = (l + t.w, top + t.h); + let area = text_area(&s.geom, x.w, x.h); + let extra = [l - area.x0, top - area.y0, area.x1 - r, area.y1 - b]; + if extra.iter().all(|v| v.is_finite() && *v >= -EPS) { + let [el, et, er, eb] = extra.map(|v| v.max(0.0)); + bp.inset_l = Some(bp.inset_l.unwrap_or(7.2) + el); + bp.inset_t = Some(bp.inset_t.unwrap_or(3.6) + et); + bp.inset_r = Some(bp.inset_r.unwrap_or(7.2) + er); + bp.inset_b = Some(bp.inset_b.unwrap_or(3.6) + eb); + return None; + } + } + if ctx.count >= MAX_SHAPES { + return None; + } + ctx.count += 1; + let text = s.text.take(); + // No fill, outline or effects; the style keeps only the text colour and font. + let style = s.style.clone().map(|mut st| { + st.fill_ref.0 = 0; + st.line_ref.0 = 0; + st.effect_ref.0 = 0; + st + }); + Some(Shape { + id: ctx.assign(imp, None), + name: s.name.clone(), + xfrm: Some(Xfrm { rot: turn(x.rot + t.rot), flip_h: false, flip_v: false, ..t }), + fill: Some(Fill::None), + line: Some(crate::style::Line { fill: Some(Fill::None), ..Default::default() }), + style, + text, + click: s.click.clone(), + hover: s.hover.clone(), + ..Default::default() + }) +} + +/// Where a shape's geometry puts its text, in shape-local points (as the renderer computes it). +fn text_area(geom: &Geom, w: f64, h: f64) -> crate::Rect { + let full = crate::Rect::new(0.0, 0.0, w, h); + match geom { + Geom::Preset { name, adj } => { + preset::build(name, w, h, adj).or_else(|| preset::build("rect", w, h, &[])).map(|g| g.text_rect).unwrap_or(full) + } + Geom::Custom { .. } => full, + } +} + +/// The outline of a SmartArt graphic (for the Text Pane), with its layout, quick style and colour +/// parts kept to be written back. +fn diagram_outline(imp: &Imp, part: &Part, rel_ids: &El, data: &El) -> Option { + let nodes = craft_drawingml::diagram::parts::outline(data); + if nodes.is_empty() { + return None; + } + let raw = |attr: &str| -> Option { + let rel = part.rels.by_id(rel_ids.attr_local(attr)?)?; + let bytes = imp.pkg.get(&rel.target)?; + (bytes.len() <= 4 << 20).then(|| String::from_utf8(bytes.to_vec()).ok()).flatten() + }; + let layout_xml = raw("r:lo"); + let (layout_id, cat) = layout_xml + .as_deref() + .and_then(|x| crate::package::parse(x.as_bytes()).ok()) + .map(|d| { + let cat = d.root.child(Local("catLst")).and_then(|c| c.child(Local("cat"))).and_then(|c| c.attr_local("type")).unwrap_or("").to_string(); + (d.root.attr_local("uniqueId").unwrap_or("").to_string(), cat) + }) + .unwrap_or_default(); + let lo_type = data + .path(&[Local("ptLst")]) + .and_then(|l| l.children(Local("pt")).find(|p| p.attr_local("type") == Some("doc"))) + .and_then(|p| p.child(Local("prSet"))) + .and_then(|p| p.attr_local("loTypeId")) + .unwrap_or(""); + let id = if layout_id.is_empty() { lo_type.to_string() } else { layout_id }; + let pr = data + .path(&[Local("ptLst")]) + .and_then(|l| l.children(Local("pt")).find(|p| p.attr_local("type") == Some("doc"))) + .and_then(|p| p.child(Local("prSet"))); + let style = craft_drawingml::diagram::parts::style_for(pr.and_then(|p| p.attr_local("qsTypeId")).unwrap_or("")).to_string(); + let colors = craft_drawingml::diagram::parts::colors_for(pr.and_then(|p| p.attr_local("csTypeId")).unwrap_or("")); + Some(crate::Diagram { + kind: craft_drawingml::diagram::parts::kind_for(&id, &cat).to_string(), + nodes, + style, + colors, + imported: true, + layout_id: id, + layout_xml, + style_xml: raw("r:qs"), + colors_xml: raw("r:cs"), + }) +} + +fn table(imp: &mut Imp, part: &Part, tbl: &El) -> Table { + // A table naming no style is drawn by PowerPoint with thin dark grid lines ("No Style, Table + // Grid"), not without lines. + let mut t = Table { style: "grid".into(), first_row: false, band_row: false, ..Default::default() }; + if let Some(pr) = tbl.child(Local("tblPr")) { + t.background = + pr.elements().find(|e| e.local().ends_with("Fill") && e.local() != "noFill").and_then(|f| dml::fill_el(&mut Src(imp, part), f)); + t.first_row = pr.attr_local("firstRow").and_then(value::to_bool).unwrap_or(false); + t.first_col = pr.attr_local("firstCol").and_then(value::to_bool).unwrap_or(false); + t.last_row = pr.attr_local("lastRow").and_then(value::to_bool).unwrap_or(false); + t.last_col = pr.attr_local("lastCol").and_then(value::to_bool).unwrap_or(false); + t.band_row = pr.attr_local("bandRow").and_then(value::to_bool).unwrap_or(false); + t.band_col = pr.attr_local("bandCol").and_then(value::to_bool).unwrap_or(false); + t.rtl = pr.attr_local("rtl").and_then(value::to_bool); + if let Some(id) = pr.child(Local("tableStyleId")).map(|e| e.deep_text()) { + // A style the file defines is drawn from its definition (by its GUID). + // DeckCraft's own styles (written with GUIDs derived from their ids) map back to them. + let up = id.trim().to_ascii_uppercase(); + t.style = if imp.table_style_ids.contains(&up) && !craft_drawingml::table::styles::is_own_guid(&up) { + up + } else { + craft_drawingml::table::styles::style_from_guid(id.trim()) + }; + } + } + t.cols = tbl + .child(Local("tblGrid")) + .map(|g| g.children(Local("gridCol")).take(1000).map(|c| dml::pt(c, "w").unwrap_or(72.0).clamp(0.0, 20_000.0)).collect()) + .unwrap_or_default(); + for tr in tbl.children(Local("tr")).take(1000) { + let mut row = Row { height: dml::pt(tr, "h").unwrap_or(0.0).clamp(0.0, 20_000.0), cells: vec![] }; + for tc in tr.children(Local("tc")).take(1000) { + row.cells.push(cell(imp, part, tc)); + } + t.rows.push(row); + } + if t.cols.is_empty() { + let n = t.rows.iter().map(|r| r.cells.len()).max().unwrap_or(0); + t.cols = vec![72.0; n]; + } + t.normalize(); + t +} + +fn cell(imp: &mut Imp, part: &Part, tc: &El) -> Cell { + let mut c = Cell { + grid_span: tc.attr_local("gridSpan").and_then(value::to_u32_lossy).unwrap_or(1).clamp(1, 1000), + row_span: tc.attr_local("rowSpan").and_then(value::to_u32_lossy).unwrap_or(1).clamp(1, 1000), + h_merge: tc.attr_local("hMerge").and_then(value::to_bool).unwrap_or(false), + v_merge: tc.attr_local("vMerge").and_then(value::to_bool).unwrap_or(false), + ..Default::default() + }; + if let Some(tx) = tc.child(Local("txBody")) { + c.text = dml::text_body(&mut Src(imp, part), tx); + } + if let Some(pr) = tc.child(Local("tcPr")) { + if ["marL", "marR", "marT", "marB"].iter().any(|n| pr.attr_local(n).is_some()) { + c.margins = Some([ + dml::pt(pr, "marL").unwrap_or(7.2), + dml::pt(pr, "marR").unwrap_or(7.2), + dml::pt(pr, "marT").unwrap_or(3.6), + dml::pt(pr, "marB").unwrap_or(3.6), + ]); + } + c.anchor = pr.attr_local("anchor").and_then(Anchor::from_xml); + c.anchor_ctr = pr.attr_local("anchorCtr").and_then(value::to_bool); + c.horz_overflow = pr.attr_local("horzOverflow").and_then(crate::text::TextOverflow::from_horz_xml); + c.vertical = pr.attr_local("vert").and_then(TextDir::from_xml); + for (i, n) in ["lnL", "lnR", "lnT", "lnB"].iter().enumerate() { + if let Some(ln) = pr.child(Local(n)) + && let Some(b) = c.borders.get_mut(i) + { + *b = Some(dml::line(&mut Src(imp, part), ln)); + } + } + c.diag_down = pr.child(Local("lnTlToBr")).map(|l| dml::line(&mut Src(imp, part), l)); + c.diag_up = pr.child(Local("lnBlToTr")).map(|l| dml::line(&mut Src(imp, part), l)); + c.fill = dml::fill_of(&mut Src(imp, part), pr); + } + c +} diff --git a/crates/pptx/tests/read.rs b/crates/pptx/tests/read.rs new file mode 100644 index 000000000..5fe263f02 --- /dev/null +++ b/crates/pptx/tests/read.rs @@ -0,0 +1,239 @@ +//! The reader on small packages written by hand. + +use craft_opc::{PackageOut, RelsOut}; +use craft_pptx::{Defaults, Geom, LayoutType, Master, PhType, Plain, Presentation, ShapeKind, Size}; + +const NS: &str = r#"xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main""#; +const CT_PRES: &str = "application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"; + +/// A package from (part, content type, xml) and (part, [(kind, target)]) relationships. +fn package(parts: &[(&str, &str, String)], rels: &[(&str, &[(&str, &str)])]) -> Vec { + let mut out = PackageOut::default(); + for (name, ct, xml) in parts { + if ct.is_empty() { + out.binary(name, "image/png", xml.clone().into_bytes()); + } else { + out.xml_part(name, ct, xml.clone().into_bytes()); + } + } + for (part, list) in rels { + let mut r = RelsOut::default(); + for (kind, target) in *list { + r.add_new(kind, target, false); + } + out.rels(part, &r); + } + out.finish().unwrap() +} + +fn sp(id: u32, name: &str, ph: &str, pr: &str, text: &str) -> String { + format!( + r#"{ph}{pr}{text}"# + ) +} + +const XFRM: &str = r#""#; + +fn deck() -> Vec { + let pres = format!( + r#""# + ); + let master = format!( + r#"{}{}"#, + sp(2, "Title", r#""#, XFRM, ""), + sp(3, "Body", r#""#, XFRM, "") + ); + let layout = format!( + r#"{}"#, + sp(2, "Title", r#""#, &format!(r#"{XFRM}"#), "") + ); + let shapes = [ + sp(5, "Title 1", r#""#, "", "Hello"), + sp(6, "Box", "", &format!(r#"{XFRM}"#), "Text"), + format!(r#"{XFRM}"#), + r#"A1B1"#.to_string(), + format!(r#"{}"#, sp(10, "Inner", "", XFRM, "In")), + r#""#.to_string(), + ]; + let slide = format!( + r#"{}"#, + shapes.concat() + ); + let theme = r#""#; + let tags = r#""#; + let core = r#"QuarterlyAnn4"#; + package( + &[ + ("ppt/presentation.xml", CT_PRES, pres), + ("ppt/slideMasters/slideMaster1.xml", "application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml", master), + ("ppt/slideLayouts/slideLayout1.xml", "application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml", layout), + ("ppt/slides/slide1.xml", "application/vnd.openxmlformats-officedocument.presentationml.slide+xml", slide), + ("ppt/theme/theme1.xml", "application/vnd.openxmlformats-officedocument.theme+xml", theme.into()), + ("ppt/tags/tag1.xml", "application/vnd.openxmlformats-officedocument.presentationml.tags+xml", tags.into()), + ("ppt/media/image1.png", "", "png bytes".into()), + ("docProps/core.xml", "application/vnd.openxmlformats-package.core-properties+xml", core.into()), + ], + &[ + ( + "", + &[ + ("officeDocument", "ppt/presentation.xml"), + ("http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties", "docProps/core.xml"), + ], + ), + ( + "ppt/presentation.xml", + &[("slideMaster", "slideMasters/slideMaster1.xml"), ("slide", "slides/slide1.xml"), ("slide", "slides/slide2.xml")], + ), + ("ppt/slideMasters/slideMaster1.xml", &[("slideLayout", "../slideLayouts/slideLayout1.xml"), ("theme", "../theme/theme1.xml")]), + ("ppt/slideLayouts/slideLayout1.xml", &[("slideMaster", "../slideMasters/slideMaster1.xml")]), + ( + "ppt/slides/slide1.xml", + &[("slideLayout", "../slideLayouts/slideLayout1.xml"), ("image", "../media/image1.png"), ("tags", "../tags/tag1.xml")], + ), + ], + ) +} + +fn shape<'a>(p: &'a Presentation, name: &str) -> &'a craft_pptx::Shape { + let mut found = None; + craft_pptx::walk(&p.slides[0].shapes, &mut |s, _| { + if s.name == name && found.is_none() { + found = Some(s); + } + }); + found.unwrap_or_else(|| panic!("no shape {name}")) +} + +#[test] +fn a_small_deck_reads_into_the_model() { + let bytes = deck(); + assert!(craft_pptx::sniff(&bytes)); + let (p, lost) = craft_pptx::import_checked(&bytes, &Plain).unwrap(); + assert_eq!(lost, ["ppt/slides/slide2.xml"], "the missing slide is reported and left out"); + assert_eq!(p.slide_size, Size::new(720.0, 540.0)); + assert_eq!(p.first_slide_number, 3); + assert_eq!(p.props.title, "Quarterly"); + assert_eq!(p.props.author, "Ann"); + assert_eq!(p.props.revision, 4); + + let m = &p.masters[0]; + assert_eq!(m.name, "Main"); + assert_eq!(m.theme.name, "Harbour"); + assert_eq!(m.theme.fonts.major.latin, "Lato"); + assert_eq!(m.title_style.level(0).and_then(|l| l.run.size), Some(44.0)); + assert_eq!(m.layouts.len(), 1); + assert_eq!(m.layouts[0].kind, LayoutType::Title); + + let s = &p.slides[0]; + assert_eq!(s.layout, m.layouts[0].id); + assert!(s.hidden); + assert_eq!(s.name, "First"); + assert_eq!(s.tags, [("ADDIN".to_string(), "42".to_string())]); + assert!(matches!(&s.background, Some(craft_pptx::Background::Fill { .. }))); + assert_eq!(p.sections.len(), 1); + assert_eq!(p.sections[0].slides, [s.id]); + assert_eq!(p.custom_shows[0].name, "Short"); + assert_eq!(p.custom_shows[0].slides, [s.id]); + + assert!(p.validate().is_empty(), "{:?}", p.validate()); + assert!(p.next_id >= 11); + + let (theme, _) = craft_pptx::import_theme(&bytes).unwrap(); + assert_eq!(theme.name, "Harbour"); +} + +#[test] +fn the_shapes_of_a_small_deck_read_into_the_model() { + let p = craft_pptx::import(&deck(), &Plain).unwrap(); + // A placeholder with no geometry of its own takes its layout placeholder's. + let title = shape(&p, "Title 1"); + assert_eq!(title.ph_type(), Some(PhType::CtrTitle)); + assert_eq!(title.geom, Geom::preset("ellipse")); + assert_eq!(title.text.as_ref().map(|t| t.text()), Some("Hello".into())); + + let b = shape(&p, "Box"); + assert!(matches!(&b.geom, Geom::Preset { name, adj } if name == "roundRect" && adj == &[30000.0])); + assert_eq!(b.xfrm.map(|x| (x.x, x.y, x.w, x.h)), Some((1.0, 2.0, 100.0, 50.0))); + assert!(b.fill.is_some()); + assert_eq!(b.text.as_ref().and_then(|t| t.body.anchor), Some(craft_pptx::text::Anchor::Top), "shape text sits at the top"); + + let pic = shape(&p, "Picture"); + assert_eq!(pic.descr, "A dot"); + let ShapeKind::Picture { fill } = &pic.kind else { panic!("not a picture: {:?}", pic.kind) }; + let media = p.media(fill.media).unwrap(); + assert_eq!((media.name.as_str(), media.content_type.as_str(), media.data.as_slice()), ("image1.png", "image/png", &b"png bytes"[..])); + + let ShapeKind::Table(t) = &shape(&p, "Table").kind else { panic!("not a table") }; + assert_eq!(t.cols, [100.0, 100.0]); + assert!(t.first_row); + assert_eq!(t.rows[0].cells[1].text.text(), "B1"); + + let g = shape(&p, "Group"); + assert_eq!(g.children().len(), 1); + let inner = shape(&p, "Inner").id; + let ShapeKind::Connector { start, end } = shape(&p, "Line").kind else { panic!("not a connector") }; + assert_eq!(start, Some((b.id, 1))); + assert_eq!(end, Some((inner, 3))); +} + +/// A file with no readable master gets the caller's. +#[test] +fn a_deck_without_a_master_takes_the_callers() { + struct Mine; + impl Defaults for Mine { + fn master(&self, size: Size, after: u32) -> (Master, u32) { + let (mut m, last) = Plain.master(size, after); + m.name = "Mine".into(); + (m, last) + } + } + let pres = format!(r#""#); + let slide = format!(r#"{}"#, sp(4, "Box", "", XFRM, "x")); + let bytes = package( + &[ + ("ppt/presentation.xml", CT_PRES, pres), + ("ppt/slides/slide1.xml", "application/vnd.openxmlformats-officedocument.presentationml.slide+xml", slide), + ], + &[("", &[("officeDocument", "ppt/presentation.xml")]), ("ppt/presentation.xml", &[("slide", "slides/slide1.xml")])], + ); + let p = craft_pptx::import(&bytes, &Mine).unwrap(); + assert_eq!(p.masters.len(), 1); + assert_eq!(p.masters[0].name, "Mine"); + assert_eq!(p.slides[0].layout, p.masters[0].layouts[0].id); + assert!(p.validate().is_empty(), "{:?}", p.validate()); +} + +#[test] +fn other_files_are_refused() { + assert!(!craft_pptx::sniff(b"not a zip")); + assert!(craft_pptx::import(b"not a zip", &Plain).is_err()); + let doc = package( + &[("word/document.xml", "application/xml", "".into())], + &[("", &[("officeDocument", "word/document.xml")])], + ); + assert!(craft_pptx::sniff(&doc), "a package, but not a presentation"); + assert!(matches!(craft_pptx::import(&doc, &Plain), Err(craft_pptx::Error::NotPptx(_)))); + assert!(craft_pptx::import_theme(&doc).is_err()); +} + +/// Groups nested past 32 levels are cut there; the rest of the slide is read. +#[test] +fn deep_group_nesting_is_cut() { + let open = r#""#; + let tree = format!("{}{}{}", open.repeat(40), sp(4, "Deep", "", XFRM, "x"), "".repeat(40)); + let pres = format!(r#""#); + let slide = format!(r#"{tree}{}"#, sp(5, "Top", "", XFRM, "y")); + let bytes = package( + &[ + ("ppt/presentation.xml", CT_PRES, pres), + ("ppt/slides/slide1.xml", "application/vnd.openxmlformats-officedocument.presentationml.slide+xml", slide), + ], + &[("", &[("officeDocument", "ppt/presentation.xml")]), ("ppt/presentation.xml", &[("slide", "slides/slide1.xml")])], + ); + let p = craft_pptx::import(&bytes, &Plain).unwrap(); + let mut depth = 0; + craft_pptx::walk(&p.slides[0].shapes, &mut |_, d| depth = depth.max(d)); + assert_eq!(depth, 32, "33 levels of groups, the last one empty"); + assert!(p.slides[0].shapes.iter().any(|s| s.name == "Top")); +}