craft-pptx: the reader (from DeckCraft), on craft-opc and craft-drawingml (#1057)

* 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ó <jordiag@kth.se>
This commit is contained in:
Dylan M. TaylorandJordi Altayó authored and GitHub committed 2026-11-11 21:36:57 -05:00
1 parent 09c1ecc2a0
commit 34ba6ae4da
9 files changed
+2719 -1

No files matched your search

Generated
+4
View File
@@ -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]]
+4
View File
@@ -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 }
+16 -1
View File
@@ -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.
+109
View File
@@ -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<Presentation, Error> {
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<String>), 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<Presentation, Error> {
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<MediaItem>), 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])*
+90
View File
@@ -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<Doc, Error> {
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<Self, Error> {
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<Vec<u8>>> {
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<Doc> {
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<String> {
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)
}
}
File diff suppressed because it is too large. Load diff
+193
View File
@@ -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<Chart> {
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::<usize>() + cached.rels.iter().map(rel_bytes).sum::<usize>();
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::<usize>() + kept.parts.iter().map(|p| p.name.len() + p.content_type.len()).sum::<usize>();
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<crate::chart::ChartPart>,
seen: &mut std::collections::HashMap<String, bool>,
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<String> = 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()
}
+848
View File
@@ -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<u32>,
/// File id → model id.
pub map: HashMap<u32, ShapeId>,
/// 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<u32>) -> 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<ShapeId> {
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<Shape> {
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<Shape> {
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<f64> = 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::<usize>().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::<f64>().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::<f64>().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<Shape> {
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<Shape> {
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::<Option<Vec<_>>>()
.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<crate::MediaId> {
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<String, String> {
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<crate::MediaId> {
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!("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\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<Shape> {
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<Shape>, depth: usize) -> Vec<Shape> {
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<Shape> {
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<crate::Diagram> {
let nodes = craft_drawingml::diagram::parts::outline(data);
if nodes.is_empty() {
return None;
}
let raw = |attr: &str| -> Option<String> {
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
}
+239
View File
@@ -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<u8> {
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#"<p:sp><p:nvSpPr><p:cNvPr id="{id}" name="{name}"/><p:cNvSpPr/><p:nvPr>{ph}</p:nvPr></p:nvSpPr><p:spPr>{pr}</p:spPr><p:txBody><a:bodyPr/><a:p><a:r><a:t>{text}</a:t></a:r></a:p></p:txBody></p:sp>"#
)
}
const XFRM: &str = r#"<a:xfrm><a:off x="12700" y="25400"/><a:ext cx="1270000" cy="635000"/></a:xfrm>"#;
fn deck() -> Vec<u8> {
let pres = format!(
r#"<p:presentation {NS} firstSlideNum="3"><p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId1"/></p:sldMasterIdLst><p:sldIdLst><p:sldId id="256" r:id="rId2"/><p:sldId id="257" r:id="rId3"/></p:sldIdLst><p:sldSz cx="9144000" cy="6858000"/><p:notesSz cx="6858000" cy="9144000"/><p:custShowLst><p:custShow name="Short" id="0"><p:sldLst><p:sld r:id="rId2"/></p:sldLst></p:custShow></p:custShowLst><p:extLst><p:ext uri="{{521415D9-36F7-43E2-AB2F-B90AF26B5E84}}"><p14:sectionLst xmlns:p14="http://schemas.microsoft.com/office/powerpoint/2010/main"><p14:section name="Intro" id="{{0}}"><p14:sldIdLst><p14:sldId id="256"/></p14:sldIdLst></p14:section></p14:sectionLst></p:ext></p:extLst></p:presentation>"#
);
let master = format!(
r#"<p:sldMaster {NS}><p:cSld name="Main"><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/>{}{}</p:spTree></p:cSld><p:clrMap bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/><p:sldLayoutIdLst><p:sldLayoutId id="2147483649" r:id="rId1"/></p:sldLayoutIdLst><p:txStyles><p:titleStyle><a:lvl1pPr><a:defRPr sz="4400"/></a:lvl1pPr></p:titleStyle><p:bodyStyle/><p:otherStyle/></p:txStyles></p:sldMaster>"#,
sp(2, "Title", r#"<p:ph type="title"/>"#, XFRM, ""),
sp(3, "Body", r#"<p:ph type="body" idx="1"/>"#, XFRM, "")
);
let layout = format!(
r#"<p:sldLayout {NS} type="title"><p:cSld name="Title Slide"><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/>{}</p:spTree></p:cSld></p:sldLayout>"#,
sp(2, "Title", r#"<p:ph type="ctrTitle"/>"#, &format!(r#"{XFRM}<a:prstGeom prst="ellipse"><a:avLst/></a:prstGeom>"#), "")
);
let shapes = [
sp(5, "Title 1", r#"<p:ph type="ctrTitle"/>"#, "", "Hello"),
sp(6, "Box", "", &format!(r#"{XFRM}<a:prstGeom prst="roundRect"><a:avLst><a:gd name="adj" fmla="val 30000"/></a:avLst></a:prstGeom><a:solidFill><a:srgbClr val="FF0000"/></a:solidFill>"#), "Text"),
format!(r#"<p:pic><p:nvPicPr><p:cNvPr id="7" name="Picture" descr="A dot"/><p:cNvPicPr/><p:nvPr/></p:nvPicPr><p:blipFill><a:blip r:embed="rId2"/><a:stretch><a:fillRect/></a:stretch></p:blipFill><p:spPr>{XFRM}<a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr></p:pic>"#),
r#"<p:graphicFrame><p:nvGraphicFramePr><p:cNvPr id="8" name="Table"/><p:cNvGraphicFramePr/><p:nvPr/></p:nvGraphicFramePr><p:xfrm><a:off x="0" y="0"/><a:ext cx="2540000" cy="508000"/></p:xfrm><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/table"><a:tbl><a:tblPr firstRow="1"/><a:tblGrid><a:gridCol w="1270000"/><a:gridCol w="1270000"/></a:tblGrid><a:tr h="254000"><a:tc><a:txBody><a:bodyPr/><a:p><a:r><a:t>A1</a:t></a:r></a:p></a:txBody><a:tcPr/></a:tc><a:tc><a:txBody><a:bodyPr/><a:p><a:r><a:t>B1</a:t></a:r></a:p></a:txBody><a:tcPr/></a:tc></a:tr></a:tbl></a:graphicData></a:graphic></p:graphicFrame>"#.to_string(),
format!(r#"<p:grpSp><p:nvGrpSpPr><p:cNvPr id="9" name="Group"/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="127000" cy="127000"/><a:chOff x="0" y="0"/><a:chExt cx="254000" cy="254000"/></a:xfrm></p:grpSpPr>{}</p:grpSp>"#, sp(10, "Inner", "", XFRM, "In")),
r#"<p:cxnSp><p:nvCxnSpPr><p:cNvPr id="11" name="Line"/><p:cNvCxnSpPr><a:stCxn id="6" idx="1"/><a:endCxn id="10" idx="3"/></p:cNvCxnSpPr><p:nvPr/></p:nvCxnSpPr><p:spPr><a:prstGeom prst="straightConnector1"><a:avLst/></a:prstGeom></p:spPr></p:cxnSp>"#.to_string(),
];
let slide = format!(
r#"<p:sld {NS} show="0"><p:cSld name="First"><p:custDataLst><p:tags r:id="rId3"/></p:custDataLst><p:bg><p:bgPr><a:solidFill><a:srgbClr val="00FF00"/></a:solidFill><a:effectLst/></p:bgPr></p:bg><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/>{}</p:spTree></p:cSld></p:sld>"#,
shapes.concat()
);
let theme = r#"<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="Harbour"><a:themeElements><a:clrScheme name="Harbour"><a:dk1><a:srgbClr val="000000"/></a:dk1><a:lt1><a:srgbClr val="FFFFFF"/></a:lt1><a:dk2><a:srgbClr val="1F1F1F"/></a:dk2><a:lt2><a:srgbClr val="EEEEEE"/></a:lt2><a:accent1><a:srgbClr val="123456"/></a:accent1><a:accent2><a:srgbClr val="222222"/></a:accent2><a:accent3><a:srgbClr val="333333"/></a:accent3><a:accent4><a:srgbClr val="444444"/></a:accent4><a:accent5><a:srgbClr val="555555"/></a:accent5><a:accent6><a:srgbClr val="666666"/></a:accent6><a:hlink><a:srgbClr val="0000FF"/></a:hlink><a:folHlink><a:srgbClr val="800080"/></a:folHlink></a:clrScheme><a:fontScheme name="Harbour"><a:majorFont><a:latin typeface="Lato"/></a:majorFont><a:minorFont><a:latin typeface="Source Sans 3"/></a:minorFont></a:fontScheme></a:themeElements></a:theme>"#;
let tags = r#"<p:tagLst xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:tag name="ADDIN" val="42"/></p:tagLst>"#;
let core = r#"<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Quarterly</dc:title><dc:creator>Ann</dc:creator><cp:revision>4</cp:revision></cp:coreProperties>"#;
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#"<p:presentation {NS}><p:sldIdLst><p:sldId id="256" r:id="rId1"/></p:sldIdLst></p:presentation>"#);
let slide = format!(r#"<p:sld {NS}><p:cSld><p:spTree>{}</p:spTree></p:cSld></p:sld>"#, 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", "<w:document xmlns:w=\"w\"/>".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#"<p:grpSp><p:nvGrpSpPr><p:cNvPr id="0" name="G"/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/>"#;
let tree = format!("{}{}{}", open.repeat(40), sp(4, "Deep", "", XFRM, "x"), "</p:grpSp>".repeat(40));
let pres = format!(r#"<p:presentation {NS}><p:sldIdLst><p:sldId id="256" r:id="rId1"/></p:sldIdLst></p:presentation>"#);
let slide = format!(r#"<p:sld {NS}><p:cSld><p:spTree>{tree}{}</p:spTree></p:cSld></p:sld>"#, 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"));
}