mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-13 22:12:12 +00:00
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:
1 parent
09c1ecc2a0
commit
34ba6ae4da
9 files changed
+2719
-1
No files matched your search
Generated
+4
@@ -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]]
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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])*
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
Reference in new issue
Block a user