diff --git a/crates/model/src/defaults.rs b/crates/model/src/defaults.rs index 8432ce4a..2031e6bd 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![], + tags: vec![], raw_ext: None, }; if title_slide { diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 43561f94..7d05d804 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -317,6 +317,9 @@ pub struct Shape { pub decorative: bool, pub click: Option, pub hover: Option, + /// 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)>, /// Unmodelled XML kept for round-trip (extension lists etc.). #[serde(skip_serializing_if = "Option::is_none")] pub raw_ext: Option, @@ -344,6 +347,7 @@ impl Default for Shape { decorative: false, click: None, hover: None, + tags: vec![], raw_ext: None, } } @@ -534,7 +538,10 @@ pub struct Slide { /// the layout. #[serde(skip_serializing_if = "Option::is_none")] pub color_map: Option>, - /// Unmodelled XML kept for round-trip (custom data, tags). + /// 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)>, + /// Unmodelled XML kept for round-trip (custom data). #[serde(skip_serializing_if = "Option::is_none")] pub raw_ext: Option, } @@ -554,6 +561,7 @@ impl Default for Slide { notes: TextBody::default(), comments: vec![], color_map: None, + tags: vec![], raw_ext: None, } } @@ -843,6 +851,9 @@ pub struct Presentation { /// Table styles defined in the file (`ppt/tableStyles.xml`). #[serde(skip_serializing_if = "Vec::is_empty")] pub table_styles: Vec, + /// 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)>, /// 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/read/mod.rs b/crates/pptx/src/read/mod.rs index 057614eb..3d8a6bb5 100644 --- a/crates/pptx/src/read/mod.rs +++ b/crates/pptx/src/read/mod.rs @@ -119,6 +119,21 @@ 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"]; +/// Add-in tags named by `holder`'s `p:custDataLst`: the name/value pairs of each tags part. +pub fn tags(imp: &Imp, part: &Part, holder: &El) -> Vec<(String, String)> { + let Some(list) = holder.child("custDataLst") else { return vec![] }; + let mut out = Vec::new(); + for t in list.children_named("tags").take(100) { + let Some(doc) = t.attr("r:id").and_then(|id| part.rels.get(id)).and_then(|r| imp.pkg.xml(&r.target)) else { continue }; + for tag in doc.root.children_named("tag").take(10_000) { + if let Some(name) = tag.attr("name") { + out.push((name.to_string(), tag.attr("val").unwrap_or("").to_string())); + } + } + } + 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 { @@ -342,6 +357,7 @@ pub fn import(bytes: &[u8]) -> Result { p.raw_ext = Some(kept); } } + p.tags = tags(&imp, &pres_part, pres_el); // Custom shows. if let Some(l) = pres_el.child("custShowLst") { for cs in l.children_named("custShow") { @@ -707,6 +723,7 @@ fn read_slide(imp: &mut Imp, part: &Part, doc: &Doc, id: SlideId, layout: Layout let mut ctx = shapes::IdCtx::default(); if let Some(csld) = root.child("cSld") { s.name = csld.attr("name").unwrap_or("").to_string(); + s.tags = tags(imp, part, csld); s.background = background(imp, part, csld); if let Some(tree) = csld.child("spTree") { s.shapes = shapes::sp_tree(imp, part, &mut ctx, tree, 0); diff --git a/crates/pptx/src/read/shapes.rs b/crates/pptx/src/read/shapes.rs index c4b3f77f..9d59d0cd 100644 --- a/crates/pptx/src/read/shapes.rs +++ b/crates/pptx/src/read/shapes.rs @@ -165,6 +165,7 @@ fn base(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, nv: Option<&El>) -> Shape { has_custom_prompt: ph.bool("hasCustomPrompt").unwrap_or(false), }); } + s.tags = super::tags(imp, part, e); } _ => {} } diff --git a/crates/pptx/src/write/mod.rs b/crates/pptx/src/write/mod.rs index 7fb6a713..3567d542 100644 --- a/crates/pptx/src/write/mod.rs +++ b/crates/pptx/src/write/mod.rs @@ -29,6 +29,7 @@ const CT_THEME: &str = "application/vnd.openxmlformats-officedocument.theme+xml" const CT_PRES_PROPS: &str = "application/vnd.openxmlformats-officedocument.presentationml.presProps+xml"; const CT_VIEW_PROPS: &str = "application/vnd.openxmlformats-officedocument.presentationml.viewProps+xml"; const CT_TABLE_STYLES: &str = "application/vnd.openxmlformats-officedocument.presentationml.tableStyles+xml"; +const CT_TAGS: &str = "application/vnd.openxmlformats-officedocument.presentationml.tags+xml"; const CT_CHART: &str = "application/vnd.openxmlformats-officedocument.drawingml.chart+xml"; const CT_COMMENTS: &str = "application/vnd.openxmlformats-officedocument.presentationml.comments+xml"; const CT_AUTHORS: &str = "application/vnd.openxmlformats-officedocument.presentationml.commentAuthors+xml"; @@ -43,6 +44,7 @@ pub struct Exp<'a> { n_image: u32, n_media: u32, n_chart: u32, + n_tags: u32, pub slide_parts: HashMap, pub table_styles: Vec, placeholder: Option, @@ -158,6 +160,19 @@ impl Exp<'_> { }; o.rels.add("image", &opc::relative(&o.name, &part), false) } + /// A new `ppt/tags/tagN.xml` part holding `tags`, related from `o`. + pub fn tags_rel(&mut self, o: &mut Out, tags: &Tags) -> String { + self.n_tags += 1; + let name = format!("ppt/tags/tag{}.xml", self.n_tags); + let mut w = W::new(); + root_open(&mut w, "p:tagLst", A::new()); + for (n, v) in tags.iter().take(10_000) { + w.empty("p:tag", A::new().a("name", n).a("val", v)); + } + w.close("p:tagLst"); + self.pkg.xml_part(&name, CT_TAGS, w.finish()); + o.rels.add_new("tags", &opc::relative(&o.name, &name), false) + } pub fn chart_rel(&mut self, o: &mut Out, c: &deckcraft_model::Chart) -> String { self.n_chart += 1; let name = format!("ppt/charts/chart{}.xml", self.n_chart); @@ -166,6 +181,17 @@ impl Exp<'_> { } } +/// `p:custDataLst` pointing at a tags part, when there are tags. +pub fn cust_data(w: &mut W, x: &mut Exp, o: &mut Out, tags: &Tags) { + if tags.is_empty() { + return; + } + let id = x.tags_rel(o, tags); + w.open0("p:custDataLst"); + w.empty("p:tags", A::new().a("r:id", id)); + w.close("p:custDataLst"); +} + fn root_open(w: &mut W, tag: &str, extra: A) { let mut a = A::new().a("xmlns:a", NS_A).a("xmlns:r", NS_R).a("xmlns:p", NS_P); a.v.extend(extra.v); @@ -194,7 +220,9 @@ fn background(w: &mut W, x: &mut Exp, o: &mut Out, bg: &Background) { w.close("p:bg"); } -fn csld(w: &mut W, x: &mut Exp, o: &mut Out, name: &str, bg: Option<&Background>, list: &[Shape]) -> shapes::IdMap { +type Tags = [(String, String)]; + +fn csld(w: &mut W, x: &mut Exp, o: &mut Out, name: &str, bg: Option<&Background>, list: &[Shape], tags: &Tags) -> shapes::IdMap { w.open("p:cSld", A::new().o("name", (!name.is_empty()).then_some(name))); if let Some(bg) = bg { background(w, x, o, bg); @@ -207,6 +235,7 @@ fn csld(w: &mut W, x: &mut Exp, o: &mut Out, name: &str, bg: Option<&Background> shapes::shapes(w, x, o, &mut cx, list, 0); } w.close("p:spTree"); + cust_data(w, x, o, tags); w.close("p:cSld"); ids } @@ -385,6 +414,7 @@ pub fn export(p: &Presentation) -> Result, PptxError> { n_image: 0, n_media: 0, n_chart: 0, + n_tags: 0, slide_parts: HashMap::new(), table_styles: vec![], placeholder: None, @@ -450,7 +480,7 @@ pub fn export(p: &Presentation) -> Result, PptxError> { let mut w = W::new(); root_open(&mut w, "p:notesMaster", A::new()); let bg = Background::Ref { idx: 1001, color: deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Bg1) }; - csld(&mut w, &mut x, &mut no, "", Some(&bg), ¬es_master_shapes(p.notes_size)); + csld(&mut w, &mut x, &mut no, "", Some(&bg), ¬es_master_shapes(p.notes_size), &[]); clr_map(&mut w, &[]); dml::list_style(&mut w, &mut x, &mut no, "p:notesStyle", ¬es_style()); w.close("p:notesMaster"); @@ -536,7 +566,7 @@ fn layout_xml(x: &mut Exp, o: &mut Out, l: &Layout) -> Vec { "p:sldLayout", A::new().o("showMasterSp", (!l.show_master_shapes).then_some("0")).a("type", l.kind.xml()).t("preserve", l.preserve), ); - csld(&mut w, x, o, &l.name, l.background.as_ref(), &l.shapes); + csld(&mut w, x, o, &l.name, l.background.as_ref(), &l.shapes, &[]); clr_map_ovr(&mut w, l.color_map.as_deref()); ext_list(&mut w, ext_children(l.raw_ext.as_deref()).as_deref()); w.close("p:sldLayout"); @@ -546,7 +576,7 @@ fn layout_xml(x: &mut Exp, o: &mut Out, l: &Layout) -> Vec { fn master_xml(x: &mut Exp, o: &mut Out, m: &Master, layouts: &[(u64, String)]) -> Vec { let mut w = W::new(); root_open(&mut w, "p:sldMaster", A::new().t("preserve", m.preserve)); - csld(&mut w, x, o, &m.name, m.background.as_ref(), &m.shapes); + csld(&mut w, x, o, &m.name, m.background.as_ref(), &m.shapes, &[]); clr_map(&mut w, &m.color_map); w.open0("p:sldLayoutIdLst"); for (id, rid) in layouts { @@ -568,7 +598,7 @@ fn master_xml(x: &mut Exp, o: &mut Out, m: &Master, layouts: &[(u64, String)]) - fn slide_xml(x: &mut Exp, o: &mut Out, s: &Slide) -> Vec { let mut w = W::new(); root_open(&mut w, "p:sld", A::new().o("showMasterSp", (!s.show_master_shapes).then_some("0")).o("show", s.hidden.then_some("0"))); - let ids = csld(&mut w, x, o, &s.name, s.background.as_ref(), &s.shapes); + let ids = csld(&mut w, x, o, &s.name, s.background.as_ref(), &s.shapes, &s.tags); clr_map_ovr(&mut w, s.color_map.as_deref()); if let Some(t) = &s.transition { timing::transition(&mut w, x, o, t); @@ -599,7 +629,7 @@ fn notes_xml(x: &mut Exp, o: &mut Out, notes: &TextBody) -> Vec { text: Some(tb), ..Default::default() }; - csld(&mut w, x, o, "", None, &[img, body]); + csld(&mut w, x, o, "", None, &[img, body], &[]); w.open0("p:clrMapOvr"); w.empty0("a:masterClrMapping"); w.close("p:clrMapOvr"); @@ -706,6 +736,7 @@ fn presentation_xml(x: &mut Exp, o: &mut Out, masters: &[(u64, String)], notes: } w.close("p:custShowLst"); } + cust_data(&mut w, x, o, &p.tags); let dts = if p.default_text_style.is_empty() { defaults::default_text_style() } else { p.default_text_style.clone() }; dml::list_style(&mut w, x, o, "p:defaultTextStyle", &dts); let sections = section_slides(p); diff --git a/crates/pptx/src/write/shapes.rs b/crates/pptx/src/write/shapes.rs index 8b2ea35d..b1ad5cac 100644 --- a/crates/pptx/src/write/shapes.rs +++ b/crates/pptx/src/write/shapes.rs @@ -153,28 +153,30 @@ fn cnvpr(w: &mut W, x: &mut Exp, o: &mut Out, id: u32, s: &Shape) { w.close("p:cNvPr"); } -fn nv_pr(w: &mut W, s: &Shape) { - match &s.ph { - Some(ph) => { - w.open0("p:nvPr"); - let ty = match ph.kind { - PhType::Obj => None, - k => Some(k.xml()), - }; - let sz = ph.size.as_deref().filter(|z| matches!(*z, "full" | "half" | "quarter")); - w.empty( - "p:ph", - A::new() - .o("type", ty) - .o("orient", ph.vertical.then_some("vert")) - .o("sz", sz) - .o("idx", (ph.idx != 0).then_some(ph.idx)) - .t("hasCustomPrompt", ph.has_custom_prompt), - ); - w.close("p:nvPr"); - } - None => w.empty0("p:nvPr"), +fn nv_pr(w: &mut W, x: &mut Exp, o: &mut Out, s: &Shape) { + if s.ph.is_none() && s.tags.is_empty() { + w.empty0("p:nvPr"); + return; } + w.open0("p:nvPr"); + if let Some(ph) = &s.ph { + let ty = match ph.kind { + PhType::Obj => None, + k => Some(k.xml()), + }; + let sz = ph.size.as_deref().filter(|z| matches!(*z, "full" | "half" | "quarter")); + w.empty( + "p:ph", + A::new() + .o("type", ty) + .o("orient", ph.vertical.then_some("vert")) + .o("sz", sz) + .o("idx", (ph.idx != 0).then_some(ph.idx)) + .t("hasCustomPrompt", ph.has_custom_prompt), + ); + } + super::cust_data(w, x, o, &s.tags); + w.close("p:nvPr"); } /// Locking attributes. Lock writes the full set PowerPoint writes for the element's kind (and @@ -284,7 +286,7 @@ pub fn shape(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, depth w.open0("p:cNvPicPr"); locks(w, "a:picLocks", s, A::new().a("noChangeAspect", "1")); w.close("p:cNvPicPr"); - nv_pr(w, s); + nv_pr(w, x, o, s); w.close("p:nvPicPr"); dml::blip_fill(w, x, o, "p:blipFill", fill); let mut s2 = s.clone(); @@ -305,7 +307,7 @@ pub fn shape(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, depth w.open0("p:cNvGrpSpPr"); locks(w, "a:grpSpLocks", s, A::new()); w.close("p:cNvGrpSpPr"); - nv_pr(w, s); + nv_pr(w, x, o, s); w.close("p:nvGrpSpPr"); w.open0("p:grpSpPr"); let xf = s.xfrm.unwrap_or(*child); @@ -340,7 +342,7 @@ pub fn shape(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, depth } w.close("p:cNvCxnSpPr"); } - nv_pr(w, s); + nv_pr(w, x, o, s); w.close("p:nvCxnSpPr"); let mut s2 = s.clone(); if matches!(s2.fill, Some(Fill::Background)) { @@ -448,7 +450,7 @@ fn sp(w: &mut W, x: &mut Exp, o: &mut Out, id: u32, s: &Shape, math: bool) { } else { w.empty("p:cNvSpPr", cnv); } - nv_pr(w, s); + nv_pr(w, x, o, s); w.close("p:nvSpPr"); let tmp; let sref = if matches!(s.fill, Some(Fill::Background)) { @@ -476,7 +478,7 @@ fn frame_open(w: &mut W, x: &mut Exp, o: &mut Out, id: u32, s: &Shape) { w.open0("p:cNvGraphicFramePr"); w.empty("a:graphicFrameLocks", A::new().a("noGrp", "1").t("noDrilldown", s.locked).t("noMove", s.locked).t("noResize", s.locked)); w.close("p:cNvGraphicFramePr"); - nv_pr(w, s); + nv_pr(w, x, o, s); w.close("p:nvGraphicFramePr"); let xf = s.xfrm.unwrap_or_default(); let mut xf0 = xf; diff --git a/crates/pptx/tests/tags.rs b/crates/pptx/tests/tags.rs new file mode 100644 index 00000000..e8903b44 --- /dev/null +++ b/crates/pptx/tests/tags.rs @@ -0,0 +1,38 @@ +//! Add-in tags (`p:custDataLst` → `ppt/tags/tagN.xml`) on the presentation, slides and shapes +//! survive a save and reopen. + +use std::io::{Cursor, Read}; +use std::sync::Arc; + +use deckcraft_engine::Session; + +fn t(n: &str, v: &str) -> (String, String) { + (n.to_string(), v.to_string()) +} + +#[test] +fn tags_survive_round_trip() { + let mut s = Session::new(); + deckcraft_engine::sample::open_sample(&mut s).expect("sample"); + let mut p = s.doc().expect("doc").doc.as_ref().clone(); + p.tags = vec![t("THINKCELLPRESENTATIONDONOTDELETE", "")]; + let slide = Arc::make_mut(&mut p.slides[0]); + slide.tags = vec![t("TIMING", "5"), t("SECTION", "Intro")]; + let shape_id = slide.shapes[0].id; + slide.shapes[0].tags = vec![t("THINKCELLSHAPEDONOTDELETE", "t!abc")]; + + let bytes = deckcraft_pptx::export(&p).expect("export"); + let mut zip = zip::ZipArchive::new(Cursor::new(bytes.clone())).expect("zip"); + let mut ct = String::new(); + zip.by_name("[Content_Types].xml").expect("ct").read_to_string(&mut ct).expect("read"); + assert!(ct.contains("presentationml.tags+xml")); + assert_eq!((0..zip.len()).filter(|i| zip.by_index(*i).is_ok_and(|f| f.name().starts_with("ppt/tags/"))).count(), 3); + + let back = deckcraft_pptx::import(&bytes).expect("import"); + assert_eq!(back.tags, p.tags); + assert_eq!(back.slides[0].tags, p.slides[0].tags); + let first = &back.slides[0].shapes[0]; + assert_eq!(first.name, p.slides[0].shapes.iter().find(|s| s.id == shape_id).map(|s| s.name.clone()).unwrap_or_default()); + assert_eq!(first.tags, vec![t("THINKCELLSHAPEDONOTDELETE", "t!abc")]); + assert!(back.slides.iter().skip(1).all(|s| s.tags.is_empty())); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 1e888c7a..48cab8d9 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 | | | +| 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 | | | 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 | Add-in tags on the presentation, slides and shapes read and written | | 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) |