mirror of
https://github.com/openai/codex.git
synced 2026-05-02 12:21:26 +03:00
357
codex-rs/artifact-spreadsheet/src/chart.rs
Normal file
357
codex-rs/artifact-spreadsheet/src/chart.rs
Normal file
@@ -0,0 +1,357 @@
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::CellAddress;
|
||||
use crate::CellRange;
|
||||
use crate::SpreadsheetArtifactError;
|
||||
use crate::SpreadsheetSheet;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SpreadsheetChartType {
|
||||
Area,
|
||||
Bar,
|
||||
Doughnut,
|
||||
Line,
|
||||
Pie,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SpreadsheetChartLegendPosition {
|
||||
Bottom,
|
||||
Top,
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SpreadsheetChartLegend {
|
||||
pub visible: bool,
|
||||
pub position: SpreadsheetChartLegendPosition,
|
||||
pub overlay: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SpreadsheetChartAxis {
|
||||
pub linked_number_format: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SpreadsheetChartSeries {
|
||||
pub id: u32,
|
||||
pub name: Option<String>,
|
||||
pub category_sheet_name: Option<String>,
|
||||
pub category_range: String,
|
||||
pub value_sheet_name: Option<String>,
|
||||
pub value_range: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SpreadsheetChart {
|
||||
pub id: u32,
|
||||
pub chart_type: SpreadsheetChartType,
|
||||
pub source_sheet_name: Option<String>,
|
||||
pub source_range: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub style_index: u32,
|
||||
pub display_blanks_as: String,
|
||||
pub legend: SpreadsheetChartLegend,
|
||||
pub category_axis: SpreadsheetChartAxis,
|
||||
pub value_axis: SpreadsheetChartAxis,
|
||||
#[serde(default)]
|
||||
pub series: Vec<SpreadsheetChartSeries>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SpreadsheetChartLookup<'a> {
|
||||
pub id: Option<u32>,
|
||||
pub index: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SpreadsheetChartCreateOptions {
|
||||
pub id: Option<u32>,
|
||||
pub title: Option<String>,
|
||||
pub legend_visible: Option<bool>,
|
||||
pub legend_position: Option<SpreadsheetChartLegendPosition>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SpreadsheetChartProperties {
|
||||
pub title: Option<String>,
|
||||
pub legend_visible: Option<bool>,
|
||||
pub legend_position: Option<SpreadsheetChartLegendPosition>,
|
||||
}
|
||||
|
||||
impl SpreadsheetSheet {
|
||||
pub fn list_charts(
|
||||
&self,
|
||||
range: Option<&CellRange>,
|
||||
) -> Result<Vec<SpreadsheetChart>, SpreadsheetArtifactError> {
|
||||
Ok(self
|
||||
.charts
|
||||
.iter()
|
||||
.filter(|chart| {
|
||||
range.is_none_or(|target| {
|
||||
chart
|
||||
.source_range
|
||||
.as_deref()
|
||||
.map(CellRange::parse)
|
||||
.transpose()
|
||||
.ok()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.is_some_and(|chart_range| chart_range.intersects(target))
|
||||
})
|
||||
})
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn get_chart(
|
||||
&self,
|
||||
action: &str,
|
||||
lookup: SpreadsheetChartLookup<'_>,
|
||||
) -> Result<&SpreadsheetChart, SpreadsheetArtifactError> {
|
||||
if let Some(id) = lookup.id {
|
||||
return self
|
||||
.charts
|
||||
.iter()
|
||||
.find(|chart| chart.id == id)
|
||||
.ok_or_else(|| SpreadsheetArtifactError::InvalidArgs {
|
||||
action: action.to_string(),
|
||||
message: format!("chart id `{id}` was not found"),
|
||||
});
|
||||
}
|
||||
if let Some(index) = lookup.index {
|
||||
return self.charts.get(index).ok_or_else(|| {
|
||||
SpreadsheetArtifactError::IndexOutOfRange {
|
||||
action: action.to_string(),
|
||||
index,
|
||||
len: self.charts.len(),
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(SpreadsheetArtifactError::InvalidArgs {
|
||||
action: action.to_string(),
|
||||
message: "chart id or index is required".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_chart(
|
||||
&mut self,
|
||||
action: &str,
|
||||
chart_type: SpreadsheetChartType,
|
||||
source_sheet_name: Option<String>,
|
||||
source_range: &CellRange,
|
||||
options: SpreadsheetChartCreateOptions,
|
||||
) -> Result<u32, SpreadsheetArtifactError> {
|
||||
if source_range.width() < 2 {
|
||||
return Err(SpreadsheetArtifactError::InvalidArgs {
|
||||
action: action.to_string(),
|
||||
message: "chart source range must include at least two columns".to_string(),
|
||||
});
|
||||
}
|
||||
let id = if let Some(id) = options.id {
|
||||
if self.charts.iter().any(|chart| chart.id == id) {
|
||||
return Err(SpreadsheetArtifactError::InvalidArgs {
|
||||
action: action.to_string(),
|
||||
message: format!("chart id `{id}` already exists"),
|
||||
});
|
||||
}
|
||||
id
|
||||
} else {
|
||||
self.charts.iter().map(|chart| chart.id).max().unwrap_or(0) + 1
|
||||
};
|
||||
let series = (source_range.start.column + 1..=source_range.end.column)
|
||||
.enumerate()
|
||||
.map(|(index, value_column)| SpreadsheetChartSeries {
|
||||
id: index as u32 + 1,
|
||||
name: None,
|
||||
category_sheet_name: source_sheet_name.clone(),
|
||||
category_range: CellRange::from_start_end(
|
||||
source_range.start,
|
||||
CellAddress {
|
||||
column: source_range.start.column,
|
||||
row: source_range.end.row,
|
||||
},
|
||||
)
|
||||
.to_a1(),
|
||||
value_sheet_name: source_sheet_name.clone(),
|
||||
value_range: CellRange::from_start_end(
|
||||
CellAddress {
|
||||
column: value_column,
|
||||
row: source_range.start.row,
|
||||
},
|
||||
CellAddress {
|
||||
column: value_column,
|
||||
row: source_range.end.row,
|
||||
},
|
||||
)
|
||||
.to_a1(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
self.charts.push(SpreadsheetChart {
|
||||
id,
|
||||
chart_type,
|
||||
source_sheet_name,
|
||||
source_range: Some(source_range.to_a1()),
|
||||
title: options.title,
|
||||
style_index: 102,
|
||||
display_blanks_as: "gap".to_string(),
|
||||
legend: SpreadsheetChartLegend {
|
||||
visible: options.legend_visible.unwrap_or(true),
|
||||
position: options
|
||||
.legend_position
|
||||
.unwrap_or(SpreadsheetChartLegendPosition::Bottom),
|
||||
overlay: false,
|
||||
},
|
||||
category_axis: SpreadsheetChartAxis {
|
||||
linked_number_format: true,
|
||||
},
|
||||
value_axis: SpreadsheetChartAxis {
|
||||
linked_number_format: true,
|
||||
},
|
||||
series,
|
||||
});
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub fn add_chart_series(
|
||||
&mut self,
|
||||
action: &str,
|
||||
lookup: SpreadsheetChartLookup<'_>,
|
||||
mut series: SpreadsheetChartSeries,
|
||||
) -> Result<u32, SpreadsheetArtifactError> {
|
||||
validate_chart_series(action, &series)?;
|
||||
let chart = self.get_chart_mut(action, lookup)?;
|
||||
let next_id = chart.series.iter().map(|entry| entry.id).max().unwrap_or(0) + 1;
|
||||
series.id = next_id;
|
||||
chart.series.push(series);
|
||||
Ok(next_id)
|
||||
}
|
||||
|
||||
pub fn delete_chart(
|
||||
&mut self,
|
||||
action: &str,
|
||||
lookup: SpreadsheetChartLookup<'_>,
|
||||
) -> Result<(), SpreadsheetArtifactError> {
|
||||
let index = if let Some(id) = lookup.id {
|
||||
self.charts
|
||||
.iter()
|
||||
.position(|chart| chart.id == id)
|
||||
.ok_or_else(|| SpreadsheetArtifactError::InvalidArgs {
|
||||
action: action.to_string(),
|
||||
message: format!("chart id `{id}` was not found"),
|
||||
})?
|
||||
} else if let Some(index) = lookup.index {
|
||||
if index >= self.charts.len() {
|
||||
return Err(SpreadsheetArtifactError::IndexOutOfRange {
|
||||
action: action.to_string(),
|
||||
index,
|
||||
len: self.charts.len(),
|
||||
});
|
||||
}
|
||||
index
|
||||
} else {
|
||||
return Err(SpreadsheetArtifactError::InvalidArgs {
|
||||
action: action.to_string(),
|
||||
message: "chart id or index is required".to_string(),
|
||||
});
|
||||
};
|
||||
self.charts.remove(index);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_chart_properties(
|
||||
&mut self,
|
||||
action: &str,
|
||||
lookup: SpreadsheetChartLookup<'_>,
|
||||
properties: SpreadsheetChartProperties,
|
||||
) -> Result<(), SpreadsheetArtifactError> {
|
||||
let chart = self.get_chart_mut(action, lookup)?;
|
||||
if let Some(title) = properties.title {
|
||||
chart.title = Some(title);
|
||||
}
|
||||
if let Some(visible) = properties.legend_visible {
|
||||
chart.legend.visible = visible;
|
||||
}
|
||||
if let Some(position) = properties.legend_position {
|
||||
chart.legend.position = position;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_charts(&self, action: &str) -> Result<(), SpreadsheetArtifactError> {
|
||||
for chart in &self.charts {
|
||||
if let Some(source_range) = &chart.source_range {
|
||||
let range = CellRange::parse(source_range)?;
|
||||
if range.width() < 2 {
|
||||
return Err(SpreadsheetArtifactError::InvalidArgs {
|
||||
action: action.to_string(),
|
||||
message: format!(
|
||||
"chart `{}` source range `{source_range}` is too narrow",
|
||||
chart.id
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
for series in &chart.series {
|
||||
validate_chart_series(action, series)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_chart_mut(
|
||||
&mut self,
|
||||
action: &str,
|
||||
lookup: SpreadsheetChartLookup<'_>,
|
||||
) -> Result<&mut SpreadsheetChart, SpreadsheetArtifactError> {
|
||||
if let Some(id) = lookup.id {
|
||||
return self
|
||||
.charts
|
||||
.iter_mut()
|
||||
.find(|chart| chart.id == id)
|
||||
.ok_or_else(|| SpreadsheetArtifactError::InvalidArgs {
|
||||
action: action.to_string(),
|
||||
message: format!("chart id `{id}` was not found"),
|
||||
});
|
||||
}
|
||||
if let Some(index) = lookup.index {
|
||||
return self.charts.get_mut(index).ok_or_else(|| {
|
||||
SpreadsheetArtifactError::IndexOutOfRange {
|
||||
action: action.to_string(),
|
||||
index,
|
||||
len: self.charts.len(),
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(SpreadsheetArtifactError::InvalidArgs {
|
||||
action: action.to_string(),
|
||||
message: "chart id or index is required".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_chart_series(
|
||||
action: &str,
|
||||
series: &SpreadsheetChartSeries,
|
||||
) -> Result<(), SpreadsheetArtifactError> {
|
||||
let category_range = CellRange::parse(&series.category_range)?;
|
||||
let value_range = CellRange::parse(&series.value_range)?;
|
||||
if !category_range.is_single_column() || !value_range.is_single_column() {
|
||||
return Err(SpreadsheetArtifactError::InvalidArgs {
|
||||
action: action.to_string(),
|
||||
message: "chart category and value ranges must be single-column ranges".to_string(),
|
||||
});
|
||||
}
|
||||
if category_range.height() != value_range.height() {
|
||||
return Err(SpreadsheetArtifactError::InvalidArgs {
|
||||
action: action.to_string(),
|
||||
message: "chart category and value series lengths must match".to_string(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
296
codex-rs/artifact-spreadsheet/src/conditional.rs
Normal file
296
codex-rs/artifact-spreadsheet/src/conditional.rs
Normal file
@@ -0,0 +1,296 @@
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::CellRange;
|
||||
use crate::SpreadsheetArtifact;
|
||||
use crate::SpreadsheetArtifactError;
|
||||
use crate::SpreadsheetSheet;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SpreadsheetConditionalFormatType {
|
||||
Expression,
|
||||
CellIs,
|
||||
ColorScale,
|
||||
DataBar,
|
||||
IconSet,
|
||||
Top10,
|
||||
UniqueValues,
|
||||
DuplicateValues,
|
||||
ContainsText,
|
||||
NotContainsText,
|
||||
BeginsWith,
|
||||
EndsWith,
|
||||
ContainsBlanks,
|
||||
NotContainsBlanks,
|
||||
ContainsErrors,
|
||||
NotContainsErrors,
|
||||
TimePeriod,
|
||||
AboveAverage,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SpreadsheetColorScale {
|
||||
pub min_type: Option<String>,
|
||||
pub mid_type: Option<String>,
|
||||
pub max_type: Option<String>,
|
||||
pub min_value: Option<String>,
|
||||
pub mid_value: Option<String>,
|
||||
pub max_value: Option<String>,
|
||||
pub min_color: String,
|
||||
pub mid_color: Option<String>,
|
||||
pub max_color: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SpreadsheetDataBar {
|
||||
pub color: String,
|
||||
pub min_length: Option<u8>,
|
||||
pub max_length: Option<u8>,
|
||||
pub show_value: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SpreadsheetIconSet {
|
||||
pub style: String,
|
||||
pub show_value: Option<bool>,
|
||||
pub reverse_order: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SpreadsheetConditionalFormat {
|
||||
pub id: u32,
|
||||
pub range: String,
|
||||
pub rule_type: SpreadsheetConditionalFormatType,
|
||||
pub operator: Option<String>,
|
||||
#[serde(default)]
|
||||
pub formulas: Vec<String>,
|
||||
pub text: Option<String>,
|
||||
pub dxf_id: Option<u32>,
|
||||
pub stop_if_true: bool,
|
||||
pub priority: u32,
|
||||
pub rank: Option<u32>,
|
||||
pub percent: Option<bool>,
|
||||
pub time_period: Option<String>,
|
||||
pub above_average: Option<bool>,
|
||||
pub equal_average: Option<bool>,
|
||||
pub color_scale: Option<SpreadsheetColorScale>,
|
||||
pub data_bar: Option<SpreadsheetDataBar>,
|
||||
pub icon_set: Option<SpreadsheetIconSet>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SpreadsheetConditionalFormatCollection {
|
||||
pub sheet_name: String,
|
||||
pub range: String,
|
||||
}
|
||||
|
||||
impl SpreadsheetConditionalFormatCollection {
|
||||
pub fn new(sheet_name: String, range: &CellRange) -> Self {
|
||||
Self {
|
||||
sheet_name,
|
||||
range: range.to_a1(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn range(&self) -> Result<CellRange, SpreadsheetArtifactError> {
|
||||
CellRange::parse(&self.range)
|
||||
}
|
||||
|
||||
pub fn list(
|
||||
&self,
|
||||
artifact: &SpreadsheetArtifact,
|
||||
) -> Result<Vec<SpreadsheetConditionalFormat>, SpreadsheetArtifactError> {
|
||||
let sheet = artifact.sheet_lookup(
|
||||
"conditional_format_collection",
|
||||
Some(&self.sheet_name),
|
||||
None,
|
||||
)?;
|
||||
Ok(sheet.list_conditional_formats(Some(&self.range()?)))
|
||||
}
|
||||
|
||||
pub fn add(
|
||||
&self,
|
||||
artifact: &mut SpreadsheetArtifact,
|
||||
mut format: SpreadsheetConditionalFormat,
|
||||
) -> Result<u32, SpreadsheetArtifactError> {
|
||||
format.range = self.range.clone();
|
||||
artifact.add_conditional_format("conditional_format_collection", &self.sheet_name, format)
|
||||
}
|
||||
|
||||
pub fn delete(
|
||||
&self,
|
||||
artifact: &mut SpreadsheetArtifact,
|
||||
id: u32,
|
||||
) -> Result<(), SpreadsheetArtifactError> {
|
||||
artifact.delete_conditional_format("conditional_format_collection", &self.sheet_name, id)
|
||||
}
|
||||
}
|
||||
|
||||
impl SpreadsheetArtifact {
|
||||
pub fn add_conditional_format(
|
||||
&mut self,
|
||||
action: &str,
|
||||
sheet_name: &str,
|
||||
mut format: SpreadsheetConditionalFormat,
|
||||
) -> Result<u32, SpreadsheetArtifactError> {
|
||||
validate_conditional_format(self, &format, action)?;
|
||||
let sheet = self.sheet_lookup_mut(action, Some(sheet_name), None)?;
|
||||
let next_id = sheet
|
||||
.conditional_formats
|
||||
.iter()
|
||||
.map(|entry| entry.id)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
+ 1;
|
||||
format.id = next_id;
|
||||
format.priority = if format.priority == 0 {
|
||||
next_id
|
||||
} else {
|
||||
format.priority
|
||||
};
|
||||
sheet.conditional_formats.push(format);
|
||||
Ok(next_id)
|
||||
}
|
||||
|
||||
pub fn delete_conditional_format(
|
||||
&mut self,
|
||||
action: &str,
|
||||
sheet_name: &str,
|
||||
id: u32,
|
||||
) -> Result<(), SpreadsheetArtifactError> {
|
||||
let sheet = self.sheet_lookup_mut(action, Some(sheet_name), None)?;
|
||||
let previous_len = sheet.conditional_formats.len();
|
||||
sheet.conditional_formats.retain(|entry| entry.id != id);
|
||||
if sheet.conditional_formats.len() == previous_len {
|
||||
return Err(SpreadsheetArtifactError::InvalidArgs {
|
||||
action: action.to_string(),
|
||||
message: format!("conditional format `{id}` was not found"),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl SpreadsheetSheet {
|
||||
pub fn conditional_format_collection(
|
||||
&self,
|
||||
range: &CellRange,
|
||||
) -> SpreadsheetConditionalFormatCollection {
|
||||
SpreadsheetConditionalFormatCollection::new(self.name.clone(), range)
|
||||
}
|
||||
|
||||
pub fn list_conditional_formats(
|
||||
&self,
|
||||
range: Option<&CellRange>,
|
||||
) -> Vec<SpreadsheetConditionalFormat> {
|
||||
self.conditional_formats
|
||||
.iter()
|
||||
.filter(|entry| {
|
||||
range.is_none_or(|target| {
|
||||
CellRange::parse(&entry.range)
|
||||
.map(|entry_range| entry_range.intersects(target))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_conditional_format(
|
||||
artifact: &SpreadsheetArtifact,
|
||||
format: &SpreadsheetConditionalFormat,
|
||||
action: &str,
|
||||
) -> Result<(), SpreadsheetArtifactError> {
|
||||
CellRange::parse(&format.range)?;
|
||||
if let Some(dxf_id) = format.dxf_id
|
||||
&& artifact.get_differential_format(dxf_id).is_none()
|
||||
{
|
||||
return Err(SpreadsheetArtifactError::InvalidArgs {
|
||||
action: action.to_string(),
|
||||
message: format!("differential format `{dxf_id}` was not found"),
|
||||
});
|
||||
}
|
||||
|
||||
let has_style = format.dxf_id.is_some();
|
||||
let has_intrinsic_visual =
|
||||
format.color_scale.is_some() || format.data_bar.is_some() || format.icon_set.is_some();
|
||||
|
||||
match format.rule_type {
|
||||
SpreadsheetConditionalFormatType::Expression | SpreadsheetConditionalFormatType::CellIs => {
|
||||
if format.formulas.is_empty() {
|
||||
return Err(SpreadsheetArtifactError::InvalidArgs {
|
||||
action: action.to_string(),
|
||||
message: "conditional format formulas are required".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
SpreadsheetConditionalFormatType::ContainsText
|
||||
| SpreadsheetConditionalFormatType::NotContainsText
|
||||
| SpreadsheetConditionalFormatType::BeginsWith
|
||||
| SpreadsheetConditionalFormatType::EndsWith => {
|
||||
if format.text.as_deref().unwrap_or_default().is_empty() {
|
||||
return Err(SpreadsheetArtifactError::InvalidArgs {
|
||||
action: action.to_string(),
|
||||
message: "conditional format text is required".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
SpreadsheetConditionalFormatType::ColorScale => {
|
||||
if format.color_scale.is_none() {
|
||||
return Err(SpreadsheetArtifactError::InvalidArgs {
|
||||
action: action.to_string(),
|
||||
message: "color scale settings are required".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
SpreadsheetConditionalFormatType::DataBar => {
|
||||
if format.data_bar.is_none() {
|
||||
return Err(SpreadsheetArtifactError::InvalidArgs {
|
||||
action: action.to_string(),
|
||||
message: "data bar settings are required".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
SpreadsheetConditionalFormatType::IconSet => {
|
||||
if format.icon_set.is_none() {
|
||||
return Err(SpreadsheetArtifactError::InvalidArgs {
|
||||
action: action.to_string(),
|
||||
message: "icon set settings are required".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
SpreadsheetConditionalFormatType::Top10 => {
|
||||
if format.rank.is_none() {
|
||||
return Err(SpreadsheetArtifactError::InvalidArgs {
|
||||
action: action.to_string(),
|
||||
message: "top10 rank is required".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
SpreadsheetConditionalFormatType::TimePeriod => {
|
||||
if format.time_period.as_deref().unwrap_or_default().is_empty() {
|
||||
return Err(SpreadsheetArtifactError::InvalidArgs {
|
||||
action: action.to_string(),
|
||||
message: "time period is required".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
SpreadsheetConditionalFormatType::AboveAverage => {}
|
||||
SpreadsheetConditionalFormatType::UniqueValues
|
||||
| SpreadsheetConditionalFormatType::DuplicateValues
|
||||
| SpreadsheetConditionalFormatType::ContainsBlanks
|
||||
| SpreadsheetConditionalFormatType::NotContainsBlanks
|
||||
| SpreadsheetConditionalFormatType::ContainsErrors
|
||||
| SpreadsheetConditionalFormatType::NotContainsErrors => {}
|
||||
}
|
||||
|
||||
if !has_style && !has_intrinsic_visual {
|
||||
return Err(SpreadsheetArtifactError::InvalidArgs {
|
||||
action: action.to_string(),
|
||||
message: "conditional formatting requires at least one style component".to_string(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -3,6 +3,7 @@ mod error;
|
||||
mod formula;
|
||||
mod manager;
|
||||
mod model;
|
||||
mod render;
|
||||
mod style;
|
||||
mod xlsx;
|
||||
|
||||
@@ -13,4 +14,5 @@ pub use address::*;
|
||||
pub use error::*;
|
||||
pub use manager::*;
|
||||
pub use model::*;
|
||||
pub use render::*;
|
||||
pub use style::*;
|
||||
|
||||
@@ -72,6 +72,18 @@ impl SpreadsheetArtifactRequest {
|
||||
path: resolve_path(cwd, &args.path),
|
||||
}]
|
||||
}
|
||||
"render_workbook" | "render_sheet" | "render_range" => {
|
||||
let args: RenderArgs = parse_args(&self.action, &self.args)?;
|
||||
args.output_path
|
||||
.map(|path| {
|
||||
vec![PathAccessRequirement {
|
||||
action: self.action.clone(),
|
||||
kind: PathAccessKind::Write,
|
||||
path: resolve_path(cwd, &path),
|
||||
}]
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
Ok(access)
|
||||
@@ -94,6 +106,9 @@ impl SpreadsheetArtifactManager {
|
||||
"import_xlsx" | "load" | "read" => self.import_xlsx(request, cwd),
|
||||
"export_xlsx" => self.export_xlsx(request, cwd),
|
||||
"save" => self.save(request, cwd),
|
||||
"render_workbook" => self.render_workbook(request, cwd),
|
||||
"render_sheet" => self.render_sheet(request, cwd),
|
||||
"render_range" => self.render_range(request, cwd),
|
||||
"get_summary" => self.get_summary(request),
|
||||
"list_sheets" => self.list_sheets(request),
|
||||
"get_sheet" => self.get_sheet(request),
|
||||
@@ -275,6 +290,90 @@ impl SpreadsheetArtifactManager {
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn render_workbook(
|
||||
&mut self,
|
||||
request: SpreadsheetArtifactRequest,
|
||||
cwd: &Path,
|
||||
) -> Result<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
|
||||
let args: RenderArgs = parse_args(&request.action, &request.args)?;
|
||||
let artifact_id = required_artifact_id(&request)?;
|
||||
let artifact = self.get_artifact(&artifact_id, &request.action)?;
|
||||
let rendered = artifact.render_workbook_previews(cwd, &render_options_from_args(args)?)?;
|
||||
let mut response = SpreadsheetArtifactResponse::new(
|
||||
artifact_id,
|
||||
request.action,
|
||||
format!("Rendered workbook to {} preview files", rendered.len()),
|
||||
snapshot_for_artifact(artifact),
|
||||
);
|
||||
response.exported_paths = rendered.into_iter().map(|output| output.path).collect();
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn render_sheet(
|
||||
&mut self,
|
||||
request: SpreadsheetArtifactRequest,
|
||||
cwd: &Path,
|
||||
) -> Result<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
|
||||
let args: RenderArgs = parse_args(&request.action, &request.args)?;
|
||||
let artifact_id = required_artifact_id(&request)?;
|
||||
let artifact = self.get_artifact(&artifact_id, &request.action)?;
|
||||
let sheet = artifact.sheet_lookup(
|
||||
&request.action,
|
||||
args.sheet_name.as_deref(),
|
||||
args.sheet_index.map(|value| value as usize),
|
||||
)?;
|
||||
let rendered =
|
||||
artifact.render_sheet_preview(cwd, sheet, &render_options_from_args(args)?)?;
|
||||
let mut response = SpreadsheetArtifactResponse::new(
|
||||
artifact_id,
|
||||
request.action,
|
||||
format!("Rendered sheet `{}`", sheet.name),
|
||||
snapshot_for_artifact(artifact),
|
||||
);
|
||||
response.sheet_ref = Some(sheet_reference(sheet));
|
||||
response.exported_paths.push(rendered.path);
|
||||
response.rendered_html = Some(rendered.html);
|
||||
response.rendered_text = Some(sheet.to_rendered_text(None));
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn render_range(
|
||||
&mut self,
|
||||
request: SpreadsheetArtifactRequest,
|
||||
cwd: &Path,
|
||||
) -> Result<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
|
||||
let args: RenderArgs = parse_args(&request.action, &request.args)?;
|
||||
let artifact_id = required_artifact_id(&request)?;
|
||||
let artifact = self.get_artifact(&artifact_id, &request.action)?;
|
||||
let sheet = artifact.sheet_lookup(
|
||||
&request.action,
|
||||
args.sheet_name.as_deref(),
|
||||
args.sheet_index.map(|value| value as usize),
|
||||
)?;
|
||||
let range_text =
|
||||
args.range
|
||||
.clone()
|
||||
.ok_or_else(|| SpreadsheetArtifactError::InvalidArgs {
|
||||
action: request.action.clone(),
|
||||
message: "range is required".to_string(),
|
||||
})?;
|
||||
let range = CellRange::parse(&range_text)?;
|
||||
let rendered =
|
||||
artifact.render_range_preview(cwd, sheet, &range, &render_options_from_args(args)?)?;
|
||||
let mut response = SpreadsheetArtifactResponse::new(
|
||||
artifact_id,
|
||||
request.action,
|
||||
format!("Rendered range `{range_text}` from `{}`", sheet.name),
|
||||
snapshot_for_artifact(artifact),
|
||||
);
|
||||
response.sheet_ref = Some(sheet_reference(sheet));
|
||||
response.range_ref = Some(SpreadsheetCellRangeRef::new(sheet.name.clone(), &range));
|
||||
response.exported_paths.push(rendered.path);
|
||||
response.rendered_html = Some(rendered.html);
|
||||
response.rendered_text = Some(sheet.to_rendered_text(Some(&range)));
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn list_sheets(
|
||||
&mut self,
|
||||
request: SpreadsheetArtifactRequest,
|
||||
@@ -1931,6 +2030,8 @@ pub struct SpreadsheetArtifactResponse {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rendered_text: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rendered_html: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub row_height: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub serialized_dict: Option<Value>,
|
||||
@@ -1967,6 +2068,7 @@ impl SpreadsheetArtifactResponse {
|
||||
top_left_style_index: None,
|
||||
cell_format_summary: None,
|
||||
rendered_text: None,
|
||||
rendered_html: None,
|
||||
row_height: None,
|
||||
serialized_dict: None,
|
||||
serialized_json: None,
|
||||
@@ -2014,6 +2116,20 @@ struct SaveArgs {
|
||||
file_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RenderArgs {
|
||||
output_path: Option<PathBuf>,
|
||||
sheet_name: Option<String>,
|
||||
sheet_index: Option<u32>,
|
||||
range: Option<String>,
|
||||
center_address: Option<String>,
|
||||
width: Option<u32>,
|
||||
height: Option<u32>,
|
||||
include_headers: Option<bool>,
|
||||
scale: Option<f64>,
|
||||
performance_mode: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SheetLookupArgs {
|
||||
sheet_name: Option<String>,
|
||||
@@ -2385,6 +2501,27 @@ fn normalize_formula(formula: String) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_options_from_args(
|
||||
args: RenderArgs,
|
||||
) -> Result<crate::SpreadsheetRenderOptions, SpreadsheetArtifactError> {
|
||||
let scale = args.scale.unwrap_or(1.0);
|
||||
if scale <= 0.0 {
|
||||
return Err(SpreadsheetArtifactError::InvalidArgs {
|
||||
action: "render".to_string(),
|
||||
message: "render scale must be positive".to_string(),
|
||||
});
|
||||
}
|
||||
Ok(crate::SpreadsheetRenderOptions {
|
||||
output_path: args.output_path,
|
||||
center_address: args.center_address,
|
||||
width: args.width,
|
||||
height: args.height,
|
||||
include_headers: args.include_headers.unwrap_or(true),
|
||||
scale,
|
||||
performance_mode: args.performance_mode.unwrap_or(false),
|
||||
})
|
||||
}
|
||||
|
||||
fn required_artifact_id(
|
||||
request: &SpreadsheetArtifactRequest,
|
||||
) -> Result<String, SpreadsheetArtifactError> {
|
||||
|
||||
177
codex-rs/artifact-spreadsheet/src/pivot.rs
Normal file
177
codex-rs/artifact-spreadsheet/src/pivot.rs
Normal file
@@ -0,0 +1,177 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::CellRange;
|
||||
use crate::SpreadsheetArtifactError;
|
||||
use crate::SpreadsheetCellRangeRef;
|
||||
use crate::SpreadsheetSheet;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SpreadsheetPivotFieldItem {
|
||||
pub item_type: Option<String>,
|
||||
pub index: Option<u32>,
|
||||
pub hidden: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SpreadsheetPivotField {
|
||||
pub index: u32,
|
||||
pub name: Option<String>,
|
||||
pub axis: Option<String>,
|
||||
#[serde(default)]
|
||||
pub items: Vec<SpreadsheetPivotFieldItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SpreadsheetPivotFieldReference {
|
||||
pub field_index: u32,
|
||||
pub field_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SpreadsheetPivotPageField {
|
||||
pub field_index: u32,
|
||||
pub field_name: Option<String>,
|
||||
pub selected_item: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SpreadsheetPivotDataField {
|
||||
pub field_index: u32,
|
||||
pub field_name: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub subtotal: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SpreadsheetPivotFilter {
|
||||
pub field_index: Option<u32>,
|
||||
pub field_name: Option<String>,
|
||||
pub filter_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SpreadsheetPivotTable {
|
||||
pub name: String,
|
||||
pub cache_id: u32,
|
||||
pub address: Option<String>,
|
||||
#[serde(default)]
|
||||
pub row_fields: Vec<SpreadsheetPivotFieldReference>,
|
||||
#[serde(default)]
|
||||
pub column_fields: Vec<SpreadsheetPivotFieldReference>,
|
||||
#[serde(default)]
|
||||
pub page_fields: Vec<SpreadsheetPivotPageField>,
|
||||
#[serde(default)]
|
||||
pub data_fields: Vec<SpreadsheetPivotDataField>,
|
||||
#[serde(default)]
|
||||
pub filters: Vec<SpreadsheetPivotFilter>,
|
||||
#[serde(default)]
|
||||
pub pivot_fields: Vec<SpreadsheetPivotField>,
|
||||
pub style_name: Option<String>,
|
||||
pub part_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SpreadsheetPivotTableLookup<'a> {
|
||||
pub name: Option<&'a str>,
|
||||
pub index: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SpreadsheetPivotCacheDefinition {
|
||||
pub definition_path: String,
|
||||
#[serde(default)]
|
||||
pub field_names: Vec<Option<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct SpreadsheetPivotPreservation {
|
||||
#[serde(default)]
|
||||
pub caches: BTreeMap<u32, SpreadsheetPivotCacheDefinition>,
|
||||
#[serde(default)]
|
||||
pub parts: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl SpreadsheetPivotTable {
|
||||
pub fn range(&self) -> Result<Option<CellRange>, SpreadsheetArtifactError> {
|
||||
self.address.as_deref().map(CellRange::parse).transpose()
|
||||
}
|
||||
|
||||
pub fn range_ref(
|
||||
&self,
|
||||
sheet_name: &str,
|
||||
) -> Result<Option<SpreadsheetCellRangeRef>, SpreadsheetArtifactError> {
|
||||
Ok(self
|
||||
.range()?
|
||||
.map(|range| SpreadsheetCellRangeRef::new(sheet_name.to_string(), &range)))
|
||||
}
|
||||
}
|
||||
|
||||
impl SpreadsheetSheet {
|
||||
pub fn list_pivot_tables(
|
||||
&self,
|
||||
range: Option<&CellRange>,
|
||||
) -> Result<Vec<SpreadsheetPivotTable>, SpreadsheetArtifactError> {
|
||||
Ok(self
|
||||
.pivot_tables
|
||||
.iter()
|
||||
.filter(|pivot_table| {
|
||||
range.is_none_or(|target| {
|
||||
pivot_table
|
||||
.range()
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some_and(|pivot_range| pivot_range.intersects(target))
|
||||
})
|
||||
})
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn get_pivot_table(
|
||||
&self,
|
||||
action: &str,
|
||||
lookup: SpreadsheetPivotTableLookup,
|
||||
) -> Result<&SpreadsheetPivotTable, SpreadsheetArtifactError> {
|
||||
if let Some(name) = lookup.name {
|
||||
return self
|
||||
.pivot_tables
|
||||
.iter()
|
||||
.find(|pivot_table| pivot_table.name == name)
|
||||
.ok_or_else(|| SpreadsheetArtifactError::InvalidArgs {
|
||||
action: action.to_string(),
|
||||
message: format!("pivot table `{name}` was not found"),
|
||||
});
|
||||
}
|
||||
if let Some(index) = lookup.index {
|
||||
return self.pivot_tables.get(index).ok_or_else(|| {
|
||||
SpreadsheetArtifactError::IndexOutOfRange {
|
||||
action: action.to_string(),
|
||||
index,
|
||||
len: self.pivot_tables.len(),
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(SpreadsheetArtifactError::InvalidArgs {
|
||||
action: action.to_string(),
|
||||
message: "pivot table name or index is required".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn validate_pivot_tables(&self, action: &str) -> Result<(), SpreadsheetArtifactError> {
|
||||
for pivot_table in &self.pivot_tables {
|
||||
if pivot_table.name.trim().is_empty() {
|
||||
return Err(SpreadsheetArtifactError::InvalidArgs {
|
||||
action: action.to_string(),
|
||||
message: "pivot table name cannot be empty".to_string(),
|
||||
});
|
||||
}
|
||||
if let Some(address) = &pivot_table.address {
|
||||
CellRange::parse(address)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
373
codex-rs/artifact-spreadsheet/src/render.rs
Normal file
373
codex-rs/artifact-spreadsheet/src/render.rs
Normal file
@@ -0,0 +1,373 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::CellAddress;
|
||||
use crate::CellRange;
|
||||
use crate::SpreadsheetArtifact;
|
||||
use crate::SpreadsheetArtifactError;
|
||||
use crate::SpreadsheetSheet;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SpreadsheetRenderOptions {
|
||||
pub output_path: Option<PathBuf>,
|
||||
pub center_address: Option<String>,
|
||||
pub width: Option<u32>,
|
||||
pub height: Option<u32>,
|
||||
pub include_headers: bool,
|
||||
pub scale: f64,
|
||||
pub performance_mode: bool,
|
||||
}
|
||||
|
||||
impl Default for SpreadsheetRenderOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
output_path: None,
|
||||
center_address: None,
|
||||
width: None,
|
||||
height: None,
|
||||
include_headers: true,
|
||||
scale: 1.0,
|
||||
performance_mode: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SpreadsheetRenderedOutput {
|
||||
pub path: PathBuf,
|
||||
pub html: String,
|
||||
}
|
||||
|
||||
impl SpreadsheetSheet {
|
||||
pub fn render_html(
|
||||
&self,
|
||||
range: Option<&CellRange>,
|
||||
options: &SpreadsheetRenderOptions,
|
||||
) -> Result<String, SpreadsheetArtifactError> {
|
||||
let center = options
|
||||
.center_address
|
||||
.as_deref()
|
||||
.map(CellAddress::parse)
|
||||
.transpose()?;
|
||||
let viewport = render_viewport(self, range, center, options)?;
|
||||
let title = range
|
||||
.map(CellRange::to_a1)
|
||||
.unwrap_or_else(|| self.name.clone());
|
||||
Ok(format!(
|
||||
concat!(
|
||||
"<!doctype html><html><head><meta charset=\"utf-8\">",
|
||||
"<title>{}</title>",
|
||||
"<style>{}</style>",
|
||||
"</head><body>",
|
||||
"<section class=\"spreadsheet-preview\" data-sheet=\"{}\" data-performance-mode=\"{}\">",
|
||||
"<header><h1>{}</h1><p>{}</p></header>",
|
||||
"<div class=\"viewport\" style=\"{}\">",
|
||||
"<table>{}</table>",
|
||||
"</div></section></body></html>"
|
||||
),
|
||||
html_escape(&title),
|
||||
preview_css(),
|
||||
html_escape(&self.name),
|
||||
options.performance_mode,
|
||||
html_escape(&title),
|
||||
html_escape(&viewport.to_a1()),
|
||||
viewport_style(options),
|
||||
render_table(self, &viewport, options),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl SpreadsheetArtifact {
|
||||
pub fn render_workbook_previews(
|
||||
&self,
|
||||
cwd: &Path,
|
||||
options: &SpreadsheetRenderOptions,
|
||||
) -> Result<Vec<SpreadsheetRenderedOutput>, SpreadsheetArtifactError> {
|
||||
let sheets = if self.sheets.is_empty() {
|
||||
vec![SpreadsheetSheet::new("Sheet1".to_string())]
|
||||
} else {
|
||||
self.sheets.clone()
|
||||
};
|
||||
let output_paths = workbook_output_paths(self, cwd, options, &sheets);
|
||||
sheets
|
||||
.iter()
|
||||
.zip(output_paths)
|
||||
.map(|(sheet, path)| {
|
||||
let html = sheet.render_html(None, options)?;
|
||||
write_rendered_output(&path, &html)?;
|
||||
Ok(SpreadsheetRenderedOutput { path, html })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn render_sheet_preview(
|
||||
&self,
|
||||
cwd: &Path,
|
||||
sheet: &SpreadsheetSheet,
|
||||
options: &SpreadsheetRenderOptions,
|
||||
) -> Result<SpreadsheetRenderedOutput, SpreadsheetArtifactError> {
|
||||
let path = single_output_path(
|
||||
cwd,
|
||||
self,
|
||||
options.output_path.as_deref(),
|
||||
&format!("render_{}", sanitize_file_component(&sheet.name)),
|
||||
);
|
||||
let html = sheet.render_html(None, options)?;
|
||||
write_rendered_output(&path, &html)?;
|
||||
Ok(SpreadsheetRenderedOutput { path, html })
|
||||
}
|
||||
|
||||
pub fn render_range_preview(
|
||||
&self,
|
||||
cwd: &Path,
|
||||
sheet: &SpreadsheetSheet,
|
||||
range: &CellRange,
|
||||
options: &SpreadsheetRenderOptions,
|
||||
) -> Result<SpreadsheetRenderedOutput, SpreadsheetArtifactError> {
|
||||
let path = single_output_path(
|
||||
cwd,
|
||||
self,
|
||||
options.output_path.as_deref(),
|
||||
&format!(
|
||||
"render_{}_{}",
|
||||
sanitize_file_component(&sheet.name),
|
||||
sanitize_file_component(&range.to_a1())
|
||||
),
|
||||
);
|
||||
let html = sheet.render_html(Some(range), options)?;
|
||||
write_rendered_output(&path, &html)?;
|
||||
Ok(SpreadsheetRenderedOutput { path, html })
|
||||
}
|
||||
}
|
||||
|
||||
fn render_viewport(
|
||||
sheet: &SpreadsheetSheet,
|
||||
range: Option<&CellRange>,
|
||||
center: Option<CellAddress>,
|
||||
options: &SpreadsheetRenderOptions,
|
||||
) -> Result<CellRange, SpreadsheetArtifactError> {
|
||||
let base = range
|
||||
.cloned()
|
||||
.or_else(|| sheet.minimum_range())
|
||||
.unwrap_or_else(|| {
|
||||
CellRange::from_start_end(
|
||||
CellAddress { column: 1, row: 1 },
|
||||
CellAddress { column: 1, row: 1 },
|
||||
)
|
||||
});
|
||||
let Some(center) = center else {
|
||||
return Ok(base);
|
||||
};
|
||||
let visible_columns = options
|
||||
.width
|
||||
.map(|width| estimated_visible_count(width, 96.0, options.scale))
|
||||
.unwrap_or(base.width() as u32);
|
||||
let visible_rows = options
|
||||
.height
|
||||
.map(|height| estimated_visible_count(height, 28.0, options.scale))
|
||||
.unwrap_or(base.height() as u32);
|
||||
|
||||
let half_columns = visible_columns / 2;
|
||||
let half_rows = visible_rows / 2;
|
||||
let start_column = center
|
||||
.column
|
||||
.saturating_sub(half_columns)
|
||||
.max(base.start.column);
|
||||
let start_row = center.row.saturating_sub(half_rows).max(base.start.row);
|
||||
let end_column = (start_column + visible_columns.saturating_sub(1)).min(base.end.column);
|
||||
let end_row = (start_row + visible_rows.saturating_sub(1)).min(base.end.row);
|
||||
Ok(CellRange::from_start_end(
|
||||
CellAddress {
|
||||
column: start_column,
|
||||
row: start_row,
|
||||
},
|
||||
CellAddress {
|
||||
column: end_column.max(start_column),
|
||||
row: end_row.max(start_row),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn estimated_visible_count(dimension: u32, cell_size: f64, scale: f64) -> u32 {
|
||||
((dimension as f64 / (cell_size * scale.max(0.1))).floor() as u32).max(1)
|
||||
}
|
||||
|
||||
fn render_table(
|
||||
sheet: &SpreadsheetSheet,
|
||||
range: &CellRange,
|
||||
options: &SpreadsheetRenderOptions,
|
||||
) -> String {
|
||||
let mut rows = Vec::new();
|
||||
if options.include_headers {
|
||||
let mut header = vec!["<tr><th class=\"corner\"></th>".to_string()];
|
||||
for column in range.start.column..=range.end.column {
|
||||
header.push(format!(
|
||||
"<th>{}</th>",
|
||||
crate::column_index_to_letters(column)
|
||||
));
|
||||
}
|
||||
header.push("</tr>".to_string());
|
||||
rows.push(header.join(""));
|
||||
}
|
||||
for row in range.start.row..=range.end.row {
|
||||
let mut cells = Vec::new();
|
||||
if options.include_headers {
|
||||
cells.push(format!("<th>{row}</th>"));
|
||||
}
|
||||
for column in range.start.column..=range.end.column {
|
||||
let address = CellAddress { column, row };
|
||||
let view = sheet.get_cell_view(address);
|
||||
let value = view
|
||||
.data
|
||||
.as_ref()
|
||||
.map(render_data_value)
|
||||
.unwrap_or_default();
|
||||
cells.push(format!(
|
||||
"<td data-address=\"{}\" data-style-index=\"{}\">{}</td>",
|
||||
address.to_a1(),
|
||||
view.style_index,
|
||||
html_escape(&value)
|
||||
));
|
||||
}
|
||||
rows.push(format!("<tr>{}</tr>", cells.join("")));
|
||||
}
|
||||
rows.join("")
|
||||
}
|
||||
|
||||
fn render_data_value(value: &serde_json::Value) -> String {
|
||||
match value {
|
||||
serde_json::Value::String(value) => value.clone(),
|
||||
serde_json::Value::Bool(value) => value.to_string(),
|
||||
serde_json::Value::Number(value) => value.to_string(),
|
||||
serde_json::Value::Null => String::new(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn viewport_style(options: &SpreadsheetRenderOptions) -> String {
|
||||
let mut style = vec![
|
||||
format!("--scale: {}", options.scale.max(0.1)),
|
||||
format!(
|
||||
"--headers: {}",
|
||||
if options.include_headers { "1" } else { "0" }
|
||||
),
|
||||
];
|
||||
if let Some(width) = options.width {
|
||||
style.push(format!("width: {width}px"));
|
||||
}
|
||||
if let Some(height) = options.height {
|
||||
style.push(format!("height: {height}px"));
|
||||
}
|
||||
style.push("overflow: auto".to_string());
|
||||
style.join("; ")
|
||||
}
|
||||
|
||||
fn preview_css() -> &'static str {
|
||||
concat!(
|
||||
"body{margin:0;padding:24px;background:#f5f3ee;color:#1e1e1e;font-family:Georgia,serif;}",
|
||||
".spreadsheet-preview{display:flex;flex-direction:column;gap:16px;}",
|
||||
"header h1{margin:0;font-size:24px;}header p{margin:0;color:#6b6257;font-size:13px;}",
|
||||
".viewport{border:1px solid #d6d0c7;background:#fff;box-shadow:0 12px 30px rgba(0,0,0,.08);}",
|
||||
"table{border-collapse:collapse;transform:scale(var(--scale));transform-origin:top left;}",
|
||||
"th,td{border:1px solid #ddd3c6;padding:6px 10px;min-width:72px;max-width:240px;font-size:13px;text-align:left;vertical-align:top;}",
|
||||
"th{background:#f0ebe3;font-weight:600;position:sticky;top:0;z-index:1;}",
|
||||
".corner{background:#e7e0d6;left:0;z-index:2;}",
|
||||
"td{white-space:pre-wrap;}"
|
||||
)
|
||||
}
|
||||
|
||||
fn write_rendered_output(path: &Path, html: &str) -> Result<(), SpreadsheetArtifactError> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|error| SpreadsheetArtifactError::ExportFailed {
|
||||
path: path.to_path_buf(),
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
}
|
||||
fs::write(path, html).map_err(|error| SpreadsheetArtifactError::ExportFailed {
|
||||
path: path.to_path_buf(),
|
||||
message: error.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn workbook_output_paths(
|
||||
artifact: &SpreadsheetArtifact,
|
||||
cwd: &Path,
|
||||
options: &SpreadsheetRenderOptions,
|
||||
sheets: &[SpreadsheetSheet],
|
||||
) -> Vec<PathBuf> {
|
||||
if let Some(output_path) = options.output_path.as_deref() {
|
||||
if output_path.extension().is_some_and(|ext| ext == "html") {
|
||||
let stem = output_path
|
||||
.file_stem()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("render");
|
||||
let parent = output_path.parent().unwrap_or(cwd);
|
||||
return sheets
|
||||
.iter()
|
||||
.map(|sheet| {
|
||||
parent.join(format!(
|
||||
"{}_{}.html",
|
||||
stem,
|
||||
sanitize_file_component(&sheet.name)
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
return sheets
|
||||
.iter()
|
||||
.map(|sheet| output_path.join(format!("{}.html", sanitize_file_component(&sheet.name))))
|
||||
.collect();
|
||||
}
|
||||
sheets
|
||||
.iter()
|
||||
.map(|sheet| {
|
||||
cwd.join(format!(
|
||||
"{}_render_{}.html",
|
||||
artifact.artifact_id,
|
||||
sanitize_file_component(&sheet.name)
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn single_output_path(
|
||||
cwd: &Path,
|
||||
artifact: &SpreadsheetArtifact,
|
||||
output_path: Option<&Path>,
|
||||
suffix: &str,
|
||||
) -> PathBuf {
|
||||
if let Some(output_path) = output_path {
|
||||
return if output_path.extension().is_some_and(|ext| ext == "html") {
|
||||
output_path.to_path_buf()
|
||||
} else {
|
||||
output_path.join(format!("{suffix}.html"))
|
||||
};
|
||||
}
|
||||
cwd.join(format!("{}_{}.html", artifact.artifact_id, suffix))
|
||||
}
|
||||
|
||||
fn sanitize_file_component(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.map(|character| {
|
||||
if character.is_ascii_alphanumeric() {
|
||||
character
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn html_escape(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
619
codex-rs/artifact-spreadsheet/src/table.rs
Normal file
619
codex-rs/artifact-spreadsheet/src/table.rs
Normal file
@@ -0,0 +1,619 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::CellAddress;
|
||||
use crate::CellRange;
|
||||
use crate::SpreadsheetArtifactError;
|
||||
use crate::SpreadsheetCellValue;
|
||||
use crate::SpreadsheetSheet;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SpreadsheetTableColumn {
|
||||
pub id: u32,
|
||||
pub name: String,
|
||||
pub totals_row_label: Option<String>,
|
||||
pub totals_row_function: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SpreadsheetTable {
|
||||
pub id: u32,
|
||||
pub name: String,
|
||||
pub display_name: String,
|
||||
pub range: String,
|
||||
pub header_row_count: u32,
|
||||
pub totals_row_count: u32,
|
||||
pub style_name: Option<String>,
|
||||
pub show_first_column: bool,
|
||||
pub show_last_column: bool,
|
||||
pub show_row_stripes: bool,
|
||||
pub show_column_stripes: bool,
|
||||
#[serde(default)]
|
||||
pub columns: Vec<SpreadsheetTableColumn>,
|
||||
#[serde(default)]
|
||||
pub filters: BTreeMap<u32, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SpreadsheetTableView {
|
||||
pub id: u32,
|
||||
pub name: String,
|
||||
pub display_name: String,
|
||||
pub address: String,
|
||||
pub full_range: String,
|
||||
pub header_row_count: u32,
|
||||
pub totals_row_count: u32,
|
||||
pub totals_row_visible: bool,
|
||||
pub header_row_range: Option<String>,
|
||||
pub data_body_range: Option<String>,
|
||||
pub totals_row_range: Option<String>,
|
||||
pub style_name: Option<String>,
|
||||
pub show_first_column: bool,
|
||||
pub show_last_column: bool,
|
||||
pub show_row_stripes: bool,
|
||||
pub show_column_stripes: bool,
|
||||
pub columns: Vec<SpreadsheetTableColumn>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SpreadsheetTableLookup<'a> {
|
||||
pub name: Option<&'a str>,
|
||||
pub display_name: Option<&'a str>,
|
||||
pub id: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SpreadsheetCreateTableOptions {
|
||||
pub name: Option<String>,
|
||||
pub display_name: Option<String>,
|
||||
pub header_row_count: u32,
|
||||
pub totals_row_count: u32,
|
||||
pub style_name: Option<String>,
|
||||
pub show_first_column: bool,
|
||||
pub show_last_column: bool,
|
||||
pub show_row_stripes: bool,
|
||||
pub show_column_stripes: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SpreadsheetTableStyleOptions {
|
||||
pub style_name: Option<String>,
|
||||
pub show_first_column: Option<bool>,
|
||||
pub show_last_column: Option<bool>,
|
||||
pub show_row_stripes: Option<bool>,
|
||||
pub show_column_stripes: Option<bool>,
|
||||
}
|
||||
|
||||
impl SpreadsheetTable {
|
||||
pub fn range(&self) -> Result<CellRange, SpreadsheetArtifactError> {
|
||||
CellRange::parse(&self.range)
|
||||
}
|
||||
|
||||
pub fn address(&self) -> String {
|
||||
self.range.clone()
|
||||
}
|
||||
|
||||
pub fn full_range(&self) -> String {
|
||||
self.range.clone()
|
||||
}
|
||||
|
||||
pub fn totals_row_visible(&self) -> bool {
|
||||
self.totals_row_count > 0
|
||||
}
|
||||
|
||||
pub fn header_row_range(&self) -> Result<Option<CellRange>, SpreadsheetArtifactError> {
|
||||
if self.header_row_count == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let range = self.range()?;
|
||||
Ok(Some(CellRange::from_start_end(
|
||||
range.start,
|
||||
CellAddress {
|
||||
column: range.end.column,
|
||||
row: range.start.row + self.header_row_count - 1,
|
||||
},
|
||||
)))
|
||||
}
|
||||
|
||||
pub fn data_body_range(&self) -> Result<Option<CellRange>, SpreadsheetArtifactError> {
|
||||
let range = self.range()?;
|
||||
let start_row = range.start.row + self.header_row_count;
|
||||
let end_row = range.end.row.saturating_sub(self.totals_row_count);
|
||||
if start_row > end_row {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(CellRange::from_start_end(
|
||||
CellAddress {
|
||||
column: range.start.column,
|
||||
row: start_row,
|
||||
},
|
||||
CellAddress {
|
||||
column: range.end.column,
|
||||
row: end_row,
|
||||
},
|
||||
)))
|
||||
}
|
||||
|
||||
pub fn totals_row_range(&self) -> Result<Option<CellRange>, SpreadsheetArtifactError> {
|
||||
if self.totals_row_count == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let range = self.range()?;
|
||||
Ok(Some(CellRange::from_start_end(
|
||||
CellAddress {
|
||||
column: range.start.column,
|
||||
row: range.end.row - self.totals_row_count + 1,
|
||||
},
|
||||
range.end,
|
||||
)))
|
||||
}
|
||||
|
||||
pub fn view(&self) -> Result<SpreadsheetTableView, SpreadsheetArtifactError> {
|
||||
Ok(SpreadsheetTableView {
|
||||
id: self.id,
|
||||
name: self.name.clone(),
|
||||
display_name: self.display_name.clone(),
|
||||
address: self.address(),
|
||||
full_range: self.full_range(),
|
||||
header_row_count: self.header_row_count,
|
||||
totals_row_count: self.totals_row_count,
|
||||
totals_row_visible: self.totals_row_visible(),
|
||||
header_row_range: self.header_row_range()?.map(|range| range.to_a1()),
|
||||
data_body_range: self.data_body_range()?.map(|range| range.to_a1()),
|
||||
totals_row_range: self.totals_row_range()?.map(|range| range.to_a1()),
|
||||
style_name: self.style_name.clone(),
|
||||
show_first_column: self.show_first_column,
|
||||
show_last_column: self.show_last_column,
|
||||
show_row_stripes: self.show_row_stripes,
|
||||
show_column_stripes: self.show_column_stripes,
|
||||
columns: self.columns.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl SpreadsheetSheet {
|
||||
pub fn create_table(
|
||||
&mut self,
|
||||
action: &str,
|
||||
range: &CellRange,
|
||||
options: SpreadsheetCreateTableOptions,
|
||||
) -> Result<u32, SpreadsheetArtifactError> {
|
||||
validate_table_geometry(action, range, options.header_row_count, options.totals_row_count)?;
|
||||
for table in &self.tables {
|
||||
let table_range = table.range()?;
|
||||
if table_range.intersects(range) {
|
||||
return Err(SpreadsheetArtifactError::InvalidArgs {
|
||||
action: action.to_string(),
|
||||
message: format!(
|
||||
"table range `{}` intersects existing table `{}`",
|
||||
range.to_a1(),
|
||||
table.name
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let next_id = self.tables.iter().map(|table| table.id).max().unwrap_or(0) + 1;
|
||||
let name = options.name.unwrap_or_else(|| format!("Table{next_id}"));
|
||||
if name.trim().is_empty() {
|
||||
return Err(SpreadsheetArtifactError::InvalidArgs {
|
||||
action: action.to_string(),
|
||||
message: "table name cannot be empty".to_string(),
|
||||
});
|
||||
}
|
||||
let display_name = options.display_name.unwrap_or_else(|| name.clone());
|
||||
if display_name.trim().is_empty() {
|
||||
return Err(SpreadsheetArtifactError::InvalidArgs {
|
||||
action: action.to_string(),
|
||||
message: "table display_name cannot be empty".to_string(),
|
||||
});
|
||||
}
|
||||
ensure_unique_table_name(&self.tables, action, &name, &display_name, None)?;
|
||||
|
||||
let columns = build_table_columns(self, range, options.header_row_count);
|
||||
self.tables.push(SpreadsheetTable {
|
||||
id: next_id,
|
||||
name,
|
||||
display_name,
|
||||
range: range.to_a1(),
|
||||
header_row_count: options.header_row_count,
|
||||
totals_row_count: options.totals_row_count,
|
||||
style_name: options.style_name,
|
||||
show_first_column: options.show_first_column,
|
||||
show_last_column: options.show_last_column,
|
||||
show_row_stripes: options.show_row_stripes,
|
||||
show_column_stripes: options.show_column_stripes,
|
||||
columns,
|
||||
filters: BTreeMap::new(),
|
||||
});
|
||||
Ok(next_id)
|
||||
}
|
||||
|
||||
pub fn list_tables(
|
||||
&self,
|
||||
range: Option<&CellRange>,
|
||||
) -> Result<Vec<SpreadsheetTableView>, SpreadsheetArtifactError> {
|
||||
self.tables
|
||||
.iter()
|
||||
.filter(|table| {
|
||||
range.is_none_or(|target| {
|
||||
table
|
||||
.range()
|
||||
.map(|table_range| table_range.intersects(target))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
})
|
||||
.map(SpreadsheetTable::view)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn get_table(
|
||||
&self,
|
||||
action: &str,
|
||||
lookup: SpreadsheetTableLookup<'_>,
|
||||
) -> Result<&SpreadsheetTable, SpreadsheetArtifactError> {
|
||||
self.table_lookup_internal(action, lookup)
|
||||
}
|
||||
|
||||
pub fn get_table_view(
|
||||
&self,
|
||||
action: &str,
|
||||
lookup: SpreadsheetTableLookup<'_>,
|
||||
) -> Result<SpreadsheetTableView, SpreadsheetArtifactError> {
|
||||
self.get_table(action, lookup)?.view()
|
||||
}
|
||||
|
||||
pub fn delete_table(
|
||||
&mut self,
|
||||
action: &str,
|
||||
lookup: SpreadsheetTableLookup<'_>,
|
||||
) -> Result<(), SpreadsheetArtifactError> {
|
||||
let index = self.table_index(action, lookup)?;
|
||||
self.tables.remove(index);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_table_style(
|
||||
&mut self,
|
||||
action: &str,
|
||||
lookup: SpreadsheetTableLookup<'_>,
|
||||
options: SpreadsheetTableStyleOptions,
|
||||
) -> Result<(), SpreadsheetArtifactError> {
|
||||
let table = self.table_lookup_mut(action, lookup)?;
|
||||
table.style_name = options.style_name;
|
||||
if let Some(value) = options.show_first_column {
|
||||
table.show_first_column = value;
|
||||
}
|
||||
if let Some(value) = options.show_last_column {
|
||||
table.show_last_column = value;
|
||||
}
|
||||
if let Some(value) = options.show_row_stripes {
|
||||
table.show_row_stripes = value;
|
||||
}
|
||||
if let Some(value) = options.show_column_stripes {
|
||||
table.show_column_stripes = value;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn clear_table_filters(
|
||||
&mut self,
|
||||
action: &str,
|
||||
lookup: SpreadsheetTableLookup<'_>,
|
||||
) -> Result<(), SpreadsheetArtifactError> {
|
||||
self.table_lookup_mut(action, lookup)?.filters.clear();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn reapply_table_filters(
|
||||
&mut self,
|
||||
action: &str,
|
||||
lookup: SpreadsheetTableLookup<'_>,
|
||||
) -> Result<(), SpreadsheetArtifactError> {
|
||||
let _ = self.table_lookup_mut(action, lookup)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn rename_table_column(
|
||||
&mut self,
|
||||
action: &str,
|
||||
lookup: SpreadsheetTableLookup<'_>,
|
||||
column_id: Option<u32>,
|
||||
column_name: Option<&str>,
|
||||
new_name: String,
|
||||
) -> Result<SpreadsheetTableColumn, SpreadsheetArtifactError> {
|
||||
if new_name.trim().is_empty() {
|
||||
return Err(SpreadsheetArtifactError::InvalidArgs {
|
||||
action: action.to_string(),
|
||||
message: "table column name cannot be empty".to_string(),
|
||||
});
|
||||
}
|
||||
let table = self.table_lookup_mut(action, lookup)?;
|
||||
if table
|
||||
.columns
|
||||
.iter()
|
||||
.any(|column| column.name == new_name && Some(column.id) != column_id)
|
||||
{
|
||||
return Err(SpreadsheetArtifactError::InvalidArgs {
|
||||
action: action.to_string(),
|
||||
message: format!("table column `{new_name}` already exists"),
|
||||
});
|
||||
}
|
||||
let column = table_column_lookup_mut(&mut table.columns, action, column_id, column_name)?;
|
||||
column.name = new_name;
|
||||
Ok(column.clone())
|
||||
}
|
||||
|
||||
pub fn set_table_column_totals(
|
||||
&mut self,
|
||||
action: &str,
|
||||
lookup: SpreadsheetTableLookup<'_>,
|
||||
column_id: Option<u32>,
|
||||
column_name: Option<&str>,
|
||||
totals_row_label: Option<String>,
|
||||
totals_row_function: Option<String>,
|
||||
) -> Result<SpreadsheetTableColumn, SpreadsheetArtifactError> {
|
||||
let table = self.table_lookup_mut(action, lookup)?;
|
||||
let column = table_column_lookup_mut(&mut table.columns, action, column_id, column_name)?;
|
||||
column.totals_row_label = totals_row_label;
|
||||
column.totals_row_function = totals_row_function;
|
||||
Ok(column.clone())
|
||||
}
|
||||
|
||||
pub fn validate_tables(&self, action: &str) -> Result<(), SpreadsheetArtifactError> {
|
||||
let mut seen_names = BTreeSet::new();
|
||||
let mut seen_display_names = BTreeSet::new();
|
||||
for table in &self.tables {
|
||||
let range = table.range()?;
|
||||
validate_table_geometry(action, &range, table.header_row_count, table.totals_row_count)?;
|
||||
if !seen_names.insert(table.name.clone()) {
|
||||
return Err(SpreadsheetArtifactError::InvalidArgs {
|
||||
action: action.to_string(),
|
||||
message: format!("duplicate table name `{}`", table.name),
|
||||
});
|
||||
}
|
||||
if !seen_display_names.insert(table.display_name.clone()) {
|
||||
return Err(SpreadsheetArtifactError::InvalidArgs {
|
||||
action: action.to_string(),
|
||||
message: format!("duplicate table display_name `{}`", table.display_name),
|
||||
});
|
||||
}
|
||||
let column_names = table
|
||||
.columns
|
||||
.iter()
|
||||
.map(|column| column.name.clone())
|
||||
.collect::<BTreeSet<_>>();
|
||||
if column_names.len() != table.columns.len() {
|
||||
return Err(SpreadsheetArtifactError::InvalidArgs {
|
||||
action: action.to_string(),
|
||||
message: format!("table `{}` has duplicate column names", table.name),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for index in 0..self.tables.len() {
|
||||
for other in index + 1..self.tables.len() {
|
||||
if self.tables[index]
|
||||
.range()?
|
||||
.intersects(&self.tables[other].range()?)
|
||||
{
|
||||
return Err(SpreadsheetArtifactError::InvalidArgs {
|
||||
action: action.to_string(),
|
||||
message: format!(
|
||||
"table `{}` intersects table `{}`",
|
||||
self.tables[index].name, self.tables[other].name
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn table_index(
|
||||
&self,
|
||||
action: &str,
|
||||
lookup: SpreadsheetTableLookup<'_>,
|
||||
) -> Result<usize, SpreadsheetArtifactError> {
|
||||
self.tables
|
||||
.iter()
|
||||
.position(|table| table_matches_lookup(table, lookup.clone()))
|
||||
.ok_or_else(|| SpreadsheetArtifactError::InvalidArgs {
|
||||
action: action.to_string(),
|
||||
message: describe_missing_table(lookup),
|
||||
})
|
||||
}
|
||||
|
||||
fn table_lookup_internal(
|
||||
&self,
|
||||
action: &str,
|
||||
lookup: SpreadsheetTableLookup<'_>,
|
||||
) -> Result<&SpreadsheetTable, SpreadsheetArtifactError> {
|
||||
self.tables
|
||||
.iter()
|
||||
.find(|table| table_matches_lookup(table, lookup.clone()))
|
||||
.ok_or_else(|| SpreadsheetArtifactError::InvalidArgs {
|
||||
action: action.to_string(),
|
||||
message: describe_missing_table(lookup),
|
||||
})
|
||||
}
|
||||
|
||||
fn table_lookup_mut(
|
||||
&mut self,
|
||||
action: &str,
|
||||
lookup: SpreadsheetTableLookup<'_>,
|
||||
) -> Result<&mut SpreadsheetTable, SpreadsheetArtifactError> {
|
||||
let index = self.table_index(action, lookup)?;
|
||||
Ok(&mut self.tables[index])
|
||||
}
|
||||
}
|
||||
|
||||
fn table_matches_lookup(table: &SpreadsheetTable, lookup: SpreadsheetTableLookup<'_>) -> bool {
|
||||
if let Some(name) = lookup.name {
|
||||
table.name == name
|
||||
} else if let Some(display_name) = lookup.display_name {
|
||||
table.display_name == display_name
|
||||
} else if let Some(id) = lookup.id {
|
||||
table.id == id
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn describe_missing_table(lookup: SpreadsheetTableLookup<'_>) -> String {
|
||||
if let Some(name) = lookup.name {
|
||||
format!("table name `{name}` was not found")
|
||||
} else if let Some(display_name) = lookup.display_name {
|
||||
format!("table display_name `{display_name}` was not found")
|
||||
} else if let Some(id) = lookup.id {
|
||||
format!("table id `{id}` was not found")
|
||||
} else {
|
||||
"table name, display_name, or id is required".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_unique_table_name(
|
||||
tables: &[SpreadsheetTable],
|
||||
action: &str,
|
||||
name: &str,
|
||||
display_name: &str,
|
||||
exclude_id: Option<u32>,
|
||||
) -> Result<(), SpreadsheetArtifactError> {
|
||||
if tables.iter().any(|table| {
|
||||
Some(table.id) != exclude_id && (table.name == name || table.display_name == name)
|
||||
}) {
|
||||
return Err(SpreadsheetArtifactError::InvalidArgs {
|
||||
action: action.to_string(),
|
||||
message: format!("table name `{name}` already exists"),
|
||||
});
|
||||
}
|
||||
if tables.iter().any(|table| {
|
||||
Some(table.id) != exclude_id
|
||||
&& (table.display_name == display_name || table.name == display_name)
|
||||
}) {
|
||||
return Err(SpreadsheetArtifactError::InvalidArgs {
|
||||
action: action.to_string(),
|
||||
message: format!("table display_name `{display_name}` already exists"),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_table_geometry(
|
||||
action: &str,
|
||||
range: &CellRange,
|
||||
header_row_count: u32,
|
||||
totals_row_count: u32,
|
||||
) -> Result<(), SpreadsheetArtifactError> {
|
||||
if range.width() == 0 {
|
||||
return Err(SpreadsheetArtifactError::InvalidArgs {
|
||||
action: action.to_string(),
|
||||
message: "table range must include at least one column".to_string(),
|
||||
});
|
||||
}
|
||||
if header_row_count + totals_row_count > range.height() as u32 {
|
||||
return Err(SpreadsheetArtifactError::InvalidArgs {
|
||||
action: action.to_string(),
|
||||
message: "table range is smaller than header and totals rows".to_string(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_table_columns(
|
||||
sheet: &SpreadsheetSheet,
|
||||
range: &CellRange,
|
||||
header_row_count: u32,
|
||||
) -> Vec<SpreadsheetTableColumn> {
|
||||
let header_row = range.start.row + header_row_count.saturating_sub(1);
|
||||
let default_names = (0..range.width())
|
||||
.map(|index| format!("Column{}", index + 1))
|
||||
.collect::<Vec<_>>();
|
||||
let names = unique_table_column_names(
|
||||
(range.start.column..=range.end.column)
|
||||
.enumerate()
|
||||
.map(|(index, column)| {
|
||||
if header_row_count == 0 {
|
||||
return default_names[index].clone();
|
||||
}
|
||||
sheet
|
||||
.get_cell(CellAddress {
|
||||
column,
|
||||
row: header_row,
|
||||
})
|
||||
.and_then(|cell| cell.value.as_ref())
|
||||
.map(cell_value_to_table_header)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| default_names[index].clone())
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
names
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, name)| SpreadsheetTableColumn {
|
||||
id: index as u32 + 1,
|
||||
name,
|
||||
totals_row_label: None,
|
||||
totals_row_function: None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn unique_table_column_names(names: Vec<String>) -> Vec<String> {
|
||||
let mut seen = BTreeMap::<String, u32>::new();
|
||||
names.into_iter()
|
||||
.map(|name| {
|
||||
let entry = seen.entry(name.clone()).or_insert(0);
|
||||
*entry += 1;
|
||||
if *entry == 1 {
|
||||
name
|
||||
} else {
|
||||
format!("{name}_{}", *entry)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn cell_value_to_table_header(value: &SpreadsheetCellValue) -> String {
|
||||
match value {
|
||||
SpreadsheetCellValue::Bool(value) => value.to_string(),
|
||||
SpreadsheetCellValue::Integer(value) => value.to_string(),
|
||||
SpreadsheetCellValue::Float(value) => value.to_string(),
|
||||
SpreadsheetCellValue::String(value)
|
||||
| SpreadsheetCellValue::DateTime(value)
|
||||
| SpreadsheetCellValue::Error(value) => value.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn table_column_lookup_mut<'a>(
|
||||
columns: &'a mut [SpreadsheetTableColumn],
|
||||
action: &str,
|
||||
column_id: Option<u32>,
|
||||
column_name: Option<&str>,
|
||||
) -> Result<&'a mut SpreadsheetTableColumn, SpreadsheetArtifactError> {
|
||||
columns
|
||||
.iter_mut()
|
||||
.find(|column| {
|
||||
if let Some(column_id) = column_id {
|
||||
column.id == column_id
|
||||
} else if let Some(column_name) = column_name {
|
||||
column.name == column_name
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
.ok_or_else(|| SpreadsheetArtifactError::InvalidArgs {
|
||||
action: action.to_string(),
|
||||
message: if let Some(column_id) = column_id {
|
||||
format!("table column id `{column_id}` was not found")
|
||||
} else if let Some(column_name) = column_name {
|
||||
format!("table column `{column_name}` was not found")
|
||||
} else {
|
||||
"table column id or name is required".to_string()
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -15,6 +15,7 @@ use crate::SpreadsheetFileType;
|
||||
use crate::SpreadsheetFill;
|
||||
use crate::SpreadsheetFontFace;
|
||||
use crate::SpreadsheetNumberFormat;
|
||||
use crate::SpreadsheetRenderOptions;
|
||||
use crate::SpreadsheetSheet;
|
||||
use crate::SpreadsheetSheetReference;
|
||||
use crate::SpreadsheetTextStyle;
|
||||
@@ -164,6 +165,72 @@ fn path_accesses_cover_import_and_export() -> Result<(), Box<dyn std::error::Err
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_options_write_deterministic_html_previews() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let temp_dir = tempfile::tempdir()?;
|
||||
let mut artifact = SpreadsheetArtifact::new(Some("Preview".to_string()));
|
||||
artifact.create_sheet("Sheet 1".to_string())?;
|
||||
{
|
||||
let sheet = artifact
|
||||
.get_sheet_mut(Some("Sheet 1"), None)
|
||||
.expect("sheet");
|
||||
sheet.set_value(
|
||||
CellAddress::parse("A1")?,
|
||||
Some(SpreadsheetCellValue::String("Name".to_string())),
|
||||
)?;
|
||||
sheet.set_value(
|
||||
CellAddress::parse("B1")?,
|
||||
Some(SpreadsheetCellValue::String("Value".to_string())),
|
||||
)?;
|
||||
sheet.set_value(
|
||||
CellAddress::parse("A2")?,
|
||||
Some(SpreadsheetCellValue::String("Alpha".to_string())),
|
||||
)?;
|
||||
sheet.set_value(
|
||||
CellAddress::parse("B2")?,
|
||||
Some(SpreadsheetCellValue::Integer(42)),
|
||||
)?;
|
||||
}
|
||||
|
||||
let rendered = artifact.render_range_preview(
|
||||
temp_dir.path(),
|
||||
artifact.get_sheet(Some("Sheet 1"), None).expect("sheet"),
|
||||
&CellRange::parse("A1:B2")?,
|
||||
&SpreadsheetRenderOptions {
|
||||
output_path: Some(temp_dir.path().join("range-preview.html")),
|
||||
width: Some(320),
|
||||
height: Some(200),
|
||||
include_headers: true,
|
||||
scale: 1.25,
|
||||
performance_mode: true,
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
assert!(rendered.path.exists());
|
||||
assert_eq!(std::fs::read_to_string(&rendered.path)?, rendered.html);
|
||||
assert!(rendered.html.contains("<!doctype html>"));
|
||||
assert!(rendered.html.contains("data-performance-mode=\"true\""));
|
||||
assert!(rendered.html.contains(
|
||||
"style=\"--scale: 1.25; --headers: 1; width: 320px; height: 200px; overflow: auto\""
|
||||
));
|
||||
assert!(rendered.html.contains("<th>A</th>"));
|
||||
assert!(rendered.html.contains("data-address=\"B2\""));
|
||||
assert!(rendered.html.contains(">42</td>"));
|
||||
|
||||
let workbook = artifact.render_workbook_previews(
|
||||
temp_dir.path(),
|
||||
&SpreadsheetRenderOptions {
|
||||
output_path: Some(temp_dir.path().join("workbook")),
|
||||
include_headers: false,
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
assert_eq!(workbook.len(), 1);
|
||||
assert!(workbook[0].path.ends_with("Sheet_1.html"));
|
||||
assert!(!workbook[0].html.contains("<th>A</th>"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sheet_refs_support_handle_and_field_apis() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut artifact = SpreadsheetArtifact::new(Some("Handles".to_string()));
|
||||
@@ -1049,3 +1116,113 @@ fn manager_get_reference_and_xlsx_import_preserve_workbook_name()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_render_actions_support_workbook_sheet_and_range()
|
||||
-> Result<(), Box<dyn std::error::Error>> {
|
||||
let temp_dir = tempfile::tempdir()?;
|
||||
let mut manager = SpreadsheetArtifactManager::default();
|
||||
let created = manager.execute(
|
||||
SpreadsheetArtifactRequest {
|
||||
artifact_id: None,
|
||||
action: "create".to_string(),
|
||||
args: serde_json::json!({ "name": "Render" }),
|
||||
},
|
||||
temp_dir.path(),
|
||||
)?;
|
||||
let artifact_id = created.artifact_id;
|
||||
|
||||
manager.execute(
|
||||
SpreadsheetArtifactRequest {
|
||||
artifact_id: Some(artifact_id.clone()),
|
||||
action: "create_sheet".to_string(),
|
||||
args: serde_json::json!({ "name": "Sheet1" }),
|
||||
},
|
||||
temp_dir.path(),
|
||||
)?;
|
||||
manager.execute(
|
||||
SpreadsheetArtifactRequest {
|
||||
artifact_id: Some(artifact_id.clone()),
|
||||
action: "set_range_values".to_string(),
|
||||
args: serde_json::json!({
|
||||
"sheet_name": "Sheet1",
|
||||
"range": "A1:C4",
|
||||
"values": [
|
||||
["h1", "h2", "h3"],
|
||||
["a", 1, 2],
|
||||
["b", 3, 4],
|
||||
["c", 5, 6]
|
||||
]
|
||||
}),
|
||||
},
|
||||
temp_dir.path(),
|
||||
)?;
|
||||
|
||||
let workbook = manager.execute(
|
||||
SpreadsheetArtifactRequest {
|
||||
artifact_id: Some(artifact_id.clone()),
|
||||
action: "render_workbook".to_string(),
|
||||
args: serde_json::json!({
|
||||
"output_path": temp_dir.path().join("workbook-previews"),
|
||||
"include_headers": false
|
||||
}),
|
||||
},
|
||||
temp_dir.path(),
|
||||
)?;
|
||||
assert_eq!(workbook.exported_paths.len(), 1);
|
||||
assert!(workbook.exported_paths[0].exists());
|
||||
|
||||
let sheet = manager.execute(
|
||||
SpreadsheetArtifactRequest {
|
||||
artifact_id: Some(artifact_id.clone()),
|
||||
action: "render_sheet".to_string(),
|
||||
args: serde_json::json!({
|
||||
"sheet_name": "Sheet1",
|
||||
"output_path": temp_dir.path().join("sheet-preview.html"),
|
||||
"center_address": "B3",
|
||||
"width": 220,
|
||||
"height": 90,
|
||||
"scale": 1.5,
|
||||
"performance_mode": true
|
||||
}),
|
||||
},
|
||||
temp_dir.path(),
|
||||
)?;
|
||||
assert_eq!(sheet.exported_paths.len(), 1);
|
||||
assert!(sheet.exported_paths[0].exists());
|
||||
assert!(
|
||||
sheet
|
||||
.rendered_html
|
||||
.as_ref()
|
||||
.is_some_and(|html| html.contains("data-performance-mode=\"true\""))
|
||||
);
|
||||
|
||||
let range = manager.execute(
|
||||
SpreadsheetArtifactRequest {
|
||||
artifact_id: Some(artifact_id),
|
||||
action: "render_range".to_string(),
|
||||
args: serde_json::json!({
|
||||
"sheet_name": "Sheet1",
|
||||
"range": "A2:C4",
|
||||
"output_path": temp_dir.path().join("range-preview.html"),
|
||||
"include_headers": true
|
||||
}),
|
||||
},
|
||||
temp_dir.path(),
|
||||
)?;
|
||||
assert_eq!(range.exported_paths.len(), 1);
|
||||
assert_eq!(
|
||||
range
|
||||
.range_ref
|
||||
.as_ref()
|
||||
.map(|range_ref| range_ref.address.clone()),
|
||||
Some("A2:C4".to_string())
|
||||
);
|
||||
assert!(
|
||||
range
|
||||
.rendered_html
|
||||
.as_ref()
|
||||
.is_some_and(|html| html.contains("<th>A</th>"))
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user