Merge PR #535 from NikitkaRa: PPTX: keep custom XML, the sensitivity label and custom properties

# Conflicts:
#	crates/model/src/defaults.rs
#	crates/model/src/lib.rs
#	crates/pptx/src/read/mod.rs
#	crates/pptx/src/write/mod.rs
This commit is contained in:
Brandon Thomas
2026-10-14 13:26:22 -04:00
7 changed files with 216 additions and 0 deletions
+1
View File
@@ -514,6 +514,7 @@ pub fn blank_presentation(size: Size, theme: Theme, title_slide: bool) -> Presen
embedded_fonts: vec![],
table_styles: vec![],
tags: vec![],
kept_parts: vec![],
raw_ext: None,
};
if title_slide {
+19
View File
@@ -862,6 +862,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 {
@@ -891,6 +906,10 @@ pub struct Presentation {
/// Add-in tags (`p:custDataLst` → `ppt/tags/tagN.xml`): name/value pairs kept for round trip.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<(String, String)>,
/// 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
@@ -339,6 +339,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
@@ -193,6 +193,74 @@ pub fn tags(imp: &Imp, part: &Part, holder: &El) -> Vec<(String, String)> {
out
}
/// Relationship types whose parts are kept verbatim, and whether they hang off the package root.
const KEPT_RELS: [(&str, bool); 3] = [
("http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml", false),
("http://schemas.microsoft.com/office/2020/02/relationships/classificationlabels", true),
("http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties", true),
];
/// A part we may keep: outside `ppt/` and the properties the writer makes itself.
fn keepable(name: &str) -> bool {
let n = name.to_ascii_lowercase();
!n.is_empty()
&& !n.contains("..")
&& !n.starts_with("ppt/")
&& n != "docprops/core.xml"
&& n != "docprops/app.xml"
&& !n.starts_with("[content_types]")
}
/// Custom XML data, sensitivity labels and custom properties, with the parts they relate to
/// (`itemProps`) and their relationships parts, as written.
fn kept_parts(pkg: &Package, pres_path: &str) -> Vec<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.
/// A 3-D model choice is left in place (even when its `Requires` prefix is not one we otherwise
/// understand) so the shape reader can keep the model and its fallback picture. Ink content parts
@@ -504,6 +572,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.embedded_fonts = read_embedded_fonts(&mut imp, &pres_part, pres_el);
+28
View File
@@ -644,7 +644,32 @@ pub fn export(p: &Presentation, main_ct: &str) -> 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", main_ct, 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));
@@ -653,6 +678,9 @@ pub fn export(p: &Presentation, main_ct: &str) -> 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
View File
@@ -104,6 +104,8 @@ the round trip even though DeckCraft doesn't model it.
| Embedded fonts | yes | yes | MicroType Express subsets kept, not drawn |
| Tags (`p:custDataLst`) on the presentation, slides and shapes | yes | yes | Add-ins such as think-cell keep their identity; `tags_survive_round_trip` |
| Custom XML, VBA | no / 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 | |
@@ -129,6 +131,7 @@ the round trip even though DeckCraft doesn't model it.
| 2026-10-14 | minor | 3-D models (`.glb`) round-trip, including an alternate-content fallback picture; animation bounce end is read and written (`corpus_media.rs`) |
| 2026-10-14 | minor | Chart trendlines round-trip (`corpus_charts.rs`); merges, diagonal borders, columns, autofit, vertical text and WordArt warp are in that slice |
| 2026-10-13 | minor | Add-in tags on the presentation, slides and shapes read and written |
| 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) |