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) |