Files
craft-libs/crates/docx/src/chart_spec.rs
T
Dylan M. Taylor e3e31693c7 Add craft-docx: WordCraft's DOCX reader and writer on craft-wordmodel, craft-opc and craft-xml (#1015)
* Add craft-wordmodel: WordCraft's document model without its editing code

WordCraft's crates/doc (wordcraft-doc, at 07f97737) moved as it is, so the DOCX,
RTF, ODT and other text-format crates can read into and write from one model.
Module paths, type names and serde attributes are unchanged: an app that
re-exports the crate keeps its native file format.

Left in the app: range editing (edit.rs), IME text and equation editing. Not
model, and already in craft-libs: code pages (craft-rtf), the ZIP entry-count
check (craft-opc), the compound-file guard (craft-cfb). The paragraph walks
moved to walk.rs and the unit helpers of wordcraft-geom to geom.rs.

* craft-wordmodel: math_tree, the arguments of an equation node and a walk over its runs

The DOCX reader and writer walk an equation's runs. The walk and the child
accessors it uses were in WordCraft's equation editor (math_edit.rs), which
stays in the app; they are structure, not editing.

* craft-wordmodel: keep the document operations (edit.rs) with the model

The DOCX and RTF readers use them: alternative-format chunks are merged into
a document with adopt_blocks, and a reader formats ranges with format_range
and inserts objects. They operate on the document, not on a view, so they
belong beside it. The tests that drove them come back with them; the
paragraph walks stay in walk.rs and edit re-exports them.

base64 0.23, as WordCraft and craft-offcrypto use.

* craft-docx: WordCraft's crates/docx as it is at wordcraft 07f97737

The source, unchanged, so the next commit shows only what the move changes.
This commit does not build on its own (it still names wordcraft-doc and
wordcraft-geom, and is not a workspace member yet).

* craft-docx: on craft-wordmodel, craft-opc, craft-xml and craft-offcrypto

- The package (zip reading, part names, relationships, content types,
  zip writing) is craft-opc's; the module keeps WordCraft's bookkeeping
  (passthrough keys, the embedded-part manifest) and its own Rel/Rels shape.
- Parts are parsed with craft-xml's canonical names (the Strict drawing
  names and mc:Choice/@Requires through its hooks), then given the tree
  shape the reader was written against.
- Encryption and Restrict Editing hashes are craft-offcrypto's; this crate
  maps its errors and the w:documentProtection attributes.
- wordcraft-doc is craft-wordmodel; wordcraft-geom's helpers are its geom.

Tests that covered what now lives in craft-opc and craft-offcrypto are left to
those crates; the DIFAT regression is craft-cfb's. Test-only zip use follows
zip 9's fallible names. The attribute-cap test times the read only.
2026-11-11 20:31:40 -05:00

551 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Charts WordCraft makes ([`ChartSpec`]) as chart parts (ECMA-376 Part 1 §21.2) and back.
//!
//! [`chart_xml`] writes a `c:chartSpace` with the data as literals in the part itself
//! (`c:strLit`, `c:numLit`): no embedded workbook (`c:externalData`) and no external references.
//! [`chart_items`] draws a chart through the same code that draws chart parts read from files.
//! [`spec_of`] turns a chart part back into the model only when writing that model again gives
//! the same markup, so a chart with anything the model leaves out stays as it was read.
use craft_wordmodel::chart::{ChartSeries, ChartSpec, ChartType, LegendPos, MAX_POINTS, MAX_SERIES};
use craft_wordmodel::graphic::GraphicItem;
use craft_wordmodel::props::Rgb;
use crate::xml::{self, El, MAX_DEPTH, Node, W};
const NS_C: &str = "http://schemas.openxmlformats.org/drawingml/2006/chart";
const NS_A: &str = "http://schemas.openxmlformats.org/drawingml/2006/main";
const NS_R: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
/// Content type of a chart part.
pub(crate) const CHART_CT: &str = "application/vnd.openxmlformats-officedocument.drawingml.chart+xml";
/// `a:graphicData` URI of a chart.
pub(crate) const CHART_URI: &str = "http://schemas.openxmlformats.org/drawingml/2006/chart";
/// Axis ids (any pair of distinct numbers will do; Word keeps them).
const CAT_AX: &str = "500000001";
const VAL_AX: &str = "500000002";
/// The chart part for `spec`: a complete XML document, written as [`ChartSpec::sanitize`] leaves
/// it (a value that isn't finite is a gap).
pub fn chart_xml(spec: &ChartSpec) -> String {
chart_part(spec).0
}
/// [`chart_xml`], and whether the chart limits left series or points of `spec` out of it.
pub(crate) fn chart_part(spec: &ChartSpec) -> (String, bool) {
let kept = bounded(spec);
let points = spec.series.iter().map(|s| s.values.len()).max().unwrap_or(0).max(spec.categories.len());
let cut = kept.series.len() < spec.series.len() || kept.points() < points;
(write_chart(&kept), cut)
}
fn write_chart(spec: &ChartSpec) -> String {
let mut w = W::new();
w.open("c:chartSpace", &[("xmlns:c", NS_C), ("xmlns:a", NS_A), ("xmlns:r", NS_R)]);
w.empty("c:roundedCorners", &[("val", "0")]);
w.open("c:chart", &[]);
if let Some(t) = &spec.title {
w.open("c:title", &[]);
w.open("c:tx", &[]);
w.open("c:rich", &[]);
w.empty("a:bodyPr", &[]);
w.empty("a:lstStyle", &[]);
w.open("a:p", &[]);
w.open("a:r", &[]);
w.leaf("a:t", &[], t);
w.close("a:r");
w.close("a:p");
w.close("c:rich");
w.close("c:tx");
w.empty("c:overlay", &[("val", "0")]);
w.close("c:title");
}
w.empty("c:autoTitleDeleted", &[("val", if spec.title.is_some() { "0" } else { "1" })]);
w.open("c:plotArea", &[]);
w.empty("c:layout", &[]);
plot(&mut w, spec);
w.close("c:plotArea");
if let Some(pos) = spec.legend {
w.open("c:legend", &[]);
let p = match pos {
LegendPos::Right => "r",
LegendPos::Left => "l",
LegendPos::Top => "t",
LegendPos::Bottom => "b",
};
w.empty("c:legendPos", &[("val", p)]);
w.empty("c:overlay", &[("val", "0")]);
w.close("c:legend");
}
w.empty("c:plotVisOnly", &[("val", "1")]);
w.empty("c:dispBlanksAs", &[("val", "gap")]);
w.close("c:chart");
// A white chart area without a border, in the document's own background colour.
w.open("c:spPr", &[]);
w.open("a:solidFill", &[]);
w.empty("a:schemeClr", &[("val", "bg1")]);
w.close("a:solidFill");
w.open("a:ln", &[]);
w.empty("a:noFill", &[]);
w.close("a:ln");
w.close("c:spPr");
w.close("c:chartSpace");
w.s
}
/// `spec` held to the chart limits. A model deserialised as is (a `.json` document) never went
/// through [`ChartSpec::sanitize`], and every series repeats every category, so its size would
/// multiply (#3648). Only what the limits keep is copied before sanitizing.
fn bounded(spec: &ChartSpec) -> ChartSpec {
ChartSpec {
kind: spec.kind,
categories: spec.categories.iter().take(MAX_POINTS).cloned().collect(),
series: spec
.series
.iter()
.take(MAX_SERIES)
.map(|s| ChartSeries { name: s.name.clone(), values: s.values.iter().take(MAX_POINTS).copied().collect() })
.collect(),
title: spec.title.clone(),
legend: spec.legend,
data_labels: spec.data_labels,
}
.sanitized()
}
/// What `spec` draws as, `w` × `h` points, in the colours of `theme` (series take the accents in
/// turn). Empty when the chart has no series.
pub fn chart_items(spec: &ChartSpec, theme: &[Rgb], w: f32, h: f32) -> Vec<GraphicItem> {
match xml::parse(chart_xml(spec).as_bytes()) {
Ok(space) => crate::read::chart::chart_items(&space, theme, w, h),
Err(_) => Vec::new(),
}
}
/// The chart type element and the series in it, then the axes.
fn plot(w: &mut W, spec: &ChartSpec) {
let k = spec.kind;
let el = match k {
ChartType::Column | ChartType::ColumnStacked | ChartType::Bar | ChartType::BarStacked => "c:barChart",
ChartType::Line => "c:lineChart",
ChartType::Area => "c:areaChart",
ChartType::Pie => "c:pieChart",
ChartType::Doughnut => "c:doughnutChart",
ChartType::Scatter => "c:scatterChart",
};
let stacked = matches!(k, ChartType::ColumnStacked | ChartType::BarStacked);
let horizontal = matches!(k, ChartType::Bar | ChartType::BarStacked);
w.open(el, &[]);
match k {
ChartType::Column | ChartType::ColumnStacked | ChartType::Bar | ChartType::BarStacked => {
w.empty("c:barDir", &[("val", if horizontal { "bar" } else { "col" })]);
w.empty("c:grouping", &[("val", if stacked { "stacked" } else { "clustered" })]);
}
ChartType::Line | ChartType::Area => w.empty("c:grouping", &[("val", "standard")]),
ChartType::Scatter => w.empty("c:scatterStyle", &[("val", "lineMarker")]),
ChartType::Pie | ChartType::Doughnut => {}
}
w.empty("c:varyColors", &[("val", if k.is_round() { "1" } else { "0" })]);
// Pie and doughnut charts show the first series; the others are kept for other types.
for (i, s) in spec.series.iter().enumerate() {
series(w, spec, i, s);
}
if spec.data_labels {
w.open("c:dLbls", &[]);
for (name, on) in [
("c:showLegendKey", false),
("c:showVal", true),
("c:showCatName", false),
("c:showSerName", false),
("c:showPercent", false),
("c:showBubbleSize", false),
] {
w.empty(name, &[("val", if on { "1" } else { "0" })]);
}
w.close("c:dLbls");
}
match k {
ChartType::Column | ChartType::ColumnStacked | ChartType::Bar | ChartType::BarStacked => {
w.empty("c:gapWidth", &[("val", "150")]);
if stacked {
w.empty("c:overlap", &[("val", "100")]);
}
}
ChartType::Line => w.empty("c:marker", &[("val", "1")]),
ChartType::Pie => w.empty("c:firstSliceAng", &[("val", "0")]),
ChartType::Doughnut => {
w.empty("c:firstSliceAng", &[("val", "0")]);
w.empty("c:holeSize", &[("val", "50")]);
}
ChartType::Area | ChartType::Scatter => {}
}
if !k.is_round() {
w.empty("c:axId", &[("val", CAT_AX)]);
w.empty("c:axId", &[("val", VAL_AX)]);
}
w.close(el);
match k {
ChartType::Pie | ChartType::Doughnut => {}
ChartType::Scatter => {
value_axis(w, CAT_AX, VAL_AX, "b", false, "midCat");
value_axis(w, VAL_AX, CAT_AX, "l", true, "midCat");
}
_ => {
category_axis(w, if horizontal { "l" } else { "b" });
value_axis(w, VAL_AX, CAT_AX, if horizontal { "b" } else { "l" }, true, "between");
}
}
}
/// One `c:ser` (ECMA-376 element order: idx, order, tx, spPr, marker, cat/xVal, val/yVal, smooth).
fn series(w: &mut W, spec: &ChartSpec, i: usize, s: &ChartSeries) {
let k = spec.kind;
let i = i.to_string();
w.open("c:ser", &[]);
w.empty("c:idx", &[("val", &i)]);
w.empty("c:order", &[("val", &i)]);
if !s.name.is_empty() {
w.open("c:tx", &[]);
w.leaf("c:v", &[], &s.name);
w.close("c:tx");
}
match k {
// Markers only: the series has no line.
ChartType::Scatter => {
w.open("c:spPr", &[]);
w.open("a:ln", &[("w", "19050")]);
w.empty("a:noFill", &[]);
w.close("a:ln");
w.close("c:spPr");
}
ChartType::Column | ChartType::ColumnStacked | ChartType::Bar | ChartType::BarStacked => {
w.empty("c:invertIfNegative", &[("val", "0")]);
}
ChartType::Line => {
w.open("c:marker", &[]);
w.empty("c:symbol", &[("val", "none")]);
w.close("c:marker");
}
_ => {}
}
if k == ChartType::Scatter {
let xs: Option<Vec<Option<f64>>> = spec
.categories
.iter()
.map(|c| if c.is_empty() { Some(None) } else { c.parse::<f64>().ok().filter(|v| v.is_finite()).map(Some) })
.collect();
w.open("c:xVal", &[]);
match xs {
Some(xs) => num_lit(w, &xs),
None => str_lit(w, &spec.categories),
}
w.close("c:xVal");
w.open("c:yVal", &[]);
num_lit(w, &s.values);
w.close("c:yVal");
} else {
w.open("c:cat", &[]);
str_lit(w, &spec.categories);
w.close("c:cat");
w.open("c:val", &[]);
num_lit(w, &s.values);
w.close("c:val");
}
if matches!(k, ChartType::Line | ChartType::Scatter) {
w.empty("c:smooth", &[("val", "0")]);
}
w.close("c:ser");
}
fn str_lit(w: &mut W, items: &[String]) {
w.open("c:strLit", &[]);
w.empty("c:ptCount", &[("val", &items.len().to_string())]);
for (i, s) in items.iter().enumerate().filter(|(_, s)| !s.is_empty()) {
w.open("c:pt", &[("idx", &i.to_string())]);
w.leaf("c:v", &[], s);
w.close("c:pt");
}
w.close("c:strLit");
}
fn num_lit(w: &mut W, values: &[Option<f64>]) {
w.open("c:numLit", &[]);
w.leaf("c:formatCode", &[], "General");
w.empty("c:ptCount", &[("val", &values.len().to_string())]);
for (i, v) in values.iter().enumerate() {
if let Some(v) = v.filter(|v| v.is_finite()) {
w.open("c:pt", &[("idx", &i.to_string())]);
w.leaf("c:v", &[], &num(v));
w.close("c:pt");
}
}
w.close("c:numLit");
}
/// A value as the shortest text that reads back as the same number.
fn num(v: f64) -> String {
if v == 0.0 { "0".to_string() } else { format!("{v}") }
}
fn category_axis(w: &mut W, pos: &str) {
w.open("c:catAx", &[]);
w.empty("c:axId", &[("val", CAT_AX)]);
w.open("c:scaling", &[]);
w.empty("c:orientation", &[("val", "minMax")]);
w.close("c:scaling");
w.empty("c:delete", &[("val", "0")]);
w.empty("c:axPos", &[("val", pos)]);
w.empty("c:majorTickMark", &[("val", "none")]);
w.empty("c:minorTickMark", &[("val", "none")]);
w.empty("c:tickLblPos", &[("val", "nextTo")]);
w.empty("c:crossAx", &[("val", VAL_AX)]);
w.empty("c:crosses", &[("val", "autoZero")]);
w.empty("c:auto", &[("val", "1")]);
w.empty("c:lblAlgn", &[("val", "ctr")]);
w.empty("c:lblOffset", &[("val", "100")]);
w.empty("c:noMultiLvlLbl", &[("val", "0")]);
w.close("c:catAx");
}
/// A value axis: with gridlines and no axis line (`grid`), else with its line and no gridlines.
fn value_axis(w: &mut W, id: &str, cross: &str, pos: &str, grid: bool, between: &str) {
w.open("c:valAx", &[]);
w.empty("c:axId", &[("val", id)]);
w.open("c:scaling", &[]);
w.empty("c:orientation", &[("val", "minMax")]);
w.close("c:scaling");
w.empty("c:delete", &[("val", "0")]);
w.empty("c:axPos", &[("val", pos)]);
if grid {
w.empty("c:majorGridlines", &[]);
}
w.empty("c:majorTickMark", &[("val", "none")]);
w.empty("c:minorTickMark", &[("val", "none")]);
w.empty("c:tickLblPos", &[("val", "nextTo")]);
if grid {
w.open("c:spPr", &[]);
w.open("a:ln", &[]);
w.empty("a:noFill", &[]);
w.close("a:ln");
w.close("c:spPr");
}
w.empty("c:crossAx", &[("val", cross)]);
w.empty("c:crosses", &[("val", "autoZero")]);
w.empty("c:crossBetween", &[("val", between)]);
w.close("c:valAx");
}
/// The chart a chart part holds, when the part is exactly what [`chart_xml`] writes for it (so
/// editing and saving it loses nothing). `None` for any other chart.
pub(crate) fn spec_of(space: &El) -> Option<ChartSpec> {
let chart = space.child("c:chart")?;
let plot = chart.child("c:plotArea")?;
let ty = plot.els().find(|e| e.name.ends_with("Chart"))?;
let val = |e: &El, n: &str| e.child(n).and_then(|c| c.attr("val")).map(str::to_string);
let bar = |stacked_kind, clustered_kind| if val(ty, "c:grouping").as_deref() == Some("stacked") { stacked_kind } else { clustered_kind };
let kind = match ty.name.as_str() {
"c:barChart" if val(ty, "c:barDir").as_deref() == Some("bar") => bar(ChartType::BarStacked, ChartType::Bar),
"c:barChart" => bar(ChartType::ColumnStacked, ChartType::Column),
"c:lineChart" => ChartType::Line,
"c:areaChart" => ChartType::Area,
"c:pieChart" => ChartType::Pie,
"c:doughnutChart" => ChartType::Doughnut,
"c:scatterChart" => ChartType::Scatter,
_ => return None,
};
let scatter = kind == ChartType::Scatter;
let sers: Vec<&El> = ty.children("c:ser").take(craft_wordmodel::chart::MAX_SERIES + 1).collect();
// More than the model holds can't be the same chart: don't build it to find out.
let count = |s: &&El| s.find("c:ptCount").and_then(|c| c.attr("val")).and_then(|v| v.parse::<usize>().ok()).unwrap_or(0);
let longest = sers.iter().map(count).max().unwrap_or(0);
if sers.len() > craft_wordmodel::chart::MAX_SERIES
|| longest > craft_wordmodel::chart::MAX_POINTS
|| sers.len().saturating_mul(longest) > craft_wordmodel::chart::MAX_CELLS
{
return None;
}
let categories = sers.first().and_then(|s| s.child(if scatter { "c:xVal" } else { "c:cat" })).map(literal).unwrap_or_default();
let series = sers
.iter()
.map(|s| ChartSeries {
name: s.child("c:tx").and_then(|t| t.child("c:v")).map(|v| v.text()).unwrap_or_default(),
values: s
.child(if scatter { "c:yVal" } else { "c:val" })
.map(literal)
.unwrap_or_default()
.iter()
.map(|v| v.parse::<f64>().ok().filter(|v| v.is_finite()))
.collect(),
})
.collect();
let title = chart.child("c:title").and_then(|t| t.find("a:t")).map(|t| t.text());
let legend = chart.child("c:legend").map(|l| match val(l, "c:legendPos").as_deref() {
Some("l") => LegendPos::Left,
Some("t") => LegendPos::Top,
Some("b") => LegendPos::Bottom,
_ => LegendPos::Right,
});
let data_labels = ty.child("c:dLbls").and_then(|d| d.child("c:showVal")).and_then(|v| v.attr("val")) == Some("1");
let spec = ChartSpec { kind, categories, series, title, legend, data_labels }.sanitized();
// Editable only when nothing would be lost: writing the model again gives the same part.
let again = xml::parse(chart_xml(&spec).as_bytes()).ok()?;
same(space, &again, 0).then_some(spec)
}
/// The points of a `c:strLit` / `c:numLit` (or a cache) as texts by index, `ptCount` long.
fn literal(e: &El) -> Vec<String> {
let Some(lit) = e.els().find(|c| matches!(c.name.as_str(), "c:strLit" | "c:numLit")) else { return Vec::new() };
let n = lit
.child("c:ptCount")
.and_then(|c| c.attr("val"))
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(0)
.min(craft_wordmodel::chart::MAX_POINTS + 1);
let mut out = vec![String::new(); n];
for p in lit.children("c:pt") {
if let Some(slot) = p.attr("idx").and_then(|i| i.parse::<usize>().ok()).and_then(|i| out.get_mut(i)) {
*slot = p.child("c:v").map(|v| v.text()).unwrap_or_default();
}
}
out
}
/// Whether two elements are the same markup: names, attributes (in any order), child elements in
/// order and text (whitespace-only text between elements ignored).
fn same(a: &El, b: &El, depth: usize) -> bool {
if depth > MAX_DEPTH || a.name != b.name || a.attrs.len() != b.attrs.len() {
return false;
}
if !a.attrs.iter().all(|(k, v)| b.attr(k) == Some(v.as_str()) && b.attrs.iter().any(|(bk, _)| bk == k)) {
return false;
}
let text = |e: &El| -> String {
e.kids
.iter()
.filter_map(|n| match n {
Node::Text(t) if !t.trim().is_empty() => Some(t.as_str()),
_ => None,
})
.collect()
};
if text(a) != text(b) {
return false;
}
let (mut x, mut y) = (a.els(), b.els());
loop {
match (x.next(), y.next()) {
(None, None) => return true,
(Some(p), Some(q)) if same(p, q, depth + 1) => {}
_ => return false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use craft_wordmodel::THEME_COLORS;
fn parse(spec: &ChartSpec) -> El {
xml::parse(chart_xml(spec).as_bytes()).unwrap()
}
/// Children of `e` by name, in order.
fn names(e: &El) -> Vec<&str> {
e.els().map(|c| c.name.as_str()).collect()
}
#[test]
fn a_chart_past_the_limits_is_written_within_them() {
// A model deserialised as is (a `.json` document) never went through `sanitize`, and every
// series repeats every category: 300 series of 4,500 points wrote 2.7 million points (#3648).
use craft_wordmodel::chart::MAX_CELLS;
let spec = ChartSpec {
kind: ChartType::Column,
categories: (0..4500).map(|i| i.to_string()).collect(),
series: (0..300).map(|i| ChartSeries { name: format!("S{i}"), values: vec![Some(1.0); 4500] }).collect(),
title: None,
legend: None,
data_labels: false,
};
let x = chart_xml(&spec);
let series = x.matches("<c:ser>").count();
let points = x.matches("<c:pt ").count();
assert!((1..=MAX_SERIES).contains(&series), "{series} series");
assert!(points <= 2 * MAX_CELLS, "{points} points");
assert!(!x.contains(&format!("<c:pt idx=\"{MAX_POINTS}\"")), "past the point limit");
}
#[test]
fn every_type_reads_back_as_itself() {
for kind in ChartType::ALL {
let mut spec = ChartSpec::sample(kind);
spec.title = Some("Sales & <costs>".into());
spec.data_labels = true;
spec.legend = Some(LegendPos::Right);
spec.series[0].values[1] = None;
spec.categories[2] = String::new();
let spec = spec.sanitized();
assert_eq!(spec_of(&parse(&spec)).as_ref(), Some(&spec), "{kind:?}");
assert!(!chart_items(&spec, &THEME_COLORS, 300.0, 200.0).is_empty(), "{kind:?} draws");
}
let plain = ChartSpec { legend: None, ..ChartSpec::sample(ChartType::Line) };
assert_eq!(spec_of(&parse(&plain)), Some(plain));
}
#[test]
fn a_chart_with_more_than_the_model_stays_read_only() {
let spec = ChartSpec::sample(ChartType::Column);
let x = chart_xml(&spec).replace("<c:roundedCorners val=\"0\"/>", "<c:roundedCorners val=\"0\"/><c:style val=\"2\"/>");
assert_eq!(spec_of(&xml::parse(x.as_bytes()).unwrap()), None);
let x = chart_xml(&spec).replace("<c:v>3.2</c:v>", "<c:v>3.20</c:v>");
assert_eq!(spec_of(&xml::parse(x.as_bytes()).unwrap()), None);
}
#[test]
fn elements_come_in_schema_order() {
let mut spec = ChartSpec::sample(ChartType::ColumnStacked);
spec.title = Some("T".into());
spec.data_labels = true;
let space = parse(&spec);
assert_eq!(names(&space), ["c:roundedCorners", "c:chart", "c:spPr"]);
let chart = space.child("c:chart").unwrap();
assert_eq!(names(chart), ["c:title", "c:autoTitleDeleted", "c:plotArea", "c:legend", "c:plotVisOnly", "c:dispBlanksAs"]);
let plot = chart.child("c:plotArea").unwrap();
assert_eq!(names(plot), ["c:layout", "c:barChart", "c:catAx", "c:valAx"]);
let bar = plot.child("c:barChart").unwrap();
assert_eq!(
names(bar),
["c:barDir", "c:grouping", "c:varyColors", "c:ser", "c:ser", "c:ser", "c:dLbls", "c:gapWidth", "c:overlap", "c:axId", "c:axId"]
);
assert_eq!(names(bar.child("c:ser").unwrap()), ["c:idx", "c:order", "c:tx", "c:invertIfNegative", "c:cat", "c:val"]);
let val_ax = plot.child("c:valAx").unwrap();
assert_eq!(
names(val_ax),
[
"c:axId",
"c:scaling",
"c:delete",
"c:axPos",
"c:majorGridlines",
"c:majorTickMark",
"c:minorTickMark",
"c:tickLblPos",
"c:spPr",
"c:crossAx",
"c:crosses",
"c:crossBetween"
]
);
let line = parse(&ChartSpec { data_labels: true, ..ChartSpec::sample(ChartType::Line) });
let lc = line.child("c:chart").unwrap().child("c:plotArea").unwrap().child("c:lineChart").unwrap();
assert_eq!(names(lc), ["c:grouping", "c:varyColors", "c:ser", "c:ser", "c:ser", "c:dLbls", "c:marker", "c:axId", "c:axId"]);
assert_eq!(names(lc.child("c:ser").unwrap()), ["c:idx", "c:order", "c:tx", "c:marker", "c:cat", "c:val", "c:smooth"]);
let sc = parse(&ChartSpec::sample(ChartType::Scatter));
let sc = sc.child("c:chart").unwrap().child("c:plotArea").unwrap().child("c:scatterChart").unwrap();
assert_eq!(names(sc.child("c:ser").unwrap()), ["c:idx", "c:order", "c:tx", "c:spPr", "c:xVal", "c:yVal", "c:smooth"]);
let dn = parse(&ChartSpec::sample(ChartType::Doughnut));
let dn = dn.child("c:chart").unwrap().child("c:plotArea").unwrap();
assert_eq!(names(dn), ["c:layout", "c:doughnutChart"]);
assert_eq!(names(dn.child("c:doughnutChart").unwrap()), ["c:varyColors", "c:ser", "c:firstSliceAng", "c:holeSize"]);
}
}