diff --git a/codex-rs/artifact-spreadsheet/src/lib.rs b/codex-rs/artifact-spreadsheet/src/lib.rs index 262ae7e9a..b6fab70c4 100644 --- a/codex-rs/artifact-spreadsheet/src/lib.rs +++ b/codex-rs/artifact-spreadsheet/src/lib.rs @@ -3,6 +3,7 @@ mod error; mod formula; mod manager; mod model; +mod style; mod xlsx; #[cfg(test)] @@ -12,3 +13,4 @@ pub use address::*; pub use error::*; pub use manager::*; pub use model::*; +pub use style::*; diff --git a/codex-rs/artifact-spreadsheet/src/manager.rs b/codex-rs/artifact-spreadsheet/src/manager.rs index 42f39a852..2e2b1755d 100644 --- a/codex-rs/artifact-spreadsheet/src/manager.rs +++ b/codex-rs/artifact-spreadsheet/src/manager.rs @@ -109,6 +109,21 @@ impl SpreadsheetArtifactManager { "set_row_heights_bulk" => self.set_row_heights_bulk(request), "get_row_height" => self.get_row_height(request), "cleanup_and_validate_sheet" => self.cleanup_and_validate_sheet(request), + "create_text_style" => self.create_text_style(request), + "get_text_style" => self.get_text_style(request), + "create_fill" => self.create_fill(request), + "get_fill" => self.get_fill(request), + "create_border" => self.create_border(request), + "get_border" => self.get_border(request), + "create_number_format" => self.create_number_format(request), + "get_number_format" => self.get_number_format(request), + "create_cell_format" => self.create_cell_format(request), + "get_cell_format" => self.get_cell_format(request), + "create_differential_format" => self.create_differential_format(request), + "get_differential_format" => self.get_differential_format(request), + "get_cell_format_summary" => self.get_cell_format_summary(request), + "get_range_format_summary" => self.get_range_format_summary(request), + "get_reference" => self.get_reference(request), "get_cell" => self.get_cell(request), "get_cell_by_indices" => self.get_cell_by_indices(request), "get_cell_field" => self.get_cell_field(request), @@ -312,6 +327,13 @@ impl SpreadsheetArtifactManager { .map(|entry| SpreadsheetCellRangeRef::new(sheet.name.clone(), entry)); response.rendered_text = Some(sheet.to_rendered_text(range.as_ref())); response.range = range.as_ref().map(|entry| sheet.get_range_view(entry)); + response.top_left_style_index = range + .as_ref() + .map(|entry| sheet.top_left_style_index(entry)); + response.range_format = range.as_ref().map(|entry| sheet.range_format(entry)); + response.cell_format_summary = response + .top_left_style_index + .and_then(|style_index| artifact.cell_format_summary(style_index)); response.serialized_dict = Some(sheet.to_dict()?); Ok(response) } @@ -665,6 +687,417 @@ impl SpreadsheetArtifactManager { Ok(response) } + fn create_text_style( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: CreateTextStyleArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let style_id = artifact.create_text_style( + args.style, + args.source_style_id, + args.merge_with_existing_components.unwrap_or(false), + )?; + let style = artifact.get_text_style(style_id).cloned().ok_or_else(|| { + SpreadsheetArtifactError::Serialization { + message: format!("created text style `{style_id}` was not available"), + } + })?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Created text style `{style_id}`"), + snapshot_for_artifact(artifact), + ); + response.style_id = Some(style_id); + response.serialized_dict = Some(to_serialized_value(style)?); + Ok(response) + } + + fn get_text_style( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: GetStyleArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let style = artifact.get_text_style(args.id).cloned().ok_or_else(|| { + SpreadsheetArtifactError::InvalidArgs { + action: request.action.clone(), + message: format!("text style `{}` was not found", args.id), + } + })?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Retrieved text style `{}`", args.id), + snapshot_for_artifact(artifact), + ); + response.style_id = Some(args.id); + response.serialized_dict = Some(to_serialized_value(style)?); + Ok(response) + } + + fn create_fill( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: CreateFillArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let style_id = artifact.create_fill( + args.fill, + args.source_fill_id, + args.merge_with_existing_components.unwrap_or(false), + )?; + let fill = artifact.get_fill(style_id).cloned().ok_or_else(|| { + SpreadsheetArtifactError::Serialization { + message: format!("created fill `{style_id}` was not available"), + } + })?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Created fill `{style_id}`"), + snapshot_for_artifact(artifact), + ); + response.style_id = Some(style_id); + response.serialized_dict = Some(to_serialized_value(fill)?); + Ok(response) + } + + fn get_fill( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: GetStyleArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let fill = artifact.get_fill(args.id).cloned().ok_or_else(|| { + SpreadsheetArtifactError::InvalidArgs { + action: request.action.clone(), + message: format!("fill `{}` was not found", args.id), + } + })?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Retrieved fill `{}`", args.id), + snapshot_for_artifact(artifact), + ); + response.style_id = Some(args.id); + response.serialized_dict = Some(to_serialized_value(fill)?); + Ok(response) + } + + fn create_border( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: CreateBorderArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let style_id = artifact.create_border( + args.border, + args.source_border_id, + args.merge_with_existing_components.unwrap_or(false), + )?; + let border = artifact.get_border(style_id).cloned().ok_or_else(|| { + SpreadsheetArtifactError::Serialization { + message: format!("created border `{style_id}` was not available"), + } + })?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Created border `{style_id}`"), + snapshot_for_artifact(artifact), + ); + response.style_id = Some(style_id); + response.serialized_dict = Some(to_serialized_value(border)?); + Ok(response) + } + + fn get_border( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: GetStyleArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let border = artifact.get_border(args.id).cloned().ok_or_else(|| { + SpreadsheetArtifactError::InvalidArgs { + action: request.action.clone(), + message: format!("border `{}` was not found", args.id), + } + })?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Retrieved border `{}`", args.id), + snapshot_for_artifact(artifact), + ); + response.style_id = Some(args.id); + response.serialized_dict = Some(to_serialized_value(border)?); + Ok(response) + } + + fn create_number_format( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: CreateNumberFormatArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let style_id = artifact.create_number_format( + args.number_format, + args.source_number_format_id, + args.merge_with_existing_components.unwrap_or(false), + )?; + let number_format = artifact + .get_number_format(style_id) + .cloned() + .ok_or_else(|| SpreadsheetArtifactError::Serialization { + message: format!("created number format `{style_id}` was not available"), + })?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Created number format `{style_id}`"), + snapshot_for_artifact(artifact), + ); + response.style_id = Some(style_id); + response.serialized_dict = Some(to_serialized_value(number_format)?); + Ok(response) + } + + fn get_number_format( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: GetStyleArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let number_format = artifact + .get_number_format(args.id) + .cloned() + .ok_or_else(|| SpreadsheetArtifactError::InvalidArgs { + action: request.action.clone(), + message: format!("number format `{}` was not found", args.id), + })?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Retrieved number format `{}`", args.id), + snapshot_for_artifact(artifact), + ); + response.style_id = Some(args.id); + response.serialized_dict = Some(to_serialized_value(number_format)?); + Ok(response) + } + + fn create_cell_format( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: CreateCellFormatArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let style_id = artifact.create_cell_format( + args.format, + args.source_format_id, + args.merge_with_existing_components.unwrap_or(false), + )?; + let format = artifact.get_cell_format(style_id).cloned().ok_or_else(|| { + SpreadsheetArtifactError::Serialization { + message: format!("created cell format `{style_id}` was not available"), + } + })?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Created cell format `{style_id}`"), + snapshot_for_artifact(artifact), + ); + response.style_id = Some(style_id); + response.serialized_dict = Some(to_serialized_value(format)?); + response.cell_format_summary = artifact.cell_format_summary(style_id); + Ok(response) + } + + fn get_cell_format( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: GetStyleArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let format = artifact.get_cell_format(args.id).cloned().ok_or_else(|| { + SpreadsheetArtifactError::InvalidArgs { + action: request.action.clone(), + message: format!("cell format `{}` was not found", args.id), + } + })?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Retrieved cell format `{}`", args.id), + snapshot_for_artifact(artifact), + ); + response.style_id = Some(args.id); + response.serialized_dict = Some(to_serialized_value(format)?); + response.cell_format_summary = artifact.cell_format_summary(args.id); + Ok(response) + } + + fn create_differential_format( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: CreateDifferentialFormatArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + let style_id = artifact.create_differential_format(args.format); + let format = artifact + .get_differential_format(style_id) + .cloned() + .ok_or_else(|| SpreadsheetArtifactError::Serialization { + message: format!("created differential format `{style_id}` was not available"), + })?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Created differential format `{style_id}`"), + snapshot_for_artifact(artifact), + ); + response.style_id = Some(style_id); + response.serialized_dict = Some(to_serialized_value(format)?); + Ok(response) + } + + fn get_differential_format( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: GetStyleArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let artifact = self.get_artifact(&artifact_id, &request.action)?; + let format = artifact + .get_differential_format(args.id) + .cloned() + .ok_or_else(|| SpreadsheetArtifactError::InvalidArgs { + action: request.action.clone(), + message: format!("differential format `{}` was not found", args.id), + })?; + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Retrieved differential format `{}`", args.id), + snapshot_for_artifact(artifact), + ); + response.style_id = Some(args.id); + response.serialized_dict = Some(to_serialized_value(format)?); + Ok(response) + } + + fn get_cell_format_summary( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: CellAddressArgs = 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 cell = sheet.get_cell_view(CellAddress::parse(&args.address)?); + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Retrieved cell format summary for `{}`", args.address), + snapshot_for_artifact(artifact), + ); + response.cell_ref = Some(sheet.cell_ref(&args.address)?); + response.cell = Some(cell.clone()); + response.cell_format_summary = artifact.cell_format_summary(cell.style_index); + Ok(response) + } + + fn get_range_format_summary( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: RangeArgs = 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 = CellRange::parse(&args.range)?; + let top_left_style_index = sheet.top_left_style_index(&range); + let mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!("Retrieved range format summary for `{}`", args.range), + snapshot_for_artifact(artifact), + ); + response.range_ref = Some(SpreadsheetCellRangeRef::new(sheet.name.clone(), &range)); + response.range_format = Some(sheet.range_format(&range)); + response.range = Some(sheet.get_range_view(&range)); + response.top_left_style_index = Some(top_left_style_index); + response.cell_format_summary = artifact.cell_format_summary(top_left_style_index); + Ok(response) + } + + fn get_reference( + &mut self, + request: SpreadsheetArtifactRequest, + ) -> Result { + let args: ReferenceArgs = 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 mut response = SpreadsheetArtifactResponse::new( + artifact_id, + request.action, + format!( + "Resolved reference `{}` on `{}`", + args.reference, sheet.name + ), + snapshot_for_artifact(artifact), + ); + match sheet.reference(&args.reference)? { + crate::SpreadsheetSheetReference::Cell { cell_ref } => { + let address = cell_ref.cell_address()?; + let cell = sheet.get_cell_view(address); + response.cell_format_summary = artifact.cell_format_summary(cell.style_index); + response.cell = Some(cell); + response.raw_cell = sheet.get_raw_cell(address); + response.cell_ref = Some(cell_ref); + } + crate::SpreadsheetSheetReference::Range { range_ref } => { + let range = range_ref.range()?; + let top_left_style_index = sheet.top_left_style_index(&range); + response.range = Some(sheet.get_range_view(&range)); + response.range_ref = Some(range_ref); + response.range_format = Some(sheet.range_format(&range)); + response.top_left_style_index = Some(top_left_style_index); + response.cell_format_summary = artifact.cell_format_summary(top_left_style_index); + response.rendered_text = Some(sheet.to_rendered_text(Some(&range))); + } + } + Ok(response) + } + fn get_cell( &mut self, request: SpreadsheetArtifactRequest, @@ -684,7 +1117,9 @@ impl SpreadsheetArtifactManager { format!("Retrieved cell `{}` from `{}`", args.address, sheet.name), snapshot_for_artifact(artifact), ); + response.cell_format_summary = artifact.cell_format_summary(cell.style_index); response.cell = Some(cell); + response.raw_cell = sheet.get_raw_cell(CellAddress::parse(&args.address)?); response.cell_ref = Some(sheet.cell_ref(&args.address)?); Ok(response) } @@ -714,7 +1149,10 @@ impl SpreadsheetArtifactManager { ), snapshot_for_artifact(artifact), ); - response.cell = Some(sheet.get_cell_view_by_indices(args.column_index, args.row_index)); + let cell = sheet.get_cell_view_by_indices(args.column_index, args.row_index); + response.cell_format_summary = artifact.cell_format_summary(cell.style_index); + response.cell = Some(cell); + response.raw_cell = sheet.get_raw_cell(address); response.cell_ref = Some(sheet.cell_ref(address.to_a1())?); Ok(response) } @@ -798,6 +1236,11 @@ impl SpreadsheetArtifactManager { snapshot_for_artifact(artifact), ); response.range = Some(sheet.get_range_view(&range)); + response.range_ref = Some(SpreadsheetCellRangeRef::new(sheet.name.clone(), &range)); + response.range_format = Some(sheet.range_format(&range)); + response.top_left_style_index = Some(sheet.top_left_style_index(&range)); + response.cell_format_summary = + artifact.cell_format_summary(sheet.top_left_style_index(&range)); response.rendered_text = Some(sheet.to_rendered_text(Some(&range))); Ok(response) } @@ -1470,12 +1913,22 @@ pub struct SpreadsheetArtifactResponse { #[serde(skip_serializing_if = "Option::is_none")] pub range_ref: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub range_format: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub cell: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub raw_cell: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub style_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub cell_field: Option, #[serde(skip_serializing_if = "Option::is_none")] pub range: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub top_left_style_index: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cell_format_summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub rendered_text: Option, #[serde(skip_serializing_if = "Option::is_none")] pub row_height: Option, @@ -1505,9 +1958,14 @@ impl SpreadsheetArtifactResponse { sheet_ref: None, cell_ref: None, range_ref: None, + range_format: None, cell: None, + raw_cell: None, + style_id: None, cell_field: None, range: None, + top_left_style_index: None, + cell_format_summary: None, rendered_text: None, row_height: None, serialized_dict: None, @@ -1636,6 +2094,51 @@ struct GetRowHeightArgs { row_index: u32, } +#[derive(Debug, Deserialize)] +struct CreateTextStyleArgs { + style: crate::SpreadsheetTextStyle, + source_style_id: Option, + merge_with_existing_components: Option, +} + +#[derive(Debug, Deserialize)] +struct CreateFillArgs { + fill: crate::SpreadsheetFill, + source_fill_id: Option, + merge_with_existing_components: Option, +} + +#[derive(Debug, Deserialize)] +struct CreateBorderArgs { + border: crate::SpreadsheetBorder, + source_border_id: Option, + merge_with_existing_components: Option, +} + +#[derive(Debug, Deserialize)] +struct CreateNumberFormatArgs { + number_format: crate::SpreadsheetNumberFormat, + source_number_format_id: Option, + merge_with_existing_components: Option, +} + +#[derive(Debug, Deserialize)] +struct CreateCellFormatArgs { + format: crate::SpreadsheetCellFormat, + source_format_id: Option, + merge_with_existing_components: Option, +} + +#[derive(Debug, Deserialize)] +struct CreateDifferentialFormatArgs { + format: crate::SpreadsheetDifferentialFormat, +} + +#[derive(Debug, Deserialize)] +struct GetStyleArgs { + id: u32, +} + #[derive(Debug, Deserialize)] struct CellAddressArgs { sheet_name: Option, @@ -1643,6 +2146,13 @@ struct CellAddressArgs { address: String, } +#[derive(Debug, Deserialize)] +struct ReferenceArgs { + sheet_name: Option, + sheet_index: Option, + reference: String, +} + #[derive(Debug, Deserialize)] struct CellIndicesArgs { sheet_name: Option, @@ -1903,3 +2413,9 @@ fn resolve_path(cwd: &Path, path: &Path) -> PathBuf { cwd.join(path) } } + +fn to_serialized_value(value: T) -> Result { + serde_json::to_value(value).map_err(|error| SpreadsheetArtifactError::Serialization { + message: error.to_string(), + }) +} diff --git a/codex-rs/artifact-spreadsheet/src/model.rs b/codex-rs/artifact-spreadsheet/src/model.rs index 5df39d8a7..228943a56 100644 --- a/codex-rs/artifact-spreadsheet/src/model.rs +++ b/codex-rs/artifact-spreadsheet/src/model.rs @@ -65,6 +65,11 @@ impl TryFrom for SpreadsheetCellValue { fn try_from(value: Value) -> Result { match value { + Value::Object(_) => serde_json::from_value(value).map_err(|error| { + SpreadsheetArtifactError::Serialization { + message: error.to_string(), + } + }), Value::Bool(value) => Ok(Self::Bool(value)), Value::Number(value) => { if let Some(integer) = value.as_i64() { @@ -337,6 +342,14 @@ impl SpreadsheetCellRangeRef { Ok(self.get(sheet)?.data) } + pub fn top_left_style_index( + &self, + sheet: &SpreadsheetSheet, + ) -> Result { + self.ensure_sheet(sheet)?; + Ok(sheet.top_left_style_index(&self.range()?)) + } + pub fn set_value( &self, sheet: &mut SpreadsheetSheet, @@ -420,6 +433,13 @@ impl SpreadsheetCellRangeRef { } } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum SpreadsheetSheetReference { + Cell { cell_ref: SpreadsheetCellRef }, + Range { range_ref: SpreadsheetCellRangeRef }, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SpreadsheetSheetSummary { pub name: String, @@ -686,6 +706,19 @@ impl SpreadsheetSheet { Ok(SpreadsheetCellRangeRef::new(self.name.clone(), &range)) } + pub fn reference( + &self, + address: impl AsRef, + ) -> Result { + let address = address.as_ref(); + if let Ok(cell_ref) = self.cell_ref(address) { + return Ok(SpreadsheetSheetReference::Cell { cell_ref }); + } + Ok(SpreadsheetSheetReference::Range { + range_ref: self.range_ref(address)?, + }) + } + pub fn set_column_widths( &mut self, reference: &str, @@ -1156,6 +1189,12 @@ impl SpreadsheetSheet { self.get_cell(address).cloned() } + pub fn top_left_style_index(&self, range: &CellRange) -> u32 { + self.get_cell(range.start) + .map(|cell| cell.style_index) + .unwrap_or(0) + } + pub fn get_range_view(&self, range: &CellRange) -> SpreadsheetRangeView { let mut values = Vec::new(); let mut formulas = Vec::new(); @@ -1307,6 +1346,18 @@ pub struct SpreadsheetArtifact { #[serde(default)] pub sheets: Vec, pub auto_recalculate: bool, + #[serde(default)] + pub text_styles: BTreeMap, + #[serde(default)] + pub fills: BTreeMap, + #[serde(default)] + pub borders: BTreeMap, + #[serde(default)] + pub number_formats: BTreeMap, + #[serde(default)] + pub cell_formats: BTreeMap, + #[serde(default)] + pub differential_formats: BTreeMap, } impl SpreadsheetArtifact { @@ -1316,6 +1367,12 @@ impl SpreadsheetArtifact { name, sheets: Vec::new(), auto_recalculate: false, + text_styles: BTreeMap::new(), + fills: BTreeMap::new(), + borders: BTreeMap::new(), + number_formats: BTreeMap::new(), + cell_formats: BTreeMap::new(), + differential_formats: BTreeMap::new(), } } diff --git a/codex-rs/artifact-spreadsheet/src/style.rs b/codex-rs/artifact-spreadsheet/src/style.rs new file mode 100644 index 000000000..0c9a3782a --- /dev/null +++ b/codex-rs/artifact-spreadsheet/src/style.rs @@ -0,0 +1,580 @@ +use std::collections::BTreeMap; + +use serde::Deserialize; +use serde::Serialize; + +use crate::CellRange; +use crate::SpreadsheetArtifact; +use crate::SpreadsheetArtifactError; +use crate::SpreadsheetCellRangeRef; +use crate::SpreadsheetSheet; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct SpreadsheetFontFace { + pub font_family: Option, + pub font_scheme: Option, + pub typeface: Option, +} + +impl SpreadsheetFontFace { + fn merge(&self, patch: &Self) -> Self { + Self { + font_family: patch + .font_family + .clone() + .or_else(|| self.font_family.clone()), + font_scheme: patch + .font_scheme + .clone() + .or_else(|| self.font_scheme.clone()), + typeface: patch.typeface.clone().or_else(|| self.typeface.clone()), + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct SpreadsheetTextStyle { + pub bold: Option, + pub italic: Option, + pub underline: Option, + pub font_size: Option, + pub font_color: Option, + pub text_alignment: Option, + pub anchor: Option, + pub vertical_text_orientation: Option, + pub text_rotation: Option, + pub paragraph_spacing: Option, + pub bottom_inset: Option, + pub left_inset: Option, + pub right_inset: Option, + pub top_inset: Option, + pub font_family: Option, + pub font_scheme: Option, + pub typeface: Option, + pub font_face: Option, +} + +impl SpreadsheetTextStyle { + fn merge(&self, patch: &Self) -> Self { + Self { + bold: patch.bold.or(self.bold), + italic: patch.italic.or(self.italic), + underline: patch.underline.or(self.underline), + font_size: patch.font_size.or(self.font_size), + font_color: patch.font_color.clone().or_else(|| self.font_color.clone()), + text_alignment: patch + .text_alignment + .clone() + .or_else(|| self.text_alignment.clone()), + anchor: patch.anchor.clone().or_else(|| self.anchor.clone()), + vertical_text_orientation: patch + .vertical_text_orientation + .clone() + .or_else(|| self.vertical_text_orientation.clone()), + text_rotation: patch.text_rotation.or(self.text_rotation), + paragraph_spacing: patch.paragraph_spacing.or(self.paragraph_spacing), + bottom_inset: patch.bottom_inset.or(self.bottom_inset), + left_inset: patch.left_inset.or(self.left_inset), + right_inset: patch.right_inset.or(self.right_inset), + top_inset: patch.top_inset.or(self.top_inset), + font_family: patch + .font_family + .clone() + .or_else(|| self.font_family.clone()), + font_scheme: patch + .font_scheme + .clone() + .or_else(|| self.font_scheme.clone()), + typeface: patch.typeface.clone().or_else(|| self.typeface.clone()), + font_face: match (&self.font_face, &patch.font_face) { + (Some(base), Some(update)) => Some(base.merge(update)), + (None, Some(update)) => Some(update.clone()), + (Some(base), None) => Some(base.clone()), + (None, None) => None, + }, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SpreadsheetGradientStop { + pub position: f64, + pub color: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SpreadsheetFillRectangle { + pub left: f64, + pub right: f64, + pub top: f64, + pub bottom: f64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct SpreadsheetFill { + pub solid_fill_color: Option, + pub pattern_type: Option, + pub pattern_foreground_color: Option, + pub pattern_background_color: Option, + #[serde(default)] + pub color_transforms: Vec, + pub gradient_fill_type: Option, + #[serde(default)] + pub gradient_stops: Vec, + pub gradient_kind: Option, + pub angle: Option, + pub scaled: Option, + pub path_type: Option, + pub fill_rectangle: Option, + pub image_reference: Option, +} + +impl SpreadsheetFill { + fn merge(&self, patch: &Self) -> Self { + Self { + solid_fill_color: patch + .solid_fill_color + .clone() + .or_else(|| self.solid_fill_color.clone()), + pattern_type: patch + .pattern_type + .clone() + .or_else(|| self.pattern_type.clone()), + pattern_foreground_color: patch + .pattern_foreground_color + .clone() + .or_else(|| self.pattern_foreground_color.clone()), + pattern_background_color: patch + .pattern_background_color + .clone() + .or_else(|| self.pattern_background_color.clone()), + color_transforms: if patch.color_transforms.is_empty() { + self.color_transforms.clone() + } else { + patch.color_transforms.clone() + }, + gradient_fill_type: patch + .gradient_fill_type + .clone() + .or_else(|| self.gradient_fill_type.clone()), + gradient_stops: if patch.gradient_stops.is_empty() { + self.gradient_stops.clone() + } else { + patch.gradient_stops.clone() + }, + gradient_kind: patch + .gradient_kind + .clone() + .or_else(|| self.gradient_kind.clone()), + angle: patch.angle.or(self.angle), + scaled: patch.scaled.or(self.scaled), + path_type: patch.path_type.clone().or_else(|| self.path_type.clone()), + fill_rectangle: patch + .fill_rectangle + .clone() + .or_else(|| self.fill_rectangle.clone()), + image_reference: patch + .image_reference + .clone() + .or_else(|| self.image_reference.clone()), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct SpreadsheetBorderLine { + pub style: Option, + pub color: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct SpreadsheetBorder { + pub top: Option, + pub right: Option, + pub bottom: Option, + pub left: Option, +} + +impl SpreadsheetBorder { + fn merge(&self, patch: &Self) -> Self { + Self { + top: patch.top.clone().or_else(|| self.top.clone()), + right: patch.right.clone().or_else(|| self.right.clone()), + bottom: patch.bottom.clone().or_else(|| self.bottom.clone()), + left: patch.left.clone().or_else(|| self.left.clone()), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct SpreadsheetAlignment { + pub horizontal: Option, + pub vertical: Option, +} + +impl SpreadsheetAlignment { + fn merge(&self, patch: &Self) -> Self { + Self { + horizontal: patch.horizontal.clone().or_else(|| self.horizontal.clone()), + vertical: patch.vertical.clone().or_else(|| self.vertical.clone()), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct SpreadsheetNumberFormat { + pub format_id: Option, + pub format_code: Option, +} + +impl SpreadsheetNumberFormat { + fn merge(&self, patch: &Self) -> Self { + Self { + format_id: patch.format_id.or(self.format_id), + format_code: patch + .format_code + .clone() + .or_else(|| self.format_code.clone()), + } + } + + fn normalized(mut self) -> Self { + if self.format_code.is_none() { + self.format_code = self.format_id.and_then(builtin_number_format_code); + } + self + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct SpreadsheetCellFormat { + pub text_style_id: Option, + pub fill_id: Option, + pub border_id: Option, + pub alignment: Option, + pub number_format_id: Option, + pub wrap_text: Option, + pub base_cell_style_format_id: Option, +} + +impl SpreadsheetCellFormat { + pub fn wrap(mut self) -> Self { + self.wrap_text = Some(true); + self + } + + pub fn unwrap(mut self) -> Self { + self.wrap_text = Some(false); + self + } + + fn merge(&self, patch: &Self) -> Self { + Self { + text_style_id: patch.text_style_id.or(self.text_style_id), + fill_id: patch.fill_id.or(self.fill_id), + border_id: patch.border_id.or(self.border_id), + alignment: match (&self.alignment, &patch.alignment) { + (Some(base), Some(update)) => Some(base.merge(update)), + (None, Some(update)) => Some(update.clone()), + (Some(base), None) => Some(base.clone()), + (None, None) => None, + }, + number_format_id: patch.number_format_id.or(self.number_format_id), + wrap_text: patch.wrap_text.or(self.wrap_text), + base_cell_style_format_id: patch + .base_cell_style_format_id + .or(self.base_cell_style_format_id), + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct SpreadsheetDifferentialFormat { + pub text_style_id: Option, + pub fill_id: Option, + pub border_id: Option, + pub alignment: Option, + pub number_format_id: Option, + pub wrap_text: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SpreadsheetCellFormatSummary { + pub style_index: u32, + pub text_style: Option, + pub fill: Option, + pub border: Option, + pub alignment: Option, + pub number_format: Option, + pub wrap_text: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpreadsheetRangeFormat { + pub sheet_name: String, + pub range: String, +} + +impl SpreadsheetRangeFormat { + pub fn new(sheet_name: String, range: &CellRange) -> Self { + Self { + sheet_name, + range: range.to_a1(), + } + } + + pub fn range_ref(&self) -> Result { + let range = CellRange::parse(&self.range)?; + Ok(SpreadsheetCellRangeRef::new( + self.sheet_name.clone(), + &range, + )) + } + + pub fn top_left_style_index( + &self, + sheet: &SpreadsheetSheet, + ) -> Result { + self.range_ref()?.top_left_style_index(sheet) + } + + pub fn top_left_cell_format( + &self, + artifact: &SpreadsheetArtifact, + sheet: &SpreadsheetSheet, + ) -> Result, SpreadsheetArtifactError> { + let range = self.range_ref()?.range()?; + Ok(artifact.cell_format_summary(sheet.top_left_style_index(&range))) + } +} + +impl SpreadsheetArtifact { + pub fn create_text_style( + &mut self, + style: SpreadsheetTextStyle, + source_style_id: Option, + merge_with_existing_components: bool, + ) -> Result { + let created = if let Some(source_style_id) = source_style_id { + let source = self + .text_styles + .get(&source_style_id) + .cloned() + .ok_or_else(|| SpreadsheetArtifactError::InvalidArgs { + action: "create_text_style".to_string(), + message: format!("text style `{source_style_id}` was not found"), + })?; + if merge_with_existing_components { + source.merge(&style) + } else { + style + } + } else { + style + }; + Ok(insert_with_next_id(&mut self.text_styles, created)) + } + + pub fn get_text_style(&self, style_id: u32) -> Option<&SpreadsheetTextStyle> { + self.text_styles.get(&style_id) + } + + pub fn create_fill( + &mut self, + fill: SpreadsheetFill, + source_fill_id: Option, + merge_with_existing_components: bool, + ) -> Result { + let created = if let Some(source_fill_id) = source_fill_id { + let source = self.fills.get(&source_fill_id).cloned().ok_or_else(|| { + SpreadsheetArtifactError::InvalidArgs { + action: "create_fill".to_string(), + message: format!("fill `{source_fill_id}` was not found"), + } + })?; + if merge_with_existing_components { + source.merge(&fill) + } else { + fill + } + } else { + fill + }; + Ok(insert_with_next_id(&mut self.fills, created)) + } + + pub fn get_fill(&self, fill_id: u32) -> Option<&SpreadsheetFill> { + self.fills.get(&fill_id) + } + + pub fn create_border( + &mut self, + border: SpreadsheetBorder, + source_border_id: Option, + merge_with_existing_components: bool, + ) -> Result { + let created = if let Some(source_border_id) = source_border_id { + let source = self + .borders + .get(&source_border_id) + .cloned() + .ok_or_else(|| SpreadsheetArtifactError::InvalidArgs { + action: "create_border".to_string(), + message: format!("border `{source_border_id}` was not found"), + })?; + if merge_with_existing_components { + source.merge(&border) + } else { + border + } + } else { + border + }; + Ok(insert_with_next_id(&mut self.borders, created)) + } + + pub fn get_border(&self, border_id: u32) -> Option<&SpreadsheetBorder> { + self.borders.get(&border_id) + } + + pub fn create_number_format( + &mut self, + format: SpreadsheetNumberFormat, + source_number_format_id: Option, + merge_with_existing_components: bool, + ) -> Result { + let created = if let Some(source_number_format_id) = source_number_format_id { + let source = self + .number_formats + .get(&source_number_format_id) + .cloned() + .ok_or_else(|| SpreadsheetArtifactError::InvalidArgs { + action: "create_number_format".to_string(), + message: format!("number format `{source_number_format_id}` was not found"), + })?; + if merge_with_existing_components { + source.merge(&format) + } else { + format + } + } else { + format + }; + Ok(insert_with_next_id( + &mut self.number_formats, + created.normalized(), + )) + } + + pub fn get_number_format(&self, number_format_id: u32) -> Option<&SpreadsheetNumberFormat> { + self.number_formats.get(&number_format_id) + } + + pub fn create_cell_format( + &mut self, + format: SpreadsheetCellFormat, + source_format_id: Option, + merge_with_existing_components: bool, + ) -> Result { + let created = if let Some(source_format_id) = source_format_id { + let source = self + .cell_formats + .get(&source_format_id) + .cloned() + .ok_or_else(|| SpreadsheetArtifactError::InvalidArgs { + action: "create_cell_format".to_string(), + message: format!("cell format `{source_format_id}` was not found"), + })?; + if merge_with_existing_components { + source.merge(&format) + } else { + format + } + } else { + format + }; + Ok(insert_with_next_id(&mut self.cell_formats, created)) + } + + pub fn get_cell_format(&self, format_id: u32) -> Option<&SpreadsheetCellFormat> { + self.cell_formats.get(&format_id) + } + + pub fn create_differential_format(&mut self, format: SpreadsheetDifferentialFormat) -> u32 { + insert_with_next_id(&mut self.differential_formats, format) + } + + pub fn get_differential_format( + &self, + format_id: u32, + ) -> Option<&SpreadsheetDifferentialFormat> { + self.differential_formats.get(&format_id) + } + + pub fn resolve_cell_format(&self, style_index: u32) -> Option { + let format = self.cell_formats.get(&style_index)?.clone(); + resolve_cell_format_recursive(&self.cell_formats, &format, 0) + } + + pub fn cell_format_summary(&self, style_index: u32) -> Option { + let resolved = self.resolve_cell_format(style_index)?; + Some(SpreadsheetCellFormatSummary { + style_index, + text_style: resolved + .text_style_id + .and_then(|id| self.text_styles.get(&id).cloned()), + fill: resolved.fill_id.and_then(|id| self.fills.get(&id).cloned()), + border: resolved + .border_id + .and_then(|id| self.borders.get(&id).cloned()), + alignment: resolved.alignment, + number_format: resolved + .number_format_id + .and_then(|id| self.number_formats.get(&id).cloned()), + wrap_text: resolved.wrap_text, + }) + } +} + +impl SpreadsheetSheet { + pub fn range_format(&self, range: &CellRange) -> SpreadsheetRangeFormat { + SpreadsheetRangeFormat::new(self.name.clone(), range) + } +} + +fn insert_with_next_id(map: &mut BTreeMap, value: T) -> u32 { + let next_id = map.last_key_value().map(|(key, _)| key + 1).unwrap_or(1); + map.insert(next_id, value); + next_id +} + +fn resolve_cell_format_recursive( + cell_formats: &BTreeMap, + format: &SpreadsheetCellFormat, + depth: usize, +) -> Option { + if depth > 32 { + return None; + } + let base = format + .base_cell_style_format_id + .and_then(|id| cell_formats.get(&id)) + .and_then(|base| resolve_cell_format_recursive(cell_formats, base, depth + 1)); + Some(match base { + Some(base) => base.merge(format), + None => format.clone(), + }) +} + +fn builtin_number_format_code(format_id: u32) -> Option { + match format_id { + 0 => Some("General".to_string()), + 1 => Some("0".to_string()), + 2 => Some("0.00".to_string()), + 3 => Some("#,##0".to_string()), + 4 => Some("#,##0.00".to_string()), + 9 => Some("0%".to_string()), + 10 => Some("0.00%".to_string()), + _ => None, + } +} diff --git a/codex-rs/artifact-spreadsheet/src/tests.rs b/codex-rs/artifact-spreadsheet/src/tests.rs index 503ec229f..8e7a37861 100644 --- a/codex-rs/artifact-spreadsheet/src/tests.rs +++ b/codex-rs/artifact-spreadsheet/src/tests.rs @@ -8,9 +8,16 @@ use crate::SpreadsheetArtifact; use crate::SpreadsheetArtifactManager; use crate::SpreadsheetArtifactRequest; use crate::SpreadsheetCell; +use crate::SpreadsheetCellFormat; +use crate::SpreadsheetCellFormatSummary; use crate::SpreadsheetCellValue; use crate::SpreadsheetFileType; +use crate::SpreadsheetFill; +use crate::SpreadsheetFontFace; +use crate::SpreadsheetNumberFormat; use crate::SpreadsheetSheet; +use crate::SpreadsheetSheetReference; +use crate::SpreadsheetTextStyle; #[test] fn manager_can_create_edit_recalculate_and_export() -> Result<(), Box> { @@ -554,3 +561,491 @@ fn manager_supports_bulk_sizes_and_row_heights() -> Result<(), Box Result<(), Box> { + 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": "Styles" }), + }, + 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(), + )?; + + let text_style = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "create_text_style".to_string(), + args: serde_json::json!({ + "style": { + "bold": true, + "italic": true, + "underline": true, + "font_size": 14.0, + "font_color": "#112233", + "text_alignment": "center", + "anchor": "middle", + "vertical_text_orientation": "stacked", + "text_rotation": 90, + "paragraph_spacing": true, + "bottom_inset": 1.0, + "left_inset": 2.0, + "right_inset": 3.0, + "top_inset": 4.0, + "font_family": "IBM Plex Sans", + "font_scheme": "minor", + "typeface": "IBM Plex Sans", + "font_face": { + "font_family": "IBM Plex Sans", + "font_scheme": "minor", + "typeface": "IBM Plex Sans" + } + } + }), + }, + temp_dir.path(), + )?; + let text_style_id = text_style.style_id.expect("text style id"); + + let fill = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "create_fill".to_string(), + args: serde_json::json!({ + "fill": { + "solid_fill_color": "#ffeeaa", + "pattern_type": "solid", + "pattern_foreground_color": "#ffeeaa", + "pattern_background_color": "#221100", + "color_transforms": ["tint:0.2"], + "gradient_fill_type": "linear", + "gradient_stops": [ + { "position": 0.0, "color": "#ffeeaa" }, + { "position": 1.0, "color": "#aa5500" } + ], + "gradient_kind": "linear", + "angle": 45.0, + "scaled": true, + "path_type": "rect", + "fill_rectangle": { + "left": 0.0, + "right": 1.0, + "top": 0.0, + "bottom": 1.0 + }, + "image_reference": "image://fill" + } + }), + }, + temp_dir.path(), + )?; + let fill_id = fill.style_id.expect("fill id"); + + let border = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "create_border".to_string(), + args: serde_json::json!({ + "border": { + "top": { "style": "solid", "color": "#111111" }, + "right": { "style": "dashed", "color": "#222222" }, + "bottom": { "style": "double", "color": "#333333" }, + "left": { "style": "solid", "color": "#444444" } + } + }), + }, + temp_dir.path(), + )?; + let border_id = border.style_id.expect("border id"); + + let number_format = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "create_number_format".to_string(), + args: serde_json::json!({ + "number_format": { + "format_id": 4 + } + }), + }, + temp_dir.path(), + )?; + let number_format_id = number_format.style_id.expect("number format id"); + + let base_format = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "create_cell_format".to_string(), + args: serde_json::json!({ + "format": { + "text_style_id": text_style_id, + "number_format_id": number_format_id, + "alignment": { + "horizontal": "center", + "vertical": "middle" + } + } + }), + }, + temp_dir.path(), + )?; + let base_format_id = base_format.style_id.expect("base format id"); + + let derived_format = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "create_cell_format".to_string(), + args: serde_json::json!({ + "format": { + "fill_id": fill_id, + "border_id": border_id, + "wrap_text": true, + "base_cell_style_format_id": base_format_id + } + }), + }, + temp_dir.path(), + )?; + let derived_format_id = derived_format.style_id.expect("derived format id"); + + let merged_format = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "create_cell_format".to_string(), + args: serde_json::json!({ + "source_format_id": derived_format_id, + "merge_with_existing_components": true, + "format": { + "alignment": { + "vertical": "bottom" + } + } + }), + }, + temp_dir.path(), + )?; + let merged_format_id = merged_format.style_id.expect("merged format id"); + + let differential_format = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "create_differential_format".to_string(), + args: serde_json::json!({ + "format": { + "fill_id": fill_id, + "wrap_text": true + } + }), + }, + temp_dir.path(), + )?; + let differential_format_id = differential_format.style_id.expect("dxf id"); + + manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "set_cell_style".to_string(), + args: serde_json::json!({ + "sheet_name": "Sheet1", + "address": "A1", + "style_index": merged_format_id + }), + }, + temp_dir.path(), + )?; + + let summary = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "get_cell_format_summary".to_string(), + args: serde_json::json!({ + "sheet_name": "Sheet1", + "address": "A1" + }), + }, + temp_dir.path(), + )?; + assert_eq!( + summary.cell_format_summary, + Some(SpreadsheetCellFormatSummary { + style_index: merged_format_id, + text_style: Some(SpreadsheetTextStyle { + bold: Some(true), + italic: Some(true), + underline: Some(true), + font_size: Some(14.0), + font_color: Some("#112233".to_string()), + text_alignment: Some("center".to_string()), + anchor: Some("middle".to_string()), + vertical_text_orientation: Some("stacked".to_string()), + text_rotation: Some(90), + paragraph_spacing: Some(true), + bottom_inset: Some(1.0), + left_inset: Some(2.0), + right_inset: Some(3.0), + top_inset: Some(4.0), + font_family: Some("IBM Plex Sans".to_string()), + font_scheme: Some("minor".to_string()), + typeface: Some("IBM Plex Sans".to_string()), + font_face: Some(SpreadsheetFontFace { + font_family: Some("IBM Plex Sans".to_string()), + font_scheme: Some("minor".to_string()), + typeface: Some("IBM Plex Sans".to_string()), + }), + }), + fill: Some(SpreadsheetFill { + solid_fill_color: Some("#ffeeaa".to_string()), + pattern_type: Some("solid".to_string()), + pattern_foreground_color: Some("#ffeeaa".to_string()), + pattern_background_color: Some("#221100".to_string()), + color_transforms: vec!["tint:0.2".to_string()], + gradient_fill_type: Some("linear".to_string()), + gradient_stops: vec![ + crate::SpreadsheetGradientStop { + position: 0.0, + color: "#ffeeaa".to_string(), + }, + crate::SpreadsheetGradientStop { + position: 1.0, + color: "#aa5500".to_string(), + }, + ], + gradient_kind: Some("linear".to_string()), + angle: Some(45.0), + scaled: Some(true), + path_type: Some("rect".to_string()), + fill_rectangle: Some(crate::SpreadsheetFillRectangle { + left: 0.0, + right: 1.0, + top: 0.0, + bottom: 1.0, + }), + image_reference: Some("image://fill".to_string()), + }), + border: Some(crate::SpreadsheetBorder { + top: Some(crate::SpreadsheetBorderLine { + style: Some("solid".to_string()), + color: Some("#111111".to_string()), + }), + right: Some(crate::SpreadsheetBorderLine { + style: Some("dashed".to_string()), + color: Some("#222222".to_string()), + }), + bottom: Some(crate::SpreadsheetBorderLine { + style: Some("double".to_string()), + color: Some("#333333".to_string()), + }), + left: Some(crate::SpreadsheetBorderLine { + style: Some("solid".to_string()), + color: Some("#444444".to_string()), + }), + }), + alignment: Some(crate::SpreadsheetAlignment { + horizontal: Some("center".to_string()), + vertical: Some("bottom".to_string()), + }), + number_format: Some(SpreadsheetNumberFormat { + format_id: Some(4), + format_code: Some("#,##0.00".to_string()), + }), + wrap_text: Some(true), + }) + ); + + let retrieved_format = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "get_cell_format".to_string(), + args: serde_json::json!({ "id": merged_format_id }), + }, + temp_dir.path(), + )?; + let retrieved_format: SpreadsheetCellFormat = + serde_json::from_value(retrieved_format.serialized_dict.expect("cell format"))?; + assert_eq!( + retrieved_format, + SpreadsheetCellFormat { + text_style_id: None, + fill_id: Some(fill_id), + border_id: Some(border_id), + alignment: Some(crate::SpreadsheetAlignment { + horizontal: None, + vertical: Some("bottom".to_string()), + }), + number_format_id: None, + wrap_text: Some(true), + base_cell_style_format_id: Some(base_format_id), + } + ); + + let retrieved_number_format = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "get_number_format".to_string(), + args: serde_json::json!({ "id": number_format_id }), + }, + temp_dir.path(), + )?; + assert_eq!( + serde_json::from_value::( + retrieved_number_format + .serialized_dict + .expect("number format") + )?, + SpreadsheetNumberFormat { + format_id: Some(4), + format_code: Some("#,##0.00".to_string()), + } + ); + + let retrieved_text_style = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "get_text_style".to_string(), + args: serde_json::json!({ "id": text_style_id }), + }, + temp_dir.path(), + )?; + assert_eq!( + serde_json::from_value::( + retrieved_text_style.serialized_dict.expect("text style") + )? + .bold, + Some(true) + ); + + let range_summary = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "get_range_format_summary".to_string(), + args: serde_json::json!({ + "sheet_name": "Sheet1", + "range": "A1:B2" + }), + }, + temp_dir.path(), + )?; + assert_eq!(range_summary.top_left_style_index, Some(merged_format_id)); + assert_eq!( + range_summary + .range_format + .as_ref() + .map(|format| format.range.clone()), + Some("A1:B2".to_string()) + ); + + let retrieved_dxf = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id), + action: "get_differential_format".to_string(), + args: serde_json::json!({ "id": differential_format_id }), + }, + temp_dir.path(), + )?; + assert_eq!( + serde_json::from_value::( + retrieved_dxf.serialized_dict.expect("differential format") + )? + .wrap_text, + Some(true) + ); + Ok(()) +} + +#[test] +fn sheet_references_resolve_cells_and_ranges() -> Result<(), Box> { + let sheet = SpreadsheetSheet::new("Sheet1".to_string()); + assert_eq!( + sheet.reference("A1")?, + SpreadsheetSheetReference::Cell { + cell_ref: sheet.cell_ref("A1")?, + } + ); + assert_eq!( + sheet.reference("A1:B2")?, + SpreadsheetSheetReference::Range { + range_ref: sheet.range_ref("A1:B2")?, + } + ); + Ok(()) +} + +#[test] +fn manager_get_reference_and_xlsx_import_preserve_workbook_name() +-> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let path = temp_dir.path().join("named.xlsx"); + + let mut artifact = SpreadsheetArtifact::new(Some("Named Workbook".to_string())); + artifact.create_sheet("Sheet1".to_string())?.set_value( + CellAddress::parse("A1")?, + Some(SpreadsheetCellValue::Integer(9)), + )?; + artifact.export(&path)?; + + let restored = SpreadsheetArtifact::from_source_file(&path, None)?; + assert_eq!(restored.name, Some("Named Workbook".to_string())); + + let mut manager = SpreadsheetArtifactManager::default(); + let imported = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: None, + action: "read".to_string(), + args: serde_json::json!({ "path": path }), + }, + temp_dir.path(), + )?; + let artifact_id = imported.artifact_id; + + let cell_reference = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "get_reference".to_string(), + args: serde_json::json!({ + "sheet_name": "Sheet1", + "reference": "A1" + }), + }, + temp_dir.path(), + )?; + assert_eq!( + cell_reference + .raw_cell + .as_ref() + .and_then(|cell| cell.value.clone()), + Some(SpreadsheetCellValue::Integer(9)) + ); + + let range_reference = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id), + action: "get_reference".to_string(), + args: serde_json::json!({ + "sheet_name": "Sheet1", + "reference": "A1:B2" + }), + }, + temp_dir.path(), + )?; + assert_eq!( + range_reference + .range_ref + .as_ref() + .map(|range_ref| range_ref.address.clone()), + Some("A1:B2".to_string()) + ); + Ok(()) +} diff --git a/codex-rs/artifact-spreadsheet/src/xlsx.rs b/codex-rs/artifact-spreadsheet/src/xlsx.rs index 486706333..5175f3b51 100644 --- a/codex-rs/artifact-spreadsheet/src/xlsx.rs +++ b/codex-rs/artifact-spreadsheet/src/xlsx.rs @@ -195,6 +195,13 @@ pub(crate) fn import_xlsx( let workbook_xml = read_zip_entry(&mut archive, "xl/workbook.xml", path)?; let workbook_rels = read_zip_entry(&mut archive, "xl/_rels/workbook.xml.rels", path)?; + let workbook_name = if archive.by_name("docProps/core.xml").is_ok() { + let title = + extract_workbook_title(&read_zip_entry(&mut archive, "docProps/core.xml", path)?); + (!title.trim().is_empty()).then_some(title) + } else { + None + }; let shared_strings = if archive.by_name("xl/sharedStrings.xml").is_ok() { Some(parse_shared_strings(&read_zip_entry( &mut archive, @@ -226,11 +233,11 @@ pub(crate) fn import_xlsx( }) .collect::, SpreadsheetArtifactError>>()?; - let mut artifact = SpreadsheetArtifact::new( + let mut artifact = SpreadsheetArtifact::new(workbook_name.or_else(|| { path.file_stem() .and_then(|value| value.to_str()) - .map(str::to_string), - ); + .map(str::to_string) + })); if let Some(artifact_id) = artifact_id { artifact.artifact_id = artifact_id; } @@ -802,6 +809,18 @@ fn first_tag_text(xml: &str, tag: &str) -> Option { captures.get(1).map(|value| value.as_str().to_string()) } +fn extract_workbook_title(xml: &str) -> String { + let Ok(regex) = + Regex::new(r#"(?s)<(?:[A-Za-z0-9_]+:)?title\b[^>]*>(.*?)"#) + else { + return String::new(); + }; + regex + .captures(xml) + .and_then(|captures| captures.get(1).map(|value| xml_unescape(value.as_str()))) + .unwrap_or_default() +} + fn all_text_nodes(xml: &str) -> Result { let regex = Regex::new(r#"(?s)]*>(.*?)"#).map_err(|error| { SpreadsheetArtifactError::Serialization {