mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-02 16:30:36 -06:00
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:
@@ -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 {
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) |
|
||||
|
||||
Reference in New Issue
Block a user