Merge the chart and EMF+ metafile PRs integrated on land-r2 (#532 #537 #591 #594 #598 #602 #603 #638 #640 #662 #666 #669 #690 #582 #588 #633 #635)

# Conflicts:
#	crates/pptx/src/write/mod.rs
#	crates/pptx/tests/roundtrip.rs
This commit is contained in:
Brandon Thomas
2026-10-14 14:17:35 -04:00
30 changed files with 5690 additions and 196 deletions
+71 -2
View File
@@ -26,7 +26,7 @@ fn simple_line(sp: Option<&El>) -> Option<deckcraft_model::Line> {
Some(l)
}
fn rich_text(t: &El) -> Option<String> {
pub(crate) fn rich_text(t: &El) -> Option<String> {
let tx = t.child("tx")?;
if let Some(r) = tx.child("rich") {
let s = r.children_named("p").map(|p| p.text()).collect::<Vec<_>>().join("\n");
@@ -188,18 +188,28 @@ fn chart_within(root: &El, mut budget: usize) -> Option<Chart> {
ChartType::Radar
}
}
"stockChart" => ChartType::Stock,
"stockChart" => {
c.up_down_bars = g.child("upDownBars").is_some();
ChartType::Stock
}
"surfaceChart" | "surface3DChart" => ChartType::Surface,
_ => continue,
};
if let Some(v) = g.child_val("varyColors") {
c.vary_colors = v == "1" || v == "true";
}
// A bar group without c:gapWidth / c:overlap takes the schema's defaults (150 %, 0 %),
// not the 219 % / -27 % PowerPoint writes into its new charts.
let bar_group = g.is("barChart") || g.is("bar3DChart");
if let Some(v) = g.child_val("gapWidth").and_then(|v| v.parse::<f64>().ok()) {
c.gap_width = (v / 100.0).clamp(0.0, 5.0);
} else if bar_group {
c.gap_width = 1.5;
}
if let Some(v) = g.child_val("overlap").and_then(|v| v.parse::<f64>().ok()) {
c.overlap = (v / 100.0).clamp(-1.0, 1.0);
} else if bar_group {
c.overlap = 0.0;
}
if let Some(v) = g.child_val("holeSize").and_then(|v| v.parse::<f64>().ok()) {
c.hole_size = (v / 100.0).clamp(0.0, 0.9);
@@ -308,21 +318,26 @@ fn chart_within(root: &El, mut budget: usize) -> Option<Chart> {
let cat_ax = pa.child("catAx").or_else(|| pa.child("dateAx")).or_else(|| pa.children_named("valAx").find(|a| !vertical(a)));
if let Some(v) = val_ax {
c.gridlines = v.child("majorGridlines").is_some();
c.minor_gridlines = v.child("minorGridlines").is_some();
let sc = v.child("scaling");
c.y_min = sc.and_then(|s| s.child_val("min")).and_then(|v| v.parse().ok()).filter(|v: &f64| v.is_finite());
c.y_max = sc.and_then(|s| s.child_val("max")).and_then(|v| v.parse().ok()).filter(|v: &f64| v.is_finite());
c.axis_titles.1 = v.child("title").map(|t| rich_text(t).unwrap_or_else(|| "Axis Title".into()));
c.y_major = num(v, "majorUnit");
c.y_log_base = sc.and_then(|s| s.child_val("logBase")).and_then(|v| v.parse().ok()).filter(|b: &f64| b.is_finite() && *b >= 2.0);
c.y_format = format_code(v);
c.y_hidden = deleted(v);
}
if let Some(a) = cat_ax {
c.cat_reversed = a.child("scaling").and_then(|s| s.child_val("orientation")).is_some_and(|o| o == "maxMin");
c.axis_titles.0 = a.child("title").map(|t| rich_text(t).unwrap_or_else(|| "Axis Title".into()));
}
// Text sizes as the file sets them (PowerPoint draws chart text at these sizes, not scaled to
// the chart): the axes' and legend's, else the whole chart's; the title's own.
let chart_size = text_size(root.child("txPr"));
c.text_size = [val_ax, cat_ax, ch.child("legend")].into_iter().flatten().find_map(|e| text_size(e.child("txPr"))).or(chart_size);
// Text colour likewise: the axes' and legend's, else the whole chart's.
c.text_color = [val_ax, cat_ax, ch.child("legend"), Some(root)].into_iter().flatten().find_map(|e| text_color(e.child("txPr")));
c.title_size = ch.child("title").and_then(|t| text_size(t.child("tx").and_then(|x| x.child("rich"))).or_else(|| text_size(t.child("txPr"))));
Some(c)
}
@@ -354,6 +369,27 @@ fn trendline(e: &El) -> Option<Trendline> {
})
}
/// The colour of the first `a:defRPr` or `a:rPr` with a solid fill under `e`.
fn text_color(e: Option<&El>) -> Option<deckcraft_model::ColorRef> {
fn rec(e: &El, depth: usize) -> Option<deckcraft_model::ColorRef> {
if depth > 8 {
return None;
}
for c in e.elements() {
if (c.is("defRPr") || c.is("rPr"))
&& let Some(Fill::Solid { color }) = simple_fill(Some(c))
{
return Some(color);
}
if let Some(v) = rec(c, depth + 1) {
return Some(v);
}
}
None
}
rec(e?, 0)
}
/// The size in points of the first `a:defRPr` or `a:rPr` with one under `e` (`sz` in hundredths).
fn text_size(e: Option<&El>) -> Option<f64> {
fn rec(e: &El, depth: usize) -> Option<f64> {
@@ -405,6 +441,22 @@ mod tests {
assert!(c.series.iter().all(|s| s.values.len() == 100_000));
}
#[test]
fn minor_gridlines_are_read_and_written_back() {
let mut c = Chart::sample(ChartType::Line);
c.minor_gridlines = true;
let back = read_chart_xml(&crate::write::chart::chart_xml(&c)).unwrap();
assert!(back.gridlines && back.minor_gridlines);
c.minor_gridlines = false;
assert!(!read_chart_xml(&crate::write::chart::chart_xml(&c)).unwrap().minor_gridlines);
}
#[test]
fn log_base_is_read() {
let xml = br#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart><c:plotArea><c:lineChart><c:axId val="1"/><c:axId val="2"/></c:lineChart><c:catAx><c:axId val="1"/></c:catAx><c:valAx><c:axId val="2"/><c:scaling><c:logBase val="10"/><c:orientation val="minMax"/></c:scaling></c:valAx></c:plotArea></c:chart></c:chartSpace>"#;
assert_eq!(read_chart_xml(xml).unwrap().y_log_base, Some(10.0));
}
#[test]
fn legend_position_and_overlay_are_read() {
let legend = r#"<c:legend><c:legendPos val="r"/><c:overlay val="1"/></c:legend>"#;
@@ -472,6 +524,23 @@ mod tests {
assert_eq!(fmt(r#"<c:numFmt formatCode="0.0" sourceLinked="0"/>"#).as_deref(), Some("0.0"));
}
#[test]
fn text_colour_is_read_from_the_axes() {
// LibreOffice's templates set light axis text for dark slides on the axes' c:txPr.
let xml = r#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><c:chart><c:plotArea><c:barChart><c:barDir val="col"/><c:ser><c:idx val="0"/><c:val><c:numRef><c:numCache><c:ptCount val="1"/><c:pt idx="0"><c:v>1</c:v></c:pt></c:numCache></c:numRef></c:val></c:ser><c:axId val="1"/><c:axId val="2"/></c:barChart><c:catAx><c:axId val="1"/><c:txPr><a:bodyPr/><a:p><a:pPr><a:defRPr sz="1000"><a:solidFill><a:srgbClr val="FFFFFF"/></a:solidFill></a:defRPr></a:pPr></a:p></c:txPr></c:catAx><c:valAx><c:axId val="2"/></c:valAx></c:plotArea></c:chart></c:chartSpace>"#;
let c = read_chart_xml(xml.as_bytes()).unwrap();
assert_eq!(c.text_color, Some(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 255, 255))));
assert!(!c.cat_reversed);
let rev = read_chart_xml(
xml.replace("<c:catAx><c:axId val=\"1\"/>", "<c:catAx><c:axId val=\"1\"/><c:scaling><c:orientation val=\"maxMin\"/></c:scaling>")
.as_bytes(),
)
.unwrap();
assert!(rev.cat_reversed, "maxMin reverses the category axis");
// No c:gapWidth / c:overlap: the schema's 150 % and 0 %.
assert_eq!((c.gap_width, c.overlap), (1.5, 0.0));
}
#[test]
fn text_sizes_are_read_from_the_file() {
// PowerPoint's new charts set the axes' and legend's text to 11.97 pt and the title's to
+143
View File
@@ -0,0 +1,143 @@
//! Office 2016 charts (`cx:chartSpace`, the `chartEx` part): waterfall, funnel, treemap,
//! sunburst, histogram, Pareto and box & whisker. Their data sits in `cx:chartData` as levels of
//! points, and each series names its layout (`layoutId`).
use super::chart::rich_text;
use crate::xml::El;
use deckcraft_model::chart::{Chart, ChartType, Series};
use std::collections::HashMap;
/// Points per level and levels per dimension we keep (guards against hostile parts).
const MAX_POINTS: usize = 100_000;
/// One `cx:data`: its categories (the innermost level) and named numeric dimensions.
#[derive(Default)]
struct Data {
categories: Vec<String>,
/// Outer category levels, innermost first.
levels: Vec<Vec<String>>,
numbers: HashMap<String, (Vec<Option<f64>>, Option<String>)>,
}
/// A level's points, by `idx`.
fn points(lvl: &El) -> Vec<String> {
let n = lvl.u32("ptCount").unwrap_or(0).min(MAX_POINTS as u32) as usize;
let mut out = vec![String::new(); n];
for pt in lvl.children_named("pt").take(MAX_POINTS) {
let i = pt.u32("idx").unwrap_or(0) as usize;
if i >= MAX_POINTS {
continue;
}
if i >= out.len() {
out.resize(i + 1, String::new());
}
if let Some(s) = out.get_mut(i) {
*s = pt.text();
}
}
out
}
fn data(d: &El) -> Data {
let mut out = Data::default();
for dim in d.elements() {
// The first level is the innermost: a treemap's leaves, a waterfall's categories.
let Some(lvl) = dim.child("lvl") else { continue };
let ty = dim.attr("type").unwrap_or("");
if dim.is("strDim") || (dim.is("numDim") && ty == "cat") {
if ty == "cat" || out.categories.is_empty() {
out.categories = points(lvl);
// Further levels are the hierarchy's parents, innermost first.
out.levels = dim.children_named("lvl").skip(1).take(deckcraft_model::chart::MAX_LEVELS).map(points).collect();
}
} else if dim.is("numDim") {
let vals = points(lvl).into_iter().map(|s| s.trim().parse::<f64>().ok().filter(|v| v.is_finite())).collect();
out.numbers.insert(ty.to_string(), (vals, lvl.attr("formatCode").map(str::to_string)));
}
}
out
}
fn kind_of(layout: &str) -> Option<ChartType> {
Some(match layout {
"waterfall" => ChartType::Waterfall,
"funnel" => ChartType::Funnel,
"treemap" => ChartType::Treemap,
"sunburst" => ChartType::Sunburst,
"clusteredColumn" => ChartType::Histogram,
"boxWhisker" => ChartType::BoxWhisker,
"regionMap" => ChartType::RegionMap,
_ => return None,
})
}
/// Parse a `chartEx` part. `None` for anything else, and for layouts DeckCraft has no type for.
pub fn read_chartex_xml(bytes: &[u8]) -> Option<Chart> {
let doc = crate::xml::parse(bytes).ok()?;
let mut root = doc.root;
super::resolve_mc(&mut root, 0);
let mut c = chartex_from(&root)?;
c.raw = Some(String::from_utf8_lossy(bytes).into_owned());
Some(c)
}
pub(crate) fn chartex_from(root: &El) -> Option<Chart> {
if !root.is("chartSpace") {
return None;
}
let datas: HashMap<String, Data> =
root.child("chartData")?.children_named("data").map(|d| (d.attr("id").unwrap_or("0").to_string(), data(d))).collect();
let ch = root.child("chart")?;
let region = ch.child("plotArea")?.child("plotAreaRegion")?;
let mut c = Chart { categories: vec![], series: vec![], title: None, legend: None, gridlines: false, ..Default::default() };
c.title = ch.child("title").map(|t| rich_text(t).filter(|s| !s.is_empty()).unwrap_or_else(|| "Chart Title".into()));
c.legend = ch.child("legend").map(|l| l.attr("pos").unwrap_or("t").to_string());
let mut kind = None;
for s in region.children_named("series") {
let layout = s.attr("layoutId").unwrap_or("");
if layout == "paretoLine" {
// The cumulative line PowerPoint adds to a Pareto histogram.
kind = Some(ChartType::Pareto);
continue;
}
let k = kind_of(layout)?;
if s.bool("hidden") == Some(true) {
continue;
}
kind = Some(match kind {
Some(ChartType::Pareto) => ChartType::Pareto,
_ => k,
});
let d = datas.get(s.child("dataId").and_then(|i| i.attr("val")).unwrap_or("0"))?;
let (values, format) =
["val", "size", "colorVal"].iter().find_map(|t| d.numbers.get(*t)).or_else(|| d.numbers.values().next()).cloned().unwrap_or_default();
if c.categories.is_empty() {
c.categories = d.categories.clone();
c.category_levels = d.levels.clone();
}
if let Some(st) = s.child("layoutPr").and_then(|l| l.child("statistics")) {
c.inclusive_quartiles = st.attr("quartileMethod") == Some("inclusive");
}
if let Some(b) = s.child("layoutPr").and_then(|l| l.child("binning")) {
c.bin_count = b.child("binCount").and_then(|n| n.u32("val")).filter(|n| *n > 0);
c.bin_size = b.child("binSize").and_then(|n| n.attr("val")).and_then(|v| v.parse::<f64>().ok()).filter(|v| v.is_finite() && *v > 0.0);
}
if let Some(st) = s.child("layoutPr").and_then(|l| l.child("subtotals")) {
c.subtotals = st.children_named("idx").filter_map(|i| i.u32("val")).map(|i| i as usize).take(MAX_POINTS).collect();
}
let name = s.child("tx").and_then(|t| t.find("v")).map(|v| v.text()).unwrap_or_default();
c.series.push(Series {
name,
values,
data_labels: s.child("dataLabels").map(|l| l.bool("hidden") != Some(true)),
number_format: format,
..Default::default()
});
}
c.kind = kind?;
c.data_labels = c.series.iter().any(|s| s.data_labels == Some(true));
c.vary_colors = matches!(c.kind, ChartType::Funnel | ChartType::Treemap | ChartType::Sunburst | ChartType::Waterfall);
// Waterfall, histogram and box charts have a value axis with gridlines by default.
c.gridlines = matches!(c.kind, ChartType::Waterfall | ChartType::Histogram | ChartType::Pareto | ChartType::BoxWhisker);
Some(c)
}
+15 -3
View File
@@ -1,12 +1,14 @@
//! PPTX import: package → [`Presentation`].
mod chart;
pub(crate) mod chart;
mod chartex;
pub(crate) mod dml;
mod shapes;
mod theme;
mod timing;
pub(crate) use chart::{chart_from as chart_from_el, read_chart_xml};
pub(crate) use chartex::{chartex_from, read_chartex_xml};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
@@ -155,7 +157,16 @@ 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"];
const SUPPORTED_MC: [&str; 10] = ["p14", "p15", "p159", "a14", "a16", "p1510", "a15", "c14", "p188", "cx1"];
/// Office 2016 charts sit in a `cx1` choice; take it only for a layout DeckCraft reads (the
/// fallback is a picture or "this chart isn't available" text). The part is checked later, when
/// the frame is read, so here only the namespace counts.
fn chartex_readable(ch: &El) -> bool {
let mut found = vec![];
ch.find_all("graphicData", &mut found);
found.iter().any(|g| g.attr("uri").is_some_and(|u| u.ends_with("/chartex")))
}
/// A relationship to an embedded 3-D model (`.glb`).
pub(crate) fn is_model3d(rel: &opc::Rel) -> bool {
@@ -285,7 +296,8 @@ fn resolve_mc_rels(el: &mut El, depth: usize, rels: Option<&Rels>) {
let choice = ac.children_named("Choice").find(|ch| {
let req_ok = ch.attr("Requires").unwrap_or("").split_whitespace().all(|r| SUPPORTED_MC.contains(&r));
// Ink content parts are not modelled; prefer their fallback picture.
let modelled = !ch.elements().any(|e| e.is("contentPart"));
let modelled = !ch.elements().any(|e| e.is("contentPart"))
&& (!ch.attr("Requires").unwrap_or("").contains("cx1") || chartex_readable(ch));
req_ok && modelled
});
let branch = choice.or_else(|| ac.child("Fallback"));
+1 -1
View File
@@ -422,7 +422,7 @@ fn frame(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> S
if let Some(c) = gd.child("chart")
&& let Some(rel) = part.rels.get(c.attr("r:id").unwrap_or(""))
&& let Some(bytes) = imp.pkg.xml_bytes(&rel.target)
&& let Some(chart) = super::read_chart_xml(bytes)
&& let Some(chart) = if uri.ends_with("/chartex") { super::read_chartex_xml(bytes) } else { super::read_chart_xml(bytes) }
{
s.kind = ShapeKind::Chart(Box::new(chart));
return s;
+304 -4
View File
@@ -4,6 +4,9 @@ use deckcraft_model::chart::{Chart, ChartType, Series, Trendline, TrendlineType}
use deckcraft_model::style::Fill;
use crate::opc::{NS_A, NS_C, NS_R};
/// The Office 2016 chart namespace.
pub const NS_CX: &str = "http://schemas.microsoft.com/office/drawing/2014/chartex";
use crate::xml::{A, W};
/// Column letter for a 0-based index (A, B, … Z, AA…).
@@ -118,11 +121,13 @@ enum Group {
Scatter,
Radar,
Bubble,
Stock,
}
fn group_of(k: ChartType) -> Group {
match k {
ChartType::Line | ChartType::LineMarkers | ChartType::StackedLine | ChartType::Stock => Group::Line,
ChartType::Line | ChartType::LineMarkers | ChartType::StackedLine => Group::Line,
ChartType::Stock => Group::Stock,
ChartType::Area | ChartType::StackedArea | ChartType::Surface => Group::Area,
ChartType::Pie => Group::Pie,
ChartType::Doughnut | ChartType::Sunburst => Group::Doughnut,
@@ -140,7 +145,32 @@ fn series(w: &mut W, c: &Chart, s: &Series, i: usize, kind: ChartType, g: Group)
w.open0("c:tx");
str_cache(w, &format!("Sheet1!${}$1", col(i + 1)), std::slice::from_ref(&s.name));
w.close("c:tx");
sp_pr(w, s.fill.as_ref(), s.line.as_ref());
if g == Group::Stock && s.line.is_none() {
// A stock chart's series are points joined by the high-low lines, not lines of their own.
w.open0("c:spPr");
w.open("a:ln", A::new().a("w", 19050));
w.empty0("a:noFill");
w.close("a:ln");
w.close("c:spPr");
} else {
sp_pr(w, s.fill.as_ref(), s.line.as_ref());
}
if g == Group::Stock {
// High-low-close: the close is a dash on the high-low line; with up/down bars (or a set
// marker) the series keep their own.
let close = !c.up_down_bars && i + 1 == c.series.len() && c.series.len() == 3;
let sym = s.marker.as_deref().filter(|m| !m.is_empty() && m.chars().all(|ch| ch.is_ascii_alphanumeric())).unwrap_or(if close {
"dash"
} else {
"none"
});
w.open0("c:marker");
w.val("c:symbol", sym);
if sym != "none" {
w.val("c:size", 7);
}
w.close("c:marker");
}
if g == Group::Bar {
w.val("c:invertIfNegative", 0);
}
@@ -302,7 +332,7 @@ fn axes(w: &mut W, c: &Chart, g: Group) {
w.open0("c:catAx");
w.val("c:axId", AX_CAT);
w.open0("c:scaling");
w.val("c:orientation", "minMax");
w.val("c:orientation", if c.cat_reversed { "maxMin" } else { "minMax" });
w.close("c:scaling");
w.val("c:delete", 0);
w.val("c:axPos", cat_pos);
@@ -324,6 +354,9 @@ fn axes(w: &mut W, c: &Chart, g: Group) {
w.open0("c:valAx");
w.val("c:axId", AX_VAL);
w.open0("c:scaling");
if let Some(b) = c.y_log_base {
w.val("c:logBase", fmt_num(b));
}
w.val("c:orientation", "minMax");
if let Some(m) = c.y_max.filter(|v| v.is_finite()) {
w.val("c:max", fmt_num(m));
@@ -337,6 +370,9 @@ fn axes(w: &mut W, c: &Chart, g: Group) {
if c.gridlines {
w.empty0("c:majorGridlines");
}
if c.minor_gridlines {
w.empty0("c:minorGridlines");
}
if let Some(t) = &c.axis_titles.1 {
title(w, t);
}
@@ -374,6 +410,7 @@ fn plot_group(w: &mut W, c: &Chart, kind: ChartType, list: &[(usize, &Series)])
Group::Scatter => "c:scatterChart",
Group::Radar => "c:radarChart",
Group::Bubble => "c:bubbleChart",
Group::Stock => "c:stockChart",
};
w.open0(tag);
match g {
@@ -386,7 +423,10 @@ fn plot_group(w: &mut W, c: &Chart, kind: ChartType, list: &[(usize, &Series)])
Group::Radar => w.val("c:radarStyle", if kind == ChartType::FilledRadar { "filled" } else { "marker" }),
_ => {}
}
w.val("c:varyColors", vary);
// `c:stockChart` has no `c:varyColors`.
if g != Group::Stock {
w.val("c:varyColors", vary);
}
for (i, s) in list {
series(w, c, s, *i, kind, g);
}
@@ -402,6 +442,24 @@ fn plot_group(w: &mut W, c: &Chart, kind: ChartType, list: &[(usize, &Series)])
}
}
Group::Line => w.val("c:marker", 1),
Group::Stock => {
w.open0("c:hiLowLines");
w.open0("c:spPr");
w.open("a:ln", A::new().a("w", 9525));
w.open0("a:solidFill");
w.empty("a:schemeClr", A::new().a("val", "tx1"));
w.close("a:solidFill");
w.close("a:ln");
w.close("c:spPr");
w.close("c:hiLowLines");
if c.up_down_bars {
w.open0("c:upDownBars");
w.val("c:gapWidth", 150);
w.empty0("c:upBars");
w.empty0("c:downBars");
w.close("c:upDownBars");
}
}
Group::Pie => w.val("c:firstSliceAng", 0),
Group::Doughnut => {
w.val("c:firstSliceAng", 0);
@@ -416,10 +474,35 @@ fn plot_group(w: &mut W, c: &Chart, kind: ChartType, list: &[(usize, &Series)])
w.close(tag);
}
/// The `chartEx` part of an Office 2016 chart (waterfall, funnel…): the kept part when the chart
/// is unchanged since it was read (without its workbook link), else one written from the model.
/// `None` for other chart types.
pub fn chartex_xml(c: &Chart) -> Option<Vec<u8>> {
kept_chartex(c).or_else(|| c.kind.is_chartex().then(|| build_chartex(c)))
}
fn kept_chartex(c: &Chart) -> Option<Vec<u8>> {
let raw = c.raw.as_ref()?;
let doc = crate::xml::parse(raw.as_bytes()).ok()?;
let mut root = doc.root.clone();
crate::read::resolve_mc(&mut root, 0);
let mut back = crate::read::chartex_from(&root)?;
back.raw = c.raw.clone();
if &back != c {
return None;
}
let mut keep = doc.root;
keep.remove_all(&["externalData"]);
let mut w = W::new();
w.raw(&keep.to_xml());
Some(w.finish())
}
/// The chart part XML.
pub fn chart_xml(c: &Chart) -> Vec<u8> {
// Reuse the kept part when the chart is unchanged since it was read.
if let Some(raw) = &c.raw
&& !raw.contains(NS_CX)
&& let Ok(doc) = crate::xml::parse(raw.as_bytes())
&& doc.root.is("chartSpace")
{
@@ -501,6 +584,7 @@ pub fn chart_xml(c: &Chart) -> Vec<u8> {
| ChartType::Bubble
| ChartType::Radar
| ChartType::FilledRadar
| ChartType::Stock
) {
log::warn!("pptx: {} chart written as a column chart", c.kind.label());
}
@@ -533,3 +617,219 @@ pub fn chart_xml(c: &Chart) -> Vec<u8> {
w.close("c:chartSpace");
w.finish()
}
/// A `cx:tx` holding plain text.
fn cx_text(w: &mut W, t: &str) {
w.open0("cx:tx");
w.open0("cx:txData");
w.open0("cx:v");
w.text(t);
w.close("cx:v");
w.close("cx:txData");
w.close("cx:tx");
}
/// A `cx:lvl` of points.
fn cx_level(w: &mut W, pts: &[String], format: Option<&str>) {
let mut a = A::new().a("ptCount", pts.len());
if let Some(f) = format {
a = a.a("formatCode", f);
}
w.open("cx:lvl", a);
for (i, p) in pts.iter().enumerate() {
w.open("cx:pt", A::new().a("idx", i));
w.text(p);
w.close("cx:pt");
}
w.close("cx:lvl");
}
/// The `cx:layoutPr` of the region map series in a kept `chartEx` part.
fn kept_region_layout(raw: &str) -> Option<String> {
let doc = crate::xml::parse(raw.as_bytes()).ok()?;
let mut root = doc.root;
crate::read::resolve_mc(&mut root, 0);
let region = root.child("chart")?.child("plotArea")?.child("plotAreaRegion")?;
let s = region.children_named("series").find(|s| s.attr("layoutId") == Some("regionMap"))?;
let lp = s.child("layoutPr")?;
// Written under our `cx` prefix; a part that bound chartex to another prefix is not copied.
lp.name.starts_with("cx:").then(|| lp.to_xml())
}
/// Writes an Office 2016 chart from the model, as PowerPoint lays the part out: one `cx:data`
/// per series (categories and values), then the plot area with each series' layout, the axes
/// the layout uses, the title and the legend.
pub fn build_chartex(c: &Chart) -> Vec<u8> {
let mut w = W::new();
w.open("cx:chartSpace", A::new().a("xmlns:a", NS_A).a("xmlns:r", NS_R).a("xmlns:cx", NS_CX));
w.open0("cx:chartData");
for (i, s) in c.series.iter().enumerate() {
w.open("cx:data", A::new().a("id", i));
if !c.categories.is_empty() {
w.open("cx:strDim", A::new().a("type", "cat"));
cx_level(&mut w, &c.categories, None);
for lv in &c.category_levels {
cx_level(&mut w, lv, None);
}
w.close("cx:strDim");
}
let ty = match c.kind {
ChartType::Treemap | ChartType::Sunburst => "size",
ChartType::RegionMap => "colorVal",
_ => "val",
};
w.open("cx:numDim", A::new().a("type", ty));
let vals: Vec<String> = s.values.iter().map(|v| v.map(fmt_num).unwrap_or_default()).collect();
cx_level(&mut w, &vals, Some(s.number_format.as_deref().unwrap_or("General")));
w.close("cx:numDim");
w.close("cx:data");
}
w.close("cx:chartData");
w.open0("cx:chart");
if let Some(t) = &c.title {
w.open("cx:title", A::new().a("pos", "t").a("align", "ctr").a("overlay", "0"));
cx_text(&mut w, t);
w.close("cx:title");
}
w.open0("cx:plotArea");
w.open0("cx:plotAreaRegion");
let layout = match c.kind {
ChartType::Waterfall => "waterfall",
ChartType::Funnel => "funnel",
ChartType::Treemap => "treemap",
ChartType::Sunburst => "sunburst",
ChartType::BoxWhisker => "boxWhisker",
ChartType::RegionMap => "regionMap",
_ => "clusteredColumn",
};
let one = !matches!(c.kind, ChartType::BoxWhisker);
for (i, s) in c.series.iter().enumerate().take(if one { 1 } else { usize::MAX }) {
w.open("cx:series", A::new().a("layoutId", layout).a("uniqueId", format!("{{DC00000{i}-0000-4000-8000-000000000000}}")));
cx_text(&mut w, &s.name);
if s.data_labels.unwrap_or(c.data_labels) {
w.open("cx:dataLabels", A::new().a("pos", if c.kind == ChartType::Funnel { "ctr" } else { "outEnd" }));
w.empty("cx:visibility", A::new().a("seriesName", "0").a("categoryName", "0").a("value", "1"));
w.close("cx:dataLabels");
}
w.empty("cx:dataId", A::new().a("val", i));
match c.kind {
ChartType::Waterfall if !c.subtotals.is_empty() => {
w.open0("cx:layoutPr");
w.open0("cx:subtotals");
for t in &c.subtotals {
w.empty("cx:idx", A::new().a("val", t));
}
w.close("cx:subtotals");
w.close("cx:layoutPr");
}
ChartType::Histogram | ChartType::Pareto => {
w.open0("cx:layoutPr");
w.open("cx:binning", A::new().a("intervalClosed", "r"));
if let Some(size) = c.bin_size {
w.empty("cx:binSize", A::new().a("val", fmt_num(size)));
} else if let Some(n) = c.bin_count {
w.empty("cx:binCount", A::new().a("val", n));
}
w.close("cx:binning");
w.close("cx:layoutPr");
}
ChartType::BoxWhisker => {
w.open0("cx:layoutPr");
w.empty("cx:visibility", A::new().a("meanLine", "0").a("meanMarker", "1").a("nonoutliers", "0").a("outliers", "1"));
w.empty("cx:statistics", A::new().a("quartileMethod", if c.inclusive_quartiles { "inclusive" } else { "exclusive" }));
w.close("cx:layoutPr");
}
ChartType::RegionMap => {
// The map's geography (projection, region level and the cached boundaries
// PowerPoint downloaded) is not modelled: carry it over from the part we read.
if let Some(lp) = c.raw.as_deref().and_then(kept_region_layout) {
w.raw(&lp);
}
}
_ => {}
}
w.close("cx:series");
}
if c.kind == ChartType::Pareto {
w.open("cx:series", A::new().a("layoutId", "paretoLine").a("ownerIdx", "0").a("uniqueId", "{DC0000FF-0000-4000-8000-000000000000}"));
w.empty("cx:axisId", A::new().a("val", "2"));
w.close("cx:series");
}
w.close("cx:plotAreaRegion");
if !matches!(c.kind, ChartType::Treemap | ChartType::Sunburst | ChartType::RegionMap) {
w.open("cx:axis", A::new().a("id", "0"));
w.empty("cx:catScaling", A::new().a("gapWidth", if matches!(c.kind, ChartType::Histogram | ChartType::Pareto) { "0" } else { "0.5" }));
w.empty0("cx:tickLabels");
w.close("cx:axis");
if c.kind != ChartType::Funnel {
w.open("cx:axis", A::new().a("id", "1"));
w.empty0("cx:valScaling");
if c.gridlines {
w.empty0("cx:majorGridlines");
}
w.empty0("cx:tickLabels");
w.close("cx:axis");
}
if c.kind == ChartType::Pareto {
w.open("cx:axis", A::new().a("id", "2"));
w.empty("cx:valScaling", A::new().a("max", "1").a("min", "0"));
w.empty0("cx:units");
w.empty0("cx:tickLabels");
w.close("cx:axis");
}
}
w.close("cx:plotArea");
if let Some(pos) = &c.legend {
w.empty("cx:legend", A::new().a("pos", pos.as_str()).a("align", "ctr").a("overlay", "0"));
}
w.close("cx:chart");
w.close("cx:chartSpace");
w.finish()
}
#[cfg(test)]
mod region_map_tests {
use super::*;
/// A region map part as PowerPoint writes it (our own numbers; the cached geography is a
/// stand-in blob).
const MAP: &str = r#"<cx:chartSpace xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:cx="http://schemas.microsoft.com/office/drawing/2014/chartex"><cx:chartData><cx:data id="0"><cx:strDim type="cat"><cx:f>Sheet1!$A$2:$A$4</cx:f><cx:lvl ptCount="3"><cx:pt idx="0">Minsk</cx:pt><cx:pt idx="1">Brest</cx:pt><cx:pt idx="2">Gomel</cx:pt></cx:lvl></cx:strDim><cx:numDim type="colorVal"><cx:f>Sheet1!$B$2:$B$4</cx:f><cx:lvl ptCount="3" formatCode="General"><cx:pt idx="0">12</cx:pt><cx:pt idx="1">5</cx:pt><cx:pt idx="2">8</cx:pt></cx:lvl></cx:numDim></cx:data></cx:chartData><cx:chart><cx:title pos="t" align="ctr" overlay="0"/><cx:plotArea><cx:plotAreaRegion><cx:series layoutId="regionMap" uniqueId="{00000000-0000-4000-8000-000000000001}"><cx:tx><cx:txData><cx:f>Sheet1!$B$1</cx:f><cx:v>Sales</cx:v></cx:txData></cx:tx><cx:dataId val="0"/><cx:layoutPr><cx:geography cultureLanguage="en-US" cultureRegion="BY" attribution="Powered by Bing"><cx:geoCache provider="{E9337A44-BEBE-4D9F-B70C-5C5E7DAFC167}"><cx:binary>ZGVja2NyYWZ0LXRlc3Q=</cx:binary></cx:geoCache></cx:geography></cx:layoutPr></cx:series></cx:plotAreaRegion></cx:plotArea><cx:legend pos="r" align="min" overlay="0"/></cx:chart></cx:chartSpace>"#;
#[test]
fn region_maps_keep_their_geography_through_save_and_edit() {
let c = crate::read::read_chartex_xml(MAP.as_bytes()).expect("a region map is read");
assert_eq!(c.kind, ChartType::RegionMap);
assert_eq!(c.categories, ["Minsk", "Brest", "Gomel"]);
assert_eq!(c.series.first().map(|s| s.values.clone()), Some(vec![Some(12.0), Some(5.0), Some(8.0)]));
// Unchanged: written back as read.
let kept = String::from_utf8(chartex_xml(&c).unwrap_or_default()).unwrap_or_default();
assert!(kept.contains("ZGVja2NyYWZ0LXRlc3Q=") && kept.contains("cultureRegion=\"BY\""), "{kept}");
// Edited: rebuilt from the model, with the geography carried over.
let mut e = c.clone();
if let Some(s) = e.series.first_mut() {
s.values[1] = Some(20.0);
}
let built = String::from_utf8(chartex_xml(&e).unwrap_or_default()).unwrap_or_default();
assert!(built.contains("<cx:geoCache") && built.contains("ZGVja2NyYWZ0LXRlc3Q=") && built.contains("colorVal"), "{built}");
assert!(!built.contains("<cx:axis"), "{built}");
let back = crate::read::read_chartex_xml(built.as_bytes()).expect("the rebuilt part reads");
assert_eq!(back.kind, ChartType::RegionMap);
assert_eq!(back.series.first().and_then(|s| s.values.get(1).copied().flatten()), Some(20.0));
}
}
#[cfg(test)]
mod tests {
use deckcraft_model::chart::{Chart, ChartType};
#[test]
fn reversed_category_axis_round_trips() {
let mut c = Chart::sample(ChartType::Bar);
c.cat_reversed = true;
let xml = super::chart_xml(&c);
assert!(String::from_utf8_lossy(&xml).contains(r#"<c:orientation val="maxMin"/>"#));
assert!(crate::read::chart::read_chart_xml(&xml).unwrap().cat_reversed);
c.cat_reversed = false;
assert!(!crate::read::chart::read_chart_xml(&super::chart_xml(&c)).unwrap().cat_reversed);
}
}
+14 -1
View File
@@ -1,6 +1,6 @@
//! PPTX export: [`Presentation`] → package.
mod chart;
pub(crate) mod chart;
pub(crate) mod dml;
mod shapes;
mod theme;
@@ -35,6 +35,8 @@ const CT_VIEW_PROPS: &str = "application/vnd.openxmlformats-officedocument.prese
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_CHARTEX: &str = "application/vnd.ms-office.chartex+xml";
const RT_CHARTEX: &str = "http://schemas.microsoft.com/office/2014/relationships/chartEx";
const CT_COMMENTS: &str = "application/vnd.openxmlformats-officedocument.presentationml.comments+xml";
const CT_AUTHORS: &str = "application/vnd.openxmlformats-officedocument.presentationml.commentAuthors+xml";
/// Modern threaded comments (read by PowerPoint 2021+ and DeckCraft; the flattened legacy
@@ -56,6 +58,7 @@ pub struct Exp<'a> {
n_chart: u32,
n_diagram: u32,
n_tags: u32,
n_chartex: u32,
pub slide_parts: HashMap<SlideId, String>,
pub table_styles: Vec<String>,
placeholder: Option<String>,
@@ -190,6 +193,15 @@ impl Exp<'_> {
self.pkg.xml_part(&name, CT_TAGS, w.finish());
o.rels.add_new("tags", &opc::relative(&o.name, &name), false)
}
/// An unchanged Office 2016 chart's `chartEx` part and its relationship id.
pub fn chartex_rel(&mut self, o: &mut Out, c: &deckcraft_model::Chart) -> Option<String> {
let xml = chart::chartex_xml(c)?;
self.n_chartex += 1;
let name = format!("ppt/charts/chartEx{}.xml", self.n_chartex);
self.pkg.xml_part(&name, CT_CHARTEX, xml);
Some(o.rels.add_new(RT_CHARTEX, &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);
@@ -509,6 +521,7 @@ pub fn export(p: &Presentation, main_ct: &str) -> Result<Vec<u8>, PptxError> {
n_chart: 0,
n_diagram: 0,
n_tags: 0,
n_chartex: 0,
slide_parts: HashMap::new(),
table_styles: vec![],
placeholder: None,
+21 -1
View File
@@ -385,12 +385,32 @@ pub fn shape(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, depth
}
ShapeKind::Chart(c) => {
let id = cx.id_for(s);
let rid = x.chart_rel(o, c);
// An unchanged Office 2016 chart goes back as itself, with a plain chart of the same
// data for readers without `cx1`, as PowerPoint writes it.
let ex = x.chartex_rel(o, c);
if let Some(exid) = &ex {
w.open("mc:AlternateContent", A::new().a("xmlns:mc", "http://schemas.openxmlformats.org/markup-compatibility/2006"));
w.open("mc:Choice", A::new().a("xmlns:cx1", "http://schemas.microsoft.com/office/drawing/2015/9/8/chartex").a("Requires", "cx1"));
frame_open(w, x, o, id, s);
w.open("a:graphicData", A::new().a("uri", super::chart::NS_CX));
w.empty("cx:chart", A::new().a("xmlns:cx", super::chart::NS_CX).a("r:id", exid));
w.close("a:graphicData");
frame_close(w);
w.close("mc:Choice");
w.open0("mc:Fallback");
}
let mut plain = (**c).clone();
plain.raw = plain.raw.filter(|r| !r.contains(super::chart::NS_CX));
let rid = x.chart_rel(o, &plain);
frame_open(w, x, o, id, s);
w.open("a:graphicData", A::new().a("uri", "http://schemas.openxmlformats.org/drawingml/2006/chart"));
w.empty("c:chart", A::new().a("xmlns:c", crate::opc::NS_C).a("r:id", rid));
w.close("a:graphicData");
frame_close(w);
if ex.is_some() {
w.close("mc:Fallback");
w.close("mc:AlternateContent");
}
}
ShapeKind::Media(m) => {
let id = cx.id_for(s);