mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-03 00:41:28 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user