PPTX: keep add-in tags on the presentation, slides and shapes

p:custDataLst points at ppt/tags/tagN.xml parts that add-ins such as
think-cell, Mekko Graphics and Office Timeline use to recognise their
decks, slides and shapes. They were dropped on open, so a saved deck lost
its add-in identity. The model now carries name/value tags on the
presentation, slides and shapes; the reader resolves every tags part and
the writer emits one tags part per holder, in the schema's element order.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nikita Rakitin
2026-10-13 23:07:50 +03:00
co-authored by Cursor
parent 457406a7a5
commit e8a383effa
8 changed files with 137 additions and 34 deletions
+1
View File
@@ -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 {
+12 -1
View File
@@ -317,6 +317,9 @@ pub struct Shape {
pub decorative: bool,
pub click: Option<text::Hyperlink>,
pub hover: Option<text::Hyperlink>,
/// 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<String>,
@@ -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<Vec<(String, String)>>,
/// 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<String>,
}
@@ -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<table::TableStyleDef>,
/// 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<String>,
+17
View File
@@ -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<Presentation, PptxError> {
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);
+1
View File
@@ -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);
}
_ => {}
}
+37 -6
View File
@@ -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<SlideId, String>,
pub table_styles: Vec<String>,
placeholder: Option<String>,
@@ -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<Vec<u8>, 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<Vec<u8>, 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), &notes_master_shapes(p.notes_size));
csld(&mut w, &mut x, &mut no, "", Some(&bg), &notes_master_shapes(p.notes_size), &[]);
clr_map(&mut w, &[]);
dml::list_style(&mut w, &mut x, &mut no, "p:notesStyle", &notes_style());
w.close("p:notesMaster");
@@ -536,7 +566,7 @@ fn layout_xml(x: &mut Exp, o: &mut Out, l: &Layout) -> Vec<u8> {
"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<u8> {
fn master_xml(x: &mut Exp, o: &mut Out, m: &Master, layouts: &[(u64, String)]) -> Vec<u8> {
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<u8> {
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<u8> {
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);
+28 -26
View File
@@ -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;
+38
View File
@@ -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", "<xml & stuff>")];
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()));
}
+3 -1
View File
@@ -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) |