mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
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:
@@ -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
@@ -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>,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -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), ¬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<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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
@@ -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) |
|
||||
|
||||
Reference in New Issue
Block a user