use super::{
Relationship, ZipEntry, append_entity, attr, is_chart_part, numfmt, read_zip_entry, rel_id,
resolve_part, scan_elements,
};
use crate::reader_output::{
CHART_EXTENDED, CHART_LABEL, CHART_NO_VALUES, CHART_TEXT, CHART_TITLE, CHART_UNREADABLE,
CHART_VALUES_ATTACHED, MARK_STORED, chart_excess, chart_header, chart_series, chart_size,
};
use crate::util::one_line;
use quick_xml::Reader;
use quick_xml::events::{BytesRef, BytesStart, Event};
use std::collections::{BTreeMap, HashMap};
use std::fmt::Write as _;
use std::io::{Read, Seek};
use zip::ZipArchive;
const MAX_CHARTS: usize = 64;
const MAX_SERIES: usize = 8;
const MAX_POINTS: usize = 25;
const MAX_TEXTS: usize = 6;
const MAX_DEPTH: usize = 128;
#[derive(Debug)]
pub(crate) enum ChartRead {
Content {
kind: Option<String>,
body: Vec<String>,
values: bool,
},
Unreadable(ChartLoss),
}
#[derive(Debug, Clone, Copy)]
pub(crate) enum ChartLoss {
Part,
Extended,
}
impl ChartLoss {
const fn mark(self) -> &'static str {
match self {
Self::Part => CHART_UNREADABLE,
Self::Extended => CHART_EXTENDED,
}
}
}
impl ChartRead {
pub(crate) fn described(&self) -> bool {
matches!(self, Self::Content { values: true, .. })
}
pub(crate) fn lines(&self) -> Vec<String> {
match self {
Self::Content { kind, body, .. } => {
let mut lines = vec![chart_header(kind.as_deref())];
lines.extend(body.iter().map(|line| format!(" {line}")));
lines
}
Self::Unreadable(loss) => vec![format!("{} {}", chart_header(None), loss.mark())],
}
}
}
pub(crate) fn read_charts<R: Read + Seek>(
archive: &mut ZipArchive<R>,
prefix: &str,
date1904: bool,
) -> HashMap<String, ChartRead> {
let parts: Vec<String> = archive
.file_names()
.filter(|name| is_chart_part(name, prefix))
.take(MAX_CHARTS)
.map(str::to_owned)
.collect();
parts
.into_iter()
.map(|part| {
let read = match read_zip_entry(archive, &part) {
ZipEntry::Bytes(xml) => read_part(&xml, date1904),
ZipEntry::Missing | ZipEntry::Unreadable => ChartRead::Unreadable(ChartLoss::Part),
};
(part, read)
})
.collect()
}
pub(crate) fn chart_parts(xml: &[u8], rels: &[Relationship], base: &str) -> Vec<String> {
let mut parts = Vec::new();
for id in scan_elements(xml, b"chart", rel_id).unwrap_or_default() {
let Some(rel) = rels.iter().find(|rel| rel.id == id) else {
continue;
};
let part = resolve_part(base, &rel.target);
if !parts.contains(&part) {
parts.push(part);
}
}
parts
}
pub(crate) fn lines(
charts: &HashMap<String, ChartRead>,
parts: &[String],
) -> (Vec<String>, Vec<String>) {
let mut lines = Vec::new();
let mut described = Vec::new();
for part in parts {
let Some(read) = charts.get(part) else {
continue;
};
if read.described() {
described.push(part.clone());
}
lines.extend(read.lines());
}
(lines, described)
}
fn read_part(xml: &[u8], date1904: bool) -> ChartRead {
let mut parser = Parser::new(date1904);
match parser.run(xml) {
Ok(()) => parser.finish(),
Err(loss) => ChartRead::Unreadable(loss),
}
}
#[derive(Default)]
struct Cache {
numeric: bool,
format: Option<String>,
points: BTreeMap<usize, String>,
more: usize,
}
#[derive(Default)]
struct Series {
name: Option<String>,
categories: Vec<Cache>,
values: Vec<Cache>,
sizes: Vec<Cache>,
}
impl Series {
fn slot(&mut self, slot: Slot) -> &mut Vec<Cache> {
match slot {
Slot::Category => &mut self.categories,
Slot::Value => &mut self.values,
Slot::Size => &mut self.sizes,
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Slot {
Category,
Value,
Size,
}
struct Capture {
depth: usize,
kind: CaptureKind,
text: String,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum CaptureKind {
Title,
Axis,
Label,
}
struct KindBuilder {
depth: usize,
kind: Kind,
bar_dir: Option<String>,
grouping: Option<String>,
of_pie: Option<String>,
}
impl KindBuilder {
fn name(&self) -> String {
let grouping = match self.grouping.as_deref() {
Some("stacked") => "stacked ",
Some("percentStacked") => "100% stacked ",
_ => "",
};
match self.kind {
Kind::Area => format!("{grouping}area chart"),
Kind::Area3D => format!("{grouping}3-D area chart"),
Kind::Bar => {
let bars = if self.bar_dir.as_deref() == Some("bar") {
"bar"
} else {
"column"
};
format!("{grouping}{bars} chart")
}
Kind::Bar3D => {
let bars = if self.bar_dir.as_deref() == Some("bar") {
"bar"
} else {
"column"
};
format!("{grouping}3-D {bars} chart")
}
Kind::Bubble => "bubble chart".to_owned(),
Kind::Doughnut => "doughnut chart".to_owned(),
Kind::Line => format!("{grouping}line chart"),
Kind::Line3D => format!("{grouping}3-D line chart"),
Kind::OfPie if self.of_pie.as_deref() == Some("bar") => "bar-of-pie chart".to_owned(),
Kind::OfPie => "pie-of-pie chart".to_owned(),
Kind::Pie => "pie chart".to_owned(),
Kind::Pie3D => "3-D pie chart".to_owned(),
Kind::Radar => "radar chart".to_owned(),
Kind::Scatter => "scatter chart".to_owned(),
Kind::Stock => "stock chart".to_owned(),
Kind::Surface => "surface chart".to_owned(),
Kind::Surface3D => "3-D surface chart".to_owned(),
}
}
}
#[derive(Clone, Copy)]
enum Kind {
Area,
Area3D,
Bar,
Bar3D,
Bubble,
Doughnut,
Line,
Line3D,
OfPie,
Pie,
Pie3D,
Radar,
Scatter,
Stock,
Surface,
Surface3D,
}
fn kind_of(name: &[u8]) -> Option<Kind> {
Some(match name {
b"areaChart" => Kind::Area,
b"area3DChart" => Kind::Area3D,
b"barChart" => Kind::Bar,
b"bar3DChart" => Kind::Bar3D,
b"bubbleChart" => Kind::Bubble,
b"doughnutChart" => Kind::Doughnut,
b"lineChart" => Kind::Line,
b"line3DChart" => Kind::Line3D,
b"ofPieChart" => Kind::OfPie,
b"pieChart" => Kind::Pie,
b"pie3DChart" => Kind::Pie3D,
b"radarChart" => Kind::Radar,
b"scatterChart" => Kind::Scatter,
b"stockChart" => Kind::Stock,
b"surfaceChart" => Kind::Surface,
b"surface3DChart" => Kind::Surface3D,
_ => return None,
})
}
struct Parser {
date1904: bool,
part_date1904: Option<bool>,
root: bool,
stack: Vec<Vec<u8>>,
kinds: Vec<String>,
kind: Option<KindBuilder>,
capture: Option<Capture>,
title: Option<String>,
texts: Vec<String>,
labels: Vec<String>,
more_texts: usize,
series: Option<Series>,
series_depth: Option<usize>,
series_done: Vec<Series>,
more_series: usize,
slot: Option<(Slot, usize)>,
point: Option<(usize, usize)>,
tx_depth: Option<usize>,
attached: bool,
}
impl Parser {
fn new(date1904: bool) -> Self {
Self {
date1904,
part_date1904: None,
root: false,
stack: Vec::new(),
kinds: Vec::new(),
kind: None,
capture: None,
title: None,
texts: Vec::new(),
labels: Vec::new(),
more_texts: 0,
series: None,
series_depth: None,
series_done: Vec::new(),
more_series: 0,
slot: None,
point: None,
tx_depth: None,
attached: false,
}
}
fn run(&mut self, xml: &[u8]) -> Result<(), ChartLoss> {
let mut reader = Reader::from_reader(xml);
let mut buffer = Vec::new();
loop {
match reader.read_event_into(&mut buffer) {
Ok(Event::Start(event)) => {
let name = event.local_name().as_ref().to_vec();
self.stack.push(name.clone());
self.start(&event, &name)?;
}
Ok(Event::Empty(event)) => {
let name = event.local_name().as_ref().to_vec();
self.stack.push(name.clone());
self.start(&event, &name)?;
self.stack.pop();
self.end();
}
Ok(Event::Text(event)) => {
if let Ok(text) = event.xml10_content() {
self.text(&text);
}
}
Ok(Event::CData(event)) => {
if let Ok(text) = event.xml10_content() {
self.text(&text);
}
}
Ok(Event::GeneralRef(reference)) => self.entity(&reference),
Ok(Event::End(_)) => {
self.stack.pop();
self.end();
}
Ok(Event::Eof) => break,
Err(_) => return Err(ChartLoss::Part),
Ok(_) => {}
}
buffer.clear();
}
if !self.root || !self.stack.is_empty() {
return Err(ChartLoss::Part);
}
Ok(())
}
fn ancestor(&self, back: usize) -> Option<&[u8]> {
let index = self.stack.len().checked_sub(back)?;
self.stack.get(index).map(Vec::as_slice)
}
fn start(&mut self, event: &BytesStart<'_>, name: &[u8]) -> Result<(), ChartLoss> {
let depth = self.stack.len();
if depth > MAX_DEPTH {
return Err(ChartLoss::Part);
}
if depth == 1 {
if name != b"chartSpace" {
return Err(ChartLoss::Part);
}
if event.name().prefix().is_some_and(|p| p.as_ref() == b"cx") {
return Err(ChartLoss::Extended);
}
self.root = true;
}
match name {
b"chartData" => return Err(ChartLoss::Extended),
b"title" => {
let kind = if self.ancestor(2) == Some(b"chart") {
CaptureKind::Title
} else {
CaptureKind::Axis
};
if self.capture.is_none() {
self.capture = Some(Capture {
depth,
kind,
text: String::new(),
});
}
}
b"p" | b"br" => self.separate(),
b"ser" if self.series.is_none() => {
if self.series_done.len() < MAX_SERIES {
self.series = Some(Series::default());
self.series_depth = Some(depth);
} else {
self.more_series += 1;
}
}
b"tx" => match self.ancestor(2) {
Some(b"ser") => self.tx_depth = Some(depth),
Some(b"dLbl") if self.capture.is_none() => {
self.capture = Some(Capture {
depth,
kind: CaptureKind::Label,
text: String::new(),
});
}
_ => {}
},
b"cat" | b"val" | b"xVal" | b"yVal" | b"bubbleSize" => {
if self.series.is_some() {
let slot = match name {
b"cat" | b"xVal" => Slot::Category,
b"val" | b"yVal" => Slot::Value,
_ => Slot::Size,
};
self.slot = Some((slot, depth));
}
}
b"numCache" | b"strCache" | b"numLit" | b"strLit" => {
self.open_cache(matches!(name, b"numCache" | b"numLit"));
}
b"lvl" => self.open_cache(false),
b"pt" => {
if self.slot.is_some()
&& let Some(index) = attr(event, b"idx").and_then(|idx| idx.trim().parse().ok())
{
self.keep_point(index, depth);
}
}
b"externalData" => self.attached = true,
b"date1904" => {
self.part_date1904 =
Some(matches!(attr(event, b"val").as_deref(), Some("1" | "true")));
}
b"barDir" | b"grouping" | b"ofPieType" => {
if let Some(kind) = self.kind.as_mut() {
let value = attr(event, b"val");
match name {
b"barDir" => kind.bar_dir = value,
b"grouping" => kind.grouping = value,
_ => kind.of_pie = value,
}
}
}
_ => {
if self.ancestor(2) == Some(b"plotArea")
&& let Some(kind) = kind_of(name)
{
self.kind = Some(KindBuilder {
depth,
kind,
bar_dir: None,
grouping: None,
of_pie: None,
});
}
}
}
Ok(())
}
fn open_cache(&mut self, numeric: bool) {
let Some((slot, _)) = self.slot else {
return;
};
let Some(series) = self.series.as_mut() else {
return;
};
series.slot(slot).push(Cache {
numeric,
..Cache::default()
});
}
fn keep_point(&mut self, index: usize, depth: usize) {
let Some((slot, _)) = self.slot else {
return;
};
let kept = match self
.series
.as_mut()
.and_then(|series| series.slot(slot).last_mut())
{
Some(cache) if cache.points.contains_key(&index) || cache.points.len() < MAX_POINTS => {
true
}
Some(cache) => {
cache.more += 1;
false
}
None => false,
};
if kept {
self.point = Some((index, depth));
}
}
fn separate(&mut self) {
if let Some(capture) = self.capture.as_mut() {
push_break(&mut capture.text);
} else if self.tx_depth.is_some()
&& let Some(series) = self.series.as_mut()
&& let Some(name) = series.name.as_mut()
{
push_break(name);
}
}
fn target(&mut self) -> Option<&mut String> {
let depth = self.stack.len();
let leaf = self.stack.last()?.as_slice();
if let Some(capture) = self.capture.as_mut()
&& capture.depth < depth
{
return matches!(leaf, b"t" | b"v").then_some(&mut capture.text);
}
if let Some(tx) = self.tx_depth
&& tx < depth
{
if !matches!(leaf, b"t" | b"v") {
return None;
}
let series = self.series.as_mut()?;
return Some(series.name.get_or_insert_with(String::new));
}
if leaf == b"v"
&& let (Some((index, _)), Some((slot, _))) = (self.point, self.slot)
{
let series = self.series.as_mut()?;
let cache = series.slot(slot).last_mut()?;
return Some(cache.points.entry(index).or_default());
}
match leaf {
b"formatCode" => {
let (slot, _) = self.slot?;
let series = self.series.as_mut()?;
let cache = series.slot(slot).last_mut()?;
Some(cache.format.get_or_insert_with(String::new))
}
_ => None,
}
}
fn text(&mut self, text: &str) {
if let Some(target) = self.target() {
target.push_str(text);
}
}
fn text_read(&mut self, kind: CaptureKind, text: String) {
if kind == CaptureKind::Title {
self.title = Some(text);
return;
}
if self.texts.len() + self.labels.len() >= MAX_TEXTS {
self.more_texts += 1;
return;
}
if kind == CaptureKind::Axis {
self.texts.push(text);
} else {
self.labels.push(text);
}
}
fn entity(&mut self, reference: &BytesRef<'_>) {
if let Some(target) = self.target() {
append_entity(target, reference);
}
}
fn end(&mut self) {
let depth = self.stack.len();
if self
.capture
.as_ref()
.is_some_and(|capture| capture.depth == depth + 1)
&& let Some(capture) = self.capture.take()
{
let text = one_line(capture.text.trim().to_owned());
if !text.is_empty() {
self.text_read(capture.kind, text);
}
}
if self.tx_depth == Some(depth + 1) {
self.tx_depth = None;
}
if self
.slot
.as_ref()
.is_some_and(|(_, slot_depth)| *slot_depth == depth + 1)
{
self.slot = None;
}
if self
.point
.as_ref()
.is_some_and(|(_, point_depth)| *point_depth == depth + 1)
{
self.point = None;
}
if self
.kind
.as_ref()
.is_some_and(|kind| kind.depth == depth + 1)
&& let Some(kind) = self.kind.take()
{
let name = kind.name();
if !self.kinds.contains(&name) {
self.kinds.push(name);
}
}
if self.series_depth == Some(depth + 1) {
self.series_depth = None;
if let Some(mut series) = self.series.take() {
series.name = series.name.map(one_line).filter(|name| !name.is_empty());
self.series_done.push(series);
}
}
}
fn finish(self) -> ChartRead {
let date1904 = self.part_date1904.unwrap_or(self.date1904);
let kind = (!self.kinds.is_empty()).then(|| self.kinds.join(" + "));
let mut body = Vec::new();
if let Some(title) = &self.title {
body.push(format!("{CHART_TITLE} {title}"));
}
for text in &self.texts {
body.push(format!("{CHART_TEXT} {text}"));
}
for label in &self.labels {
body.push(format!("{CHART_LABEL} {label}"));
}
if self.more_texts > 0 {
body.push(chart_excess(self.more_texts, "text(s)"));
}
let mut described = false;
for (index, series) in self.series_done.iter().enumerate() {
let (line, values) = series_line(index, series, date1904, self.attached);
described |= values;
body.push(line);
}
if self.more_series > 0 {
body.push(chart_excess(self.more_series, "series"));
}
if self.series_done.is_empty() {
body.push(note(self.attached).to_owned());
}
ChartRead::Content {
kind,
body,
values: described,
}
}
}
const fn note(attached: bool) -> &'static str {
if attached {
CHART_VALUES_ATTACHED
} else {
CHART_NO_VALUES
}
}
fn push_break(text: &mut String) {
if !text.is_empty() && !text.ends_with(' ') {
text.push(' ');
}
}
fn series_line(index: usize, series: &Series, date1904: bool, attached: bool) -> (String, bool) {
let label = chart_series(series.name.as_deref(), index);
let mut stored = false;
let Some(values) = series
.values
.first()
.filter(|cache| !cache.points.is_empty())
else {
let mut categories = Vec::new();
for point_index in series
.categories
.first()
.map(|cache| cache.points.keys())
.into_iter()
.flatten()
{
let (category, category_stored) =
category_label(&series.categories, *point_index, date1904);
stored |= category_stored;
categories.extend(category);
}
let mut line = label;
if !categories.is_empty() {
let _ = write!(line, " {}", categories.join(", "));
}
let _ = write!(line, " {}", note(attached));
if stored {
line.push_str(MARK_STORED);
}
return (line, false);
};
let mut points = Vec::new();
for point_index in values.points.keys() {
let Some((value, value_stored)) = point_text(values, *point_index, date1904) else {
continue;
};
stored |= value_stored;
let (category, category_stored) =
category_label(&series.categories, *point_index, date1904);
stored |= category_stored;
let mut point = match category {
Some(category) => format!("{category}: {value}"),
None => value,
};
if let Some((size, size_stored)) = series
.sizes
.first()
.and_then(|cache| point_text(cache, *point_index, date1904))
{
stored |= size_stored;
let _ = write!(point, " {}", chart_size(&size));
}
points.push(point);
}
let mut line = format!("{label} {}", points.join(", "));
if values.more > 0 {
let _ = write!(line, ", {}", chart_excess(values.more, "value(s)"));
}
if stored {
line.push_str(MARK_STORED);
}
(line, true)
}
fn point_text(cache: &Cache, index: usize, date1904: bool) -> Option<(String, bool)> {
let stored = cache.points.get(&index)?;
let written = || one_line(stored.trim().to_owned());
if !cache.numeric {
return Some((written(), false));
}
let format = cache.format.as_deref();
if numfmt::is_general(0, format) {
return Some((written(), false));
}
let display = stored
.trim()
.parse::<f64>()
.ok()
.and_then(|value| numfmt::display(value, 0, format, date1904));
Some(display.map_or_else(|| (written(), true), |text| (text, false)))
}
fn category_label(levels: &[Cache], index: usize, date1904: bool) -> (Option<String>, bool) {
let Some((innermost, coarse)) = levels.split_first() else {
return (None, false);
};
let mut parts: Vec<String> = Vec::new();
let mut stored = false;
for level in coarse.iter().rev() {
if let Some((group, _)) = level.points.range(..=index).next_back()
&& let Some((text, level_stored)) = point_text(level, *group, date1904)
&& !text.is_empty()
{
stored |= level_stored;
parts.push(text);
}
}
if let Some((text, level_stored)) = point_text(innermost, index, date1904)
&& !text.is_empty()
{
stored |= level_stored;
parts.push(text);
}
if parts.is_empty() {
(None, stored)
} else {
(Some(parts.join(" / ")), stored)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn chart_space(plot: &str, chart: &str, space: &str) -> String {
format!(
r#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">{space}<c:chart>{chart}<c:plotArea>{plot}</c:plotArea></c:chart></c:chartSpace>"#
)
}
fn series(name: &str, categories: &[&str], values: &[&str]) -> String {
let categories = cache("strCache", None, categories);
let values = cache("numCache", None, values);
format!(
r#"<c:ser><c:tx><c:strRef><c:f>Sheet1!$B$1</c:f><c:strCache><c:pt idx="0"><c:v>{name}</c:v></c:pt></c:strCache></c:strRef></c:tx><c:cat>{categories}</c:cat><c:val>{values}</c:val></c:ser>"#
)
}
fn cache(kind: &str, format: Option<&str>, points: &[&str]) -> String {
let format = format.map_or_else(String::new, |format| {
format!("<c:formatCode>{format}</c:formatCode>")
});
let mut cached = String::new();
for (index, value) in points.iter().enumerate() {
let _ = write!(cached, r#"<c:pt idx="{index}"><c:v>{value}</c:v></c:pt>"#);
}
format!(r"<c:{kind}>{format}{cached}</c:{kind}>")
}
fn lines_of(xml: &str) -> Vec<String> {
read_part(xml.as_bytes(), false).lines()
}
#[test]
fn a_chart_is_described_by_its_kind_title_and_series() {
let plot = format!(
r#"<c:barChart><c:barDir val="col"/><c:grouping val="clustered"/><c:ser><c:tx><c:strRef><c:f>Sheet1!$B$1</c:f><c:strCache><c:pt idx="0"><c:v>Revenue</c:v></c:pt></c:strCache></c:strRef></c:tx><c:cat>{}</c:cat><c:val>{}</c:val></c:ser>{}</c:barChart><c:catAx><c:title><c:tx><c:rich><a:p><a:r><a:t>Month</a:t></a:r></a:p></c:rich></c:tx></c:title></c:catAx>"#,
cache("strCache", None, &["Jan", "Feb"]),
cache("numCache", Some("General"), &["10", "20"]),
series("Cost", &["Jan", "Feb"], &["4", "8"]),
);
let title = r"<c:title><c:tx><c:rich><a:p><a:r><a:t>Sales by region</a:t></a:r></a:p></c:rich></c:tx></c:title>";
let xml = chart_space(&plot, title, "");
assert_eq!(
lines_of(&xml),
[
"Chart (column chart):",
" Title: Sales by region",
" Text: Month",
" Series \"Revenue\": Jan: 10, Feb: 20",
" Series \"Cost\": Jan: 4, Feb: 8",
]
);
}
#[test]
fn rich_text_paragraphs_and_data_labels_are_kept_apart() {
let plot = format!(
r#"<c:barChart><c:barDir val="col"/><c:ser>{}</c:ser><c:dLbls><c:dLbl><c:tx><c:rich><a:p><a:r><a:t>Peak</a:t></a:r></a:p></c:rich></c:tx></c:dLbl></c:dLbls></c:barChart>"#,
r#"<c:val><c:numCache><c:pt idx="0"><c:v>10</c:v></c:pt></c:numCache></c:val>"#,
);
let title = r"<c:title><c:tx><c:rich><a:p><a:r><a:t>Sales</a:t></a:r></a:p><a:p><a:r><a:t>by region</a:t></a:r></a:p></c:rich></c:tx></c:title>";
assert_eq!(
lines_of(&chart_space(&plot, title, "")),
[
"Chart (column chart):",
" Title: Sales by region",
" Label: Peak",
" Series 1: 10",
]
);
}
#[test]
fn a_cached_value_keeps_its_own_number_format() {
let dates = cache("numCache", Some("mmm-yy"), &["45200", "45231"]);
let shares = cache("numCache", Some("0.00%"), &["0.25", "0.5"]);
let plot = format!(
r#"<c:lineChart><c:ser><c:tx><c:strRef><c:strCache><c:pt idx="0"><c:v>Sales</c:v></c:pt></c:strCache></c:strRef></c:tx><c:cat>{dates}</c:cat><c:val>{shares}</c:val></c:ser></c:lineChart>"#
);
assert_eq!(
lines_of(&chart_space(&plot, "", "")),
[
"Chart (line chart):",
" Series \"Sales\": 2023-10-01: 25.00%, 2023-11-01: 50.00%",
]
);
let values = cache("numCache", Some("# ?/?"), &["1.5"]);
let plot = format!(
r#"<c:pieChart><c:ser><c:tx><c:strRef><c:strCache><c:pt idx="0"><c:v>Share</c:v></c:pt></c:strCache></c:strRef></c:tx><c:val>{values}</c:val></c:ser></c:pieChart>"#
);
assert_eq!(
lines_of(&chart_space(&plot, "", "")),
[
"Chart (pie chart):",
" Series \"Share\": 1.5 (stored number, format not shown)",
]
);
}
#[test]
fn kinds_names_and_scatter_are_all_printed() {
let plot = format!(
r#"<c:barChart><c:barDir val="bar"/><c:grouping val="stacked"/><c:ser><c:cat>{}</c:cat><c:val>{}</c:val></c:ser></c:barChart><c:scatterChart><c:ser><c:tx><c:strRef><c:strCache><c:pt idx="0"><c:v>Points</c:v></c:pt></c:strCache></c:strRef></c:tx><c:xVal>{}</c:xVal><c:yVal>{}</c:yVal></c:ser></c:scatterChart>"#,
cache("strCache", None, &["A"]),
cache("numCache", None, &["1"]),
cache("numCache", None, &["1", "2"]),
cache("numCache", None, &["10", "20"]),
);
assert_eq!(
lines_of(&chart_space(&plot, "", "")),
[
"Chart (stacked bar chart + scatter chart):",
" Series 1: A: 1",
" Series \"Points\": 1: 10, 2: 20",
]
);
assert_eq!(
lines_of(&chart_space("", "", "")),
["Chart:", " (no values in the chart part)"]
);
}
#[test]
fn multi_level_categories_and_bubble_sizes_are_printed() {
let two_levels = format!(
r"<c:multiLvlStrRef><c:multiLvlStrCache>{}{}</c:multiLvlStrCache></c:multiLvlStrRef>",
cache("lvl", None, &["Q1", "Q2"]),
cache("lvl", None, &["East", "West"]),
);
let spanning = format!(
r"<c:multiLvlStrRef><c:multiLvlStrCache>{}{}</c:multiLvlStrCache></c:multiLvlStrRef>",
cache("lvl", None, &["Q1", "Q2"]),
cache("lvl", None, &["East"]),
);
let sizes = cache("numCache", None, &["5", "7"]);
let values = cache("numCache", None, &["10", "20"]);
let plot = |categories: &str| {
format!(
r"<c:bubbleChart><c:ser><c:cat>{categories}</c:cat><c:val>{values}</c:val><c:bubbleSize>{sizes}</c:bubbleSize></c:ser></c:bubbleChart>"
)
};
assert_eq!(
lines_of(&chart_space(&plot(&two_levels), "", "")),
[
"Chart (bubble chart):",
" Series 1: East / Q1: 10 (size 5), West / Q2: 20 (size 7)",
]
);
assert_eq!(
lines_of(&chart_space(&plot(&spanning), "", "")),
[
"Chart (bubble chart):",
" Series 1: East / Q1: 10 (size 5), East / Q2: 20 (size 7)",
]
);
}
#[test]
fn a_cut_part_is_a_loss() {
let cut = r#"<c:chartSpace xmlns:c="x"><c:chart><c:plotArea><c:barChart><c:ser><c:tx><c:strRef><c:strCache><c:pt idx="0"><c:v>Revenue</c:v>"#;
assert_eq!(
read_part(cut.as_bytes(), false).lines(),
["Chart: (the chart part could not be read)"]
);
}
#[test]
fn a_wild_point_index_allocates_nothing() {
let wild = r#"<c:chartSpace xmlns:c="x"><c:chart><c:plotArea><c:barChart><c:ser><c:val><c:numCache><c:pt idx="18446744073709551615"><c:v>1</c:v></c:pt></c:numCache></c:val></c:ser></c:barChart></c:plotArea></c:chart></c:chartSpace>"#;
assert_eq!(
read_part(wild.as_bytes(), false).lines(),
["Chart (column chart):", " Series 1: 1"]
);
}
#[test]
fn a_part_nested_past_the_bound_is_a_loss() {
let deep = format!(
r#"<c:chartSpace xmlns:c="x"><c:chart><c:plotArea><c:barChart>{}{}</c:barChart></c:plotArea></c:chart></c:chartSpace>"#,
"<c:x>".repeat(MAX_DEPTH),
"</c:x>".repeat(MAX_DEPTH),
);
assert_eq!(
read_part(deep.as_bytes(), false).lines(),
["Chart: (the chart part could not be read)"]
);
}
#[test]
fn a_chart_without_cached_values_says_where_its_numbers_are() {
let plot = r#"<c:barChart><c:barDir val="col"/><c:ser><c:tx><c:strRef><c:f>Sheet1!$B$1</c:f></c:strRef></c:tx><c:val><c:numRef><c:f>Sheet1!$B$2:$B$3</c:f></c:numRef></c:val></c:ser></c:barChart>"#;
assert_eq!(
lines_of(&chart_space(plot, "", "")),
[
"Chart (column chart):",
" Series 1: (no values in the chart part)",
]
);
let external = r#"<c:externalData r:id="rId1"/>"#;
assert_eq!(
lines_of(&chart_space(plot, "", external)),
[
"Chart (column chart):",
" Series 1: (values only in the attached table)",
]
);
let title = r"<c:title><c:tx><c:rich><a:p><a:r><a:t>Sales</a:t></a:r></a:p></c:rich></c:tx></c:title>";
let named = r#"<c:barChart><c:barDir val="col"/><c:ser><c:tx><c:strRef><c:f>Sheet1!$B$1</c:f><c:strCache><c:pt idx="0"><c:v>Revenue</c:v></c:pt></c:strCache></c:strRef></c:tx><c:val><c:numRef><c:f>Sheet1!$B$2:$B$3</c:f></c:numRef></c:val></c:ser></c:barChart>"#;
assert_eq!(
lines_of(&chart_space(named, title, external)),
[
"Chart (column chart):",
" Title: Sales",
" Series \"Revenue\": (values only in the attached table)",
]
);
let categories = cache("strCache", None, &["Jan", "Feb"]);
let labelled = format!(
r#"<c:barChart><c:barDir val="col"/><c:ser><c:cat>{categories}</c:cat><c:val><c:numRef><c:f>Sheet1!$B$2:$B$3</c:f></c:numRef></c:val></c:ser></c:barChart>"#
);
assert_eq!(
lines_of(&chart_space(&labelled, "", "")),
[
"Chart (column chart):",
" Series 1: Jan, Feb (no values in the chart part)",
]
);
let uncached = r"<c:ser><c:tx><c:strRef><c:f>Sheet1!$C$1</c:f></c:strRef></c:tx><c:val><c:numRef><c:f>Sheet1!$C$2:$C$3</c:f></c:numRef></c:val></c:ser>";
let mixed = format!(
r#"<c:barChart><c:barDir val="col"/><c:ser><c:tx><c:strRef><c:strCache><c:pt idx="0"><c:v>Revenue</c:v></c:pt></c:strCache></c:strRef></c:tx><c:val><c:numCache><c:pt idx="0"><c:v>10</c:v></c:pt></c:numCache></c:val></c:ser>{uncached}</c:barChart>"#
);
assert_eq!(
lines_of(&chart_space(&mixed, "", "")),
[
"Chart (column chart):",
" Series \"Revenue\": 10",
" Series 2: (no values in the chart part)",
]
);
let read = read_part(chart_space(plot, "", "").as_bytes(), false);
assert!(!read.described());
}
#[test]
fn parts_of_another_schema_are_said_not_read() {
assert_eq!(
read_part(b"<chart/>", false).lines(),
["Chart: (the chart part could not be read)"]
);
assert_eq!(
read_part(
br#"<cx:chartSpace xmlns:cx="http://schemas.microsoft.com/office/drawing/2014/chartex"><cx:chartData/></cx:chartSpace>"#,
false
)
.lines(),
["Chart: (an extended chart kind, not read)"]
);
}
#[test]
fn the_description_is_bounded_and_says_what_it_left_out() {
let values: Vec<String> = (0..(MAX_POINTS + 3)).map(|i| i.to_string()).collect();
let values: Vec<&str> = values.iter().map(String::as_str).collect();
let sers: String = (0..(MAX_SERIES + 2))
.map(|i| series(&format!("S{i}"), &[], &values))
.collect();
let axes = (0..=MAX_TEXTS).fold(String::new(), |mut axes, i| {
let _ = write!(
axes,
r"<c:catAx><c:title><c:tx><c:rich><a:p><a:r><a:t>T{i}</a:t></a:r></a:p></c:rich></c:tx></c:title></c:catAx>"
);
axes
});
let plot = format!(r"<c:lineChart>{sers}{axes}</c:lineChart>");
let lines = lines_of(&chart_space(&plot, "", ""));
assert_eq!(lines[0], "Chart (line chart):");
assert_eq!(lines.len(), 1 + MAX_TEXTS + 1 + MAX_SERIES + 1);
assert_eq!(lines[MAX_TEXTS + 1], format!(" {} more text(s)", 1));
assert_eq!(lines.last().map(String::as_str), Some(" 2 more series"));
assert!(
lines[MAX_TEXTS + 2].ends_with(", 3 more value(s)"),
"{:?}",
lines[MAX_TEXTS + 2]
);
}
#[test]
fn the_marks_the_kit_refuses_are_the_ones_a_reading_prints() {
let patterns: Vec<regex::Regex> = crate::docgen::docx_marks()
.iter()
.map(|mark| regex::Regex::new(&mark.pattern).expect("a mark's own pattern"))
.collect();
for mark in [
chart_header(Some("column chart")),
chart_series(Some("Revenue"), 0),
chart_size("12"),
chart_excess(3, "value(s)"),
chart_excess(1, "series"),
chart_excess(1, "text(s)"),
MARK_STORED.trim().to_owned(),
CHART_NO_VALUES.to_owned(),
CHART_VALUES_ATTACHED.to_owned(),
CHART_UNREADABLE.to_owned(),
CHART_EXTENDED.to_owned(),
] {
assert!(
patterns.iter().any(|pattern| pattern.is_match(&mark)),
"{mark:?} is a mark a chart reading prints, which no entry of the shared list \
names — a mark the kit refuses belongs in assets/docgen/rules.json's docx_marks"
);
}
assert!(
!patterns
.iter()
.any(|pattern| pattern.is_match(&chart_header(None))),
"a bare `Chart:` is a document's own word, never a refusal"
);
}
#[test]
fn chart_references_resolve_in_part_order() {
let xml = br#"<p:sld><p:graphicFrame><a:graphic><a:graphicData><c:chart r:id="rId2"/></a:graphicData></a:graphic></p:graphicFrame><p:graphicFrame><a:graphic><a:graphicData><c:chart r:id="rId1"/></a:graphicData></a:graphic></p:graphicFrame><p:graphicFrame><a:graphic><a:graphicData><c:chart r:id="rId2"/></a:graphicData></a:graphic></p:graphicFrame>"#;
let rels = vec![
Relationship {
id: "rId1".to_owned(),
kind: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart"
.to_owned(),
target: "../charts/chart1.xml".to_owned(),
},
Relationship {
id: "rId2".to_owned(),
kind: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart"
.to_owned(),
target: "../charts/chart2.xml".to_owned(),
},
];
assert_eq!(
chart_parts(xml, &rels, "ppt/slides/"),
["ppt/charts/chart2.xml", "ppt/charts/chart1.xml"]
);
}
}