use crate::oxml::color::Color;
use crate::oxml::sppr::{Fill, Line};
use crate::oxml::writer::XmlWriter;
#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
pub enum ChartType {
Column,
Bar,
Line,
Pie,
Scatter,
Area,
Radar,
Bubble,
}
impl ChartType {
pub fn chart_element(self) -> &'static str {
match self {
ChartType::Column | ChartType::Bar => "c:barChart",
ChartType::Line => "c:lineChart",
ChartType::Pie => "c:pieChart",
ChartType::Scatter => "c:scatterChart",
ChartType::Area => "c:areaChart",
ChartType::Radar => "c:radarChart",
ChartType::Bubble => "c:bubbleChart",
}
}
pub fn bar_dir(self) -> &'static str {
match self {
ChartType::Column => "col",
ChartType::Bar => "bar",
_ => "",
}
}
pub fn is_scatter(self) -> bool {
matches!(self, ChartType::Scatter)
}
pub fn is_xy_chart(self) -> bool {
matches!(self, ChartType::Scatter | ChartType::Bubble)
}
pub fn is_bubble(self) -> bool {
matches!(self, ChartType::Bubble)
}
}
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Hash)]
pub enum LabelPosition {
#[default]
BestFit,
Above,
Below,
Center,
Base,
InsideBase,
InsideEnd,
OutsideEnd,
Left,
Right,
OutsideEndPie,
InsideEndPie,
}
impl LabelPosition {
pub fn as_str(self) -> &'static str {
match self {
LabelPosition::BestFit => "bestFit",
LabelPosition::Above => "t",
LabelPosition::Below => "b",
LabelPosition::Center => "ctr",
LabelPosition::Base => "inBase",
LabelPosition::InsideBase => "inBase",
LabelPosition::InsideEnd => "inEnd",
LabelPosition::OutsideEnd => "outEnd",
LabelPosition::Left => "l",
LabelPosition::Right => "r",
LabelPosition::OutsideEndPie => "outEnd",
LabelPosition::InsideEndPie => "inEnd",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"bestFit" => Some(LabelPosition::BestFit),
"t" => Some(LabelPosition::Above),
"b" => Some(LabelPosition::Below),
"ctr" => Some(LabelPosition::Center),
"inBase" => Some(LabelPosition::Base),
"inEnd" => Some(LabelPosition::InsideEnd),
"outEnd" => Some(LabelPosition::OutsideEnd),
"l" => Some(LabelPosition::Left),
"r" => Some(LabelPosition::Right),
_ => None,
}
}
}
#[derive(Clone, Debug, Default)]
pub struct DataLabels {
pub show_val: Option<bool>,
pub show_cat_name: Option<bool>,
pub show_ser_name: Option<bool>,
pub show_legend_key: Option<bool>,
pub show_percent: Option<bool>,
pub show_bubble_size: Option<bool>,
pub position: Option<LabelPosition>,
pub separator: Option<String>,
pub num_fmt: Option<String>,
}
impl DataLabels {
pub fn show_values() -> Self {
DataLabels {
show_val: Some(true),
..Default::default()
}
}
pub fn show_percent_pie() -> Self {
DataLabels {
show_percent: Some(true),
..Default::default()
}
}
pub fn write_xml(&self, w: &mut XmlWriter) {
w.open("c:dLbls");
if let Some(b) = self.show_legend_key {
w.empty_with("c:showLegendKey", &[("val", if b { "1" } else { "0" })]);
}
if let Some(b) = self.show_val {
w.empty_with("c:showVal", &[("val", if b { "1" } else { "0" })]);
}
if let Some(b) = self.show_cat_name {
w.empty_with("c:showCatName", &[("val", if b { "1" } else { "0" })]);
}
if let Some(b) = self.show_ser_name {
w.empty_with("c:showSerName", &[("val", if b { "1" } else { "0" })]);
}
if let Some(b) = self.show_percent {
w.empty_with("c:showPercent", &[("val", if b { "1" } else { "0" })]);
}
if let Some(b) = self.show_bubble_size {
w.empty_with("c:showBubbleSize", &[("val", if b { "1" } else { "0" })]);
}
if let Some(pos) = self.position {
w.empty_with("c:dLblPos", &[("val", pos.as_str())]);
}
if let Some(sep) = &self.separator {
w.empty_with("c:separator", &[("val", sep.as_str())]);
}
if let Some(fmt) = &self.num_fmt {
w.empty_with(
"c:numFmt",
&[("formatCode", fmt.as_str()), ("sourceLinked", "0")],
);
}
w.close("c:dLbls");
}
pub fn is_empty(&self) -> bool {
self.show_val.is_none()
&& self.show_cat_name.is_none()
&& self.show_ser_name.is_none()
&& self.show_legend_key.is_none()
&& self.show_percent.is_none()
&& self.show_bubble_size.is_none()
&& self.position.is_none()
&& self.separator.is_none()
&& self.num_fmt.is_none()
}
}
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Hash)]
pub enum LegendPosition {
#[default]
Bottom,
Top,
Right,
Left,
}
impl LegendPosition {
pub fn as_str(self) -> &'static str {
match self {
LegendPosition::Bottom => "b",
LegendPosition::Top => "t",
LegendPosition::Right => "r",
LegendPosition::Left => "l",
}
}
}
#[derive(Clone, Debug, Default)]
pub struct Legend {
pub position: LegendPosition,
pub overlay: bool,
}
#[derive(Clone, Debug, Default)]
pub struct Gridlines {
pub major: bool,
pub minor: bool,
}
#[derive(Clone, Debug, Default)]
pub struct AxisTitle {
pub text: String,
}
impl AxisTitle {
pub fn new(text: impl Into<String>) -> Self {
AxisTitle { text: text.into() }
}
}
#[derive(Clone, Debug, Default)]
pub struct ChartData {
pub categories: Vec<ChartCategory>,
pub series: Vec<ChartSeries>,
pub title: Option<String>,
pub data_labels: Option<DataLabels>,
pub legend: Option<Legend>,
pub category_axis_gridlines: Gridlines,
pub value_axis_gridlines: Gridlines,
pub category_axis_title: Option<AxisTitle>,
pub value_axis_title: Option<AxisTitle>,
pub secondary_chart_type: Option<ChartType>,
}
#[derive(Clone, Debug, Default)]
pub struct ChartCategory {
pub name: String,
}
impl ChartCategory {
pub fn new(name: impl Into<String>) -> Self {
ChartCategory { name: name.into() }
}
}
#[derive(Clone, Debug, Default)]
pub struct SeriesFormat {
pub fill: Fill,
pub line: Option<Line>,
}
impl SeriesFormat {
pub fn solid_fill(color: Color) -> Self {
SeriesFormat {
fill: Fill::Solid(color),
line: None,
}
}
pub fn write_xml(&self, w: &mut XmlWriter) -> bool {
let has_fill = !matches!(self.fill, Fill::Inherit);
let has_line = self.line.is_some();
if !has_fill && !has_line {
return false;
}
w.open("c:spPr");
self.fill.write_xml(w);
if let Some(ln) = &self.line {
ln.write_xml(w);
}
w.close("c:spPr");
true
}
}
#[derive(Clone, Debug, Default)]
pub struct ChartSeries {
pub name: String,
pub values: Vec<f64>,
pub x_values: Option<Vec<f64>>,
pub bubble_sizes: Option<Vec<f64>>,
pub data_labels: Option<DataLabels>,
pub secondary_axis: bool,
pub format: Option<SeriesFormat>,
}
impl ChartSeries {
pub fn new(name: impl Into<String>, values: Vec<f64>) -> Self {
ChartSeries {
name: name.into(),
values,
x_values: None,
bubble_sizes: None,
data_labels: None,
secondary_axis: false,
format: None,
}
}
pub fn new_scatter(name: impl Into<String>, x_values: Vec<f64>, y_values: Vec<f64>) -> Self {
ChartSeries {
name: name.into(),
values: y_values,
x_values: Some(x_values),
bubble_sizes: None,
data_labels: None,
secondary_axis: false,
format: None,
}
}
pub fn new_bubble(
name: impl Into<String>,
x_values: Vec<f64>,
y_values: Vec<f64>,
bubble_sizes: Vec<f64>,
) -> Self {
ChartSeries {
name: name.into(),
values: y_values,
x_values: Some(x_values),
bubble_sizes: Some(bubble_sizes),
data_labels: None,
secondary_axis: false,
format: None,
}
}
pub fn with_format(mut self, format: SeriesFormat) -> Self {
self.format = Some(format);
self
}
}
#[derive(Clone, Debug)]
pub struct Chart {
pub chart_type: ChartType,
pub data: ChartData,
pub rid: String,
pub external_data_rid: Option<String>,
}
impl Chart {
pub fn new(chart_type: ChartType, data: ChartData) -> Self {
Chart {
chart_type,
data,
rid: String::new(),
external_data_rid: None,
}
}
pub fn parse_from_xml(xml: &str) -> crate::Result<Chart> {
parse_from_xml(xml)
}
}
impl Default for Chart {
fn default() -> Self {
Chart {
chart_type: ChartType::Column,
data: ChartData::default(),
rid: String::new(),
external_data_rid: None,
}
}
}
impl Chart {
pub fn to_xml(&self) -> String {
let mut w = XmlWriter::new();
w.raw("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n");
w.open_with(
"c:chartSpace",
&[
(
"xmlns:c",
"http://schemas.openxmlformats.org/drawingml/2006/chart",
),
(
"xmlns:a",
"http://schemas.openxmlformats.org/drawingml/2006/main",
),
(
"xmlns:r",
"http://schemas.openxmlformats.org/officeDocument/2006/relationships",
),
],
);
w.open("c:chart");
if let Some(title) = &self.data.title {
w.open("c:title");
w.open("a:tx");
w.open("a:rich");
w.open("a:bodyPr");
w.close("a:bodyPr");
w.open("a:lstStyle");
w.close("a:lstStyle");
w.open("a:p");
w.open("a:pPr");
w.empty_with("a:defRPr", &[("sz", "1400"), ("b", "1")]);
w.close("a:pPr");
w.leaf("a:t", title.as_str());
w.close("a:p");
w.close("a:rich");
w.close("a:tx");
w.empty_with("c:overlay", &[("val", "0")]);
w.close("c:title");
}
w.open("c:plotArea");
w.open("c:layout");
w.close("c:layout");
let chart_elem = self.chart_type.chart_element();
w.open(chart_elem);
if matches!(self.chart_type, ChartType::Column | ChartType::Bar) {
w.empty_with("c:barDir", &[("val", self.chart_type.bar_dir())]);
w.empty_with("c:grouping", &[("val", "clustered")]);
}
if matches!(self.chart_type, ChartType::Area) {
w.empty_with("c:grouping", &[("val", "standard")]);
}
if matches!(self.chart_type, ChartType::Scatter) {
w.empty_with("c:scatterStyle", &[("val", "lineMarker")]);
}
if matches!(self.chart_type, ChartType::Radar) {
w.empty_with("c:radarStyle", &[("val", "marker")]);
}
if matches!(self.chart_type, ChartType::Bubble) {
w.empty_with("c:bubbleScale", &[("val", "100")]);
}
if matches!(self.chart_type, ChartType::Pie) {
w.empty_with("c:varyColors", &[("val", "1")]);
}
if let Some(dl) = &self.data.data_labels {
if !dl.is_empty() {
dl.write_xml(&mut w);
}
}
let has_combo = self.data.secondary_chart_type.is_some();
for (idx, s) in self.data.series.iter().enumerate() {
if has_combo && s.secondary_axis {
continue;
}
write_series(&mut w, self.chart_type, s, idx, &self.data.categories);
}
let has_secondary = !self.chart_type.is_xy_chart()
&& !matches!(self.chart_type, ChartType::Pie)
&& self.data.series.iter().any(|s| s.secondary_axis);
if matches!(self.chart_type, ChartType::Pie) {
} else if self.chart_type.is_xy_chart() {
w.empty_with("c:axId", &[("val", "111111111")]);
w.empty_with("c:axId", &[("val", "222222222")]);
} else {
w.empty_with("c:axId", &[("val", "111111111")]);
w.empty_with("c:axId", &[("val", "222222222")]);
if has_secondary {
w.empty_with("c:axId", &[("val", "444444444")]);
}
}
w.close(chart_elem);
if let Some(sec_ct) = self.data.secondary_chart_type {
if self.data.series.iter().any(|s| s.secondary_axis) {
let sec_elem = sec_ct.chart_element();
w.open(sec_elem);
if matches!(sec_ct, ChartType::Column | ChartType::Bar) {
w.empty_with("c:barDir", &[("val", sec_ct.bar_dir())]);
w.empty_with("c:grouping", &[("val", "clustered")]);
}
if matches!(sec_ct, ChartType::Area) {
w.empty_with("c:grouping", &[("val", "standard")]);
}
if matches!(sec_ct, ChartType::Scatter) {
w.empty_with("c:scatterStyle", &[("val", "lineMarker")]);
}
if matches!(sec_ct, ChartType::Radar) {
w.empty_with("c:radarStyle", &[("val", "marker")]);
}
if matches!(sec_ct, ChartType::Bubble) {
w.empty_with("c:bubbleScale", &[("val", "100")]);
}
if let Some(dl) = &self.data.data_labels {
if !dl.is_empty() {
dl.write_xml(&mut w);
}
}
for (idx, s) in self.data.series.iter().enumerate() {
if s.secondary_axis {
write_series(&mut w, sec_ct, s, idx, &self.data.categories);
}
}
w.empty_with("c:axId", &[("val", "111111111")]);
w.empty_with("c:axId", &[("val", "444444444")]);
w.close(sec_elem);
}
}
if !matches!(self.chart_type, ChartType::Pie) {
if self.chart_type.is_xy_chart() {
w.open("c:valAx");
w.empty_with("c:axId", &[("val", "111111111")]);
w.empty_with("c:scaling", &[("orientation", "minMax")]);
w.empty_with("c:delete", &[("val", "0")]);
w.empty_with("c:axPos", &[("val", "b")]);
write_gridlines(&mut w, &self.data.category_axis_gridlines);
if let Some(t) = &self.data.category_axis_title {
write_axis_title(&mut w, t.text.as_str());
}
w.empty_with("c:crossAx", &[("val", "222222222")]);
w.close("c:valAx");
w.open("c:valAx");
w.empty_with("c:axId", &[("val", "222222222")]);
w.empty_with("c:scaling", &[("orientation", "minMax")]);
w.empty_with("c:delete", &[("val", "0")]);
w.empty_with("c:axPos", &[("val", "l")]);
write_gridlines(&mut w, &self.data.value_axis_gridlines);
if let Some(t) = &self.data.value_axis_title {
write_axis_title(&mut w, t.text.as_str());
}
w.empty_with("c:crossAx", &[("val", "111111111")]);
w.close("c:valAx");
} else {
w.open("c:catAx");
w.empty_with("c:axId", &[("val", "111111111")]);
w.empty_with("c:scaling", &[("orientation", "minMax")]);
w.empty_with("c:delete", &[("val", "0")]);
w.empty_with("c:axPos", &[("val", "b")]);
write_gridlines(&mut w, &self.data.category_axis_gridlines);
if let Some(t) = &self.data.category_axis_title {
write_axis_title(&mut w, t.text.as_str());
}
w.empty_with("c:crossAx", &[("val", "222222222")]);
w.close("c:catAx");
w.open("c:valAx");
w.empty_with("c:axId", &[("val", "222222222")]);
w.empty_with("c:scaling", &[("orientation", "minMax")]);
w.empty_with("c:delete", &[("val", "0")]);
w.empty_with("c:axPos", &[("val", "l")]);
write_gridlines(&mut w, &self.data.value_axis_gridlines);
if let Some(t) = &self.data.value_axis_title {
write_axis_title(&mut w, t.text.as_str());
}
w.empty_with("c:crossAx", &[("val", "111111111")]);
w.close("c:valAx");
if has_secondary {
w.open("c:valAx");
w.empty_with("c:axId", &[("val", "444444444")]);
w.empty_with("c:scaling", &[("orientation", "minMax")]);
w.empty_with("c:delete", &[("val", "0")]);
w.empty_with("c:axPos", &[("val", "r")]);
write_gridlines(&mut w, &self.data.value_axis_gridlines);
w.empty_with("c:crossAx", &[("val", "111111111")]);
w.empty_with("c:crosses", &[("val", "max")]);
w.close("c:valAx");
}
}
}
w.close("c:plotArea");
if let Some(legend) = &self.data.legend {
w.open("c:legend");
w.empty_with("c:legendPos", &[("val", legend.position.as_str())]);
let overlay_val = if legend.overlay { "1" } else { "0" };
w.empty_with("c:overlay", &[("val", overlay_val)]);
w.close("c:legend");
}
w.empty_with("c:plotVisOnly", &[("val", "1")]);
w.empty_with("c:dispBlanksAs", &[("val", "gap")]);
w.close("c:chart");
if let Some(ext_rid) = &self.external_data_rid {
w.open_with("c:externalData", &[("r:id", ext_rid.as_str())]);
w.empty_with("c:autoUpdate", &[("val", "0")]);
w.close("c:externalData");
}
w.close("c:chartSpace");
w.into_string()
}
}
fn write_gridlines(w: &mut XmlWriter, gridlines: &Gridlines) {
if gridlines.major {
w.open("c:majorGridlines");
w.close("c:majorGridlines");
}
if gridlines.minor {
w.open("c:minorGridlines");
w.close("c:minorGridlines");
}
}
fn write_axis_title(w: &mut XmlWriter, text: &str) {
w.open("c:title");
w.open("a:tx");
w.open("a:rich");
w.open("a:bodyPr");
w.close("a:bodyPr");
w.open("a:lstStyle");
w.close("a:lstStyle");
w.open("a:p");
w.open("a:pPr");
w.empty_with("a:defRPr", &[("sz", "1000"), ("b", "0")]);
w.close("a:pPr");
w.leaf("a:t", text);
w.close("a:p");
w.close("a:rich");
w.close("a:tx");
w.empty_with("c:overlay", &[("val", "0")]);
w.close("c:title");
}
fn write_series(
w: &mut XmlWriter,
chart_type: ChartType,
s: &ChartSeries,
idx: usize,
categories: &[ChartCategory],
) {
let idx_s = idx.to_string();
w.open("c:ser");
w.empty_with("c:idx", &[("val", idx_s.as_str())]);
w.empty_with("c:order", &[("val", idx_s.as_str())]);
w.open("c:tx");
w.open("c:strRef");
w.leaf("c:f", &format!("Sheet1!${}$1", col_letter(idx + 1)));
w.open("c:strCache");
w.open_with("c:pt", &[("idx", "0")]);
w.leaf("c:v", s.name.as_str());
w.close("c:pt");
w.close("c:strCache");
w.close("c:strRef");
w.close("c:tx");
if let Some(fmt) = &s.format {
fmt.write_xml(w);
}
if chart_type.is_xy_chart() {
w.open("c:xVal");
w.open("c:numRef");
w.leaf("c:f", "Sheet1!$A$2:$A$100");
w.open("c:numCache");
w.empty_with("c:formatCode", &[("val", "General")]);
if let Some(xs) = &s.x_values {
for (i, v) in xs.iter().enumerate() {
let i_s = i.to_string();
let v_s = format_f64(*v);
w.open_with("c:pt", &[("idx", i_s.as_str())]);
w.leaf("c:v", v_s.as_str());
w.close("c:pt");
}
}
w.close("c:numCache");
w.close("c:numRef");
w.close("c:xVal");
w.open("c:yVal");
w.open("c:numRef");
w.leaf(
"c:f",
&format!(
"Sheet1!${}$2:${}$100",
col_letter(idx + 1),
col_letter(idx + 1)
),
);
w.open("c:numCache");
w.empty_with("c:formatCode", &[("val", "General")]);
for (i, v) in s.values.iter().enumerate() {
let i_s = i.to_string();
let v_s = format_f64(*v);
w.open_with("c:pt", &[("idx", i_s.as_str())]);
w.leaf("c:v", v_s.as_str());
w.close("c:pt");
}
w.close("c:numCache");
w.close("c:numRef");
w.close("c:yVal");
if chart_type.is_bubble() {
w.open("c:bubbleSize");
w.open("c:numRef");
w.leaf(
"c:f",
&format!(
"Sheet1!${}$2:${}$100",
col_letter(idx + 2),
col_letter(idx + 2)
),
);
w.open("c:numCache");
w.empty_with("c:formatCode", &[("val", "General")]);
if let Some(sizes) = &s.bubble_sizes {
for (i, v) in sizes.iter().enumerate() {
let i_s = i.to_string();
let v_s = format_f64(*v);
w.open_with("c:pt", &[("idx", i_s.as_str())]);
w.leaf("c:v", v_s.as_str());
w.close("c:pt");
}
}
w.close("c:numCache");
w.close("c:numRef");
w.close("c:bubbleSize");
}
} else {
if !matches!(chart_type, ChartType::Pie) && !categories.is_empty() {
w.open("c:cat");
w.open("c:strRef");
w.leaf("c:f", "Sheet1!$A$2:$A$100");
w.open("c:strCache");
for (i, cat) in categories.iter().enumerate() {
let i_s = i.to_string();
w.open_with("c:pt", &[("idx", i_s.as_str())]);
w.leaf("c:v", cat.name.as_str());
w.close("c:pt");
}
w.close("c:strCache");
w.close("c:strRef");
w.close("c:cat");
} else if matches!(chart_type, ChartType::Pie) && !categories.is_empty() {
w.open("c:cat");
w.open("c:strRef");
w.leaf("c:f", "Sheet1!$A$2:$A$100");
w.open("c:strCache");
for (i, cat) in categories.iter().enumerate() {
let i_s = i.to_string();
w.open_with("c:pt", &[("idx", i_s.as_str())]);
w.leaf("c:v", cat.name.as_str());
w.close("c:pt");
}
w.close("c:strCache");
w.close("c:strRef");
w.close("c:cat");
}
w.open("c:val");
w.open("c:numRef");
w.leaf(
"c:f",
&format!(
"Sheet1!${}$2:${}$100",
col_letter(idx + 1),
col_letter(idx + 1)
),
);
w.open("c:numCache");
w.empty_with("c:formatCode", &[("val", "General")]);
for (i, v) in s.values.iter().enumerate() {
let i_s = i.to_string();
let v_s = format_f64(*v);
w.open_with("c:pt", &[("idx", i_s.as_str())]);
w.leaf("c:v", v_s.as_str());
w.close("c:pt");
}
w.close("c:numCache");
w.close("c:numRef");
w.close("c:val");
}
if let Some(dl) = &s.data_labels {
if !dl.is_empty() {
dl.write_xml(w);
}
}
w.close("c:ser");
}
fn col_letter(n: usize) -> String {
let mut s = String::new();
let mut n = n;
while n > 0 {
n -= 1;
let ch = (b'A' + (n % 26) as u8) as char;
s.insert(0, ch);
n /= 26;
}
s
}
fn format_f64(v: f64) -> String {
if v.is_nan() || v.is_infinite() {
return "0".to_string();
}
if v.fract() == 0.0 && v.abs() < 1e15 {
format!("{}", v as i64)
} else {
format!("{}", v)
}
}
pub fn parse_from_xml(xml: &str) -> crate::Result<Chart> {
use quick_xml::events::Event;
use quick_xml::reader::Reader;
let mut chart_type = ChartType::Column;
let mut title: Option<String> = None;
let mut series: Vec<ChartSeries> = Vec::new();
let mut categories: Vec<ChartCategory> = Vec::new();
let mut rd = Reader::from_str(xml);
rd.config_mut().trim_text(true);
let mut buf = Vec::new();
let mut in_chart_elem: Option<&'static str> = None;
let mut in_plot_area = false;
let mut bar_dir: Option<String> = None;
let mut in_title_text = false;
let mut cur_ser: Option<ChartSeries> = None;
let mut ser_field: Option<SerField> = None;
let mut in_cache = false;
let mut external_data_rid: Option<String> = None;
let mut cur_values: Vec<f64> = Vec::new();
let mut cur_strings: Vec<String> = Vec::new();
let mut chart_dl: Option<DataLabels> = None;
let mut series_dl: Option<DataLabels> = None;
let mut dl_target: Option<DlTarget> = None;
let mut has_secondary_axis = false;
let mut primary_chart_seen = false;
let mut secondary_chart_type: Option<ChartType> = None;
let mut in_secondary_chart = false;
let mut secondary_bar_dir: Option<String> = None;
let mut legend_position: Option<LegendPosition> = None;
let mut legend_overlay: Option<bool> = None;
let mut in_legend = false;
let mut legend_buf: Option<Legend> = None;
let mut in_sp_pr = false;
let mut cur_series_format: Option<SeriesFormat> = None;
let mut sppr_color: Option<Color> = None;
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
enum SerField {
Name,
Cat,
Val,
XVal,
YVal,
BubbleSize,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
enum DlTarget {
Chart,
Series,
}
fn chart_elem_for(local: &[u8]) -> Option<(&'static str, ChartType)> {
match local {
b"barChart" => Some(("barChart", ChartType::Column)), b"lineChart" => Some(("lineChart", ChartType::Line)),
b"pieChart" => Some(("pieChart", ChartType::Pie)),
b"scatterChart" => Some(("scatterChart", ChartType::Scatter)),
b"areaChart" => Some(("areaChart", ChartType::Area)),
b"radarChart" => Some(("radarChart", ChartType::Radar)),
b"bubbleChart" => Some(("bubbleChart", ChartType::Bubble)),
_ => None,
}
}
fn local_name_quick(name: &[u8]) -> &[u8] {
match name.iter().position(|&b| b == b':') {
Some(i) => &name[i + 1..],
None => name,
}
}
loop {
match rd.read_event_into(&mut buf) {
Ok(Event::Start(e)) => {
let name = e.name();
let local = local_name_quick(name.as_ref());
if local == b"externalData" {
for a in e.attributes().flatten() {
let key = a.key.as_ref();
if key == b"r:id" || key == b"id" || key.ends_with(b":id") {
external_data_rid = Some(
a.normalized_value(quick_xml::XmlVersion::Implicit1_0)
.unwrap_or_default()
.to_string(),
);
}
}
}
if local == b"plotArea" {
in_plot_area = true;
}
if let Some((elem, ct)) = chart_elem_for(local) {
if primary_chart_seen && in_chart_elem.is_none() {
secondary_chart_type = Some(ct);
in_secondary_chart = true;
secondary_bar_dir = None;
} else if !primary_chart_seen {
chart_type = ct;
primary_chart_seen = true;
}
in_chart_elem = Some(elem);
}
if local == b"barDir" && in_chart_elem.is_some() {
for a in e.attributes().flatten() {
if a.key.as_ref() == b"val" {
let v = a
.normalized_value(quick_xml::XmlVersion::Implicit1_0)
.unwrap_or_default()
.to_string();
if in_secondary_chart {
secondary_bar_dir = Some(v);
} else {
bar_dir = Some(v);
}
}
}
}
if local == b"t" {
in_title_text = true;
}
if local == b"ser" && in_chart_elem.is_some() {
cur_ser = Some(ChartSeries::default());
series_dl = None;
}
if local == b"spPr" && cur_ser.is_some() {
in_sp_pr = true;
cur_series_format = Some(SeriesFormat::default());
sppr_color = None;
}
if local == b"dLbls" {
if cur_ser.is_some() {
dl_target = Some(DlTarget::Series);
series_dl = Some(DataLabels::default());
} else if in_chart_elem.is_some() {
dl_target = Some(DlTarget::Chart);
chart_dl = Some(DataLabels::default());
}
}
if cur_ser.is_some() {
match local {
b"tx" => ser_field = Some(SerField::Name),
b"cat" => ser_field = Some(SerField::Cat),
b"val" => ser_field = Some(SerField::Val),
b"xVal" => ser_field = Some(SerField::XVal),
b"yVal" => ser_field = Some(SerField::YVal),
b"bubbleSize" => ser_field = Some(SerField::BubbleSize),
_ => {}
}
}
if matches!(local, b"numCache" | b"strCache") {
in_cache = true;
cur_values.clear();
cur_strings.clear();
}
if local == b"crosses" && in_plot_area {
for a in e.attributes().flatten() {
if a.key.as_ref() == b"val" {
let v = a
.normalized_value(quick_xml::XmlVersion::Implicit1_0)
.unwrap_or_default();
if v == "max" {
has_secondary_axis = true;
}
}
}
}
if local == b"legend" {
in_legend = true;
}
if in_legend {
if local == b"legendPos" {
if let Some(v) = attr_val(&e, "val") {
legend_position = match v.as_str() {
"b" => Some(LegendPosition::Bottom),
"t" => Some(LegendPosition::Top),
"r" => Some(LegendPosition::Right),
"l" => Some(LegendPosition::Left),
_ => None,
};
}
}
if local == b"overlay" {
legend_overlay = parse_bool_val(&e);
}
}
}
Ok(Event::Empty(e)) => {
let name = e.name();
let local = local_name_quick(name.as_ref());
if local == b"barDir" && in_chart_elem.is_some() {
for a in e.attributes().flatten() {
if a.key.as_ref() == b"val" {
let v = a
.normalized_value(quick_xml::XmlVersion::Implicit1_0)
.unwrap_or_default()
.to_string();
if in_secondary_chart {
secondary_bar_dir = Some(v);
} else {
bar_dir = Some(v);
}
}
}
}
if local == b"v" && in_cache {
if let Some(v) = e.attributes().flatten().find(|a| a.key.as_ref() == b"val") {
let s = v
.normalized_value(quick_xml::XmlVersion::Implicit1_0)
.unwrap_or_default()
.to_string();
if let Ok(f) = s.parse::<f64>() {
cur_values.push(f);
}
cur_strings.push(s);
}
}
if let Some(target) = dl_target.as_ref() {
let dl_buf: &mut Option<DataLabels> = if *target == DlTarget::Chart {
&mut chart_dl
} else {
&mut series_dl
};
let dl = dl_buf.get_or_insert_with(DataLabels::default);
match local {
b"showVal" => {
dl.show_val = parse_bool_val(&e);
}
b"showCatName" => {
dl.show_cat_name = parse_bool_val(&e);
}
b"showSerName" => {
dl.show_ser_name = parse_bool_val(&e);
}
b"showLegendKey" => {
dl.show_legend_key = parse_bool_val(&e);
}
b"showPercent" => {
dl.show_percent = parse_bool_val(&e);
}
b"showBubbleSize" => {
dl.show_bubble_size = parse_bool_val(&e);
}
b"dLblPos" => {
if let Some(v) = attr_val(&e, "val") {
dl.position = LabelPosition::parse(&v);
}
}
b"separator" => {
if let Some(v) = attr_val(&e, "val") {
dl.separator = Some(v);
}
}
b"numFmt" => {
if let Some(fc) = attr_val(&e, "formatCode") {
dl.num_fmt = Some(fc);
}
}
_ => {}
}
}
if local == b"crosses" && in_plot_area {
if let Some(v) = attr_val(&e, "val") {
if v == "max" {
has_secondary_axis = true;
}
}
}
if in_sp_pr && local == b"srgbClr" {
if let Some(v) = attr_val(&e, "val") {
if let Some(c) = parse_hex_color(&v) {
sppr_color = Some(c);
}
}
}
if in_legend {
if local == b"legendPos" {
if let Some(v) = attr_val(&e, "val") {
legend_position = match v.as_str() {
"b" => Some(LegendPosition::Bottom),
"t" => Some(LegendPosition::Top),
"r" => Some(LegendPosition::Right),
"l" => Some(LegendPosition::Left),
_ => None,
};
}
}
if local == b"overlay" {
legend_overlay = parse_bool_val(&e);
}
}
}
Ok(Event::Text(t)) => {
if in_title_text && title.is_none() {
let text_str = std::str::from_utf8(t.as_ref()).unwrap_or("");
let text = quick_xml::escape::unescape(text_str)
.unwrap_or_default()
.to_string();
if !text.is_empty() {
title = Some(text);
}
}
if in_cache {
let text_str = std::str::from_utf8(t.as_ref()).unwrap_or("");
let text = quick_xml::escape::unescape(text_str)
.unwrap_or_default()
.to_string();
if !text.is_empty() {
if let Ok(f) = text.parse::<f64>() {
cur_values.push(f);
}
cur_strings.push(text);
}
}
}
Ok(Event::End(e)) => {
let name = e.name();
let local = local_name_quick(name.as_ref());
if local == b"plotArea" {
in_plot_area = false;
}
if let Some(elem) = in_chart_elem {
if local == elem.as_bytes() {
if !in_secondary_chart {
if elem == "barChart" {
if let Some(dir) = &bar_dir {
if dir == "bar" {
chart_type = ChartType::Bar;
}
}
}
} else {
if elem == "barChart" {
if let Some(dir) = &secondary_bar_dir {
if dir == "bar" {
secondary_chart_type = Some(ChartType::Bar);
}
}
}
}
in_chart_elem = None;
in_secondary_chart = false;
}
}
if local == b"t" {
in_title_text = false;
}
if local == b"dLbls" {
dl_target = None;
}
if local == b"spPr" && in_sp_pr {
if let Some(color) = sppr_color.take() {
if let Some(fmt) = cur_series_format.as_mut() {
fmt.fill = Fill::Solid(color);
}
}
if let Some(fmt) = cur_series_format.take() {
if let Some(s) = cur_ser.as_mut() {
s.format = Some(fmt);
}
}
in_sp_pr = false;
}
if local == b"legend" {
let pos = legend_position.take().unwrap_or_default();
legend_buf = Some(Legend {
position: pos,
overlay: legend_overlay.take().unwrap_or(false),
});
in_legend = false;
}
if matches!(local, b"numCache" | b"strCache") && cur_ser.is_some() {
let field = ser_field.take();
if let Some(f) = field {
match f {
SerField::Name => {
if let Some(s) = cur_strings.first() {
if let Some(s_obj) = cur_ser.as_mut() {
s_obj.name = s.clone();
}
}
ser_field = None;
}
SerField::Cat => {
if categories.is_empty() {
for s in &cur_strings {
categories.push(ChartCategory::new(s.clone()));
}
}
ser_field = None;
}
SerField::Val | SerField::YVal => {
if let Some(s_obj) = cur_ser.as_mut() {
s_obj.values = cur_values.clone();
}
ser_field = None;
}
SerField::XVal => {
if let Some(s_obj) = cur_ser.as_mut() {
s_obj.x_values = Some(cur_values.clone());
}
ser_field = None;
}
SerField::BubbleSize => {
if let Some(s_obj) = cur_ser.as_mut() {
s_obj.bubble_sizes = Some(cur_values.clone());
}
ser_field = None;
}
}
}
in_cache = false;
cur_values.clear();
cur_strings.clear();
}
if local == b"ser" && cur_ser.is_some() {
if let Some(mut s_obj) = cur_ser.take() {
if let Some(dl) = series_dl.take() {
if !dl.is_empty() {
s_obj.data_labels = Some(dl);
}
}
if in_secondary_chart {
s_obj.secondary_axis = true;
}
series.push(s_obj);
}
ser_field = None;
series_dl = None;
if dl_target == Some(DlTarget::Series) {
dl_target = None;
}
}
}
Ok(Event::Eof) => break,
Err(e) => return Err(crate::Error::Xml(format!("chart parse_from_xml: {e}"))),
_ => {}
}
buf.clear();
}
if has_secondary_axis
&& secondary_chart_type.is_none()
&& !chart_type.is_xy_chart()
&& !matches!(chart_type, ChartType::Pie)
{
for s in series.iter_mut() {
s.secondary_axis = true;
}
}
let data = ChartData {
categories,
series,
title,
data_labels: chart_dl.filter(|dl| !dl.is_empty()),
legend: legend_buf,
secondary_chart_type,
..Default::default()
};
Ok(Chart {
chart_type,
data,
rid: String::new(),
external_data_rid,
})
}
fn parse_bool_val(e: &quick_xml::events::BytesStart<'_>) -> Option<bool> {
for a in e.attributes().flatten() {
if a.key.as_ref() == b"val" {
let v = a
.normalized_value(quick_xml::XmlVersion::Implicit1_0)
.unwrap_or_default();
return match v.as_ref() {
"1" | "true" | "True" => Some(true),
"0" | "false" | "False" => Some(false),
_ => None,
};
}
}
None
}
fn attr_val(e: &quick_xml::events::BytesStart<'_>, key: &str) -> Option<String> {
let key_bytes = key.as_bytes();
let suffix: Vec<u8> = std::iter::once(b':')
.chain(key_bytes.iter().copied())
.collect();
for a in e.attributes().flatten() {
let k = a.key.as_ref();
if k == key_bytes || k.ends_with(&suffix) {
return Some(
a.normalized_value(quick_xml::XmlVersion::Implicit1_0)
.unwrap_or_default()
.to_string(),
);
}
}
None
}
fn parse_hex_color(s: &str) -> Option<Color> {
use crate::units::RGBColor;
let s = s.trim();
let (r, g, b) = if s.len() == 6 {
(
u8::from_str_radix(&s[0..2], 16).ok()?,
u8::from_str_radix(&s[2..4], 16).ok()?,
u8::from_str_radix(&s[4..6], 16).ok()?,
)
} else if s.len() == 3 {
(
u8::from_str_radix(&s[0..1].repeat(2), 16).ok()?,
u8::from_str_radix(&s[1..2].repeat(2), 16).ok()?,
u8::from_str_radix(&s[2..3].repeat(2), 16).ok()?,
)
} else {
return None;
};
Some(Color::RGB(RGBColor(r, g, b)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn column_chart_minimal_xml() {
let data = ChartData {
categories: vec![ChartCategory::new("Q1"), ChartCategory::new("Q2")],
series: vec![ChartSeries::new("Sales", vec![10.0, 20.0])],
title: Some("Revenue".to_string()),
data_labels: None,
..Default::default()
};
let chart = Chart::new(ChartType::Column, data);
let xml = chart.to_xml();
assert!(xml.contains("<c:chartSpace"), "xml: {}", xml);
assert!(xml.contains("<c:barChart>"), "xml: {}", xml);
assert!(xml.contains("barDir"), "xml: {}", xml);
assert!(xml.contains("val=\"col\""), "xml: {}", xml);
assert!(xml.contains("<c:ser>"), "xml: {}", xml);
assert!(xml.contains("<c:catAx>"), "xml: {}", xml);
assert!(xml.contains("<c:valAx>"), "xml: {}", xml);
assert!(xml.contains("Revenue"), "xml: {}", xml);
}
#[test]
fn pie_chart_no_axes() {
let data = ChartData {
categories: vec![ChartCategory::new("A"), ChartCategory::new("B")],
series: vec![ChartSeries::new("X", vec![1.0, 2.0])],
title: None,
data_labels: None,
..Default::default()
};
let xml = Chart::new(ChartType::Pie, data).to_xml();
assert!(xml.contains("<c:pieChart>"), "xml: {}", xml);
assert!(!xml.contains("<c:catAx>"), "xml: {}", xml);
assert!(!xml.contains("<c:valAx>"), "xml: {}", xml);
assert!(xml.contains("varyColors"), "xml: {}", xml);
}
#[test]
fn line_chart_uses_line_chart_element() {
let data = ChartData {
categories: vec![ChartCategory::new("X1"), ChartCategory::new("X2")],
series: vec![ChartSeries::new("Y", vec![1.5, 2.5])],
title: None,
data_labels: None,
..Default::default()
};
let xml = Chart::new(ChartType::Line, data).to_xml();
assert!(xml.contains("<c:lineChart>"), "xml: {}", xml);
assert!(!xml.contains("<c:barChart>"), "xml: {}", xml);
assert!(xml.contains("1.5"), "xml: {}", xml);
assert!(xml.contains("2.5"), "xml: {}", xml);
}
#[test]
fn scatter_chart_uses_scatter_elements() {
let data = ChartData {
categories: vec![], series: vec![ChartSeries::new_scatter(
"Series1",
vec![1.0, 2.0, 3.0], vec![2.0, 4.0, 6.0], )],
title: Some("Scatter".to_string()),
data_labels: None,
..Default::default()
};
let xml = Chart::new(ChartType::Scatter, data).to_xml();
assert!(xml.contains("<c:scatterChart>"), "xml: {}", xml);
assert!(xml.contains("scatterStyle"), "xml: {}", xml);
assert!(xml.contains("<c:xVal>"), "xml: {}", xml);
assert!(xml.contains("<c:yVal>"), "xml: {}", xml);
assert!(!xml.contains("<c:catAx>"), "xml: {}", xml);
let val_ax_count = xml.matches("<c:valAx>").count();
assert_eq!(
val_ax_count, 2,
"expected 2 valAx, got {}: {}",
val_ax_count, xml
);
assert!(!xml.contains("<c:cat>"), "xml: {}", xml);
assert!(xml.contains(">1<"), "xml: {}", xml);
assert!(xml.contains(">3<"), "xml: {}", xml);
}
#[test]
fn area_chart_uses_area_elements() {
let data = ChartData {
categories: vec![ChartCategory::new("A"), ChartCategory::new("B")],
series: vec![ChartSeries::new("S1", vec![10.0, 20.0])],
title: None,
data_labels: None,
..Default::default()
};
let xml = Chart::new(ChartType::Area, data).to_xml();
assert!(xml.contains("<c:areaChart>"), "xml: {}", xml);
assert!(xml.contains("grouping"), "xml: {}", xml);
assert!(xml.contains("val=\"standard\""), "xml: {}", xml);
assert!(xml.contains("<c:catAx>"), "xml: {}", xml);
assert!(xml.contains("<c:valAx>"), "xml: {}", xml);
assert!(xml.contains("<c:cat>"), "xml: {}", xml);
}
#[test]
fn radar_chart_uses_radar_elements() {
let data = ChartData {
categories: vec![
ChartCategory::new("速度"),
ChartCategory::new("力量"),
ChartCategory::new("技巧"),
],
series: vec![ChartSeries::new("选手A", vec![8.0, 6.0, 9.0])],
title: Some("Radar".to_string()),
data_labels: None,
..Default::default()
};
let xml = Chart::new(ChartType::Radar, data).to_xml();
assert!(xml.contains("<c:radarChart>"), "xml: {}", xml);
assert!(xml.contains("radarStyle"), "xml: {}", xml);
assert!(xml.contains("val=\"marker\""), "xml: {}", xml);
assert!(xml.contains("<c:catAx>"), "xml: {}", xml);
assert!(xml.contains("<c:valAx>"), "xml: {}", xml);
assert!(xml.contains("<c:cat>"), "xml: {}", xml);
assert!(!xml.contains("<c:xVal>"), "xml: {}", xml);
assert!(!xml.contains("<c:bubbleSize>"), "xml: {}", xml);
}
#[test]
fn bubble_chart_uses_bubble_elements() {
let data = ChartData {
categories: vec![],
series: vec![ChartSeries::new_bubble(
"S1",
vec![1.0, 2.0, 3.0], vec![10.0, 20.0, 30.0], vec![5.0, 15.0, 25.0], )],
title: Some("Bubble".to_string()),
data_labels: None,
..Default::default()
};
let xml = Chart::new(ChartType::Bubble, data).to_xml();
assert!(xml.contains("<c:bubbleChart>"), "xml: {}", xml);
assert!(xml.contains("bubbleScale"), "xml: {}", xml);
assert!(xml.contains("val=\"100\""), "xml: {}", xml);
assert!(xml.contains("<c:xVal>"), "xml: {}", xml);
assert!(xml.contains("<c:yVal>"), "xml: {}", xml);
assert!(xml.contains("<c:bubbleSize>"), "xml: {}", xml);
assert!(!xml.contains("<c:catAx>"), "xml: {}", xml);
assert!(xml.contains("<c:valAx>"), "xml: {}", xml);
assert!(xml.contains(">5<"), "xml: {}", xml);
assert!(xml.contains(">25<"), "xml: {}", xml);
}
#[test]
fn col_letter_basic() {
assert_eq!(col_letter(1), "A");
assert_eq!(col_letter(2), "B");
assert_eq!(col_letter(26), "Z");
assert_eq!(col_letter(27), "AA");
assert_eq!(col_letter(28), "AB");
}
#[test]
fn format_f64_integer() {
assert_eq!(format_f64(10.0), "10");
assert_eq!(format_f64(1.5), "1.5");
assert_eq!(format_f64(f64::NAN), "0");
}
#[test]
fn parse_column_chart_round_trip() {
let original = Chart::new(
ChartType::Column,
ChartData {
categories: vec![
ChartCategory::new("Q1"),
ChartCategory::new("Q2"),
ChartCategory::new("Q3"),
],
series: vec![ChartSeries::new("Sales", vec![10.0, 20.0, 30.0])],
title: Some("Revenue".to_string()),
data_labels: None,
..Default::default()
},
);
let xml = original.to_xml();
let parsed = Chart::parse_from_xml(&xml).expect("parse column chart");
assert_eq!(parsed.chart_type, ChartType::Column);
assert_eq!(parsed.data.title.as_deref(), Some("Revenue"));
assert_eq!(parsed.data.categories.len(), 3);
assert_eq!(parsed.data.categories[0].name, "Q1");
assert_eq!(parsed.data.categories[2].name, "Q3");
assert_eq!(parsed.data.series.len(), 1);
assert_eq!(parsed.data.series[0].name, "Sales");
assert_eq!(parsed.data.series[0].values, vec![10.0, 20.0, 30.0]);
}
#[test]
fn parse_bar_chart_distinguishes_bar_from_column() {
let original = Chart::new(
ChartType::Bar,
ChartData {
categories: vec![ChartCategory::new("A"), ChartCategory::new("B")],
series: vec![ChartSeries::new("S1", vec![1.0, 2.0])],
title: None,
data_labels: None,
..Default::default()
},
);
let xml = original.to_xml();
let parsed = Chart::parse_from_xml(&xml).expect("parse bar chart");
assert_eq!(parsed.chart_type, ChartType::Bar);
assert_ne!(parsed.chart_type, ChartType::Column);
assert_eq!(parsed.data.series.len(), 1);
assert_eq!(parsed.data.series[0].values, vec![1.0, 2.0]);
}
#[test]
fn parse_line_chart_round_trip() {
let original = Chart::new(
ChartType::Line,
ChartData {
categories: vec![ChartCategory::new("X1"), ChartCategory::new("X2")],
series: vec![ChartSeries::new("Y", vec![1.5, 2.5])],
title: None,
data_labels: None,
..Default::default()
},
);
let xml = original.to_xml();
let parsed = Chart::parse_from_xml(&xml).expect("parse line chart");
assert_eq!(parsed.chart_type, ChartType::Line);
assert_eq!(parsed.data.categories.len(), 2);
assert_eq!(parsed.data.series[0].name, "Y");
assert_eq!(parsed.data.series[0].values, vec![1.5, 2.5]);
}
#[test]
fn parse_pie_chart_round_trip() {
let original = Chart::new(
ChartType::Pie,
ChartData {
categories: vec![ChartCategory::new("A"), ChartCategory::new("B")],
series: vec![ChartSeries::new("X", vec![1.0, 2.0])],
title: None,
data_labels: None,
..Default::default()
},
);
let xml = original.to_xml();
let parsed = Chart::parse_from_xml(&xml).expect("parse pie chart");
assert_eq!(parsed.chart_type, ChartType::Pie);
assert_eq!(parsed.data.categories.len(), 2);
assert_eq!(parsed.data.categories[0].name, "A");
assert_eq!(parsed.data.categories[1].name, "B");
assert_eq!(parsed.data.series.len(), 1);
assert_eq!(parsed.data.series[0].values, vec![1.0, 2.0]);
}
#[test]
fn parse_scatter_chart_round_trip() {
let original = Chart::new(
ChartType::Scatter,
ChartData {
categories: vec![],
series: vec![ChartSeries::new_scatter(
"Series1",
vec![1.0, 2.0, 3.0], vec![2.0, 4.0, 6.0], )],
title: Some("Scatter".to_string()),
data_labels: None,
..Default::default()
},
);
let xml = original.to_xml();
let parsed = Chart::parse_from_xml(&xml).expect("parse scatter chart");
assert_eq!(parsed.chart_type, ChartType::Scatter);
assert_eq!(parsed.data.title.as_deref(), Some("Scatter"));
assert_eq!(parsed.data.series.len(), 1);
let s = &parsed.data.series[0];
assert_eq!(s.name, "Series1");
assert_eq!(s.values, vec![2.0, 4.0, 6.0]);
assert_eq!(s.x_values.as_deref(), Some(&[1.0, 2.0, 3.0][..]));
assert!(s.bubble_sizes.is_none());
}
#[test]
fn parse_bubble_chart_round_trip() {
let original = Chart::new(
ChartType::Bubble,
ChartData {
categories: vec![],
series: vec![ChartSeries::new_bubble(
"S1",
vec![1.0, 2.0, 3.0], vec![10.0, 20.0, 30.0], vec![5.0, 15.0, 25.0], )],
title: Some("Bubble".to_string()),
data_labels: None,
..Default::default()
},
);
let xml = original.to_xml();
let parsed = Chart::parse_from_xml(&xml).expect("parse bubble chart");
assert_eq!(parsed.chart_type, ChartType::Bubble);
assert_eq!(parsed.data.series.len(), 1);
let s = &parsed.data.series[0];
assert_eq!(s.name, "S1");
assert_eq!(s.values, vec![10.0, 20.0, 30.0]);
assert_eq!(s.x_values.as_deref(), Some(&[1.0, 2.0, 3.0][..]));
assert_eq!(s.bubble_sizes.as_deref(), Some(&[5.0, 15.0, 25.0][..]));
}
#[test]
fn parse_radar_chart_round_trip() {
let original = Chart::new(
ChartType::Radar,
ChartData {
categories: vec![
ChartCategory::new("速度"),
ChartCategory::new("力量"),
ChartCategory::new("技巧"),
],
series: vec![ChartSeries::new("选手A", vec![8.0, 6.0, 9.0])],
title: Some("Radar".to_string()),
data_labels: None,
..Default::default()
},
);
let xml = original.to_xml();
let parsed = Chart::parse_from_xml(&xml).expect("parse radar chart");
assert_eq!(parsed.chart_type, ChartType::Radar);
assert_eq!(parsed.data.categories.len(), 3);
assert_eq!(parsed.data.categories[0].name, "速度");
assert_eq!(parsed.data.series[0].values, vec![8.0, 6.0, 9.0]);
}
#[test]
fn parse_multi_series_column_chart() {
let original = Chart::new(
ChartType::Column,
ChartData {
categories: vec![ChartCategory::new("A"), ChartCategory::new("B")],
series: vec![
ChartSeries::new("S1", vec![1.0, 2.0]),
ChartSeries::new("S2", vec![3.0, 4.0]),
ChartSeries::new("S3", vec![5.0, 6.0]),
],
title: None,
data_labels: None,
..Default::default()
},
);
let xml = original.to_xml();
let parsed = Chart::parse_from_xml(&xml).expect("parse multi-series chart");
assert_eq!(parsed.data.series.len(), 3);
assert_eq!(parsed.data.series[0].name, "S1");
assert_eq!(parsed.data.series[1].name, "S2");
assert_eq!(parsed.data.series[2].name, "S3");
assert_eq!(parsed.data.series[2].values, vec![5.0, 6.0]);
}
#[test]
fn parse_chart_without_title() {
let original = Chart::new(
ChartType::Column,
ChartData {
categories: vec![ChartCategory::new("A")],
series: vec![ChartSeries::new("S", vec![1.0])],
title: None,
data_labels: None,
..Default::default()
},
);
let xml = original.to_xml();
let parsed = Chart::parse_from_xml(&xml).expect("parse no-title chart");
assert!(parsed.data.title.is_none());
}
#[test]
fn parse_empty_chart_space_no_panic() {
let xml = "<?xml version=\"1.0\"?>\
<c:chartSpace xmlns:c=\"http://schemas.openxmlformats.org/drawingml/2006/chart\">\
<c:chart></c:chart>\
</c:chartSpace>";
let parsed = Chart::parse_from_xml(xml).expect("parse empty chartSpace");
assert_eq!(parsed.chart_type, ChartType::Column);
assert!(parsed.data.series.is_empty());
assert!(parsed.data.categories.is_empty());
}
#[test]
fn parse_malformed_xml_returns_error() {
let xml = "<c:chartSpace><!-- unclosed comment <c:chart/></c:chartSpace>";
let result = Chart::parse_from_xml(xml);
assert!(result.is_err(), "malformed xml should error");
}
#[test]
fn to_xml_writes_external_data() {
let mut chart = Chart::new(
ChartType::Column,
ChartData {
categories: vec![ChartCategory::new("A")],
series: vec![ChartSeries::new("S", vec![1.0])],
title: None,
data_labels: None,
..Default::default()
},
);
chart.external_data_rid = Some("rIdXlsx1".to_string());
let xml = chart.to_xml();
assert!(
xml.contains(r#"<c:externalData r:id="rIdXlsx1">"#),
"应输出 c:externalData 开标签"
);
assert!(
xml.contains(r#"<c:autoUpdate val="0"/>"#),
"应输出 autoUpdate=0"
);
assert!(xml.contains("</c:externalData>"), "应闭合 c:externalData");
let pos_chart_close = xml.find("</c:chart>").expect("c:chart 关闭存在");
let pos_ext = xml.find("<c:externalData").expect("c:externalData 存在");
assert!(
pos_chart_close < pos_ext,
"c:externalData 必须在 </c:chart> 之后"
);
let pos_chart_space_close = xml.rfind("</c:chartSpace>").expect("c:chartSpace 关闭存在");
assert!(
pos_ext < pos_chart_space_close,
"c:externalData 必须在 </c:chartSpace> 之前"
);
}
#[test]
fn to_xml_omits_external_data_when_none() {
let chart = Chart::new(
ChartType::Line,
ChartData {
categories: vec![ChartCategory::new("A")],
series: vec![ChartSeries::new("S", vec![1.0])],
title: None,
data_labels: None,
..Default::default()
},
);
let xml = chart.to_xml();
assert!(
!xml.contains("c:externalData"),
"None 时不应输出 c:externalData"
);
}
#[test]
fn parse_external_data_rid_round_trip() {
let mut original = Chart::new(
ChartType::Column,
ChartData {
categories: vec![ChartCategory::new("Q1"), ChartCategory::new("Q2")],
series: vec![ChartSeries::new("Sales", vec![10.0, 20.0])],
title: Some("Revenue".into()),
data_labels: None,
..Default::default()
},
);
original.external_data_rid = Some("rIdXlsx1".into());
let xml = original.to_xml();
let parsed = Chart::parse_from_xml(&xml).expect("parse round-trip");
assert_eq!(parsed.chart_type, ChartType::Column);
assert_eq!(parsed.external_data_rid, Some("rIdXlsx1".to_string()));
}
#[test]
fn parse_external_data_rid_bare_id_form() {
let xml = "<?xml version=\"1.0\"?>\
<c:chartSpace xmlns:c=\"http://schemas.openxmlformats.org/drawingml/2006/chart\" \
xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\">\
<c:chart><c:plotArea><c:barChart><c:barDir val=\"col\"/>\
<c:ser><c:idx val=\"0\"/><c:order val=\"0\"/>\
<c:val><c:numCache><c:formatCode>General</c:formatCode>\
<c:ptCount val=\"1\"/><c:pt idx=\"0\"><c:v>1</c:v></c:pt>\
</c:numCache></c:val>\
</c:ser></c:barChart></c:plotArea></c:chart>\
<c:externalData id=\"rIdBare\"><c:autoUpdate val=\"0\"/></c:externalData>\
</c:chartSpace>";
let parsed = Chart::parse_from_xml(xml).expect("parse bare id form");
assert_eq!(parsed.external_data_rid, Some("rIdBare".to_string()));
}
#[test]
fn data_labels_show_values_constructor() {
let dl = DataLabels::show_values();
assert_eq!(dl.show_val, Some(true));
assert_eq!(dl.show_cat_name, None);
assert_eq!(dl.show_ser_name, None);
assert!(!dl.is_empty());
}
#[test]
fn data_labels_show_percent_pie_constructor() {
let dl = DataLabels::show_percent_pie();
assert_eq!(dl.show_percent, Some(true));
assert_eq!(dl.show_val, None);
assert!(!dl.is_empty());
}
#[test]
fn data_labels_is_empty_for_default() {
let dl = DataLabels::default();
assert!(dl.is_empty());
}
#[test]
fn label_position_round_trip() {
for pos in [
LabelPosition::BestFit,
LabelPosition::Above,
LabelPosition::Below,
LabelPosition::Center,
LabelPosition::InsideEnd,
LabelPosition::OutsideEnd,
LabelPosition::Left,
LabelPosition::Right,
] {
let s = pos.as_str();
let parsed = LabelPosition::parse(s).expect("known position should parse");
assert_eq!(parsed, pos, "position round-trip failed for {:?}", pos);
}
assert!(LabelPosition::parse("unknown").is_none());
}
#[test]
fn parse_chart_level_data_labels_round_trip() {
let dl = DataLabels {
show_val: Some(true),
show_cat_name: Some(true),
show_ser_name: Some(false),
show_legend_key: Some(false),
show_percent: None,
show_bubble_size: None,
position: Some(LabelPosition::OutsideEnd),
separator: Some(", ".to_string()),
num_fmt: Some("0.00".to_string()),
};
let original = Chart::new(
ChartType::Column,
ChartData {
categories: vec![ChartCategory::new("Q1"), ChartCategory::new("Q2")],
series: vec![ChartSeries::new("Sales", vec![10.0, 20.0])],
title: None,
data_labels: Some(dl.clone()),
..Default::default()
},
);
let xml = original.to_xml();
assert!(xml.contains("<c:dLbls>"), "xml: {}", xml);
assert!(xml.contains(r#"<c:showVal val="1"/>"#), "xml: {}", xml);
assert!(xml.contains(r#"<c:showCatName val="1"/>"#), "xml: {}", xml);
assert!(xml.contains(r#"<c:showSerName val="0"/>"#), "xml: {}", xml);
assert!(
xml.contains(r#"<c:showLegendKey val="0"/>"#),
"xml: {}",
xml
);
assert!(xml.contains(r#"<c:dLblPos val="outEnd"/>"#), "xml: {}", xml);
assert!(xml.contains(r#"<c:separator val=", "/>"#), "xml: {}", xml);
assert!(xml.contains(r#"formatCode="0.00""#), "xml: {}", xml);
let parsed = Chart::parse_from_xml(&xml).expect("parse chart with dLbls");
let parsed_dl = parsed
.data
.data_labels
.expect("chart-level dLbls should parse");
assert_eq!(parsed_dl.show_val, Some(true));
assert_eq!(parsed_dl.show_cat_name, Some(true));
assert_eq!(parsed_dl.show_ser_name, Some(false));
assert_eq!(parsed_dl.show_legend_key, Some(false));
assert_eq!(parsed_dl.show_percent, None);
assert_eq!(parsed_dl.position, Some(LabelPosition::OutsideEnd));
assert_eq!(parsed_dl.separator.as_deref(), Some(", "));
assert_eq!(parsed_dl.num_fmt.as_deref(), Some("0.00"));
}
#[test]
fn parse_series_level_data_labels_round_trip() {
let series_dl = DataLabels {
show_val: Some(true),
show_percent: Some(true),
position: Some(LabelPosition::InsideEnd),
..Default::default()
};
let original = Chart::new(
ChartType::Pie,
ChartData {
categories: vec![ChartCategory::new("A"), ChartCategory::new("B")],
series: vec![{
let mut s = ChartSeries::new("X", vec![1.0, 2.0]);
s.data_labels = Some(series_dl.clone());
s
}],
title: None,
data_labels: None,
..Default::default()
},
);
let xml = original.to_xml();
assert!(xml.contains("<c:dLbls>"), "xml: {}", xml);
assert!(xml.contains(r#"<c:showVal val="1"/>"#), "xml: {}", xml);
assert!(xml.contains(r#"<c:showPercent val="1"/>"#), "xml: {}", xml);
assert!(xml.contains(r#"<c:dLblPos val="inEnd"/>"#), "xml: {}", xml);
let parsed = Chart::parse_from_xml(&xml).expect("parse pie with series dLbls");
assert_eq!(parsed.chart_type, ChartType::Pie);
assert!(parsed.data.data_labels.is_none());
let s = &parsed.data.series[0];
let parsed_dl = s.data_labels.as_ref().expect("series dLbls should parse");
assert_eq!(parsed_dl.show_val, Some(true));
assert_eq!(parsed_dl.show_percent, Some(true));
assert_eq!(parsed_dl.position, Some(LabelPosition::InsideEnd));
}
#[test]
fn parse_empty_dlbls_is_ignored() {
let original = Chart::new(
ChartType::Column,
ChartData {
categories: vec![ChartCategory::new("A")],
series: vec![ChartSeries::new("S", vec![1.0])],
title: None,
data_labels: Some(DataLabels::default()), ..Default::default()
},
);
let xml = original.to_xml();
assert!(
!xml.contains("<c:dLbls>"),
"empty dLbls should not be written: {}",
xml
);
let parsed = Chart::parse_from_xml(&xml).expect("parse");
assert!(parsed.data.data_labels.is_none());
}
#[test]
fn to_xml_writes_secondary_axis() {
let s2 = {
let mut s = ChartSeries::new("Secondary", vec![1.0, 2.0]);
s.secondary_axis = true;
s
};
let original = Chart::new(
ChartType::Column,
ChartData {
categories: vec![ChartCategory::new("Q1"), ChartCategory::new("Q2")],
series: vec![s2],
title: None,
data_labels: None,
..Default::default()
},
);
let xml = original.to_xml();
assert!(xml.contains(r#"<c:axId val="444444444"/>"#), "xml: {}", xml);
assert!(xml.contains(r#"<c:crosses val="max"/>"#), "xml: {}", xml);
let val_ax_count = xml.matches("<c:valAx>").count();
assert_eq!(
val_ax_count, 2,
"expected 2 valAx (primary + secondary): {}",
xml
);
assert!(xml.contains("<c:catAx>"), "xml: {}", xml);
}
#[test]
fn parse_secondary_axis_round_trip() {
let original = Chart::new(
ChartType::Column,
ChartData {
categories: vec![ChartCategory::new("Q1"), ChartCategory::new("Q2")],
series: vec![{
let mut s = ChartSeries::new("S", vec![10.0, 20.0]);
s.secondary_axis = true;
s
}],
title: None,
data_labels: None,
..Default::default()
},
);
let xml = original.to_xml();
let parsed = Chart::parse_from_xml(&xml).expect("parse secondary axis chart");
assert_eq!(parsed.data.series.len(), 1);
assert!(
parsed.data.series[0].secondary_axis,
"secondary_axis should be true after round-trip"
);
}
#[test]
fn pie_chart_ignores_secondary_axis() {
let original = Chart::new(
ChartType::Pie,
ChartData {
categories: vec![ChartCategory::new("A"), ChartCategory::new("B")],
series: vec![{
let mut s = ChartSeries::new("X", vec![1.0, 2.0]);
s.secondary_axis = true; s
}],
title: None,
data_labels: None,
..Default::default()
},
);
let xml = original.to_xml();
assert!(
!xml.contains("444444444"),
"pie should not write secondary axId: {}",
xml
);
assert!(
!xml.contains(r#"<c:crosses val="max"/>"#),
"pie should not write crosses=max: {}",
xml
);
}
#[test]
fn scatter_chart_ignores_secondary_axis() {
let mut s = ChartSeries::new_scatter("S", vec![1.0, 2.0], vec![3.0, 4.0]);
s.secondary_axis = true; let original = Chart::new(
ChartType::Scatter,
ChartData {
categories: vec![],
series: vec![s],
title: None,
data_labels: None,
..Default::default()
},
);
let xml = original.to_xml();
assert!(
!xml.contains("444444444"),
"scatter should not write secondary axId: {}",
xml
);
assert!(
!xml.contains(r#"<c:crosses val="max"/>"#),
"scatter should not write crosses=max: {}",
xml
);
}
#[test]
fn legend_round_trip() {
let original = Chart::new(
ChartType::Column,
ChartData {
categories: vec![ChartCategory::new("Q1"), ChartCategory::new("Q2")],
series: vec![ChartSeries::new("S", vec![10.0, 20.0])],
title: Some("Legend Test".to_string()),
data_labels: None,
legend: Some(Legend {
position: LegendPosition::Top,
overlay: false,
}),
..Default::default()
},
);
let xml = original.to_xml();
assert!(xml.contains("<c:legend>"), "xml: {}", xml);
assert!(xml.contains(r#"<c:legendPos val="t"/>"#), "xml: {}", xml);
assert!(xml.contains(r#"<c:overlay val="0"/>"#), "xml: {}", xml);
let parsed = Chart::parse_from_xml(&xml).expect("parse legend chart");
assert_eq!(
parsed
.data
.legend
.as_ref()
.expect("legend should exist")
.position,
LegendPosition::Top,
"legend position should round-trip"
);
assert!(
!parsed.data.legend.as_ref().unwrap().overlay,
"legend overlay should round-trip"
);
}
#[test]
fn no_legend_round_trip() {
let original = Chart::new(
ChartType::Line,
ChartData {
categories: vec![ChartCategory::new("A"), ChartCategory::new("B")],
series: vec![ChartSeries::new("S", vec![1.0, 2.0])],
title: None,
data_labels: None,
legend: None, ..Default::default()
},
);
let xml = original.to_xml();
assert!(
!xml.contains("<c:legend>"),
"should not write legend: {}",
xml
);
let parsed = Chart::parse_from_xml(&xml).expect("parse no-legend chart");
assert!(
parsed.data.legend.is_none(),
"legend should remain None after round-trip"
);
}
#[test]
fn legend_all_positions_round_trip() {
for (pos, val) in [
(LegendPosition::Bottom, "b"),
(LegendPosition::Top, "t"),
(LegendPosition::Right, "r"),
(LegendPosition::Left, "l"),
] {
let original = Chart::new(
ChartType::Column,
ChartData {
categories: vec![ChartCategory::new("A")],
series: vec![ChartSeries::new("S", vec![1.0])],
title: None,
data_labels: None,
legend: Some(Legend {
position: pos,
overlay: true,
}),
..Default::default()
},
);
let xml = original.to_xml();
assert!(
xml.contains(&format!(r#"<c:legendPos val="{}"/>"#, val)),
"position {:?} should write val={}: {}",
pos,
val,
xml
);
let parsed = Chart::parse_from_xml(&xml).expect("parse legend positions");
assert_eq!(
parsed.data.legend.as_ref().expect("legend").position,
pos,
"position {:?} should round-trip",
pos
);
assert!(
parsed.data.legend.as_ref().unwrap().overlay,
"overlay=true should round-trip for position {:?}",
pos
);
}
}
#[test]
fn series_format_solid_color_round_trip() {
use crate::oxml::color::Color;
use crate::oxml::sppr::Fill;
use crate::units::RGBColor;
let original = Chart::new(
ChartType::Column,
ChartData {
categories: vec![ChartCategory::new("A"), ChartCategory::new("B")],
series: vec![{
let mut s = ChartSeries::new("S", vec![10.0, 20.0]);
s.format = Some(SeriesFormat::solid_fill(Color::RGB(RGBColor(
0xFF, 0x00, 0x00,
))));
s
}],
title: None,
data_labels: None,
..Default::default()
},
);
let xml = original.to_xml();
assert!(xml.contains("<c:spPr>"), "xml should contain spPr: {}", xml);
assert!(
xml.contains(r#"<a:srgbClr val="FF0000"/>"#),
"xml should contain red color: {}",
xml
);
let parsed = Chart::parse_from_xml(&xml).expect("parse series_format chart");
let fmt = parsed.data.series[0]
.format
.as_ref()
.expect("format should round-trip");
match &fmt.fill {
Fill::Solid(Color::RGB(c)) => {
assert_eq!(c.0, 0xFF, "red component");
assert_eq!(c.1, 0x00, "green component");
assert_eq!(c.2, 0x00, "blue component");
}
other => panic!("expected Fill::Solid(Color::RGB), got {:?}", other),
}
}
#[test]
fn series_format_none_no_sp_pr() {
let original = Chart::new(
ChartType::Line,
ChartData {
categories: vec![ChartCategory::new("X")],
series: vec![ChartSeries::new("S", vec![1.0])],
title: None,
data_labels: None,
..Default::default()
},
);
let xml = original.to_xml();
assert!(
!xml.contains("<c:spPr>"),
"should not write spPr when format is None: {}",
xml
);
let parsed = Chart::parse_from_xml(&xml).expect("parse no-format chart");
assert!(
parsed.data.series[0].format.is_none(),
"format should remain None after round-trip"
);
}
#[test]
fn chart_combo_round_trip() {
let mut s1 = ChartSeries::new("Sales", vec![10.0, 20.0, 15.0]);
s1.secondary_axis = false;
let mut s2 = ChartSeries::new("Growth", vec![5.0, 8.0, 12.0]);
s2.secondary_axis = true;
let original = Chart::new(
ChartType::Column,
ChartData {
categories: vec![
ChartCategory::new("Q1"),
ChartCategory::new("Q2"),
ChartCategory::new("Q3"),
],
series: vec![s1, s2],
title: Some("Combo Chart".to_string()),
data_labels: None,
secondary_chart_type: Some(ChartType::Line),
..Default::default()
},
);
let xml = original.to_xml();
assert!(
xml.contains("<c:barChart>"),
"should contain primary c:barChart: {}",
xml
);
assert!(
xml.contains("<c:lineChart>"),
"should contain secondary c:lineChart: {}",
xml
);
assert!(
xml.contains(r#"<c:crosses val="max"/>"#),
"should contain secondary axis crosses=max: {}",
xml
);
let parsed = Chart::parse_from_xml(&xml).expect("parse combo chart");
assert_eq!(
parsed.data.secondary_chart_type,
Some(ChartType::Line),
"secondary_chart_type should round-trip"
);
assert_eq!(parsed.chart_type, ChartType::Column);
assert_eq!(parsed.data.series.len(), 2, "should have 2 series");
assert!(
!parsed.data.series[0].secondary_axis,
"series 0 (Sales) should be on primary axis"
);
assert!(
parsed.data.series[1].secondary_axis,
"series 1 (Growth) should be on secondary axis"
);
}
#[test]
fn chart_combo_series_distribution() {
let mut s1 = ChartSeries::new("Bar1", vec![1.0, 2.0]);
s1.secondary_axis = false;
let mut s2 = ChartSeries::new("Bar2", vec![3.0, 4.0]);
s2.secondary_axis = false;
let mut s3 = ChartSeries::new("Line1", vec![5.0, 6.0]);
s3.secondary_axis = true;
let chart = Chart::new(
ChartType::Column,
ChartData {
categories: vec![ChartCategory::new("A"), ChartCategory::new("B")],
series: vec![s1, s2, s3],
title: None,
data_labels: None,
secondary_chart_type: Some(ChartType::Line),
..Default::default()
},
);
let xml = chart.to_xml();
let ser_count = xml.matches("<c:ser>").count();
assert_eq!(ser_count, 3, "should have 3 c:ser elements total: {}", xml);
assert!(
xml.contains(r#"<c:axId val="444444444"/>"#),
"should contain secondary axId 444444444: {}",
xml
);
}
}