// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 ALGFORCE INC.
use anyhow::Result;
use serde_json::Value;
use uuid::Uuid;
use crate::{get_chart_font_for_locale, get_chart_label_font_size};
fn parse_series_numeric_value(value: &Value) -> Option<f64> {
value.as_f64().or_else(|| {
value.get("value").and_then(|inner| {
inner
.as_f64()
.or_else(|| inner.as_str()?.parse::<f64>().ok())
})
})
}
fn parse_series_role(value: &Value) -> Option<String> {
value
.get("algRole")
.and_then(|role| role.as_str())
.or_else(|| value.get("role").and_then(|role| role.as_str()))
.map(|role| role.to_lowercase())
}
/// 自定义主题颜色
pub struct ThemeColors {
/// 内置的常用主题色
pub static_colors: [&'static str; 6],
}
impl Default for ThemeColors {
fn default() -> Self {
Self {
static_colors: [
"#4765FF", "#6982FF", "#8BA2FF", "#ADC6FF", "#D6E4FF", "#F0F5FF",
],
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum ChartType {
Column, // 纵向柱状图
Bar, // 横向条形图
Line,
Pie,
Radar,
Scatter,
Bubble,
Waterfall,
}
#[derive(Debug, Clone)]
pub struct ChartData {
pub category: String,
pub value: f64,
pub x_value: Option<f64>,
pub bubble_size: Option<f64>,
pub role: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Series {
pub name: String,
pub chart_type: ChartType,
pub data: Vec<ChartData>,
pub use_secondary_axis: bool,
pub is_stacked: bool,
pub is_area: bool,
}
/// 表示一个图表(支持多个系列)
#[derive(Debug, Clone)]
pub struct Chart {
pub title: String,
pub series_list: Vec<Series>,
pub style: Option<u32>,
pub colors: Option<Vec<String>>,
pub has_secondary_axis: bool,
pub x_axis_name: Option<String>,
pub y_axis_name: Option<String>,
pub secondary_y_axis_name: Option<String>,
pub theme: Option<Value>,
pub default_font: String,
pub locale: String,
}
impl Chart {
pub fn new(title: String) -> Self {
Self {
title,
series_list: Vec::new(),
style: Some(1),
colors: None,
has_secondary_axis: false,
x_axis_name: None,
y_axis_name: None,
secondary_y_axis_name: None,
theme: None,
default_font: "Microsoft YaHei UI Light".to_string(),
locale: "en".to_string(),
}
}
/// 根据 locale 设置默认字体
pub fn with_locale(mut self, locale: String) -> Self {
self.locale = locale.clone();
self.default_font = get_chart_font_for_locale(&locale);
self
}
/// 设置默认字体
pub fn with_font(mut self, font: String) -> Self {
self.default_font = font;
self
}
pub fn set_colors(&mut self, colors: Vec<String>) {
self.colors = Some(colors);
}
fn as_xlsx_overlay_chart(&self) -> xlsxcraft::Chart {
use xlsxcraft::{Chart as XlsxChart, ChartData as XlsxChartData};
use xlsxcraft::{ChartType as XlsxChartType, Series as XlsxSeries};
let mut chart = XlsxChart::new(self.title.clone()).with_locale(self.locale.clone());
chart.default_font = self.default_font.clone();
chart.style = self.style;
chart.colors = self.colors.clone();
chart.has_secondary_axis = self.has_secondary_axis;
chart.x_axis_name = self.x_axis_name.clone();
chart.y_axis_name = self.y_axis_name.clone();
chart.secondary_y_axis_name = self.secondary_y_axis_name.clone();
chart.theme = self.theme.clone();
chart.series_list = self
.series_list
.iter()
.map(|series| XlsxSeries {
name: series.name.clone(),
chart_type: match series.chart_type {
ChartType::Column => XlsxChartType::Column,
ChartType::Bar => XlsxChartType::Bar,
ChartType::Line => XlsxChartType::Line,
ChartType::Pie => XlsxChartType::Pie,
ChartType::Radar => XlsxChartType::Radar,
ChartType::Scatter => XlsxChartType::Scatter,
ChartType::Bubble => XlsxChartType::Bubble,
ChartType::Waterfall => XlsxChartType::Waterfall,
},
data: series
.data
.iter()
.map(|point| XlsxChartData {
category: point.category.clone(),
value: point.value,
x_value: point.x_value,
bubble_size: point.bubble_size,
role: point.role.clone(),
})
.collect(),
use_secondary_axis: series.use_secondary_axis,
is_stacked: series.is_stacked,
is_area: series.is_area,
})
.collect();
chart
}
pub fn has_chart_user_shapes(&self) -> bool {
!self.uses_chart_ex() && self.as_xlsx_overlay_chart().has_chart_user_shapes()
}
pub fn generate_chart_user_shapes_xml(&self) -> Option<String> {
self.has_chart_user_shapes()
.then(|| {
self.as_xlsx_overlay_chart()
// wp:extent is 6.0 x 3.5 inches; pass its 96-DPI canvas so
// annotation backgrounds track text width rather than XLSX size.
.generate_chart_user_shapes_xml_with_size(576.0, 336.0)
})
.flatten()
}
fn is_waterfall_theme(option: &Value, theme: Option<&Value>) -> bool {
theme
.and_then(|theme| theme.get("chartType"))
.and_then(|chart_type| chart_type.as_str())
.or_else(|| {
option
.get("theme")
.and_then(|theme| theme.get("chartType"))
.and_then(|chart_type| chart_type.as_str())
})
.or_else(|| {
option
.get("chartType")
.and_then(|chart_type| chart_type.as_str())
})
== Some("waterfall")
}
pub fn uses_chart_ex(&self) -> bool {
self.series_list.len() == 1
&& self
.series_list
.first()
.map(|series| series.chart_type == ChartType::Waterfall)
.unwrap_or(false)
}
pub fn chart_part_name(&self, chart_index: usize) -> String {
if self.uses_chart_ex() {
format!("chartEx{}.xml", chart_index)
} else {
format!("chart{}.xml", chart_index)
}
}
pub fn chart_style_part_name(&self, chart_index: usize) -> String {
format!("style{}.xml", chart_index)
}
pub fn chart_color_part_name(&self, chart_index: usize) -> String {
format!("colors{}.xml", chart_index)
}
pub fn chart_content_type(&self) -> &'static str {
if self.uses_chart_ex() {
"application/vnd.ms-office.chartex+xml"
} else {
"application/vnd.openxmlformats-officedocument.drawingml.chart+xml"
}
}
fn waterfall_subtotal_indexes(&self, series: &Series) -> Vec<usize> {
let mut indexes: Vec<usize> = series
.data
.iter()
.enumerate()
.filter_map(|(index, point)| match point.role.as_deref() {
Some("total") | Some("subtotal") => Some(index),
_ => None,
})
.collect();
if indexes.is_empty() && !series.data.is_empty() {
indexes.push(0);
}
indexes.sort_unstable();
indexes.dedup();
indexes
}
fn generate_chart_ex_xml(&self) -> Result<String> {
let Some(series) = self.series_list.first() else {
return Err(anyhow::anyhow!("waterfall chart requires a series"));
};
let unique_id = format!(
"{{{}}}",
Uuid::new_v4().as_hyphenated().to_string().to_uppercase()
);
let show_data_labels = should_show_data_labels(self, &ChartType::Waterfall);
let show_grid_lines = self
.theme
.as_ref()
.and_then(|theme| theme.get("waterfallStyle"))
.and_then(|style| style.get("showGridLines"))
.and_then(|value| value.as_bool())
.unwrap_or(true);
let mut xml = String::new();
xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>");
xml.push_str(
"<cx:chartSpace xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" ",
);
xml.push_str(
"xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\" ",
);
xml.push_str("xmlns:cx=\"http://schemas.microsoft.com/office/drawing/2014/chartex\">");
xml.push_str(
"<cx:chartData><cx:externalData r:id=\"rId1\" cx:autoUpdate=\"0\" /><cx:data id=\"0\">",
);
xml.push_str("<cx:strDim type=\"cat\"><cx:f>Sheet1!$A$2:$A$");
xml.push_str(&(series.data.len() + 1).to_string());
xml.push_str("</cx:f>");
xml.push_str(&format!("<cx:lvl ptCount=\"{}\">", series.data.len()));
for (index, point) in series.data.iter().enumerate() {
xml.push_str(&format!(
"<cx:pt idx=\"{}\">{}</cx:pt>",
index,
escape_xml(&point.category)
));
}
xml.push_str("</cx:lvl></cx:strDim>");
xml.push_str("<cx:numDim type=\"val\"><cx:f>Sheet1!$B$2:$B$");
xml.push_str(&(series.data.len() + 1).to_string());
xml.push_str("</cx:f>");
xml.push_str(&format!(
"<cx:lvl ptCount=\"{}\" formatCode=\"General\">",
series.data.len()
));
for (index, point) in series.data.iter().enumerate() {
xml.push_str(&format!("<cx:pt idx=\"{}\">{}</cx:pt>", index, point.value));
}
xml.push_str("</cx:lvl></cx:numDim>");
xml.push_str("</cx:data></cx:chartData>");
xml.push_str("<cx:chart>");
if !self.title.is_empty() {
xml.push_str("<cx:title pos=\"t\" align=\"ctr\" overlay=\"0\"><cx:tx><cx:txData>");
xml.push_str(&format!("<cx:v>{}</cx:v>", escape_xml(&self.title)));
xml.push_str("</cx:txData></cx:tx></cx:title>");
} else {
xml.push_str("<cx:title pos=\"t\" align=\"ctr\" overlay=\"0\" />");
}
xml.push_str("<cx:plotArea><cx:plotAreaRegion>");
xml.push_str(&format!(
"<cx:series layoutId=\"waterfall\" uniqueId=\"{}\">",
unique_id
));
xml.push_str("<cx:tx><cx:txData><cx:f>Sheet1!$B$1</cx:f>");
xml.push_str(&format!("<cx:v>{}</cx:v>", escape_xml(&series.name)));
xml.push_str("</cx:txData></cx:tx>");
if show_data_labels {
xml.push_str("<cx:dataLabels pos=\"outEnd\"><cx:visibility seriesName=\"0\" categoryName=\"0\" value=\"1\" /></cx:dataLabels>");
}
xml.push_str("<cx:dataId val=\"0\" /><cx:layoutPr><cx:subtotals>");
for index in self.waterfall_subtotal_indexes(series) {
xml.push_str(&format!("<cx:idx val=\"{}\" />", index));
}
xml.push_str("</cx:subtotals></cx:layoutPr></cx:series></cx:plotAreaRegion>");
xml.push_str(
"<cx:axis id=\"0\"><cx:catScaling gapWidth=\"0.5\" /><cx:tickLabels /></cx:axis>",
);
xml.push_str("<cx:axis id=\"1\"><cx:valScaling />");
if show_grid_lines {
xml.push_str("<cx:majorGridlines />");
}
xml.push_str("<cx:tickLabels /></cx:axis>");
xml.push_str("</cx:plotArea>");
if should_show_legend(self) {
xml.push_str("<cx:legend pos=\"t\" align=\"ctr\" overlay=\"0\" />");
}
xml.push_str("</cx:chart></cx:chartSpace>");
Ok(xml)
}
pub fn get_color(&self, index: usize) -> String {
// 与前端 getPaletteColors 保持一致,支持 preset、brand-kit、
// freestyle 和 customMonochrome,而不只读取 customColors。
if self.theme.as_ref().and_then(|t| t.get("palette")).is_some() {
let colors = chart_frontend_palette_colors(self);
if !colors.is_empty() {
return colors[index % colors.len()].clone();
}
}
// 如果主题中没有颜色,则使用自定义颜色列表
if let Some(colors) = &self.colors {
if !colors.is_empty() {
return colors[index % colors.len()].clone();
}
}
// 如果都没有,使用默认颜色
let default_colors = ThemeColors::default();
default_colors.static_colors[index % default_colors.static_colors.len()].to_string()
}
/// 从 ECharts option 解析生成 Chart(支持多个 series)
pub fn from_echart_option(title: &str, option: &str, theme: Option<Value>) -> Result<Self> {
let v: Value = serde_json::from_str(option)?;
let mut chart = Chart::new(title.to_owned());
if let Some(ref theme_value) = theme {
chart.theme = Some(theme_value.clone());
}
// 检查是否有次坐标轴
let has_secondary_axis = Self::has_secondary_axis_in_options(&v);
// 将次坐标轴信息存储在图表中
chart.has_secondary_axis = has_secondary_axis;
// 提取 x 轴名称
if let Some(x_axis_name) = Self::extract_axis_name(&v, "xAxis") {
chart.x_axis_name = Some(x_axis_name);
}
// 提取 y 轴名称(主轴)
if let Some(y_axis_name) = Self::extract_primary_axis_name(&v, "yAxis") {
chart.y_axis_name = Some(y_axis_name);
}
// 提取 y 轴名称(次轴,如果存在)
if has_secondary_axis {
if let Some(secondary_y_axis_name) = Self::extract_secondary_axis_name(&v, "yAxis") {
chart.secondary_y_axis_name = Some(secondary_y_axis_name);
}
}
// 提取主题信息
if let Some(theme) = v.get("theme") {
chart.theme = Some(theme.clone());
}
let series_array = v["series"]
.as_array()
.ok_or_else(|| anyhow::anyhow!("series must be array"))?;
if series_array
.iter()
.any(|series| series["type"].as_str() == Some("radar"))
{
let indicators = v["radar"]["indicator"]
.as_array()
.ok_or_else(|| anyhow::anyhow!("radar.indicator must be array"))?;
let categories: Vec<String> = indicators
.iter()
.map(|item| {
item.get("name")
.and_then(|v| v.as_str())
.or_else(|| item.as_str())
.unwrap_or("")
.to_string()
})
.collect();
let radar_entries: Vec<Value> = series_array
.iter()
.filter(|series| series["type"].as_str() == Some("radar"))
.flat_map(|series| {
let outer_name = series["name"].as_str().map(|s| s.to_string());
series["data"]
.as_array()
.cloned()
.unwrap_or_default()
.into_iter()
.map(move |mut entry| {
if entry.get("name").and_then(|v| v.as_str()).is_none() {
if let Some(name) = &outer_name {
if let Some(obj) = entry.as_object_mut() {
obj.insert("name".to_string(), Value::String(name.clone()));
}
}
}
entry
})
})
.collect();
for (i, radar_entry) in radar_entries.iter().enumerate() {
let name = radar_entry["name"]
.as_str()
.unwrap_or(&format!("Series {}", i + 1))
.to_string();
let values = radar_entry["value"]
.as_array()
.ok_or_else(|| anyhow::anyhow!("radar data value must be array"))?;
let data = categories
.iter()
.enumerate()
.map(|(idx, category)| ChartData {
category: category.clone(),
value: values.get(idx).and_then(|v| v.as_f64()).unwrap_or(0.0),
x_value: None,
bubble_size: None,
role: None,
})
.collect();
chart.series_list.push(Series {
name,
chart_type: ChartType::Radar,
data,
use_secondary_axis: false,
is_stacked: false,
is_area: false,
});
}
return Ok(chart);
}
if series_array
.iter()
.any(|series| series["type"].as_str() == Some("bubble"))
{
for (i, series) in series_array.iter().enumerate() {
if series["type"].as_str() != Some("bubble") {
continue;
}
let name = series["name"]
.as_str()
.unwrap_or(&format!("Series {}", i + 1))
.to_string();
let mut data = Vec::new();
if let Some(points) = series["data"].as_array() {
for point in points {
let Some(tuple) = point.as_array() else {
continue;
};
let x = tuple.first().and_then(|v| v.as_f64());
let y = tuple.get(1).and_then(|v| v.as_f64());
let size = tuple.get(2).and_then(|v| v.as_f64());
if let (Some(xv), Some(yv), Some(sizev)) = (x, y, size) {
data.push(ChartData {
category: String::new(),
value: yv,
x_value: Some(xv),
bubble_size: Some(sizev),
role: None,
});
}
}
}
chart.series_list.push(Series {
name,
chart_type: ChartType::Bubble,
data,
use_secondary_axis: false,
is_stacked: false,
is_area: false,
});
}
return Ok(chart);
}
if series_array
.iter()
.any(|series| series["type"].as_str() == Some("scatter"))
{
for (i, series) in series_array.iter().enumerate() {
if series["type"].as_str() != Some("scatter") {
continue;
}
let name = series["name"]
.as_str()
.unwrap_or(&format!("Series {}", i + 1))
.to_string();
let mut data = Vec::new();
if let Some(points) = series["data"].as_array() {
for point in points {
let Some(tuple) = point.as_array() else {
continue;
};
let x = tuple.first().and_then(|v| v.as_f64());
let y = tuple.get(1).and_then(|v| v.as_f64());
if let (Some(xv), Some(yv)) = (x, y) {
data.push(ChartData {
category: String::new(),
value: yv,
x_value: Some(xv),
bubble_size: None,
role: None,
});
}
}
}
chart.series_list.push(Series {
name,
chart_type: ChartType::Scatter,
data,
use_secondary_axis: false,
is_stacked: false,
is_area: false,
});
}
return Ok(chart);
}
for (i, series) in series_array.iter().enumerate() {
let series_type = series["type"].as_str().unwrap_or("");
let name = series["name"]
.as_str()
.unwrap_or(&format!("Series {}", i + 1))
.to_string();
// 检查系列是否使用次坐标轴
let use_secondary_axis = if has_secondary_axis {
// 检查series中是否指定了yAxisIndex
if let Some(y_axis_index) = series["yAxisIndex"].as_u64() {
y_axis_index == 1
} else if let Some(x_axis_index) = series["xAxisIndex"].as_u64() {
x_axis_index == 1
} else {
false
}
} else {
false
};
// 检查是否启用了堆叠
let mut is_stacked = series["stack"].is_string()
|| (series["stack"].is_number() && series["stack"].as_i64().unwrap_or(0) > 0)
|| Self::is_stacked_from_theme(&theme);
// 检查是否为面积图(area chart)
let is_area = series_type == "line" && series["areaStyle"].is_object();
let chart_type = match series_type {
"bar" => {
if Self::is_waterfall_theme(&v, theme.as_ref()) {
ChartType::Waterfall
} else {
// More robust horizontal bar chart detection - any of these conditions could indicate a horizontal bar
let is_horizontal = v["yAxis"].is_object() && v["yAxis"]["data"].is_array()
|| v["yAxis"].is_array() && v["yAxis"][0]["data"].is_array();
if is_horizontal {
ChartType::Bar
} else {
ChartType::Column
}
}
}
"line" => ChartType::Line,
"pie" => ChartType::Pie,
"radar" => ChartType::Radar,
"scatter" => ChartType::Scatter,
"bubble" => ChartType::Bubble,
_ => return Err(anyhow::anyhow!("Unsupported chart type")),
};
if has_secondary_axis && chart_type.eq(&ChartType::Line) {
is_stacked = false
}
let mut data = Vec::new();
match chart_type {
ChartType::Pie => {
if let Some(data_array) = series["data"].as_array() {
for item in data_array {
let name_op = match &item["name"] {
Value::String(name_str) => Some(name_str.to_string()),
Value::Number(name_num) => Some(name_num.to_string()),
Value::Bool(name_bool) => Some(name_bool.to_string()),
_ => None,
};
if let Some(name) = name_op {
if let Some(value) = item["value"].as_f64() {
data.push(ChartData {
category: name,
value,
x_value: None,
bubble_size: None,
role: None,
});
}
}
}
}
}
ChartType::Bar => {
if let Some(categories) = v["yAxis"]["data"].as_array() {
if let Some(values) = series["data"].as_array() {
for (i, cat) in categories.iter().enumerate() {
let cat_op = match cat {
Value::String(cat_str) => Some(cat_str.to_string()),
Value::Number(cat_num) => Some(cat_num.to_string()),
Value::Bool(cat_bool) => Some(cat_bool.to_string()),
_ => None,
};
if let Some(cat_str) = cat_op {
if let Some(val) = values.get(i) {
if let Some(val_num) = parse_series_numeric_value(val) {
data.push(ChartData {
category: cat_str,
value: val_num,
x_value: None,
bubble_size: None,
role: parse_series_role(val),
});
}
}
}
}
}
} else {
if v["yAxis"].is_array() {
if let Some(categories) = v["yAxis"][0]["data"].as_array() {
if let Some(values) = series["data"].as_array() {
for (i, cat) in categories.iter().enumerate() {
let cat_op = match cat {
Value::String(cat_str) => Some(cat_str.to_string()),
Value::Number(cat_num) => Some(cat_num.to_string()),
Value::Bool(cat_bool) => Some(cat_bool.to_string()),
_ => None,
};
if let Some(cat_str) = cat_op {
if let Some(val) = values.get(i) {
if let Some(val_num) =
parse_series_numeric_value(val)
{
data.push(ChartData {
category: cat_str,
value: val_num,
x_value: None,
bubble_size: None,
role: parse_series_role(val),
});
}
}
}
}
}
}
}
}
}
ChartType::Radar => {
return Err(anyhow::anyhow!("Unexpected inline radar series format"));
}
ChartType::Scatter => {
return Err(anyhow::anyhow!("Unexpected inline scatter series format"));
}
ChartType::Bubble => {
return Err(anyhow::anyhow!("Unexpected inline bubble series format"));
}
ChartType::Waterfall => {
if let Some(categories) = v["xAxis"]["data"].as_array() {
if let Some(values) = series["data"].as_array() {
for (i, cat) in categories.iter().enumerate() {
let cat_op = match cat {
Value::String(cat_str) => Some(cat_str.to_string()),
Value::Number(cat_num) => Some(cat_num.to_string()),
Value::Bool(cat_bool) => Some(cat_bool.to_string()),
_ => None,
};
if let Some(cat_str) = cat_op {
if let Some(val) = values.get(i) {
if let Some(val_num) = parse_series_numeric_value(val) {
data.push(ChartData {
category: cat_str,
value: val_num,
x_value: None,
bubble_size: None,
role: parse_series_role(val),
});
}
}
}
}
}
} else if v["xAxis"].is_array() {
if let Some(categories) = v["xAxis"][0]["data"].as_array() {
if let Some(values) = series["data"].as_array() {
for (i, cat) in categories.iter().enumerate() {
let cat_op = match cat {
Value::String(cat_str) => Some(cat_str.to_string()),
Value::Number(cat_num) => Some(cat_num.to_string()),
Value::Bool(cat_bool) => Some(cat_bool.to_string()),
_ => None,
};
if let Some(cat_str) = cat_op {
if let Some(val) = values.get(i) {
if let Some(val_num) = parse_series_numeric_value(val) {
data.push(ChartData {
category: cat_str,
value: val_num,
x_value: None,
bubble_size: None,
role: parse_series_role(val),
});
}
}
}
}
}
}
}
}
_ => {
if let Some(categories) = v["xAxis"]["data"].as_array() {
if let Some(values) = series["data"].as_array() {
for (i, cat) in categories.iter().enumerate() {
let cat_op = match cat {
Value::String(cat_str) => Some(cat_str.to_string()),
Value::Number(cat_num) => Some(cat_num.to_string()),
Value::Bool(cat_bool) => Some(cat_bool.to_string()),
_ => None,
};
if let Some(cat_str) = cat_op {
if let Some(val) = values.get(i) {
if let Some(val_num) = parse_series_numeric_value(val) {
data.push(ChartData {
category: cat_str.to_string(),
value: val_num,
x_value: None,
bubble_size: None,
role: parse_series_role(val),
});
}
}
}
}
}
} else {
if v["xAxis"].is_array() {
if let Some(categories) = v["xAxis"][0]["data"].as_array() {
if let Some(values) = series["data"].as_array() {
for (i, cat) in categories.iter().enumerate() {
let cat_op = match cat {
Value::String(cat_str) => Some(cat_str.to_string()),
Value::Number(cat_num) => Some(cat_num.to_string()),
Value::Bool(cat_bool) => Some(cat_bool.to_string()),
_ => None,
};
if let Some(cat_str) = cat_op {
if let Some(val) = values.get(i) {
if let Some(val_num) =
parse_series_numeric_value(val)
{
data.push(ChartData {
category: cat_str.to_string(),
value: val_num,
x_value: None,
bubble_size: None,
role: parse_series_role(val),
});
}
}
}
}
}
}
}
}
}
}
// 添加系列,并设置是否使用次坐标轴、是否堆叠和是否为面积图
chart.series_list.push(Series {
name,
chart_type,
data,
use_secondary_axis,
is_stacked,
is_area: is_area || series_type == "area", // 如果是area类型或有areaStyle,则标记为面积图
});
}
Ok(chart)
}
// 从 ECharts options 中提取轴线名称(通用方法)
fn extract_axis_name(options: &Value, axis_key: &str) -> Option<String> {
// 如果轴是一个对象
if options[axis_key].is_object() {
if let Some(name) = options[axis_key]["name"].as_str() {
return Some(name.to_string());
}
}
// 如果轴是一个数组
else if options[axis_key].is_array() {
// 默认使用第一个轴的名称
if let Some(axis_array) = options[axis_key].as_array() {
if !axis_array.is_empty() {
if let Some(name) = axis_array[0]["name"].as_str() {
return Some(name.to_string());
}
}
}
}
None
}
// 提取主轴名称
fn extract_primary_axis_name(options: &Value, axis_key: &str) -> Option<String> {
// 如果轴是一个对象
if options[axis_key].is_object() {
if let Some(name) = options[axis_key]["name"].as_str() {
return Some(name.to_string());
}
}
// 如果轴是一个数组
else if options[axis_key].is_array() {
if let Some(axis_array) = options[axis_key].as_array() {
if !axis_array.is_empty() {
if let Some(name) = axis_array[0]["name"].as_str() {
return Some(name.to_string());
}
}
}
}
None
}
// 提取次轴名称
fn extract_secondary_axis_name(options: &Value, axis_key: &str) -> Option<String> {
// 次轴只可能在数组中的第二个位置
if options[axis_key].is_array() {
if let Some(axis_array) = options[axis_key].as_array() {
if axis_array.len() > 1 {
if let Some(name) = axis_array[1]["name"].as_str() {
return Some(name.to_string());
}
}
}
}
None
}
// 检查ECharts options中是否有次坐标轴
fn has_secondary_axis_in_options(options: &Value) -> bool {
// 检查是否有yAxis数组,且长度大于1
if let Some(y_axis) = options.get("yAxis") {
if let Some(y_axis_array) = y_axis.as_array() {
return y_axis_array.len() > 1;
}
}
// 也可以检查是否有xAxis数组,且长度大于1(对于条形图可能是水平的次坐标)
if let Some(x_axis) = options.get("xAxis") {
if let Some(x_axis_array) = x_axis.as_array() {
return x_axis_array.len() > 1;
}
}
false
}
// 检查主题中是否设置了堆叠模式
fn is_stacked_from_theme(theme: &Option<Value>) -> bool {
if let Some(theme_obj) = theme {
if let Some(bar_style) = theme_obj.get("barStyle") {
if let Some(appearance) = bar_style.get("appearance").and_then(|v| v.as_str()) {
return appearance == "stacked" || appearance == "stacked100";
}
} else if let Some(bar_style) = theme_obj.get("comboStyle") {
if let Some(appearance) = bar_style.get("appearance").and_then(|v| v.as_str()) {
return appearance == "stacked" || appearance == "stacked100";
}
}
}
false
}
// 获取堆叠模式类型
fn get_stacking_mode(theme: &Option<Value>) -> &'static str {
if let Some(theme_obj) = theme {
if let Some(bar_style) = theme_obj.get("barStyle") {
if let Some(appearance) = bar_style.get("appearance").and_then(|v| v.as_str()) {
return match appearance {
"stacked100" => "percentStacked",
"stacked" => "stacked",
_ => "clustered",
};
}
} else if let Some(combo_style) = theme_obj.get("comboStyle") {
if let Some(appearance) = combo_style.get("appearance").and_then(|v| v.as_str()) {
return match appearance {
"stacked100" => "percentStacked",
"stacked" => "stacked",
_ => "clustered",
};
}
}
}
"clustered" // 默认堆叠模式
}
fn get_bar_gap_width(theme: &Option<Value>) -> u32 {
let style = theme
.as_ref()
.and_then(|value| value.get("barStyle").or_else(|| value.get("comboStyle")));
match style
.and_then(|value| value.get("spacing"))
.and_then(Value::as_str)
{
Some("small") => 70,
Some("large") => 160,
Some("extraLarge") => 220,
_ => 110,
}
}
// 检查是否为百分比堆叠模式
fn is_stacked100_mode(theme: &Option<Value>) -> bool {
if let Some(theme_obj) = theme {
if let Some(bar_style) = theme_obj.get("barStyle") {
if let Some(appearance) = bar_style.get("appearance").and_then(|v| v.as_str()) {
return appearance == "stacked100";
}
}
}
false
}
// 将数据转换为百分比(用于stacked100模式)
fn convert_to_percentage(&self) -> Chart {
let mut result = self.clone();
// 只处理相同类型的可堆叠系列
let stackable_types = [ChartType::Column, ChartType::Bar, ChartType::Line];
for chart_type in &stackable_types {
let series_indices: Vec<usize> = self
.series_list
.iter()
.enumerate()
.filter(|(_, series)| series.chart_type == *chart_type && series.is_stacked)
.map(|(i, _)| i)
.collect();
if series_indices.len() < 2 {
continue; // 至少需要2个系列才有意义进行百分比堆叠
}
// 获取所有分类
let categories: Vec<String> = if let Some(first_series_idx) = series_indices.first() {
self.series_list[*first_series_idx]
.data
.iter()
.map(|d| d.category.clone())
.collect()
} else {
continue;
};
// 计算每个分类的总和
let mut category_sums: Vec<f64> = vec![0.0; categories.len()];
for series_idx in &series_indices {
let series = &self.series_list[*series_idx];
for (cat_idx, data_point) in series.data.iter().enumerate() {
if cat_idx < category_sums.len() {
category_sums[cat_idx] += data_point.value;
}
}
}
// 转换每个系列的数据为0-1范围的比例
for series_idx in &series_indices {
for (cat_idx, data_point) in
result.series_list[*series_idx].data.iter_mut().enumerate()
{
if cat_idx < category_sums.len() && category_sums[cat_idx] > 0.0 {
data_point.value = data_point.value / category_sums[cat_idx] * 100.0;
// 0-1范围
}
}
}
}
result
}
pub fn generate_chart_xml(&self) -> Result<String> {
let mut xml = self.generate_unstyled_chart_xml()?;
if let Some(theme) = &self.theme {
if let Some(style) = theme.get("typography") {
let family = style["officeFamily"].as_str().unwrap_or("Noto Sans SC");
let size = style["fontSizePt"].as_f64().unwrap_or(8.0);
let bold = style["fontWeight"].as_u64().unwrap_or(400) >= 700;
let color = style["color"]
.as_str()
.unwrap_or("#333333")
.trim_start_matches('#');
xml = apply_chart_text_typography(
xml,
family,
(size * 100.0).round() as u32,
bold,
color,
);
}
if let Some(style) = theme.get("titleTypography") {
xml = style_chart_title_section(xml, style);
}
if let Some(subtitle) = theme.get("_reportSubtitle").and_then(Value::as_str) {
xml = append_chart_subtitle(xml, subtitle, theme.get("subtitleTypography"));
}
}
Ok(xml)
}
fn generate_unstyled_chart_xml(&self) -> Result<String> {
if self.uses_chart_ex() {
return self.generate_chart_ex_xml();
}
// 如果是百分比堆叠,需要预处理数据
let processed_chart = if Self::is_stacked100_mode(&self.theme) {
self.convert_to_percentage()
} else {
self.clone()
};
processed_chart.generate_chart_xml_internal()
}
fn generate_chart_xml_internal(&self) -> Result<String> {
let mut xml = String::new();
xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>");
xml.push_str(
"<c:chartSpace xmlns:c=\"http://schemas.openxmlformats.org/drawingml/2006/chart\" ",
);
xml.push_str("xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" ");
xml.push_str(
"xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\"> ",
);
// Apply the chart background and remove borders
xml.push_str("<c:spPr>");
xml.push_str(&chart_background_fill(self));
xml.push_str("<a:ln><a:noFill/></a:ln>"); // No border line
xml.push_str("<a:effectLst/>"); // No effects
xml.push_str("</c:spPr>");
xml.push_str("<c:chart>");
// Add chart title if present
if !self.title.is_empty() {
xml.push_str("<c:title>");
xml.push_str("<c:tx>");
xml.push_str("<c:rich>");
xml.push_str("<a:bodyPr/>");
xml.push_str("<a:lstStyle/>");
xml.push_str("<a:p>");
xml.push_str("<a:pPr>");
xml.push_str("<a:defRPr/>");
xml.push_str("</a:pPr>");
xml.push_str("<a:r>");
xml.push_str("<a:rPr lang=\"zh-CN\" altLang=\"en-US\" sz=\"1400\" b=\"1\">");
xml.push_str(&format!("<a:latin typeface=\"{}\"/>", self.default_font));
xml.push_str(&format!("<a:ea typeface=\"{}\"/>", self.default_font));
xml.push_str(&format!("<a:cs typeface=\"{}\"/>", self.default_font));
xml.push_str("</a:rPr>");
xml.push_str(&format!("<a:t>{}</a:t>", escape_xml(&self.title)));
xml.push_str("</a:r>");
xml.push_str("</a:p>");
xml.push_str("</c:rich>");
xml.push_str("</c:tx>");
xml.push_str("<c:overlay val=\"0\"/>");
xml.push_str("</c:title>");
}
xml.push_str("<c:plotArea>");
if self.has_chart_user_shapes() {
let (x, y, w, h) = self
.as_xlsx_overlay_chart()
.overlay_plot_layout_for_size(576.0, 336.0);
xml.push_str(&format!(
"<c:layout><c:manualLayout><c:layoutTarget val=\"inner\"/><c:xMode val=\"edge\"/><c:yMode val=\"edge\"/><c:wMode val=\"factor\"/><c:hMode val=\"factor\"/><c:x val=\"{x:.5}\"/><c:y val=\"{y:.5}\"/><c:w val=\"{w:.5}\"/><c:h val=\"{h:.5}\"/></c:manualLayout></c:layout>"
));
}
// 为每个系列创建对应的图表类型
for (i, series) in self.series_list.iter().enumerate() {
match series.chart_type {
ChartType::Column | ChartType::Waterfall => {
// 检查这是否是此类型的第一个系列,或前一个系列与当前系列类型不同
let is_first_of_type = i == 0
|| !matches!(
self.series_list[i - 1].chart_type,
ChartType::Column | ChartType::Waterfall
);
// 或者前一个系列的堆叠属性与当前不同
let is_different_stack = i > 0
&& matches!(
self.series_list[i - 1].chart_type,
ChartType::Column | ChartType::Waterfall
)
&& self.series_list[i - 1].is_stacked != series.is_stacked;
if is_first_of_type || is_different_stack {
xml.push_str("<c:barChart>");
xml.push_str("<c:barDir val=\"col\"/>");
// 选择合适的分组方式
if series.is_stacked {
let stacking_mode = Self::get_stacking_mode(&self.theme);
xml.push_str(&format!("<c:grouping val=\"{}\"/>", stacking_mode));
xml.push_str("<c:overlap val=\"100\"/>");
xml.push_str(&format!(
"<c:gapWidth val=\"{}\"/>",
Self::get_bar_gap_width(&self.theme)
));
} else {
xml.push_str("<c:grouping val=\"clustered\"/>");
xml.push_str(&format!(
"<c:gapWidth val=\"{}\"/>",
Self::get_bar_gap_width(&self.theme)
));
xml.push_str("<c:overlap val=\"-10\"/>");
}
xml.push_str("<c:varyColors val=\"0\"/>");
}
xml.push_str(&create_bar_column_series_xml(self, series, i, false));
// 检查这是否是此类型的最后一个系列,或下一个系列与当前系列类型不同
let is_last_of_type = i == self.series_list.len() - 1
|| !matches!(
self.series_list[i + 1].chart_type,
ChartType::Column | ChartType::Waterfall
);
// 或者下一个系列的堆叠属性与当前不同
let next_has_different_stack = i < self.series_list.len() - 1
&& matches!(
self.series_list[i + 1].chart_type,
ChartType::Column | ChartType::Waterfall
)
&& self.series_list[i + 1].is_stacked != series.is_stacked;
if is_last_of_type || next_has_different_stack {
xml.push_str("<c:axId val=\"123456\"/>");
// 根据是否使用次坐标轴选择不同的ID
if series.use_secondary_axis && self.has_secondary_axis {
xml.push_str("<c:axId val=\"987654\"/>");
} else {
xml.push_str("<c:axId val=\"654321\"/>");
}
xml.push_str("</c:barChart>");
}
}
ChartType::Bar => {
// 检查这是否是此类型的第一个系列,或前一个系列与当前系列类型不同
let is_first_of_type =
i == 0 || (self.series_list[i - 1].chart_type != ChartType::Bar);
// 或者前一个系列的堆叠属性与当前不同
let is_different_stack = i > 0
&& (self.series_list[i - 1].chart_type == ChartType::Bar)
&& self.series_list[i - 1].is_stacked != series.is_stacked;
if is_first_of_type || is_different_stack {
xml.push_str("<c:barChart>");
xml.push_str("<c:barDir val=\"bar\"/>");
// 选择合适的分组方式
if series.is_stacked {
let stacking_mode = Self::get_stacking_mode(&self.theme);
xml.push_str(&format!("<c:grouping val=\"{}\"/>", stacking_mode));
xml.push_str("<c:overlap val=\"100\"/>");
xml.push_str(&format!(
"<c:gapWidth val=\"{}\"/>",
Self::get_bar_gap_width(&self.theme)
));
} else {
xml.push_str("<c:grouping val=\"clustered\"/>");
xml.push_str(&format!(
"<c:gapWidth val=\"{}\"/>",
Self::get_bar_gap_width(&self.theme)
));
xml.push_str("<c:overlap val=\"-10\"/>");
}
xml.push_str("<c:varyColors val=\"0\"/>");
}
xml.push_str(&create_bar_column_series_xml(self, series, i, true));
// 检查这是否是此类型的最后一个系列,或下一个系列与当前系列类型不同
let is_last_of_type = i == self.series_list.len() - 1
|| (self.series_list[i + 1].chart_type != ChartType::Bar);
// 或者下一个系列的堆叠属性与当前不同
let next_has_different_stack = i < self.series_list.len() - 1
&& (self.series_list[i + 1].chart_type == ChartType::Bar)
&& self.series_list[i + 1].is_stacked != series.is_stacked;
if is_last_of_type || next_has_different_stack {
xml.push_str("<c:axId val=\"123456\"/>");
// 根据是否使用次坐标轴选择不同的ID
if series.use_secondary_axis && self.has_secondary_axis {
xml.push_str("<c:axId val=\"987654\"/>");
} else {
xml.push_str("<c:axId val=\"654321\"/>");
}
xml.push_str("</c:barChart>");
}
}
ChartType::Line => {
// 检查这是否是此类型的第一个系列,或前一个系列与当前系列类型不同
let is_first_of_type =
i == 0 || (self.series_list[i - 1].chart_type != ChartType::Line);
// 或者前一个系列的堆叠属性与当前不同,或者面积图属性与当前不同
let is_different_stack = i > 0
&& (self.series_list[i - 1].chart_type == ChartType::Line)
&& (self.series_list[i - 1].is_stacked != series.is_stacked
|| self.series_list[i - 1].is_area != series.is_area);
if is_first_of_type || is_different_stack {
// 使用正确的图表类型 - 面积图使用areaChart,普通折线图使用lineChart
if series.is_area {
xml.push_str("<c:areaChart>");
// 简化面积图设置
if series.is_stacked {
let stacking_mode = Self::get_stacking_mode(&self.theme);
xml.push_str(&format!("<c:grouping val=\"{}\"/>", stacking_mode));
} else {
xml.push_str("<c:grouping val=\"standard\"/>");
}
} else {
xml.push_str("<c:lineChart>");
// 选择合适的分组方式
if series.is_stacked {
let stacking_mode = Self::get_stacking_mode(&self.theme);
xml.push_str(&format!("<c:grouping val=\"{}\"/>", stacking_mode));
} else {
xml.push_str("<c:grouping val=\"standard\"/>");
}
}
xml.push_str("<c:varyColors val=\"0\"/>");
}
// 为面积图和线图使用不同的系列XML生成
if series.is_area {
xml.push_str(&create_area_series_xml(self, series, i));
} else {
xml.push_str(&create_line_series_xml(self, series, i));
}
// 检查这是否是此类型的最后一个系列,或下一个系列与当前系列类型不同
let is_last_of_type = i == self.series_list.len() - 1
|| (self.series_list[i + 1].chart_type != ChartType::Line);
// 或者下一个系列的堆叠属性与当前不同,或者面积图属性与当前不同
let next_has_different_stack = i < self.series_list.len() - 1
&& (self.series_list[i + 1].chart_type == ChartType::Line)
&& (self.series_list[i + 1].is_stacked != series.is_stacked
|| self.series_list[i + 1].is_area != series.is_area);
if is_last_of_type || next_has_different_stack {
// 不需要为面积图添加marker设置
if !series.is_area {
// 应用主题中的点标记设置
if let Some(theme) = &self.theme {
if let Some(line_style) = theme.get("lineStyle") {
if let Some(show_points) =
line_style.get("showPoints").and_then(|v| v.as_bool())
{
if show_points {
xml.push_str("<c:marker val=\"1\"/>");
} else {
xml.push_str("<c:marker val=\"0\"/>");
}
} else {
xml.push_str("<c:marker val=\"1\"/>");
}
} else {
xml.push_str("<c:marker val=\"1\"/>");
}
} else {
xml.push_str("<c:marker val=\"1\"/>");
}
}
xml.push_str("<c:axId val=\"123456\"/>");
// 根据是否使用次坐标轴选择不同的ID
if series.use_secondary_axis && self.has_secondary_axis {
xml.push_str("<c:axId val=\"987654\"/>");
} else {
xml.push_str("<c:axId val=\"654321\"/>");
}
// 关闭正确的标签
if series.is_area {
xml.push_str("</c:areaChart>");
} else {
xml.push_str("</c:lineChart>");
}
}
}
ChartType::Pie => {
let is_donut = if let Some(theme) = &self.theme {
if let Some(pie_style) = theme.get("pieStyle") {
pie_style
.get("donut")
.and_then(|v| v.as_bool())
.unwrap_or(false)
} else {
false
}
} else {
false
};
if i == 0 || self.series_list[i - 1].chart_type != ChartType::Pie {
// 检查是否为环形图
if is_donut {
xml.push_str("<c:doughnutChart>");
} else {
xml.push_str("<c:pieChart>");
}
xml.push_str("<c:varyColors val=\"1\"/>");
// 应用主题中的饼图样式
if let Some(theme) = &self.theme {
if let Some(pie_style) = theme.get("pieStyle") {
// 应用环形图设置
if is_donut {
xml.push_str(&format!("<c:holeSize val=\"{}\"/>", 50));
}
// 应用起始角度
if let Some(start_angle) =
pie_style.get("startAngle").and_then(|v| v.as_str())
{
if let Ok(angle) = start_angle.parse::<i32>() {
xml.push_str(&format!(
"<c:firstSliceAng val=\"{}\"/>",
angle
));
}
}
}
}
}
xml.push_str("<c:ser>");
xml.push_str(&format!("<c:idx val=\"{}\"/>", i));
xml.push_str(&format!("<c:order val=\"{}\"/>", i));
xml.push_str("<c:tx>");
xml.push_str("<c:strRef>");
xml.push_str("<c:f>Sheet1!$B$1</c:f>");
xml.push_str("<c:strCache>");
xml.push_str("<c:ptCount val=\"1\"/>");
xml.push_str(&format!(
"<c:pt idx=\"0\"><c:v>{}</c:v></c:pt>",
escape_xml(&series.name)
));
xml.push_str("</c:strCache>");
xml.push_str("</c:strRef>");
xml.push_str("</c:tx>");
xml.push_str("<c:spPr>");
xml.push_str("<a:noFill/>");
xml.push_str("<a:ln w=\"9525\" cap=\"flat\"><a:solidFill><a:srgbClr val=\"F9F9F9\"/></a:solidFill><a:prstDash val=\"solid\"/><a:round/></a:ln>");
xml.push_str("<a:effectLst/>");
xml.push_str("</c:spPr>");
// 调整数据点样式,应用自定义主题颜色
for (j, _) in series.data.iter().enumerate() {
xml.push_str("<c:dPt>");
xml.push_str(&format!("<c:idx val=\"{}\"/>", j));
xml.push_str("<c:bubble3D val=\"0\"/>");
xml.push_str("<c:spPr>");
// 使用自定义主题颜色
let color = self.get_color(i * series.data.len() + j);
let color = color.trim_start_matches('#');
xml.push_str(&format!(
"<a:solidFill><a:srgbClr val=\"{}\"/></a:solidFill>",
color
));
xml.push_str("<a:ln w=\"9525\" cap=\"flat\"><a:solidFill><a:srgbClr val=\"FFFFFF\"/></a:solidFill><a:prstDash val=\"solid\"/><a:round/></a:ln>");
xml.push_str("<a:effectLst/>");
xml.push_str("</c:spPr>");
xml.push_str("</c:dPt>");
}
// 检查主题中的数据标签设置
let show_data_labels = should_show_data_labels(self, &ChartType::Pie);
// Add data labels
if show_data_labels {
let data_label_font_size = get_chart_label_font_size(&self.locale);
xml.push_str("<c:dLbls>");
for (j, _) in series.data.iter().enumerate() {
xml.push_str("<c:dLbl>");
xml.push_str(&format!("<c:idx val=\"{}\"/>", j));
xml.push_str("<c:numFmt formatCode=\"General\" sourceLinked=\"0\"/>");
xml.push_str("<c:spPr/><c:txPr>");
xml.push_str("<a:bodyPr/><a:lstStyle/>");
xml.push_str("<a:p><a:pPr>");
xml.push_str(&format!(
"<a:defRPr sz=\"{}\" b=\"0\" i=\"0\" u=\"none\" strike=\"noStrike\">",
data_label_font_size
));
xml.push_str("<a:solidFill><a:srgbClr val=\"000000\"/></a:solidFill>");
xml.push_str(&format!("<a:latin typeface=\"{}\"/>", self.default_font));
xml.push_str(&format!("<a:ea typeface=\"{}\"/>", self.default_font));
xml.push_str(&format!("<a:cs typeface=\"{}\"/>", self.default_font));
xml.push_str("</a:defRPr>");
xml.push_str("</a:pPr></a:p>");
xml.push_str("</c:txPr>");
if !is_donut {
xml.push_str("<c:dLblPos val=\"ctr\"/>");
}
xml.push_str("<c:showLegendKey val=\"0\"/>");
xml.push_str("<c:showVal val=\"0\"/>");
xml.push_str("<c:showCatName val=\"0\"/>");
xml.push_str("<c:showSerName val=\"0\"/>");
xml.push_str("<c:showPercent val=\"1\"/>");
xml.push_str("<c:showBubbleSize val=\"0\"/>");
xml.push_str("<c:showLeaderLines val=\"1\"/>");
xml.push_str("</c:dLbl>");
}
xml.push_str("</c:dLbls>");
}
// Add categories
xml.push_str("<c:cat>");
xml.push_str("<c:strRef>");
xml.push_str(&format!(
"<c:f>Sheet1!$A$2:$A${}</c:f>",
series.data.len() + 1
));
xml.push_str("<c:strCache>");
xml.push_str(&format!("<c:ptCount val=\"{}\"/>", series.data.len()));
for (j, data) in series.data.iter().enumerate() {
xml.push_str(&format!(
"<c:pt idx=\"{}\"><c:v>{}</c:v></c:pt>",
j,
escape_xml(&data.category)
));
}
xml.push_str("</c:strCache>");
xml.push_str("</c:strRef>");
xml.push_str("</c:cat>");
// Add values - 使用动态列
let column = index_to_excel_column(i);
xml.push_str("<c:val>");
xml.push_str("<c:numRef>");
xml.push_str(&format!("<c:f>Sheet1!${}$2:${}${}</c:f><c:numCache><c:formatCode>General</c:formatCode><c:ptCount val=\"", column, column, series.data.len() + 1));
xml.push_str(&format!("{}", series.data.len()));
xml.push_str("\"/>");
for (j, data) in series.data.iter().enumerate() {
xml.push_str(&format!(
"<c:pt idx=\"{}\"><c:v>{}</c:v></c:pt>",
j, data.value
));
}
xml.push_str("</c:numCache></c:numRef></c:val>");
xml.push_str("</c:ser>");
if i == self.series_list.len() - 1
|| self.series_list[i + 1].chart_type != ChartType::Pie
{
if is_donut {
xml.push_str("</c:doughnutChart>");
} else {
xml.push_str("</c:pieChart>");
}
}
}
ChartType::Radar => {
let is_first_of_type =
i == 0 || (self.series_list[i - 1].chart_type != ChartType::Radar);
if is_first_of_type {
xml.push_str("<c:radarChart>");
xml.push_str(&format!(
"<c:radarStyle val=\"{}\"/>",
get_radar_style(self)
));
xml.push_str("<c:varyColors val=\"0\"/>");
}
xml.push_str(&create_radar_series_xml(self, series, i));
let is_last_of_type = i == self.series_list.len() - 1
|| (self.series_list[i + 1].chart_type != ChartType::Radar);
if is_last_of_type {
xml.push_str("<c:axId val=\"123456\"/>");
xml.push_str("<c:axId val=\"654321\"/>");
xml.push_str("</c:radarChart>");
}
}
ChartType::Scatter => {
let is_first_of_type =
i == 0 || (self.series_list[i - 1].chart_type != ChartType::Scatter);
if is_first_of_type {
xml.push_str("<c:scatterChart>");
xml.push_str("<c:scatterStyle val=\"marker\"/>");
xml.push_str("<c:varyColors val=\"0\"/>");
}
xml.push_str(&create_scatter_series_xml(self, series, i));
let is_last_of_type = i == self.series_list.len() - 1
|| (self.series_list[i + 1].chart_type != ChartType::Scatter);
if is_last_of_type {
xml.push_str("<c:axId val=\"123456\"/>");
xml.push_str("<c:axId val=\"654321\"/>");
xml.push_str("</c:scatterChart>");
}
}
ChartType::Bubble => {
let is_first_of_type =
i == 0 || (self.series_list[i - 1].chart_type != ChartType::Bubble);
if is_first_of_type {
xml.push_str("<c:bubbleChart>");
}
xml.push_str(&create_bubble_series_xml(self, series, i));
let is_last_of_type = i == self.series_list.len() - 1
|| (self.series_list[i + 1].chart_type != ChartType::Bubble);
if is_last_of_type {
xml.push_str("<c:axId val=\"123456\"/>");
xml.push_str("<c:axId val=\"654321\"/>");
xml.push_str("</c:bubbleChart>");
}
}
}
}
// 添加轴(只在需要时添加)
let has_bar_or_column = self.series_list.iter().any(|s| {
matches!(
s.chart_type,
ChartType::Bar | ChartType::Column | ChartType::Waterfall
)
});
let has_bar = self
.series_list
.iter()
.any(|s| matches!(s.chart_type, ChartType::Bar));
let has_line_or_area = self
.series_list
.iter()
.any(|s| s.chart_type == ChartType::Line); // 包括普通折线图和面积图
let has_radar = self
.series_list
.iter()
.any(|s| s.chart_type == ChartType::Radar);
let has_scatter = self
.series_list
.iter()
.any(|s| s.chart_type == ChartType::Scatter);
let has_bubble = self
.series_list
.iter()
.any(|s| s.chart_type == ChartType::Bubble);
if has_bubble {
xml.push_str(&create_bubble_x_axis_xml(self));
xml.push_str(&create_bubble_y_axis_xml(self));
} else if has_scatter {
xml.push_str(&create_scatter_x_axis_xml(self));
xml.push_str(&create_scatter_y_axis_xml(self));
} else if has_bar_or_column || has_line_or_area || has_radar {
xml.push_str(&create_cat_axis_xml(self, has_bar)); // 水平类别轴
xml.push_str(&create_val_axis_xml(self, has_bar)); // 垂直数值轴
// 如果有次坐标轴,则添加
if self.has_secondary_axis {
xml.push_str(&create_secondary_val_axis_xml(self, true)); // 垂直次数值轴
}
}
// Plot-area styling belongs after chart groups and axes in the OOXML sequence.
xml.push_str("<c:spPr><a:noFill/><a:ln><a:noFill/></a:ln><a:effectLst/></c:spPr>");
xml.push_str("</c:plotArea>");
if should_show_legend(self) {
xml.push_str(&create_legend_xml(self));
}
xml.push_str("<c:plotVisOnly val=\"1\"/>");
xml.push_str(
"</c:chart><c:externalData r:id=\"rId1\"><c:autoUpdate val=\"0\"/></c:externalData>",
);
if self.has_chart_user_shapes() {
xml.push_str("<c:userShapes r:id=\"rId2\"/>");
}
xml.push_str("</c:chartSpace>");
Ok(xml)
}
/// 生成图表关系文件 XML(chart{n}.xml.rels)
pub fn generate_chart_rels(&self, chart_index: usize) -> String {
let mut xml = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/package" Target="../embeddings/Microsoft_Excel_Worksheet{}.xlsx"/>
</Relationships>"#,
chart_index
);
if self.has_chart_user_shapes() {
xml = xml.replace(
"</Relationships>",
&format!(
" <Relationship Id=\"rId2\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/chartUserShapes\" Target=\"../drawings/drawing{}.xml\"/>\n</Relationships>",
chart_index
),
);
} else if self.uses_chart_ex() {
xml = xml.replace(
"</Relationships>",
&format!(
" <Relationship Id=\"rId2\" Type=\"http://schemas.microsoft.com/office/2011/relationships/chartStyle\" Target=\"{}\"/>\n <Relationship Id=\"rId3\" Type=\"http://schemas.microsoft.com/office/2011/relationships/chartColorStyle\" Target=\"{}\"/>\n</Relationships>",
self.chart_style_part_name(chart_index),
self.chart_color_part_name(chart_index)
),
);
}
xml
}
pub fn generate_chart_style_xml(&self) -> Option<String> {
if !self.uses_chart_ex() {
return None;
}
let axis_sppr = chart_ex_axis_sppr(self);
let gridline_sppr = chart_ex_gridline_sppr(self);
let chart_area_sppr = chart_ex_chart_area_sppr(self);
let plot_area_sppr = chart_ex_plot_area_sppr();
let axis_font_size = get_chart_label_font_size(&self.locale).to_string();
let title_font_size = "1400".to_string();
let mut xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<cs:chartStyle xmlns:cs="http://schemas.microsoft.com/office/drawing/2012/chartStyle" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" id="395">
<cs:axisTitle><cs:lnRef idx="0"/><cs:fillRef idx="0"/><cs:effectRef idx="0"/><cs:fontRef idx="minor"><a:schemeClr val="tx1"><a:lumMod val="65000"/><a:lumOff val="35000"/></a:schemeClr></cs:fontRef><cs:defRPr sz="__AXIS_FONT_SIZE__"/></cs:axisTitle>
<cs:categoryAxis><cs:lnRef idx="0"/><cs:fillRef idx="0"/><cs:effectRef idx="0"/><cs:fontRef idx="minor"><a:schemeClr val="tx1"><a:lumMod val="65000"/><a:lumOff val="35000"/></a:schemeClr></cs:fontRef>__CATEGORY_AXIS_SPPR__<cs:defRPr sz="__AXIS_FONT_SIZE__"/></cs:categoryAxis>
<cs:chartArea mods="allowNoFillOverride allowNoLineOverride"><cs:lnRef idx="0"/><cs:fillRef idx="0"/><cs:effectRef idx="0"/><cs:fontRef idx="minor"><a:schemeClr val="tx1"/></cs:fontRef>__CHART_AREA_SPPR__<cs:defRPr sz="1330"/></cs:chartArea>
<cs:dataLabel><cs:lnRef idx="0"/><cs:fillRef idx="0"/><cs:effectRef idx="0"/><cs:fontRef idx="minor"><a:schemeClr val="tx1"><a:lumMod val="65000"/><a:lumOff val="35000"/></a:schemeClr></cs:fontRef><cs:defRPr sz="__AXIS_FONT_SIZE__"/></cs:dataLabel>
<cs:dataLabelCallout><cs:lnRef idx="0"/><cs:fillRef idx="0"/><cs:effectRef idx="0"/><cs:fontRef idx="minor"><a:schemeClr val="dk1"><a:lumMod val="65000"/><a:lumOff val="35000"/></a:schemeClr></cs:fontRef><cs:spPr><a:solidFill><a:schemeClr val="lt1"/></a:solidFill><a:ln><a:solidFill><a:schemeClr val="dk1"><a:lumMod val="25000"/><a:lumOff val="75000"/></a:schemeClr></a:solidFill></a:ln></cs:spPr><cs:defRPr sz="__AXIS_FONT_SIZE__"/><cs:bodyPr rot="0" spcFirstLastPara="1" vertOverflow="clip" horzOverflow="clip" vert="horz" wrap="square" lIns="36576" tIns="18288" rIns="36576" bIns="18288" anchor="ctr" anchorCtr="1"><a:spAutoFit/></cs:bodyPr></cs:dataLabelCallout>
<cs:dataPoint><cs:lnRef idx="0"/><cs:fillRef idx="0"><cs:styleClr val="auto"/></cs:fillRef><cs:effectRef idx="0"/><cs:fontRef idx="minor"><a:schemeClr val="tx1"/></cs:fontRef><cs:spPr><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></cs:spPr></cs:dataPoint>
<cs:dataPoint3D><cs:lnRef idx="0"/><cs:fillRef idx="0"><cs:styleClr val="auto"/></cs:fillRef><cs:effectRef idx="0"/><cs:fontRef idx="minor"><a:schemeClr val="tx1"/></cs:fontRef><cs:spPr><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></cs:spPr></cs:dataPoint3D>
<cs:dataPointLine><cs:lnRef idx="0"><cs:styleClr val="auto"/></cs:lnRef><cs:fillRef idx="0"/><cs:effectRef idx="0"/><cs:fontRef idx="minor"><a:schemeClr val="tx1"/></cs:fontRef><cs:spPr><a:ln w="28575" cap="rnd"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:round/></a:ln></cs:spPr></cs:dataPointLine>
<cs:dataPointMarker><cs:lnRef idx="0"/><cs:fillRef idx="0"><cs:styleClr val="auto"/></cs:fillRef><cs:effectRef idx="0"/><cs:fontRef idx="minor"><a:schemeClr val="tx1"/></cs:fontRef><cs:spPr><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:ln w="9525"><a:solidFill><a:schemeClr val="lt1"/></a:solidFill></a:ln></cs:spPr></cs:dataPointMarker>
<cs:dataPointMarkerLayout symbol="circle" size="5"/>
<cs:dataPointWireframe><cs:lnRef idx="0"><cs:styleClr val="auto"/></cs:lnRef><cs:fillRef idx="0"/><cs:effectRef idx="0"/><cs:fontRef idx="minor"><a:schemeClr val="tx1"/></cs:fontRef><cs:spPr><a:ln w="28575" cap="rnd"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:round/></a:ln></cs:spPr></cs:dataPointWireframe>
<cs:dataTable><cs:lnRef idx="0"/><cs:fillRef idx="0"/><cs:effectRef idx="0"/><cs:fontRef idx="minor"><a:schemeClr val="tx1"><a:lumMod val="65000"/><a:lumOff val="35000"/></a:schemeClr></cs:fontRef><cs:spPr><a:ln w="9525"><a:solidFill><a:schemeClr val="tx1"><a:lumMod val="15000"/><a:lumOff val="85000"/></a:schemeClr></a:solidFill></a:ln></cs:spPr><cs:defRPr sz="__AXIS_FONT_SIZE__"/></cs:dataTable>
<cs:downBar><cs:lnRef idx="0"/><cs:fillRef idx="0"/><cs:effectRef idx="0"/><cs:fontRef idx="minor"><a:schemeClr val="dk1"/></cs:fontRef><cs:spPr><a:solidFill><a:schemeClr val="dk1"><a:lumMod val="65000"/><a:lumOff val="35000"/></a:schemeClr></a:solidFill><a:ln w="9525"><a:solidFill><a:schemeClr val="tx1"><a:lumMod val="65000"/><a:lumOff val="35000"/></a:schemeClr></a:solidFill></a:ln></cs:spPr></cs:downBar>
<cs:dropLine><cs:lnRef idx="0"/><cs:fillRef idx="0"/><cs:effectRef idx="0"/><cs:fontRef idx="minor"><a:schemeClr val="tx1"/></cs:fontRef><cs:spPr><a:ln w="9525" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="tx1"><a:lumMod val="35000"/><a:lumOff val="65000"/></a:schemeClr></a:solidFill><a:round/></a:ln></cs:spPr></cs:dropLine>
<cs:errorBar><cs:lnRef idx="0"/><cs:fillRef idx="0"/><cs:effectRef idx="0"/><cs:fontRef idx="minor"><a:schemeClr val="tx1"/></cs:fontRef><cs:spPr><a:ln w="9525" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="tx1"><a:lumMod val="65000"/><a:lumOff val="35000"/></a:schemeClr></a:solidFill><a:round/></a:ln></cs:spPr></cs:errorBar>
<cs:floor><cs:lnRef idx="0"/><cs:fillRef idx="0"/><cs:effectRef idx="0"/><cs:fontRef idx="minor"><a:schemeClr val="tx1"/></cs:fontRef></cs:floor>
<cs:gridlineMajor><cs:lnRef idx="0"/><cs:fillRef idx="0"/><cs:effectRef idx="0"/><cs:fontRef idx="minor"><a:schemeClr val="tx1"/></cs:fontRef>__GRIDLINE_MAJOR_SPPR__</cs:gridlineMajor>
<cs:gridlineMinor><cs:lnRef idx="0"/><cs:fillRef idx="0"/><cs:effectRef idx="0"/><cs:fontRef idx="minor"><a:schemeClr val="tx1"/></cs:fontRef>__GRIDLINE_MINOR_SPPR__</cs:gridlineMinor>
<cs:hiLoLine><cs:lnRef idx="0"/><cs:fillRef idx="0"/><cs:effectRef idx="0"/><cs:fontRef idx="minor"><a:schemeClr val="tx1"/></cs:fontRef><cs:spPr><a:ln w="9525" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="tx1"><a:lumMod val="75000"/><a:lumOff val="25000"/></a:schemeClr></a:solidFill><a:round/></a:ln></cs:spPr></cs:hiLoLine>
<cs:leaderLine><cs:lnRef idx="0"/><cs:fillRef idx="0"/><cs:effectRef idx="0"/><cs:fontRef idx="minor"><a:schemeClr val="tx1"/></cs:fontRef><cs:spPr><a:ln w="9525" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="tx1"><a:lumMod val="35000"/><a:lumOff val="65000"/></a:schemeClr></a:solidFill><a:round/></a:ln></cs:spPr></cs:leaderLine>
<cs:legend><cs:lnRef idx="0"/><cs:fillRef idx="0"/><cs:effectRef idx="0"/><cs:fontRef idx="minor"><a:schemeClr val="tx1"><a:lumMod val="65000"/><a:lumOff val="35000"/></a:schemeClr></cs:fontRef><cs:defRPr sz="__AXIS_FONT_SIZE__"/></cs:legend>
<cs:plotArea mods="allowNoFillOverride allowNoLineOverride"><cs:lnRef idx="0"/><cs:fillRef idx="0"/><cs:effectRef idx="0"/><cs:fontRef idx="minor"><a:schemeClr val="tx1"/></cs:fontRef>__PLOT_AREA_SPPR__</cs:plotArea>
<cs:plotArea3D mods="allowNoFillOverride allowNoLineOverride"><cs:lnRef idx="0"/><cs:fillRef idx="0"/><cs:effectRef idx="0"/><cs:fontRef idx="minor"><a:schemeClr val="tx1"/></cs:fontRef>__PLOT_AREA_SPPR__</cs:plotArea3D>
<cs:seriesAxis><cs:lnRef idx="0"/><cs:fillRef idx="0"/><cs:effectRef idx="0"/><cs:fontRef idx="minor"><a:schemeClr val="tx1"><a:lumMod val="65000"/><a:lumOff val="35000"/></a:schemeClr></cs:fontRef>__SERIES_AXIS_SPPR__<cs:defRPr sz="__AXIS_FONT_SIZE__"/></cs:seriesAxis>
<cs:seriesLine><cs:lnRef idx="0"/><cs:fillRef idx="0"/><cs:effectRef idx="0"/><cs:fontRef idx="minor"><a:schemeClr val="tx1"/></cs:fontRef><cs:spPr><a:ln w="9525" cap="flat"><a:solidFill><a:srgbClr val="D9D9D9"/></a:solidFill><a:round/></a:ln></cs:spPr></cs:seriesLine>
<cs:title><cs:lnRef idx="0"/><cs:fillRef idx="0"/><cs:effectRef idx="0"/><cs:fontRef idx="minor"><a:schemeClr val="tx1"><a:lumMod val="65000"/><a:lumOff val="35000"/></a:schemeClr></cs:fontRef><cs:defRPr sz="__TITLE_FONT_SIZE__"/></cs:title>
<cs:trendline><cs:lnRef idx="0"><cs:styleClr val="auto"/></cs:lnRef><cs:fillRef idx="0"/><cs:effectRef idx="0"/><cs:fontRef idx="minor"><a:schemeClr val="tx1"/></cs:fontRef><cs:spPr><a:ln w="19050" cap="rnd"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:prstDash val="sysDash"/></a:ln></cs:spPr></cs:trendline>
<cs:trendlineLabel><cs:lnRef idx="0"/><cs:fillRef idx="0"/><cs:effectRef idx="0"/><cs:fontRef idx="minor"><a:schemeClr val="tx1"><a:lumMod val="65000"/><a:lumOff val="35000"/></a:schemeClr></cs:fontRef><cs:defRPr sz="__AXIS_FONT_SIZE__"/></cs:trendlineLabel>
<cs:upBar><cs:lnRef idx="0"/><cs:fillRef idx="0"/><cs:effectRef idx="0"/><cs:fontRef idx="minor"><a:schemeClr val="dk1"/></cs:fontRef><cs:spPr><a:solidFill><a:schemeClr val="lt1"/></a:solidFill><a:ln w="9525"><a:solidFill><a:schemeClr val="tx1"><a:lumMod val="15000"/><a:lumOff val="85000"/></a:schemeClr></a:solidFill></a:ln></cs:spPr></cs:upBar>
<cs:valueAxis><cs:lnRef idx="0"/><cs:fillRef idx="0"/><cs:effectRef idx="0"/><cs:fontRef idx="minor"><a:schemeClr val="tx1"><a:lumMod val="65000"/><a:lumOff val="35000"/></a:schemeClr></cs:fontRef><cs:defRPr sz="__AXIS_FONT_SIZE__"/></cs:valueAxis>
<cs:wall><cs:lnRef idx="0"/><cs:fillRef idx="0"/><cs:effectRef idx="0"/><cs:fontRef idx="minor"><a:schemeClr val="tx1"/></cs:fontRef></cs:wall>
</cs:chartStyle>"#
.to_string();
xml = xml.replace("__CATEGORY_AXIS_SPPR__", &axis_sppr);
xml = xml.replace("__SERIES_AXIS_SPPR__", &axis_sppr);
xml = xml.replace("__GRIDLINE_MAJOR_SPPR__", &gridline_sppr);
xml = xml.replace("__GRIDLINE_MINOR_SPPR__", &gridline_sppr);
xml = xml.replace("__CHART_AREA_SPPR__", &chart_area_sppr);
xml = xml.replace("__PLOT_AREA_SPPR__", &plot_area_sppr);
xml = xml.replace("__AXIS_FONT_SIZE__", &axis_font_size);
xml = xml.replace("__TITLE_FONT_SIZE__", &title_font_size);
Some(xml)
}
pub fn generate_chart_color_style_xml(&self) -> Option<String> {
if !self.uses_chart_ex() {
return None;
}
let palette = chart_ex_palette_colors(self);
Some(format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<cs:colorStyle xmlns:cs="http://schemas.microsoft.com/office/drawing/2012/chartStyle" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" meth="cycle" id="10">
<a:srgbClr val="{c0}"/>
<a:srgbClr val="{c1}"/>
<a:srgbClr val="{c2}"/>
<a:srgbClr val="{c3}"/>
<a:srgbClr val="{c4}"/>
<a:srgbClr val="{c5}"/>
<cs:variation/>
<cs:variation><a:lumMod val="60000"/></cs:variation>
<cs:variation><a:lumMod val="80000"/><a:lumOff val="20000"/></cs:variation>
<cs:variation><a:lumMod val="80000"/></cs:variation>
<cs:variation><a:lumMod val="60000"/><a:lumOff val="40000"/></cs:variation>
<cs:variation><a:lumMod val="50000"/></cs:variation>
<cs:variation><a:lumMod val="70000"/><a:lumOff val="30000"/></cs:variation>
<cs:variation><a:lumMod val="70000"/></cs:variation>
<cs:variation><a:lumMod val="50000"/><a:lumOff val="50000"/></cs:variation>
</cs:colorStyle>"#,
c0 = palette[0],
c1 = palette[1],
c2 = palette[2],
c3 = palette[3],
c4 = palette[4],
c5 = palette[5]
))
}
/// 生成 Excel 工作表数据(作为嵌入对象)
/// 使用 xlsxcraft 库来创建 Excel 文件
pub fn generate_excel_data(&self) -> Result<Vec<u8>> {
use xlsxcraft::Workbook;
// 创建工作簿
let mut workbook = Workbook::new(&self.locale);
let worksheet = workbook.add_worksheet(Some("Sheet1"));
if self
.series_list
.iter()
.any(|series| series.chart_type == ChartType::Bubble)
{
for (series_index, series) in self.series_list.iter().enumerate() {
let x_col = (series_index * 3) as u16;
let y_col = x_col + 1;
let size_col = x_col + 2;
worksheet.write_string(0, x_col, &format!("{} X", series.name));
worksheet.write_string(0, y_col, &format!("{} Y", series.name));
worksheet.write_string(0, size_col, &format!("{} Size", series.name));
for (row_idx, point) in series.data.iter().enumerate() {
let row = (row_idx + 1) as u32;
if let Some(x) = point.x_value {
worksheet.write_number(row, x_col, x);
}
worksheet.write_number(row, y_col, point.value);
if let Some(size) = point.bubble_size {
worksheet.write_number(row, size_col, normalize_bubble_size(self, size));
}
}
}
return workbook.save_to_buffer();
}
if self
.series_list
.iter()
.any(|series| series.chart_type == ChartType::Scatter)
{
for (series_index, series) in self.series_list.iter().enumerate() {
let x_col = (series_index * 2) as u16;
let y_col = x_col + 1;
worksheet.write_string(0, x_col, &format!("{} X", series.name));
worksheet.write_string(0, y_col, &format!("{} Y", series.name));
for (row_idx, point) in series.data.iter().enumerate() {
let row = (row_idx + 1) as u32;
if let Some(x) = point.x_value {
worksheet.write_number(row, x_col, x);
}
worksheet.write_number(row, y_col, point.value);
}
}
return workbook.save_to_buffer();
}
// 收集所有类别
let categories: Vec<String> = if let Some(first_series) = self.series_list.first() {
first_series
.data
.iter()
.map(|d| d.category.clone())
.collect()
} else {
vec![]
};
// 第一行:标题行
// A1 留空
worksheet.write_string(0, 0, "");
// B1, C1, ... 写入系列名称
for (idx, series) in self.series_list.iter().enumerate() {
worksheet.write_string(0, (idx + 1) as u16, &series.name);
}
// 数据行
for (row_idx, category) in categories.iter().enumerate() {
let row = (row_idx + 1) as u32; // 从第2行开始(索引1)
// A列:类别名称
worksheet.write_string(row, 0, category);
// B列, C列, ... 写入各系列的数据
for (series_idx, series) in self.series_list.iter().enumerate() {
if let Some(data_point) = series.data.get(row_idx) {
worksheet.write_number(row, (series_idx + 1) as u16, data_point.value);
}
}
}
// 保存到 buffer
workbook.save_to_buffer()
}
}
// 辅助函数:检查是否需要显示数据标签
fn should_show_data_labels(chart: &Chart, chart_type: &ChartType) -> bool {
if let Some(theme) = &chart.theme {
match chart_type {
ChartType::Column | ChartType::Bar | ChartType::Waterfall => {
if let Some(waterfall_style) = theme.get("waterfallStyle") {
waterfall_style
.get("showDataLabels")
.and_then(|v| v.as_bool())
.unwrap_or(true)
} else if let Some(bar_style) = theme.get("barStyle") {
bar_style
.get("showDataLabels")
.and_then(|v| v.as_bool())
.unwrap_or(true) // 默认显示数据标签
} else if let Some(combo_style) = theme.get("comboStyle") {
combo_style
.get("showDataLabels")
.and_then(|v| v.as_bool())
.unwrap_or(true) // 默认显示数据标签
} else {
true // 默认显示数据标签
}
}
ChartType::Line => {
if let Some(line_style) = theme.get("lineStyle") {
line_style
.get("showDataLabels")
.and_then(|v| v.as_bool())
.unwrap_or(false) // 折线图默认隐藏,避免密集点位标签碰撞
} else if let Some(combo_style) = theme.get("comboStyle") {
combo_style
.get("showDataLabels")
.and_then(|v| v.as_bool())
.unwrap_or(false) // 组合图中的折线默认隐藏标签
} else {
false
}
}
ChartType::Pie => {
if let Some(pie_style) = theme.get("pieStyle") {
pie_style
.get("showDataLabels")
.and_then(|v| v.as_bool())
.unwrap_or(true)
} else if let Some(combo_style) = theme.get("comboStyle") {
combo_style
.get("showDataLabels")
.and_then(|v| v.as_bool())
.unwrap_or(true)
} else {
true // 饼图默认显示数据标签
}
}
ChartType::Radar => {
if let Some(radar_style) = theme.get("radarStyle") {
radar_style
.get("showDataLabels")
.and_then(|v| v.as_bool())
.unwrap_or(false)
} else {
false
}
}
ChartType::Scatter => {
if let Some(scatter_style) = theme.get("scatterStyle") {
scatter_style
.get("showDataLabels")
.and_then(|v| v.as_bool())
.unwrap_or(false)
} else {
false
}
}
ChartType::Bubble => {
if let Some(bubble_style) = theme.get("bubbleStyle") {
bubble_style
.get("showDataLabels")
.and_then(|v| v.as_bool())
.unwrap_or(false)
} else {
false
}
}
}
} else {
!matches!(chart_type, ChartType::Line)
}
}
fn should_show_legend(chart: &Chart) -> bool {
chart.series_list.len() > 1
|| chart
.series_list
.iter()
.any(|series| series.chart_type == ChartType::Pie)
}
fn get_bubble_size_limits(chart: &Chart) -> (f64, f64) {
let min_size = chart
.theme
.as_ref()
.and_then(|theme| theme.get("bubbleStyle"))
.and_then(|style| style.get("minBubbleSize"))
.and_then(|v| v.as_f64())
.unwrap_or(10.0);
let max_size = chart
.theme
.as_ref()
.and_then(|theme| theme.get("bubbleStyle"))
.and_then(|style| style.get("maxBubbleSize"))
.and_then(|v| v.as_f64())
.unwrap_or(50.0);
(min_size, max_size.max(min_size))
}
fn get_bubble_size_extent(chart: &Chart) -> Option<(f64, f64)> {
let mut min_value = f64::MAX;
let mut max_value = f64::MIN;
for series in &chart.series_list {
if series.chart_type != ChartType::Bubble {
continue;
}
for point in &series.data {
if let Some(size) = point.bubble_size {
if size.is_finite() {
min_value = min_value.min(size);
max_value = max_value.max(size);
}
}
}
}
if min_value == f64::MAX || max_value == f64::MIN {
None
} else {
Some((min_value, max_value))
}
}
fn normalize_bubble_size(chart: &Chart, raw_size: f64) -> f64 {
let (min_limit, max_limit) = get_bubble_size_limits(chart);
let Some((raw_min, raw_max)) = get_bubble_size_extent(chart) else {
return (min_limit + max_limit) / 2.0;
};
if !raw_size.is_finite() {
return min_limit;
}
if (raw_max - raw_min).abs() < f64::EPSILON {
return (min_limit + max_limit) / 2.0;
}
let normalized = ((raw_size - raw_min) / (raw_max - raw_min)).clamp(0.0, 1.0);
min_limit + normalized * (max_limit - min_limit)
}
fn get_radar_style(chart: &Chart) -> &'static str {
if let Some(theme) = &chart.theme {
if let Some(radar_style) = theme.get("radarStyle") {
let area_opacity = radar_style
.get("areaOpacity")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
if area_opacity > 0.0 {
return "filled";
}
if radar_style
.get("showPoints")
.and_then(|v| v.as_bool())
.unwrap_or(true)
{
return "marker";
}
}
}
"standard"
}
fn create_radar_series_xml(chart: &Chart, series: &Series, index: usize) -> String {
let values = extract_values(&chart.series_list);
let axis_options = build_axis_options_with_chart(&values, Some(chart), "yAxis");
let mut xml = String::new();
xml.push_str("<c:ser>");
xml.push_str(&format!("<c:idx val=\"{}\"/>", index));
xml.push_str(&format!("<c:order val=\"{}\"/>", index));
xml.push_str(&format!(
"<c:tx><c:v>{}</c:v></c:tx>",
escape_xml(&series.name)
));
let color = chart.get_color(index);
let color = color.trim_start_matches('#');
let show_points = chart
.theme
.as_ref()
.and_then(|theme| theme.get("radarStyle"))
.and_then(|style| style.get("showPoints"))
.and_then(|v| v.as_bool())
.unwrap_or(true);
let area_alpha = chart
.theme
.as_ref()
.and_then(|theme| theme.get("radarStyle"))
.and_then(|style| style.get("areaOpacity"))
.and_then(|v| v.as_f64())
.unwrap_or(0.0)
.clamp(0.0, 1.0);
xml.push_str("<c:spPr>");
if area_alpha > 0.0 {
let alpha = (area_alpha * 100000.0).round() as i32;
xml.push_str(&format!(
"<a:solidFill><a:srgbClr val=\"{}\"><a:alpha val=\"{}\"/></a:srgbClr></a:solidFill>",
color, alpha
));
} else {
xml.push_str("<a:noFill/>");
}
xml.push_str("<a:ln w=\"9525\" cap=\"flat\">");
xml.push_str(&format!(
"<a:solidFill><a:srgbClr val=\"{}\"/></a:solidFill>",
color
));
xml.push_str("</a:ln>");
xml.push_str("</c:spPr>");
if show_points {
xml.push_str("<c:marker><c:symbol val=\"circle\"/><c:size val=\"5\"/></c:marker>");
} else {
xml.push_str("<c:marker><c:symbol val=\"none\"/></c:marker>");
}
xml.push_str("<c:cat><c:strRef><c:f>Sheet1!$A$2:$A$");
xml.push_str(&format!("{}", series.data.len() + 1));
xml.push_str("</c:f><c:strCache><c:ptCount val=\"");
xml.push_str(&format!("{}", series.data.len()));
xml.push_str("\"/>");
for (i, data) in series.data.iter().enumerate() {
xml.push_str(&format!(
"<c:pt idx=\"{}\"><c:v>{}</c:v></c:pt>",
i,
escape_xml(&data.category)
));
}
xml.push_str("</c:strCache></c:strRef></c:cat>");
let column = index_to_excel_column(index);
xml.push_str("<c:val><c:numRef>");
xml.push_str(&format!(
"<c:f>Sheet1!${}$2:${}${}</c:f><c:numCache><c:formatCode>General</c:formatCode><c:ptCount val=\"{}\"/>",
column,
column,
series.data.len() + 1,
series.data.len()
));
for (i, data) in series.data.iter().enumerate() {
xml.push_str(&format!(
"<c:pt idx=\"{}\"><c:v>{}</c:v></c:pt>",
i, data.value
));
}
xml.push_str("</c:numCache></c:numRef></c:val>");
if should_show_data_labels(chart, &ChartType::Radar) {
let data_label_font_size = get_chart_label_font_size(&chart.locale);
xml.push_str("<c:dLbls>");
if let Some(ref format_code) = axis_options.data_label_format_code {
xml.push_str(&format!(
"<c:numFmt formatCode=\"{}\" sourceLinked=\"0\"/>",
format_code
));
} else {
xml.push_str("<c:numFmt formatCode=\"General\" sourceLinked=\"0\"/>");
}
xml.push_str("<c:dLblPos val=\"t\"/>");
xml.push_str("<c:showLegendKey val=\"0\"/>");
xml.push_str("<c:showVal val=\"1\"/>");
xml.push_str("<c:showCatName val=\"0\"/>");
xml.push_str("<c:showSerName val=\"0\"/>");
xml.push_str("<c:showPercent val=\"0\"/>");
xml.push_str("<c:showBubbleSize val=\"0\"/>");
xml.push_str("<c:txPr><a:bodyPr/><a:lstStyle/><a:p><a:pPr>");
xml.push_str(&format!(
"<a:defRPr sz=\"{}\" b=\"0\" i=\"0\" u=\"none\" strike=\"noStrike\">",
data_label_font_size
));
xml.push_str("<a:solidFill><a:srgbClr val=\"000000\"/></a:solidFill>");
xml.push_str(&format!("<a:latin typeface=\"{}\"/>", chart.default_font));
xml.push_str(&format!("<a:ea typeface=\"{}\"/>", chart.default_font));
xml.push_str(&format!("<a:cs typeface=\"{}\"/>", chart.default_font));
xml.push_str("</a:defRPr></a:pPr></a:p></c:txPr>");
xml.push_str("</c:dLbls>");
}
xml.push_str("</c:ser>");
xml
}
fn create_scatter_series_xml(chart: &Chart, series: &Series, index: usize) -> String {
let mut xml = String::new();
let point_count = series.data.len();
let x_col = index * 2;
let y_col = x_col + 1;
let x_column = index_to_excel_column_zero_based(x_col);
let y_column = index_to_excel_column_zero_based(y_col);
let point_size = chart
.theme
.as_ref()
.and_then(|theme| theme.get("scatterStyle"))
.and_then(|style| style.get("pointSize"))
.and_then(|v| {
v.as_u64()
.or_else(|| {
v.as_i64()
.filter(|value| *value > 0)
.map(|value| value as u64)
})
.or_else(|| {
v.as_f64()
.filter(|value| *value > 0.0)
.map(|value| value.round() as u64)
})
})
.unwrap_or(10);
let point_size = ((point_size as f64) * (2.0 / 3.0)).round() as u64;
let point_size = point_size.clamp(2, 72);
let show_points = chart
.theme
.as_ref()
.and_then(|theme| theme.get("scatterStyle"))
.and_then(|style| style.get("showPoints"))
.and_then(|v| v.as_bool())
.unwrap_or(true);
let color = chart.get_color(index).trim_start_matches('#').to_string();
xml.push_str("<c:ser>");
xml.push_str(&format!("<c:idx val=\"{}\"/>", index));
xml.push_str(&format!("<c:order val=\"{}\"/>", index));
xml.push_str(&format!(
"<c:tx><c:v>{}</c:v></c:tx>",
escape_xml(&series.name)
));
xml.push_str("<c:spPr><a:noFill/><a:ln w=\"0\"><a:noFill/></a:ln></c:spPr>");
if show_points {
xml.push_str("<c:marker>");
xml.push_str("<c:symbol val=\"circle\"/>");
xml.push_str(&format!("<c:size val=\"{}\"/>", point_size));
xml.push_str("<c:spPr>");
xml.push_str(&format!(
"<a:solidFill><a:srgbClr val=\"{}\"/></a:solidFill>",
color
));
xml.push_str(&format!(
"<a:ln w=\"12700\"><a:solidFill><a:srgbClr val=\"{}\"/></a:solidFill></a:ln>",
color
));
xml.push_str("</c:spPr>");
xml.push_str("</c:marker>");
} else {
xml.push_str("<c:marker><c:symbol val=\"none\"/></c:marker>");
}
xml.push_str("<c:xVal><c:numRef>");
xml.push_str(&format!(
"<c:f>Sheet1!${}$2:${}${}</c:f><c:numCache><c:formatCode>General</c:formatCode><c:ptCount val=\"{}\"/>",
x_column,
x_column,
point_count + 1,
point_count
));
for (i, point) in series.data.iter().enumerate() {
xml.push_str(&format!(
"<c:pt idx=\"{}\"><c:v>{}</c:v></c:pt>",
i,
point.x_value.unwrap_or(0.0)
));
}
xml.push_str("</c:numCache></c:numRef></c:xVal>");
xml.push_str("<c:yVal><c:numRef>");
xml.push_str(&format!(
"<c:f>Sheet1!${}$2:${}${}</c:f><c:numCache><c:formatCode>General</c:formatCode><c:ptCount val=\"{}\"/>",
y_column,
y_column,
point_count + 1,
point_count
));
for (i, point) in series.data.iter().enumerate() {
xml.push_str(&format!(
"<c:pt idx=\"{}\"><c:v>{}</c:v></c:pt>",
i, point.value
));
}
xml.push_str("</c:numCache></c:numRef></c:yVal>");
if should_show_data_labels(chart, &ChartType::Scatter) {
xml.push_str("<c:dLbls><c:numFmt formatCode=\"General\" sourceLinked=\"0\"/><c:showLegendKey val=\"0\"/><c:showVal val=\"1\"/><c:showCatName val=\"0\"/><c:showSerName val=\"0\"/><c:showPercent val=\"0\"/><c:showBubbleSize val=\"0\"/></c:dLbls>");
}
xml.push_str("<c:smooth val=\"0\"/>");
xml.push_str("</c:ser>");
xml
}
fn create_bubble_series_xml(chart: &Chart, series: &Series, index: usize) -> String {
let values = extract_values(&chart.series_list);
let axis_options = build_axis_options_with_chart(&values, Some(chart), "yAxis");
let mut xml = String::new();
let point_count = series.data.len();
let x_col = index * 3;
let y_col = x_col + 1;
let size_col = x_col + 2;
let x_column = index_to_excel_column_zero_based(x_col);
let y_column = index_to_excel_column_zero_based(y_col);
let size_column = index_to_excel_column_zero_based(size_col);
let color = chart.get_color(index).trim_start_matches('#').to_string();
xml.push_str("<c:ser>");
xml.push_str(&format!("<c:idx val=\"{}\"/>", index));
xml.push_str(&format!("<c:order val=\"{}\"/>", index));
xml.push_str(&format!(
"<c:tx><c:v>{}</c:v></c:tx>",
escape_xml(&series.name)
));
xml.push_str("<c:spPr>");
xml.push_str(&format!(
"<a:solidFill><a:srgbClr val=\"{}\"/></a:solidFill>",
color
));
xml.push_str(&format!(
"<a:ln w=\"12700\"><a:solidFill><a:srgbClr val=\"{}\"/></a:solidFill></a:ln>",
color
));
xml.push_str("</c:spPr>");
xml.push_str("<c:xVal><c:numRef>");
xml.push_str(&format!(
"<c:f>Sheet1!${}$2:${}${}</c:f><c:numCache><c:formatCode>General</c:formatCode><c:ptCount val=\"{}\"/>",
x_column,
x_column,
point_count + 1,
point_count
));
for (i, point) in series.data.iter().enumerate() {
xml.push_str(&format!(
"<c:pt idx=\"{}\"><c:v>{}</c:v></c:pt>",
i,
point.x_value.unwrap_or(0.0)
));
}
xml.push_str("</c:numCache></c:numRef></c:xVal>");
xml.push_str("<c:yVal><c:numRef>");
xml.push_str(&format!(
"<c:f>Sheet1!${}$2:${}${}</c:f><c:numCache><c:formatCode>General</c:formatCode><c:ptCount val=\"{}\"/>",
y_column,
y_column,
point_count + 1,
point_count
));
for (i, point) in series.data.iter().enumerate() {
xml.push_str(&format!(
"<c:pt idx=\"{}\"><c:v>{}</c:v></c:pt>",
i, point.value
));
}
xml.push_str("</c:numCache></c:numRef></c:yVal>");
xml.push_str("<c:bubbleSize><c:numRef>");
xml.push_str(&format!(
"<c:f>Sheet1!${}$2:${}${}</c:f><c:numCache><c:formatCode>General</c:formatCode><c:ptCount val=\"{}\"/>",
size_column,
size_column,
point_count + 1,
point_count
));
for (i, point) in series.data.iter().enumerate() {
xml.push_str(&format!(
"<c:pt idx=\"{}\"><c:v>{}</c:v></c:pt>",
i,
normalize_bubble_size(chart, point.bubble_size.unwrap_or(0.0))
));
}
xml.push_str("</c:numCache></c:numRef></c:bubbleSize>");
if should_show_data_labels(chart, &ChartType::Bubble) {
let data_label_font_size = get_chart_label_font_size(&chart.locale);
xml.push_str("<c:dLbls>");
if let Some(ref format_code) = axis_options.data_label_format_code {
xml.push_str(&format!(
"<c:numFmt formatCode=\"{}\" sourceLinked=\"0\"/>",
format_code
));
} else {
xml.push_str("<c:numFmt formatCode=\"General\" sourceLinked=\"0\"/>");
}
xml.push_str("<c:dLblPos val=\"t\"/>");
xml.push_str("<c:showLegendKey val=\"0\"/>");
xml.push_str("<c:showVal val=\"1\"/>");
xml.push_str("<c:showCatName val=\"0\"/>");
xml.push_str("<c:showSerName val=\"0\"/>");
xml.push_str("<c:showPercent val=\"0\"/>");
xml.push_str("<c:showBubbleSize val=\"0\"/>");
xml.push_str("<c:txPr><a:bodyPr/><a:lstStyle/><a:p><a:pPr>");
xml.push_str(&format!(
"<a:defRPr sz=\"{}\" b=\"0\" i=\"0\" u=\"none\" strike=\"noStrike\">",
data_label_font_size
));
xml.push_str("<a:solidFill><a:srgbClr val=\"000000\"/></a:solidFill>");
xml.push_str(&format!("<a:latin typeface=\"{}\"/>", chart.default_font));
xml.push_str(&format!("<a:ea typeface=\"{}\"/>", chart.default_font));
xml.push_str(&format!("<a:cs typeface=\"{}\"/>", chart.default_font));
xml.push_str("</a:defRPr></a:pPr></a:p></c:txPr>");
xml.push_str("</c:dLbls>");
}
xml.push_str("</c:ser>");
xml
}
// 为柱状图/条形图创建系列XML
fn create_bar_column_series_xml(
chart: &Chart,
series: &Series,
index: usize,
is_horizontal: bool,
) -> String {
let values = extract_values(&chart.series_list);
let axis_key = if is_horizontal { "xAxis" } else { "yAxis" };
let axis_options = build_axis_options_with_chart(&values, Some(chart), axis_key);
let mut xml = String::new();
xml.push_str("<c:ser>");
xml.push_str(&format!("<c:idx val=\"{}\"/>", index));
xml.push_str(&format!("<c:order val=\"{}\"/>", index));
xml.push_str(&format!(
"<c:tx><c:v>{}</c:v></c:tx>",
escape_xml(&series.name)
));
// 添加数据点样式 - 使用自定义主题颜色
xml.push_str("<c:spPr>");
let color = if series.chart_type == ChartType::Waterfall {
get_waterfall_point_color(chart, None)
} else {
chart.get_color(index)
};
let color = color.trim_start_matches('#');
xml.push_str(&format!(
"<a:solidFill><a:srgbClr val=\"{}\"/></a:solidFill>",
color
));
xml.push_str("<a:ln><a:noFill/></a:ln>");
xml.push_str("</c:spPr>");
xml.push_str("<c:invertIfNegative val=\"0\"/>");
// 对于堆叠图表,添加形状属性确保正确堆叠
if series.is_stacked {
xml.push_str("<c:shape val=\"box\"/>");
}
if series.chart_type == ChartType::Waterfall {
for (point_index, point) in series.data.iter().enumerate() {
let point_color = get_waterfall_point_color(chart, Some(point));
let point_color = point_color.trim_start_matches('#');
xml.push_str("<c:dPt>");
xml.push_str(&format!("<c:idx val=\"{}\"/>", point_index));
xml.push_str("<c:spPr>");
xml.push_str(&format!(
"<a:solidFill><a:srgbClr val=\"{}\"/></a:solidFill>",
point_color
));
xml.push_str("<a:ln><a:noFill/></a:ln>");
xml.push_str("</c:spPr>");
xml.push_str("</c:dPt>");
}
}
if !series.use_secondary_axis {
// 添加分类数据
xml.push_str("<c:cat><c:strRef><c:f>Sheet1!$A$2:$A$");
xml.push_str(&format!("{}", series.data.len() + 1));
xml.push_str("</c:f><c:strCache><c:ptCount val=\"");
xml.push_str(&format!("{}", series.data.len()));
xml.push_str("\"/>");
for (i, data) in series.data.iter().enumerate() {
xml.push_str(&format!(
"<c:pt idx=\"{}\"><c:v>{}</c:v></c:pt>",
i,
escape_xml(&data.category)
));
}
xml.push_str("</c:strCache></c:strRef></c:cat>");
}
// 添加数值数据 - 使用动态列
let column = index_to_excel_column(index);
xml.push_str(&format!(
"<c:val><c:numRef><c:f>Sheet1!${}$2:${}$",
column, column
));
xml.push_str(&format!("{}", series.data.len() + 1));
xml.push_str("</c:f><c:numCache><c:formatCode>General</c:formatCode><c:ptCount val=\"");
xml.push_str(&format!("{}", series.data.len()));
xml.push_str("\"/>");
for (i, data) in series.data.iter().enumerate() {
xml.push_str(&format!(
"<c:pt idx=\"{}\"><c:v>{}</c:v></c:pt>",
i, data.value
));
}
xml.push_str("</c:numCache></c:numRef></c:val>");
// 检查主题中的数据标签设置
let show_data_labels = should_show_data_labels(chart, &series.chart_type);
// 添加数据标签配置
if show_data_labels {
let data_label_font_size = get_chart_label_font_size(&chart.locale);
xml.push_str("<c:dLbls>");
if let Some(ref format_code) = axis_options.data_label_format_code {
xml.push_str(&format!(
"<c:numFmt formatCode=\"{}\" sourceLinked=\"0\"/>",
format_code
));
} else {
xml.push_str("<c:numFmt formatCode=\"General\" sourceLinked=\"0\"/>");
}
xml.push_str("<c:spPr>");
xml.push_str("<a:noFill/>");
xml.push_str("<a:ln><a:noFill/></a:ln>");
xml.push_str("<a:effectLst/>");
xml.push_str("</c:spPr>");
xml.push_str("<c:txPr>");
xml.push_str("<a:bodyPr/>");
xml.push_str("<a:lstStyle/>");
xml.push_str("<a:p>");
xml.push_str("<a:pPr>");
xml.push_str(&format!(
"<a:defRPr sz=\"{}\" b=\"0\" i=\"0\" u=\"none\" strike=\"noStrike\">",
data_label_font_size
));
xml.push_str("<a:solidFill><a:srgbClr val=\"000000\"/></a:solidFill>");
xml.push_str(&format!("<a:latin typeface=\"{}\"/>", chart.default_font));
xml.push_str(&format!("<a:ea typeface=\"{}\"/>", chart.default_font));
xml.push_str(&format!("<a:cs typeface=\"{}\"/>", chart.default_font));
xml.push_str("</a:defRPr>");
xml.push_str("</a:pPr>");
xml.push_str("</a:p>");
xml.push_str("</c:txPr>");
xml.push_str("<c:showLegendKey val=\"0\"/>");
let stacking_mode = Chart::get_stacking_mode(&chart.theme);
match stacking_mode {
"clustered" => {
xml.push_str("<c:dLblPos val=\"outEnd\"/>");
xml.push_str("<c:showVal val=\"1\"/>");
xml.push_str("<c:showPercent val=\"0\"/>");
}
"stacked" => {
xml.push_str("<c:showVal val=\"1\"/>");
xml.push_str("<c:showPercent val=\"0\"/>");
}
"percentStacked" => {
xml.push_str("<c:showVal val=\"0\"/>");
xml.push_str("<c:showPercent val=\"1\"/>");
}
_ => {
xml.push_str("<c:showVal val=\"1\"/>");
xml.push_str("<c:showPercent val=\"0\"/>");
}
};
xml.push_str("<c:showCatName val=\"0\"/>"); // 不显示类别名称
xml.push_str("<c:showSerName val=\"0\"/>"); // 不显示系列名称
xml.push_str("<c:showBubbleSize val=\"0\"/>");
xml.push_str("</c:dLbls>");
}
xml.push_str("</c:ser>");
xml
}
fn get_waterfall_point_color(chart: &Chart, point: Option<&ChartData>) -> String {
let palette = chart_frontend_palette_colors(chart);
match point.and_then(|p| p.role.as_deref()) {
Some("increase") => palette
.first()
.cloned()
.unwrap_or_else(|| "4765FF".to_string()),
Some("decrease") => palette
.get(1)
.cloned()
.unwrap_or_else(|| "EF4444".to_string()),
Some("subtotal") => palette
.get(2)
.cloned()
.unwrap_or_else(|| "6B7280".to_string()),
Some("total") => palette
.get(3)
.cloned()
.unwrap_or_else(|| "111827".to_string()),
_ => match point {
Some(p) if p.value < 0.0 => palette
.get(1)
.cloned()
.unwrap_or_else(|| "EF4444".to_string()),
Some(_) | None => palette
.first()
.cloned()
.unwrap_or_else(|| "4765FF".to_string()),
},
}
}
// 为折线图创建系列XML
fn create_line_series_xml(chart: &Chart, series: &Series, index: usize) -> String {
let values = extract_values(&chart.series_list);
let axis_options = build_axis_options_with_chart(&values, Some(chart), "yAxis");
let mut xml = String::new();
xml.push_str("<c:ser>");
xml.push_str(&format!("<c:idx val=\"{}\"/>", index));
xml.push_str(&format!("<c:order val=\"{}\"/>", index));
xml.push_str(&format!(
"<c:tx><c:v>{}</c:v></c:tx>",
escape_xml(&series.name)
));
// 添加线条样式 - 使用自定义颜色和主题样式
xml.push_str("<c:spPr>");
xml.push_str("<a:noFill/>");
let color = chart.get_color(index);
let color = color.trim_start_matches('#');
// 应用主题中的线条样式
let (line_width, line_style) = if let Some(theme) = &chart.theme {
if let Some(line_style_theme) = theme.get("lineStyle") {
let width = match line_style_theme.get("width").and_then(|v| v.as_str()) {
Some("thin") => "6350",
Some("thick") => "19050",
_ => "9525", // medium
};
let style = match line_style_theme.get("type").and_then(|v| v.as_str()) {
Some("dashed") => "dash",
Some("dotted") => "dot",
_ => "solid",
};
(width, style)
} else {
("9525", "solid")
}
} else {
("9525", "solid")
};
xml.push_str(&format!("<a:ln w=\"{}\" cap=\"flat\">", line_width));
xml.push_str(&format!(
"<a:solidFill><a:srgbClr val=\"{}\"/></a:solidFill>",
color
));
xml.push_str(&format!("<a:prstDash val=\"{}\"/>", line_style));
xml.push_str("</a:ln>");
xml.push_str("</c:spPr>");
// 折线标记配置 - 默认显示节点,除非主题设置不显示
let show_points = if let Some(theme) = &chart.theme {
if let Some(line_style_theme) = theme.get("lineStyle") {
line_style_theme
.get("showPoints")
.and_then(|v| v.as_bool())
.unwrap_or(true) // 默认显示节点
} else {
false
}
} else {
false // 没有主题时默认显示节点
};
if show_points {
xml.push_str("<c:marker>");
xml.push_str("<c:symbol val=\"circle\"/>");
xml.push_str("<c:size val=\"5\"/>");
xml.push_str("<c:spPr>");
// 使用线条颜色作为实心填充
xml.push_str(&format!(
"<a:solidFill><a:srgbClr val=\"{}\"/></a:solidFill>",
color
));
xml.push_str(&format!(
"<a:ln w=\"12700\"><a:solidFill><a:srgbClr val=\"{}\"/></a:solidFill></a:ln>",
color
));
xml.push_str("</c:spPr>");
xml.push_str("</c:marker>");
} else {
xml.push_str("<c:marker><c:symbol val=\"none\"/></c:marker>");
}
if !series.use_secondary_axis {
// 添加分类数据
xml.push_str("<c:cat><c:strRef><c:f>Sheet1!$A$2:$A$");
xml.push_str(&format!("{}", series.data.len() + 1));
xml.push_str("</c:f><c:strCache><c:ptCount val=\"");
xml.push_str(&format!("{}", series.data.len()));
xml.push_str("\"/>");
for (i, data) in series.data.iter().enumerate() {
xml.push_str(&format!(
"<c:pt idx=\"{}\"><c:v>{}</c:v></c:pt>",
i,
escape_xml(&data.category)
));
}
xml.push_str("</c:strCache></c:strRef></c:cat>");
}
// 添加数值数据 - 使用动态列
let column = index_to_excel_column(index);
xml.push_str(&format!(
"<c:val><c:numRef><c:f>Sheet1!${}$2:${}$",
column, column
));
xml.push_str(&format!("{}", series.data.len() + 1));
xml.push_str("</c:f><c:numCache><c:formatCode>General</c:formatCode><c:ptCount val=\"");
xml.push_str(&format!("{}", series.data.len()));
xml.push_str("\"/>");
for (i, data) in series.data.iter().enumerate() {
xml.push_str(&format!(
"<c:pt idx=\"{}\"><c:v>{}</c:v></c:pt>",
i, data.value
));
}
xml.push_str("</c:numCache></c:numRef></c:val>");
// 检查主题中的数据标签设置
let show_data_labels = should_show_data_labels(chart, &ChartType::Line);
// 添加数据标签配置
if show_data_labels {
let data_label_font_size = get_chart_label_font_size(&chart.locale);
xml.push_str("<c:dLbls>");
if let Some(ref format_code) = axis_options.data_label_format_code {
xml.push_str(&format!(
"<c:numFmt formatCode=\"{}\" sourceLinked=\"0\"/>",
format_code
));
} else {
xml.push_str("<c:numFmt formatCode=\"General\" sourceLinked=\"0\"/>");
}
xml.push_str("<c:spPr>");
xml.push_str("<a:noFill/>");
xml.push_str("<a:ln><a:noFill/></a:ln>");
xml.push_str("<a:effectLst/>");
xml.push_str("</c:spPr>");
xml.push_str("<c:txPr>");
xml.push_str("<a:bodyPr/>");
xml.push_str("<a:lstStyle/>");
xml.push_str("<a:p>");
xml.push_str("<a:pPr>");
xml.push_str(&format!(
"<a:defRPr sz=\"{}\" b=\"0\" i=\"0\" u=\"none\" strike=\"noStrike\">",
data_label_font_size
));
xml.push_str("<a:solidFill><a:srgbClr val=\"000000\"/></a:solidFill>");
xml.push_str(&format!("<a:latin typeface=\"{}\"/>", chart.default_font));
xml.push_str(&format!("<a:ea typeface=\"{}\"/>", chart.default_font));
xml.push_str(&format!("<a:cs typeface=\"{}\"/>", chart.default_font));
xml.push_str("</a:defRPr>");
xml.push_str("</a:pPr>");
xml.push_str("</a:p>");
xml.push_str("</c:txPr>");
xml.push_str("<c:dLblPos val=\"t\"/>");
xml.push_str("<c:showLegendKey val=\"0\"/>");
xml.push_str("<c:showVal val=\"1\"/>"); // 显示数值
xml.push_str("<c:showCatName val=\"0\"/>"); // 不显示类别名称
xml.push_str("<c:showSerName val=\"0\"/>"); // 不显示系列名称
xml.push_str("<c:showPercent val=\"0\"/>"); // 不显示百分比
xml.push_str("<c:showBubbleSize val=\"0\"/>");
xml.push_str("</c:dLbls>");
}
// 应用平滑曲线设置
let smooth = if let Some(theme) = &chart.theme {
if let Some(line_style_theme) = theme.get("lineStyle") {
line_style_theme
.get("smooth")
.and_then(|v| v.as_bool())
.unwrap_or(false) // 默认不平滑
} else {
if let Some(combo_style_theme) = theme.get("comboStyle") {
combo_style_theme
.get("smooth")
.and_then(|v| v.as_bool())
.unwrap_or(false) // 默认不平滑
} else {
false
}
}
} else {
false
};
xml.push_str(&format!(
"<c:smooth val=\"{}\"/>",
if smooth { "1" } else { "0" }
));
xml.push_str("</c:ser>");
xml
}
// 为面积图创建系列XML - 使用简化的XML结构
fn create_area_series_xml(chart: &Chart, series: &Series, index: usize) -> String {
let values = extract_values(&chart.series_list);
let axis_options = build_axis_options_with_chart(&values, Some(chart), "yAxis");
let mut xml = String::new();
xml.push_str("<c:ser>");
xml.push_str(&format!("<c:idx val=\"{}\"/>", index));
xml.push_str(&format!("<c:order val=\"{}\"/>", index));
xml.push_str(&format!(
"<c:tx><c:v>{}</c:v></c:tx>",
escape_xml(&series.name)
));
// 添加填充样式 - 使用固定格式
xml.push_str("<c:spPr>");
let color = chart.get_color(index);
let color = color.trim_start_matches('#');
xml.push_str(&format!(
"<a:solidFill><a:srgbClr val=\"{}\"/></a:solidFill>",
color
));
// 对面积图添加简单的线条样式
xml.push_str("<a:ln>");
xml.push_str(&format!(
"<a:solidFill><a:srgbClr val=\"{}\"/></a:solidFill>",
color
));
xml.push_str("</a:ln>");
xml.push_str("</c:spPr>");
// 简化标记的处理
xml.push_str("<c:marker><c:symbol val=\"none\"/></c:marker>");
if !series.use_secondary_axis {
// 添加分类数据
xml.push_str("<c:cat><c:strRef><c:f>Sheet1!$A$2:$A$");
xml.push_str(&format!("{}", series.data.len() + 1));
xml.push_str("</c:f><c:strCache><c:ptCount val=\"");
xml.push_str(&format!("{}", series.data.len()));
xml.push_str("\"/>");
for (i, data) in series.data.iter().enumerate() {
xml.push_str(&format!(
"<c:pt idx=\"{}\"><c:v>{}</c:v></c:pt>",
i,
escape_xml(&data.category)
));
}
xml.push_str("</c:strCache></c:strRef></c:cat>");
}
// 添加数值数据 - 使用动态列
let column = index_to_excel_column(index);
xml.push_str(&format!(
"<c:val><c:numRef><c:f>Sheet1!${}$2:${}$",
column, column
));
xml.push_str(&format!("{}", series.data.len() + 1));
xml.push_str("</c:f><c:numCache><c:formatCode>General</c:formatCode><c:ptCount val=\"");
xml.push_str(&format!("{}", series.data.len()));
xml.push_str("\"/>");
for (i, data) in series.data.iter().enumerate() {
xml.push_str(&format!(
"<c:pt idx=\"{}\"><c:v>{}</c:v></c:pt>",
i, data.value
));
}
xml.push_str("</c:numCache></c:numRef></c:val>");
// 检查主题中的数据标签设置
let show_data_labels = should_show_data_labels(chart, &ChartType::Line);
// 添加数据标签配置
if show_data_labels {
let data_label_font_size = get_chart_label_font_size(&chart.locale);
xml.push_str("<c:dLbls>");
if let Some(ref format_code) = axis_options.data_label_format_code {
xml.push_str(&format!(
"<c:numFmt formatCode=\"{}\" sourceLinked=\"0\"/>",
format_code
));
} else {
xml.push_str("<c:numFmt formatCode=\"General\" sourceLinked=\"0\"/>");
}
xml.push_str("<c:spPr>");
xml.push_str("<a:noFill/>");
xml.push_str("<a:ln><a:noFill/></a:ln>");
xml.push_str("<a:effectLst/>");
xml.push_str("</c:spPr>");
xml.push_str("<c:txPr>");
xml.push_str("<a:bodyPr/>");
xml.push_str("<a:lstStyle/>");
xml.push_str("<a:p>");
xml.push_str("<a:pPr>");
xml.push_str(&format!(
"<a:defRPr sz=\"{}\" b=\"0\" i=\"0\" u=\"none\" strike=\"noStrike\">",
data_label_font_size
));
xml.push_str("<a:solidFill><a:srgbClr val=\"000000\"/></a:solidFill>");
xml.push_str(&format!("<a:latin typeface=\"{}\"/>", chart.default_font));
xml.push_str(&format!("<a:ea typeface=\"{}\"/>", chart.default_font));
xml.push_str(&format!("<a:cs typeface=\"{}\"/>", chart.default_font));
xml.push_str("</a:defRPr>");
xml.push_str("</a:pPr>");
xml.push_str("</a:p>");
xml.push_str("</c:txPr>");
xml.push_str("<c:dLblPos val=\"ctr\"/>");
xml.push_str("<c:showLegendKey val=\"0\"/>");
xml.push_str("<c:showVal val=\"1\"/>"); // 显示数值
xml.push_str("<c:showCatName val=\"0\"/>"); // 不显示类别名称
xml.push_str("<c:showSerName val=\"0\"/>"); // 不显示系列名称
xml.push_str("<c:showPercent val=\"0\"/>"); // 不显示百分比
xml.push_str("<c:showBubbleSize val=\"0\"/>");
xml.push_str("</c:dLbls>");
}
xml.push_str("</c:ser>");
xml
}
fn category_axis_label_rotation(chart: &Chart, is_horizontal: bool) -> i32 {
if is_horizontal {
return 0;
}
if let Some(rotation) = chart
.theme
.as_ref()
.and_then(|theme| theme.get("xAxis"))
.and_then(|axis| axis.get("axisLabel"))
.and_then(|label| label.get("rotate"))
.and_then(Value::as_f64)
{
return (rotation.clamp(-60.0, 60.0) * 60_000.0).round() as i32;
}
let categories = chart
.series_list
.iter()
.find(|series| !series.data.is_empty())
.map(|series| {
series
.data
.iter()
.map(|point| point.category.as_str())
.collect::<Vec<_>>()
})
.unwrap_or_default();
if categories.is_empty()
|| categories.iter().all(|label| {
label.chars().all(|ch| {
ch.is_ascii_digit()
|| matches!(ch, '-' | '/' | '.' | ':' | ' ' | '年' | '月' | '日')
})
})
{
return 0;
}
let widths = categories.iter().map(|label| {
label
.chars()
.map(|ch| if ch.is_ascii() { 1usize } else { 2usize })
.sum::<usize>()
});
let max_width = widths.clone().max().unwrap_or(0);
let total_width: usize = widths.sum();
if categories.len() >= 10 || (categories.len() >= 5 && max_width > 14) || total_width > 100 {
-2_700_000
} else {
0
}
}
// 辅助函数:为类别轴创建XML
fn create_cat_axis_xml(chart: &Chart, is_horizontal: bool) -> String {
let axis_font_size = get_chart_label_font_size(&chart.locale);
let label_rotation = category_axis_label_rotation(chart, is_horizontal);
let mut xml = String::new();
xml.push_str("<c:catAx>");
xml.push_str("<c:axId val=\"123456\"/>");
xml.push_str(&format!(
"<c:scaling><c:orientation val=\"{}\"/></c:scaling>",
if is_horizontal { "maxMin" } else { "minMax" }
));
xml.push_str("<c:delete val=\"0\"/>"); // 确保显示轴
xml.push_str(&format!(
"<c:axPos val=\"{}\"/>",
if is_horizontal { "l" } else { "b" }
));
xml.push_str("<c:numFmt formatCode=\"General\" sourceLinked=\"1\"/>");
xml.push_str("<c:majorTickMark val=\"none\"/>"); // 主刻度线向外
xml.push_str("<c:minorTickMark val=\"none\"/>");
xml.push_str("<c:tickLblPos val=\"low\"/>"); // 刻度标签位置
if should_show_grid_lines(chart) {
xml.push_str("<c:spPr>");
xml.push_str("<a:ln w=\"6350\" cap=\"flat\">");
xml.push_str("<a:solidFill><a:srgbClr val=\"64748B\"><a:alpha val=\"30000\"/></a:srgbClr></a:solidFill>");
xml.push_str("</a:ln>");
xml.push_str("</c:spPr>");
} else {
xml.push_str("<c:spPr>");
xml.push_str("<a:ln w=\"0\">");
xml.push_str("<a:noFill/>");
xml.push_str("</a:ln>");
xml.push_str("</c:spPr>");
}
xml.push_str("<c:txPr>");
xml.push_str(&format!(
"<a:bodyPr rot=\"{}\" vert=\"horz\" wrap=\"square\"/>",
label_rotation
));
xml.push_str("<a:lstStyle/>");
xml.push_str("<a:p>");
xml.push_str("<a:pPr>");
xml.push_str(&format!(
"<a:defRPr sz=\"{}\" b=\"0\" i=\"0\" u=\"none\" strike=\"noStrike\">",
axis_font_size
));
xml.push_str("<a:solidFill><a:srgbClr val=\"000000\"/></a:solidFill>");
xml.push_str(&format!("<a:latin typeface=\"{}\"/>", chart.default_font));
xml.push_str(&format!("<a:ea typeface=\"{}\"/>", chart.default_font));
xml.push_str(&format!("<a:cs typeface=\"{}\"/>", chart.default_font));
xml.push_str("</a:defRPr>");
xml.push_str("</a:pPr>");
xml.push_str("<a:endParaRPr lang=\"en-US\"/>");
xml.push_str("</a:p>");
xml.push_str("</c:txPr>");
// 添加轴线名称
if let Some(axis_name) = &chart.x_axis_name {
if !axis_name.is_empty() {
xml.push_str("<c:title>");
xml.push_str("<c:tx>");
xml.push_str("<c:rich>");
xml.push_str("<a:bodyPr rot=\"0\" vert=\"horz\"/>");
xml.push_str("<a:lstStyle/>");
xml.push_str("<a:p>");
xml.push_str("<a:pPr>");
xml.push_str(&format!(
"<a:defRPr sz=\"{}\" b=\"0\" i=\"0\" u=\"none\" strike=\"noStrike\">",
axis_font_size
));
xml.push_str("<a:solidFill><a:srgbClr val=\"000000\"/></a:solidFill>");
xml.push_str(&format!("<a:latin typeface=\"{}\"/>", chart.default_font));
xml.push_str(&format!("<a:ea typeface=\"{}\"/>", chart.default_font));
xml.push_str(&format!("<a:cs typeface=\"{}\"/>", chart.default_font));
xml.push_str("</a:defRPr>");
xml.push_str("</a:pPr>");
xml.push_str("<a:r>");
xml.push_str(&format!(
"<a:rPr lang=\"en-US\" sz=\"{}\" b=\"0\" i=\"0\" u=\"none\" strike=\"noStrike\">",
axis_font_size
));
xml.push_str("<a:solidFill><a:srgbClr val=\"000000\"/></a:solidFill>");
xml.push_str(&format!("<a:latin typeface=\"{}\"/>", chart.default_font));
xml.push_str(&format!("<a:ea typeface=\"{}\"/>", chart.default_font));
xml.push_str(&format!("<a:cs typeface=\"{}\"/>", chart.default_font));
xml.push_str("</a:rPr>");
xml.push_str(&format!("<a:t>{}</a:t>", escape_xml(axis_name)));
xml.push_str("</a:r>");
xml.push_str("</a:p>");
xml.push_str("</c:rich>");
xml.push_str("</c:tx>");
xml.push_str("<c:overlay val=\"0\"/>");
xml.push_str("</c:title>");
}
}
xml.push_str("<c:crossAx val=\"654321\"/>");
// Keep the category axis anchored at the value-axis origin so horizontal
// bars continue to grow from left to right.
xml.push_str("<c:crosses val=\"autoZero\"/>");
xml.push_str("<c:auto val=\"1\"/>");
xml.push_str("<c:lblAlgn val=\"ctr\"/>");
xml.push_str("<c:lblOffset val=\"100\"/>");
xml.push_str("<c:tickLblSkip val=\"1\"/>");
xml.push_str("<c:noMultiLvlLbl val=\"1\"/>");
xml.push_str("</c:catAx>");
xml
}
// 辅助函数:为数值轴创建XML
fn extract_values(series_list: &[Series]) -> Vec<f64> {
// 判断是否为堆叠图
let has_stack = series_list.iter().any(|s| s.is_stacked);
if has_stack {
use std::collections::HashMap;
let mut pos_stack: HashMap<usize, f64> = HashMap::new();
let mut neg_stack: HashMap<usize, f64> = HashMap::new();
for series in series_list {
for (index, data) in series.data.iter().enumerate() {
let value = data.value;
if value >= 0.0 {
*pos_stack.entry(index).or_insert(0.0) += value;
} else {
*neg_stack.entry(index).or_insert(0.0) += value;
}
}
}
let mut combined_values = Vec::new();
combined_values.extend((0..).zip(pos_stack.values()).map(|(_, v)| *v));
combined_values.extend((0..).zip(neg_stack.values()).map(|(_, v)| *v));
combined_values
} else {
// 原始逻辑
let mut all_values = Vec::new();
for series in series_list {
for data in &series.data {
all_values.push(data.value);
}
}
all_values
}
}
// 辅助函数:提取主轴数据(yAxisIndex不为1的数据)
fn extract_primary_axis_values(series_list: &[Series]) -> Vec<f64> {
// 判断是否为堆叠图
let has_stack = series_list.iter().any(|s| s.is_stacked);
if has_stack {
use std::collections::HashMap;
let mut pos_stack: HashMap<usize, f64> = HashMap::new();
let mut neg_stack: HashMap<usize, f64> = HashMap::new();
for series in series_list {
if !series.use_secondary_axis {
for (index, data) in series.data.iter().enumerate() {
let value = data.value;
if value >= 0.0 {
*pos_stack.entry(index).or_insert(0.0) += value;
} else {
*neg_stack.entry(index).or_insert(0.0) += value;
}
}
}
}
let mut combined_values = Vec::new();
combined_values.extend((0..).zip(pos_stack.values()).map(|(_, v)| *v));
combined_values.extend((0..).zip(neg_stack.values()).map(|(_, v)| *v));
combined_values
} else {
// 原始逻辑
let mut all_values = Vec::new();
for series in series_list {
if !series.use_secondary_axis {
// 不使用次坐标轴的系列
for data in &series.data {
all_values.push(data.value);
}
}
}
all_values
}
}
// 辅助函数:提取次轴数据(yAxisIndex为1的数据)
fn extract_secondary_axis_values(series_list: &[Series]) -> Vec<f64> {
// 判断是否为堆叠图
let has_stack = series_list.iter().any(|s| s.is_stacked);
if has_stack {
use std::collections::HashMap;
let mut pos_stack: HashMap<usize, f64> = HashMap::new();
let mut neg_stack: HashMap<usize, f64> = HashMap::new();
for series in series_list {
if series.use_secondary_axis {
for (index, data) in series.data.iter().enumerate() {
let value = data.value;
if value >= 0.0 {
*pos_stack.entry(index).or_insert(0.0) += value;
} else {
*neg_stack.entry(index).or_insert(0.0) += value;
}
}
}
}
let mut combined_values = Vec::new();
combined_values.extend((0..).zip(pos_stack.values()).map(|(_, v)| *v));
combined_values.extend((0..).zip(neg_stack.values()).map(|(_, v)| *v));
combined_values
} else {
// 原始逻辑
let mut all_values = Vec::new();
for series in series_list {
if series.use_secondary_axis {
// 不使用次坐标轴的系列
for data in &series.data {
all_values.push(data.value);
}
}
}
all_values
}
}
fn extract_scatter_x_values(series_list: &[Series]) -> Vec<f64> {
let mut values = Vec::new();
for series in series_list {
if series.chart_type != ChartType::Scatter && series.chart_type != ChartType::Bubble {
continue;
}
for data in &series.data {
if let Some(x) = data.x_value {
values.push(x);
}
}
}
values
}
// 辅助函数:获取自动显示单位
fn get_auto_display_unit(max_value: f64) -> Option<&'static str> {
let abs_val = max_value.abs();
if abs_val <= 0.0 || !abs_val.is_finite() {
return None;
}
if abs_val >= 1_000_000_000_000.0 {
Some("trillions")
} else if abs_val >= 1_000_000_000.0 {
Some("billions")
} else if abs_val >= 1_000_000.0 {
Some("millions")
} else if abs_val >= 1_000.0 {
// 提高阈值避免不必要的单位显示
Some("thousands")
} else {
None
}
}
fn compact_number_format(display_unit: &str) -> Option<&'static str> {
match display_unit {
"thousands" => Some("0.#,"k";-0.#,"k";0"),
"millions" => Some("0.#,,"M";-0.#,,"M";0"),
"billions" => Some("0.#,,,"B";-0.#,,,"B";0"),
"trillions" => Some("0.#,,,,"T";-0.#,,,,"T";0"),
_ => None,
}
}
fn configured_axis_unit_format(chart: Option<&Chart>, axis_key: &str) -> Option<String> {
let unit = chart?
.theme
.as_ref()?
.get(axis_key)?
.get("unit")?
.as_str()?
.trim();
if unit.is_empty() {
return None;
}
let unit = escape_xml(unit);
Some(format!(
"0.##"{unit}";-0.##"{unit}";0"{unit}""
))
}
// 轴选项结构体
#[derive(Debug, Clone)]
pub struct AxisOptions {
pub val_axis_major_unit: f64,
pub val_axis_label_format_code: Option<String>,
pub data_label_format_code: Option<String>,
pub val_axis_display_unit: Option<&'static str>,
pub val_axis_display_unit_label: bool,
pub val_axis_min: Option<f64>,
pub val_axis_max: Option<f64>,
}
// 辅助函数:智能计算刻度间隔,严格显示4个刻度值
fn calculate_smart_interval(max_value: f64, min_value: f64) -> f64 {
if max_value == min_value {
return 1.0; // 避免除零错误
}
let range = max_value - min_value;
// 严格使用3个间隔来确保4个刻度值(起点、中间两个、终点)
let raw_interval = range / 4.0;
// 计算合适的步长
let magnitude = 10_f64.powf(raw_interval.log10().floor());
let normalized = raw_interval / magnitude;
// 选择最接近的"好看"步长
let nice_interval = if normalized <= 1.0 {
1.0
} else if normalized <= 2.0 {
2.0
} else if normalized <= 5.0 {
5.0
} else {
10.0
};
let interval = nice_interval * magnitude;
// 确保间隔能够覆盖整个数据范围并且正好有4个刻度
// 计算对齐后的起始值和结束值
let aligned_min = (min_value / interval).floor() * interval;
let aligned_max = (max_value / interval).ceil() * interval;
let actual_range = aligned_max - aligned_min;
// 如果对齐后的范围需要超过3个间隔,调整间隔大小
if actual_range > interval * 4.0 {
// 重新计算间隔以确保正好3个间隔(4个刻度)
actual_range / 4.0
} else {
interval
}
}
fn build_axis_options_with_chart(
values: &[f64],
chart: Option<&Chart>,
axis_key: &str,
) -> AxisOptions {
if values.is_empty() {
return AxisOptions {
val_axis_major_unit: 0.0,
val_axis_label_format_code: None,
data_label_format_code: None,
val_axis_display_unit: None,
val_axis_display_unit_label: false,
val_axis_max: None,
val_axis_min: None,
};
}
let raw_max = values.iter().cloned().fold(f64::MIN, f64::max);
let raw_min = values.iter().cloned().fold(f64::MAX, f64::min);
// 记录是否指定了最大最小值
let mut user_specified_max = false;
let mut user_specified_min = false;
// 优先使用 chart.theme 中指定轴的最大最小值
let (max_value, min_value) = if let Some(chart_ref) = chart {
if let Some(theme) = &chart_ref.theme {
let theme_max = theme
.get(axis_key)
.and_then(|axis| axis.get("max"))
.and_then(|v| v.as_f64());
let theme_min = theme
.get(axis_key)
.and_then(|axis| axis.get("min"))
.and_then(|v| v.as_f64());
match (theme_max, theme_min) {
(Some(max), Some(min)) => {
user_specified_max = true;
user_specified_min = true;
(max, min)
}
(Some(max), None) => {
user_specified_max = true;
let min = if raw_min >= 0.0 { 0.0 } else { raw_min };
(max, min)
}
(None, Some(min)) => {
user_specified_min = true;
let max = if raw_max <= 0.0 { 0.0 } else { raw_max };
(max, min)
}
(None, None) => apply_default_min_max_logic(raw_max, raw_min),
}
} else {
apply_default_min_max_logic(raw_max, raw_min)
}
} else {
apply_default_min_max_logic(raw_max, raw_min)
};
// 智能计算刻度间隔
let interval = calculate_smart_interval(max_value, min_value);
let adjusted_max = if user_specified_max {
max_value
} else {
(max_value / interval).ceil() * interval
};
let adjusted_min = if user_specified_min {
min_value
} else {
(min_value / interval).floor() * interval
};
let display_units = get_auto_display_unit(adjusted_max);
let mut axis_options = AxisOptions {
val_axis_major_unit: interval,
val_axis_label_format_code: None,
data_label_format_code: None,
val_axis_display_unit: None,
val_axis_display_unit_label: false,
val_axis_max: Some(adjusted_max),
val_axis_min: Some(adjusted_min),
};
// 检查是否为百分比堆叠模式
let is_percentage_mode = if let Some(chart_ref) = chart {
Chart::is_stacked100_mode(&chart_ref.theme)
} else {
false
};
if is_percentage_mode {
// 百分比堆叠模式使用固定的刻度:0%、50%、100%
axis_options.val_axis_major_unit = 0.5; // 间隔50,产生0、50、100三个刻度
axis_options.val_axis_label_format_code = Some("0.#%".to_string());
axis_options.data_label_format_code = Some("0.#%".to_string());
} else if let Some(format_code) = configured_axis_unit_format(chart, axis_key) {
axis_options.val_axis_label_format_code = Some(format_code.clone());
axis_options.data_label_format_code = Some(format_code);
} else if let Some(units) = display_units {
if let Some(format_code) = compact_number_format(units) {
axis_options.val_axis_label_format_code = Some(format_code.to_string());
axis_options.data_label_format_code = Some(format_code.to_string());
}
}
axis_options
}
fn build_scatter_axis_options_with_chart(
values: &[f64],
chart: Option<&Chart>,
axis_key: &str,
padding_ratio: f64,
) -> AxisOptions {
if values.is_empty() {
return AxisOptions {
val_axis_major_unit: 0.0,
val_axis_label_format_code: None,
data_label_format_code: None,
val_axis_display_unit: None,
val_axis_display_unit_label: false,
val_axis_max: None,
val_axis_min: None,
};
}
let raw_max = values.iter().cloned().fold(f64::MIN, f64::max);
let raw_min = values.iter().cloned().fold(f64::MAX, f64::min);
let (theme_max, theme_min) = if let Some(chart_ref) = chart {
if let Some(theme) = &chart_ref.theme {
(
theme
.get(axis_key)
.and_then(|axis| axis.get("max"))
.and_then(|v| v.as_f64()),
theme
.get(axis_key)
.and_then(|axis| axis.get("min"))
.and_then(|v| v.as_f64()),
)
} else {
(None, None)
}
} else {
(None, None)
};
let mut min_value = theme_min.unwrap_or(raw_min);
let mut max_value = theme_max.unwrap_or(raw_max);
if (max_value - min_value).abs() < f64::EPSILON {
min_value -= 1.0;
max_value += 1.0;
} else if theme_min.is_none() || theme_max.is_none() {
let pad = (max_value - min_value) * padding_ratio;
min_value -= pad;
max_value += pad;
}
let interval = round_up_to_axis_multiple(calculate_smart_interval(max_value, min_value), 5.0);
let adjusted_max = (max_value / interval).ceil() * interval;
let adjusted_min = (min_value / interval).floor() * interval;
let display_units = get_auto_display_unit(adjusted_max);
let mut axis_options = AxisOptions {
val_axis_major_unit: interval,
val_axis_label_format_code: Some("0".to_string()),
data_label_format_code: None,
val_axis_display_unit: None,
val_axis_display_unit_label: false,
val_axis_max: Some(adjusted_max),
val_axis_min: Some(adjusted_min),
};
if let Some(units) = display_units {
axis_options.val_axis_display_unit = Some(units);
axis_options.val_axis_display_unit_label = true;
}
axis_options
}
fn round_up_to_axis_multiple(value: f64, step: f64) -> f64 {
if !value.is_finite() || value <= 0.0 || !step.is_finite() || step <= 0.0 {
return step.max(1.0);
}
(value / step).ceil().max(1.0) * step
}
fn apply_default_min_max_logic(raw_max: f64, raw_min: f64) -> (f64, f64) {
if raw_min >= 0.0 {
(raw_max, 0.0)
} else if raw_max <= 0.0 {
(0.0, raw_min)
} else {
(raw_max, raw_min)
}
}
// 单位转换辅助函数
fn get_display_unit_text(unit_label: &str) -> &str {
match unit_label {
"thousands" => "K",
"millions" => "M",
"billions" => "B",
"trillions" => "T",
_ => "",
}
}
fn show_y_axis(chart: &Chart) -> bool {
if let Some(theme) = &chart.theme {
// 检查各种样式设置中的showGridLines
if let Some(y_axis) = theme.get("yAxis") {
if let Some(show) = y_axis.get("show").and_then(|v| v.as_bool()) {
return show;
}
}
}
// 默认显示网格线
true
}
// 辅助函数:检查是否应该显示网格线
fn should_show_grid_lines(chart: &Chart) -> bool {
if let Some(theme) = &chart.theme {
// 检查各种样式设置中的showGridLines
if let Some(waterfall_style) = theme.get("waterfallStyle") {
if let Some(show_grid) = waterfall_style
.get("showGridLines")
.and_then(|v| v.as_bool())
{
return show_grid;
}
}
if let Some(bar_style) = theme.get("barStyle") {
if let Some(show_grid) = bar_style.get("showGridLines").and_then(|v| v.as_bool()) {
return show_grid;
}
}
if let Some(line_style) = theme.get("lineStyle") {
if let Some(show_grid) = line_style.get("showGridLines").and_then(|v| v.as_bool()) {
return show_grid;
}
}
if let Some(combo_style) = theme.get("comboStyle") {
if let Some(show_grid) = combo_style.get("showGridLines").and_then(|v| v.as_bool()) {
return show_grid;
}
}
}
// 默认显示网格线
true
}
fn create_val_axis_xml(chart: &Chart, is_horizontal: bool) -> String {
let axis_font_size = get_chart_label_font_size(&chart.locale);
let mut xml = String::new();
xml.push_str("<c:valAx>");
xml.push_str("<c:axId val=\"654321\"/>");
xml.push_str("<c:scaling>");
xml.push_str("<c:orientation val=\"minMax\"/>");
let values = extract_primary_axis_values(&chart.series_list);
let axis_key = if is_horizontal { "xAxis" } else { "yAxis" };
let axis_options = build_axis_options_with_chart(&values, Some(chart), axis_key);
// 如果是百分比模式,设置固定的最小值和最大值
if Chart::is_stacked100_mode(&chart.theme) {
xml.push_str("<c:min val=\"0\"/>");
xml.push_str("<c:max val=\"1\"/>");
} else if axis_options.val_axis_max.is_some() || axis_options.val_axis_min.is_some() {
if axis_options.val_axis_max.is_some() {
xml.push_str(&format!(
"<c:max val=\"{}\"/>",
axis_options.val_axis_max.unwrap()
));
}
if axis_options.val_axis_min.is_some() {
xml.push_str(&format!(
"<c:min val=\"{}\"/>",
axis_options.val_axis_min.unwrap()
));
}
}
xml.push_str("</c:scaling>");
// 数值轴显示设置(默认显示)
xml.push_str(&format!(
"<c:delete val=\"{}\"/>",
if show_y_axis(chart) { 0 } else { 1 }
));
let position = if is_horizontal { "b" } else { "l" };
xml.push_str(&format!("<c:axPos val=\"{}\"/>", position));
// 数值轴网格线配置(根据主题设置决定是否显示)
if should_show_grid_lines(chart) {
let val_grid_color = "D1D5DB";
let val_grid_size = 6350; // 0.5 * 12700
xml.push_str("<c:majorGridlines>");
xml.push_str("<c:spPr>");
xml.push_str(&format!("<a:ln w=\"{}\" cap=\"flat\">", val_grid_size));
xml.push_str(&format!(
"<a:solidFill><a:srgbClr val=\"{}\"><a:alpha val=\"45000\"/></a:srgbClr></a:solidFill>",
val_grid_color
));
xml.push_str("</a:ln>");
xml.push_str("</c:spPr>");
xml.push_str("</c:majorGridlines>");
}
// 应用轴选项的格式设置
if let Some(ref format_code) = axis_options.val_axis_label_format_code {
xml.push_str(&format!(
"<c:numFmt formatCode=\"{}\" sourceLinked=\"0\"/>",
format_code
));
} else {
xml.push_str("<c:numFmt formatCode=\"General\" sourceLinked=\"0\"/>");
}
// 应用显示单位设置
if let Some(unit_label) = axis_options.val_axis_display_unit {
xml.push_str("<c:dispUnits>");
xml.push_str(&format!("<c:builtInUnit val=\"{}\"/>", unit_label));
if axis_options.val_axis_display_unit_label {
xml.push_str("<c:dispUnitsLbl>");
xml.push_str("<c:layout/>");
xml.push_str("<c:tx><c:rich>");
xml.push_str("<a:bodyPr rot=\"0\" vert=\"horz\"/>");
xml.push_str("<a:lstStyle/>");
xml.push_str("<a:p>");
xml.push_str("<a:r>");
xml.push_str(&format!("<a:rPr lang=\"en-US\" sz=\"{}\"><a:solidFill><a:srgbClr val=\"000000\"/></a:solidFill><a:latin typeface=\"{}\"/></a:rPr>", axis_font_size, chart.default_font));
xml.push_str(&format!(
"<a:t>{}</a:t>",
escape_xml(get_display_unit_text(unit_label))
));
xml.push_str("</a:r>");
xml.push_str("</a:p>");
xml.push_str("</c:rich></c:tx>");
xml.push_str("</c:dispUnitsLbl>");
}
xml.push_str("</c:dispUnits>");
}
xml.push_str(&format!(
"<c:majorUnit val=\"{}\"/>",
axis_options.val_axis_major_unit
));
// 刻度标记配置(默认不显示)
let val_tick_mark = "none";
xml.push_str(&format!("<c:majorTickMark val=\"{}\"/>", val_tick_mark));
xml.push_str("<c:minorTickMark val=\"none\"/>");
xml.push_str(&format!(
"<c:tickLblPos val=\"{}\"/>",
if is_horizontal { "low" } else { "nextTo" }
));
// 数值轴线样式配置(使用默认配置)
let val_line_color = "64748B";
let val_line_size = 6350; // 0.5 * 12700
if should_show_grid_lines(chart) {
xml.push_str("<c:spPr>");
xml.push_str(&format!("<a:ln w=\"{}\" cap=\"flat\">", val_line_size));
xml.push_str(&format!(
"<a:solidFill><a:srgbClr val=\"{}\"><a:alpha val=\"30000\"/></a:srgbClr></a:solidFill>",
val_line_color
));
xml.push_str("</a:ln>");
xml.push_str("</c:spPr>");
} else {
xml.push_str("<c:spPr>");
xml.push_str("<a:ln w=\"0\">");
xml.push_str("<a:noFill/>");
xml.push_str("</a:ln>");
xml.push_str("</c:spPr>");
}
// 文本样式
xml.push_str("<c:txPr>");
xml.push_str("<a:bodyPr rot=\"0\" vert=\"horz\"/>");
xml.push_str("<a:lstStyle/>");
xml.push_str("<a:p>");
xml.push_str("<a:pPr>");
xml.push_str(&format!(
"<a:defRPr sz=\"{}\" b=\"0\" i=\"0\" u=\"none\" strike=\"noStrike\">",
axis_font_size
));
xml.push_str("<a:solidFill><a:srgbClr val=\"000000\"/></a:solidFill>");
xml.push_str(&format!("<a:latin typeface=\"{}\"/>", chart.default_font));
xml.push_str(&format!("<a:ea typeface=\"{}\"/>", chart.default_font));
xml.push_str(&format!("<a:cs typeface=\"{}\"/>", chart.default_font));
xml.push_str("</a:defRPr>");
xml.push_str("</a:pPr>");
xml.push_str("<a:endParaRPr lang=\"en-US\"/>");
xml.push_str("</a:p>");
xml.push_str("</c:txPr>");
if let Some(axis_name) = &chart.y_axis_name {
if !axis_name.is_empty() {
xml.push_str("<c:title>");
xml.push_str("<c:tx>");
xml.push_str("<c:rich>");
// 将 y 轴名称设置为垂直显示(逆时针旋转90度,即270度)
xml.push_str("<a:bodyPr rot=\"-5400000\" vert=\"horz\"/>");
xml.push_str("<a:lstStyle/>");
xml.push_str("<a:p>");
xml.push_str("<a:pPr>");
xml.push_str(&format!(
"<a:defRPr sz=\"{}\" b=\"0\" i=\"0\" u=\"none\" strike=\"noStrike\">",
axis_font_size
));
xml.push_str("<a:solidFill><a:srgbClr val=\"000000\"/></a:solidFill>");
xml.push_str(&format!("<a:latin typeface=\"{}\"/>", chart.default_font));
xml.push_str(&format!("<a:ea typeface=\"{}\"/>", chart.default_font));
xml.push_str(&format!("<a:cs typeface=\"{}\"/>", chart.default_font));
xml.push_str("</a:defRPr>");
xml.push_str("</a:pPr>");
xml.push_str("<a:r>");
xml.push_str(&format!(
"<a:rPr lang=\"en-US\" sz=\"{}\" b=\"0\" i=\"0\" u=\"none\" strike=\"noStrike\">",
axis_font_size
));
xml.push_str("<a:solidFill><a:srgbClr val=\"000000\"/></a:solidFill>");
xml.push_str(&format!("<a:latin typeface=\"{}\"/>", chart.default_font));
xml.push_str(&format!("<a:ea typeface=\"{}\"/>", chart.default_font));
xml.push_str(&format!("<a:cs typeface=\"{}\"/>", chart.default_font));
xml.push_str("</a:rPr>");
xml.push_str(&format!("<a:t>{}</a:t>", escape_xml(axis_name)));
xml.push_str("</a:r>");
xml.push_str("</a:p>");
xml.push_str("</c:rich>");
xml.push_str("</c:tx>");
xml.push_str("<c:overlay val=\"0\"/>");
xml.push_str("</c:title>");
}
}
xml.push_str("<c:crossAx val=\"123456\"/>");
// With reversed horizontal-bar categories, the value axis must cross the
// category axis at its maximum category to remain below the bars. Put this
// on valAx—not catAx—so the Y axis stays on the left and bars keep their
// original direction.
xml.push_str(&format!(
"<c:crosses val=\"{}\"/>",
if is_horizontal { "max" } else { "autoZero" }
));
xml.push_str("<c:crossBetween val=\"between\"/>");
xml.push_str("</c:valAx>");
xml
}
// 辅助函数:为次数值轴创建XML
fn create_secondary_val_axis_xml(chart: &Chart, is_horizontal: bool) -> String {
let axis_font_size = get_chart_label_font_size(&chart.locale);
let mut xml = String::new();
xml.push_str("<c:valAx>");
// 使用不同的ID以区分主坐标轴和次坐标轴
xml.push_str("<c:axId val=\"987654\"/>");
xml.push_str("<c:scaling>");
xml.push_str("<c:orientation val=\"minMax\"/>");
let values = extract_secondary_axis_values(&chart.series_list);
let axis_options = build_axis_options_with_chart(&values, Some(chart), "yAxis2");
// 如果是百分比模式,设置固定的最小值和最大值
if Chart::is_stacked100_mode(&chart.theme) {
xml.push_str("<c:min val=\"0\"/>");
xml.push_str("<c:max val=\"1\"/>");
} else if axis_options.val_axis_max.is_some() || axis_options.val_axis_min.is_some() {
if axis_options.val_axis_max.is_some() {
xml.push_str(&format!(
"<c:max val=\"{}\"/>",
axis_options.val_axis_max.unwrap()
));
}
if axis_options.val_axis_min.is_some() {
xml.push_str(&format!(
"<c:min val=\"{}\"/>",
axis_options.val_axis_min.unwrap()
));
}
}
xml.push_str("</c:scaling>");
// 次数值轴显示设置(默认显示)
xml.push_str(&format!(
"<c:delete val=\"{}\"/>",
if show_y_axis(chart) { 0 } else { 1 }
));
// 次坐标轴位于右侧
let position = if is_horizontal { "r" } else { "t" };
xml.push_str(&format!("<c:axPos val=\"{}\"/>", position));
// 次数值轴网格线配置(根据主题设置决定是否显示)
if should_show_grid_lines(chart) {
let val_grid_color = "D1D5DB";
let val_grid_size = 6350; // 0.5 * 12700
xml.push_str("<c:majorGridlines>");
xml.push_str("<c:spPr>");
xml.push_str(&format!("<a:ln w=\"{}\" cap=\"flat\">", val_grid_size));
xml.push_str(&format!(
"<a:solidFill><a:srgbClr val=\"{}\"><a:alpha val=\"45000\"/></a:srgbClr></a:solidFill>",
val_grid_color
));
xml.push_str("</a:ln>");
xml.push_str("</c:spPr>");
xml.push_str("</c:majorGridlines>");
}
// 应用轴选项的格式设置
if let Some(ref format_code) = axis_options.val_axis_label_format_code {
xml.push_str(&format!(
"<c:numFmt formatCode=\"{}\" sourceLinked=\"0\"/>",
format_code
));
} else {
xml.push_str("<c:numFmt formatCode=\"General\" sourceLinked=\"0\"/>");
}
// 应用显示单位设置
if let Some(unit_label) = axis_options.val_axis_display_unit {
xml.push_str("<c:dispUnits>");
xml.push_str(&format!("<c:builtInUnit val=\"{}\"/>", unit_label));
if axis_options.val_axis_display_unit_label {
xml.push_str("<c:dispUnitsLbl>");
xml.push_str("<c:layout/>");
xml.push_str("<c:tx><c:rich>");
xml.push_str("<a:bodyPr rot=\"0\" vert=\"horz\"/>");
xml.push_str("<a:lstStyle/>");
xml.push_str("<a:p>");
xml.push_str("<a:r>");
xml.push_str(&format!("<a:rPr lang=\"en-US\" sz=\"{}\"><a:solidFill><a:srgbClr val=\"000000\"/></a:solidFill><a:latin typeface=\"{}\"/></a:rPr>", axis_font_size, chart.default_font));
xml.push_str(&format!(
"<a:t>{}</a:t>",
escape_xml(get_display_unit_text(unit_label))
));
xml.push_str("</a:r>");
xml.push_str("</a:p>");
xml.push_str("</c:rich></c:tx>");
xml.push_str("</c:dispUnitsLbl>");
}
xml.push_str("</c:dispUnits>");
}
xml.push_str(&format!(
"<c:majorUnit val=\"{}\"/>",
axis_options.val_axis_major_unit
));
// 刻度标记配置(默认不显示)
let val_tick_mark = "none";
xml.push_str(&format!("<c:majorTickMark val=\"{}\"/>", val_tick_mark));
xml.push_str("<c:minorTickMark val=\"none\"/>");
xml.push_str(&format!(
"<c:tickLblPos val=\"{}\"/>",
if is_horizontal { "low" } else { "nextTo" }
));
// 次数值轴线样式配置(使用稍微不同的样式以区分主轴)
let val_line_color = "64748B";
let val_line_size = 6350; // 0.5 * 12700
if should_show_grid_lines(chart) {
xml.push_str("<c:spPr>");
xml.push_str(&format!("<a:ln w=\"{}\" cap=\"flat\">", val_line_size));
xml.push_str(&format!(
"<a:solidFill><a:srgbClr val=\"{}\"><a:alpha val=\"30000\"/></a:srgbClr></a:solidFill>",
val_line_color
));
xml.push_str("</a:ln>");
xml.push_str("</c:spPr>");
} else {
xml.push_str("<c:spPr>");
xml.push_str("<a:ln w=\"0\">");
xml.push_str("<a:noFill/>");
xml.push_str("</a:ln>");
xml.push_str("</c:spPr>");
}
// 文本样式
xml.push_str("<c:txPr>");
xml.push_str("<a:bodyPr rot=\"0\" vert=\"horz\"/>");
xml.push_str("<a:lstStyle/>");
xml.push_str("<a:p>");
xml.push_str("<a:pPr>");
xml.push_str(&format!(
"<a:defRPr sz=\"{}\" b=\"0\" i=\"0\" u=\"none\" strike=\"noStrike\">",
axis_font_size
));
xml.push_str("<a:solidFill><a:srgbClr val=\"000000\"/></a:solidFill>");
xml.push_str(&format!("<a:latin typeface=\"{}\"/>", chart.default_font));
xml.push_str(&format!("<a:ea typeface=\"{}\"/>", chart.default_font));
xml.push_str(&format!("<a:cs typeface=\"{}\"/>", chart.default_font));
xml.push_str("</a:defRPr>");
xml.push_str("</a:pPr>");
xml.push_str("<a:endParaRPr lang=\"en-US\"/>");
xml.push_str("</a:p>");
xml.push_str("</c:txPr>");
// 添加次轴名称
if let Some(axis_name) = &chart.secondary_y_axis_name {
if !axis_name.is_empty() {
xml.push_str("<c:title>");
xml.push_str("<c:tx>");
xml.push_str("<c:rich>");
// 将 y 轴名称设置为垂直显示(逆时针旋转90度,即270度)
xml.push_str("<a:bodyPr rot=\"-5400000\" vert=\"horz\"/>");
xml.push_str("<a:lstStyle/>");
xml.push_str("<a:p>");
xml.push_str("<a:pPr>");
xml.push_str(&format!(
"<a:defRPr sz=\"{}\" b=\"0\" i=\"0\" u=\"none\" strike=\"noStrike\">",
axis_font_size
));
xml.push_str("<a:solidFill><a:srgbClr val=\"000000\"/></a:solidFill>");
xml.push_str(&format!("<a:latin typeface=\"{}\"/>", chart.default_font));
xml.push_str(&format!("<a:ea typeface=\"{}\"/>", chart.default_font));
xml.push_str(&format!("<a:cs typeface=\"{}\"/>", chart.default_font));
xml.push_str("</a:defRPr>");
xml.push_str("</a:pPr>");
xml.push_str("<a:r>");
xml.push_str(&format!(
"<a:rPr lang=\"en-US\" sz=\"{}\" b=\"0\" i=\"0\" u=\"none\" strike=\"noStrike\">",
axis_font_size
));
xml.push_str("<a:solidFill><a:srgbClr val=\"000000\"/></a:solidFill>");
xml.push_str(&format!("<a:latin typeface=\"{}\"/>", chart.default_font));
xml.push_str(&format!("<a:ea typeface=\"{}\"/>", chart.default_font));
xml.push_str(&format!("<a:cs typeface=\"{}\"/>", chart.default_font));
xml.push_str("</a:rPr>");
xml.push_str(&format!("<a:t>{}</a:t>", escape_xml(axis_name)));
xml.push_str("</a:r>");
xml.push_str("</a:p>");
xml.push_str("</c:rich>");
xml.push_str("</c:tx>");
xml.push_str("<c:overlay val=\"0\"/>");
xml.push_str("</c:title>");
}
}
xml.push_str("<c:crossAx val=\"123456\"/>");
xml.push_str("<c:crosses val=\"max\"/>"); // 与分类轴在最大值处相交
xml.push_str("<c:crossBetween val=\"between\"/>");
xml.push_str("</c:valAx>");
xml
}
fn create_scatter_x_axis_xml(chart: &Chart) -> String {
let axis_font_size = get_chart_label_font_size(&chart.locale);
let axis_options = build_scatter_axis_options_with_chart(
&extract_scatter_x_values(&chart.series_list),
Some(chart),
"xAxis",
0.05,
);
let mut xml = String::new();
xml.push_str("<c:valAx>");
xml.push_str("<c:axId val=\"123456\"/>");
xml.push_str("<c:scaling><c:orientation val=\"minMax\"/>");
if let Some(max) = axis_options.val_axis_max {
xml.push_str(&format!("<c:max val=\"{}\"/>", max));
}
if let Some(min) = axis_options.val_axis_min {
xml.push_str(&format!("<c:min val=\"{}\"/>", min));
}
xml.push_str("</c:scaling>");
xml.push_str("<c:delete val=\"0\"/><c:axPos val=\"b\"/>");
if should_show_grid_lines(chart) {
xml.push_str("<c:majorGridlines><c:spPr><a:ln w=\"6350\" cap=\"flat\"><a:solidFill><a:srgbClr val=\"D1D5DB\"><a:alpha val=\"45000\"/></a:srgbClr></a:solidFill></a:ln></c:spPr></c:majorGridlines>");
}
if let Some(ref format_code) = axis_options.val_axis_label_format_code {
xml.push_str(&format!(
"<c:numFmt formatCode=\"{}\" sourceLinked=\"0\"/>",
format_code
));
} else {
xml.push_str("<c:numFmt formatCode=\"General\" sourceLinked=\"0\"/>");
}
xml.push_str(&format!(
"<c:majorUnit val=\"{}\"/>",
axis_options.val_axis_major_unit
));
xml.push_str("<c:majorTickMark val=\"none\"/><c:minorTickMark val=\"none\"/><c:tickLblPos val=\"nextTo\"/>");
xml.push_str("<c:spPr><a:ln w=\"0\"><a:noFill/></a:ln></c:spPr>");
xml.push_str("<c:txPr><a:bodyPr rot=\"0\" vert=\"horz\"/><a:lstStyle/><a:p><a:pPr>");
xml.push_str(&format!("<a:defRPr sz=\"{}\" b=\"0\" i=\"0\" u=\"none\" strike=\"noStrike\"><a:solidFill><a:srgbClr val=\"000000\"/></a:solidFill><a:latin typeface=\"{}\"/><a:ea typeface=\"{}\"/><a:cs typeface=\"{}\"/></a:defRPr>", axis_font_size, chart.default_font, chart.default_font, chart.default_font));
xml.push_str("</a:pPr><a:endParaRPr lang=\"en-US\"/></a:p></c:txPr>");
if let Some(axis_name) = &chart.x_axis_name {
if !axis_name.is_empty() {
xml.push_str("<c:title><c:tx><c:rich><a:bodyPr rot=\"0\" vert=\"horz\"/><a:lstStyle/><a:p><a:pPr>");
xml.push_str(&format!("<a:defRPr sz=\"{}\" b=\"0\" i=\"0\" u=\"none\" strike=\"noStrike\"><a:solidFill><a:srgbClr val=\"000000\"/></a:solidFill><a:latin typeface=\"{}\"/><a:ea typeface=\"{}\"/><a:cs typeface=\"{}\"/></a:defRPr>", axis_font_size, chart.default_font, chart.default_font, chart.default_font));
xml.push_str("</a:pPr><a:r>");
xml.push_str(&format!("<a:rPr lang=\"en-US\" sz=\"{}\" b=\"0\" i=\"0\" u=\"none\" strike=\"noStrike\"><a:solidFill><a:srgbClr val=\"000000\"/></a:solidFill><a:latin typeface=\"{}\"/><a:ea typeface=\"{}\"/><a:cs typeface=\"{}\"/></a:rPr><a:t>{}</a:t>", axis_font_size, chart.default_font, chart.default_font, chart.default_font, escape_xml(axis_name)));
xml.push_str("</a:r></a:p></c:rich></c:tx><c:overlay val=\"0\"/></c:title>");
}
}
xml.push_str("<c:crossAx val=\"654321\"/><c:crosses val=\"min\"/><c:crossBetween val=\"midCat\"/></c:valAx>");
xml
}
fn create_scatter_y_axis_xml(chart: &Chart) -> String {
let values = extract_values(&chart.series_list);
let axis_options = build_scatter_axis_options_with_chart(&values, Some(chart), "yAxis", 0.08);
let axis_font_size = get_chart_label_font_size(&chart.locale);
let mut xml = String::new();
xml.push_str("<c:valAx>");
xml.push_str("<c:axId val=\"654321\"/>");
xml.push_str("<c:scaling><c:orientation val=\"minMax\"/>");
if let Some(max) = axis_options.val_axis_max {
xml.push_str(&format!("<c:max val=\"{}\"/>", max));
}
if let Some(min) = axis_options.val_axis_min {
xml.push_str(&format!("<c:min val=\"{}\"/>", min));
}
xml.push_str("</c:scaling>");
xml.push_str("<c:delete val=\"0\"/><c:axPos val=\"l\"/>");
if should_show_grid_lines(chart) {
xml.push_str("<c:majorGridlines><c:spPr><a:ln w=\"6350\" cap=\"flat\"><a:solidFill><a:srgbClr val=\"D1D5DB\"><a:alpha val=\"45000\"/></a:srgbClr></a:solidFill></a:ln></c:spPr></c:majorGridlines>");
}
if let Some(ref format_code) = axis_options.val_axis_label_format_code {
xml.push_str(&format!(
"<c:numFmt formatCode=\"{}\" sourceLinked=\"0\"/>",
format_code
));
} else {
xml.push_str("<c:numFmt formatCode=\"General\" sourceLinked=\"0\"/>");
}
if let Some(unit_label) = axis_options.val_axis_display_unit {
xml.push_str("<c:dispUnits><c:builtInUnit val=\"");
xml.push_str(unit_label);
xml.push_str("\"/>");
if axis_options.val_axis_display_unit_label {
xml.push_str("<c:dispUnitsLbl><c:layout/><c:tx><c:rich><a:bodyPr rot=\"0\" vert=\"horz\"/><a:lstStyle/><a:p><a:r>");
xml.push_str(&format!("<a:rPr lang=\"en-US\" sz=\"1000\"><a:solidFill><a:srgbClr val=\"000000\"/></a:solidFill><a:latin typeface=\"{f}\"/><a:ea typeface=\"{f}\"/><a:cs typeface=\"{f}\"/></a:rPr>", f = chart.default_font));
xml.push_str(&format!(
"<a:t>{}</a:t>",
escape_xml(get_display_unit_text(unit_label))
));
xml.push_str("</a:r></a:p></c:rich></c:tx></c:dispUnitsLbl>");
}
xml.push_str("</c:dispUnits>");
}
xml.push_str(&format!(
"<c:majorUnit val=\"{}\"/>",
axis_options.val_axis_major_unit
));
xml.push_str("<c:majorTickMark val=\"none\"/><c:minorTickMark val=\"none\"/><c:tickLblPos val=\"nextTo\"/>");
xml.push_str("<c:spPr><a:ln w=\"0\"><a:noFill/></a:ln></c:spPr>");
xml.push_str("<c:txPr><a:bodyPr rot=\"-5400000\" vert=\"horz\"/><a:lstStyle/><a:p><a:pPr>");
xml.push_str(&format!("<a:defRPr sz=\"{}\" b=\"0\" i=\"0\" u=\"none\" strike=\"noStrike\"><a:solidFill><a:srgbClr val=\"000000\"/></a:solidFill><a:latin typeface=\"{}\"/><a:ea typeface=\"{}\"/><a:cs typeface=\"{}\"/></a:defRPr>", axis_font_size, chart.default_font, chart.default_font, chart.default_font));
xml.push_str("</a:pPr><a:endParaRPr lang=\"en-US\"/></a:p></c:txPr>");
if let Some(axis_name) = &chart.y_axis_name {
if !axis_name.is_empty() {
xml.push_str("<c:title><c:tx><c:rich><a:bodyPr rot=\"-5400000\" vert=\"horz\"/><a:lstStyle/><a:p><a:pPr>");
xml.push_str(&format!("<a:defRPr sz=\"{}\" b=\"0\" i=\"0\" u=\"none\" strike=\"noStrike\"><a:solidFill><a:srgbClr val=\"000000\"/></a:solidFill><a:latin typeface=\"{}\"/><a:ea typeface=\"{}\"/><a:cs typeface=\"{}\"/></a:defRPr>", axis_font_size, chart.default_font, chart.default_font, chart.default_font));
xml.push_str("</a:pPr><a:r>");
xml.push_str(&format!("<a:rPr lang=\"en-US\" sz=\"{}\" b=\"0\" i=\"0\" u=\"none\" strike=\"noStrike\"><a:solidFill><a:srgbClr val=\"000000\"/></a:solidFill><a:latin typeface=\"{}\"/><a:ea typeface=\"{}\"/><a:cs typeface=\"{}\"/></a:rPr><a:t>{}</a:t>", axis_font_size, chart.default_font, chart.default_font, chart.default_font, escape_xml(axis_name)));
xml.push_str("</a:r></a:p></c:rich></c:tx><c:overlay val=\"0\"/></c:title>");
}
}
xml.push_str("<c:crossAx val=\"123456\"/><c:crosses val=\"min\"/><c:crossBetween val=\"midCat\"/></c:valAx>");
xml
}
fn create_bubble_x_axis_xml(chart: &Chart) -> String {
create_scatter_x_axis_xml(chart)
}
fn create_bubble_y_axis_xml(chart: &Chart) -> String {
create_scatter_y_axis_xml(chart)
}
// 辅助函数:创建图例XML
fn create_legend_xml(chart: &Chart) -> String {
let mut xml = String::new();
let legend_pos = "t"; // 顶部
let legend_font_size = get_chart_label_font_size(&chart.locale);
// ===== legend 开始 =====
xml.push_str("<c:legend>");
// legend 位置
xml.push_str(&format!("<c:legendPos val=\"{}\"/>", legend_pos));
// 不覆盖绘图区
xml.push_str("<c:overlay val=\"0\"/>");
xml.push_str("<c:txPr>");
xml.push_str("<a:bodyPr/>");
xml.push_str("<a:lstStyle/>");
xml.push_str("<a:p>");
// ---- 段落属性 ----
xml.push_str("<a:pPr>");
xml.push_str(&format!("<a:defRPr sz=\"{}\">", legend_font_size));
xml.push_str("</a:defRPr>");
xml.push_str("</a:pPr>");
// ⚠️ Chart 必须存在 run(JS 里靠 Office 容错)
xml.push_str("<a:r><a:t/></a:r>");
// JS 里的 endParaRPr
xml.push_str("<a:endParaRPr lang=\"en-US\"/>");
xml.push_str("</a:p>");
xml.push_str("</c:txPr>");
// ===== legend 结束 =====
xml.push_str("</c:legend>");
xml
}
/// 生成添加图表到文档中所需的绘图XML
pub fn generate_chart_drawing_xml(chart: &Chart, chart_id: usize) -> String {
// 生成唯一的ID - 这里使用简单的随机数方法
let anchor_id = format!("{:08X}", chart_id * 16 + 1);
let edit_id = format!("{:08X}", chart_id * 16 + 2);
let graphic_data = if chart.uses_chart_ex() {
format!(
r#"<a:graphicData uri="http://schemas.microsoft.com/office/drawing/2014/chartex">
<cx:chart xmlns:cx="http://schemas.microsoft.com/office/drawing/2014/chartex"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
r:id="rId{}"/>
</a:graphicData>"#,
chart_id
)
} else {
format!(
r#"<a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">
<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
r:id="rId{}"/>
</a:graphicData>"#,
chart_id
)
};
format!(
r#"
<w:p>
<w:r>
<w:drawing>
<wp:inline distT="0" distB="0" distL="0" distR="0" wp14:anchorId="{}" wp14:editId="{}">
<wp:extent cx="5486400" cy="3200400"/>
<wp:effectExtent l="0" t="0" r="0" b="0"/>
<wp:docPr id="{}" name="Chart {}"/>
<wp:cNvGraphicFramePr>
<a:graphicFrameLocks xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" noChangeAspect="1"/>
</wp:cNvGraphicFramePr>
<a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
{}
</a:graphic>
</wp:inline>
</w:drawing>
</w:r>
</w:p>"#,
anchor_id, edit_id, chart_id, chart_id, graphic_data
)
}
fn escape_xml(text: &str) -> String {
text.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace("\"", """)
.replace("'", "'")
}
fn normalize_chart_hex_color(color: &str) -> String {
color.trim_start_matches('#').to_uppercase()
}
fn chart_ex_axis_sppr(chart: &Chart) -> String {
if should_show_grid_lines(chart) {
r#"<cs:spPr><a:ln w="6350" cap="flat"><a:solidFill><a:srgbClr val="64748B"><a:alpha val="30000"/></a:srgbClr></a:solidFill></a:ln></cs:spPr>"#.to_string()
} else {
r#"<cs:spPr><a:ln w="0"><a:noFill/></a:ln></cs:spPr>"#.to_string()
}
}
fn chart_ex_gridline_sppr(chart: &Chart) -> String {
if should_show_grid_lines(chart) {
r#"<cs:spPr><a:ln w="6350" cap="flat"><a:solidFill><a:srgbClr val="D1D5DB"><a:alpha val="45000"/></a:srgbClr></a:solidFill></a:ln></cs:spPr>"#.to_string()
} else {
r#"<cs:spPr><a:ln w="0"><a:noFill/></a:ln></cs:spPr>"#.to_string()
}
}
fn chart_background_fill(chart: &Chart) -> String {
let background = chart
.theme
.as_ref()
.and_then(|theme| theme.get("background"));
let color = match background.and_then(|value| value["type"].as_str()) {
Some("custom") => background.and_then(|value| value["color"].as_str()),
Some("light") => Some("FFFFFF"),
Some("dark") => Some("1a1a1a"),
_ => None,
};
if let Some(color) = color {
let hex = color.trim_start_matches('#');
if hex.len() == 6 && hex.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return format!(
"<a:solidFill><a:srgbClr val=\"{}\"/></a:solidFill>",
hex.to_ascii_uppercase()
);
}
}
"<a:noFill/>".into()
}
fn chart_ex_chart_area_sppr(chart: &Chart) -> String {
format!(
"<cs:spPr>{}<a:ln w=\"0\"><a:noFill/></a:ln></cs:spPr>",
chart_background_fill(chart)
)
}
fn chart_ex_plot_area_sppr() -> String {
r#"<cs:spPr><a:noFill/><a:ln w="0"><a:noFill/></a:ln></cs:spPr>"#.to_string()
}
fn chart_frontend_palette_colors(chart: &Chart) -> Vec<String> {
if let Some(theme) = &chart.theme {
if let Some(palette) = theme.get("palette") {
if let Some(custom_monochrome) =
palette.get("customMonochrome").and_then(|v| v.as_array())
{
let colors: Vec<String> = custom_monochrome
.iter()
.filter_map(|v| v.as_str())
.map(normalize_chart_hex_color)
.collect();
if !colors.is_empty() {
return colors;
}
}
let palette_type = palette.get("type").and_then(|v| v.as_str()).unwrap_or("");
match palette_type {
"preset" => {
let preset = palette.get("preset").and_then(|v| v.as_str()).unwrap_or("");
let colors = match preset {
"blue" => vec!["2F51FF", "0D0D0D", "757575", "01A9F5", "99E6FF"],
"teal" => vec!["14B8A6", "0D0D0D", "757575", "2DD4BF", "CCFBF1"],
"purple" => vec!["9333EA", "0D0D0D", "757575", "C084FC", "D8B4FE"],
"green" => vec!["1F7250", "0D0D0D", "757575", "35AC5F", "AAD5A7"],
"sky" => vec!["38BDF8", "0EA5E9", "0284C7", "7DD3FC", "BAE6FD"],
"dark" => vec!["163E64", "DFD8CC", "C5B6A4", "665E56", "215F9A"],
"monochrome" => vec!["4765FF", "6982FF", "8BA2FF", "ADC6FF", "D6E4FF"],
"colorful" => vec!["EF4444", "F59E0B", "10B981", "3B82F6", "8B5CF6"],
"neon" => vec!["F0ABFC", "C084FC", "818CF8", "34D399", "FBBF24"],
"graphy" => vec!["1E293B", "334155", "475569", "64748B", "94A3B8"],
"pastel" => vec!["FECACA", "FED7AA", "FEF08A", "BBF7D0", "BFDBFE"],
_ => vec!["4765FF", "6982FF", "8BA2FF", "ADC6FF", "D6E4FF"],
};
return colors.into_iter().map(|s| s.to_string()).collect();
}
"brand-kit" => {
let colors: Vec<String> = palette
.get("brandColors")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.map(normalize_chart_hex_color)
.collect()
})
.unwrap_or_default();
if !colors.is_empty() {
return colors;
}
return vec![
"0EA5E9".to_string(),
"6366F1".to_string(),
"EC4899".to_string(),
"F59E0B".to_string(),
"10B981".to_string(),
];
}
"freestyle" => {
let colors: Vec<String> = palette
.get("customColors")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.map(normalize_chart_hex_color)
.collect()
})
.unwrap_or_default();
if !colors.is_empty() {
return colors;
}
}
_ => {}
}
}
}
vec![
"4765FF".to_string(),
"6982FF".to_string(),
"8BA2FF".to_string(),
"ADC6FF".to_string(),
"D6E4FF".to_string(),
]
}
fn chart_ex_palette_colors(chart: &Chart) -> [String; 6] {
let palette = chart_frontend_palette_colors(chart);
[
palette
.first()
.cloned()
.unwrap_or_else(|| "4765FF".to_string()),
palette
.get(1)
.cloned()
.unwrap_or_else(|| "EF4444".to_string()),
palette
.get(2)
.cloned()
.unwrap_or_else(|| "6B7280".to_string()),
palette
.get(3)
.cloned()
.unwrap_or_else(|| "111827".to_string()),
palette
.get(4)
.cloned()
.unwrap_or_else(|| "D6E4FF".to_string()),
palette
.get(5)
.cloned()
.unwrap_or_else(|| "F0F5FF".to_string()),
]
}
// 将索引转换为Excel列字母 (0 -> B, 1 -> C, 2 -> D, ...)
// 注意:索引0对应列B,因为列A用于类别
fn index_to_excel_column(index: usize) -> String {
let column_index = index + 2; // 0 -> 2 (B), 1 -> 3 (C), 2 -> 4 (D), etc.
let mut result = String::new();
let mut n = column_index;
while n > 0 {
n -= 1;
result.push((b'A' + (n % 26) as u8) as char);
n /= 26;
}
result.chars().rev().collect()
}
fn index_to_excel_column_zero_based(index: usize) -> String {
let mut result = String::new();
let mut n = index + 1;
while n > 0 {
n -= 1;
result.push((b'A' + (n % 26) as u8) as char);
n /= 26;
}
result.chars().rev().collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bar_spacing_maps_to_office_gap_width() {
for (spacing, expected) in [
("small", 70),
("medium", 110),
("large", 160),
("extraLarge", 220),
] {
let theme = Some(serde_json::json!({ "barStyle": { "spacing": spacing } }));
assert_eq!(Chart::get_bar_gap_width(&theme), expected);
}
assert_eq!(Chart::get_bar_gap_width(&None), 110);
}
#[test]
fn horizontal_bar_keeps_y_axis_left_and_value_axis_at_bottom() {
let chart = Chart::new("Ordering".to_string());
let horizontal_axis = create_cat_axis_xml(&chart, true);
assert!(horizontal_axis.contains("<c:orientation val=\"maxMin\"/>"));
assert!(horizontal_axis.contains("<c:crosses val=\"autoZero\"/>"));
let vertical_axis = create_cat_axis_xml(&chart, false);
assert!(vertical_axis.contains("<c:orientation val=\"minMax\"/>"));
assert!(vertical_axis.contains("<c:crosses val=\"autoZero\"/>"));
let horizontal_value_axis = create_val_axis_xml(&chart, true);
assert!(horizontal_value_axis.contains("<c:axPos val=\"b\"/>"));
assert!(horizontal_value_axis.contains("<c:tickLblPos val=\"low\"/>"));
assert!(horizontal_value_axis.contains("<c:crosses val=\"max\"/>"));
let vertical_value_axis = create_val_axis_xml(&chart, false);
assert!(vertical_value_axis.contains("<c:tickLblPos val=\"nextTo\"/>"));
assert!(vertical_value_axis.contains("<c:crosses val=\"autoZero\"/>"));
}
#[test]
fn bar_series_formulas_and_caches_follow_series_order() {
let make_series = |name: &str, values: [f64; 2]| Series {
name: name.to_string(),
chart_type: ChartType::Bar,
data: ["North", "South"]
.into_iter()
.zip(values)
.map(|(category, value)| ChartData {
category: category.to_string(),
value,
x_value: None,
bubble_size: None,
role: None,
})
.collect(),
use_secondary_axis: false,
is_stacked: false,
is_area: false,
};
let mut chart = Chart::new("Selected metrics".to_string());
chart.series_list = vec![
make_series("Selected Profit", [30.0, 10.0]),
make_series("Selected Revenue", [300.0, 100.0]),
];
let xml = chart.generate_chart_xml().unwrap();
assert!(xml.contains("<c:tx><c:v>Selected Profit</c:v></c:tx>"));
assert!(xml.contains("Sheet1!$B$2:$B$3"));
assert!(xml.contains("<c:pt idx=\"0\"><c:v>30</c:v></c:pt>"));
assert!(xml.contains("<c:tx><c:v>Selected Revenue</c:v></c:tx>"));
assert!(xml.contains("Sheet1!$C$2:$C$3"));
assert!(xml.contains("<c:pt idx=\"0\"><c:v>300</c:v></c:pt>"));
}
#[test]
fn standard_chart_marks_emit_frontend_aligned_user_shapes_and_rels() {
let mut chart = Chart::new("Revenue".to_string()).with_locale("zh-CN".to_string());
chart.series_list = vec![Series {
name: "Sales".to_string(),
chart_type: ChartType::Column,
data: vec![
ChartData {
category: "Q1".to_string(),
value: 15_900.0,
x_value: None,
bubble_size: None,
role: None,
},
ChartData {
category: "Q2".to_string(),
value: 15_900.0,
x_value: None,
bubble_size: None,
role: None,
},
],
use_secondary_axis: false,
is_stacked: false,
is_area: false,
}];
chart.theme = Some(serde_json::json!({
"marks": [{ "markType": "line", "axis": "y", "preset": "average" }]
}));
let chart_xml = chart.generate_chart_xml().unwrap();
let rels = chart.generate_chart_rels(1);
let shapes = chart.generate_chart_user_shapes_xml().unwrap();
assert!(chart_xml.contains("<c:userShapes r:id=\"rId2\"/>"));
assert!(rels.contains("relationships/chartUserShapes"));
assert!(rels.contains("../drawings/drawing1.xml"));
assert!(shapes.contains("w=\"13335\""));
assert!(shapes.contains("val=\"6B7280\""));
assert!(shapes.contains("sz=\"750\""));
assert!(shapes.contains("均线:15.9k"));
}
#[test]
fn long_column_categories_lock_docx_plot_layout_to_overlay_geometry() {
let mut chart = Chart::new("Revenue".to_string());
chart.series_list = vec![Series {
name: "Sales".to_string(),
chart_type: ChartType::Column,
data: [10.0, 20.0, 30.0, 40.0, 50.0]
.into_iter()
.enumerate()
.map(|(index, value)| ChartData {
category: format!("International Consumer Products Division {index}"),
value,
x_value: None,
bubble_size: None,
role: None,
})
.collect(),
use_secondary_axis: false,
is_stacked: false,
is_area: false,
}];
chart.theme = Some(serde_json::json!({
"marks": [{ "markType": "line", "axis": "y", "preset": "average" }]
}));
let (x, y, width, height) = chart
.as_xlsx_overlay_chart()
.overlay_plot_layout_for_size(576.0, 336.0);
assert!(height < 0.60);
let chart_xml = chart.generate_chart_xml().unwrap();
assert!(chart_xml.contains(&format!(
"<c:x val=\"{x:.5}\"/><c:y val=\"{y:.5}\"/><c:w val=\"{width:.5}\"/><c:h val=\"{height:.5}\"/>"
)));
assert!(chart.generate_chart_user_shapes_xml().is_some());
}
#[test]
fn horizontal_bar_delta_locks_plot_area_to_overlay_coordinates() {
let mut chart = Chart::new("Sales".to_string());
chart.series_list = vec![Series {
name: "Revenue".to_string(),
chart_type: ChartType::Bar,
data: vec![
ChartData {
category: "A".to_string(),
value: 10.0,
x_value: None,
bubble_size: None,
role: None,
},
ChartData {
category: "B".to_string(),
value: 20.0,
x_value: None,
bubble_size: None,
role: None,
},
],
use_secondary_axis: false,
is_stacked: false,
is_area: false,
}];
chart.theme = Some(serde_json::json!({
"marks": [{
"markType": "delta",
"seriesKey": "Revenue",
"fromX": "A",
"toX": "B"
}]
}));
let xml = chart.generate_chart_xml().unwrap();
assert!(xml.contains("<c:layoutTarget val=\"inner\"/>"));
assert!(xml.contains("<c:xMode val=\"edge\"/>"));
assert!(xml.contains("<c:yMode val=\"edge\"/>"));
assert!(xml.contains("<c:x val=\"0.15000\"/>"));
assert!(xml.contains("<c:y val=\"0.26000\"/>"));
assert!(xml.contains("<c:w val=\"0.80000\"/>"));
assert!(xml.contains("<c:h val=\"0.58000\"/>"));
let shapes = chart.generate_chart_user_shapes_xml().unwrap();
assert!(shapes.contains("w=\"7144\""));
assert!(shapes.contains("sz=\"825\""));
assert!(shapes.contains("fmla=\"val 50000\""));
assert!(shapes.contains("<a:round/>"));
}
#[test]
fn compact_units_match_between_axis_and_data_labels() {
let options = build_axis_options_with_chart(&[0.0, 2_500_000.0], None, "yAxis");
let expected = Some("0.#,,"M";-0.#,,"M";0".to_string());
assert_eq!(options.val_axis_label_format_code, expected);
assert_eq!(
options.data_label_format_code,
options.val_axis_label_format_code
);
assert_eq!(options.val_axis_display_unit, None);
}
#[test]
fn series_colors_follow_all_frontend_palette_modes() {
let mut chart = Chart::new("Palette".to_string());
chart.theme =
Some(serde_json::json!({ "palette": { "type": "preset", "preset": "teal" } }));
assert_eq!(chart.get_color(0), "14B8A6");
chart.theme = Some(
serde_json::json!({ "palette": { "type": "brand-kit", "brandColors": ["#AABBCC"] } }),
);
assert_eq!(chart.get_color(0), "AABBCC");
chart.theme = Some(
serde_json::json!({ "palette": { "type": "freestyle", "customColors": ["#FF0000"], "customMonochrome": ["#112233"] } }),
);
assert_eq!(chart.get_color(0), "112233");
chart.series_list = vec![Series {
name: "Sales".to_string(),
chart_type: ChartType::Column,
data: vec![ChartData {
category: "A".to_string(),
value: 1.0,
x_value: None,
bubble_size: None,
role: None,
}],
use_secondary_axis: false,
is_stacked: false,
is_area: false,
}];
let xml = chart.generate_chart_xml().unwrap();
assert!(xml.contains("<a:srgbClr val=\"112233\"/>"));
}
use serde_json::json;
#[test]
fn bubble_docx_chart_references_embedded_sheet_from_column_a() {
let mut chart = Chart::new("Bubble".to_string());
chart.theme = Some(json!({
"chartType": "bubble",
"bubbleStyle": {
"showDataLabels": true,
"minBubbleSize": 10,
"maxBubbleSize": 50
}
}));
chart.series_list = vec![Series {
name: "Series A".to_string(),
chart_type: ChartType::Bubble,
data: vec![
ChartData {
category: "A".to_string(),
value: 25.0,
x_value: Some(10.0),
bubble_size: Some(58.0),
role: None,
},
ChartData {
category: "B".to_string(),
value: 35.0,
x_value: Some(20.0),
bubble_size: Some(88.0),
role: None,
},
],
use_secondary_axis: false,
is_stacked: false,
is_area: false,
}];
chart.x_axis_name = Some("X Axis".to_string());
chart.y_axis_name = Some("Y Axis".to_string());
let xml = chart.generate_chart_xml().unwrap();
assert!(xml.contains("Sheet1!$A$2:$A$3"));
assert!(xml.contains("Sheet1!$B$2:$B$3"));
assert!(xml.contains("Sheet1!$C$2:$C$3"));
assert!(xml.contains("<c:axPos val=\"b\"/>"));
assert!(xml.contains("<a:t>X Axis</a:t>"));
assert!(xml.contains("<a:t>Y Axis</a:t>"));
assert!(xml.contains("<c:crossBetween val=\"midCat\"/>"));
assert!(xml.contains("<c:numFmt formatCode=\"0\" sourceLinked=\"0\"/>"));
assert!(xml.contains("<c:dLbls>"));
assert!(xml.contains("<c:dLblPos val=\"t\"/>"));
assert!(xml.contains("<c:showVal val=\"1\"/>"));
}
#[test]
fn scatter_docx_chart_uses_integer_axis_ticks() {
let mut chart = Chart::new("Scatter".to_string());
chart.series_list = vec![Series {
name: "Series A".to_string(),
chart_type: ChartType::Scatter,
data: vec![
ChartData {
category: String::new(),
value: 2.2,
x_value: Some(1.4),
bubble_size: None,
role: None,
},
ChartData {
category: String::new(),
value: 8.7,
x_value: Some(6.6),
bubble_size: None,
role: None,
},
],
use_secondary_axis: false,
is_stacked: false,
is_area: false,
}];
let xml = chart.generate_chart_xml().unwrap();
assert!(xml.contains("<c:numFmt formatCode=\"0\" sourceLinked=\"0\"/>"));
assert!(xml.contains("<c:majorUnit val=\"5\"/>"));
}
#[test]
fn waterfall_docx_chart_ex_has_title_and_style_parts() {
let mut chart = Chart::new("Revenue Bridge".to_string());
chart.series_list = vec![Series {
name: "Revenue".to_string(),
chart_type: ChartType::Waterfall,
data: vec![
ChartData {
category: "Start".to_string(),
value: 120.0,
x_value: None,
bubble_size: None,
role: Some("total".to_string()),
},
ChartData {
category: "Upsell".to_string(),
value: 35.0,
x_value: None,
bubble_size: None,
role: None,
},
ChartData {
category: "Churn".to_string(),
value: -18.0,
x_value: None,
bubble_size: None,
role: None,
},
ChartData {
category: "End".to_string(),
value: 137.0,
x_value: None,
bubble_size: None,
role: Some("total".to_string()),
},
],
use_secondary_axis: false,
is_stacked: false,
is_area: false,
}];
let xml = chart.generate_chart_xml().unwrap();
let rels = chart.generate_chart_rels(1);
let style_xml = chart.generate_chart_style_xml().unwrap();
let color_xml = chart.generate_chart_color_style_xml().unwrap();
assert!(xml.contains("<cx:v>Revenue Bridge</cx:v>"));
assert!(rels.contains("office/2011/relationships/chartStyle"));
assert!(rels.contains("Target=\"style1.xml\""));
assert!(rels.contains("Target=\"colors1.xml\""));
assert!(style_xml.contains("sz=\"1400\""));
assert!(style_xml.contains("D1D5DB"));
assert!(style_xml.contains("<a:alpha val=\"30000\"/>"));
assert!(style_xml.contains("<a:alpha val=\"45000\"/>"));
assert!(color_xml.contains("4765FF"));
}
}
fn apply_chart_text_typography(
xml: String,
font_family: &str,
font_size: u32,
bold: bool,
color: &str,
) -> String {
style_drawing_text_runs(xml, font_family, font_size, bold, color)
}
fn style_chart_title_section(xml: String, typography: &Value) -> String {
let family = typography
.get("fontFamily")
.or_else(|| typography.get("officeFamily"))
.and_then(Value::as_str)
.unwrap_or("Noto Sans SC");
let size = typography
.get("fontSize")
.or_else(|| typography.get("fontSizePt"))
.and_then(Value::as_f64)
.unwrap_or(10.0);
let bold = typography
.get("fontWeight")
.and_then(Value::as_u64)
.unwrap_or(400)
>= 700;
let color = typography
.get("color")
.and_then(Value::as_str)
.unwrap_or("#333333")
.trim_start_matches('#');
let mut output = xml;
for (open, close) in [("<c:title>", "</c:title>"), ("<cx:title>", "</cx:title>")] {
if let Some(start_offset) = output.find(open) {
let start = start_offset + open.len();
let Some(end_offset) = output[start..].find(close) else {
continue;
};
let end = start + end_offset;
let styled = apply_chart_text_typography(
output[start..end].to_owned(),
family,
(size.clamp(6.0, 96.0) * 100.0).round() as u32,
bold,
color,
);
output.replace_range(start..end, &styled);
}
}
output
}
fn style_drawing_text_runs(
xml: String,
font_family: &str,
font_size: u32,
bold: bool,
color: &str,
) -> String {
let mut output = String::with_capacity(xml.len());
let mut rest = xml.as_str();
while let Some((offset, closing)) = [
rest.find("<a:rPr").map(|value| (value, "</a:rPr>")),
rest.find("<a:defRPr").map(|value| (value, "</a:defRPr>")),
]
.into_iter()
.flatten()
.min_by_key(|entry| entry.0)
{
output.push_str(&rest[..offset]);
rest = &rest[offset..];
let Some(tag_end) = rest.find('>') else { break };
let mut tag = rest[..=tag_end].to_owned();
let self_closing = tag.trim_end().ends_with("/>");
for (name, value) in [
("sz", font_size.to_string()),
("b", if bold { "1" } else { "0" }.to_owned()),
] {
let marker = format!(" {name}=\"");
if let Some(start) = tag.find(&marker) {
let value_start = start + marker.len();
if let Some(end) = tag[value_start..].find('"') {
tag.replace_range(value_start..value_start + end, &value);
}
} else {
let insert_at = if self_closing {
tag.rfind("/>").unwrap_or(tag.len() - 1)
} else {
tag.rfind('>').unwrap_or(tag.len())
};
tag.insert_str(insert_at, &format!(" {name}=\"{value}\""));
}
}
rest = &rest[tag_end + 1..];
if self_closing {
let opening_end = tag.rfind("/>").unwrap_or(tag.len() - 1);
output.push_str(&tag[..opening_end]);
output.push('>');
output.push_str(&style_drawing_run_body(String::new(), font_family, color));
output.push_str(closing);
continue;
}
output.push_str(&tag);
let Some(close_at) = rest.find(closing) else {
continue;
};
let mut body = rest[..close_at].to_owned();
body = style_drawing_run_body(body, font_family, color);
output.push_str(&body);
output.push_str(closing);
rest = &rest[close_at + closing.len()..];
}
output.push_str(rest);
output
}
fn style_drawing_run_body(mut body: String, font_family: &str, color: &str) -> String {
if let Some(marker_at) = body.find("<a:srgbClr val=\"") {
let value_start = marker_at + "<a:srgbClr val=\"".len();
if let Some(end) = body[value_start..].find('"') {
body.replace_range(value_start..value_start + end, color);
}
} else {
body.insert_str(
0,
&format!("<a:solidFill><a:srgbClr val=\"{color}\"/></a:solidFill>"),
);
}
let font_family = escape_xml(font_family);
let mut search_from = 0;
while let Some(offset) = body[search_from..].find(" typeface=\"") {
let value_start = search_from + offset + " typeface=\"".len();
let Some(end) = body[value_start..].find('"') else {
break;
};
body.replace_range(value_start..value_start + end, &font_family);
search_from = value_start + font_family.len();
}
for script in ["latin", "ea", "cs"] {
if !body.contains(&format!("<a:{script}")) {
body.push_str(&format!("<a:{script} typeface=\"{font_family}\"/>"));
}
}
body
}
fn append_chart_subtitle(mut xml: String, text: &str, style: Option<&Value>) -> String {
let Some(title_start) = xml.find("<c:title>") else {
return xml;
};
let Some(end) = xml[title_start..].find("</c:rich>") else {
return xml;
};
let fallback = serde_json::json!({"officeFamily":"Noto Sans SC","fontSizePt":9,"fontWeight":400,"color":"#666666"});
let style = style.unwrap_or(&fallback);
let family = escape_xml(style["officeFamily"].as_str().unwrap_or("Noto Sans SC"));
let size = (style["fontSizePt"].as_f64().unwrap_or(9.0) * 100.0).round() as u32;
let bold = if style["fontWeight"].as_u64().unwrap_or(400) >= 700 {
"1"
} else {
"0"
};
let color = style["color"]
.as_str()
.unwrap_or("#666666")
.trim_start_matches('#');
let paragraph = format!("<a:p><a:pPr/><a:r><a:rPr sz=\"{size}\" b=\"{bold}\"><a:solidFill><a:srgbClr val=\"{color}\"/></a:solidFill><a:latin typeface=\"{family}\"/><a:ea typeface=\"{family}\"/></a:rPr><a:t>{}</a:t></a:r></a:p>", escape_xml(text));
xml.insert_str(title_start + end, ¶graph);
xml
}