diff --git a/crates/model/src/defaults.rs b/crates/model/src/defaults.rs index 2031e6bd..5507b23a 100644 --- a/crates/model/src/defaults.rs +++ b/crates/model/src/defaults.rs @@ -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 { diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 35f3c2cf..427c97d2 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -862,6 +862,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 { @@ -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, /// 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 b28fcd7f..bd090438 100644 --- a/crates/pptx/src/opc.rs +++ b/crates/pptx/src/opc.rs @@ -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) { + 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 268b9e31..a298e8d8 100644 --- a/crates/pptx/src/read/mod.rs +++ b/crates/pptx/src/read/mod.rs @@ -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 { + 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. /// 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 { 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); diff --git a/crates/pptx/src/write/mod.rs b/crates/pptx/src/write/mod.rs index 4704a167..d4a10db5 100644 --- a/crates/pptx/src/write/mod.rs +++ b/crates/pptx/src/write/mod.rs @@ -644,7 +644,32 @@ pub fn export(p: &Presentation, main_ct: &str) -> 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", 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, 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 cda83612..c04c16b6 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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) |