From 5c1432b362942ebe40b3e758021bf9ebccdba4fd Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 23:12:18 +0300 Subject: [PATCH] PPTX: keep custom XML, the sensitivity label and custom properties Decks from SharePoint and Microsoft 365 carry customXml items (document library metadata and content types), docMetadata/LabelInfo.xml (the Microsoft Purview sensitivity label) and docProps/custom.xml. All were dropped on save, so a labelled corporate deck came back unlabelled. They are now kept verbatim with their content types, their own relationships (itemProps) and the root or presentation relationship linking them. Kept parts never replace a part the writer makes. Co-authored-by: Cursor --- crates/model/src/defaults.rs | 1 + crates/model/src/lib.rs | 19 +++++++ crates/pptx/src/opc.rs | 4 ++ crates/pptx/src/read/mod.rs | 70 +++++++++++++++++++++++++ crates/pptx/src/write/mod.rs | 28 ++++++++++ crates/pptx/tests/kept_parts.rs | 91 +++++++++++++++++++++++++++++++++ docs/file-format-parity.md | 4 +- 7 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 crates/pptx/tests/kept_parts.rs diff --git a/crates/model/src/defaults.rs b/crates/model/src/defaults.rs index 8432ce4a..b2308214 100644 --- a/crates/model/src/defaults.rs +++ b/crates/model/src/defaults.rs @@ -513,6 +513,7 @@ pub fn blank_presentation(size: Size, theme: Theme, title_slide: bool) -> Presen next_id: next.max(256), embedded_fonts: vec![], table_styles: vec![], + kept_parts: vec![], raw_ext: None, }; if title_slide { diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 43561f94..830d16d1 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -817,6 +817,21 @@ pub struct MediaItem { pub link: Option, } +/// An XML package part DeckCraft doesn't model, kept as written. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct KeptPart { + /// Part name from the package root (`customXml/item1.xml`). + pub name: String, + /// Content type; empty for a relationships part. + pub content_type: String, + pub xml: String, + /// Full relationship type linking it, empty when only its sibling parts point at it. + pub rel_type: String, + /// Linked from the package root rather than from the presentation part. + pub from_root: bool, +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct Presentation { @@ -843,6 +858,10 @@ pub struct Presentation { /// Table styles defined in the file (`ppt/tableStyles.xml`). #[serde(skip_serializing_if = "Vec::is_empty")] pub table_styles: Vec, + /// Package parts kept verbatim for round trip: custom XML data (SharePoint metadata), the + /// sensitivity label and custom document properties. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub kept_parts: Vec, /// Unmodelled presentation-level XML kept for round-trip. #[serde(skip_serializing_if = "Option::is_none")] pub raw_ext: Option, diff --git a/crates/pptx/src/opc.rs b/crates/pptx/src/opc.rs index 4ef21c3d..f10f7a55 100644 --- a/crates/pptx/src/opc.rs +++ b/crates/pptx/src/opc.rs @@ -311,6 +311,10 @@ impl PackageOut { } self.files.push((rels_path(part), rels.to_xml(), true)); } + /// Add a part with no content type of its own (a relationships part). + pub fn raw(&mut self, name: &str, data: Vec) { + self.files.push((name.to_string(), data, true)); + } /// Add a binary part; its extension gets a Default content type. pub fn binary(&mut self, name: &str, content_type: &str, data: Vec) { let ext = name.rsplit('.').next().unwrap_or("bin").to_ascii_lowercase(); diff --git a/crates/pptx/src/read/mod.rs b/crates/pptx/src/read/mod.rs index 057614eb..86509071 100644 --- a/crates/pptx/src/read/mod.rs +++ b/crates/pptx/src/read/mod.rs @@ -119,6 +119,74 @@ pub fn keep_xml(el: &El, ns: &[(String, String)]) -> String { /// Namespace prefixes whose `mc:Choice` content we understand. const SUPPORTED_MC: [&str; 9] = ["p14", "p15", "p159", "a14", "a16", "p1510", "a15", "c14", "p188"]; +/// Relationship types whose parts are kept verbatim, and whether they hang off the package root. +const KEPT_RELS: [(&str, bool); 3] = [ + ("http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml", false), + ("http://schemas.microsoft.com/office/2020/02/relationships/classificationlabels", true), + ("http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties", true), +]; + +/// A part we may keep: outside `ppt/` and the properties the writer makes itself. +fn keepable(name: &str) -> bool { + let n = name.to_ascii_lowercase(); + !n.is_empty() + && !n.contains("..") + && !n.starts_with("ppt/") + && n != "docprops/core.xml" + && n != "docprops/app.xml" + && !n.starts_with("[content_types]") +} + +/// Custom XML data, sensitivity labels and custom properties, with the parts they relate to +/// (`itemProps`) and their relationships parts, as written. +fn kept_parts(pkg: &Package, pres_path: &str) -> Vec { + use deckcraft_model::KeptPart; + let mut out: Vec = Vec::new(); + let text = |name: &str| pkg.get(name).and_then(|b| std::str::from_utf8(b).ok()).map(str::to_string); + for (holder, from_root) in [("", true), (pres_path, false)] { + let Some(doc) = pkg.xml(&opc::rels_path(holder)) else { continue }; + let base = opc::dir_of(holder); + for r in doc.root.children_named("Relationship") { + let (Some(ty), Some(target)) = (r.attr("Type"), r.attr("Target")) else { continue }; + if r.attr("TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("external")) + || !KEPT_RELS.iter().any(|(t, root)| *t == ty && *root == from_root) + { + continue; + } + let name = opc::resolve(&base, target); + if !keepable(&name) || out.iter().any(|k| k.name.eq_ignore_ascii_case(&name)) || out.len() >= 400 { + continue; + } + let Some(xml) = text(&name) else { continue }; + out.push(KeptPart { + content_type: pkg.content_type(&name).unwrap_or_default(), + name: name.clone(), + xml, + rel_type: ty.to_string(), + from_root, + }); + // The part's own relationships (customXml item → itemProps) and their targets. + let rp = opc::rels_path(&name); + if let Some(rels_xml) = text(&rp) { + for sub in pkg.rels(&name).items.iter().filter(|s| !s.external && keepable(&s.target)) { + if let Some(x) = text(&sub.target) + && !out.iter().any(|k| k.name.eq_ignore_ascii_case(&sub.target)) + { + out.push(KeptPart { + content_type: pkg.content_type(&sub.target).unwrap_or_default(), + name: sub.target.clone(), + xml: x, + ..Default::default() + }); + } + } + out.push(KeptPart { name: rp, xml: rels_xml, ..Default::default() }); + } + } + } + out +} + /// Replace every `mc:AlternateContent` by the children of the branch we can read best. pub fn resolve_mc(el: &mut El, depth: usize) { if depth > crate::xml::MAX_DEPTH { @@ -397,6 +465,8 @@ pub fn import(bytes: &[u8]) -> Result { p.props.company = d.root.child("Company").map(|c| c.text()).unwrap_or_default(); } + p.kept_parts = kept_parts(&pkg, &pres_path); + inherit_placeholder_geometry(&mut p); p.header_footer = header_footer(&p); p.media = std::mem::take(&mut imp.media); diff --git a/crates/pptx/src/write/mod.rs b/crates/pptx/src/write/mod.rs index 7fb6a713..f35a97b0 100644 --- a/crates/pptx/src/write/mod.rs +++ b/crates/pptx/src/write/mod.rs @@ -516,7 +516,32 @@ pub fn export(p: &Presentation) -> Result, PptxError> { x.pkg.xml_part("ppt/tableStyles.xml", CT_TABLE_STYLES, ts); let pxml = presentation_xml(&mut x, &mut pres, &master_rids, notes_master_rid.as_deref(), &slide_rids); x.pkg.xml_part("ppt/presentation.xml", CT_PRES, pxml); + // Kept parts never replace a part the writer makes. + let kept: Vec<_> = p + .kept_parts + .iter() + .filter(|k| { + let n = k.name.to_ascii_lowercase(); + !n.is_empty() + && !n.contains("..") + && !n.starts_with("ppt/") + && !n.starts_with("docprops/core") + && !n.starts_with("docprops/app") + && !n.starts_with("[content_types]") + && n != "_rels/.rels" + }) + .collect(); + for k in kept.iter().filter(|k| !k.rel_type.is_empty() && !k.from_root) { + pres.rels.add_new(&k.rel_type, &opc::relative("ppt/presentation.xml", &k.name), false); + } x.pkg.rels("ppt/presentation.xml", &pres.rels); + for k in &kept { + if k.content_type.is_empty() { + x.pkg.raw(&k.name, k.xml.clone().into_bytes()); + } else { + x.pkg.xml_part(&k.name, &k.content_type, k.xml.clone().into_bytes()); + } + } // Document properties and package relationships. x.pkg.xml_part("docProps/core.xml", CT_CORE, core_xml(p)); @@ -525,6 +550,9 @@ pub fn export(p: &Presentation) -> Result, PptxError> { root.add("officeDocument", "ppt/presentation.xml", false); root.add(opc::RT_CORE, "docProps/core.xml", false); root.add("extended-properties", "docProps/app.xml", false); + for k in kept.iter().filter(|k| !k.rel_type.is_empty() && k.from_root) { + root.add_new(&k.rel_type, &k.name, false); + } x.pkg.rels("", &root); x.pkg.finish() } diff --git a/crates/pptx/tests/kept_parts.rs b/crates/pptx/tests/kept_parts.rs new file mode 100644 index 00000000..1f4ecbc3 --- /dev/null +++ b/crates/pptx/tests/kept_parts.rs @@ -0,0 +1,91 @@ +//! Custom XML data (SharePoint metadata), the sensitivity label and custom document properties +//! survive opening and saving a deck. + +use std::io::{Cursor, Read, Write}; + +use deckcraft_engine::Session; +use zip::write::SimpleFileOptions; + +const ITEM: &str = r#""#; +const PROPS: &str = r#""#; +const ITEM_RELS: &str = r#""#; +const LABEL: &str = r#""#; +const CUSTOM: &str = r#"0x0101"#; + +fn deck() -> Vec { + let mut s = Session::new(); + deckcraft_engine::sample::open_sample(&mut s).expect("sample"); + let p = s.doc().expect("doc").doc.as_ref().clone(); + let bytes = deckcraft_pptx::export(&p).expect("export"); + let mut src = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + for i in 0..src.len() { + let mut f = src.by_index(i).expect("entry"); + let name = f.name().to_string(); + let mut data = Vec::new(); + f.read_to_end(&mut data).expect("read"); + let edit = |d: Vec, at: &str, add: &str| String::from_utf8(d).expect("utf8").replacen(at, &format!("{add}{at}"), 1).into_bytes(); + let data = match name.as_str() { + "[Content_Types].xml" => edit( + data, + "", + r#""#, + ), + "_rels/.rels" => edit( + data, + "", + r#""#, + ), + "ppt/_rels/presentation.xml.rels" => edit( + data, + "", + r#""#, + ), + _ => data, + }; + out.start_file(name, SimpleFileOptions::default()).expect("start"); + out.write_all(&data).expect("write"); + } + for (n, d) in [ + ("customXml/item1.xml", ITEM), + ("customXml/itemProps1.xml", PROPS), + ("customXml/_rels/item1.xml.rels", ITEM_RELS), + ("docMetadata/LabelInfo.xml", LABEL), + ("docProps/custom.xml", CUSTOM), + ] { + out.start_file(n, SimpleFileOptions::default()).expect("start"); + out.write_all(d.as_bytes()).expect("write"); + } + out.finish().expect("finish").into_inner() +} + +fn read(zip: &mut zip::ZipArchive>>, name: &str) -> String { + let mut s = String::new(); + zip.by_name(name).unwrap_or_else(|_| panic!("{name} missing")).read_to_string(&mut s).expect("read"); + s +} + +#[test] +fn custom_xml_label_and_custom_properties_survive_save() { + let p = deckcraft_pptx::import(&deck()).expect("import"); + assert_eq!(p.kept_parts.len(), 5); + let saved = deckcraft_pptx::export(&p).expect("export"); + let mut zip = zip::ZipArchive::new(Cursor::new(saved.clone())).expect("zip"); + for (n, d) in [ + ("customXml/item1.xml", ITEM), + ("customXml/itemProps1.xml", PROPS), + ("customXml/_rels/item1.xml.rels", ITEM_RELS), + ("docMetadata/LabelInfo.xml", LABEL), + ("docProps/custom.xml", CUSTOM), + ] { + assert_eq!(read(&mut zip, n), d, "{n}"); + } + let ct = read(&mut zip, "[Content_Types].xml"); + assert!(ct.contains("/docMetadata/LabelInfo.xml") && ct.contains("customXmlProperties+xml") && ct.contains("custom-properties+xml")); + let root = read(&mut zip, "_rels/.rels"); + assert!(root.contains("classificationlabels") && root.contains(r#"Target="docProps/custom.xml""#)); + assert!(read(&mut zip, "ppt/_rels/presentation.xml.rels").contains(r#"Target="../customXml/item1.xml""#)); + // And again: a second save keeps them once. + let again = deckcraft_pptx::import(&saved).expect("reimport"); + assert_eq!(again.kept_parts, p.kept_parts); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 1e888c7a..e0ff308a 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -83,7 +83,8 @@ the round trip even though DeckCraft doesn't model it. | Comments: legacy | yes | yes | | | Comments: modern threaded (`p188`) | yes | no (written as legacy) | Replies and resolved state lost (#70) | | Unknown slide-level elements (`graphicFrame` OLE, extensions, `mc:AlternateContent`) | kept | kept | Rendered as a labelled placeholder with the preview picture when present | -| Tags, custom XML, VBA | no / no / no | | | +| Custom XML data, sensitivity label (`docMetadata/LabelInfo.xml`), custom document properties | kept verbatim | yes | SharePoint metadata and the label survive a save; `custom_xml_label_and_custom_properties_survive_save` | +| Tags, VBA | no / no | | | | Embedded fonts | no | no | | | Encryption (password to open) | no | no | | @@ -99,6 +100,7 @@ the round trip even though DeckCraft doesn't model it. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Custom XML parts, the sensitivity label and custom properties kept on save | | 2026-10-12 | minor | EMF/WMF text and pattern brushes drawn; OLE (VML) and ActiveX previews read | | 2026-10-12 | minor | Text warps rendered; their adjust values read and written | | 2026-10-12 | trivial | Groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233) |