Fix up #508: read 3-D settings with the XML parser; keep only known presets

Shape3d::parse scanned the kept a:scene3d/a:sp3d XML as strings and copied the
camera, light rig, direction, material and bevel names from the file into the
XML it writes back unescaped. It now parses the fragment with the importer's
XML reader (exported from deckcraft-pptx as parse_xml) and keeps a value only
when it is in the format's lists (ST_PresetCameraType, ST_LightRigType,
ST_LightRigDirection, ST_PresetMaterialType, ST_BevelPresetType), else the
default; lengths are clamped and the extrusion/contour colours are
re-serialized (escaped) by the XML writer. The Format Shape pane rows in
ui-parity.md that earlier merges had duplicated are folded into one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-14 15:42:23 -04:00
co-authored by Claude Opus 5.5
parent b4be808ba3
commit b54ec601d8
3 changed files with 209 additions and 81 deletions
+204 -76
View File
@@ -62,6 +62,126 @@ pub const LIGHTS: &[&str] = &[
"brightRoom",
];
/// Every camera preset the file format defines (`ST_PresetCameraType`); a file's camera is kept
/// only when it is one of these.
pub const ALL_CAMERAS: &[&str] = &[
"legacyObliqueTopLeft",
"legacyObliqueTop",
"legacyObliqueTopRight",
"legacyObliqueLeft",
"legacyObliqueFront",
"legacyObliqueRight",
"legacyObliqueBottomLeft",
"legacyObliqueBottom",
"legacyObliqueBottomRight",
"legacyPerspectiveTopLeft",
"legacyPerspectiveTop",
"legacyPerspectiveTopRight",
"legacyPerspectiveLeft",
"legacyPerspectiveFront",
"legacyPerspectiveRight",
"legacyPerspectiveBottomLeft",
"legacyPerspectiveBottom",
"legacyPerspectiveBottomRight",
"orthographicFront",
"isometricTopUp",
"isometricTopDown",
"isometricBottomUp",
"isometricBottomDown",
"isometricLeftUp",
"isometricLeftDown",
"isometricRightUp",
"isometricRightDown",
"isometricOffAxis1Left",
"isometricOffAxis1Right",
"isometricOffAxis1Top",
"isometricOffAxis2Left",
"isometricOffAxis2Right",
"isometricOffAxis2Top",
"isometricOffAxis3Left",
"isometricOffAxis3Right",
"isometricOffAxis3Bottom",
"isometricOffAxis4Left",
"isometricOffAxis4Right",
"isometricOffAxis4Bottom",
"obliqueTopLeft",
"obliqueTop",
"obliqueTopRight",
"obliqueLeft",
"obliqueRight",
"obliqueBottomLeft",
"obliqueBottom",
"obliqueBottomRight",
"perspectiveFront",
"perspectiveLeft",
"perspectiveRight",
"perspectiveAbove",
"perspectiveBelow",
"perspectiveAboveLeftFacing",
"perspectiveAboveRightFacing",
"perspectiveContrastingLeftFacing",
"perspectiveContrastingRightFacing",
"perspectiveHeroicLeftFacing",
"perspectiveHeroicRightFacing",
"perspectiveHeroicExtremeLeftFacing",
"perspectiveHeroicExtremeRightFacing",
"perspectiveRelaxed",
"perspectiveRelaxedModerately",
];
/// Every light rig the file format defines (`ST_LightRigType`).
pub const ALL_LIGHTS: &[&str] = &[
"legacyFlat1",
"legacyFlat2",
"legacyFlat3",
"legacyFlat4",
"legacyNormal1",
"legacyNormal2",
"legacyNormal3",
"legacyNormal4",
"legacyHarsh1",
"legacyHarsh2",
"legacyHarsh3",
"legacyHarsh4",
"threePt",
"balanced",
"soft",
"harsh",
"flood",
"contrasting",
"morning",
"sunrise",
"sunset",
"chilly",
"freezing",
"flat",
"twoPt",
"glow",
"brightRoom",
];
/// Light rig directions (`ST_LightRigDirection`).
pub const LIGHT_DIRS: &[&str] = &["tl", "t", "tr", "l", "r", "bl", "b", "br"];
/// Every material the file format defines (`ST_PresetMaterialType`).
pub const ALL_MATERIALS: &[&str] = &[
"legacyMatte",
"legacyPlastic",
"legacyMetal",
"legacyWireframe",
"matte",
"plastic",
"metal",
"warmMatte",
"translucentPowder",
"powder",
"dkEdge",
"softEdge",
"clear",
"flat",
"softmetal",
];
/// One bevel: preset, width and height in points.
#[derive(Clone, Debug, PartialEq)]
pub struct Bevel {
@@ -107,92 +227,54 @@ impl Default for Shape3d {
}
}
/// The start tags of `raw` as (local name, tag text), in order.
fn tags(raw: &str) -> Vec<(&str, &str)> {
let mut out = Vec::new();
let mut rest = raw;
while let Some(i) = rest.find('<') {
let Some(t) = rest.get(i + 1..) else { break };
let end = t.find('>').unwrap_or(t.len());
let tag = t.get(..end).unwrap_or("");
rest = t.get(end..).unwrap_or("");
if tag.starts_with('/') || tag.starts_with('?') || tag.starts_with('!') {
continue;
}
let name = tag.split(|c: char| c.is_whitespace() || c == '/').next().unwrap_or("");
let local = name.rsplit(':').next().unwrap_or(name);
out.push((local, tag));
}
out
}
fn attr<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
let key = format!(" {name}=\"");
let s = tag.find(&key)? + key.len();
let rest = tag.get(s..)?;
rest.get(..rest.find('"')?)
}
fn num(tag: &str, name: &str) -> Option<f64> {
attr(tag, name).and_then(|v| v.parse::<f64>().ok()).filter(|v| v.is_finite())
}
const EMU: f64 = 12700.0;
const ANGLE: f64 = 60000.0;
/// Largest length kept, in points (PowerPoint's own limit).
const MAX_PT: f64 = 1584.0;
/// An `a:`-prefixed element `local` (with its children) cut from `raw`, if there is one.
fn element(raw: &str, local: &str) -> Option<String> {
let open = format!("<a:{local}");
let close = format!("</a:{local}>");
let i = raw.find(&open)?;
let j = raw.get(i..)?.find(&close)? + i + close.len();
raw.get(i..j).map(str::to_string)
/// `value` when it is one of `known`, else `default`. Values from a file are written back into
/// XML as they are, so nothing outside these lists may pass.
fn known(value: Option<&str>, known: &[&str], default: &str) -> String {
value.filter(|v| known.contains(v)).unwrap_or(default).to_string()
}
fn num(e: &deckcraft_pptx::XmlEl, name: &str) -> Option<f64> {
e.attr(name).and_then(|v| v.trim().parse::<f64>().ok()).filter(|v| v.is_finite())
}
impl Shape3d {
/// Reads kept 3-D XML; unknown parts take their defaults.
/// Reads kept 3-D XML (`a:scene3d` and `a:sp3d`); unknown or invalid parts take their defaults.
pub fn parse(raw: &str) -> Shape3d {
let mut s = Shape3d::default();
let mut in_camera = false;
for (name, tag) in tags(raw) {
match name {
"camera" => {
s.camera = attr(tag, "prst").unwrap_or("orthographicFront").to_string();
in_camera = !tag.ends_with('/');
}
"rot" if in_camera => {
let d = |n| num(tag, n).unwrap_or(0.0) / ANGLE;
s.rot = Some((d("lon"), d("lat"), d("rev")));
in_camera = false;
}
"lightRig" => {
in_camera = false;
s.light = attr(tag, "rig").unwrap_or("threePt").to_string();
s.light_dir = attr(tag, "dir").unwrap_or("t").to_string();
}
"sp3d" => {
in_camera = false;
s.depth = num(tag, "extrusionH").unwrap_or(0.0) / EMU;
s.contour = num(tag, "contourW").unwrap_or(0.0) / EMU;
s.z = num(tag, "z").unwrap_or(0.0) / EMU;
s.material = attr(tag, "prstMaterial").unwrap_or("warmMatte").to_string();
}
"bevelT" | "bevelB" => {
let b = Bevel {
preset: attr(tag, "prst").unwrap_or("circle").to_string(),
w: num(tag, "w").unwrap_or(76200.0) / EMU,
h: num(tag, "h").unwrap_or(76200.0) / EMU,
};
if name == "bevelT" {
s.top = Some(b);
} else {
s.bottom = Some(b);
}
}
_ => {}
let Ok(doc) = deckcraft_pptx::parse_xml(format!("<r>{raw}</r>").as_bytes()) else { return s };
let root = &doc.root;
if let Some(cam) = root.path(&["scene3d", "camera"]) {
s.camera = known(cam.attr("prst"), ALL_CAMERAS, "orthographicFront");
if let Some(rot) = cam.child("rot") {
let d = |n| num(rot, n).unwrap_or(0.0) / ANGLE;
s.rot = Some((d("lon"), d("lat"), d("rev")));
}
}
s.colors = ["extrusionClr", "contourClr"].iter().filter_map(|n| element(raw, n)).collect();
if let Some(l) = root.path(&["scene3d", "lightRig"]) {
s.light = known(l.attr("rig"), ALL_LIGHTS, "threePt");
s.light_dir = known(l.attr("dir"), LIGHT_DIRS, "t");
}
if let Some(sp) = root.child("sp3d") {
let pt = |n, lo: f64| (num(sp, n).unwrap_or(0.0) / EMU).clamp(lo, MAX_PT);
s.depth = pt("extrusionH", 0.0);
s.contour = pt("contourW", 0.0);
s.z = pt("z", -MAX_PT);
s.material = known(sp.attr("prstMaterial"), ALL_MATERIALS, "warmMatte");
for (name, slot) in [("bevelT", &mut s.top), ("bevelB", &mut s.bottom)] {
if let Some(b) = sp.child(name) {
let len = |n| (num(b, n).unwrap_or(76200.0) / EMU).clamp(0.0, MAX_PT);
*slot = Some(Bevel { preset: known(b.attr("prst"), BEVELS, "circle"), w: len("w"), h: len("h") });
}
}
// Colours are re-serialized by the XML writer (escaped); only `a:`-prefixed ones,
// since they are written back inside our own `a:sp3d`.
s.colors = sp.elements().filter(|c| (c.is("extrusionClr") || c.is("contourClr")) && c.prefix() == "a").map(|c| c.to_xml()).collect();
}
s
}
@@ -302,3 +384,49 @@ pub fn store(e: &mut deckcraft_model::Effects, s: &Shape3d) {
e.raw3d = s.to_xml();
e.bevel = if e.raw3d.is_some() { s.top.as_ref().map(|b| b.preset.clone()) } else { None };
}
#[cfg(test)]
mod tests {
use super::*;
const NS: &str = r#"xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main""#;
/// Preset names from a file are written back into XML; anything outside the format's lists
/// (here, values that would close the tag and inject markup once unescaped) falls back to the
/// default instead.
#[test]
fn unknown_presets_from_a_file_are_not_written_back() {
let evil = "x&quot;/&gt;&lt;a:evil/&gt;";
let raw = format!(
r#"<a:scene3d {NS}><a:camera prst="{evil}"/><a:lightRig rig="{evil}" dir="{evil}"/></a:scene3d><a:sp3d {NS} extrusionH="12700" prstMaterial="{evil}"><a:bevelT w="25400" h="25400" prst="{evil}"/><a:extrusionClr><a:srgbClr val="{evil}"/></a:extrusionClr></a:sp3d>"#
);
let s = Shape3d::parse(&raw);
assert_eq!(
(s.camera.as_str(), s.light.as_str(), s.light_dir.as_str(), s.material.as_str()),
("orthographicFront", "threePt", "t", "warmMatte")
);
assert_eq!(s.top.as_ref().map(|b| b.preset.as_str()), Some("circle"));
assert_eq!(s.depth, 1.0);
let xml = s.to_xml().expect("not flat");
assert!(!xml.contains("<a:evil"), "{xml}");
// What is written is well-formed XML with the colour's value escaped.
let doc = deckcraft_pptx::parse_xml(format!("<r {NS}>{xml}</r>").as_bytes()).expect("well-formed");
assert_eq!(doc.root.path(&["sp3d", "extrusionClr", "srgbClr"]).and_then(|c| c.attr("val")), Some(r#"x"/><a:evil/>"#));
}
#[test]
fn known_presets_round_trip() {
let raw = format!(
r#"<a:scene3d {NS}><a:camera prst="legacyObliqueTopLeft"><a:rot lat="1200000" lon="600000" rev="0"/></a:camera><a:lightRig rig="legacyHarsh2" dir="tl"/></a:scene3d><a:sp3d {NS} z="-12700" contourW="12700" prstMaterial="legacyMetal"><a:bevelB w="38100" h="12700" prst="artDeco"/></a:sp3d>"#
);
let s = Shape3d::parse(&raw);
assert_eq!(s.camera, "legacyObliqueTopLeft");
assert_eq!(s.rot, Some((10.0, 20.0, 0.0)));
assert_eq!((s.light.as_str(), s.light_dir.as_str(), s.material.as_str()), ("legacyHarsh2", "tl", "legacyMetal"));
assert_eq!((s.z, s.contour), (-1.0, 1.0));
assert_eq!(s.bottom, Some(Bevel { preset: "artDeco".into(), w: 3.0, h: 1.0 }));
assert_eq!(Shape3d::parse(&s.to_xml().expect("3-D")), s);
// Not XML at all: defaults.
assert_eq!(Shape3d::parse("<a:scene3d><a:camera"), Shape3d::default());
}
}
+4
View File
@@ -18,6 +18,10 @@ mod tables;
mod write;
mod xml;
/// The bounded XML reader the importer uses, for callers that read kept fragments (a shape's
/// 3-D settings) instead of scanning strings.
pub use xml::{Doc as XmlDoc, El as XmlEl, parse as parse_xml};
use deckcraft_model::Presentation;
/// Extension URI of the PowerPoint 2010 section list in `presentation.xml`.
+1 -5
View File
@@ -94,11 +94,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`).
| Pane / dialog | PowerPoint | DeckCraft |
|---|---|---|
| Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, line (dash, compound, cap, join, begin/end arrows and sizes), some effects, size (lock aspect ratio); no 3-D, thin picture/texture options |
| Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, line, shadow/glow/reflection details (colour, transparency, blur, angle, distance, size), soft edges, size; no 3-D, thin picture/texture options |
| Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill (solid transparency follows the selection), gradient editor, line, some effects, size; no 3-D, thin picture/texture options |
| Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, picture/texture fill (source, tile, offsets, scale, alignment, mirror, transparency, rotate with shape), line, some effects, size; no 3-D |
| Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, line, some effects, 3-D format and rotation (bevels, depth, contour, material, lighting, camera presets, X/Y/Z rotation), size; thin picture/texture options |
| Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill (solid transparency follows the selection), gradient editor, picture/texture fill (source, tile, offsets, scale, alignment, mirror, transparency, rotate with shape), line (dash, compound, cap, join, begin/end arrows and sizes), shadow/glow/reflection details (colour, transparency, blur, angle, distance, size), soft edges, 3-D format and rotation (bevels, depth, contour, material, lighting, camera presets, X/Y/Z rotation), size (lock aspect ratio) |
| Format Background pane | yes | yes |
| Slide Master | Insert Layout, Rename, Delete, Preserve, Insert Placeholder, Title and Footers | Insert Layout, Rename, Delete, Insert Placeholder, and Preserve, Title and Footers checkboxes (`master.preserve`, `master.placeholders`). A master thumbnail selects the master |
| Selection Pane | front-most first, groups fold, drag to restack, Bring Forward / Send Backward arrows, Show All / Hide All, eye, padlock (Lock), rename in place | same: drag restacks among siblings (a child stays in its group), Ctrl+click toggles, Shift+click selects a run, arrows / Home / End / F2 / Esc in the list, right-click menu |