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 <cursoragent@cursor.com>
This commit is contained in:
Nikita Rakitin
2026-10-13 23:12:18 +03:00
co-authored by Cursor
parent 457406a7a5
commit 5c1432b362
7 changed files with 216 additions and 1 deletions
+1
View File
@@ -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 {
+19
View File
@@ -817,6 +817,21 @@ pub struct MediaItem {
pub link: Option<String>,
}
/// 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<table::TableStyleDef>,
/// 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<KeptPart>,
/// Unmodelled presentation-level XML kept for round-trip.
#[serde(skip_serializing_if = "Option::is_none")]
pub raw_ext: Option<String>,
+4
View File
@@ -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<u8>) {
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<u8>) {
let ext = name.rsplit('.').next().unwrap_or("bin").to_ascii_lowercase();
+70
View File
@@ -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<deckcraft_model::KeptPart> {
use deckcraft_model::KeptPart;
let mut out: Vec<KeptPart> = 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<Presentation, PptxError> {
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);
+28
View File
@@ -516,7 +516,32 @@ pub fn export(p: &Presentation) -> Result<Vec<u8>, 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<Vec<u8>, 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()
}
+91
View File
@@ -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#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><b:Sources xmlns:b="http://schemas.openxmlformats.org/officeDocument/2006/bibliography"/>"#;
const PROPS: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="no"?><ds:datastoreItem ds:itemID="{11111111-2222-3333-4444-555555555555}" xmlns:ds="http://schemas.openxmlformats.org/officeDocument/2006/customXml"><ds:schemaRefs/></ds:datastoreItem>"#;
const ITEM_RELS: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXmlProps" Target="itemProps1.xml"/></Relationships>"#;
const LABEL: &str = r#"<?xml version="1.0" encoding="utf-8" standalone="yes"?><clbl:labelList xmlns:clbl="http://schemas.microsoft.com/office/2020/mipLabelMetadata"><clbl:label id="{aaaaaaaa-0000-0000-0000-000000000001}" enabled="1" method="Standard" siteId="{bbbbbbbb-0000-0000-0000-000000000002}" removed="0"/></clbl:labelList>"#;
const CUSTOM: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/custom-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"><property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="2" name="ContentTypeId"><vt:lpwstr>0x0101</vt:lpwstr></property></Properties>"#;
fn deck() -> Vec<u8> {
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<u8>, 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,
"</Types>",
r#"<Override PartName="/customXml/itemProps1.xml" ContentType="application/vnd.openxmlformats-officedocument.customXmlProperties+xml"/><Override PartName="/docProps/custom.xml" ContentType="application/vnd.openxmlformats-officedocument.custom-properties+xml"/><Override PartName="/docMetadata/LabelInfo.xml" ContentType="application/vnd.ms-office.classificationlabels+xml"/>"#,
),
"_rels/.rels" => edit(
data,
"</Relationships>",
r#"<Relationship Id="rIdL" Type="http://schemas.microsoft.com/office/2020/02/relationships/classificationlabels" Target="docMetadata/LabelInfo.xml"/><Relationship Id="rIdC" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties" Target="docProps/custom.xml"/>"#,
),
"ppt/_rels/presentation.xml.rels" => edit(
data,
"</Relationships>",
r#"<Relationship Id="rIdX" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml" Target="../customXml/item1.xml"/>"#,
),
_ => 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<Cursor<Vec<u8>>>, 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);
}
+3 -1
View File
@@ -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) |