From ad393fa753f534fe4e134e0f3712482eb3b8e072 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 3 Mar 2026 14:08:01 +0000 Subject: [PATCH] feat: pres artifact part 5 (#13355) Mostly written by Codex --- .../src/presentation_artifact.rs | 6281 ----------------- .../src/presentation_artifact/api.rs | 177 + .../src/presentation_artifact/args.rs | 468 ++ .../src/presentation_artifact/inspect.rs | 611 ++ .../src/presentation_artifact/manager.rs | 2308 ++++++ .../src/presentation_artifact/mod.rs | 10 + .../src/presentation_artifact/model.rs | 1691 +++++ .../src/presentation_artifact/parsing.rs | 864 +++ .../src/presentation_artifact/pptx.rs | 922 +++ .../src/presentation_artifact/proto.rs | 356 + .../src/presentation_artifact/response.rs | 135 + .../src/presentation_artifact/snapshot.rs | 338 + codex-rs/artifact-presentation/src/tests.rs | 1450 +++- codex-rs/artifact-spreadsheet/src/chart.rs | 357 + .../artifact-spreadsheet/src/conditional.rs | 296 + codex-rs/artifact-spreadsheet/src/lib.rs | 2 + codex-rs/artifact-spreadsheet/src/manager.rs | 137 + codex-rs/artifact-spreadsheet/src/pivot.rs | 177 + codex-rs/artifact-spreadsheet/src/render.rs | 373 + codex-rs/artifact-spreadsheet/src/table.rs | 619 ++ codex-rs/artifact-spreadsheet/src/tests.rs | 177 + .../tools/handlers/presentation_artifact.rs | 4 + .../templates/tools/presentation_artifact.md | 57 +- 23 files changed, 11518 insertions(+), 6292 deletions(-) delete mode 100644 codex-rs/artifact-presentation/src/presentation_artifact.rs create mode 100644 codex-rs/artifact-presentation/src/presentation_artifact/api.rs create mode 100644 codex-rs/artifact-presentation/src/presentation_artifact/args.rs create mode 100644 codex-rs/artifact-presentation/src/presentation_artifact/inspect.rs create mode 100644 codex-rs/artifact-presentation/src/presentation_artifact/manager.rs create mode 100644 codex-rs/artifact-presentation/src/presentation_artifact/mod.rs create mode 100644 codex-rs/artifact-presentation/src/presentation_artifact/model.rs create mode 100644 codex-rs/artifact-presentation/src/presentation_artifact/parsing.rs create mode 100644 codex-rs/artifact-presentation/src/presentation_artifact/pptx.rs create mode 100644 codex-rs/artifact-presentation/src/presentation_artifact/proto.rs create mode 100644 codex-rs/artifact-presentation/src/presentation_artifact/response.rs create mode 100644 codex-rs/artifact-presentation/src/presentation_artifact/snapshot.rs create mode 100644 codex-rs/artifact-spreadsheet/src/chart.rs create mode 100644 codex-rs/artifact-spreadsheet/src/conditional.rs create mode 100644 codex-rs/artifact-spreadsheet/src/pivot.rs create mode 100644 codex-rs/artifact-spreadsheet/src/render.rs create mode 100644 codex-rs/artifact-spreadsheet/src/table.rs diff --git a/codex-rs/artifact-presentation/src/presentation_artifact.rs b/codex-rs/artifact-presentation/src/presentation_artifact.rs deleted file mode 100644 index f3872fb80..000000000 --- a/codex-rs/artifact-presentation/src/presentation_artifact.rs +++ /dev/null @@ -1,6281 +0,0 @@ -use base64::Engine; -use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; -use image::GenericImageView; -use image::ImageFormat; -use image::codecs::jpeg::JpegEncoder; -use image::imageops::FilterType; -use ppt_rs::Chart; -use ppt_rs::ChartSeries; -use ppt_rs::ChartType; -use ppt_rs::Hyperlink as PptHyperlink; -use ppt_rs::HyperlinkAction as PptHyperlinkAction; -use ppt_rs::Image; -use ppt_rs::Presentation; -use ppt_rs::Shape; -use ppt_rs::ShapeFill; -use ppt_rs::ShapeLine; -use ppt_rs::ShapeType; -use ppt_rs::SlideContent; -use ppt_rs::SlideLayout; -use ppt_rs::TableBuilder; -use ppt_rs::TableCell; -use ppt_rs::TableRow; -use ppt_rs::generator::ArrowSize; -use ppt_rs::generator::ArrowType; -use ppt_rs::generator::CellAlign; -use ppt_rs::generator::Connector; -use ppt_rs::generator::ConnectorLine; -use ppt_rs::generator::ConnectorType; -use ppt_rs::generator::LineDash; -use ppt_rs::generator::generate_image_content_type; -use serde::Deserialize; -use serde::Serialize; -use serde_json::Value; -use std::collections::BTreeSet; -use std::collections::HashMap; -use std::collections::HashSet; -use std::io::Cursor; -use std::io::Read; -use std::io::Seek; -use std::io::Write; -use std::path::Path; -use std::path::PathBuf; -use std::process::Command; -use thiserror::Error; -use uuid::Uuid; -use zip::ZipArchive; -use zip::ZipWriter; -use zip::write::SimpleFileOptions; - -const POINT_TO_EMU: u32 = 12_700; -const DEFAULT_SLIDE_WIDTH_POINTS: u32 = 720; -const DEFAULT_SLIDE_HEIGHT_POINTS: u32 = 540; -const DEFAULT_IMPORTED_TITLE_LEFT: u32 = 36; -const DEFAULT_IMPORTED_TITLE_TOP: u32 = 24; -const DEFAULT_IMPORTED_TITLE_WIDTH: u32 = 648; -const DEFAULT_IMPORTED_TITLE_HEIGHT: u32 = 48; -const DEFAULT_IMPORTED_CONTENT_LEFT: u32 = 48; -const DEFAULT_IMPORTED_CONTENT_TOP: u32 = 96; -const DEFAULT_IMPORTED_CONTENT_WIDTH: u32 = 624; -const DEFAULT_IMPORTED_CONTENT_HEIGHT: u32 = 324; - -#[derive(Debug, Error)] -pub enum PresentationArtifactError { - #[error("missing `artifact_id` for action `{action}`")] - MissingArtifactId { action: String }, - #[error("unknown artifact id `{artifact_id}` for action `{action}`")] - UnknownArtifactId { action: String, artifact_id: String }, - #[error("unknown action `{0}`")] - UnknownAction(String), - #[error("invalid args for action `{action}`: {message}")] - InvalidArgs { action: String, message: String }, - #[error("unsupported feature for action `{action}`: {message}")] - UnsupportedFeature { action: String, message: String }, - #[error("failed to import PPTX `{path}`: {message}")] - ImportFailed { path: PathBuf, message: String }, - #[error("failed to export PPTX `{path}`: {message}")] - ExportFailed { path: PathBuf, message: String }, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct PresentationArtifactRequest { - pub artifact_id: Option, - pub action: String, - #[serde(default)] - pub args: Value, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PathAccessKind { - Read, - Write, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PathAccessRequirement { - pub action: String, - pub kind: PathAccessKind, - pub path: PathBuf, -} - -impl PresentationArtifactRequest { - pub fn required_path_accesses( - &self, - cwd: &Path, - ) -> Result, PresentationArtifactError> { - let access = match self.action.as_str() { - "import_pptx" => { - let args: ImportPptxArgs = parse_args(&self.action, &self.args)?; - vec![PathAccessRequirement { - action: self.action.clone(), - kind: PathAccessKind::Read, - path: resolve_path(cwd, &args.path), - }] - } - "export_pptx" => { - let args: ExportPptxArgs = parse_args(&self.action, &self.args)?; - vec![PathAccessRequirement { - action: self.action.clone(), - kind: PathAccessKind::Write, - path: resolve_path(cwd, &args.path), - }] - } - "export_preview" => { - let args: ExportPreviewArgs = parse_args(&self.action, &self.args)?; - vec![PathAccessRequirement { - action: self.action.clone(), - kind: PathAccessKind::Write, - path: resolve_path(cwd, &args.path), - }] - } - "add_image" => { - let args: AddImageArgs = parse_args(&self.action, &self.args)?; - match args.image_source()? { - ImageInputSource::Path(path) => vec![PathAccessRequirement { - action: self.action.clone(), - kind: PathAccessKind::Read, - path: resolve_path(cwd, &path), - }], - ImageInputSource::DataUrl(_) - | ImageInputSource::Uri(_) - | ImageInputSource::Placeholder => Vec::new(), - } - } - "replace_image" => { - let args: ReplaceImageArgs = parse_args(&self.action, &self.args)?; - match (&args.path, &args.data_url, &args.uri, &args.prompt) { - (Some(path), None, None, None) => vec![PathAccessRequirement { - action: self.action.clone(), - kind: PathAccessKind::Read, - path: resolve_path(cwd, path), - }], - (None, Some(_), None, None) - | (None, None, Some(_), None) - | (None, None, None, Some(_)) => Vec::new(), - _ => { - return Err(PresentationArtifactError::InvalidArgs { - action: self.action.clone(), - message: - "provide exactly one of `path`, `data_url`, or `uri`, or provide `prompt` for a placeholder image" - .to_string(), - }); - } - } - } - _ => Vec::new(), - }; - Ok(access) - } -} - -#[derive(Debug, Default)] -pub struct PresentationArtifactManager { - documents: HashMap, -} - -impl PresentationArtifactManager { - pub fn execute( - &mut self, - request: PresentationArtifactRequest, - cwd: &Path, - ) -> Result { - match request.action.as_str() { - "create" => self.create(request), - "import_pptx" => self.import_pptx(request, cwd), - "export_pptx" => self.export_pptx(request, cwd), - "export_preview" => self.export_preview(request, cwd), - "get_summary" => self.get_summary(request), - "list_slides" => self.list_slides(request), - "list_layouts" => self.list_layouts(request), - "list_layout_placeholders" => self.list_layout_placeholders(request), - "list_slide_placeholders" => self.list_slide_placeholders(request), - "inspect" => self.inspect(request), - "resolve" => self.resolve(request), - "add_slide" => self.add_slide(request), - "insert_slide" => self.insert_slide(request), - "duplicate_slide" => self.duplicate_slide(request), - "move_slide" => self.move_slide(request), - "delete_slide" => self.delete_slide(request), - "create_layout" => self.create_layout(request), - "add_layout_placeholder" => self.add_layout_placeholder(request), - "set_slide_layout" => self.set_slide_layout(request), - "update_placeholder_text" => self.update_placeholder_text(request), - "set_theme" => self.set_theme(request), - "set_notes" => self.set_notes(request), - "append_notes" => self.append_notes(request), - "clear_notes" => self.clear_notes(request), - "set_notes_visibility" => self.set_notes_visibility(request), - "set_active_slide" => self.set_active_slide(request), - "set_slide_background" => self.set_slide_background(request), - "add_text_shape" => self.add_text_shape(request), - "add_shape" => self.add_shape(request), - "add_connector" => self.add_connector(request), - "add_image" => self.add_image(request, cwd), - "replace_image" => self.replace_image(request, cwd), - "add_table" => self.add_table(request), - "update_table_cell" => self.update_table_cell(request), - "merge_table_cells" => self.merge_table_cells(request), - "add_chart" => self.add_chart(request), - "update_text" => self.update_text(request), - "replace_text" => self.replace_text(request), - "insert_text_after" => self.insert_text_after(request), - "set_hyperlink" => self.set_hyperlink(request), - "update_shape_style" => self.update_shape_style(request), - "bring_to_front" => self.bring_to_front(request), - "send_to_back" => self.send_to_back(request), - "delete_element" => self.delete_element(request), - "delete_artifact" => self.delete_artifact(request), - other => Err(PresentationArtifactError::UnknownAction(other.to_string())), - } - } - - fn create( - &mut self, - request: PresentationArtifactRequest, - ) -> Result { - let args: CreateArgs = parse_args(&request.action, &request.args)?; - let mut document = PresentationDocument::new(args.name); - if let Some(slide_size) = args.slide_size { - document.slide_size = parse_slide_size(&slide_size, &request.action)?; - } - if let Some(theme) = args.theme { - document.theme = normalize_theme(theme, &request.action)?; - } - let artifact_id = document.artifact_id.clone(); - let summary = format!( - "Created presentation artifact `{artifact_id}` with {} slides", - document.slides.len() - ); - let snapshot = snapshot_for_document(&document); - let mut response = - PresentationArtifactResponse::new(artifact_id, request.action, summary, snapshot); - response.theme = Some(document.theme_snapshot()); - self.documents - .insert(response.artifact_id.clone(), document); - Ok(response) - } - - fn import_pptx( - &mut self, - request: PresentationArtifactRequest, - cwd: &Path, - ) -> Result { - let args: ImportPptxArgs = parse_args(&request.action, &request.args)?; - let path = resolve_path(cwd, &args.path); - let imported = Presentation::from_path(&path).map_err(|error| { - PresentationArtifactError::ImportFailed { - path: path.clone(), - message: error.to_string(), - } - })?; - let mut document = PresentationDocument::from_ppt_rs(imported); - import_pptx_images(&path, &mut document, &request.action)?; - let artifact_id = document.artifact_id.clone(); - let slide_count = document.slides.len(); - let snapshot = snapshot_for_document(&document); - self.documents.insert(artifact_id.clone(), document); - let summary = format!( - "Imported `{}` as artifact `{artifact_id}` with {slide_count} slides", - path.display() - ); - Ok(PresentationArtifactResponse::new( - artifact_id, - request.action, - summary, - snapshot, - )) - } - - fn export_pptx( - &mut self, - request: PresentationArtifactRequest, - cwd: &Path, - ) -> Result { - let args: ExportPptxArgs = parse_args(&request.action, &request.args)?; - let artifact_id = required_artifact_id(&request)?; - let document = self.get_document(&artifact_id, &request.action)?; - let path = resolve_path(cwd, &args.path); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).map_err(|error| { - PresentationArtifactError::ExportFailed { - path: path.clone(), - message: error.to_string(), - } - })?; - } - - let bytes = build_pptx_bytes(document, &request.action).map_err(|message| { - PresentationArtifactError::ExportFailed { - path: path.clone(), - message, - } - })?; - std::fs::write(&path, bytes).map_err(|error| PresentationArtifactError::ExportFailed { - path: path.clone(), - message: error.to_string(), - })?; - - let mut response = PresentationArtifactResponse::new( - artifact_id, - request.action, - format!("Exported presentation to `{}`", path.display()), - snapshot_for_document(document), - ); - response.exported_paths.push(path); - Ok(response) - } - - fn export_preview( - &mut self, - request: PresentationArtifactRequest, - cwd: &Path, - ) -> Result { - let args: ExportPreviewArgs = parse_args(&request.action, &request.args)?; - let artifact_id = required_artifact_id(&request)?; - let document = self.get_document(&artifact_id, &request.action)?; - let output_path = resolve_path(cwd, &args.path); - let preview_format = - parse_preview_output_format(args.format.as_deref(), &output_path, &request.action)?; - let scale = normalize_preview_scale(args.scale, &request.action)?; - let quality = normalize_preview_quality(args.quality, &request.action)?; - if let Some(parent) = output_path.parent() { - std::fs::create_dir_all(parent).map_err(|error| { - PresentationArtifactError::ExportFailed { - path: output_path.clone(), - message: error.to_string(), - } - })?; - } - let temp_dir = - std::env::temp_dir().join(format!("presentation_preview_{}", Uuid::new_v4().simple())); - std::fs::create_dir_all(&temp_dir).map_err(|error| { - PresentationArtifactError::ExportFailed { - path: output_path.clone(), - message: error.to_string(), - } - })?; - let preview_document = if let Some(slide_index) = args.slide_index { - let slide = document - .slides - .get(slide_index as usize) - .cloned() - .ok_or_else(|| { - index_out_of_range(&request.action, slide_index as usize, document.slides.len()) - })?; - PresentationDocument { - artifact_id: document.artifact_id.clone(), - name: document.name.clone(), - slide_size: document.slide_size, - theme: document.theme.clone(), - layouts: Vec::new(), - slides: vec![slide], - active_slide_index: Some(0), - next_slide_seq: 1, - next_element_seq: 1, - next_layout_seq: 1, - } - } else { - document.clone() - }; - write_preview_images(&preview_document, &temp_dir, &request.action)?; - let mut exported_paths = collect_pngs(&temp_dir)?; - if args.slide_index.is_some() { - let rendered = - exported_paths - .pop() - .ok_or_else(|| PresentationArtifactError::ExportFailed { - path: output_path.clone(), - message: "preview renderer produced no images".to_string(), - })?; - write_preview_image( - &rendered, - &output_path, - preview_format, - scale, - quality, - &request.action, - )?; - exported_paths = vec![output_path]; - } else { - std::fs::create_dir_all(&output_path).map_err(|error| { - PresentationArtifactError::ExportFailed { - path: output_path.clone(), - message: error.to_string(), - } - })?; - let mut relocated = Vec::new(); - for rendered in exported_paths { - let filename = rendered.file_name().ok_or_else(|| { - PresentationArtifactError::ExportFailed { - path: output_path.clone(), - message: "rendered preview had no filename".to_string(), - } - })?; - let stem = Path::new(filename) - .file_stem() - .and_then(|value| value.to_str()) - .unwrap_or("preview"); - let target = output_path.join(format!("{stem}.{}", preview_format.extension())); - write_preview_image( - &rendered, - &target, - preview_format, - scale, - quality, - &request.action, - )?; - relocated.push(target); - } - exported_paths = relocated; - } - let mut response = PresentationArtifactResponse::new( - artifact_id, - request.action, - "Exported slide preview".to_string(), - snapshot_for_document(document), - ); - response.exported_paths = exported_paths; - Ok(response) - } - - fn get_summary( - &mut self, - request: PresentationArtifactRequest, - ) -> Result { - let artifact_id = required_artifact_id(&request)?; - let document = self.get_document(&artifact_id, &request.action)?; - let mut response = PresentationArtifactResponse::new( - artifact_id, - request.action, - format!( - "Presentation `{}` has {} slides, {} elements, {} layouts, and active slide {}", - document.name.as_deref().unwrap_or("Untitled"), - document.slides.len(), - document.total_element_count(), - document.layouts.len(), - document - .active_slide_index - .map(|index| index.to_string()) - .unwrap_or_else(|| "none".to_string()) - ), - snapshot_for_document(document), - ); - response.slide_list = Some(slide_list(document)); - response.layout_list = Some(layout_list(document)); - response.theme = Some(document.theme_snapshot()); - response.active_slide_index = document.active_slide_index; - Ok(response) - } - - fn list_slides( - &mut self, - request: PresentationArtifactRequest, - ) -> Result { - let artifact_id = required_artifact_id(&request)?; - let document = self.get_document(&artifact_id, &request.action)?; - let mut response = PresentationArtifactResponse::new( - artifact_id, - request.action, - format!("Listed {} slides", document.slides.len()), - snapshot_for_document(document), - ); - response.slide_list = Some(slide_list(document)); - response.theme = Some(document.theme_snapshot()); - response.active_slide_index = document.active_slide_index; - Ok(response) - } - - fn list_layouts( - &mut self, - request: PresentationArtifactRequest, - ) -> Result { - let artifact_id = required_artifact_id(&request)?; - let document = self.get_document(&artifact_id, &request.action)?; - let mut response = PresentationArtifactResponse::new( - artifact_id, - request.action, - format!("Listed {} layouts", document.layouts.len()), - snapshot_for_document(document), - ); - response.layout_list = Some(layout_list(document)); - response.theme = Some(document.theme_snapshot()); - Ok(response) - } - - fn list_layout_placeholders( - &mut self, - request: PresentationArtifactRequest, - ) -> Result { - let args: LayoutIdArgs = parse_args(&request.action, &request.args)?; - let artifact_id = required_artifact_id(&request)?; - let document = self.get_document(&artifact_id, &request.action)?; - let placeholders = layout_placeholder_list(document, &args.layout_id, &request.action)?; - let mut response = PresentationArtifactResponse::new( - artifact_id, - request.action, - format!( - "Listed {} placeholders for layout `{}`", - placeholders.len(), - args.layout_id - ), - snapshot_for_document(document), - ); - response.placeholder_list = Some(placeholders); - response.layout_list = Some(layout_list(document)); - Ok(response) - } - - fn list_slide_placeholders( - &mut self, - request: PresentationArtifactRequest, - ) -> Result { - let args: SlideIndexArgs = parse_args(&request.action, &request.args)?; - let artifact_id = required_artifact_id(&request)?; - let document = self.get_document(&artifact_id, &request.action)?; - let slide_index = args.slide_index as usize; - let slide = document.slides.get(slide_index).ok_or_else(|| { - index_out_of_range(&request.action, slide_index, document.slides.len()) - })?; - let placeholders = slide_placeholder_list(slide, slide_index); - let mut response = PresentationArtifactResponse::new( - artifact_id, - request.action, - format!( - "Listed {} placeholders for slide {}", - placeholders.len(), - args.slide_index - ), - snapshot_for_document(document), - ); - response.placeholder_list = Some(placeholders); - response.slide_list = Some(slide_list(document)); - Ok(response) - } - - fn inspect( - &mut self, - request: PresentationArtifactRequest, - ) -> Result { - let args: InspectArgs = parse_args(&request.action, &request.args)?; - let artifact_id = required_artifact_id(&request)?; - let document = self.get_document(&artifact_id, &request.action)?; - let inspect_ndjson = inspect_document( - document, - args.kind.as_deref(), - args.target_id.as_deref(), - args.max_chars, - ); - let mut response = PresentationArtifactResponse::new( - artifact_id, - request.action, - "Generated inspection snapshot".to_string(), - snapshot_for_document(document), - ); - response.inspect_ndjson = Some(inspect_ndjson); - response.theme = Some(document.theme_snapshot()); - response.active_slide_index = document.active_slide_index; - Ok(response) - } - - fn resolve( - &mut self, - request: PresentationArtifactRequest, - ) -> Result { - let args: ResolveArgs = parse_args(&request.action, &request.args)?; - let artifact_id = required_artifact_id(&request)?; - let document = self.get_document(&artifact_id, &request.action)?; - let resolved_record = resolve_anchor(document, &args.id, &request.action)?; - let mut response = PresentationArtifactResponse::new( - artifact_id, - request.action, - format!("Resolved `{}`", args.id), - snapshot_for_document(document), - ); - response.resolved_record = Some(resolved_record); - response.active_slide_index = document.active_slide_index; - Ok(response) - } - - fn create_layout( - &mut self, - request: PresentationArtifactRequest, - ) -> Result { - let args: CreateLayoutArgs = parse_args(&request.action, &request.args)?; - let artifact_id = required_artifact_id(&request)?; - let document = self.get_document_mut(&artifact_id, &request.action)?; - let layout_id = document.next_layout_id(); - let kind = match args.kind.as_deref() { - Some("master") => LayoutKind::Master, - Some("layout") | None => LayoutKind::Layout, - Some(other) => { - return Err(PresentationArtifactError::InvalidArgs { - action: request.action, - message: format!("unsupported layout kind `{other}`"), - }); - } - }; - document.layouts.push(LayoutDocument { - layout_id: layout_id.clone(), - name: args.name, - kind, - parent_layout_id: args.parent_layout_id, - placeholders: Vec::new(), - }); - let mut response = PresentationArtifactResponse::new( - artifact_id, - request.action, - format!("Created layout `{layout_id}`"), - snapshot_for_document(document), - ); - response.layout_list = Some(layout_list(document)); - Ok(response) - } - - fn add_layout_placeholder( - &mut self, - request: PresentationArtifactRequest, - ) -> Result { - let args: AddLayoutPlaceholderArgs = parse_args(&request.action, &request.args)?; - let artifact_id = required_artifact_id(&request)?; - let document = self.get_document_mut(&artifact_id, &request.action)?; - let geometry = args - .geometry - .as_deref() - .map(|value| parse_shape_geometry(value, &request.action)) - .transpose()? - .unwrap_or(ShapeGeometry::Rectangle); - let frame = args.position.unwrap_or(PositionArgs { - left: 48, - top: 72, - width: 624, - height: 96, - }); - let layout = document - .layouts - .iter_mut() - .find(|layout| layout.layout_id == args.layout_id) - .ok_or_else(|| PresentationArtifactError::UnsupportedFeature { - action: request.action.clone(), - message: format!("unknown layout id `{}`", args.layout_id), - })?; - layout.placeholders.push(PlaceholderDefinition { - name: args.name, - placeholder_type: args.placeholder_type, - index: args.index, - text: args.text, - geometry, - frame: frame.into(), - }); - let mut response = PresentationArtifactResponse::new( - artifact_id, - request.action, - format!("Added placeholder to layout `{}`", layout.layout_id), - snapshot_for_document(document), - ); - response.layout_list = Some(layout_list(document)); - Ok(response) - } - - fn set_slide_layout( - &mut self, - request: PresentationArtifactRequest, - ) -> Result { - let args: SetSlideLayoutArgs = parse_args(&request.action, &request.args)?; - let artifact_id = required_artifact_id(&request)?; - let document = self.get_document_mut(&artifact_id, &request.action)?; - let layout = document - .get_layout(&args.layout_id, &request.action)? - .clone(); - let mut placeholder_elements = Vec::new(); - for placeholder in layout.placeholders { - let element_id = document.next_element_id(); - let placeholder_ref = Some(PlaceholderRef { - name: placeholder.name.clone(), - placeholder_type: placeholder.placeholder_type.clone(), - index: placeholder.index, - }); - if placeholder.geometry == ShapeGeometry::Rectangle { - placeholder_elements.push(PresentationElement::Text(TextElement { - element_id, - text: placeholder.text.unwrap_or_default(), - frame: placeholder.frame, - fill: None, - style: TextStyle::default(), - hyperlink: None, - placeholder: placeholder_ref, - z_order: placeholder_elements.len(), - })); - } else { - placeholder_elements.push(PresentationElement::Shape(ShapeElement { - element_id, - geometry: placeholder.geometry, - frame: placeholder.frame, - fill: None, - stroke: None, - text: placeholder.text, - text_style: TextStyle::default(), - hyperlink: None, - placeholder: placeholder_ref, - rotation_degrees: None, - z_order: placeholder_elements.len(), - })); - } - } - let slide = document.get_slide_mut(args.slide_index, &request.action)?; - slide.elements.retain(|element| match element { - PresentationElement::Text(text) => text.placeholder.is_none(), - PresentationElement::Shape(shape) => shape.placeholder.is_none(), - _ => true, - }); - slide.layout_id = Some(args.layout_id); - slide.elements.extend(placeholder_elements); - resequence_z_order(slide); - let mut response = PresentationArtifactResponse::new( - artifact_id, - request.action, - format!("Applied layout to slide {}", args.slide_index), - snapshot_for_document(document), - ); - response.slide_list = Some(slide_list(document)); - response.layout_list = Some(layout_list(document)); - Ok(response) - } - - fn update_placeholder_text( - &mut self, - request: PresentationArtifactRequest, - ) -> Result { - let args: UpdatePlaceholderTextArgs = parse_args(&request.action, &request.args)?; - let artifact_id = required_artifact_id(&request)?; - let document = self.get_document_mut(&artifact_id, &request.action)?; - let slide = document.get_slide_mut(args.slide_index, &request.action)?; - let target_name = args.name.to_ascii_lowercase(); - let element = slide - .elements - .iter_mut() - .find(|element| match element { - PresentationElement::Text(text) => text - .placeholder - .as_ref() - .map(|placeholder| placeholder.name.eq_ignore_ascii_case(&target_name)) - .unwrap_or(false), - PresentationElement::Shape(shape) => shape - .placeholder - .as_ref() - .map(|placeholder| placeholder.name.eq_ignore_ascii_case(&target_name)) - .unwrap_or(false), - _ => false, - }) - .ok_or_else(|| PresentationArtifactError::UnsupportedFeature { - action: request.action.clone(), - message: format!( - "placeholder `{}` was not found on slide {}", - args.name, args.slide_index - ), - })?; - match element { - PresentationElement::Text(text) => text.text = args.text, - PresentationElement::Shape(shape) => shape.text = Some(args.text), - PresentationElement::Connector(_) - | PresentationElement::Image(_) - | PresentationElement::Table(_) - | PresentationElement::Chart(_) => {} - } - Ok(PresentationArtifactResponse::new( - artifact_id, - request.action, - format!( - "Updated placeholder `{}` on slide {}", - args.name, args.slide_index - ), - snapshot_for_document(document), - )) - } - - fn set_theme( - &mut self, - request: PresentationArtifactRequest, - ) -> Result { - let args: ThemeArgs = parse_args(&request.action, &request.args)?; - let artifact_id = required_artifact_id(&request)?; - let document = self.get_document_mut(&artifact_id, &request.action)?; - document.theme = normalize_theme(args, &request.action)?; - let mut response = PresentationArtifactResponse::new( - artifact_id, - request.action, - "Updated theme".to_string(), - snapshot_for_document(document), - ); - response.theme = Some(document.theme_snapshot()); - Ok(response) - } - - fn set_notes( - &mut self, - request: PresentationArtifactRequest, - ) -> Result { - let args: NotesArgs = parse_args(&request.action, &request.args)?; - let artifact_id = required_artifact_id(&request)?; - let document = self.get_document_mut(&artifact_id, &request.action)?; - let slide = document.get_slide_mut(args.slide_index, &request.action)?; - slide.notes.text = args.text.unwrap_or_default(); - Ok(PresentationArtifactResponse::new( - artifact_id, - request.action, - format!("Updated notes for slide {}", args.slide_index), - snapshot_for_document(document), - )) - } - - fn append_notes( - &mut self, - request: PresentationArtifactRequest, - ) -> Result { - let args: NotesArgs = parse_args(&request.action, &request.args)?; - let artifact_id = required_artifact_id(&request)?; - let document = self.get_document_mut(&artifact_id, &request.action)?; - let slide = document.get_slide_mut(args.slide_index, &request.action)?; - let text = args.text.unwrap_or_default(); - if slide.notes.text.is_empty() { - slide.notes.text = text; - } else { - slide.notes.text = format!("{}\n{text}", slide.notes.text); - } - Ok(PresentationArtifactResponse::new( - artifact_id, - request.action, - format!("Appended notes for slide {}", args.slide_index), - snapshot_for_document(document), - )) - } - - fn clear_notes( - &mut self, - request: PresentationArtifactRequest, - ) -> Result { - let args: NotesArgs = parse_args(&request.action, &request.args)?; - let artifact_id = required_artifact_id(&request)?; - let document = self.get_document_mut(&artifact_id, &request.action)?; - let slide = document.get_slide_mut(args.slide_index, &request.action)?; - slide.notes.text.clear(); - Ok(PresentationArtifactResponse::new( - artifact_id, - request.action, - format!("Cleared notes for slide {}", args.slide_index), - snapshot_for_document(document), - )) - } - - fn set_notes_visibility( - &mut self, - request: PresentationArtifactRequest, - ) -> Result { - let args: NotesVisibilityArgs = parse_args(&request.action, &request.args)?; - let artifact_id = required_artifact_id(&request)?; - let document = self.get_document_mut(&artifact_id, &request.action)?; - let slide = document.get_slide_mut(args.slide_index, &request.action)?; - slide.notes.visible = args.visible; - Ok(PresentationArtifactResponse::new( - artifact_id, - request.action, - format!("Updated notes visibility for slide {}", args.slide_index), - snapshot_for_document(document), - )) - } - - fn set_active_slide( - &mut self, - request: PresentationArtifactRequest, - ) -> Result { - let args: SetActiveSlideArgs = parse_args(&request.action, &request.args)?; - let artifact_id = required_artifact_id(&request)?; - let document = self.get_document_mut(&artifact_id, &request.action)?; - document.set_active_slide_index(args.slide_index as usize, &request.action)?; - let mut response = PresentationArtifactResponse::new( - artifact_id, - request.action, - format!("Set active slide to {}", args.slide_index), - snapshot_for_document(document), - ); - response.slide_list = Some(slide_list(document)); - response.active_slide_index = document.active_slide_index; - Ok(response) - } - - fn add_slide( - &mut self, - request: PresentationArtifactRequest, - ) -> Result { - let args: AddSlideArgs = parse_args(&request.action, &request.args)?; - let artifact_id = required_artifact_id(&request)?; - let document = self.get_document_mut(&artifact_id, &request.action)?; - let mut slide = document.new_slide(args.notes, args.background_fill, &request.action)?; - if let Some(layout_id) = args.layout { - apply_layout_to_slide(document, &mut slide, &layout_id, &request.action)?; - } - let index = document.append_slide(slide); - Ok(PresentationArtifactResponse::new( - artifact_id, - request.action, - format!("Added slide at index {index}"), - snapshot_for_document(document), - )) - } - - fn insert_slide( - &mut self, - request: PresentationArtifactRequest, - ) -> Result { - let args: InsertSlideArgs = parse_args(&request.action, &request.args)?; - if args.index.is_some() == args.after_slide_index.is_some() { - return Err(PresentationArtifactError::InvalidArgs { - action: request.action, - message: "provide exactly one of `index` or `after_slide_index`".to_string(), - }); - } - let artifact_id = required_artifact_id(&request)?; - let document = self.get_document_mut(&artifact_id, &request.action)?; - let index = args.index.map(to_index).transpose()?.unwrap_or_else(|| { - args.after_slide_index - .map(|value| value as usize + 1) - .unwrap_or(0) - }); - if index > document.slides.len() { - return Err(index_out_of_range( - &request.action, - index, - document.slides.len(), - )); - } - let mut slide = document.new_slide(args.notes, args.background_fill, &request.action)?; - if let Some(layout_id) = args.layout { - apply_layout_to_slide(document, &mut slide, &layout_id, &request.action)?; - } - document.adjust_active_slide_for_insert(index); - document.slides.insert(index, slide); - Ok(PresentationArtifactResponse::new( - artifact_id, - request.action, - format!("Inserted slide at index {index}"), - snapshot_for_document(document), - )) - } - - fn duplicate_slide( - &mut self, - request: PresentationArtifactRequest, - ) -> Result { - let args: SlideIndexArgs = parse_args(&request.action, &request.args)?; - let artifact_id = required_artifact_id(&request)?; - let document = self.get_document_mut(&artifact_id, &request.action)?; - let source = document - .slides - .get(args.slide_index as usize) - .cloned() - .ok_or_else(|| { - index_out_of_range( - &request.action, - args.slide_index as usize, - document.slides.len(), - ) - })?; - let duplicated = document.clone_slide(source); - let insert_at = args.slide_index as usize + 1; - document.adjust_active_slide_for_insert(insert_at); - document.slides.insert(insert_at, duplicated); - Ok(PresentationArtifactResponse::new( - artifact_id, - request.action, - format!("Duplicated slide {} to index {insert_at}", args.slide_index), - snapshot_for_document(document), - )) - } - - fn move_slide( - &mut self, - request: PresentationArtifactRequest, - ) -> Result { - let args: MoveSlideArgs = parse_args(&request.action, &request.args)?; - let artifact_id = required_artifact_id(&request)?; - let document = self.get_document_mut(&artifact_id, &request.action)?; - let from = args.from_index as usize; - let to = args.to_index as usize; - if from >= document.slides.len() { - return Err(index_out_of_range( - &request.action, - from, - document.slides.len(), - )); - } - if to >= document.slides.len() { - return Err(index_out_of_range( - &request.action, - to, - document.slides.len(), - )); - } - let slide = document.slides.remove(from); - document.slides.insert(to, slide); - document.adjust_active_slide_for_move(from, to); - Ok(PresentationArtifactResponse::new( - artifact_id, - request.action, - format!("Moved slide from index {from} to {to}"), - snapshot_for_document(document), - )) - } - - fn delete_slide( - &mut self, - request: PresentationArtifactRequest, - ) -> Result { - let args: SlideIndexArgs = parse_args(&request.action, &request.args)?; - let artifact_id = required_artifact_id(&request)?; - let document = self.get_document_mut(&artifact_id, &request.action)?; - let index = args.slide_index as usize; - if index >= document.slides.len() { - return Err(index_out_of_range( - &request.action, - index, - document.slides.len(), - )); - } - document.slides.remove(index); - document.adjust_active_slide_for_delete(index); - Ok(PresentationArtifactResponse::new( - artifact_id, - request.action, - format!("Deleted slide at index {index}"), - snapshot_for_document(document), - )) - } - - fn set_slide_background( - &mut self, - request: PresentationArtifactRequest, - ) -> Result { - let args: SetSlideBackgroundArgs = parse_args(&request.action, &request.args)?; - let artifact_id = required_artifact_id(&request)?; - let document = self.get_document_mut(&artifact_id, &request.action)?; - let fill = normalize_color_with_document(document, &args.fill, &request.action, "fill")?; - let slide = document.get_slide_mut(args.slide_index, &request.action)?; - slide.background_fill = Some(fill); - Ok(PresentationArtifactResponse::new( - artifact_id, - request.action, - format!("Updated background for slide {}", args.slide_index), - snapshot_for_document(document), - )) - } - - fn add_text_shape( - &mut self, - request: PresentationArtifactRequest, - ) -> Result { - let args: AddTextShapeArgs = parse_args(&request.action, &request.args)?; - let artifact_id = required_artifact_id(&request)?; - let document = self.get_document_mut(&artifact_id, &request.action)?; - let style = normalize_text_style_with_document(document, &args.styling, &request.action)?; - let fill = args - .styling - .fill - .as_deref() - .map(|value| normalize_color_with_document(document, value, &request.action, "fill")) - .transpose()?; - let element_id = document.next_element_id(); - let slide = document.get_slide_mut(args.slide_index, &request.action)?; - slide.elements.push(PresentationElement::Text(TextElement { - element_id: element_id.clone(), - text: args.text, - frame: args.position.into(), - fill, - style, - hyperlink: None, - placeholder: None, - z_order: slide.elements.len(), - })); - Ok(PresentationArtifactResponse::new( - artifact_id, - request.action, - format!( - "Added text element `{element_id}` to slide {}", - args.slide_index - ), - snapshot_for_document(document), - )) - } - - fn add_shape( - &mut self, - request: PresentationArtifactRequest, - ) -> Result { - let args: AddShapeArgs = parse_args(&request.action, &request.args)?; - let artifact_id = required_artifact_id(&request)?; - let document = self.get_document_mut(&artifact_id, &request.action)?; - let text_style = - normalize_text_style_with_document(document, &args.text_style, &request.action)?; - let fill = args - .fill - .as_deref() - .map(|value| normalize_color_with_document(document, value, &request.action, "fill")) - .transpose()?; - let stroke = parse_stroke(document, args.stroke, &request.action)?; - let element_id = document.next_element_id(); - let slide = document.get_slide_mut(args.slide_index, &request.action)?; - slide - .elements - .push(PresentationElement::Shape(ShapeElement { - element_id: element_id.clone(), - geometry: parse_shape_geometry(&args.geometry, &request.action)?, - frame: args.position.into(), - fill, - stroke, - text: args.text, - text_style, - hyperlink: None, - placeholder: None, - rotation_degrees: None, - z_order: slide.elements.len(), - })); - Ok(PresentationArtifactResponse::new( - artifact_id, - request.action, - format!( - "Added shape element `{element_id}` to slide {}", - args.slide_index - ), - snapshot_for_document(document), - )) - } - - fn add_connector( - &mut self, - request: PresentationArtifactRequest, - ) -> Result { - let args: AddConnectorArgs = parse_args(&request.action, &request.args)?; - let artifact_id = required_artifact_id(&request)?; - let document = self.get_document_mut(&artifact_id, &request.action)?; - let element_id = document.next_element_id(); - let line = parse_connector_line(document, args.line, &request.action)?; - let slide = document.get_slide_mut(args.slide_index, &request.action)?; - slide - .elements - .push(PresentationElement::Connector(ConnectorElement { - element_id: element_id.clone(), - connector_type: parse_connector_kind(&args.connector_type, &request.action)?, - start: args.start, - end: args.end, - line: StrokeStyle { - color: line.color, - width: line.width, - }, - line_style: line.style, - start_arrow: args - .start_arrow - .as_deref() - .map(|value| parse_connector_arrow(value, &request.action)) - .transpose()? - .unwrap_or(ConnectorArrowKind::None), - end_arrow: args - .end_arrow - .as_deref() - .map(|value| parse_connector_arrow(value, &request.action)) - .transpose()? - .unwrap_or(ConnectorArrowKind::None), - arrow_size: args - .arrow_size - .as_deref() - .map(|value| parse_connector_arrow_size(value, &request.action)) - .transpose()? - .unwrap_or(ConnectorArrowScale::Medium), - label: args.label, - z_order: slide.elements.len(), - })); - Ok(PresentationArtifactResponse::new( - artifact_id, - request.action, - format!( - "Added connector element `{element_id}` to slide {}", - args.slide_index - ), - snapshot_for_document(document), - )) - } - - fn add_image( - &mut self, - request: PresentationArtifactRequest, - cwd: &Path, - ) -> Result { - let args: AddImageArgs = parse_args(&request.action, &request.args)?; - let image_source = args.image_source()?; - let is_placeholder = matches!(image_source, ImageInputSource::Placeholder); - let image_payload = match image_source { - ImageInputSource::Path(path) => Some(load_image_payload_from_path( - &resolve_path(cwd, &path), - &request.action, - )?), - ImageInputSource::DataUrl(data_url) => Some(load_image_payload_from_data_url( - &data_url, - &request.action, - )?), - ImageInputSource::Uri(uri) => Some(load_image_payload_from_uri(&uri, &request.action)?), - ImageInputSource::Placeholder => None, - }; - let fit_mode = args.fit.unwrap_or(ImageFitMode::Stretch); - let lock_aspect_ratio = args - .lock_aspect_ratio - .unwrap_or(fit_mode != ImageFitMode::Stretch); - let crop = args - .crop - .map(|crop| normalize_image_crop(crop, &request.action)) - .transpose()?; - let artifact_id = required_artifact_id(&request)?; - let document = self.get_document_mut(&artifact_id, &request.action)?; - let element_id = document.next_element_id(); - let slide = document.get_slide_mut(args.slide_index, &request.action)?; - slide - .elements - .push(PresentationElement::Image(ImageElement { - element_id: element_id.clone(), - frame: args.position.into(), - payload: image_payload, - fit_mode, - crop, - rotation_degrees: args.rotation, - flip_horizontal: args.flip_horizontal.unwrap_or(false), - flip_vertical: args.flip_vertical.unwrap_or(false), - lock_aspect_ratio, - alt_text: args.alt, - prompt: args.prompt, - is_placeholder, - z_order: slide.elements.len(), - })); - Ok(PresentationArtifactResponse::new( - artifact_id, - request.action, - format!( - "Added image element `{element_id}` to slide {}", - args.slide_index - ), - snapshot_for_document(document), - )) - } - - fn replace_image( - &mut self, - request: PresentationArtifactRequest, - cwd: &Path, - ) -> Result { - let args: ReplaceImageArgs = parse_args(&request.action, &request.args)?; - let artifact_id = required_artifact_id(&request)?; - let image_source = match (&args.path, &args.data_url, &args.uri, &args.prompt) { - (Some(path), None, None, None) => ImageInputSource::Path(path.clone()), - (None, Some(data_url), None, None) => ImageInputSource::DataUrl(data_url.clone()), - (None, None, Some(uri), None) => ImageInputSource::Uri(uri.clone()), - (None, None, None, Some(_)) => ImageInputSource::Placeholder, - _ => { - return Err(PresentationArtifactError::InvalidArgs { - action: request.action, - message: - "provide exactly one of `path`, `data_url`, or `uri`, or provide `prompt` for a placeholder image" - .to_string(), - }); - } - }; - let is_placeholder = matches!(image_source, ImageInputSource::Placeholder); - let image_payload = match image_source { - ImageInputSource::Path(path) => Some(load_image_payload_from_path( - &resolve_path(cwd, &path), - "replace_image", - )?), - ImageInputSource::DataUrl(data_url) => Some(load_image_payload_from_data_url( - &data_url, - "replace_image", - )?), - ImageInputSource::Uri(uri) => Some(load_image_payload_from_uri(&uri, "replace_image")?), - ImageInputSource::Placeholder => None, - }; - let fit_mode = args.fit.unwrap_or(ImageFitMode::Stretch); - let lock_aspect_ratio = args - .lock_aspect_ratio - .unwrap_or(fit_mode != ImageFitMode::Stretch); - let crop = args - .crop - .map(|crop| normalize_image_crop(crop, &request.action)) - .transpose()?; - let document = self.get_document_mut(&artifact_id, &request.action)?; - let element = document.find_element_mut(&args.element_id, &request.action)?; - let PresentationElement::Image(image) = element else { - return Err(PresentationArtifactError::UnsupportedFeature { - action: request.action, - message: format!("element `{}` is not an image", args.element_id), - }); - }; - image.payload = image_payload; - image.fit_mode = fit_mode; - image.crop = crop; - if let Some(rotation) = args.rotation { - image.rotation_degrees = Some(rotation); - } - if let Some(flip_horizontal) = args.flip_horizontal { - image.flip_horizontal = flip_horizontal; - } - if let Some(flip_vertical) = args.flip_vertical { - image.flip_vertical = flip_vertical; - } - image.lock_aspect_ratio = lock_aspect_ratio; - image.alt_text = args.alt; - image.prompt = args.prompt; - image.is_placeholder = is_placeholder; - Ok(PresentationArtifactResponse::new( - artifact_id, - "replace_image".to_string(), - format!("Replaced image `{}`", args.element_id), - snapshot_for_document(document), - )) - } - - fn add_table( - &mut self, - request: PresentationArtifactRequest, - ) -> Result { - let args: AddTableArgs = parse_args(&request.action, &request.args)?; - let artifact_id = required_artifact_id(&request)?; - let document = self.get_document_mut(&artifact_id, &request.action)?; - let rows = coerce_table_rows(args.rows, &request.action)?; - let mut frame: Rect = args.position.into(); - let (column_widths, row_heights) = normalize_table_dimensions( - &rows, - frame, - args.column_widths, - args.row_heights, - &request.action, - )?; - frame.width = column_widths.iter().sum(); - frame.height = row_heights.iter().sum(); - let element_id = document.next_element_id(); - let slide = document.get_slide_mut(args.slide_index, &request.action)?; - slide - .elements - .push(PresentationElement::Table(TableElement { - element_id: element_id.clone(), - frame, - rows, - column_widths, - row_heights, - style: args.style, - merges: Vec::new(), - z_order: slide.elements.len(), - })); - Ok(PresentationArtifactResponse::new( - artifact_id, - request.action, - format!( - "Added table element `{element_id}` to slide {}", - args.slide_index - ), - snapshot_for_document(document), - )) - } - - fn update_table_cell( - &mut self, - request: PresentationArtifactRequest, - ) -> Result { - let args: UpdateTableCellArgs = parse_args(&request.action, &request.args)?; - let artifact_id = required_artifact_id(&request)?; - let document = self.get_document_mut(&artifact_id, &request.action)?; - let text_style = - normalize_text_style_with_document(document, &args.styling, &request.action)?; - let background_fill = args - .background_fill - .as_deref() - .map(|fill| { - normalize_color_with_document(document, fill, &request.action, "background_fill") - }) - .transpose()?; - let element = document.find_element_mut(&args.element_id, &request.action)?; - let PresentationElement::Table(table) = element else { - return Err(PresentationArtifactError::UnsupportedFeature { - action: request.action, - message: format!("element `{}` is not a table", args.element_id), - }); - }; - let row = args.row as usize; - let column = args.column as usize; - if row >= table.rows.len() || column >= table.rows[row].len() { - return Err(PresentationArtifactError::InvalidArgs { - action: request.action, - message: format!("cell ({row}, {column}) is out of bounds"), - }); - } - let cell = &mut table.rows[row][column]; - cell.text = cell_value_to_string(args.value); - cell.text_style = text_style; - cell.background_fill = background_fill; - cell.alignment = args - .alignment - .as_deref() - .map(|value| parse_alignment(value, &request.action)) - .transpose()?; - Ok(PresentationArtifactResponse::new( - artifact_id, - request.action, - format!("Updated table cell ({row}, {column})"), - snapshot_for_document(document), - )) - } - - fn merge_table_cells( - &mut self, - request: PresentationArtifactRequest, - ) -> Result { - let args: MergeTableCellsArgs = parse_args(&request.action, &request.args)?; - let artifact_id = required_artifact_id(&request)?; - let document = self.get_document_mut(&artifact_id, &request.action)?; - let element = document.find_element_mut(&args.element_id, &request.action)?; - let PresentationElement::Table(table) = element else { - return Err(PresentationArtifactError::UnsupportedFeature { - action: request.action, - message: format!("element `{}` is not a table", args.element_id), - }); - }; - let region = TableMergeRegion { - start_row: args.start_row as usize, - end_row: args.end_row as usize, - start_column: args.start_column as usize, - end_column: args.end_column as usize, - }; - table.merges.push(region); - Ok(PresentationArtifactResponse::new( - artifact_id, - request.action, - format!("Merged table cells in `{}`", args.element_id), - snapshot_for_document(document), - )) - } - - fn add_chart( - &mut self, - request: PresentationArtifactRequest, - ) -> Result { - let args: AddChartArgs = parse_args(&request.action, &request.args)?; - let artifact_id = required_artifact_id(&request)?; - let document = self.get_document_mut(&artifact_id, &request.action)?; - let chart_type = parse_chart_type(&args.chart_type, &request.action)?; - let series = args - .series - .into_iter() - .map(|entry| { - if entry.values.is_empty() { - return Err(PresentationArtifactError::InvalidArgs { - action: request.action.clone(), - message: format!("series `{}` must contain at least one value", entry.name), - }); - } - Ok(ChartSeriesSpec { - name: entry.name, - values: entry.values, - }) - }) - .collect::, _>>()?; - let element_id = document.next_element_id(); - let slide = document.get_slide_mut(args.slide_index, &request.action)?; - slide - .elements - .push(PresentationElement::Chart(ChartElement { - element_id: element_id.clone(), - frame: args.position.into(), - chart_type, - categories: args.categories, - series, - title: args.title, - z_order: slide.elements.len(), - })); - Ok(PresentationArtifactResponse::new( - artifact_id, - request.action, - format!( - "Added chart element `{element_id}` to slide {}", - args.slide_index - ), - snapshot_for_document(document), - )) - } - - fn update_text( - &mut self, - request: PresentationArtifactRequest, - ) -> Result { - let args: UpdateTextArgs = parse_args(&request.action, &request.args)?; - let artifact_id = required_artifact_id(&request)?; - let document = self.get_document_mut(&artifact_id, &request.action)?; - let style = normalize_text_style_with_document(document, &args.styling, &request.action)?; - let fill = args - .styling - .fill - .as_deref() - .map(|value| normalize_color_with_document(document, value, &request.action, "fill")) - .transpose()?; - let element = document.find_element_mut(&args.element_id, &request.action)?; - match element { - PresentationElement::Text(text) => { - text.text = args.text; - if let Some(fill) = fill.clone() { - text.fill = Some(fill); - } - text.style = style; - } - PresentationElement::Shape(shape) => { - if shape.text.is_none() { - return Err(PresentationArtifactError::UnsupportedFeature { - action: request.action, - message: format!( - "element `{}` does not contain editable text", - args.element_id - ), - }); - } - shape.text = Some(args.text); - if let Some(fill) = fill { - shape.fill = Some(fill); - } - shape.text_style = style; - } - other => { - return Err(PresentationArtifactError::UnsupportedFeature { - action: request.action, - message: format!( - "element `{}` is `{}`; only text-bearing elements support `update_text`", - args.element_id, - other.kind() - ), - }); - } - } - Ok(PresentationArtifactResponse::new( - artifact_id, - request.action, - format!("Updated text for element `{}`", args.element_id), - snapshot_for_document(document), - )) - } - - fn replace_text( - &mut self, - request: PresentationArtifactRequest, - ) -> Result { - let args: ReplaceTextArgs = parse_args(&request.action, &request.args)?; - let artifact_id = required_artifact_id(&request)?; - let document = self.get_document_mut(&artifact_id, &request.action)?; - let element = document.find_element_mut(&args.element_id, &request.action)?; - match element { - PresentationElement::Text(text) => { - if !text.text.contains(&args.search) { - return Err(PresentationArtifactError::InvalidArgs { - action: request.action, - message: format!( - "text `{}` was not found in element `{}`", - args.search, args.element_id - ), - }); - } - text.text = text.text.replace(&args.search, &args.replace); - } - PresentationElement::Shape(shape) => { - let Some(text) = &mut shape.text else { - return Err(PresentationArtifactError::UnsupportedFeature { - action: request.action, - message: format!( - "element `{}` does not contain editable text", - args.element_id - ), - }); - }; - if !text.contains(&args.search) { - return Err(PresentationArtifactError::InvalidArgs { - action: request.action, - message: format!( - "text `{}` was not found in element `{}`", - args.search, args.element_id - ), - }); - } - *text = text.replace(&args.search, &args.replace); - } - other => { - return Err(PresentationArtifactError::UnsupportedFeature { - action: request.action, - message: format!( - "element `{}` is `{}`; only text-bearing elements support `replace_text`", - args.element_id, - other.kind() - ), - }); - } - } - Ok(PresentationArtifactResponse::new( - artifact_id, - request.action, - format!("Replaced text in element `{}`", args.element_id), - snapshot_for_document(document), - )) - } - - fn insert_text_after( - &mut self, - request: PresentationArtifactRequest, - ) -> Result { - let args: InsertTextAfterArgs = parse_args(&request.action, &request.args)?; - let artifact_id = required_artifact_id(&request)?; - let document = self.get_document_mut(&artifact_id, &request.action)?; - let element = document.find_element_mut(&args.element_id, &request.action)?; - match element { - PresentationElement::Text(text) => { - let Some(index) = text.text.find(&args.after) else { - return Err(PresentationArtifactError::InvalidArgs { - action: request.action, - message: format!( - "text `{}` was not found in element `{}`", - args.after, args.element_id - ), - }); - }; - let insert_at = index + args.after.len(); - text.text.insert_str(insert_at, &args.insert); - } - PresentationElement::Shape(shape) => { - let Some(text) = &mut shape.text else { - return Err(PresentationArtifactError::UnsupportedFeature { - action: request.action, - message: format!( - "element `{}` does not contain editable text", - args.element_id - ), - }); - }; - let Some(index) = text.find(&args.after) else { - return Err(PresentationArtifactError::InvalidArgs { - action: request.action, - message: format!( - "text `{}` was not found in element `{}`", - args.after, args.element_id - ), - }); - }; - let insert_at = index + args.after.len(); - text.insert_str(insert_at, &args.insert); - } - other => { - return Err(PresentationArtifactError::UnsupportedFeature { - action: request.action, - message: format!( - "element `{}` is `{}`; only text-bearing elements support `insert_text_after`", - args.element_id, - other.kind() - ), - }); - } - } - Ok(PresentationArtifactResponse::new( - artifact_id, - request.action, - format!("Inserted text in element `{}`", args.element_id), - snapshot_for_document(document), - )) - } - - fn set_hyperlink( - &mut self, - request: PresentationArtifactRequest, - ) -> Result { - let args: SetHyperlinkArgs = parse_args(&request.action, &request.args)?; - let artifact_id = required_artifact_id(&request)?; - let document = self.get_document_mut(&artifact_id, &request.action)?; - let clear = args.clear.unwrap_or(false); - let hyperlink = if clear { - None - } else { - Some(parse_hyperlink_state(document, &args, &request.action)?) - }; - let element = document.find_element_mut(&args.element_id, &request.action)?; - match element { - PresentationElement::Text(text) => text.hyperlink = hyperlink, - PresentationElement::Shape(shape) => shape.hyperlink = hyperlink, - other => { - return Err(PresentationArtifactError::UnsupportedFeature { - action: request.action, - message: format!( - "element `{}` is `{}`; only text boxes and shapes support `set_hyperlink`", - args.element_id, - other.kind() - ), - }); - } - } - Ok(PresentationArtifactResponse::new( - artifact_id, - "set_hyperlink".to_string(), - if clear { - format!("Cleared hyperlink for element `{}`", args.element_id) - } else { - format!("Updated hyperlink for element `{}`", args.element_id) - }, - snapshot_for_document(document), - )) - } - - fn update_shape_style( - &mut self, - request: PresentationArtifactRequest, - ) -> Result { - let args: UpdateShapeStyleArgs = parse_args(&request.action, &request.args)?; - let artifact_id = required_artifact_id(&request)?; - let document = self.get_document_mut(&artifact_id, &request.action)?; - let fill = args - .fill - .as_deref() - .map(|value| normalize_color_with_document(document, value, &request.action, "fill")) - .transpose()?; - let stroke = args - .stroke - .clone() - .map(|value| parse_required_stroke(document, value, &request.action)) - .transpose()?; - let element = document.find_element_mut(&args.element_id, &request.action)?; - match element { - PresentationElement::Text(text) => { - if let Some(position) = args.position { - text.frame = apply_partial_position(text.frame, position); - } - if let Some(fill) = fill.clone() { - text.fill = Some(fill); - } - if args.stroke.is_some() - || args.rotation.is_some() - || args.flip_horizontal.is_some() - || args.flip_vertical.is_some() - { - return Err(PresentationArtifactError::UnsupportedFeature { - action: request.action, - message: - "text elements support only `position`, `z_order`, and `fill` updates" - .to_string(), - }); - } - } - PresentationElement::Shape(shape) => { - if let Some(position) = args.position { - shape.frame = apply_partial_position(shape.frame, position); - } - if let Some(fill) = fill { - shape.fill = Some(fill); - } - if let Some(stroke) = stroke { - shape.stroke = Some(stroke); - } - if args.flip_horizontal.is_some() || args.flip_vertical.is_some() { - return Err(PresentationArtifactError::UnsupportedFeature { - action: request.action, - message: - "shape elements support `position`, `fill`, `stroke`, `rotation`, and `z_order` updates" - .to_string(), - }); - } - if let Some(rotation) = args.rotation { - shape.rotation_degrees = Some(rotation); - } - } - PresentationElement::Connector(connector) => { - if args.fill.is_some() - || args.rotation.is_some() - || args.flip_horizontal.is_some() - || args.flip_vertical.is_some() - || args.fit.is_some() - || args.crop.is_some() - || args.lock_aspect_ratio.is_some() - { - return Err(PresentationArtifactError::UnsupportedFeature { - action: request.action, - message: - "connector elements support only `position`, `stroke`, and `z_order` updates" - .to_string(), - }); - } - if let Some(position) = args.position { - let updated = apply_partial_position( - Rect { - left: connector.start.left, - top: connector.start.top, - width: connector.end.left.abs_diff(connector.start.left), - height: connector.end.top.abs_diff(connector.start.top), - }, - position, - ); - connector.start = PointArgs { - left: updated.left, - top: updated.top, - }; - connector.end = PointArgs { - left: updated.left.saturating_add(updated.width), - top: updated.top.saturating_add(updated.height), - }; - } - if let Some(stroke) = stroke { - connector.line = stroke; - } - } - PresentationElement::Image(image) => { - if args.fill.is_some() || args.stroke.is_some() { - return Err(PresentationArtifactError::UnsupportedFeature { - action: request.action, - message: - "image elements support only `position`, `fit`, `crop`, `rotation`, `flip_horizontal`, `flip_vertical`, `lock_aspect_ratio`, and `z_order` updates" - .to_string(), - }); - } - if let Some(position) = args.position { - image.frame = apply_partial_position_to_image(image, position); - } - if let Some(fit) = args.fit { - image.fit_mode = fit; - if !matches!(fit, ImageFitMode::Stretch) && args.lock_aspect_ratio.is_none() { - image.lock_aspect_ratio = true; - } - } - if let Some(crop) = args.crop { - image.crop = Some(normalize_image_crop(crop, &request.action)?); - } - if let Some(rotation) = args.rotation { - image.rotation_degrees = Some(rotation); - } - if let Some(flip_horizontal) = args.flip_horizontal { - image.flip_horizontal = flip_horizontal; - } - if let Some(flip_vertical) = args.flip_vertical { - image.flip_vertical = flip_vertical; - } - if let Some(lock_aspect_ratio) = args.lock_aspect_ratio { - image.lock_aspect_ratio = lock_aspect_ratio; - } - } - PresentationElement::Table(table) => { - if args.fill.is_some() - || args.stroke.is_some() - || args.rotation.is_some() - || args.flip_horizontal.is_some() - || args.flip_vertical.is_some() - { - return Err(PresentationArtifactError::UnsupportedFeature { - action: request.action, - message: "table elements support only `position` and `z_order` updates" - .to_string(), - }); - } - if let Some(position) = args.position { - table.frame = apply_partial_position(table.frame, position); - } - } - PresentationElement::Chart(chart) => { - if args.fill.is_some() - || args.stroke.is_some() - || args.rotation.is_some() - || args.flip_horizontal.is_some() - || args.flip_vertical.is_some() - { - return Err(PresentationArtifactError::UnsupportedFeature { - action: request.action, - message: "chart elements support only `position` and `z_order` updates" - .to_string(), - }); - } - if let Some(position) = args.position { - chart.frame = apply_partial_position(chart.frame, position); - } - } - } - if let Some(z_order) = args.z_order { - document.set_z_order(&args.element_id, z_order as usize, &request.action)?; - } - Ok(PresentationArtifactResponse::new( - artifact_id, - request.action, - format!("Updated style for element `{}`", args.element_id), - snapshot_for_document(document), - )) - } - - fn delete_element( - &mut self, - request: PresentationArtifactRequest, - ) -> Result { - let args: ElementIdArgs = parse_args(&request.action, &request.args)?; - let artifact_id = required_artifact_id(&request)?; - let document = self.get_document_mut(&artifact_id, &request.action)?; - document.remove_element(&args.element_id, &request.action)?; - Ok(PresentationArtifactResponse::new( - artifact_id, - request.action, - format!("Deleted element `{}`", args.element_id), - snapshot_for_document(document), - )) - } - - fn bring_to_front( - &mut self, - request: PresentationArtifactRequest, - ) -> Result { - let args: ElementIdArgs = parse_args(&request.action, &request.args)?; - let artifact_id = required_artifact_id(&request)?; - let document = self.get_document_mut(&artifact_id, &request.action)?; - let target_index = document.total_element_count(); - document.set_z_order(&args.element_id, target_index, &request.action)?; - Ok(PresentationArtifactResponse::new( - artifact_id, - request.action, - format!("Brought `{}` to front", args.element_id), - snapshot_for_document(document), - )) - } - - fn send_to_back( - &mut self, - request: PresentationArtifactRequest, - ) -> Result { - let args: ElementIdArgs = parse_args(&request.action, &request.args)?; - let artifact_id = required_artifact_id(&request)?; - let document = self.get_document_mut(&artifact_id, &request.action)?; - document.set_z_order(&args.element_id, 0, &request.action)?; - Ok(PresentationArtifactResponse::new( - artifact_id, - request.action, - format!("Sent `{}` to back", args.element_id), - snapshot_for_document(document), - )) - } - - fn delete_artifact( - &mut self, - request: PresentationArtifactRequest, - ) -> Result { - let artifact_id = required_artifact_id(&request)?; - let removed = self.documents.remove(&artifact_id).ok_or_else(|| { - PresentationArtifactError::UnknownArtifactId { - action: request.action.clone(), - artifact_id: artifact_id.clone(), - } - })?; - Ok(PresentationArtifactResponse { - artifact_id, - action: request.action, - summary: format!( - "Deleted in-memory artifact `{}` with {} slides", - removed.artifact_id, - removed.slides.len() - ), - exported_paths: Vec::new(), - artifact_snapshot: None, - slide_list: None, - layout_list: None, - placeholder_list: None, - theme: None, - inspect_ndjson: None, - resolved_record: None, - active_slide_index: None, - }) - } - - fn get_document( - &self, - artifact_id: &str, - action: &str, - ) -> Result<&PresentationDocument, PresentationArtifactError> { - self.documents.get(artifact_id).ok_or_else(|| { - PresentationArtifactError::UnknownArtifactId { - action: action.to_string(), - artifact_id: artifact_id.to_string(), - } - }) - } - - fn get_document_mut( - &mut self, - artifact_id: &str, - action: &str, - ) -> Result<&mut PresentationDocument, PresentationArtifactError> { - self.documents.get_mut(artifact_id).ok_or_else(|| { - PresentationArtifactError::UnknownArtifactId { - action: action.to_string(), - artifact_id: artifact_id.to_string(), - } - }) - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct PresentationArtifactResponse { - pub artifact_id: String, - pub action: String, - pub summary: String, - #[serde(skip_serializing_if = "Vec::is_empty")] - pub exported_paths: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub artifact_snapshot: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub slide_list: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub layout_list: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub placeholder_list: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub theme: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub inspect_ndjson: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub resolved_record: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub active_slide_index: Option, -} - -impl PresentationArtifactResponse { - fn new( - artifact_id: String, - action: String, - summary: String, - artifact_snapshot: ArtifactSnapshot, - ) -> Self { - Self { - artifact_id, - action, - summary, - exported_paths: Vec::new(), - artifact_snapshot: Some(artifact_snapshot), - slide_list: None, - layout_list: None, - placeholder_list: None, - theme: None, - inspect_ndjson: None, - resolved_record: None, - active_slide_index: None, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct ArtifactSnapshot { - pub slide_count: usize, - pub slides: Vec, -} - -#[derive(Debug, Clone, Serialize)] -pub struct SlideSnapshot { - pub slide_id: String, - pub index: usize, - pub element_ids: Vec, - pub element_types: Vec, -} - -#[derive(Debug, Clone, Serialize)] -pub struct SlideListEntry { - pub slide_id: String, - pub index: usize, - pub is_active: bool, - pub notes: Option, - pub notes_visible: bool, - pub background_fill: Option, - pub layout_id: Option, - pub element_count: usize, -} - -#[derive(Debug, Clone, Serialize)] -pub struct LayoutListEntry { - pub layout_id: String, - pub name: String, - pub kind: String, - pub parent_layout_id: Option, - pub placeholder_count: usize, -} - -#[derive(Debug, Clone, Serialize)] -pub struct PlaceholderListEntry { - pub scope: String, - pub source_layout_id: Option, - pub slide_index: Option, - pub element_id: Option, - pub name: String, - pub placeholder_type: String, - pub index: Option, - pub geometry: Option, - pub text_preview: Option, -} - -#[derive(Debug, Clone, Serialize)] -pub struct ThemeSnapshot { - pub color_scheme: HashMap, - pub major_font: Option, - pub minor_font: Option, -} - -#[derive(Debug, Clone, Default)] -struct ThemeState { - color_scheme: HashMap, - major_font: Option, - minor_font: Option, -} - -impl ThemeState { - fn resolve_color(&self, color: &str) -> Option { - let key = color.trim().to_ascii_lowercase(); - let alias = match key.as_str() { - "background1" => "bg1", - "background2" => "bg2", - "text1" => "tx1", - "text2" => "tx2", - "dark1" => "dk1", - "dark2" => "dk2", - "light1" => "lt1", - "light2" => "lt2", - other => other, - }; - self.color_scheme - .get(alias) - .or_else(|| self.color_scheme.get(&key)) - .cloned() - .map(|value| value.trim_start_matches('#').to_uppercase()) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum LayoutKind { - Layout, - Master, -} - -#[derive(Debug, Clone)] -struct LayoutDocument { - layout_id: String, - name: String, - kind: LayoutKind, - parent_layout_id: Option, - placeholders: Vec, -} - -#[derive(Debug, Clone)] -struct PlaceholderDefinition { - name: String, - placeholder_type: String, - index: Option, - text: Option, - geometry: ShapeGeometry, - frame: Rect, -} - -#[derive(Debug, Clone)] -struct ResolvedPlaceholder { - source_layout_id: String, - definition: PlaceholderDefinition, -} - -#[derive(Debug, Clone, Default)] -struct NotesState { - text: String, - visible: bool, -} - -#[derive(Debug, Clone, Default)] -struct TextStyle { - font_size: Option, - font_family: Option, - color: Option, - alignment: Option, - bold: bool, - italic: bool, - underline: bool, -} - -#[derive(Debug, Clone)] -struct HyperlinkState { - target: HyperlinkTarget, - tooltip: Option, - highlight_click: bool, -} - -#[derive(Debug, Clone)] -enum HyperlinkTarget { - Url(String), - Slide(u32), - FirstSlide, - LastSlide, - NextSlide, - PreviousSlide, - EndShow, - Email { - address: String, - subject: Option, - }, - File(String), -} - -impl HyperlinkTarget { - fn relationship_target(&self) -> String { - match self { - Self::Url(url) => url.clone(), - Self::Slide(slide_index) => format!("slide{}.xml", slide_index + 1), - Self::FirstSlide => "ppaction://hlinkshowjump?jump=firstslide".to_string(), - Self::LastSlide => "ppaction://hlinkshowjump?jump=lastslide".to_string(), - Self::NextSlide => "ppaction://hlinkshowjump?jump=nextslide".to_string(), - Self::PreviousSlide => "ppaction://hlinkshowjump?jump=previousslide".to_string(), - Self::EndShow => "ppaction://hlinkshowjump?jump=endshow".to_string(), - Self::Email { address, subject } => { - let mut mailto = format!("mailto:{address}"); - if let Some(subject) = subject { - mailto.push_str(&format!("?subject={subject}")); - } - mailto - } - Self::File(path) => format!("file:///{}", path.replace('\\', "/")), - } - } - - fn is_external(&self) -> bool { - matches!(self, Self::Url(_) | Self::Email { .. } | Self::File(_)) - } -} - -impl HyperlinkState { - fn to_ppt_rs(&self, relationship_id: &str) -> PptHyperlink { - let hyperlink = match &self.target { - HyperlinkTarget::Url(url) => PptHyperlink::new(PptHyperlinkAction::url(url)), - HyperlinkTarget::Slide(slide_index) => { - PptHyperlink::new(PptHyperlinkAction::slide(slide_index + 1)) - } - HyperlinkTarget::FirstSlide => PptHyperlink::new(PptHyperlinkAction::FirstSlide), - HyperlinkTarget::LastSlide => PptHyperlink::new(PptHyperlinkAction::LastSlide), - HyperlinkTarget::NextSlide => PptHyperlink::new(PptHyperlinkAction::NextSlide), - HyperlinkTarget::PreviousSlide => PptHyperlink::new(PptHyperlinkAction::PreviousSlide), - HyperlinkTarget::EndShow => PptHyperlink::new(PptHyperlinkAction::EndShow), - HyperlinkTarget::Email { address, subject } => PptHyperlink::new(match subject { - Some(subject) => PptHyperlinkAction::email_with_subject(address, subject), - None => PptHyperlinkAction::email(address), - }), - HyperlinkTarget::File(path) => PptHyperlink::new(PptHyperlinkAction::file(path)), - }; - let hyperlink = if let Some(tooltip) = &self.tooltip { - hyperlink.with_tooltip(tooltip) - } else { - hyperlink - }; - hyperlink - .with_highlight_click(self.highlight_click) - .with_r_id(relationship_id) - } - - fn to_json(&self) -> Value { - let mut record = match &self.target { - HyperlinkTarget::Url(url) => serde_json::json!({ - "type": "url", - "url": url, - }), - HyperlinkTarget::Slide(slide_index) => serde_json::json!({ - "type": "slide", - "slideIndex": slide_index, - }), - HyperlinkTarget::FirstSlide => serde_json::json!({ - "type": "firstSlide", - }), - HyperlinkTarget::LastSlide => serde_json::json!({ - "type": "lastSlide", - }), - HyperlinkTarget::NextSlide => serde_json::json!({ - "type": "nextSlide", - }), - HyperlinkTarget::PreviousSlide => serde_json::json!({ - "type": "previousSlide", - }), - HyperlinkTarget::EndShow => serde_json::json!({ - "type": "endShow", - }), - HyperlinkTarget::Email { address, subject } => serde_json::json!({ - "type": "email", - "address": address, - "subject": subject, - }), - HyperlinkTarget::File(path) => serde_json::json!({ - "type": "file", - "path": path, - }), - }; - record["tooltip"] = self - .tooltip - .as_ref() - .map(|tooltip| Value::String(tooltip.clone())) - .unwrap_or(Value::Null); - record["highlightClick"] = Value::Bool(self.highlight_click); - record - } - - fn relationship_xml(&self, relationship_id: &str) -> String { - let target_mode = if self.target.is_external() { - r#" TargetMode="External""# - } else { - "" - }; - format!( - r#""#, - ppt_rs::escape_xml(&self.target.relationship_target()), - ) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -enum TextAlignment { - Left, - Center, - Right, - Justify, -} - -#[derive(Debug, Clone)] -struct PlaceholderRef { - name: String, - placeholder_type: String, - index: Option, -} - -#[derive(Debug, Clone)] -struct TableMergeRegion { - start_row: usize, - end_row: usize, - start_column: usize, - end_column: usize, -} - -#[derive(Debug, Clone)] -struct TableCellSpec { - text: String, - text_style: TextStyle, - background_fill: Option, - alignment: Option, -} - -#[derive(Debug, Clone)] -struct PresentationDocument { - artifact_id: String, - name: Option, - slide_size: Rect, - theme: ThemeState, - layouts: Vec, - slides: Vec, - active_slide_index: Option, - next_slide_seq: u32, - next_element_seq: u32, - next_layout_seq: u32, -} - -impl PresentationDocument { - fn new(name: Option) -> Self { - Self { - artifact_id: format!("presentation_{}", Uuid::new_v4().simple()), - name, - slide_size: Rect { - left: 0, - top: 0, - width: DEFAULT_SLIDE_WIDTH_POINTS, - height: DEFAULT_SLIDE_HEIGHT_POINTS, - }, - theme: ThemeState::default(), - layouts: Vec::new(), - slides: Vec::new(), - active_slide_index: None, - next_slide_seq: 1, - next_element_seq: 1, - next_layout_seq: 1, - } - } - - fn from_ppt_rs(presentation: Presentation) -> Self { - let mut document = Self::new( - (!presentation.get_title().is_empty()).then(|| presentation.get_title().to_string()), - ); - for imported_slide in presentation.slides() { - let mut slide = PresentationSlide { - slide_id: format!("slide_{}", document.next_slide_seq), - notes: NotesState { - text: imported_slide.notes.clone().unwrap_or_default(), - visible: true, - }, - background_fill: None, - layout_id: None, - elements: Vec::new(), - }; - document.next_slide_seq += 1; - - if !imported_slide.title.is_empty() { - slide.elements.push(PresentationElement::Text(TextElement { - element_id: document.next_element_id(), - text: imported_slide.title.clone(), - frame: Rect { - left: DEFAULT_IMPORTED_TITLE_LEFT, - top: DEFAULT_IMPORTED_TITLE_TOP, - width: DEFAULT_IMPORTED_TITLE_WIDTH, - height: DEFAULT_IMPORTED_TITLE_HEIGHT, - }, - fill: None, - style: TextStyle::default(), - hyperlink: None, - placeholder: None, - z_order: slide.elements.len(), - })); - } - - if !imported_slide.content.is_empty() { - slide.elements.push(PresentationElement::Text(TextElement { - element_id: document.next_element_id(), - text: imported_slide.content.join("\n"), - frame: Rect { - left: DEFAULT_IMPORTED_CONTENT_LEFT, - top: DEFAULT_IMPORTED_CONTENT_TOP, - width: DEFAULT_IMPORTED_CONTENT_WIDTH, - height: DEFAULT_IMPORTED_CONTENT_HEIGHT, - }, - fill: None, - style: TextStyle::default(), - hyperlink: None, - placeholder: None, - z_order: slide.elements.len(), - })); - } - - for imported_shape in &imported_slide.shapes { - slide - .elements - .push(PresentationElement::Shape(ShapeElement { - element_id: document.next_element_id(), - geometry: ShapeGeometry::from_shape_type(imported_shape.shape_type), - frame: Rect::from_emu( - imported_shape.x, - imported_shape.y, - imported_shape.width, - imported_shape.height, - ), - fill: imported_shape.fill.as_ref().map(|fill| fill.color.clone()), - stroke: imported_shape.line.as_ref().map(|line| StrokeStyle { - color: line.color.clone(), - width: emu_to_points(line.width), - }), - text: imported_shape.text.clone(), - text_style: TextStyle::default(), - hyperlink: None, - placeholder: None, - rotation_degrees: imported_shape.rotation, - z_order: slide.elements.len(), - })); - } - - if let Some(imported_table) = &imported_slide.table { - slide - .elements - .push(PresentationElement::Table(TableElement { - element_id: document.next_element_id(), - frame: Rect::from_emu( - imported_table.x, - imported_table.y, - imported_table.width(), - imported_table.height(), - ), - rows: imported_table - .rows - .iter() - .map(|row| { - row.cells - .iter() - .map(|text| TableCellSpec { - text: text.text.clone(), - text_style: TextStyle::default(), - background_fill: None, - alignment: None, - }) - .collect() - }) - .collect(), - column_widths: imported_table - .column_widths - .iter() - .copied() - .map(emu_to_points) - .collect(), - row_heights: imported_table - .rows - .iter() - .map(|row| row.height.map_or(400_000, |height| height)) - .map(emu_to_points) - .collect(), - style: None, - merges: Vec::new(), - z_order: slide.elements.len(), - })); - } - - document.slides.push(slide); - } - document.active_slide_index = (!document.slides.is_empty()).then_some(0); - document - } - - fn new_slide( - &mut self, - notes: Option, - background_fill: Option, - action: &str, - ) -> Result { - let normalized_fill = background_fill - .map(|value| { - normalize_color_with_palette(Some(&self.theme), &value, action, "background_fill") - }) - .transpose()?; - let slide = PresentationSlide { - slide_id: format!("slide_{}", self.next_slide_seq), - notes: NotesState { - text: notes.unwrap_or_default(), - visible: true, - }, - background_fill: normalized_fill, - layout_id: None, - elements: Vec::new(), - }; - self.next_slide_seq += 1; - Ok(slide) - } - - fn append_slide(&mut self, slide: PresentationSlide) -> usize { - let index = self.slides.len(); - self.slides.push(slide); - if self.active_slide_index.is_none() { - self.active_slide_index = Some(index); - } - index - } - - fn clone_slide(&mut self, slide: PresentationSlide) -> PresentationSlide { - let mut clone = slide; - clone.slide_id = format!("slide_{}", self.next_slide_seq); - self.next_slide_seq += 1; - for element in &mut clone.elements { - element.set_element_id(self.next_element_id()); - } - clone - } - - fn next_element_id(&mut self) -> String { - let element_id = format!("element_{}", self.next_element_seq); - self.next_element_seq += 1; - element_id - } - - fn total_element_count(&self) -> usize { - self.slides.iter().map(|slide| slide.elements.len()).sum() - } - - fn set_active_slide_index( - &mut self, - slide_index: usize, - action: &str, - ) -> Result<(), PresentationArtifactError> { - if slide_index >= self.slides.len() { - return Err(index_out_of_range(action, slide_index, self.slides.len())); - } - self.active_slide_index = Some(slide_index); - Ok(()) - } - - fn adjust_active_slide_for_insert(&mut self, inserted_index: usize) { - match self.active_slide_index { - None => self.active_slide_index = Some(inserted_index), - Some(active_index) if inserted_index <= active_index => { - self.active_slide_index = Some(active_index + 1); - } - Some(_) => {} - } - } - - fn adjust_active_slide_for_move(&mut self, from_index: usize, to_index: usize) { - if let Some(active_index) = self.active_slide_index { - self.active_slide_index = Some(if active_index == from_index { - to_index - } else if from_index < active_index && active_index <= to_index { - active_index - 1 - } else if to_index <= active_index && active_index < from_index { - active_index + 1 - } else { - active_index - }); - } - } - - fn adjust_active_slide_for_delete(&mut self, deleted_index: usize) { - self.active_slide_index = match self.active_slide_index { - None => None, - Some(_) if self.slides.is_empty() => None, - Some(active_index) if active_index == deleted_index => { - Some(deleted_index.min(self.slides.len() - 1)) - } - Some(active_index) if deleted_index < active_index => Some(active_index - 1), - Some(active_index) => Some(active_index), - }; - } - - fn next_layout_id(&mut self) -> String { - let layout_id = format!("layout_{}", self.next_layout_seq); - self.next_layout_seq += 1; - layout_id - } - - fn get_layout( - &self, - layout_id: &str, - action: &str, - ) -> Result<&LayoutDocument, PresentationArtifactError> { - self.layouts - .iter() - .find(|layout| layout.layout_id == layout_id) - .ok_or_else(|| PresentationArtifactError::UnsupportedFeature { - action: action.to_string(), - message: format!("unknown layout id `{layout_id}`"), - }) - } - - fn theme_snapshot(&self) -> ThemeSnapshot { - ThemeSnapshot { - color_scheme: self.theme.color_scheme.clone(), - major_font: self.theme.major_font.clone(), - minor_font: self.theme.minor_font.clone(), - } - } - - fn find_element_mut( - &mut self, - element_id: &str, - action: &str, - ) -> Result<&mut PresentationElement, PresentationArtifactError> { - let element_id = normalize_element_lookup_id(element_id); - for slide in &mut self.slides { - if let Some(element) = slide - .elements - .iter_mut() - .find(|element| element.element_id() == element_id) - { - return Ok(element); - } - } - Err(PresentationArtifactError::UnsupportedFeature { - action: action.to_string(), - message: format!("unknown element id `{element_id}`"), - }) - } - - fn get_slide_mut( - &mut self, - slide_index: u32, - action: &str, - ) -> Result<&mut PresentationSlide, PresentationArtifactError> { - let index = slide_index as usize; - if index >= self.slides.len() { - return Err(index_out_of_range(action, index, self.slides.len())); - } - Ok(&mut self.slides[index]) - } - - fn remove_element( - &mut self, - element_id: &str, - action: &str, - ) -> Result<(), PresentationArtifactError> { - let element_id = normalize_element_lookup_id(element_id); - for slide in &mut self.slides { - if let Some(index) = slide - .elements - .iter() - .position(|element| element.element_id() == element_id) - { - slide.elements.remove(index); - resequence_z_order(slide); - return Ok(()); - } - } - Err(PresentationArtifactError::UnsupportedFeature { - action: action.to_string(), - message: format!("unknown element id `{element_id}`"), - }) - } - - fn set_z_order( - &mut self, - element_id: &str, - target_index: usize, - action: &str, - ) -> Result<(), PresentationArtifactError> { - let element_id = normalize_element_lookup_id(element_id); - for slide in &mut self.slides { - if let Some(current_index) = slide - .elements - .iter() - .position(|element| element.element_id() == element_id) - { - let destination = target_index.min(slide.elements.len().saturating_sub(1)); - let element = slide.elements.remove(current_index); - slide.elements.insert(destination, element); - resequence_z_order(slide); - return Ok(()); - } - } - Err(PresentationArtifactError::UnsupportedFeature { - action: action.to_string(), - message: format!("unknown element id `{element_id}`"), - }) - } - - fn to_ppt_rs(&self) -> Presentation { - let mut presentation = self - .name - .as_deref() - .map(Presentation::with_title) - .unwrap_or_default(); - for slide in &self.slides { - presentation = presentation.add_slide(slide.to_ppt_rs(self.slide_size)); - } - presentation - } -} - -#[derive(Debug, Clone)] -struct PresentationSlide { - slide_id: String, - notes: NotesState, - background_fill: Option, - layout_id: Option, - elements: Vec, -} - -struct ImportedPicture { - relationship_id: String, - frame: Rect, - crop: Option, - alt_text: Option, - rotation_degrees: Option, - flip_horizontal: bool, - flip_vertical: bool, - lock_aspect_ratio: bool, -} - -fn import_pptx_images( - path: &Path, - document: &mut PresentationDocument, - action: &str, -) -> Result<(), PresentationArtifactError> { - let file = - std::fs::File::open(path).map_err(|error| PresentationArtifactError::ImportFailed { - path: path.to_path_buf(), - message: error.to_string(), - })?; - let mut archive = - ZipArchive::new(file).map_err(|error| PresentationArtifactError::ImportFailed { - path: path.to_path_buf(), - message: error.to_string(), - })?; - for slide_index in 0..document.slides.len() { - let slide_number = slide_index + 1; - let slide_xml_path = format!("ppt/slides/slide{slide_number}.xml"); - let Some(slide_xml) = - zip_entry_string_if_exists(&mut archive, &slide_xml_path).map_err(|message| { - PresentationArtifactError::ImportFailed { - path: path.to_path_buf(), - message, - } - })? - else { - continue; - }; - let pictures = parse_imported_pictures(&slide_xml); - if pictures.is_empty() { - continue; - } - let relationships = zip_entry_string_if_exists( - &mut archive, - &format!("ppt/slides/_rels/slide{slide_number}.xml.rels"), - ) - .map_err(|message| PresentationArtifactError::ImportFailed { - path: path.to_path_buf(), - message, - })? - .map(|xml| parse_slide_image_relationship_targets(&xml)) - .unwrap_or_default(); - let mut imported_images = Vec::new(); - for picture in pictures { - let Some(target) = relationships.get(&picture.relationship_id) else { - continue; - }; - let media_path = resolve_zip_relative_path(&slide_xml_path, target); - let Some(bytes) = - zip_entry_bytes_if_exists(&mut archive, &media_path).map_err(|message| { - PresentationArtifactError::ImportFailed { - path: path.to_path_buf(), - message, - } - })? - else { - continue; - }; - let Some(filename) = Path::new(&media_path) - .file_name() - .and_then(|name| name.to_str()) - .map(str::to_owned) - else { - continue; - }; - let Ok(payload) = build_image_payload(bytes, filename, action) else { - continue; - }; - imported_images.push(ImageElement { - element_id: document.next_element_id(), - frame: picture.frame, - payload: Some(payload), - fit_mode: ImageFitMode::Stretch, - crop: picture.crop, - rotation_degrees: picture.rotation_degrees, - flip_horizontal: picture.flip_horizontal, - flip_vertical: picture.flip_vertical, - lock_aspect_ratio: picture.lock_aspect_ratio, - alt_text: picture.alt_text, - prompt: None, - is_placeholder: false, - z_order: 0, - }); - } - let slide = &mut document.slides[slide_index]; - for mut image in imported_images { - image.z_order = slide.elements.len(); - slide.elements.push(PresentationElement::Image(image)); - } - } - Ok(()) -} - -fn zip_entry_string_if_exists( - archive: &mut ZipArchive, - path: &str, -) -> Result, String> { - let Some(bytes) = zip_entry_bytes_if_exists(archive, path)? else { - return Ok(None); - }; - String::from_utf8(bytes) - .map(Some) - .map_err(|error| format!("zip entry `{path}` is not valid UTF-8: {error}")) -} - -fn zip_entry_bytes_if_exists( - archive: &mut ZipArchive, - path: &str, -) -> Result>, String> { - match archive.by_name(path) { - Ok(mut entry) => { - let mut bytes = Vec::new(); - entry - .read_to_end(&mut bytes) - .map_err(|error| format!("failed to read zip entry `{path}`: {error}"))?; - Ok(Some(bytes)) - } - Err(zip::result::ZipError::FileNotFound) => Ok(None), - Err(error) => Err(format!("failed to open zip entry `{path}`: {error}")), - } -} - -fn parse_imported_pictures(slide_xml: &str) -> Vec { - let mut pictures = Vec::new(); - let mut remaining = slide_xml; - while let Some(start) = remaining.find("") { - let block_start = start; - let Some(block_end_offset) = remaining[block_start..].find("") else { - break; - }; - let block_end = block_start + block_end_offset + "".len(); - let block = &remaining[block_start..block_end]; - remaining = &remaining[block_end..]; - - let Some(relationship_id) = xml_tag_attribute(block, "().unwrap_or(0.0) / 100_000.0, - xml_tag_attribute(block, "().ok()) - .unwrap_or(0.0) - / 100_000.0, - xml_tag_attribute(block, "().ok()) - .unwrap_or(0.0) - / 100_000.0, - xml_tag_attribute(block, "().ok()) - .unwrap_or(0.0) - / 100_000.0, - ) - }), - alt_text: xml_tag_attribute(block, "().ok()) - .map(|rotation| (rotation as f64 / 60_000.0).round() as i32), - flip_horizontal: xml_tag_attribute(block, " HashMap { - let mut relationships = HashMap::new(); - let mut remaining = rels_xml; - while let Some(start) = remaining.find("") else { - break; - }; - let tag_end = tag_start + tag_end_offset + 2; - let tag = &remaining[tag_start..tag_end]; - remaining = &remaining[tag_end..]; - if xml_attribute(tag, "Type").as_deref() - != Some("http://schemas.openxmlformats.org/officeDocument/2006/relationships/image") - { - continue; - } - let (Some(id), Some(target)) = (xml_attribute(tag, "Id"), xml_attribute(tag, "Target")) - else { - continue; - }; - relationships.insert(id, target); - } - relationships -} - -fn resolve_zip_relative_path(base_path: &str, target: &str) -> String { - let mut components = Path::new(base_path) - .parent() - .into_iter() - .flat_map(Path::components) - .filter_map(|component| match component { - std::path::Component::Normal(value) => Some(value.to_string_lossy().to_string()), - std::path::Component::CurDir => None, - std::path::Component::ParentDir => None, - std::path::Component::RootDir | std::path::Component::Prefix(_) => None, - }) - .collect::>(); - for component in Path::new(target).components() { - match component { - std::path::Component::Normal(value) => { - components.push(value.to_string_lossy().to_string()) - } - std::path::Component::ParentDir => { - components.pop(); - } - std::path::Component::CurDir => {} - std::path::Component::RootDir | std::path::Component::Prefix(_) => { - components.clear(); - } - } - } - components.join("/") -} - -fn xml_tag_attribute(xml: &str, tag_start: &str, attribute: &str) -> Option { - let start = xml.find(tag_start)?; - let tag = &xml[start..start + xml[start..].find('>')?]; - xml_attribute(tag, attribute) -} - -fn xml_attribute(tag: &str, attribute: &str) -> Option { - let pattern = format!(r#"{attribute}=""#); - let start = tag.find(&pattern)? + pattern.len(); - let end = start + tag[start..].find('"')?; - Some( - tag[start..end] - .replace(""", "\"") - .replace("'", "'") - .replace("<", "<") - .replace(">", ">") - .replace("&", "&"), - ) -} - -impl PresentationSlide { - fn to_ppt_rs(&self, slide_size: Rect) -> SlideContent { - let mut content = SlideContent::new("").layout(SlideLayout::Blank); - if self.notes.visible && !self.notes.text.is_empty() { - content = content.notes(&self.notes.text); - } - - if let Some(background_fill) = &self.background_fill { - content = content.add_shape( - Shape::new( - ShapeType::Rectangle, - 0, - 0, - points_to_emu(slide_size.width), - points_to_emu(slide_size.height), - ) - .with_fill(ShapeFill::new(background_fill)), - ); - } - - let mut ordered = self.elements.clone(); - ordered.sort_by_key(PresentationElement::z_order); - let mut hyperlink_seq = 1_u32; - for element in ordered { - match element { - PresentationElement::Text(text) => { - let mut shape = Shape::new( - ShapeType::Rectangle, - points_to_emu(text.frame.left), - points_to_emu(text.frame.top), - points_to_emu(text.frame.width), - points_to_emu(text.frame.height), - ) - .with_text(&text.text); - if let Some(fill) = text.fill { - shape = shape.with_fill(ShapeFill::new(&fill)); - } - if let Some(hyperlink) = &text.hyperlink { - let relationship_id = format!("rIdHyperlink{hyperlink_seq}"); - hyperlink_seq += 1; - shape = shape.with_hyperlink(hyperlink.to_ppt_rs(&relationship_id)); - } - content = content.add_shape(shape); - } - PresentationElement::Shape(shape) => { - let mut ppt_shape = Shape::new( - shape.geometry.to_ppt_rs(), - points_to_emu(shape.frame.left), - points_to_emu(shape.frame.top), - points_to_emu(shape.frame.width), - points_to_emu(shape.frame.height), - ); - if let Some(text) = shape.text { - ppt_shape = ppt_shape.with_text(&text); - } - if let Some(fill) = shape.fill { - ppt_shape = ppt_shape.with_fill(ShapeFill::new(&fill)); - } - if let Some(stroke) = shape.stroke { - ppt_shape = ppt_shape - .with_line(ShapeLine::new(&stroke.color, points_to_emu(stroke.width))); - } - if let Some(rotation) = shape.rotation_degrees { - ppt_shape = ppt_shape.with_rotation(rotation); - } - if let Some(hyperlink) = &shape.hyperlink { - let relationship_id = format!("rIdHyperlink{hyperlink_seq}"); - hyperlink_seq += 1; - ppt_shape = ppt_shape.with_hyperlink(hyperlink.to_ppt_rs(&relationship_id)); - } - content = content.add_shape(ppt_shape); - } - PresentationElement::Connector(connector) => { - let mut ppt_connector = Connector::new( - connector.connector_type.to_ppt_rs(), - points_to_emu(connector.start.left), - points_to_emu(connector.start.top), - points_to_emu(connector.end.left), - points_to_emu(connector.end.top), - ) - .with_line( - ConnectorLine::new( - &connector.line.color, - points_to_emu(connector.line.width), - ) - .with_dash(connector.line_style.to_ppt_rs()), - ) - .with_arrow_size(connector.arrow_size.to_ppt_rs()) - .with_start_arrow(connector.start_arrow.to_ppt_rs()) - .with_end_arrow(connector.end_arrow.to_ppt_rs()); - if let Some(label) = connector.label { - ppt_connector = ppt_connector.with_label(&label); - } - content = content.add_connector(ppt_connector); - } - PresentationElement::Image(image) => { - if let Some(ref payload) = image.payload { - let mut ppt_image = Image::from_bytes( - payload.bytes.clone(), - points_to_emu(image.frame.width), - points_to_emu(image.frame.height), - &payload.format, - ) - .position( - points_to_emu(image.frame.left), - points_to_emu(image.frame.top), - ); - if image.fit_mode != ImageFitMode::Stretch { - let (x, y, width, height, crop) = fit_image(&image); - ppt_image = Image::from_bytes( - payload.bytes.clone(), - points_to_emu(width), - points_to_emu(height), - &payload.format, - ) - .position(points_to_emu(x), points_to_emu(y)); - if let Some((left, top, right, bottom)) = crop { - ppt_image = ppt_image.with_crop(left, top, right, bottom); - } - } - if let Some((left, top, right, bottom)) = image.crop { - ppt_image = ppt_image.with_crop(left, top, right, bottom); - } - content = content.add_image(ppt_image); - } else { - let mut placeholder = Shape::new( - ShapeType::Rectangle, - points_to_emu(image.frame.left), - points_to_emu(image.frame.top), - points_to_emu(image.frame.width), - points_to_emu(image.frame.height), - ) - .with_text(image.prompt.as_deref().unwrap_or("Image placeholder")); - if let Some(rotation) = image.rotation_degrees { - placeholder = placeholder.with_rotation(rotation); - } - content = content.add_shape(placeholder); - } - } - PresentationElement::Table(table) => { - let mut builder = TableBuilder::new( - table - .column_widths - .iter() - .copied() - .map(points_to_emu) - .collect(), - ) - .position( - points_to_emu(table.frame.left), - points_to_emu(table.frame.top), - ); - for (row_index, row) in table.rows.into_iter().enumerate() { - let cells = row - .into_iter() - .enumerate() - .map(|(column_index, cell)| { - build_table_cell(cell, &table.merges, row_index, column_index) - }) - .collect::>(); - let mut table_row = TableRow::new(cells); - if let Some(height) = table.row_heights.get(row_index) { - table_row = table_row.with_height(points_to_emu(*height)); - } - builder = builder.add_row(table_row); - } - content = content.table(builder.build()); - } - PresentationElement::Chart(chart) => { - let mut ppt_chart = Chart::new( - chart.title.as_deref().unwrap_or("Chart"), - chart.chart_type.to_ppt_rs(), - chart.categories, - points_to_emu(chart.frame.left), - points_to_emu(chart.frame.top), - points_to_emu(chart.frame.width), - points_to_emu(chart.frame.height), - ); - for series in chart.series { - ppt_chart = - ppt_chart.add_series(ChartSeries::new(&series.name, series.values)); - } - content = content.add_chart(ppt_chart); - } - } - } - content - } -} - -#[derive(Debug, Clone)] -enum PresentationElement { - Text(TextElement), - Shape(ShapeElement), - Connector(ConnectorElement), - Image(ImageElement), - Table(TableElement), - Chart(ChartElement), -} - -impl PresentationElement { - fn element_id(&self) -> &str { - match self { - Self::Text(element) => &element.element_id, - Self::Shape(element) => &element.element_id, - Self::Connector(element) => &element.element_id, - Self::Image(element) => &element.element_id, - Self::Table(element) => &element.element_id, - Self::Chart(element) => &element.element_id, - } - } - - fn set_element_id(&mut self, new_id: String) { - match self { - Self::Text(element) => element.element_id = new_id, - Self::Shape(element) => element.element_id = new_id, - Self::Connector(element) => element.element_id = new_id, - Self::Image(element) => element.element_id = new_id, - Self::Table(element) => element.element_id = new_id, - Self::Chart(element) => element.element_id = new_id, - } - } - - fn kind(&self) -> &'static str { - match self { - Self::Text(_) => "text", - Self::Shape(_) => "shape", - Self::Connector(_) => "connector", - Self::Image(_) => "image", - Self::Table(_) => "table", - Self::Chart(_) => "chart", - } - } - - fn z_order(&self) -> usize { - match self { - Self::Text(element) => element.z_order, - Self::Shape(element) => element.z_order, - Self::Connector(element) => element.z_order, - Self::Image(element) => element.z_order, - Self::Table(element) => element.z_order, - Self::Chart(element) => element.z_order, - } - } - - fn set_z_order(&mut self, z_order: usize) { - match self { - Self::Text(element) => element.z_order = z_order, - Self::Shape(element) => element.z_order = z_order, - Self::Connector(element) => element.z_order = z_order, - Self::Image(element) => element.z_order = z_order, - Self::Table(element) => element.z_order = z_order, - Self::Chart(element) => element.z_order = z_order, - } - } -} - -#[derive(Debug, Clone)] -struct TextElement { - element_id: String, - text: String, - frame: Rect, - fill: Option, - style: TextStyle, - hyperlink: Option, - placeholder: Option, - z_order: usize, -} - -#[derive(Debug, Clone)] -struct ShapeElement { - element_id: String, - geometry: ShapeGeometry, - frame: Rect, - fill: Option, - stroke: Option, - text: Option, - text_style: TextStyle, - hyperlink: Option, - placeholder: Option, - rotation_degrees: Option, - z_order: usize, -} - -#[derive(Debug, Clone)] -struct ConnectorElement { - element_id: String, - connector_type: ConnectorKind, - start: PointArgs, - end: PointArgs, - line: StrokeStyle, - line_style: LineStyle, - start_arrow: ConnectorArrowKind, - end_arrow: ConnectorArrowKind, - arrow_size: ConnectorArrowScale, - label: Option, - z_order: usize, -} - -#[derive(Debug, Clone)] -pub(crate) struct ImageElement { - pub(crate) element_id: String, - pub(crate) frame: Rect, - pub(crate) payload: Option, - pub(crate) fit_mode: ImageFitMode, - pub(crate) crop: Option, - pub(crate) rotation_degrees: Option, - pub(crate) flip_horizontal: bool, - pub(crate) flip_vertical: bool, - pub(crate) lock_aspect_ratio: bool, - pub(crate) alt_text: Option, - pub(crate) prompt: Option, - pub(crate) is_placeholder: bool, - pub(crate) z_order: usize, -} - -#[derive(Debug, Clone)] -struct TableElement { - element_id: String, - frame: Rect, - rows: Vec>, - column_widths: Vec, - row_heights: Vec, - style: Option, - merges: Vec, - z_order: usize, -} - -#[derive(Debug, Clone)] -struct ChartElement { - element_id: String, - frame: Rect, - chart_type: ChartTypeSpec, - categories: Vec, - series: Vec, - title: Option, - z_order: usize, -} - -#[derive(Debug, Clone)] -pub(crate) struct ImagePayload { - pub(crate) bytes: Vec, - pub(crate) format: String, - pub(crate) width_px: u32, - pub(crate) height_px: u32, -} - -#[derive(Debug, Clone)] -struct ChartSeriesSpec { - name: String, - values: Vec, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ShapeGeometry { - Rectangle, - RoundedRectangle, - Ellipse, - Triangle, - RightTriangle, - Diamond, - Pentagon, - Hexagon, - Octagon, - Star4, - Star5, - Star6, - Star8, - RightArrow, - LeftArrow, - UpArrow, - DownArrow, - LeftRightArrow, - UpDownArrow, - Chevron, - Heart, - Cloud, - Wave, - FlowChartProcess, - FlowChartDecision, - FlowChartConnector, - Parallelogram, - Trapezoid, -} - -impl ShapeGeometry { - fn from_shape_type(shape_type: ShapeType) -> Self { - match shape_type { - ShapeType::RoundedRectangle => Self::RoundedRectangle, - ShapeType::Ellipse | ShapeType::Circle => Self::Ellipse, - ShapeType::Triangle => Self::Triangle, - ShapeType::RightTriangle => Self::RightTriangle, - ShapeType::Diamond => Self::Diamond, - ShapeType::Pentagon => Self::Pentagon, - ShapeType::Hexagon => Self::Hexagon, - ShapeType::Octagon => Self::Octagon, - ShapeType::Star4 => Self::Star4, - ShapeType::Star5 => Self::Star5, - ShapeType::Star6 => Self::Star6, - ShapeType::Star8 => Self::Star8, - ShapeType::RightArrow => Self::RightArrow, - ShapeType::LeftArrow => Self::LeftArrow, - ShapeType::UpArrow => Self::UpArrow, - ShapeType::DownArrow => Self::DownArrow, - ShapeType::LeftRightArrow => Self::LeftRightArrow, - ShapeType::UpDownArrow => Self::UpDownArrow, - ShapeType::ChevronArrow => Self::Chevron, - ShapeType::Heart => Self::Heart, - ShapeType::Cloud => Self::Cloud, - ShapeType::Wave => Self::Wave, - ShapeType::FlowChartProcess => Self::FlowChartProcess, - ShapeType::FlowChartDecision => Self::FlowChartDecision, - ShapeType::FlowChartConnector => Self::FlowChartConnector, - ShapeType::Parallelogram => Self::Parallelogram, - ShapeType::Trapezoid => Self::Trapezoid, - _ => Self::Rectangle, - } - } - - fn to_ppt_rs(self) -> ShapeType { - match self { - Self::Rectangle => ShapeType::Rectangle, - Self::RoundedRectangle => ShapeType::RoundedRectangle, - Self::Ellipse => ShapeType::Ellipse, - Self::Triangle => ShapeType::Triangle, - Self::RightTriangle => ShapeType::RightTriangle, - Self::Diamond => ShapeType::Diamond, - Self::Pentagon => ShapeType::Pentagon, - Self::Hexagon => ShapeType::Hexagon, - Self::Octagon => ShapeType::Octagon, - Self::Star4 => ShapeType::Star4, - Self::Star5 => ShapeType::Star5, - Self::Star6 => ShapeType::Star6, - Self::Star8 => ShapeType::Star8, - Self::RightArrow => ShapeType::RightArrow, - Self::LeftArrow => ShapeType::LeftArrow, - Self::UpArrow => ShapeType::UpArrow, - Self::DownArrow => ShapeType::DownArrow, - Self::LeftRightArrow => ShapeType::LeftRightArrow, - Self::UpDownArrow => ShapeType::UpDownArrow, - Self::Chevron => ShapeType::ChevronArrow, - Self::Heart => ShapeType::Heart, - Self::Cloud => ShapeType::Cloud, - Self::Wave => ShapeType::Wave, - Self::FlowChartProcess => ShapeType::FlowChartProcess, - Self::FlowChartDecision => ShapeType::FlowChartDecision, - Self::FlowChartConnector => ShapeType::FlowChartConnector, - Self::Parallelogram => ShapeType::Parallelogram, - Self::Trapezoid => ShapeType::Trapezoid, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ChartTypeSpec { - Bar, - BarHorizontal, - BarStacked, - BarStacked100, - Line, - LineMarkers, - LineStacked, - Pie, - Doughnut, - Area, - AreaStacked, - AreaStacked100, - Scatter, - ScatterLines, - ScatterSmooth, - Bubble, - Radar, - RadarFilled, - StockHlc, - StockOhlc, - Combo, -} - -impl ChartTypeSpec { - fn to_ppt_rs(self) -> ChartType { - match self { - Self::Bar => ChartType::Bar, - Self::BarHorizontal => ChartType::BarHorizontal, - Self::BarStacked => ChartType::BarStacked, - Self::BarStacked100 => ChartType::BarStacked100, - Self::Line => ChartType::Line, - Self::LineMarkers => ChartType::LineMarkers, - Self::LineStacked => ChartType::LineStacked, - Self::Pie => ChartType::Pie, - Self::Doughnut => ChartType::Doughnut, - Self::Area => ChartType::Area, - Self::AreaStacked => ChartType::AreaStacked, - Self::AreaStacked100 => ChartType::AreaStacked100, - Self::Scatter => ChartType::Scatter, - Self::ScatterLines => ChartType::ScatterLines, - Self::ScatterSmooth => ChartType::ScatterSmooth, - Self::Bubble => ChartType::Bubble, - Self::Radar => ChartType::Radar, - Self::RadarFilled => ChartType::RadarFilled, - Self::StockHlc => ChartType::StockHLC, - Self::StockOhlc => ChartType::StockOHLC, - Self::Combo => ChartType::Combo, - } - } -} - -impl ConnectorKind { - fn to_ppt_rs(self) -> ConnectorType { - match self { - Self::Straight => ConnectorType::Straight, - Self::Elbow => ConnectorType::Elbow, - Self::Curved => ConnectorType::Curved, - } - } -} - -impl ConnectorArrowKind { - fn to_ppt_rs(self) -> ArrowType { - match self { - Self::None => ArrowType::None, - Self::Triangle => ArrowType::Triangle, - Self::Stealth => ArrowType::Stealth, - Self::Diamond => ArrowType::Diamond, - Self::Oval => ArrowType::Oval, - Self::Open => ArrowType::Open, - } - } -} - -impl ConnectorArrowScale { - fn to_ppt_rs(self) -> ArrowSize { - match self { - Self::Small => ArrowSize::Small, - Self::Medium => ArrowSize::Medium, - Self::Large => ArrowSize::Large, - } - } -} - -impl LineStyle { - fn to_ppt_rs(self) -> LineDash { - match self { - Self::Solid => LineDash::Solid, - Self::Dashed => LineDash::Dash, - Self::Dotted => LineDash::Dot, - Self::DashDot => LineDash::DashDot, - Self::DashDotDot => LineDash::DashDotDot, - Self::LongDash => LineDash::LongDash, - Self::LongDashDot => LineDash::LongDashDot, - } - } -} - -#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub(crate) enum ImageFitMode { - Stretch, - Contain, - Cover, -} - -#[derive(Debug, Clone)] -struct StrokeStyle { - color: String, - width: u32, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ConnectorKind { - Straight, - Elbow, - Curved, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ConnectorArrowKind { - None, - Triangle, - Stealth, - Diamond, - Oval, - Open, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ConnectorArrowScale { - Small, - Medium, - Large, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum LineStyle { - Solid, - Dashed, - Dotted, - DashDot, - DashDotDot, - LongDash, - LongDashDot, -} - -#[derive(Debug, Clone, Copy)] -pub(crate) struct Rect { - pub(crate) left: u32, - pub(crate) top: u32, - pub(crate) width: u32, - pub(crate) height: u32, -} - -impl Rect { - fn from_emu(left: u32, top: u32, width: u32, height: u32) -> Self { - Self { - left: emu_to_points(left), - top: emu_to_points(top), - width: emu_to_points(width), - height: emu_to_points(height), - } - } -} - -impl From for Rect { - fn from(value: PositionArgs) -> Self { - Self { - left: value.left, - top: value.top, - width: value.width, - height: value.height, - } - } -} - -fn apply_partial_position(rect: Rect, position: PartialPositionArgs) -> Rect { - Rect { - left: position.left.unwrap_or(rect.left), - top: position.top.unwrap_or(rect.top), - width: position.width.unwrap_or(rect.width), - height: position.height.unwrap_or(rect.height), - } -} - -fn apply_partial_position_to_image(image: &ImageElement, position: PartialPositionArgs) -> Rect { - let mut frame = apply_partial_position(image.frame, position.clone()); - if image.lock_aspect_ratio { - let base_ratio = image - .payload - .as_ref() - .map(|payload| payload.width_px as f64 / payload.height_px as f64) - .unwrap_or_else(|| image.frame.width as f64 / image.frame.height as f64); - if let Some(width) = position.width - && position.height.is_none() - { - frame.height = (width as f64 / base_ratio).round() as u32; - } else if let Some(height) = position.height - && position.width.is_none() - { - frame.width = (height as f64 * base_ratio).round() as u32; - } - } - frame -} - -#[derive(Debug, Deserialize)] -struct CreateArgs { - name: Option, - slide_size: Option, - theme: Option, -} - -#[derive(Debug, Deserialize)] -struct ImportPptxArgs { - path: PathBuf, -} - -#[derive(Debug, Deserialize)] -struct ExportPptxArgs { - path: PathBuf, -} - -#[derive(Debug, Deserialize)] -struct ExportPreviewArgs { - path: PathBuf, - slide_index: Option, - format: Option, - scale: Option, - quality: Option, -} - -#[derive(Debug, Default, Deserialize)] -struct AddSlideArgs { - layout: Option, - notes: Option, - background_fill: Option, -} - -#[derive(Debug, Deserialize)] -struct CreateLayoutArgs { - name: String, - kind: Option, - parent_layout_id: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum PreviewOutputFormat { - Png, - Jpeg, -} - -impl PreviewOutputFormat { - fn extension(self) -> &'static str { - match self { - Self::Png => "png", - Self::Jpeg => "jpg", - } - } -} - -#[derive(Debug, Deserialize)] -struct AddLayoutPlaceholderArgs { - layout_id: String, - name: String, - placeholder_type: String, - index: Option, - text: Option, - geometry: Option, - position: Option, -} - -#[derive(Debug, Deserialize)] -struct LayoutIdArgs { - layout_id: String, -} - -#[derive(Debug, Deserialize)] -struct SetSlideLayoutArgs { - slide_index: u32, - layout_id: String, -} - -#[derive(Debug, Deserialize)] -struct UpdatePlaceholderTextArgs { - slide_index: u32, - name: String, - text: String, -} - -#[derive(Debug, Deserialize)] -struct NotesArgs { - slide_index: u32, - text: Option, -} - -#[derive(Debug, Deserialize)] -struct NotesVisibilityArgs { - slide_index: u32, - visible: bool, -} - -#[derive(Debug, Deserialize)] -struct ThemeArgs { - color_scheme: HashMap, - major_font: Option, - minor_font: Option, -} - -#[derive(Debug, Deserialize)] -struct InspectArgs { - kind: Option, - target_id: Option, - max_chars: Option, -} - -#[derive(Debug, Deserialize)] -struct ResolveArgs { - id: String, -} - -#[derive(Debug, Default, Deserialize)] -struct InsertSlideArgs { - index: Option, - after_slide_index: Option, - layout: Option, - notes: Option, - background_fill: Option, -} - -#[derive(Debug, Deserialize)] -struct SlideIndexArgs { - slide_index: u32, -} - -#[derive(Debug, Deserialize)] -struct MoveSlideArgs { - from_index: u32, - to_index: u32, -} - -#[derive(Debug, Deserialize)] -struct SetActiveSlideArgs { - slide_index: u32, -} - -#[derive(Debug, Deserialize)] -struct SetSlideBackgroundArgs { - slide_index: u32, - fill: String, -} - -#[derive(Debug, Clone, Deserialize)] -struct PositionArgs { - left: u32, - top: u32, - width: u32, - height: u32, -} - -#[derive(Debug, Clone, Default, Deserialize)] -struct PartialPositionArgs { - left: Option, - top: Option, - width: Option, - height: Option, -} - -#[derive(Debug, Default, Deserialize)] -struct TextStylingArgs { - font_size: Option, - font_family: Option, - color: Option, - fill: Option, - alignment: Option, - bold: Option, - italic: Option, -} - -#[derive(Debug, Deserialize)] -struct AddTextShapeArgs { - slide_index: u32, - text: String, - position: PositionArgs, - #[serde(flatten)] - styling: TextStylingArgs, -} - -#[derive(Debug, Clone, Default, Deserialize)] -struct StrokeArgs { - color: String, - width: u32, -} - -#[derive(Debug, Deserialize)] -struct AddShapeArgs { - slide_index: u32, - geometry: String, - position: PositionArgs, - fill: Option, - stroke: Option, - text: Option, - #[serde(default)] - text_style: TextStylingArgs, -} - -#[derive(Debug, Clone, Default, Deserialize)] -struct ConnectorLineArgs { - color: Option, - width: Option, - style: Option, -} - -#[derive(Debug, Clone, Deserialize)] -struct PointArgs { - left: u32, - top: u32, -} - -#[derive(Debug, Deserialize)] -struct AddConnectorArgs { - slide_index: u32, - connector_type: String, - start: PointArgs, - end: PointArgs, - line: Option, - start_arrow: Option, - end_arrow: Option, - arrow_size: Option, - label: Option, -} - -#[derive(Debug, Deserialize)] -struct AddImageArgs { - slide_index: u32, - path: Option, - data_url: Option, - uri: Option, - position: PositionArgs, - fit: Option, - crop: Option, - rotation: Option, - flip_horizontal: Option, - flip_vertical: Option, - lock_aspect_ratio: Option, - alt: Option, - prompt: Option, -} - -impl AddImageArgs { - fn image_source(&self) -> Result { - match (&self.path, &self.data_url, &self.uri) { - (Some(path), None, None) => Ok(ImageInputSource::Path(path.clone())), - (None, Some(data_url), None) => Ok(ImageInputSource::DataUrl(data_url.clone())), - (None, None, Some(uri)) => Ok(ImageInputSource::Uri(uri.clone())), - (None, None, None) if self.prompt.is_some() => Ok(ImageInputSource::Placeholder), - _ => Err(PresentationArtifactError::InvalidArgs { - action: "add_image".to_string(), - message: - "provide exactly one of `path`, `data_url`, or `uri`, or provide `prompt` for a placeholder image" - .to_string(), - }), - } - } -} - -enum ImageInputSource { - Path(PathBuf), - DataUrl(String), - Uri(String), - Placeholder, -} - -#[derive(Debug, Clone, Deserialize)] -struct ImageCropArgs { - left: f64, - top: f64, - right: f64, - bottom: f64, -} - -#[derive(Debug, Deserialize)] -struct AddTableArgs { - slide_index: u32, - position: PositionArgs, - rows: Vec>, - column_widths: Option>, - row_heights: Option>, - style: Option, -} - -#[derive(Debug, Deserialize)] -struct AddChartArgs { - slide_index: u32, - position: PositionArgs, - chart_type: String, - categories: Vec, - series: Vec, - title: Option, -} - -#[derive(Debug, Deserialize)] -struct ChartSeriesArgs { - name: String, - values: Vec, -} - -#[derive(Debug, Deserialize)] -struct UpdateTextArgs { - element_id: String, - text: String, - #[serde(default)] - styling: TextStylingArgs, -} - -#[derive(Debug, Deserialize)] -struct ReplaceTextArgs { - element_id: String, - search: String, - replace: String, -} - -#[derive(Debug, Deserialize)] -struct InsertTextAfterArgs { - element_id: String, - after: String, - insert: String, -} - -#[derive(Debug, Deserialize)] -struct SetHyperlinkArgs { - element_id: String, - link_type: Option, - url: Option, - slide_index: Option, - address: Option, - subject: Option, - path: Option, - tooltip: Option, - highlight_click: Option, - clear: Option, -} - -#[derive(Debug, Deserialize)] -struct UpdateShapeStyleArgs { - element_id: String, - position: Option, - fill: Option, - stroke: Option, - rotation: Option, - flip_horizontal: Option, - flip_vertical: Option, - fit: Option, - crop: Option, - lock_aspect_ratio: Option, - z_order: Option, -} - -#[derive(Debug, Deserialize)] -struct ElementIdArgs { - element_id: String, -} - -#[derive(Debug, Deserialize)] -struct ReplaceImageArgs { - element_id: String, - path: Option, - data_url: Option, - uri: Option, - fit: Option, - crop: Option, - rotation: Option, - flip_horizontal: Option, - flip_vertical: Option, - lock_aspect_ratio: Option, - alt: Option, - prompt: Option, -} - -#[derive(Debug, Deserialize)] -struct UpdateTableCellArgs { - element_id: String, - row: u32, - column: u32, - value: Value, - #[serde(default)] - styling: TextStylingArgs, - background_fill: Option, - alignment: Option, -} - -#[derive(Debug, Deserialize)] -struct MergeTableCellsArgs { - element_id: String, - start_row: u32, - end_row: u32, - start_column: u32, - end_column: u32, -} - -fn parse_args(action: &str, value: &Value) -> Result -where - T: for<'de> Deserialize<'de>, -{ - serde_json::from_value(value.clone()).map_err(|error| PresentationArtifactError::InvalidArgs { - action: action.to_string(), - message: error.to_string(), - }) -} - -fn required_artifact_id( - request: &PresentationArtifactRequest, -) -> Result { - request - .artifact_id - .clone() - .ok_or_else(|| PresentationArtifactError::MissingArtifactId { - action: request.action.clone(), - }) -} - -fn resolve_path(cwd: &Path, path: &Path) -> PathBuf { - if path.is_absolute() { - path.to_path_buf() - } else { - cwd.join(path) - } -} - -fn normalize_color( - color: &str, - action: &str, - field: &str, -) -> Result { - normalize_color_with_palette(None, color, action, field) -} - -fn normalize_color_with_document( - document: &PresentationDocument, - color: &str, - action: &str, - field: &str, -) -> Result { - normalize_color_with_palette(Some(&document.theme), color, action, field) -} - -fn normalize_color_with_palette( - theme: Option<&ThemeState>, - color: &str, - action: &str, - field: &str, -) -> Result { - let trimmed = color.trim(); - let normalized = theme - .and_then(|palette| palette.resolve_color(trimmed)) - .unwrap_or_else(|| trimmed.trim_start_matches('#').to_uppercase()); - if normalized.len() != 6 - || !normalized - .chars() - .all(|character| character.is_ascii_hexdigit()) - { - return Err(PresentationArtifactError::InvalidArgs { - action: action.to_string(), - message: format!("field `{field}` must be a 6-digit RGB hex color"), - }); - } - Ok(normalized) -} - -fn parse_shape_geometry( - geometry: &str, - action: &str, -) -> Result { - match geometry { - "rectangle" | "rect" => Ok(ShapeGeometry::Rectangle), - "rounded_rectangle" | "roundedRect" => Ok(ShapeGeometry::RoundedRectangle), - "ellipse" | "circle" => Ok(ShapeGeometry::Ellipse), - "triangle" => Ok(ShapeGeometry::Triangle), - "right_triangle" => Ok(ShapeGeometry::RightTriangle), - "diamond" => Ok(ShapeGeometry::Diamond), - "pentagon" => Ok(ShapeGeometry::Pentagon), - "hexagon" => Ok(ShapeGeometry::Hexagon), - "octagon" => Ok(ShapeGeometry::Octagon), - "star4" => Ok(ShapeGeometry::Star4), - "star" | "star5" => Ok(ShapeGeometry::Star5), - "star6" => Ok(ShapeGeometry::Star6), - "star8" => Ok(ShapeGeometry::Star8), - "right_arrow" => Ok(ShapeGeometry::RightArrow), - "left_arrow" => Ok(ShapeGeometry::LeftArrow), - "up_arrow" => Ok(ShapeGeometry::UpArrow), - "down_arrow" => Ok(ShapeGeometry::DownArrow), - "left_right_arrow" | "leftRightArrow" => Ok(ShapeGeometry::LeftRightArrow), - "up_down_arrow" | "upDownArrow" => Ok(ShapeGeometry::UpDownArrow), - "chevron" => Ok(ShapeGeometry::Chevron), - "heart" => Ok(ShapeGeometry::Heart), - "cloud" => Ok(ShapeGeometry::Cloud), - "wave" => Ok(ShapeGeometry::Wave), - "flowChartProcess" | "flow_chart_process" => Ok(ShapeGeometry::FlowChartProcess), - "flowChartDecision" | "flow_chart_decision" => Ok(ShapeGeometry::FlowChartDecision), - "flowChartConnector" | "flow_chart_connector" => Ok(ShapeGeometry::FlowChartConnector), - "parallelogram" => Ok(ShapeGeometry::Parallelogram), - "trapezoid" => Ok(ShapeGeometry::Trapezoid), - _ => Err(PresentationArtifactError::UnsupportedFeature { - action: action.to_string(), - message: format!("geometry `{geometry}` is not supported"), - }), - } -} - -fn parse_chart_type( - chart_type: &str, - action: &str, -) -> Result { - match chart_type { - "bar" => Ok(ChartTypeSpec::Bar), - "bar_horizontal" => Ok(ChartTypeSpec::BarHorizontal), - "bar_stacked" => Ok(ChartTypeSpec::BarStacked), - "bar_stacked_100" => Ok(ChartTypeSpec::BarStacked100), - "line" => Ok(ChartTypeSpec::Line), - "line_markers" => Ok(ChartTypeSpec::LineMarkers), - "line_stacked" => Ok(ChartTypeSpec::LineStacked), - "pie" => Ok(ChartTypeSpec::Pie), - "doughnut" => Ok(ChartTypeSpec::Doughnut), - "area" => Ok(ChartTypeSpec::Area), - "area_stacked" => Ok(ChartTypeSpec::AreaStacked), - "area_stacked_100" => Ok(ChartTypeSpec::AreaStacked100), - "scatter" => Ok(ChartTypeSpec::Scatter), - "scatter_lines" => Ok(ChartTypeSpec::ScatterLines), - "scatter_smooth" => Ok(ChartTypeSpec::ScatterSmooth), - "bubble" => Ok(ChartTypeSpec::Bubble), - "radar" => Ok(ChartTypeSpec::Radar), - "radar_filled" => Ok(ChartTypeSpec::RadarFilled), - "stock_hlc" => Ok(ChartTypeSpec::StockHlc), - "stock_ohlc" => Ok(ChartTypeSpec::StockOhlc), - "combo" => Ok(ChartTypeSpec::Combo), - _ => Err(PresentationArtifactError::UnsupportedFeature { - action: action.to_string(), - message: format!("chart_type `{chart_type}` is not supported"), - }), - } -} - -fn parse_stroke( - document: &PresentationDocument, - stroke: Option, - action: &str, -) -> Result, PresentationArtifactError> { - stroke - .map(|value| parse_required_stroke(document, value, action)) - .transpose() -} - -fn parse_required_stroke( - document: &PresentationDocument, - stroke: StrokeArgs, - action: &str, -) -> Result { - Ok(StrokeStyle { - color: normalize_color_with_document(document, &stroke.color, action, "stroke.color")?, - width: stroke.width, - }) -} - -fn parse_connector_kind( - connector_type: &str, - action: &str, -) -> Result { - match connector_type { - "straight" => Ok(ConnectorKind::Straight), - "elbow" => Ok(ConnectorKind::Elbow), - "curved" => Ok(ConnectorKind::Curved), - _ => Err(PresentationArtifactError::UnsupportedFeature { - action: action.to_string(), - message: format!("connector_type `{connector_type}` is not supported"), - }), - } -} - -fn parse_connector_arrow( - value: &str, - action: &str, -) -> Result { - match value { - "none" => Ok(ConnectorArrowKind::None), - "triangle" => Ok(ConnectorArrowKind::Triangle), - "stealth" => Ok(ConnectorArrowKind::Stealth), - "diamond" => Ok(ConnectorArrowKind::Diamond), - "oval" => Ok(ConnectorArrowKind::Oval), - "open" => Ok(ConnectorArrowKind::Open), - _ => Err(PresentationArtifactError::UnsupportedFeature { - action: action.to_string(), - message: format!("connector arrow `{value}` is not supported"), - }), - } -} - -fn parse_connector_arrow_size( - value: &str, - action: &str, -) -> Result { - match value { - "small" => Ok(ConnectorArrowScale::Small), - "medium" => Ok(ConnectorArrowScale::Medium), - "large" => Ok(ConnectorArrowScale::Large), - _ => Err(PresentationArtifactError::UnsupportedFeature { - action: action.to_string(), - message: format!("connector arrow_size `{value}` is not supported"), - }), - } -} - -fn parse_line_style(value: &str, action: &str) -> Result { - match value { - "solid" => Ok(LineStyle::Solid), - "dashed" => Ok(LineStyle::Dashed), - "dotted" => Ok(LineStyle::Dotted), - "dash-dot" | "dash_dot" => Ok(LineStyle::DashDot), - "dash-dot-dot" | "dash_dot_dot" => Ok(LineStyle::DashDotDot), - "long-dash" | "long_dash" => Ok(LineStyle::LongDash), - "long-dash-dot" | "long_dash_dot" => Ok(LineStyle::LongDashDot), - _ => Err(PresentationArtifactError::UnsupportedFeature { - action: action.to_string(), - message: format!("line style `{value}` is not supported"), - }), - } -} - -fn parse_connector_line( - document: &PresentationDocument, - line: Option, - action: &str, -) -> Result { - let line = line.unwrap_or_default(); - Ok(ParsedConnectorLine { - color: line - .color - .as_deref() - .map(|value| normalize_color_with_document(document, value, action, "line.color")) - .transpose()? - .unwrap_or_else(|| "000000".to_string()), - width: line.width.unwrap_or(1), - style: line - .style - .as_deref() - .map(|value| parse_line_style(value, action)) - .transpose()? - .unwrap_or(LineStyle::Solid), - }) -} - -struct ParsedConnectorLine { - color: String, - width: u32, - style: LineStyle, -} - -fn normalize_text_style_with_document( - document: &PresentationDocument, - styling: &TextStylingArgs, - action: &str, -) -> Result { - normalize_text_style_with_palette(Some(&document.theme), styling, action) -} - -fn normalize_text_style_with_palette( - theme: Option<&ThemeState>, - styling: &TextStylingArgs, - action: &str, -) -> Result { - Ok(TextStyle { - font_size: styling.font_size, - font_family: styling.font_family.clone(), - color: styling - .color - .as_deref() - .map(|value| normalize_color_with_palette(theme, value, action, "color")) - .transpose()?, - alignment: styling - .alignment - .as_deref() - .map(|value| parse_alignment(value, action)) - .transpose()?, - bold: styling.bold.unwrap_or(false), - italic: styling.italic.unwrap_or(false), - underline: false, - }) -} - -fn parse_hyperlink_state( - document: &PresentationDocument, - args: &SetHyperlinkArgs, - action: &str, -) -> Result { - let link_type = - args.link_type - .as_deref() - .ok_or_else(|| PresentationArtifactError::InvalidArgs { - action: action.to_string(), - message: "`link_type` is required unless `clear` is true".to_string(), - })?; - let target = match link_type { - "url" => HyperlinkTarget::Url(required_hyperlink_field(&args.url, action, "url")?.clone()), - "slide" => { - let slide_index = - args.slide_index - .ok_or_else(|| PresentationArtifactError::InvalidArgs { - action: action.to_string(), - message: "`slide_index` is required for slide hyperlinks".to_string(), - })?; - if slide_index as usize >= document.slides.len() { - return Err(index_out_of_range( - action, - slide_index as usize, - document.slides.len(), - )); - } - HyperlinkTarget::Slide(slide_index) - } - "first_slide" => HyperlinkTarget::FirstSlide, - "last_slide" => HyperlinkTarget::LastSlide, - "next_slide" => HyperlinkTarget::NextSlide, - "previous_slide" => HyperlinkTarget::PreviousSlide, - "end_show" => HyperlinkTarget::EndShow, - "email" => HyperlinkTarget::Email { - address: required_hyperlink_field(&args.address, action, "address")?.clone(), - subject: args.subject.clone(), - }, - "file" => { - HyperlinkTarget::File(required_hyperlink_field(&args.path, action, "path")?.clone()) - } - other => { - return Err(PresentationArtifactError::UnsupportedFeature { - action: action.to_string(), - message: format!("hyperlink type `{other}` is not supported"), - }); - } - }; - Ok(HyperlinkState { - target, - tooltip: args.tooltip.clone(), - highlight_click: args.highlight_click.unwrap_or(true), - }) -} - -fn required_hyperlink_field<'a>( - value: &'a Option, - action: &str, - field: &str, -) -> Result<&'a String, PresentationArtifactError> { - value - .as_ref() - .filter(|value| !value.trim().is_empty()) - .ok_or_else(|| PresentationArtifactError::InvalidArgs { - action: action.to_string(), - message: format!("`{field}` is required for this hyperlink type"), - }) -} - -fn coerce_table_rows( - rows: Vec>, - action: &str, -) -> Result>, PresentationArtifactError> { - if rows.is_empty() { - return Err(PresentationArtifactError::InvalidArgs { - action: action.to_string(), - message: "`rows` must contain at least one row".to_string(), - }); - } - Ok(rows - .into_iter() - .map(|row| { - row.into_iter() - .map(|value| TableCellSpec { - text: cell_value_to_string(value), - text_style: TextStyle::default(), - background_fill: None, - alignment: None, - }) - .collect() - }) - .collect()) -} - -fn normalize_table_dimensions( - rows: &[Vec], - frame: Rect, - column_widths: Option>, - row_heights: Option>, - action: &str, -) -> Result<(Vec, Vec), PresentationArtifactError> { - let column_count = rows.iter().map(std::vec::Vec::len).max().unwrap_or(1); - let normalized_column_widths = match column_widths { - Some(widths) => { - if widths.len() != column_count { - return Err(PresentationArtifactError::InvalidArgs { - action: action.to_string(), - message: format!( - "`column_widths` must contain {column_count} entries for this table" - ), - }); - } - widths - } - None => split_points(frame.width, column_count), - }; - let normalized_row_heights = match row_heights { - Some(heights) => { - if heights.len() != rows.len() { - return Err(PresentationArtifactError::InvalidArgs { - action: action.to_string(), - message: format!( - "`row_heights` must contain {} entries for this table", - rows.len() - ), - }); - } - heights - } - None => split_points(frame.height, rows.len()), - }; - Ok((normalized_column_widths, normalized_row_heights)) -} - -fn split_points(total: u32, count: usize) -> Vec { - if count == 0 { - return Vec::new(); - } - let base = total / count as u32; - let remainder = total % count as u32; - (0..count) - .map(|index| base + u32::from(index < remainder as usize)) - .collect() -} - -fn parse_alignment(value: &str, action: &str) -> Result { - match value { - "left" => Ok(TextAlignment::Left), - "center" | "middle" => Ok(TextAlignment::Center), - "right" => Ok(TextAlignment::Right), - "justify" => Ok(TextAlignment::Justify), - _ => Err(PresentationArtifactError::InvalidArgs { - action: action.to_string(), - message: format!("unsupported alignment `{value}`"), - }), - } -} - -fn normalize_theme(args: ThemeArgs, action: &str) -> Result { - let color_scheme = args - .color_scheme - .into_iter() - .map(|(key, value)| { - normalize_color(&value, action, &key) - .map(|normalized| (key.to_ascii_lowercase(), normalized)) - }) - .collect::, _>>()?; - Ok(ThemeState { - color_scheme, - major_font: args.major_font, - minor_font: args.minor_font, - }) -} - -fn parse_slide_size(value: &Value, action: &str) -> Result { - #[derive(Deserialize)] - struct SlideSizeArgs { - width: u32, - height: u32, - } - - let slide_size: SlideSizeArgs = serde_json::from_value(value.clone()).map_err(|error| { - PresentationArtifactError::InvalidArgs { - action: action.to_string(), - message: format!("invalid slide_size: {error}"), - } - })?; - Ok(Rect { - left: 0, - top: 0, - width: slide_size.width, - height: slide_size.height, - }) -} - -fn apply_layout_to_slide( - document: &mut PresentationDocument, - slide: &mut PresentationSlide, - layout_id: &str, - action: &str, -) -> Result<(), PresentationArtifactError> { - let placeholders = resolved_layout_placeholders(document, layout_id, action)?; - slide.layout_id = Some(layout_id.to_string()); - for resolved in placeholders { - let placeholder = resolved.definition; - let placeholder_ref = Some(PlaceholderRef { - name: placeholder.name, - placeholder_type: placeholder.placeholder_type, - index: placeholder.index, - }); - let element_id = document.next_element_id(); - if placeholder.geometry == ShapeGeometry::Rectangle { - slide.elements.push(PresentationElement::Text(TextElement { - element_id, - text: placeholder.text.unwrap_or_default(), - frame: placeholder.frame, - fill: None, - style: TextStyle::default(), - hyperlink: None, - placeholder: placeholder_ref, - z_order: slide.elements.len(), - })); - } else { - slide - .elements - .push(PresentationElement::Shape(ShapeElement { - element_id, - geometry: placeholder.geometry, - frame: placeholder.frame, - fill: None, - stroke: None, - text: placeholder.text, - text_style: TextStyle::default(), - hyperlink: None, - placeholder: placeholder_ref, - rotation_degrees: None, - z_order: slide.elements.len(), - })); - } - } - Ok(()) -} - -fn resolved_layout_placeholders( - document: &PresentationDocument, - layout_id: &str, - action: &str, -) -> Result, PresentationArtifactError> { - let mut lineage = Vec::new(); - collect_layout_lineage( - document, - layout_id, - action, - &mut HashSet::new(), - &mut lineage, - )?; - let mut resolved: Vec = Vec::new(); - for layout in lineage { - for placeholder in &layout.placeholders { - if let Some(index) = resolved.iter().position(|entry| { - placeholder_key(&entry.definition) == placeholder_key(placeholder) - }) { - resolved[index] = ResolvedPlaceholder { - source_layout_id: layout.layout_id.clone(), - definition: placeholder.clone(), - }; - } else { - resolved.push(ResolvedPlaceholder { - source_layout_id: layout.layout_id.clone(), - definition: placeholder.clone(), - }); - } - } - } - Ok(resolved) -} - -fn collect_layout_lineage<'a>( - document: &'a PresentationDocument, - layout_id: &str, - action: &str, - seen: &mut HashSet, - lineage: &mut Vec<&'a LayoutDocument>, -) -> Result<(), PresentationArtifactError> { - if !seen.insert(layout_id.to_string()) { - return Err(PresentationArtifactError::UnsupportedFeature { - action: action.to_string(), - message: format!("layout inheritance cycle detected at `{layout_id}`"), - }); - } - let layout = document.get_layout(layout_id, action)?; - if let Some(parent_layout_id) = &layout.parent_layout_id { - collect_layout_lineage(document, parent_layout_id, action, seen, lineage)?; - } - lineage.push(layout); - Ok(()) -} - -fn placeholder_key(placeholder: &PlaceholderDefinition) -> (String, String, Option) { - ( - placeholder.name.to_ascii_lowercase(), - placeholder.placeholder_type.to_ascii_lowercase(), - placeholder.index, - ) -} - -fn layout_placeholder_list( - document: &PresentationDocument, - layout_id: &str, - action: &str, -) -> Result, PresentationArtifactError> { - resolved_layout_placeholders(document, layout_id, action).map(|placeholders| { - placeholders - .into_iter() - .map(|placeholder| PlaceholderListEntry { - scope: "layout".to_string(), - source_layout_id: Some(placeholder.source_layout_id), - slide_index: None, - element_id: None, - name: placeholder.definition.name, - placeholder_type: placeholder.definition.placeholder_type, - index: placeholder.definition.index, - geometry: Some(format!("{:?}", placeholder.definition.geometry)), - text_preview: placeholder.definition.text, - }) - .collect() - }) -} - -fn slide_placeholder_list( - slide: &PresentationSlide, - slide_index: usize, -) -> Vec { - slide - .elements - .iter() - .filter_map(|element| match element { - PresentationElement::Text(text) => { - text.placeholder - .as_ref() - .map(|placeholder| PlaceholderListEntry { - scope: "slide".to_string(), - source_layout_id: slide.layout_id.clone(), - slide_index: Some(slide_index), - element_id: Some(text.element_id.clone()), - name: placeholder.name.clone(), - placeholder_type: placeholder.placeholder_type.clone(), - index: placeholder.index, - geometry: Some("Rectangle".to_string()), - text_preview: Some(text.text.clone()), - }) - } - PresentationElement::Shape(shape) => { - shape - .placeholder - .as_ref() - .map(|placeholder| PlaceholderListEntry { - scope: "slide".to_string(), - source_layout_id: slide.layout_id.clone(), - slide_index: Some(slide_index), - element_id: Some(shape.element_id.clone()), - name: placeholder.name.clone(), - placeholder_type: placeholder.placeholder_type.clone(), - index: placeholder.index, - geometry: Some(format!("{:?}", shape.geometry)), - text_preview: shape.text.clone(), - }) - } - PresentationElement::Connector(_) - | PresentationElement::Image(_) - | PresentationElement::Table(_) - | PresentationElement::Chart(_) => None, - }) - .collect() -} - -fn build_table_cell( - cell: TableCellSpec, - merges: &[TableMergeRegion], - row_index: usize, - column_index: usize, -) -> TableCell { - let mut table_cell = TableCell::new(&cell.text); - if cell.text_style.bold { - table_cell = table_cell.bold(); - } - if cell.text_style.italic { - table_cell = table_cell.italic(); - } - if cell.text_style.underline { - table_cell = table_cell.underline(); - } - if let Some(color) = cell.text_style.color { - table_cell = table_cell.text_color(&color); - } - if let Some(fill) = cell.background_fill { - table_cell = table_cell.background_color(&fill); - } - if let Some(size) = cell.text_style.font_size { - table_cell = table_cell.font_size(size); - } - if let Some(font_family) = cell.text_style.font_family { - table_cell = table_cell.font_family(&font_family); - } - if let Some(alignment) = cell.alignment.or(cell.text_style.alignment) { - table_cell = match alignment { - TextAlignment::Left => table_cell.align_left(), - TextAlignment::Center => table_cell.align_center(), - TextAlignment::Right => table_cell.align_right(), - TextAlignment::Justify => table_cell.align(CellAlign::Justify), - }; - } - for merge in merges { - if row_index == merge.start_row && column_index == merge.start_column { - table_cell = table_cell - .grid_span((merge.end_column - merge.start_column + 1) as u32) - .row_span((merge.end_row - merge.start_row + 1) as u32); - } else if row_index >= merge.start_row - && row_index <= merge.end_row - && column_index >= merge.start_column - && column_index <= merge.end_column - { - if row_index == merge.start_row { - table_cell = table_cell.h_merge(); - } else { - table_cell = table_cell.v_merge(); - } - } - } - table_cell -} - -fn inspect_document( - document: &PresentationDocument, - kind: Option<&str>, - target_id: Option<&str>, - max_chars: Option, -) -> String { - let kinds = - kind.unwrap_or("deck,slide,textbox,shape,connector,table,chart,image,notes,layoutList"); - let include = |name: &str| kinds.split(',').map(str::trim).any(|entry| entry == name); - let mut lines = Vec::new(); - if include("deck") { - let record = serde_json::json!({ - "kind": "deck", - "id": format!("pr/{}", document.artifact_id), - "name": document.name, - "slides": document.slides.len(), - "activeSlideIndex": document.active_slide_index, - "activeSlideId": document.active_slide_index.and_then(|index| document.slides.get(index)).map(|slide| format!("sl/{}", slide.slide_id)), - }); - if target_matches(target_id, &record) { - lines.push(record); - } - } - if include("layoutList") { - for layout in &document.layouts { - let placeholders = resolved_layout_placeholders(document, &layout.layout_id, "inspect") - .unwrap_or_default() - .into_iter() - .map(|placeholder| { - serde_json::json!({ - "name": placeholder.definition.name, - "type": placeholder.definition.placeholder_type, - "sourceLayoutId": placeholder.source_layout_id, - "textPreview": placeholder.definition.text, - }) - }) - .collect::>(); - let record = serde_json::json!({ - "kind": "layout", - "id": format!("ly/{}", layout.layout_id), - "layoutId": layout.layout_id, - "name": layout.name, - "type": match layout.kind { LayoutKind::Layout => "layout", LayoutKind::Master => "master" }, - "parentLayoutId": layout.parent_layout_id, - "placeholders": placeholders, - }); - if target_matches(target_id, &record) { - lines.push(record); - } - } - } - for (index, slide) in document.slides.iter().enumerate() { - let slide_id = format!("sl/{}", slide.slide_id); - if include("slide") { - let record = serde_json::json!({ - "kind": "slide", - "id": slide_id, - "slide": index + 1, - "slideIndex": index, - "isActive": document.active_slide_index == Some(index), - "layoutId": slide.layout_id, - "elements": slide.elements.len(), - }); - if target_matches(target_id, &record) { - lines.push(record); - } - } - if include("notes") && !slide.notes.text.is_empty() { - let record = serde_json::json!({ - "kind": "notes", - "id": format!("nt/{}", slide.slide_id), - "slide": index + 1, - "visible": slide.notes.visible, - "text": slide.notes.text, - }); - if target_matches(target_id, &record) || target_id == Some(slide_id.as_str()) { - lines.push(record); - } - } - for element in &slide.elements { - let mut record = match element { - PresentationElement::Text(text) => { - if !include("textbox") { - continue; - } - serde_json::json!({ - "kind": "textbox", - "id": format!("sh/{}", text.element_id), - "slide": index + 1, - "text": text.text, - "textPreview": text.text.replace('\n', " | "), - "textChars": text.text.chars().count(), - "textLines": text.text.lines().count(), - "bbox": [text.frame.left, text.frame.top, text.frame.width, text.frame.height], - "bboxUnit": "points", - }) - } - PresentationElement::Shape(shape) => { - if !(include("shape") || include("textbox") && shape.text.is_some()) { - continue; - } - let kind = if shape.text.is_some() && include("textbox") { - "textbox" - } else { - "shape" - }; - serde_json::json!({ - "kind": kind, - "id": format!("sh/{}", shape.element_id), - "slide": index + 1, - "geometry": format!("{:?}", shape.geometry), - "text": shape.text, - "bbox": [shape.frame.left, shape.frame.top, shape.frame.width, shape.frame.height], - "bboxUnit": "points", - }) - } - PresentationElement::Connector(connector) => { - if !include("shape") && !include("connector") { - continue; - } - serde_json::json!({ - "kind": "connector", - "id": format!("cn/{}", connector.element_id), - "slide": index + 1, - "connectorType": format!("{:?}", connector.connector_type), - "start": [connector.start.left, connector.start.top], - "end": [connector.end.left, connector.end.top], - "lineStyle": format!("{:?}", connector.line_style), - "label": connector.label, - }) - } - PresentationElement::Table(table) => { - if !include("table") { - continue; - } - serde_json::json!({ - "kind": "table", - "id": format!("tb/{}", table.element_id), - "slide": index + 1, - "rows": table.rows.len(), - "cols": table.rows.iter().map(std::vec::Vec::len).max().unwrap_or(0), - "columnWidths": table.column_widths, - "rowHeights": table.row_heights, - "preview": table.rows.first().map(|row| row.iter().map(|cell| cell.text.clone()).collect::>().join(" | ")), - "style": table.style, - "bbox": [table.frame.left, table.frame.top, table.frame.width, table.frame.height], - "bboxUnit": "points", - }) - } - PresentationElement::Chart(chart) => { - if !include("chart") { - continue; - } - serde_json::json!({ - "kind": "chart", - "id": format!("ch/{}", chart.element_id), - "slide": index + 1, - "chartType": format!("{:?}", chart.chart_type), - "title": chart.title, - "bbox": [chart.frame.left, chart.frame.top, chart.frame.width, chart.frame.height], - "bboxUnit": "points", - }) - } - PresentationElement::Image(image) => { - if !include("image") { - continue; - } - serde_json::json!({ - "kind": "image", - "id": format!("im/{}", image.element_id), - "slide": index + 1, - "alt": image.alt_text, - "prompt": image.prompt, - "fit": format!("{:?}", image.fit_mode), - "rotation": image.rotation_degrees, - "flipHorizontal": image.flip_horizontal, - "flipVertical": image.flip_vertical, - "crop": image.crop.map(|(left, top, right, bottom)| serde_json::json!({ - "left": left, - "top": top, - "right": right, - "bottom": bottom, - })), - "isPlaceholder": image.is_placeholder, - "lockAspectRatio": image.lock_aspect_ratio, - "bbox": [image.frame.left, image.frame.top, image.frame.width, image.frame.height], - "bboxUnit": "points", - }) - } - }; - if !target_matches(target_id, &record) && target_id != Some(slide_id.as_str()) { - continue; - } - if let Some(placeholder) = match element { - PresentationElement::Text(text) => text.placeholder.as_ref(), - PresentationElement::Shape(shape) => shape.placeholder.as_ref(), - PresentationElement::Connector(_) - | PresentationElement::Image(_) - | PresentationElement::Table(_) - | PresentationElement::Chart(_) => None, - } { - record["placeholder"] = - serde_json::Value::String(placeholder.placeholder_type.clone()); - record["placeholderName"] = serde_json::Value::String(placeholder.name.clone()); - record["placeholderIndex"] = placeholder - .index - .map(serde_json::Value::from) - .unwrap_or(serde_json::Value::Null); - } - if let Some(hyperlink) = match element { - PresentationElement::Text(text) => text.hyperlink.as_ref(), - PresentationElement::Shape(shape) => shape.hyperlink.as_ref(), - PresentationElement::Connector(_) - | PresentationElement::Image(_) - | PresentationElement::Table(_) - | PresentationElement::Chart(_) => None, - } { - record["hyperlink"] = hyperlink.to_json(); - } - lines.push(record); - } - } - let mut ndjson = lines - .into_iter() - .map(|line| line.to_string()) - .collect::>() - .join("\n"); - if let Some(max_chars) = max_chars - && ndjson.len() > max_chars - { - let omitted_lines = ndjson[max_chars..].lines().count(); - ndjson.truncate(max_chars); - ndjson.push('\n'); - ndjson.push_str( - &serde_json::json!({ - "kind": "notice", - "message": format!( - "Truncated: omitted {omitted_lines} lines. Increase maxChars or narrow target." - ), - }) - .to_string(), - ); - } - ndjson -} - -fn target_matches(target_id: Option<&str>, record: &Value) -> bool { - match target_id { - None => true, - Some(target_id) => record.get("id").and_then(Value::as_str) == Some(target_id), - } -} - -fn normalize_element_lookup_id(element_id: &str) -> &str { - element_id - .split_once('/') - .map(|(_, normalized)| normalized) - .unwrap_or(element_id) -} - -fn resolve_anchor( - document: &PresentationDocument, - id: &str, - action: &str, -) -> Result { - if id == format!("pr/{}", document.artifact_id) { - return Ok(serde_json::json!({ - "kind": "deck", - "id": id, - "artifactId": document.artifact_id, - "name": document.name, - "slideCount": document.slides.len(), - "activeSlideIndex": document.active_slide_index, - "activeSlideId": document.active_slide_index.and_then(|index| document.slides.get(index)).map(|slide| format!("sl/{}", slide.slide_id)), - })); - } - - for (slide_index, slide) in document.slides.iter().enumerate() { - let slide_id = format!("sl/{}", slide.slide_id); - if id == slide_id { - return Ok(serde_json::json!({ - "kind": "slide", - "id": slide_id, - "slide": slide_index + 1, - "slideIndex": slide_index, - "isActive": document.active_slide_index == Some(slide_index), - "layoutId": slide.layout_id, - "notesId": (!slide.notes.text.is_empty()).then(|| format!("nt/{}", slide.slide_id)), - "elementIds": slide.elements.iter().map(|element| { - let prefix = match element { - PresentationElement::Text(_) | PresentationElement::Shape(_) => "sh", - PresentationElement::Connector(_) => "cn", - PresentationElement::Image(_) => "im", - PresentationElement::Table(_) => "tb", - PresentationElement::Chart(_) => "ch", - }; - format!("{prefix}/{}", element.element_id()) - }).collect::>(), - })); - } - let notes_id = format!("nt/{}", slide.slide_id); - if id == notes_id { - return Ok(serde_json::json!({ - "kind": "notes", - "id": notes_id, - "slide": slide_index + 1, - "slideIndex": slide_index, - "visible": slide.notes.visible, - "text": slide.notes.text, - })); - } - for element in &slide.elements { - let mut record = match element { - PresentationElement::Text(text) => serde_json::json!({ - "kind": "textbox", - "id": format!("sh/{}", text.element_id), - "elementId": text.element_id, - "slide": slide_index + 1, - "slideIndex": slide_index, - "text": text.text, - "bbox": [text.frame.left, text.frame.top, text.frame.width, text.frame.height], - "bboxUnit": "points", - }), - PresentationElement::Shape(shape) => serde_json::json!({ - "kind": if shape.text.is_some() { "textbox" } else { "shape" }, - "id": format!("sh/{}", shape.element_id), - "elementId": shape.element_id, - "slide": slide_index + 1, - "slideIndex": slide_index, - "geometry": format!("{:?}", shape.geometry), - "text": shape.text, - "bbox": [shape.frame.left, shape.frame.top, shape.frame.width, shape.frame.height], - "bboxUnit": "points", - }), - PresentationElement::Connector(connector) => serde_json::json!({ - "kind": "connector", - "id": format!("cn/{}", connector.element_id), - "elementId": connector.element_id, - "slide": slide_index + 1, - "slideIndex": slide_index, - "connectorType": format!("{:?}", connector.connector_type), - "start": [connector.start.left, connector.start.top], - "end": [connector.end.left, connector.end.top], - "lineStyle": format!("{:?}", connector.line_style), - "label": connector.label, - }), - PresentationElement::Image(image) => serde_json::json!({ - "kind": "image", - "id": format!("im/{}", image.element_id), - "elementId": image.element_id, - "slide": slide_index + 1, - "slideIndex": slide_index, - "alt": image.alt_text, - "prompt": image.prompt, - "fit": format!("{:?}", image.fit_mode), - "rotation": image.rotation_degrees, - "flipHorizontal": image.flip_horizontal, - "flipVertical": image.flip_vertical, - "crop": image.crop.map(|(left, top, right, bottom)| serde_json::json!({ - "left": left, - "top": top, - "right": right, - "bottom": bottom, - })), - "isPlaceholder": image.is_placeholder, - "lockAspectRatio": image.lock_aspect_ratio, - "bbox": [image.frame.left, image.frame.top, image.frame.width, image.frame.height], - "bboxUnit": "points", - }), - PresentationElement::Table(table) => serde_json::json!({ - "kind": "table", - "id": format!("tb/{}", table.element_id), - "elementId": table.element_id, - "slide": slide_index + 1, - "slideIndex": slide_index, - "rows": table.rows.len(), - "cols": table.rows.iter().map(std::vec::Vec::len).max().unwrap_or(0), - "columnWidths": table.column_widths, - "rowHeights": table.row_heights, - "bbox": [table.frame.left, table.frame.top, table.frame.width, table.frame.height], - "bboxUnit": "points", - }), - PresentationElement::Chart(chart) => serde_json::json!({ - "kind": "chart", - "id": format!("ch/{}", chart.element_id), - "elementId": chart.element_id, - "slide": slide_index + 1, - "slideIndex": slide_index, - "chartType": format!("{:?}", chart.chart_type), - "title": chart.title, - "bbox": [chart.frame.left, chart.frame.top, chart.frame.width, chart.frame.height], - "bboxUnit": "points", - }), - }; - if let Some(hyperlink) = match element { - PresentationElement::Text(text) => text.hyperlink.as_ref(), - PresentationElement::Shape(shape) => shape.hyperlink.as_ref(), - PresentationElement::Connector(_) - | PresentationElement::Image(_) - | PresentationElement::Table(_) - | PresentationElement::Chart(_) => None, - } { - record["hyperlink"] = hyperlink.to_json(); - } - if record.get("id").and_then(Value::as_str) == Some(id) { - return Ok(record); - } - } - } - - for layout in &document.layouts { - let layout_id = format!("ly/{}", layout.layout_id); - if id == layout_id { - return Ok(serde_json::json!({ - "kind": "layout", - "id": layout_id, - "layoutId": layout.layout_id, - "name": layout.name, - "type": match layout.kind { - LayoutKind::Layout => "layout", - LayoutKind::Master => "master", - }, - "parentLayoutId": layout.parent_layout_id, - "placeholders": layout_placeholder_list(document, &layout.layout_id, action)?, - })); - } - } - - Err(PresentationArtifactError::UnsupportedFeature { - action: action.to_string(), - message: format!("unknown resolve id `{id}`"), - }) -} - -fn build_pptx_bytes(document: &PresentationDocument, action: &str) -> Result, String> { - let bytes = document - .to_ppt_rs() - .build() - .map_err(|error| format!("{action}: {error}"))?; - patch_pptx_package(bytes, document).map_err(|error| format!("{action}: {error}")) -} - -struct SlideImageAsset { - xml: String, - relationship_xml: String, - media_path: String, - media_bytes: Vec, - extension: String, -} - -fn normalized_image_extension(format: &str) -> String { - match format.to_ascii_lowercase().as_str() { - "jpeg" => "jpg".to_string(), - other => other.to_string(), - } -} - -fn image_relationship_xml(relationship_id: &str, target: &str) -> String { - format!( - r#""#, - ppt_rs::escape_xml(target) - ) -} - -fn image_picture_xml( - image: &ImageElement, - shape_id: usize, - relationship_id: &str, - frame: Rect, - crop: Option, -) -> String { - let blip_fill = if let Some((crop_left, crop_top, crop_right, crop_bottom)) = crop { - format!( - r#" - - - - - -"#, - (crop_left * 100_000.0).round() as u32, - (crop_top * 100_000.0).round() as u32, - (crop_right * 100_000.0).round() as u32, - (crop_bottom * 100_000.0).round() as u32, - ) - } else { - format!( - r#" - - - - -"# - ) - }; - let descr = image - .alt_text - .as_deref() - .map(|alt| format!(r#" descr="{}""#, ppt_rs::escape_xml(alt))) - .unwrap_or_default(); - let no_change_aspect = if image.lock_aspect_ratio { 1 } else { 0 }; - let rotation = image - .rotation_degrees - .map(|rotation| format!(r#" rot="{}""#, i64::from(rotation) * 60_000)) - .unwrap_or_default(); - let flip_horizontal = if image.flip_horizontal { - r#" flipH="1""# - } else { - "" - }; - let flip_vertical = if image.flip_vertical { - r#" flipV="1""# - } else { - "" - }; - format!( - r#" - - - - - - - -{blip_fill} - - - - - - - - - -"#, - points_to_emu(frame.left), - points_to_emu(frame.top), - points_to_emu(frame.width), - points_to_emu(frame.height), - ) -} - -fn slide_image_assets( - slide: &PresentationSlide, - next_media_index: &mut usize, -) -> Vec { - let mut ordered = slide.elements.iter().collect::>(); - ordered.sort_by_key(|element| element.z_order()); - let shape_count = ordered - .iter() - .filter(|element| { - matches!( - element, - PresentationElement::Text(_) - | PresentationElement::Shape(_) - | PresentationElement::Image(ImageElement { payload: None, .. }) - ) - }) - .count() - + usize::from(slide.background_fill.is_some()); - let mut image_index = 0_usize; - let mut assets = Vec::new(); - for element in ordered { - let PresentationElement::Image(image) = element else { - continue; - }; - let Some(payload) = &image.payload else { - continue; - }; - let (left, top, width, height, fitted_crop) = if image.fit_mode != ImageFitMode::Stretch { - fit_image(image) - } else { - ( - image.frame.left, - image.frame.top, - image.frame.width, - image.frame.height, - None, - ) - }; - image_index += 1; - let relationship_id = format!("rIdImage{image_index}"); - let extension = normalized_image_extension(&payload.format); - let media_name = format!("image{next_media_index}.{extension}"); - *next_media_index += 1; - assets.push(SlideImageAsset { - xml: image_picture_xml( - image, - 20 + shape_count + image_index - 1, - &relationship_id, - Rect { - left, - top, - width, - height, - }, - image.crop.or(fitted_crop), - ), - relationship_xml: image_relationship_xml( - &relationship_id, - &format!("../media/{media_name}"), - ), - media_path: format!("ppt/media/{media_name}"), - media_bytes: payload.bytes.clone(), - extension, - }); - } - assets -} - -fn patch_pptx_package( - source_bytes: Vec, - document: &PresentationDocument, -) -> Result, String> { - let mut archive = - ZipArchive::new(Cursor::new(source_bytes)).map_err(|error| error.to_string())?; - let mut writer = ZipWriter::new(Cursor::new(Vec::new())); - let mut next_media_index = 1_usize; - let mut pending_slide_relationships = HashMap::new(); - let mut pending_slide_images = HashMap::new(); - let mut pending_media = Vec::new(); - let mut image_extensions = BTreeSet::new(); - for (slide_index, slide) in document.slides.iter().enumerate() { - let slide_number = slide_index + 1; - let images = slide_image_assets(slide, &mut next_media_index); - let mut relationships = slide_hyperlink_relationships(slide); - relationships.extend(images.iter().map(|image| image.relationship_xml.clone())); - if !relationships.is_empty() { - pending_slide_relationships.insert(slide_number, relationships); - } - if !images.is_empty() { - image_extensions.extend(images.iter().map(|image| image.extension.clone())); - pending_media.extend( - images - .iter() - .map(|image| (image.media_path.clone(), image.media_bytes.clone())), - ); - pending_slide_images.insert(slide_number, images); - } - } - - for index in 0..archive.len() { - let mut file = archive.by_index(index).map_err(|error| error.to_string())?; - if file.is_dir() { - continue; - } - let name = file.name().to_string(); - let options = file.options(); - let mut bytes = Vec::new(); - file.read_to_end(&mut bytes) - .map_err(|error| error.to_string())?; - writer - .start_file(&name, options) - .map_err(|error| error.to_string())?; - if name == "[Content_Types].xml" { - writer - .write_all(update_content_types_xml(bytes, &image_extensions)?.as_bytes()) - .map_err(|error| error.to_string())?; - continue; - } - if name == "ppt/presentation.xml" { - writer - .write_all( - update_presentation_xml_dimensions(bytes, document.slide_size)?.as_bytes(), - ) - .map_err(|error| error.to_string())?; - continue; - } - if let Some(slide_number) = parse_slide_xml_path(&name) { - writer - .write_all( - update_slide_xml( - bytes, - &document.slides[slide_number - 1], - pending_slide_images - .get(&slide_number) - .map(std::vec::Vec::as_slice) - .unwrap_or(&[]), - )? - .as_bytes(), - ) - .map_err(|error| error.to_string())?; - continue; - } - if let Some(slide_number) = parse_slide_relationships_path(&name) - && let Some(relationships) = pending_slide_relationships.remove(&slide_number) - { - writer - .write_all(update_slide_relationships_xml(bytes, &relationships)?.as_bytes()) - .map_err(|error| error.to_string())?; - continue; - } - writer - .write_all(&bytes) - .map_err(|error| error.to_string())?; - } - - for (slide_number, relationships) in pending_slide_relationships { - writer - .start_file( - format!("ppt/slides/_rels/slide{slide_number}.xml.rels"), - SimpleFileOptions::default(), - ) - .map_err(|error| error.to_string())?; - writer - .write_all(slide_relationships_xml(&relationships).as_bytes()) - .map_err(|error| error.to_string())?; - } - - for (path, bytes) in pending_media { - writer - .start_file(path, SimpleFileOptions::default()) - .map_err(|error| error.to_string())?; - writer - .write_all(&bytes) - .map_err(|error| error.to_string())?; - } - - writer - .finish() - .map_err(|error| error.to_string()) - .map(Cursor::into_inner) -} - -fn update_presentation_xml_dimensions( - existing_bytes: Vec, - slide_size: Rect, -) -> Result { - let existing = String::from_utf8(existing_bytes).map_err(|error| error.to_string())?; - let updated = replace_self_closing_xml_tag( - &existing, - "p:sldSz", - &format!( - r#""#, - points_to_emu(slide_size.width), - points_to_emu(slide_size.height) - ), - )?; - replace_self_closing_xml_tag( - &updated, - "p:notesSz", - &format!( - r#""#, - points_to_emu(slide_size.height), - points_to_emu(slide_size.width) - ), - ) -} - -fn replace_self_closing_xml_tag(xml: &str, tag: &str, replacement: &str) -> Result { - let start = xml - .find(&format!("<{tag} ")) - .ok_or_else(|| format!("presentation xml is missing `<{tag} .../>`"))?; - let end = xml[start..] - .find("/>") - .map(|offset| start + offset + 2) - .ok_or_else(|| format!("presentation xml tag `{tag}` is not self-closing"))?; - Ok(format!("{}{replacement}{}", &xml[..start], &xml[end..])) -} - -fn slide_hyperlink_relationships(slide: &PresentationSlide) -> Vec { - let mut ordered = slide.elements.iter().collect::>(); - ordered.sort_by_key(|element| element.z_order()); - let mut hyperlink_index = 1_u32; - let mut relationships = Vec::new(); - for element in ordered { - let Some(hyperlink) = (match element { - PresentationElement::Text(text) => text.hyperlink.as_ref(), - PresentationElement::Shape(shape) => shape.hyperlink.as_ref(), - PresentationElement::Connector(_) - | PresentationElement::Image(_) - | PresentationElement::Table(_) - | PresentationElement::Chart(_) => None, - }) else { - continue; - }; - let relationship_id = format!("rIdHyperlink{hyperlink_index}"); - hyperlink_index += 1; - relationships.push(hyperlink.relationship_xml(&relationship_id)); - } - relationships -} - -fn parse_slide_relationships_path(path: &str) -> Option { - path.strip_prefix("ppt/slides/_rels/slide")? - .strip_suffix(".xml.rels")? - .parse::() - .ok() -} - -fn parse_slide_xml_path(path: &str) -> Option { - path.strip_prefix("ppt/slides/slide")? - .strip_suffix(".xml")? - .parse::() - .ok() -} - -fn update_slide_relationships_xml( - existing_bytes: Vec, - relationships: &[String], -) -> Result { - let existing = String::from_utf8(existing_bytes).map_err(|error| error.to_string())?; - let injected = relationships.join("\n"); - existing - .contains("") - .then(|| existing.replace("", &format!("{injected}\n"))) - .ok_or_else(|| { - "slide relationships xml is missing a closing ``".to_string() - }) -} - -fn slide_relationships_xml(relationships: &[String]) -> String { - let body = relationships.join("\n"); - format!( - r#" - -{body} -"# - ) -} - -fn update_content_types_xml( - existing_bytes: Vec, - image_extensions: &BTreeSet, -) -> Result { - let existing = String::from_utf8(existing_bytes).map_err(|error| error.to_string())?; - if image_extensions.is_empty() { - return Ok(existing); - } - let existing_lower = existing.to_ascii_lowercase(); - let additions = image_extensions - .iter() - .filter(|extension| { - !existing_lower.contains(&format!( - r#"extension="{}""#, - extension.to_ascii_lowercase() - )) - }) - .map(|extension| generate_image_content_type(extension)) - .collect::>(); - if additions.is_empty() { - return Ok(existing); - } - existing - .contains("") - .then(|| existing.replace("", &format!("{}\n", additions.join("\n")))) - .ok_or_else(|| "content types xml is missing a closing ``".to_string()) -} - -fn update_slide_xml( - existing_bytes: Vec, - slide: &PresentationSlide, - slide_images: &[SlideImageAsset], -) -> Result { - let existing = String::from_utf8(existing_bytes).map_err(|error| error.to_string())?; - let existing = replace_image_placeholders(existing, slide_images)?; - let table_xml = slide_table_xml(slide); - if table_xml.is_empty() { - return Ok(existing); - } - existing - .contains("") - .then(|| existing.replace("", &format!("{table_xml}\n"))) - .ok_or_else(|| "slide xml is missing a closing ``".to_string()) -} - -fn replace_image_placeholders( - existing: String, - slide_images: &[SlideImageAsset], -) -> Result { - if slide_images.is_empty() { - return Ok(existing); - } - let mut updated = String::with_capacity(existing.len()); - let mut remaining = existing.as_str(); - for image in slide_images { - let marker = remaining - .find("name=\"Image Placeholder: ") - .ok_or_else(|| { - "slide xml is missing an image placeholder block for exported images".to_string() - })?; - let start = remaining[..marker].rfind("").ok_or_else(|| { - "slide xml is missing an opening `` for image placeholder".to_string() - })?; - let end = remaining[marker..] - .find("") - .map(|offset| marker + offset + "".len()) - .ok_or_else(|| { - "slide xml is missing a closing `` for image placeholder".to_string() - })?; - updated.push_str(&remaining[..start]); - updated.push_str(&image.xml); - remaining = &remaining[end..]; - } - updated.push_str(remaining); - Ok(updated) -} - -fn slide_table_xml(slide: &PresentationSlide) -> String { - let mut ordered = slide.elements.iter().collect::>(); - ordered.sort_by_key(|element| element.z_order()); - let mut table_index = 0_usize; - ordered - .into_iter() - .filter_map(|element| { - let PresentationElement::Table(table) = element else { - return None; - }; - table_index += 1; - let rows = table - .rows - .clone() - .into_iter() - .enumerate() - .map(|(row_index, row)| { - let cells = row - .into_iter() - .enumerate() - .map(|(column_index, cell)| { - build_table_cell(cell, &table.merges, row_index, column_index) - }) - .collect::>(); - let mut table_row = TableRow::new(cells); - if let Some(height) = table.row_heights.get(row_index) { - table_row = table_row.with_height(points_to_emu(*height)); - } - Some(table_row) - }) - .collect::>>()?; - Some(ppt_rs::generator::table::generate_table_xml( - &ppt_rs::generator::table::Table::new( - rows, - table - .column_widths - .iter() - .copied() - .map(points_to_emu) - .collect(), - points_to_emu(table.frame.left), - points_to_emu(table.frame.top), - ), - 300 + table_index, - )) - }) - .collect::>() - .join("\n") -} - -fn write_preview_images( - document: &PresentationDocument, - output_dir: &Path, - action: &str, -) -> Result<(), PresentationArtifactError> { - let pptx_path = output_dir.join("preview.pptx"); - let bytes = build_pptx_bytes(document, action).map_err(|message| { - PresentationArtifactError::ExportFailed { - path: pptx_path.clone(), - message, - } - })?; - std::fs::write(&pptx_path, bytes).map_err(|error| PresentationArtifactError::ExportFailed { - path: pptx_path.clone(), - message: error.to_string(), - })?; - render_pptx_to_pngs(&pptx_path, output_dir, action) -} - -fn render_pptx_to_pngs( - pptx_path: &Path, - output_dir: &Path, - action: &str, -) -> Result<(), PresentationArtifactError> { - let soffice_cmd = if cfg!(target_os = "macos") - && Path::new("/Applications/LibreOffice.app/Contents/MacOS/soffice").exists() - { - "/Applications/LibreOffice.app/Contents/MacOS/soffice" - } else { - "soffice" - }; - let conversion = Command::new(soffice_cmd) - .arg("--headless") - .arg("--convert-to") - .arg("pdf") - .arg(pptx_path) - .arg("--outdir") - .arg(output_dir) - .output() - .map_err(|error| PresentationArtifactError::ExportFailed { - path: pptx_path.to_path_buf(), - message: format!("{action}: failed to execute LibreOffice: {error}"), - })?; - if !conversion.status.success() { - return Err(PresentationArtifactError::ExportFailed { - path: pptx_path.to_path_buf(), - message: format!( - "{action}: LibreOffice conversion failed: {}", - String::from_utf8_lossy(&conversion.stderr) - ), - }); - } - - let pdf_path = output_dir.join( - pptx_path - .file_stem() - .and_then(|stem| stem.to_str()) - .map(|stem| format!("{stem}.pdf")) - .ok_or_else(|| PresentationArtifactError::ExportFailed { - path: pptx_path.to_path_buf(), - message: format!("{action}: preview pptx filename is invalid"), - })?, - ); - let prefix = output_dir.join("slide"); - let conversion = Command::new("pdftoppm") - .arg("-png") - .arg(&pdf_path) - .arg(&prefix) - .output() - .map_err(|error| PresentationArtifactError::ExportFailed { - path: pdf_path.clone(), - message: format!("{action}: failed to execute pdftoppm: {error}"), - })?; - std::fs::remove_file(&pdf_path).ok(); - if !conversion.status.success() { - return Err(PresentationArtifactError::ExportFailed { - path: output_dir.to_path_buf(), - message: format!( - "{action}: pdftoppm conversion failed: {}", - String::from_utf8_lossy(&conversion.stderr) - ), - }); - } - Ok(()) -} - -pub(crate) fn write_preview_image( - source_path: &Path, - target_path: &Path, - format: PreviewOutputFormat, - scale: f32, - quality: u8, - action: &str, -) -> Result<(), PresentationArtifactError> { - if matches!(format, PreviewOutputFormat::Png) && scale == 1.0 { - std::fs::rename(source_path, target_path).map_err(|error| { - PresentationArtifactError::ExportFailed { - path: target_path.to_path_buf(), - message: error.to_string(), - } - })?; - return Ok(()); - } - let mut preview = - image::open(source_path).map_err(|error| PresentationArtifactError::ExportFailed { - path: source_path.to_path_buf(), - message: format!("{action}: {error}"), - })?; - if scale != 1.0 { - let width = (preview.width() as f32 * scale).round().max(1.0) as u32; - let height = (preview.height() as f32 * scale).round().max(1.0) as u32; - preview = preview.resize_exact(width, height, FilterType::Lanczos3); - } - let file = std::fs::File::create(target_path).map_err(|error| { - PresentationArtifactError::ExportFailed { - path: target_path.to_path_buf(), - message: error.to_string(), - } - })?; - let mut writer = std::io::BufWriter::new(file); - match format { - PreviewOutputFormat::Png => { - preview - .write_to(&mut writer, ImageFormat::Png) - .map_err(|error| PresentationArtifactError::ExportFailed { - path: target_path.to_path_buf(), - message: format!("{action}: {error}"), - })? - } - PreviewOutputFormat::Jpeg => { - let rgb = preview.to_rgb8(); - let mut encoder = JpegEncoder::new_with_quality(&mut writer, quality); - encoder.encode_image(&rgb).map_err(|error| { - PresentationArtifactError::ExportFailed { - path: target_path.to_path_buf(), - message: format!("{action}: {error}"), - } - })?; - } - } - std::fs::remove_file(source_path).ok(); - Ok(()) -} - -fn collect_pngs(output_dir: &Path) -> Result, PresentationArtifactError> { - let mut files = std::fs::read_dir(output_dir) - .map_err(|error| PresentationArtifactError::ExportFailed { - path: output_dir.to_path_buf(), - message: error.to_string(), - })? - .filter_map(Result::ok) - .map(|entry| entry.path()) - .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("png")) - .collect::>(); - files.sort(); - Ok(files) -} - -fn parse_preview_output_format( - format: Option<&str>, - path: &Path, - action: &str, -) -> Result { - let value = format - .map(str::to_owned) - .or_else(|| { - path.extension() - .and_then(|extension| extension.to_str()) - .map(str::to_owned) - }) - .unwrap_or_else(|| "png".to_string()); - match value.to_ascii_lowercase().as_str() { - "png" => Ok(PreviewOutputFormat::Png), - "jpg" | "jpeg" => Ok(PreviewOutputFormat::Jpeg), - "svg" => Err(PresentationArtifactError::UnsupportedFeature { - action: action.to_string(), - message: "preview format `svg` is not supported".to_string(), - }), - other => Err(PresentationArtifactError::InvalidArgs { - action: action.to_string(), - message: format!("preview format `{other}` is not supported"), - }), - } -} - -fn normalize_preview_scale( - scale: Option, - action: &str, -) -> Result { - let scale = scale.unwrap_or(1.0); - if !scale.is_finite() || scale <= 0.0 { - return Err(PresentationArtifactError::InvalidArgs { - action: action.to_string(), - message: "`scale` must be a positive number".to_string(), - }); - } - Ok(scale) -} - -fn normalize_preview_quality( - quality: Option, - action: &str, -) -> Result { - let quality = quality.unwrap_or(90); - if quality == 0 || quality > 100 { - return Err(PresentationArtifactError::InvalidArgs { - action: action.to_string(), - message: "`quality` must be between 1 and 100".to_string(), - }); - } - Ok(quality) -} - -fn cell_value_to_string(value: Value) -> String { - match value { - Value::Null => String::new(), - Value::String(text) => text, - Value::Bool(boolean) => boolean.to_string(), - Value::Number(number) => number.to_string(), - other => other.to_string(), - } -} - -fn snapshot_for_document(document: &PresentationDocument) -> ArtifactSnapshot { - ArtifactSnapshot { - slide_count: document.slides.len(), - slides: document - .slides - .iter() - .enumerate() - .map(|(index, slide)| SlideSnapshot { - slide_id: slide.slide_id.clone(), - index, - element_ids: slide - .elements - .iter() - .map(|element| element.element_id().to_string()) - .collect(), - element_types: slide - .elements - .iter() - .map(|element| element.kind().to_string()) - .collect(), - }) - .collect(), - } -} - -fn slide_list(document: &PresentationDocument) -> Vec { - document - .slides - .iter() - .enumerate() - .map(|(index, slide)| SlideListEntry { - slide_id: slide.slide_id.clone(), - index, - is_active: document.active_slide_index == Some(index), - notes: (!slide.notes.text.is_empty()).then(|| slide.notes.text.clone()), - notes_visible: slide.notes.visible, - background_fill: slide.background_fill.clone(), - layout_id: slide.layout_id.clone(), - element_count: slide.elements.len(), - }) - .collect() -} - -fn layout_list(document: &PresentationDocument) -> Vec { - document - .layouts - .iter() - .map(|layout| LayoutListEntry { - layout_id: layout.layout_id.clone(), - name: layout.name.clone(), - kind: match layout.kind { - LayoutKind::Layout => "layout".to_string(), - LayoutKind::Master => "master".to_string(), - }, - parent_layout_id: layout.parent_layout_id.clone(), - placeholder_count: layout.placeholders.len(), - }) - .collect() -} - -fn points_to_emu(points: u32) -> u32 { - points.saturating_mul(POINT_TO_EMU) -} - -fn emu_to_points(emu: u32) -> u32 { - emu / POINT_TO_EMU -} - -type ImageCrop = (f64, f64, f64, f64); -type FittedImage = (u32, u32, u32, u32, Option); - -pub(crate) fn fit_image(image: &ImageElement) -> FittedImage { - let Some(payload) = image.payload.as_ref() else { - return ( - image.frame.left, - image.frame.top, - image.frame.width, - image.frame.height, - None, - ); - }; - let frame = image.frame; - let source_width = payload.width_px as f64; - let source_height = payload.height_px as f64; - let target_width = frame.width as f64; - let target_height = frame.height as f64; - let source_ratio = source_width / source_height; - let target_ratio = target_width / target_height; - - match image.fit_mode { - ImageFitMode::Stretch => (frame.left, frame.top, frame.width, frame.height, None), - ImageFitMode::Contain => { - let scale = if source_ratio > target_ratio { - target_width / source_width - } else { - target_height / source_height - }; - let width = (source_width * scale).round() as u32; - let height = (source_height * scale).round() as u32; - let left = frame.left + frame.width.saturating_sub(width) / 2; - let top = frame.top + frame.height.saturating_sub(height) / 2; - (left, top, width, height, None) - } - ImageFitMode::Cover => { - let scale = if source_ratio > target_ratio { - target_height / source_height - } else { - target_width / source_width - }; - let width = source_width * scale; - let height = source_height * scale; - let crop_x = ((width - target_width).max(0.0) / width) / 2.0; - let crop_y = ((height - target_height).max(0.0) / height) / 2.0; - ( - frame.left, - frame.top, - frame.width, - frame.height, - Some((crop_x, crop_y, crop_x, crop_y)), - ) - } - } -} - -fn normalize_image_crop( - crop: ImageCropArgs, - action: &str, -) -> Result { - for (name, value) in [ - ("left", crop.left), - ("top", crop.top), - ("right", crop.right), - ("bottom", crop.bottom), - ] { - if !(0.0..=1.0).contains(&value) { - return Err(PresentationArtifactError::InvalidArgs { - action: action.to_string(), - message: format!("image crop `{name}` must be between 0.0 and 1.0"), - }); - } - } - Ok((crop.left, crop.top, crop.right, crop.bottom)) -} - -fn load_image_payload_from_path( - path: &Path, - action: &str, -) -> Result { - let bytes = std::fs::read(path).map_err(|error| PresentationArtifactError::InvalidArgs { - action: action.to_string(), - message: format!("failed to read image `{}`: {error}", path.display()), - })?; - build_image_payload( - bytes, - path.file_name() - .and_then(|name| name.to_str()) - .unwrap_or("image") - .to_string(), - action, - ) -} - -fn load_image_payload_from_data_url( - data_url: &str, - action: &str, -) -> Result { - let (header, payload) = - data_url - .split_once(',') - .ok_or_else(|| PresentationArtifactError::InvalidArgs { - action: action.to_string(), - message: "data_url must include a MIME header and base64 payload".to_string(), - })?; - let mime = header - .strip_prefix("data:") - .and_then(|prefix| prefix.strip_suffix(";base64")) - .ok_or_else(|| PresentationArtifactError::InvalidArgs { - action: action.to_string(), - message: "data_url must be base64-encoded".to_string(), - })?; - let bytes = BASE64_STANDARD.decode(payload).map_err(|error| { - PresentationArtifactError::InvalidArgs { - action: action.to_string(), - message: format!("failed to decode image data_url: {error}"), - } - })?; - build_image_payload( - bytes, - format!("image.{}", image_extension_from_mime(mime)), - action, - ) -} - -fn load_image_payload_from_uri( - uri: &str, - action: &str, -) -> Result { - let response = - reqwest::blocking::get(uri).map_err(|error| PresentationArtifactError::InvalidArgs { - action: action.to_string(), - message: format!("failed to fetch image `{uri}`: {error}"), - })?; - let status = response.status(); - if !status.is_success() { - return Err(PresentationArtifactError::InvalidArgs { - action: action.to_string(), - message: format!("failed to fetch image `{uri}`: HTTP {status}"), - }); - } - let content_type = response - .headers() - .get(reqwest::header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .map(|value| value.split(';').next().unwrap_or(value).trim().to_string()); - let bytes = response - .bytes() - .map_err(|error| PresentationArtifactError::InvalidArgs { - action: action.to_string(), - message: format!("failed to read image `{uri}`: {error}"), - })?; - build_image_payload( - bytes.to_vec(), - infer_remote_image_filename(uri, content_type.as_deref()), - action, - ) -} - -fn infer_remote_image_filename(uri: &str, content_type: Option<&str>) -> String { - let path_name = reqwest::Url::parse(uri) - .ok() - .and_then(|url| { - url.path_segments() - .and_then(Iterator::last) - .map(str::to_owned) - }) - .filter(|segment| !segment.is_empty()); - match (path_name, content_type) { - (Some(path_name), _) if Path::new(&path_name).extension().is_some() => path_name, - (Some(path_name), Some(content_type)) => { - format!("{path_name}.{}", image_extension_from_mime(content_type)) - } - (Some(path_name), None) => path_name, - (None, Some(content_type)) => format!("image.{}", image_extension_from_mime(content_type)), - (None, None) => "image.png".to_string(), - } -} - -fn build_image_payload( - bytes: Vec, - filename: String, - action: &str, -) -> Result { - let image = image::load_from_memory(&bytes).map_err(|error| { - PresentationArtifactError::InvalidArgs { - action: action.to_string(), - message: format!("failed to decode image bytes: {error}"), - } - })?; - let (width_px, height_px) = image.dimensions(); - let format = Path::new(&filename) - .extension() - .and_then(|extension| extension.to_str()) - .unwrap_or("png") - .to_uppercase(); - Ok(ImagePayload { - bytes, - format, - width_px, - height_px, - }) -} - -fn image_extension_from_mime(mime: &str) -> &'static str { - match mime { - "image/jpeg" => "jpg", - "image/gif" => "gif", - "image/webp" => "webp", - _ => "png", - } -} - -fn index_out_of_range(action: &str, index: usize, len: usize) -> PresentationArtifactError { - PresentationArtifactError::InvalidArgs { - action: action.to_string(), - message: format!("slide index {index} is out of range for {len} slides"), - } -} - -fn to_index(value: u32) -> Result { - usize::try_from(value).map_err(|_| PresentationArtifactError::InvalidArgs { - action: "insert_slide".to_string(), - message: "index does not fit in usize".to_string(), - }) -} - -fn resequence_z_order(slide: &mut PresentationSlide) { - for (index, element) in slide.elements.iter_mut().enumerate() { - element.set_z_order(index); - } -} diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/api.rs b/codex-rs/artifact-presentation/src/presentation_artifact/api.rs new file mode 100644 index 000000000..1b36392cd --- /dev/null +++ b/codex-rs/artifact-presentation/src/presentation_artifact/api.rs @@ -0,0 +1,177 @@ +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use image::GenericImageView; +use image::ImageFormat; +use image::codecs::jpeg::JpegEncoder; +use image::imageops::FilterType; +use ppt_rs::Chart; +use ppt_rs::ChartSeries; +use ppt_rs::ChartType; +use ppt_rs::Hyperlink as PptHyperlink; +use ppt_rs::HyperlinkAction as PptHyperlinkAction; +use ppt_rs::Image; +use ppt_rs::Presentation; +use ppt_rs::Shape; +use ppt_rs::ShapeFill; +use ppt_rs::ShapeLine; +use ppt_rs::ShapeType; +use ppt_rs::SlideContent; +use ppt_rs::SlideLayout; +use ppt_rs::TableBuilder; +use ppt_rs::TableCell; +use ppt_rs::TableRow; +use ppt_rs::generator::ArrowSize; +use ppt_rs::generator::ArrowType; +use ppt_rs::generator::CellAlign; +use ppt_rs::generator::Connector; +use ppt_rs::generator::ConnectorLine; +use ppt_rs::generator::ConnectorType; +use ppt_rs::generator::LineDash; +use ppt_rs::generator::generate_image_content_type; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value; +use std::collections::BTreeSet; +use std::collections::HashMap; +use std::collections::HashSet; +use std::io::Cursor; +use std::io::Read; +use std::io::Seek; +use std::io::Write; +use std::path::Path; +use std::path::PathBuf; +use std::process::Command; +use thiserror::Error; +use uuid::Uuid; +use zip::ZipArchive; +use zip::ZipWriter; +use zip::write::SimpleFileOptions; + +const POINT_TO_EMU: u32 = 12_700; +const DEFAULT_SLIDE_WIDTH_POINTS: u32 = 720; +const DEFAULT_SLIDE_HEIGHT_POINTS: u32 = 540; +const DEFAULT_IMPORTED_TITLE_LEFT: u32 = 36; +const DEFAULT_IMPORTED_TITLE_TOP: u32 = 24; +const DEFAULT_IMPORTED_TITLE_WIDTH: u32 = 648; +const DEFAULT_IMPORTED_TITLE_HEIGHT: u32 = 48; +const DEFAULT_IMPORTED_CONTENT_LEFT: u32 = 48; +const DEFAULT_IMPORTED_CONTENT_TOP: u32 = 96; +const DEFAULT_IMPORTED_CONTENT_WIDTH: u32 = 624; +const DEFAULT_IMPORTED_CONTENT_HEIGHT: u32 = 324; + +#[derive(Debug, Error)] +pub enum PresentationArtifactError { + #[error("missing `artifact_id` for action `{action}`")] + MissingArtifactId { action: String }, + #[error("unknown artifact id `{artifact_id}` for action `{action}`")] + UnknownArtifactId { action: String, artifact_id: String }, + #[error("unknown action `{0}`")] + UnknownAction(String), + #[error("invalid args for action `{action}`: {message}")] + InvalidArgs { action: String, message: String }, + #[error("unsupported feature for action `{action}`: {message}")] + UnsupportedFeature { action: String, message: String }, + #[error("failed to import PPTX `{path}`: {message}")] + ImportFailed { path: PathBuf, message: String }, + #[error("failed to export PPTX `{path}`: {message}")] + ExportFailed { path: PathBuf, message: String }, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PresentationArtifactRequest { + pub artifact_id: Option, + pub action: String, + #[serde(default)] + pub args: Value, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PathAccessKind { + Read, + Write, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PathAccessRequirement { + pub action: String, + pub kind: PathAccessKind, + pub path: PathBuf, +} + +impl PresentationArtifactRequest { + pub fn required_path_accesses( + &self, + cwd: &Path, + ) -> Result, PresentationArtifactError> { + let access = match self.action.as_str() { + "import_pptx" => { + let args: ImportPptxArgs = parse_args(&self.action, &self.args)?; + vec![PathAccessRequirement { + action: self.action.clone(), + kind: PathAccessKind::Read, + path: resolve_path(cwd, &args.path), + }] + } + "export_pptx" => { + let args: ExportPptxArgs = parse_args(&self.action, &self.args)?; + vec![PathAccessRequirement { + action: self.action.clone(), + kind: PathAccessKind::Write, + path: resolve_path(cwd, &args.path), + }] + } + "export_preview" => { + let args: ExportPreviewArgs = parse_args(&self.action, &self.args)?; + vec![PathAccessRequirement { + action: self.action.clone(), + kind: PathAccessKind::Write, + path: resolve_path(cwd, &args.path), + }] + } + "add_image" => { + let args: AddImageArgs = parse_args(&self.action, &self.args)?; + match args.image_source()? { + ImageInputSource::Path(path) => vec![PathAccessRequirement { + action: self.action.clone(), + kind: PathAccessKind::Read, + path: resolve_path(cwd, &path), + }], + ImageInputSource::DataUrl(_) + | ImageInputSource::Blob(_) + | ImageInputSource::Uri(_) + | ImageInputSource::Placeholder => Vec::new(), + } + } + "replace_image" => { + let args: ReplaceImageArgs = parse_args(&self.action, &self.args)?; + match ( + &args.path, + &args.data_url, + &args.blob, + &args.uri, + &args.prompt, + ) { + (Some(path), None, None, None, None) => vec![PathAccessRequirement { + action: self.action.clone(), + kind: PathAccessKind::Read, + path: resolve_path(cwd, path), + }], + (None, Some(_), None, None, None) + | (None, None, Some(_), None, None) + | (None, None, None, Some(_), None) + | (None, None, None, None, Some(_)) => Vec::new(), + _ => { + return Err(PresentationArtifactError::InvalidArgs { + action: self.action.clone(), + message: + "provide exactly one of `path`, `data_url`, `blob`, or `uri`, or provide `prompt` for a placeholder image" + .to_string(), + }); + } + } + } + _ => Vec::new(), + }; + Ok(access) + } +} diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/args.rs b/codex-rs/artifact-presentation/src/presentation_artifact/args.rs new file mode 100644 index 000000000..b85bcdff8 --- /dev/null +++ b/codex-rs/artifact-presentation/src/presentation_artifact/args.rs @@ -0,0 +1,468 @@ +#[derive(Debug, Deserialize)] +struct CreateArgs { + name: Option, + slide_size: Option, + theme: Option, +} + +#[derive(Debug, Deserialize)] +struct ImportPptxArgs { + path: PathBuf, +} + +#[derive(Debug, Deserialize)] +struct ExportPptxArgs { + path: PathBuf, +} + +#[derive(Debug, Deserialize)] +struct ExportPreviewArgs { + path: PathBuf, + slide_index: Option, + format: Option, + scale: Option, + quality: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct AddSlideArgs { + layout: Option, + notes: Option, + background_fill: Option, +} + +#[derive(Debug, Deserialize)] +struct CreateLayoutArgs { + name: String, + kind: Option, + parent_layout_id: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PreviewOutputFormat { + Png, + Jpeg, + Svg, +} + +impl PreviewOutputFormat { + fn extension(self) -> &'static str { + match self { + Self::Png => "png", + Self::Jpeg => "jpg", + Self::Svg => "svg", + } + } +} + +#[derive(Debug, Deserialize)] +struct AddLayoutPlaceholderArgs { + layout_id: String, + name: String, + placeholder_type: String, + index: Option, + text: Option, + geometry: Option, + position: Option, +} + +#[derive(Debug, Deserialize)] +struct LayoutIdArgs { + layout_id: String, +} + +#[derive(Debug, Deserialize)] +struct SetSlideLayoutArgs { + slide_index: u32, + layout_id: String, +} + +#[derive(Debug, Deserialize)] +struct UpdatePlaceholderTextArgs { + slide_index: u32, + name: String, + text: String, +} + +#[derive(Debug, Deserialize)] +struct NotesArgs { + slide_index: u32, + text: Option, +} + +#[derive(Debug, Deserialize)] +struct NotesVisibilityArgs { + slide_index: u32, + visible: bool, +} + +#[derive(Debug, Deserialize)] +struct ThemeArgs { + color_scheme: HashMap, + major_font: Option, + minor_font: Option, +} + +#[derive(Debug, Deserialize)] +struct StyleNameArgs { + name: String, +} + +#[derive(Debug, Deserialize)] +struct AddStyleArgs { + name: String, + #[serde(flatten)] + styling: TextStylingArgs, +} + +#[derive(Debug, Deserialize)] +struct InspectArgs { + kind: Option, + include: Option, + exclude: Option, + search: Option, + target_id: Option, + target: Option, + max_chars: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct InspectTargetArgs { + id: String, + before_lines: Option, + after_lines: Option, +} + +#[derive(Debug, Deserialize)] +struct ResolveArgs { + id: String, +} + +#[derive(Debug, Clone, Deserialize)] +struct PatchOperationInput { + artifact_id: Option, + action: String, + #[serde(default)] + args: Value, +} + +#[derive(Debug, Deserialize)] +struct RecordPatchArgs { + operations: Vec, +} + +#[derive(Debug, Deserialize)] +struct ApplyPatchArgs { + operations: Option>, + patch: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PresentationPatch { + version: u32, + artifact_id: String, + operations: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PatchOperation { + action: String, + #[serde(default)] + args: Value, +} + +#[derive(Debug, Default, Deserialize)] +struct InsertSlideArgs { + index: Option, + after_slide_index: Option, + layout: Option, + notes: Option, + background_fill: Option, +} + +#[derive(Debug, Deserialize)] +struct SlideIndexArgs { + slide_index: u32, +} + +#[derive(Debug, Deserialize)] +struct MoveSlideArgs { + from_index: u32, + to_index: u32, +} + +#[derive(Debug, Deserialize)] +struct SetActiveSlideArgs { + slide_index: u32, +} + +#[derive(Debug, Deserialize)] +struct SetSlideBackgroundArgs { + slide_index: u32, + fill: String, +} + +#[derive(Debug, Clone, Deserialize)] +struct PositionArgs { + left: u32, + top: u32, + width: u32, + height: u32, + rotation: Option, + flip_horizontal: Option, + flip_vertical: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +struct PartialPositionArgs { + left: Option, + top: Option, + width: Option, + height: Option, + rotation: Option, + flip_horizontal: Option, + flip_vertical: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct TextStylingArgs { + style: Option, + font_size: Option, + font_family: Option, + color: Option, + fill: Option, + alignment: Option, + bold: Option, + italic: Option, + underline: Option, +} + +#[derive(Debug, Deserialize)] +struct AddTextShapeArgs { + slide_index: u32, + text: String, + position: PositionArgs, + #[serde(flatten)] + styling: TextStylingArgs, +} + +#[derive(Debug, Clone, Default, Deserialize)] +struct StrokeArgs { + color: String, + width: u32, + style: Option, +} + +#[derive(Debug, Deserialize)] +struct AddShapeArgs { + slide_index: u32, + geometry: String, + position: PositionArgs, + fill: Option, + stroke: Option, + text: Option, + rotation: Option, + flip_horizontal: Option, + flip_vertical: Option, + #[serde(default)] + text_style: TextStylingArgs, +} + +#[derive(Debug, Clone, Default, Deserialize)] +struct ConnectorLineArgs { + color: Option, + width: Option, + style: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct PointArgs { + left: u32, + top: u32, +} + +#[derive(Debug, Deserialize)] +struct AddConnectorArgs { + slide_index: u32, + connector_type: String, + start: PointArgs, + end: PointArgs, + line: Option, + start_arrow: Option, + end_arrow: Option, + arrow_size: Option, + label: Option, +} + +#[derive(Debug, Deserialize)] +struct AddImageArgs { + slide_index: u32, + path: Option, + data_url: Option, + blob: Option, + uri: Option, + position: PositionArgs, + fit: Option, + crop: Option, + rotation: Option, + flip_horizontal: Option, + flip_vertical: Option, + lock_aspect_ratio: Option, + alt: Option, + prompt: Option, +} + +impl AddImageArgs { + fn image_source(&self) -> Result { + match (&self.path, &self.data_url, &self.blob, &self.uri) { + (Some(path), None, None, None) => Ok(ImageInputSource::Path(path.clone())), + (None, Some(data_url), None, None) => Ok(ImageInputSource::DataUrl(data_url.clone())), + (None, None, Some(blob), None) => Ok(ImageInputSource::Blob(blob.clone())), + (None, None, None, Some(uri)) => Ok(ImageInputSource::Uri(uri.clone())), + (None, None, None, None) if self.prompt.is_some() => Ok(ImageInputSource::Placeholder), + _ => Err(PresentationArtifactError::InvalidArgs { + action: "add_image".to_string(), + message: + "provide exactly one of `path`, `data_url`, `blob`, or `uri`, or provide `prompt` for a placeholder image" + .to_string(), + }), + } + } +} + +enum ImageInputSource { + Path(PathBuf), + DataUrl(String), + Blob(String), + Uri(String), + Placeholder, +} + +#[derive(Debug, Clone, Deserialize)] +struct ImageCropArgs { + left: f64, + top: f64, + right: f64, + bottom: f64, +} + +#[derive(Debug, Deserialize)] +struct AddTableArgs { + slide_index: u32, + position: PositionArgs, + rows: Vec>, + column_widths: Option>, + row_heights: Option>, + style: Option, +} + +#[derive(Debug, Deserialize)] +struct AddChartArgs { + slide_index: u32, + position: PositionArgs, + chart_type: String, + categories: Vec, + series: Vec, + title: Option, +} + +#[derive(Debug, Deserialize)] +struct ChartSeriesArgs { + name: String, + values: Vec, +} + +#[derive(Debug, Deserialize)] +struct UpdateTextArgs { + element_id: String, + text: String, + #[serde(default)] + styling: TextStylingArgs, +} + +#[derive(Debug, Deserialize)] +struct ReplaceTextArgs { + element_id: String, + search: String, + replace: String, +} + +#[derive(Debug, Deserialize)] +struct InsertTextAfterArgs { + element_id: String, + after: String, + insert: String, +} + +#[derive(Debug, Deserialize)] +struct SetHyperlinkArgs { + element_id: String, + link_type: Option, + url: Option, + slide_index: Option, + address: Option, + subject: Option, + path: Option, + tooltip: Option, + highlight_click: Option, + clear: Option, +} + +#[derive(Debug, Deserialize)] +struct UpdateShapeStyleArgs { + element_id: String, + position: Option, + fill: Option, + stroke: Option, + rotation: Option, + flip_horizontal: Option, + flip_vertical: Option, + fit: Option, + crop: Option, + lock_aspect_ratio: Option, + z_order: Option, +} + +#[derive(Debug, Deserialize)] +struct ElementIdArgs { + element_id: String, +} + +#[derive(Debug, Deserialize)] +struct ReplaceImageArgs { + element_id: String, + path: Option, + data_url: Option, + blob: Option, + uri: Option, + fit: Option, + crop: Option, + rotation: Option, + flip_horizontal: Option, + flip_vertical: Option, + lock_aspect_ratio: Option, + alt: Option, + prompt: Option, +} + +#[derive(Debug, Deserialize)] +struct UpdateTableCellArgs { + element_id: String, + row: u32, + column: u32, + value: Value, + #[serde(default)] + styling: TextStylingArgs, + background_fill: Option, + alignment: Option, +} + +#[derive(Debug, Deserialize)] +struct MergeTableCellsArgs { + element_id: String, + start_row: u32, + end_row: u32, + start_column: u32, + end_column: u32, +} diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/inspect.rs b/codex-rs/artifact-presentation/src/presentation_artifact/inspect.rs new file mode 100644 index 000000000..c270926f3 --- /dev/null +++ b/codex-rs/artifact-presentation/src/presentation_artifact/inspect.rs @@ -0,0 +1,611 @@ +fn inspect_document(document: &PresentationDocument, args: &InspectArgs) -> String { + let include_kinds = args + .include + .as_deref() + .or(args.kind.as_deref()) + .unwrap_or("deck,slide,textbox,shape,connector,table,chart,image,notes,layoutList"); + let included_kinds = include_kinds + .split(',') + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .collect::>(); + let excluded_kinds = args + .exclude + .as_deref() + .unwrap_or_default() + .split(',') + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .collect::>(); + let include = |name: &str| included_kinds.contains(name) && !excluded_kinds.contains(name); + let mut records: Vec<(Value, Option)> = Vec::new(); + if include("deck") { + records.push(( + serde_json::json!({ + "kind": "deck", + "id": format!("pr/{}", document.artifact_id), + "name": document.name, + "slides": document.slides.len(), + "styleIds": document + .named_text_styles() + .iter() + .map(|style| format!("st/{}", style.name)) + .collect::>(), + "activeSlideIndex": document.active_slide_index, + "activeSlideId": document.active_slide_index.and_then(|index| document.slides.get(index)).map(|slide| format!("sl/{}", slide.slide_id)), + }), + None, + )); + } + if include("styleList") { + for style in document.named_text_styles() { + records.push((named_text_style_to_json(&style, "st"), None)); + } + } + if include("layoutList") { + for layout in &document.layouts { + let placeholders = resolved_layout_placeholders(document, &layout.layout_id, "inspect") + .unwrap_or_default() + .into_iter() + .map(|placeholder| { + serde_json::json!({ + "name": placeholder.definition.name, + "type": placeholder.definition.placeholder_type, + "sourceLayoutId": placeholder.source_layout_id, + "textPreview": placeholder.definition.text, + }) + }) + .collect::>(); + records.push(( + serde_json::json!({ + "kind": "layout", + "id": format!("ly/{}", layout.layout_id), + "layoutId": layout.layout_id, + "name": layout.name, + "type": match layout.kind { LayoutKind::Layout => "layout", LayoutKind::Master => "master" }, + "parentLayoutId": layout.parent_layout_id, + "placeholders": placeholders, + }), + None, + )); + } + } + for (index, slide) in document.slides.iter().enumerate() { + let slide_id = format!("sl/{}", slide.slide_id); + if include("slide") { + records.push(( + serde_json::json!({ + "kind": "slide", + "id": slide_id, + "slide": index + 1, + "slideIndex": index, + "isActive": document.active_slide_index == Some(index), + "layoutId": slide.layout_id, + "elements": slide.elements.len(), + }), + Some(slide_id.clone()), + )); + } + if include("notes") && !slide.notes.text.is_empty() { + records.push(( + serde_json::json!({ + "kind": "notes", + "id": format!("nt/{}", slide.slide_id), + "slide": index + 1, + "visible": slide.notes.visible, + "text": slide.notes.text, + "textPreview": slide.notes.text.replace('\n', " | "), + "textChars": slide.notes.text.chars().count(), + "textLines": slide.notes.text.lines().count(), + }), + Some(slide_id.clone()), + )); + } + for element in &slide.elements { + let mut record = match element { + PresentationElement::Text(text) => { + if !include("textbox") { + continue; + } + serde_json::json!({ + "kind": "textbox", + "id": format!("sh/{}", text.element_id), + "slide": index + 1, + "text": text.text, + "textStyle": text_style_to_proto(&text.style), + "textPreview": text.text.replace('\n', " | "), + "textChars": text.text.chars().count(), + "textLines": text.text.lines().count(), + "bbox": [text.frame.left, text.frame.top, text.frame.width, text.frame.height], + "bboxUnit": "points", + }) + } + PresentationElement::Shape(shape) => { + if !(include("shape") || include("textbox") && shape.text.is_some()) { + continue; + } + let kind = if shape.text.is_some() && include("textbox") { + "textbox" + } else { + "shape" + }; + let mut record = serde_json::json!({ + "kind": kind, + "id": format!("sh/{}", shape.element_id), + "slide": index + 1, + "geometry": format!("{:?}", shape.geometry), + "text": shape.text, + "textStyle": text_style_to_proto(&shape.text_style), + "rotation": shape.rotation_degrees, + "flipHorizontal": shape.flip_horizontal, + "flipVertical": shape.flip_vertical, + "bbox": [shape.frame.left, shape.frame.top, shape.frame.width, shape.frame.height], + "bboxUnit": "points", + }); + if let Some(text) = &shape.text { + record["textPreview"] = Value::String(text.replace('\n', " | ")); + record["textChars"] = Value::from(text.chars().count()); + record["textLines"] = Value::from(text.lines().count()); + } + record + } + PresentationElement::Connector(connector) => { + if !include("shape") && !include("connector") { + continue; + } + serde_json::json!({ + "kind": "connector", + "id": format!("cn/{}", connector.element_id), + "slide": index + 1, + "connectorType": format!("{:?}", connector.connector_type), + "start": [connector.start.left, connector.start.top], + "end": [connector.end.left, connector.end.top], + "lineStyle": format!("{:?}", connector.line_style), + "label": connector.label, + }) + } + PresentationElement::Table(table) => { + if !include("table") { + continue; + } + serde_json::json!({ + "kind": "table", + "id": format!("tb/{}", table.element_id), + "slide": index + 1, + "rows": table.rows.len(), + "cols": table.rows.iter().map(std::vec::Vec::len).max().unwrap_or(0), + "columnWidths": table.column_widths, + "rowHeights": table.row_heights, + "preview": table.rows.first().map(|row| row.iter().map(|cell| cell.text.clone()).collect::>().join(" | ")), + "style": table.style, + "cellTextStyles": table + .rows + .iter() + .map(|row| row.iter().map(|cell| text_style_to_proto(&cell.text_style)).collect::>()) + .collect::>(), + "bbox": [table.frame.left, table.frame.top, table.frame.width, table.frame.height], + "bboxUnit": "points", + }) + } + PresentationElement::Chart(chart) => { + if !include("chart") { + continue; + } + serde_json::json!({ + "kind": "chart", + "id": format!("ch/{}", chart.element_id), + "slide": index + 1, + "chartType": format!("{:?}", chart.chart_type), + "title": chart.title, + "bbox": [chart.frame.left, chart.frame.top, chart.frame.width, chart.frame.height], + "bboxUnit": "points", + }) + } + PresentationElement::Image(image) => { + if !include("image") { + continue; + } + serde_json::json!({ + "kind": "image", + "id": format!("im/{}", image.element_id), + "slide": index + 1, + "alt": image.alt_text, + "prompt": image.prompt, + "fit": format!("{:?}", image.fit_mode), + "rotation": image.rotation_degrees, + "flipHorizontal": image.flip_horizontal, + "flipVertical": image.flip_vertical, + "crop": image.crop.map(|(left, top, right, bottom)| serde_json::json!({ + "left": left, + "top": top, + "right": right, + "bottom": bottom, + })), + "isPlaceholder": image.is_placeholder, + "lockAspectRatio": image.lock_aspect_ratio, + "bbox": [image.frame.left, image.frame.top, image.frame.width, image.frame.height], + "bboxUnit": "points", + }) + } + }; + if let Some(placeholder) = match element { + PresentationElement::Text(text) => text.placeholder.as_ref(), + PresentationElement::Shape(shape) => shape.placeholder.as_ref(), + PresentationElement::Connector(_) + | PresentationElement::Table(_) + | PresentationElement::Chart(_) => None, + PresentationElement::Image(image) => image.placeholder.as_ref(), + } { + record["placeholder"] = Value::String(placeholder.placeholder_type.clone()); + record["placeholderName"] = Value::String(placeholder.name.clone()); + record["placeholderIndex"] = + placeholder.index.map(Value::from).unwrap_or(Value::Null); + } + if let PresentationElement::Shape(shape) = element + && let Some(stroke) = &shape.stroke + { + record["stroke"] = serde_json::json!({ + "color": stroke.color, + "width": stroke.width, + "style": stroke.style.as_api_str(), + }); + } + if let Some(hyperlink) = match element { + PresentationElement::Text(text) => text.hyperlink.as_ref(), + PresentationElement::Shape(shape) => shape.hyperlink.as_ref(), + PresentationElement::Connector(_) + | PresentationElement::Image(_) + | PresentationElement::Table(_) + | PresentationElement::Chart(_) => None, + } { + record["hyperlink"] = hyperlink.to_json(); + } + records.push((record, Some(slide_id.clone()))); + } + } + + if let Some(target_id) = args.target_id.as_deref() { + records.retain(|(record, slide_id)| { + legacy_target_matches(target_id, record, slide_id.as_deref()) + }); + if records.is_empty() { + records.push(( + serde_json::json!({ + "kind": "notice", + "noticeType": "targetNotFound", + "target": { "id": target_id }, + "message": format!("No inspect records matched target `{target_id}`."), + }), + None, + )); + } + } + + if let Some(search) = args.search.as_deref() { + let search_lowercase = search.to_ascii_lowercase(); + records.retain(|(record, _)| { + record + .to_string() + .to_ascii_lowercase() + .contains(&search_lowercase) + }); + if records.is_empty() { + records.push(( + serde_json::json!({ + "kind": "notice", + "noticeType": "noMatches", + "search": search, + "message": format!("No inspect records matched search `{search}`."), + }), + None, + )); + } + } + + if let Some(target) = args.target.as_ref() { + if let Some(target_index) = records.iter().position(|(record, _)| { + record.get("id").and_then(Value::as_str) == Some(target.id.as_str()) + }) { + let start = target_index.saturating_sub(target.before_lines.unwrap_or(0)); + let end = (target_index + target.after_lines.unwrap_or(0) + 1).min(records.len()); + records = records.into_iter().skip(start).take(end - start).collect(); + } else { + records = vec![( + serde_json::json!({ + "kind": "notice", + "noticeType": "targetNotFound", + "target": { + "id": target.id, + "beforeLines": target.before_lines, + "afterLines": target.after_lines, + }, + "message": format!("No inspect records matched target `{}`.", target.id), + }), + None, + )]; + } + } + + let mut lines = Vec::new(); + let mut omitted_lines = 0usize; + let mut omitted_chars = 0usize; + for line in records.into_iter().map(|(record, _)| record.to_string()) { + let separator_len = usize::from(!lines.is_empty()); + if let Some(max_chars) = args.max_chars + && lines.iter().map(String::len).sum::() + separator_len + line.len() > max_chars + { + omitted_lines += 1; + omitted_chars += line.len(); + continue; + } + lines.push(line); + } + if omitted_lines > 0 { + lines.push( + serde_json::json!({ + "kind": "notice", + "noticeType": "truncation", + "maxChars": args.max_chars, + "omittedLines": omitted_lines, + "omittedChars": omitted_chars, + "message": format!( + "Truncated inspect output by omitting {omitted_lines} lines. Increase maxChars or narrow the filter." + ), + }) + .to_string(), + ); + } + lines.join("\n") +} + +fn legacy_target_matches(target_id: &str, record: &Value, slide_id: Option<&str>) -> bool { + record.get("id").and_then(Value::as_str) == Some(target_id) || slide_id == Some(target_id) +} + +fn add_text_metadata(record: &mut Value, text: &str) { + record["textPreview"] = Value::String(text.replace('\n', " | ")); + record["textChars"] = Value::from(text.chars().count()); + record["textLines"] = Value::from(text.lines().count()); +} + +fn normalize_element_lookup_id(element_id: &str) -> &str { + element_id + .split_once('/') + .map(|(_, normalized)| normalized) + .unwrap_or(element_id) +} + +fn resolve_anchor( + document: &PresentationDocument, + id: &str, + action: &str, +) -> Result { + if id == format!("pr/{}", document.artifact_id) { + return Ok(serde_json::json!({ + "kind": "deck", + "id": id, + "artifactId": document.artifact_id, + "name": document.name, + "slideCount": document.slides.len(), + "styleIds": document + .named_text_styles() + .iter() + .map(|style| format!("st/{}", style.name)) + .collect::>(), + "activeSlideIndex": document.active_slide_index, + "activeSlideId": document.active_slide_index.and_then(|index| document.slides.get(index)).map(|slide| format!("sl/{}", slide.slide_id)), + })); + } + if let Some(style_name) = id.strip_prefix("st/") { + let named_style = document + .named_text_styles() + .into_iter() + .find(|style| style.name == style_name) + .ok_or_else(|| PresentationArtifactError::UnsupportedFeature { + action: action.to_string(), + message: format!("unknown style id `{id}`"), + })?; + return Ok(named_text_style_to_json(&named_style, "st")); + } + + for (slide_index, slide) in document.slides.iter().enumerate() { + let slide_id = format!("sl/{}", slide.slide_id); + if id == slide_id { + return Ok(serde_json::json!({ + "kind": "slide", + "id": slide_id, + "slide": slide_index + 1, + "slideIndex": slide_index, + "isActive": document.active_slide_index == Some(slide_index), + "layoutId": slide.layout_id, + "notesId": (!slide.notes.text.is_empty()).then(|| format!("nt/{}", slide.slide_id)), + "elementIds": slide.elements.iter().map(|element| { + let prefix = match element { + PresentationElement::Text(_) | PresentationElement::Shape(_) => "sh", + PresentationElement::Connector(_) => "cn", + PresentationElement::Image(_) => "im", + PresentationElement::Table(_) => "tb", + PresentationElement::Chart(_) => "ch", + }; + format!("{prefix}/{}", element.element_id()) + }).collect::>(), + })); + } + let notes_id = format!("nt/{}", slide.slide_id); + if id == notes_id { + let mut record = serde_json::json!({ + "kind": "notes", + "id": notes_id, + "slide": slide_index + 1, + "slideIndex": slide_index, + "visible": slide.notes.visible, + "text": slide.notes.text, + }); + add_text_metadata(&mut record, &slide.notes.text); + return Ok(record); + } + for element in &slide.elements { + let mut record = match element { + PresentationElement::Text(text) => { + let mut record = serde_json::json!({ + "kind": "textbox", + "id": format!("sh/{}", text.element_id), + "elementId": text.element_id, + "slide": slide_index + 1, + "slideIndex": slide_index, + "text": text.text, + "textStyle": text_style_to_proto(&text.style), + "bbox": [text.frame.left, text.frame.top, text.frame.width, text.frame.height], + "bboxUnit": "points", + }); + add_text_metadata(&mut record, &text.text); + record + } + PresentationElement::Shape(shape) => { + let mut record = serde_json::json!({ + "kind": if shape.text.is_some() { "textbox" } else { "shape" }, + "id": format!("sh/{}", shape.element_id), + "elementId": shape.element_id, + "slide": slide_index + 1, + "slideIndex": slide_index, + "geometry": format!("{:?}", shape.geometry), + "text": shape.text, + "textStyle": text_style_to_proto(&shape.text_style), + "rotation": shape.rotation_degrees, + "flipHorizontal": shape.flip_horizontal, + "flipVertical": shape.flip_vertical, + "bbox": [shape.frame.left, shape.frame.top, shape.frame.width, shape.frame.height], + "bboxUnit": "points", + }); + if let Some(text) = &shape.text { + add_text_metadata(&mut record, text); + } + record + } + PresentationElement::Connector(connector) => serde_json::json!({ + "kind": "connector", + "id": format!("cn/{}", connector.element_id), + "elementId": connector.element_id, + "slide": slide_index + 1, + "slideIndex": slide_index, + "connectorType": format!("{:?}", connector.connector_type), + "start": [connector.start.left, connector.start.top], + "end": [connector.end.left, connector.end.top], + "lineStyle": format!("{:?}", connector.line_style), + "label": connector.label, + }), + PresentationElement::Image(image) => serde_json::json!({ + "kind": "image", + "id": format!("im/{}", image.element_id), + "elementId": image.element_id, + "slide": slide_index + 1, + "slideIndex": slide_index, + "alt": image.alt_text, + "prompt": image.prompt, + "fit": format!("{:?}", image.fit_mode), + "rotation": image.rotation_degrees, + "flipHorizontal": image.flip_horizontal, + "flipVertical": image.flip_vertical, + "crop": image.crop.map(|(left, top, right, bottom)| serde_json::json!({ + "left": left, + "top": top, + "right": right, + "bottom": bottom, + })), + "isPlaceholder": image.is_placeholder, + "lockAspectRatio": image.lock_aspect_ratio, + "bbox": [image.frame.left, image.frame.top, image.frame.width, image.frame.height], + "bboxUnit": "points", + }), + PresentationElement::Table(table) => serde_json::json!({ + "kind": "table", + "id": format!("tb/{}", table.element_id), + "elementId": table.element_id, + "slide": slide_index + 1, + "slideIndex": slide_index, + "rows": table.rows.len(), + "cols": table.rows.iter().map(std::vec::Vec::len).max().unwrap_or(0), + "columnWidths": table.column_widths, + "rowHeights": table.row_heights, + "cellTextStyles": table + .rows + .iter() + .map(|row| row.iter().map(|cell| text_style_to_proto(&cell.text_style)).collect::>()) + .collect::>(), + "bbox": [table.frame.left, table.frame.top, table.frame.width, table.frame.height], + "bboxUnit": "points", + }), + PresentationElement::Chart(chart) => serde_json::json!({ + "kind": "chart", + "id": format!("ch/{}", chart.element_id), + "elementId": chart.element_id, + "slide": slide_index + 1, + "slideIndex": slide_index, + "chartType": format!("{:?}", chart.chart_type), + "title": chart.title, + "bbox": [chart.frame.left, chart.frame.top, chart.frame.width, chart.frame.height], + "bboxUnit": "points", + }), + }; + if let Some(hyperlink) = match element { + PresentationElement::Text(text) => text.hyperlink.as_ref(), + PresentationElement::Shape(shape) => shape.hyperlink.as_ref(), + PresentationElement::Connector(_) + | PresentationElement::Image(_) + | PresentationElement::Table(_) + | PresentationElement::Chart(_) => None, + } { + record["hyperlink"] = hyperlink.to_json(); + } + if let PresentationElement::Shape(shape) = element + && let Some(stroke) = &shape.stroke + { + record["stroke"] = serde_json::json!({ + "color": stroke.color, + "width": stroke.width, + "style": stroke.style.as_api_str(), + }); + } + if let Some(placeholder) = match element { + PresentationElement::Text(text) => text.placeholder.as_ref(), + PresentationElement::Shape(shape) => shape.placeholder.as_ref(), + PresentationElement::Image(image) => image.placeholder.as_ref(), + PresentationElement::Connector(_) + | PresentationElement::Table(_) + | PresentationElement::Chart(_) => None, + } { + record["placeholder"] = Value::String(placeholder.placeholder_type.clone()); + record["placeholderName"] = Value::String(placeholder.name.clone()); + record["placeholderIndex"] = + placeholder.index.map(Value::from).unwrap_or(Value::Null); + } + if record.get("id").and_then(Value::as_str) == Some(id) { + return Ok(record); + } + } + } + + for layout in &document.layouts { + let layout_id = format!("ly/{}", layout.layout_id); + if id == layout_id { + return Ok(serde_json::json!({ + "kind": "layout", + "id": layout_id, + "layoutId": layout.layout_id, + "name": layout.name, + "type": match layout.kind { + LayoutKind::Layout => "layout", + LayoutKind::Master => "master", + }, + "parentLayoutId": layout.parent_layout_id, + "placeholders": layout_placeholder_list(document, &layout.layout_id, action)?, + })); + } + } + + Err(PresentationArtifactError::UnsupportedFeature { + action: action.to_string(), + message: format!("unknown resolve id `{id}`"), + }) +} + diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/manager.rs b/codex-rs/artifact-presentation/src/presentation_artifact/manager.rs new file mode 100644 index 000000000..67e190ee5 --- /dev/null +++ b/codex-rs/artifact-presentation/src/presentation_artifact/manager.rs @@ -0,0 +1,2308 @@ +#[derive(Debug, Default)] +pub struct PresentationArtifactManager { + documents: HashMap, + undo_stack: Vec, + redo_stack: Vec, +} + +#[derive(Debug, Clone)] +struct HistoryEntry { + artifact_id: String, + action: String, + before: Option, + after: Option, +} + +impl PresentationArtifactManager { + pub fn execute( + &mut self, + request: PresentationArtifactRequest, + cwd: &Path, + ) -> Result { + match request.action.as_str() { + "undo" => return self.undo(request), + "redo" => return self.redo(request), + "record_patch" => return self.record_patch(request), + "apply_patch" => return self.apply_patch(request, cwd), + _ => {} + } + + let action = request.action.clone(); + let before = if tracks_history(&action) { + request + .artifact_id + .as_ref() + .and_then(|artifact_id| self.documents.get(artifact_id).cloned()) + } else { + None + }; + let response = self.execute_action(request, cwd)?; + if tracks_history(&action) { + let after = self.documents.get(&response.artifact_id).cloned(); + self.push_history_entry(HistoryEntry { + artifact_id: response.artifact_id.clone(), + action, + before, + after, + }); + } + Ok(response) + } + + fn execute_action( + &mut self, + request: PresentationArtifactRequest, + cwd: &Path, + ) -> Result { + match request.action.as_str() { + "create" => self.create(request), + "import_pptx" => self.import_pptx(request, cwd), + "export_pptx" => self.export_pptx(request, cwd), + "export_preview" => self.export_preview(request, cwd), + "get_summary" => self.get_summary(request), + "list_slides" => self.list_slides(request), + "list_layouts" => self.list_layouts(request), + "list_layout_placeholders" => self.list_layout_placeholders(request), + "list_slide_placeholders" => self.list_slide_placeholders(request), + "inspect" => self.inspect(request), + "resolve" => self.resolve(request), + "to_proto" => self.proto_snapshot(request), + "add_slide" => self.add_slide(request), + "insert_slide" => self.insert_slide(request), + "duplicate_slide" => self.duplicate_slide(request), + "move_slide" => self.move_slide(request), + "delete_slide" => self.delete_slide(request), + "create_layout" => self.create_layout(request), + "add_layout_placeholder" => self.add_layout_placeholder(request), + "set_slide_layout" => self.set_slide_layout(request), + "update_placeholder_text" => self.update_placeholder_text(request), + "set_theme" => self.set_theme(request), + "add_style" => self.add_style(request), + "get_style" => self.get_style(request), + "describe_styles" => self.describe_styles(request), + "set_notes" => self.set_notes(request), + "append_notes" => self.append_notes(request), + "clear_notes" => self.clear_notes(request), + "set_notes_visibility" => self.set_notes_visibility(request), + "set_active_slide" => self.set_active_slide(request), + "set_slide_background" => self.set_slide_background(request), + "add_text_shape" => self.add_text_shape(request), + "add_shape" => self.add_shape(request), + "add_connector" => self.add_connector(request), + "add_image" => self.add_image(request, cwd), + "replace_image" => self.replace_image(request, cwd), + "add_table" => self.add_table(request), + "update_table_cell" => self.update_table_cell(request), + "merge_table_cells" => self.merge_table_cells(request), + "add_chart" => self.add_chart(request), + "update_text" => self.update_text(request), + "replace_text" => self.replace_text(request), + "insert_text_after" => self.insert_text_after(request), + "set_hyperlink" => self.set_hyperlink(request), + "update_shape_style" => self.update_shape_style(request), + "bring_to_front" => self.bring_to_front(request), + "send_to_back" => self.send_to_back(request), + "delete_element" => self.delete_element(request), + "delete_artifact" => self.delete_artifact(request), + other => Err(PresentationArtifactError::UnknownAction(other.to_string())), + } + } + + fn push_history_entry(&mut self, entry: HistoryEntry) { + self.undo_stack.push(entry); + self.redo_stack.clear(); + } + + fn create( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: CreateArgs = parse_args(&request.action, &request.args)?; + let mut document = PresentationDocument::new(args.name); + if let Some(slide_size) = args.slide_size { + document.slide_size = parse_slide_size(&slide_size, &request.action)?; + } + if let Some(theme) = args.theme { + document.theme = normalize_theme(theme, &request.action)?; + } + let artifact_id = document.artifact_id.clone(); + let summary = format!( + "Created presentation artifact `{artifact_id}` with {} slides", + document.slides.len() + ); + let snapshot = snapshot_for_document(&document); + let mut response = + PresentationArtifactResponse::new(artifact_id, request.action, summary, snapshot); + response.theme = Some(document.theme_snapshot()); + self.documents + .insert(response.artifact_id.clone(), document); + Ok(response) + } + + fn import_pptx( + &mut self, + request: PresentationArtifactRequest, + cwd: &Path, + ) -> Result { + let args: ImportPptxArgs = parse_args(&request.action, &request.args)?; + let path = resolve_path(cwd, &args.path); + let imported = Presentation::from_path(&path).map_err(|error| { + PresentationArtifactError::ImportFailed { + path: path.clone(), + message: error.to_string(), + } + })?; + let mut document = PresentationDocument::from_ppt_rs(imported); + import_pptx_images(&path, &mut document, &request.action)?; + let artifact_id = document.artifact_id.clone(); + let slide_count = document.slides.len(); + let snapshot = snapshot_for_document(&document); + self.documents.insert(artifact_id.clone(), document); + let summary = format!( + "Imported `{}` as artifact `{artifact_id}` with {slide_count} slides", + path.display() + ); + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + summary, + snapshot, + )) + } + + fn export_pptx( + &mut self, + request: PresentationArtifactRequest, + cwd: &Path, + ) -> Result { + let args: ExportPptxArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document(&artifact_id, &request.action)?; + let path = resolve_path(cwd, &args.path); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|error| { + PresentationArtifactError::ExportFailed { + path: path.clone(), + message: error.to_string(), + } + })?; + } + + let bytes = build_pptx_bytes(document, &request.action).map_err(|message| { + PresentationArtifactError::ExportFailed { + path: path.clone(), + message, + } + })?; + std::fs::write(&path, bytes).map_err(|error| PresentationArtifactError::ExportFailed { + path: path.clone(), + message: error.to_string(), + })?; + + let mut response = PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Exported presentation to `{}`", path.display()), + snapshot_for_document(document), + ); + response.exported_paths.push(path); + Ok(response) + } + + fn export_preview( + &mut self, + request: PresentationArtifactRequest, + cwd: &Path, + ) -> Result { + let args: ExportPreviewArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document(&artifact_id, &request.action)?; + let output_path = resolve_path(cwd, &args.path); + let preview_format = + parse_preview_output_format(args.format.as_deref(), &output_path, &request.action)?; + let scale = normalize_preview_scale(args.scale, &request.action)?; + let quality = normalize_preview_quality(args.quality, &request.action)?; + if let Some(parent) = output_path.parent() { + std::fs::create_dir_all(parent).map_err(|error| { + PresentationArtifactError::ExportFailed { + path: output_path.clone(), + message: error.to_string(), + } + })?; + } + let temp_dir = + std::env::temp_dir().join(format!("presentation_preview_{}", Uuid::new_v4().simple())); + std::fs::create_dir_all(&temp_dir).map_err(|error| { + PresentationArtifactError::ExportFailed { + path: output_path.clone(), + message: error.to_string(), + } + })?; + let preview_document = if let Some(slide_index) = args.slide_index { + let slide = document + .slides + .get(slide_index as usize) + .cloned() + .ok_or_else(|| { + index_out_of_range(&request.action, slide_index as usize, document.slides.len()) + })?; + PresentationDocument { + artifact_id: document.artifact_id.clone(), + name: document.name.clone(), + slide_size: document.slide_size, + theme: document.theme.clone(), + custom_text_styles: document.custom_text_styles.clone(), + layouts: Vec::new(), + slides: vec![slide], + active_slide_index: Some(0), + next_slide_seq: 1, + next_element_seq: 1, + next_layout_seq: 1, + } + } else { + document.clone() + }; + write_preview_images(&preview_document, &temp_dir, &request.action)?; + let mut exported_paths = collect_pngs(&temp_dir)?; + if args.slide_index.is_some() { + let rendered = + exported_paths + .pop() + .ok_or_else(|| PresentationArtifactError::ExportFailed { + path: output_path.clone(), + message: "preview renderer produced no images".to_string(), + })?; + write_preview_image( + &rendered, + &output_path, + preview_format, + scale, + quality, + &request.action, + )?; + exported_paths = vec![output_path]; + } else { + std::fs::create_dir_all(&output_path).map_err(|error| { + PresentationArtifactError::ExportFailed { + path: output_path.clone(), + message: error.to_string(), + } + })?; + let mut relocated = Vec::new(); + for rendered in exported_paths { + let filename = rendered.file_name().ok_or_else(|| { + PresentationArtifactError::ExportFailed { + path: output_path.clone(), + message: "rendered preview had no filename".to_string(), + } + })?; + let stem = Path::new(filename) + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("preview"); + let target = output_path.join(format!("{stem}.{}", preview_format.extension())); + write_preview_image( + &rendered, + &target, + preview_format, + scale, + quality, + &request.action, + )?; + relocated.push(target); + } + exported_paths = relocated; + } + let mut response = PresentationArtifactResponse::new( + artifact_id, + request.action, + "Exported slide preview".to_string(), + snapshot_for_document(document), + ); + response.exported_paths = exported_paths; + Ok(response) + } + + fn get_summary( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document(&artifact_id, &request.action)?; + let mut response = PresentationArtifactResponse::new( + artifact_id, + request.action, + format!( + "Presentation `{}` has {} slides, {} elements, {} layouts, and active slide {}", + document.name.as_deref().unwrap_or("Untitled"), + document.slides.len(), + document.total_element_count(), + document.layouts.len(), + document + .active_slide_index + .map(|index| index.to_string()) + .unwrap_or_else(|| "none".to_string()) + ), + snapshot_for_document(document), + ); + response.slide_list = Some(slide_list(document)); + response.layout_list = Some(layout_list(document)); + response.theme = Some(document.theme_snapshot()); + response.active_slide_index = document.active_slide_index; + Ok(response) + } + + fn list_slides( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document(&artifact_id, &request.action)?; + let mut response = PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Listed {} slides", document.slides.len()), + snapshot_for_document(document), + ); + response.slide_list = Some(slide_list(document)); + response.theme = Some(document.theme_snapshot()); + response.active_slide_index = document.active_slide_index; + Ok(response) + } + + fn list_layouts( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document(&artifact_id, &request.action)?; + let mut response = PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Listed {} layouts", document.layouts.len()), + snapshot_for_document(document), + ); + response.layout_list = Some(layout_list(document)); + response.theme = Some(document.theme_snapshot()); + Ok(response) + } + + fn list_layout_placeholders( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: LayoutIdArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document(&artifact_id, &request.action)?; + let placeholders = layout_placeholder_list(document, &args.layout_id, &request.action)?; + let mut response = PresentationArtifactResponse::new( + artifact_id, + request.action, + format!( + "Listed {} placeholders for layout `{}`", + placeholders.len(), + args.layout_id + ), + snapshot_for_document(document), + ); + response.placeholder_list = Some(placeholders); + response.layout_list = Some(layout_list(document)); + Ok(response) + } + + fn list_slide_placeholders( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: SlideIndexArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document(&artifact_id, &request.action)?; + let slide_index = args.slide_index as usize; + let slide = document.slides.get(slide_index).ok_or_else(|| { + index_out_of_range(&request.action, slide_index, document.slides.len()) + })?; + let placeholders = slide_placeholder_list(slide, slide_index); + let mut response = PresentationArtifactResponse::new( + artifact_id, + request.action, + format!( + "Listed {} placeholders for slide {}", + placeholders.len(), + args.slide_index + ), + snapshot_for_document(document), + ); + response.placeholder_list = Some(placeholders); + response.slide_list = Some(slide_list(document)); + Ok(response) + } + + fn inspect( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: InspectArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document(&artifact_id, &request.action)?; + let inspect_ndjson = inspect_document(document, &args); + let mut response = PresentationArtifactResponse::new( + artifact_id, + request.action, + "Generated inspection snapshot".to_string(), + snapshot_for_document(document), + ); + response.inspect_ndjson = Some(inspect_ndjson); + response.theme = Some(document.theme_snapshot()); + response.active_slide_index = document.active_slide_index; + Ok(response) + } + + fn resolve( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: ResolveArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document(&artifact_id, &request.action)?; + let resolved_record = resolve_anchor(document, &args.id, &request.action)?; + let mut response = PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Resolved `{}`", args.id), + snapshot_for_document(document), + ); + response.resolved_record = Some(resolved_record); + response.active_slide_index = document.active_slide_index; + Ok(response) + } + + fn proto_snapshot( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document(&artifact_id, &request.action)?; + let mut response = PresentationArtifactResponse::new( + artifact_id, + request.action, + "Generated proto snapshot".to_string(), + snapshot_for_document(document), + ); + response.proto_json = Some(document_to_proto(document, "to_proto")?); + response.theme = Some(document.theme_snapshot()); + response.active_slide_index = document.active_slide_index; + Ok(response) + } + + fn record_patch( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: RecordPatchArgs = parse_args(&request.action, &request.args)?; + let patch = self.normalize_patch( + request.artifact_id.as_deref(), + args.operations, + &request.action, + )?; + let document = self.get_document(&patch.artifact_id, &request.action)?; + let mut response = PresentationArtifactResponse::new( + patch.artifact_id.clone(), + request.action, + format!("Recorded patch with {} operations", patch.operations.len()), + snapshot_for_document(document), + ); + response.patch = Some(serde_json::to_value(&patch).map_err(|error| { + PresentationArtifactError::InvalidArgs { + action: "record_patch".to_string(), + message: format!("failed to serialize patch: {error}"), + } + })?); + response.active_slide_index = document.active_slide_index; + Ok(response) + } + + fn apply_patch( + &mut self, + request: PresentationArtifactRequest, + _cwd: &Path, + ) -> Result { + let args: ApplyPatchArgs = parse_args(&request.action, &request.args)?; + let patch = if let Some(patch) = args.patch { + self.normalize_serialized_patch(request.artifact_id.as_deref(), patch, &request.action)? + } else { + let operations = + args.operations + .ok_or_else(|| PresentationArtifactError::InvalidArgs { + action: request.action.clone(), + message: "provide either `patch` or `operations`".to_string(), + })?; + self.normalize_patch(request.artifact_id.as_deref(), operations, &request.action)? + }; + let before = self.documents.get(&patch.artifact_id).cloned(); + let Some(before_document) = before else { + return Err(PresentationArtifactError::UnknownArtifactId { + action: request.action, + artifact_id: patch.artifact_id, + }); + }; + for operation in &patch.operations { + let nested_request = PresentationArtifactRequest { + artifact_id: Some(patch.artifact_id.clone()), + action: operation.action.clone(), + args: operation.args.clone(), + }; + if let Err(error) = self.execute_action(nested_request, Path::new(".")) { + self.documents + .insert(patch.artifact_id.clone(), before_document); + return Err(error); + } + } + let document = self + .get_document(&patch.artifact_id, &request.action)? + .clone(); + self.push_history_entry(HistoryEntry { + artifact_id: patch.artifact_id.clone(), + action: request.action.clone(), + before: Some(before_document), + after: Some(document.clone()), + }); + let mut response = PresentationArtifactResponse::new( + patch.artifact_id.clone(), + request.action, + format!("Applied patch with {} operations", patch.operations.len()), + snapshot_for_document(&document), + ); + response.patch = Some(serde_json::to_value(&patch).map_err(|error| { + PresentationArtifactError::InvalidArgs { + action: "apply_patch".to_string(), + message: format!("failed to serialize patch: {error}"), + } + })?); + response.active_slide_index = document.active_slide_index; + Ok(response) + } + + fn undo( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let position = self + .undo_stack + .iter() + .rposition(|entry| { + request + .artifact_id + .as_deref() + .is_none_or(|artifact_id| artifact_id == entry.artifact_id) + }) + .ok_or_else(|| PresentationArtifactError::UnsupportedFeature { + action: request.action.clone(), + message: "nothing to undo".to_string(), + })?; + let entry = self.undo_stack.remove(position); + match &entry.before { + Some(document) => { + self.documents + .insert(entry.artifact_id.clone(), document.clone()); + } + None => { + self.documents.remove(&entry.artifact_id); + } + } + self.redo_stack.push(entry.clone()); + Ok(response_for_document_state( + entry.artifact_id, + request.action, + format!("Undid `{}`", entry.action), + entry.before.as_ref(), + )) + } + + fn redo( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let position = self + .redo_stack + .iter() + .rposition(|entry| { + request + .artifact_id + .as_deref() + .is_none_or(|artifact_id| artifact_id == entry.artifact_id) + }) + .ok_or_else(|| PresentationArtifactError::UnsupportedFeature { + action: request.action.clone(), + message: "nothing to redo".to_string(), + })?; + let entry = self.redo_stack.remove(position); + match &entry.after { + Some(document) => { + self.documents + .insert(entry.artifact_id.clone(), document.clone()); + } + None => { + self.documents.remove(&entry.artifact_id); + } + } + self.undo_stack.push(entry.clone()); + Ok(response_for_document_state( + entry.artifact_id, + request.action, + format!("Redid `{}`", entry.action), + entry.after.as_ref(), + )) + } + + fn create_layout( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: CreateLayoutArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let layout_id = document.next_layout_id(); + let kind = match args.kind.as_deref() { + Some("master") => LayoutKind::Master, + Some("layout") | None => LayoutKind::Layout, + Some(other) => { + return Err(PresentationArtifactError::InvalidArgs { + action: request.action, + message: format!("unsupported layout kind `{other}`"), + }); + } + }; + let parent_layout_id = args + .parent_layout_id + .map(|parent_layout_ref| { + document + .get_layout(&parent_layout_ref, &request.action) + .map(|layout| layout.layout_id.clone()) + }) + .transpose()?; + document.layouts.push(LayoutDocument { + layout_id: layout_id.clone(), + name: args.name, + kind, + parent_layout_id, + placeholders: Vec::new(), + }); + let mut response = PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Created layout `{layout_id}`"), + snapshot_for_document(document), + ); + response.layout_list = Some(layout_list(document)); + Ok(response) + } + + fn add_layout_placeholder( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: AddLayoutPlaceholderArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let geometry = args + .geometry + .as_deref() + .map(|value| parse_shape_geometry(value, &request.action)) + .transpose()? + .unwrap_or(ShapeGeometry::Rectangle); + let frame = args.position.unwrap_or(PositionArgs { + left: 48, + top: 72, + width: 624, + height: 96, + rotation: None, + flip_horizontal: None, + flip_vertical: None, + }); + let layout_id = document + .get_layout(&args.layout_id, &request.action)? + .layout_id + .clone(); + let layout = document + .layouts + .iter_mut() + .find(|layout| layout.layout_id == layout_id) + .ok_or_else(|| PresentationArtifactError::UnsupportedFeature { + action: request.action.clone(), + message: format!("unknown layout id `{}`", args.layout_id), + })?; + layout.placeholders.push(PlaceholderDefinition { + name: args.name, + placeholder_type: args.placeholder_type, + index: args.index, + text: args.text, + geometry, + frame: frame.into(), + }); + let mut response = PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Added placeholder to layout `{}`", layout.layout_id), + snapshot_for_document(document), + ); + response.layout_list = Some(layout_list(document)); + Ok(response) + } + + fn set_slide_layout( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: SetSlideLayoutArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let layout = document + .get_layout(&args.layout_id, &request.action)? + .clone(); + let placeholders = + resolved_layout_placeholders(document, &layout.layout_id, &request.action)? + .into_iter() + .map(|resolved| resolved.definition) + .collect::>(); + let mut placeholder_elements = Vec::new(); + for placeholder in placeholders { + placeholder_elements.push(materialize_placeholder_element( + document.next_element_id(), + placeholder, + placeholder_elements.len(), + )); + } + let slide = document.get_slide_mut(args.slide_index, &request.action)?; + slide.elements.retain(|element| match element { + PresentationElement::Text(text) => text.placeholder.is_none(), + PresentationElement::Shape(shape) => shape.placeholder.is_none(), + PresentationElement::Image(image) => image.placeholder.is_none(), + _ => true, + }); + slide.layout_id = Some(layout.layout_id.clone()); + slide.elements.extend(placeholder_elements); + resequence_z_order(slide); + let mut response = PresentationArtifactResponse::new( + artifact_id, + request.action, + format!( + "Applied layout `{}` to slide {}", + layout.layout_id, args.slide_index + ), + snapshot_for_document(document), + ); + response.slide_list = Some(slide_list(document)); + response.layout_list = Some(layout_list(document)); + Ok(response) + } + + fn update_placeholder_text( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: UpdatePlaceholderTextArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let slide = document.get_slide_mut(args.slide_index, &request.action)?; + let target_name = args.name.to_ascii_lowercase(); + let element = slide + .elements + .iter_mut() + .find(|element| match element { + PresentationElement::Text(text) => text + .placeholder + .as_ref() + .map(|placeholder| placeholder.name.eq_ignore_ascii_case(&target_name)) + .unwrap_or(false), + PresentationElement::Shape(shape) => shape + .placeholder + .as_ref() + .map(|placeholder| placeholder.name.eq_ignore_ascii_case(&target_name)) + .unwrap_or(false), + _ => false, + }) + .ok_or_else(|| PresentationArtifactError::UnsupportedFeature { + action: request.action.clone(), + message: format!( + "placeholder `{}` was not found on slide {}", + args.name, args.slide_index + ), + })?; + match element { + PresentationElement::Text(text) => text.text = args.text, + PresentationElement::Shape(shape) => shape.text = Some(args.text), + PresentationElement::Connector(_) + | PresentationElement::Image(_) + | PresentationElement::Table(_) + | PresentationElement::Chart(_) => {} + } + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!( + "Updated placeholder `{}` on slide {}", + args.name, args.slide_index + ), + snapshot_for_document(document), + )) + } + + fn set_theme( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: ThemeArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + document.theme = normalize_theme(args, &request.action)?; + let mut response = PresentationArtifactResponse::new( + artifact_id, + request.action, + "Updated theme".to_string(), + snapshot_for_document(document), + ); + response.theme = Some(document.theme_snapshot()); + Ok(response) + } + + fn add_style( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: AddStyleArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let style_name = normalize_style_name(&args.name, &request.action)?; + let mut style = + normalize_text_style_with_document(document, &args.styling, &request.action)?; + style.style_name = Some(style_name.clone()); + let style_record = NamedTextStyle { + name: style_name.clone(), + style: style.clone(), + built_in: false, + }; + document + .custom_text_styles + .insert(style_name.clone(), style); + let mut response = PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Added text style `{style_name}`"), + snapshot_for_document(document), + ); + response.resolved_record = Some(named_text_style_to_json(&style_record, "st")); + Ok(response) + } + + fn get_style( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: StyleNameArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document(&artifact_id, &request.action)?; + let normalized_style_name = normalize_style_name(&args.name, &request.action)?; + let named_style = document + .named_text_styles() + .into_iter() + .find(|style| style.name == normalized_style_name) + .ok_or_else(|| PresentationArtifactError::UnsupportedFeature { + action: request.action.clone(), + message: format!("unknown text style `{}`", args.name), + })?; + let mut response = PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Resolved text style `{}`", args.name), + snapshot_for_document(document), + ); + response.resolved_record = Some(named_text_style_to_json(&named_style, "st")); + Ok(response) + } + + fn describe_styles( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document(&artifact_id, &request.action)?; + let mut response = PresentationArtifactResponse::new( + artifact_id, + request.action, + "Described text styles".to_string(), + snapshot_for_document(document), + ); + response.resolved_record = Some(serde_json::json!({ + "kind": "styleList", + "styles": document + .named_text_styles() + .iter() + .map(|style| named_text_style_to_json(style, "st")) + .collect::>(), + })); + Ok(response) + } + + fn set_notes( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: NotesArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let slide = document.get_slide_mut(args.slide_index, &request.action)?; + slide.notes.text = args.text.unwrap_or_default(); + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Updated notes for slide {}", args.slide_index), + snapshot_for_document(document), + )) + } + + fn append_notes( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: NotesArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let slide = document.get_slide_mut(args.slide_index, &request.action)?; + let text = args.text.unwrap_or_default(); + if slide.notes.text.is_empty() { + slide.notes.text = text; + } else { + slide.notes.text = format!("{}\n{text}", slide.notes.text); + } + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Appended notes for slide {}", args.slide_index), + snapshot_for_document(document), + )) + } + + fn clear_notes( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: NotesArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let slide = document.get_slide_mut(args.slide_index, &request.action)?; + slide.notes.text.clear(); + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Cleared notes for slide {}", args.slide_index), + snapshot_for_document(document), + )) + } + + fn set_notes_visibility( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: NotesVisibilityArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let slide = document.get_slide_mut(args.slide_index, &request.action)?; + slide.notes.visible = args.visible; + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Updated notes visibility for slide {}", args.slide_index), + snapshot_for_document(document), + )) + } + + fn set_active_slide( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: SetActiveSlideArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + document.set_active_slide_index(args.slide_index as usize, &request.action)?; + let mut response = PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Set active slide to {}", args.slide_index), + snapshot_for_document(document), + ); + response.slide_list = Some(slide_list(document)); + response.active_slide_index = document.active_slide_index; + Ok(response) + } + + fn add_slide( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: AddSlideArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let mut slide = document.new_slide(args.notes, args.background_fill, &request.action)?; + if let Some(layout_id) = args.layout { + apply_layout_to_slide(document, &mut slide, &layout_id, &request.action)?; + } + let index = document.append_slide(slide); + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Added slide at index {index}"), + snapshot_for_document(document), + )) + } + + fn insert_slide( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: InsertSlideArgs = parse_args(&request.action, &request.args)?; + if args.index.is_some() && args.after_slide_index.is_some() { + return Err(PresentationArtifactError::InvalidArgs { + action: request.action, + message: "provide at most one of `index` or `after_slide_index`".to_string(), + }); + } + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let index = if let Some(index) = args.index { + to_index(index)? + } else if let Some(after_slide_index) = args.after_slide_index { + after_slide_index as usize + 1 + } else { + document + .active_slide_index + .map(|active_slide_index| active_slide_index + 1) + .unwrap_or(document.slides.len()) + }; + if index > document.slides.len() { + return Err(index_out_of_range( + &request.action, + index, + document.slides.len(), + )); + } + let mut slide = document.new_slide(args.notes, args.background_fill, &request.action)?; + if let Some(layout_id) = args.layout { + apply_layout_to_slide(document, &mut slide, &layout_id, &request.action)?; + } + document.adjust_active_slide_for_insert(index); + document.slides.insert(index, slide); + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Inserted slide at index {index}"), + snapshot_for_document(document), + )) + } + + fn duplicate_slide( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: SlideIndexArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let source = document + .slides + .get(args.slide_index as usize) + .cloned() + .ok_or_else(|| { + index_out_of_range( + &request.action, + args.slide_index as usize, + document.slides.len(), + ) + })?; + let duplicated = document.clone_slide(source); + let insert_at = args.slide_index as usize + 1; + document.adjust_active_slide_for_insert(insert_at); + document.slides.insert(insert_at, duplicated); + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Duplicated slide {} to index {insert_at}", args.slide_index), + snapshot_for_document(document), + )) + } + + fn move_slide( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: MoveSlideArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let from = args.from_index as usize; + let to = args.to_index as usize; + if from >= document.slides.len() { + return Err(index_out_of_range( + &request.action, + from, + document.slides.len(), + )); + } + if to >= document.slides.len() { + return Err(index_out_of_range( + &request.action, + to, + document.slides.len(), + )); + } + let slide = document.slides.remove(from); + document.slides.insert(to, slide); + document.adjust_active_slide_for_move(from, to); + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Moved slide from index {from} to {to}"), + snapshot_for_document(document), + )) + } + + fn delete_slide( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: SlideIndexArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let index = args.slide_index as usize; + if index >= document.slides.len() { + return Err(index_out_of_range( + &request.action, + index, + document.slides.len(), + )); + } + document.slides.remove(index); + document.adjust_active_slide_for_delete(index); + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Deleted slide at index {index}"), + snapshot_for_document(document), + )) + } + + fn set_slide_background( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: SetSlideBackgroundArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let fill = normalize_color_with_document(document, &args.fill, &request.action, "fill")?; + let slide = document.get_slide_mut(args.slide_index, &request.action)?; + slide.background_fill = Some(fill); + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Updated background for slide {}", args.slide_index), + snapshot_for_document(document), + )) + } + + fn add_text_shape( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: AddTextShapeArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let style = normalize_text_style_with_document(document, &args.styling, &request.action)?; + let fill = args + .styling + .fill + .as_deref() + .map(|value| normalize_color_with_document(document, value, &request.action, "fill")) + .transpose()?; + let element_id = document.next_element_id(); + let slide = document.get_slide_mut(args.slide_index, &request.action)?; + slide.elements.push(PresentationElement::Text(TextElement { + element_id: element_id.clone(), + text: args.text, + frame: args.position.into(), + fill, + style, + hyperlink: None, + placeholder: None, + z_order: slide.elements.len(), + })); + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!( + "Added text element `{element_id}` to slide {}", + args.slide_index + ), + snapshot_for_document(document), + )) + } + + fn add_shape( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: AddShapeArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let text_style = + normalize_text_style_with_document(document, &args.text_style, &request.action)?; + let fill = args + .fill + .as_deref() + .map(|value| normalize_color_with_document(document, value, &request.action, "fill")) + .transpose()?; + let stroke = parse_stroke(document, args.stroke, &request.action)?; + let element_id = document.next_element_id(); + let slide = document.get_slide_mut(args.slide_index, &request.action)?; + slide + .elements + .push(PresentationElement::Shape(ShapeElement { + element_id: element_id.clone(), + geometry: parse_shape_geometry(&args.geometry, &request.action)?, + frame: args.position.clone().into(), + fill, + stroke, + text: args.text, + text_style, + hyperlink: None, + placeholder: None, + rotation_degrees: args.rotation.or(args.position.rotation), + flip_horizontal: args + .flip_horizontal + .or(args.position.flip_horizontal) + .unwrap_or(false), + flip_vertical: args + .flip_vertical + .or(args.position.flip_vertical) + .unwrap_or(false), + z_order: slide.elements.len(), + })); + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!( + "Added shape element `{element_id}` to slide {}", + args.slide_index + ), + snapshot_for_document(document), + )) + } + + fn add_connector( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: AddConnectorArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let element_id = document.next_element_id(); + let line = parse_connector_line(document, args.line, &request.action)?; + let slide = document.get_slide_mut(args.slide_index, &request.action)?; + slide + .elements + .push(PresentationElement::Connector(ConnectorElement { + element_id: element_id.clone(), + connector_type: parse_connector_kind(&args.connector_type, &request.action)?, + start: args.start, + end: args.end, + line: StrokeStyle { + color: line.color, + width: line.width, + style: LineStyle::Solid, + }, + line_style: line.style, + start_arrow: args + .start_arrow + .as_deref() + .map(|value| parse_connector_arrow(value, &request.action)) + .transpose()? + .unwrap_or(ConnectorArrowKind::None), + end_arrow: args + .end_arrow + .as_deref() + .map(|value| parse_connector_arrow(value, &request.action)) + .transpose()? + .unwrap_or(ConnectorArrowKind::None), + arrow_size: args + .arrow_size + .as_deref() + .map(|value| parse_connector_arrow_size(value, &request.action)) + .transpose()? + .unwrap_or(ConnectorArrowScale::Medium), + label: args.label, + z_order: slide.elements.len(), + })); + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!( + "Added connector element `{element_id}` to slide {}", + args.slide_index + ), + snapshot_for_document(document), + )) + } + + fn add_image( + &mut self, + request: PresentationArtifactRequest, + cwd: &Path, + ) -> Result { + let args: AddImageArgs = parse_args(&request.action, &request.args)?; + let image_source = args.image_source()?; + let is_placeholder = matches!(image_source, ImageInputSource::Placeholder); + let image_payload = match image_source { + ImageInputSource::Path(path) => Some(load_image_payload_from_path( + &resolve_path(cwd, &path), + &request.action, + )?), + ImageInputSource::DataUrl(data_url) => Some(load_image_payload_from_data_url( + &data_url, + &request.action, + )?), + ImageInputSource::Blob(blob) => { + Some(load_image_payload_from_blob(&blob, &request.action)?) + } + ImageInputSource::Uri(uri) => Some(load_image_payload_from_uri(&uri, &request.action)?), + ImageInputSource::Placeholder => None, + }; + let fit_mode = args.fit.unwrap_or(ImageFitMode::Stretch); + let lock_aspect_ratio = args + .lock_aspect_ratio + .unwrap_or(fit_mode != ImageFitMode::Stretch); + let crop = args + .crop + .map(|crop| normalize_image_crop(crop, &request.action)) + .transpose()?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let element_id = document.next_element_id(); + let slide = document.get_slide_mut(args.slide_index, &request.action)?; + slide + .elements + .push(PresentationElement::Image(ImageElement { + element_id: element_id.clone(), + frame: args.position.clone().into(), + payload: image_payload, + fit_mode, + crop, + rotation_degrees: args.rotation.or(args.position.rotation), + flip_horizontal: args + .flip_horizontal + .or(args.position.flip_horizontal) + .unwrap_or(false), + flip_vertical: args + .flip_vertical + .or(args.position.flip_vertical) + .unwrap_or(false), + lock_aspect_ratio, + alt_text: args.alt, + prompt: args.prompt, + is_placeholder, + placeholder: None, + z_order: slide.elements.len(), + })); + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!( + "Added image element `{element_id}` to slide {}", + args.slide_index + ), + snapshot_for_document(document), + )) + } + + fn replace_image( + &mut self, + request: PresentationArtifactRequest, + cwd: &Path, + ) -> Result { + let args: ReplaceImageArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let image_source = match ( + &args.path, + &args.data_url, + &args.blob, + &args.uri, + &args.prompt, + ) { + (Some(path), None, None, None, None) => ImageInputSource::Path(path.clone()), + (None, Some(data_url), None, None, None) => ImageInputSource::DataUrl(data_url.clone()), + (None, None, Some(blob), None, None) => ImageInputSource::Blob(blob.clone()), + (None, None, None, Some(uri), None) => ImageInputSource::Uri(uri.clone()), + (None, None, None, None, Some(_)) => ImageInputSource::Placeholder, + _ => { + return Err(PresentationArtifactError::InvalidArgs { + action: request.action, + message: + "provide exactly one of `path`, `data_url`, `blob`, or `uri`, or provide `prompt` for a placeholder image" + .to_string(), + }); + } + }; + let is_placeholder = matches!(image_source, ImageInputSource::Placeholder); + let image_payload = match image_source { + ImageInputSource::Path(path) => Some(load_image_payload_from_path( + &resolve_path(cwd, &path), + "replace_image", + )?), + ImageInputSource::DataUrl(data_url) => Some(load_image_payload_from_data_url( + &data_url, + "replace_image", + )?), + ImageInputSource::Blob(blob) => { + Some(load_image_payload_from_blob(&blob, "replace_image")?) + } + ImageInputSource::Uri(uri) => Some(load_image_payload_from_uri(&uri, "replace_image")?), + ImageInputSource::Placeholder => None, + }; + let fit_mode = args.fit.unwrap_or(ImageFitMode::Stretch); + let lock_aspect_ratio = args + .lock_aspect_ratio + .unwrap_or(fit_mode != ImageFitMode::Stretch); + let crop = args + .crop + .map(|crop| normalize_image_crop(crop, &request.action)) + .transpose()?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let element = document.find_element_mut(&args.element_id, &request.action)?; + let PresentationElement::Image(image) = element else { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: format!("element `{}` is not an image", args.element_id), + }); + }; + image.payload = image_payload; + image.fit_mode = fit_mode; + image.crop = crop; + if let Some(rotation) = args.rotation { + image.rotation_degrees = Some(rotation); + } + if let Some(flip_horizontal) = args.flip_horizontal { + image.flip_horizontal = flip_horizontal; + } + if let Some(flip_vertical) = args.flip_vertical { + image.flip_vertical = flip_vertical; + } + image.lock_aspect_ratio = lock_aspect_ratio; + image.alt_text = args.alt; + image.prompt = args.prompt; + image.is_placeholder = is_placeholder; + Ok(PresentationArtifactResponse::new( + artifact_id, + "replace_image".to_string(), + format!("Replaced image `{}`", args.element_id), + snapshot_for_document(document), + )) + } + + fn add_table( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: AddTableArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let rows = coerce_table_rows(args.rows, &request.action)?; + let mut frame: Rect = args.position.into(); + let (column_widths, row_heights) = normalize_table_dimensions( + &rows, + frame, + args.column_widths, + args.row_heights, + &request.action, + )?; + frame.width = column_widths.iter().sum(); + frame.height = row_heights.iter().sum(); + let element_id = document.next_element_id(); + let slide = document.get_slide_mut(args.slide_index, &request.action)?; + slide + .elements + .push(PresentationElement::Table(TableElement { + element_id: element_id.clone(), + frame, + rows, + column_widths, + row_heights, + style: args.style, + merges: Vec::new(), + z_order: slide.elements.len(), + })); + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!( + "Added table element `{element_id}` to slide {}", + args.slide_index + ), + snapshot_for_document(document), + )) + } + + fn update_table_cell( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: UpdateTableCellArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let text_style = + normalize_text_style_with_document(document, &args.styling, &request.action)?; + let background_fill = args + .background_fill + .as_deref() + .map(|fill| { + normalize_color_with_document(document, fill, &request.action, "background_fill") + }) + .transpose()?; + let element = document.find_element_mut(&args.element_id, &request.action)?; + let PresentationElement::Table(table) = element else { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: format!("element `{}` is not a table", args.element_id), + }); + }; + let row = args.row as usize; + let column = args.column as usize; + if row >= table.rows.len() || column >= table.rows[row].len() { + return Err(PresentationArtifactError::InvalidArgs { + action: request.action, + message: format!("cell ({row}, {column}) is out of bounds"), + }); + } + let cell = &mut table.rows[row][column]; + cell.text = cell_value_to_string(args.value); + cell.text_style = text_style; + cell.background_fill = background_fill; + cell.alignment = args + .alignment + .as_deref() + .map(|value| parse_alignment(value, &request.action)) + .transpose()?; + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Updated table cell ({row}, {column})"), + snapshot_for_document(document), + )) + } + + fn merge_table_cells( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: MergeTableCellsArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let element = document.find_element_mut(&args.element_id, &request.action)?; + let PresentationElement::Table(table) = element else { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: format!("element `{}` is not a table", args.element_id), + }); + }; + let region = TableMergeRegion { + start_row: args.start_row as usize, + end_row: args.end_row as usize, + start_column: args.start_column as usize, + end_column: args.end_column as usize, + }; + table.merges.push(region); + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Merged table cells in `{}`", args.element_id), + snapshot_for_document(document), + )) + } + + fn add_chart( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: AddChartArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let chart_type = parse_chart_type(&args.chart_type, &request.action)?; + let series = args + .series + .into_iter() + .map(|entry| { + if entry.values.is_empty() { + return Err(PresentationArtifactError::InvalidArgs { + action: request.action.clone(), + message: format!("series `{}` must contain at least one value", entry.name), + }); + } + Ok(ChartSeriesSpec { + name: entry.name, + values: entry.values, + }) + }) + .collect::, _>>()?; + let element_id = document.next_element_id(); + let slide = document.get_slide_mut(args.slide_index, &request.action)?; + slide + .elements + .push(PresentationElement::Chart(ChartElement { + element_id: element_id.clone(), + frame: args.position.into(), + chart_type, + categories: args.categories, + series, + title: args.title, + z_order: slide.elements.len(), + })); + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!( + "Added chart element `{element_id}` to slide {}", + args.slide_index + ), + snapshot_for_document(document), + )) + } + + fn update_text( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: UpdateTextArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let style = normalize_text_style_with_document(document, &args.styling, &request.action)?; + let fill = args + .styling + .fill + .as_deref() + .map(|value| normalize_color_with_document(document, value, &request.action, "fill")) + .transpose()?; + let element = document.find_element_mut(&args.element_id, &request.action)?; + match element { + PresentationElement::Text(text) => { + text.text = args.text; + if let Some(fill) = fill.clone() { + text.fill = Some(fill); + } + text.style = style; + } + PresentationElement::Shape(shape) => { + if shape.text.is_none() { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: format!( + "element `{}` does not contain editable text", + args.element_id + ), + }); + } + shape.text = Some(args.text); + if let Some(fill) = fill { + shape.fill = Some(fill); + } + shape.text_style = style; + } + other => { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: format!( + "element `{}` is `{}`; only text-bearing elements support `update_text`", + args.element_id, + other.kind() + ), + }); + } + } + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Updated text for element `{}`", args.element_id), + snapshot_for_document(document), + )) + } + + fn replace_text( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: ReplaceTextArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let element = document.find_element_mut(&args.element_id, &request.action)?; + match element { + PresentationElement::Text(text) => { + if !text.text.contains(&args.search) { + return Err(PresentationArtifactError::InvalidArgs { + action: request.action, + message: format!( + "text `{}` was not found in element `{}`", + args.search, args.element_id + ), + }); + } + text.text = text.text.replace(&args.search, &args.replace); + } + PresentationElement::Shape(shape) => { + let Some(text) = &mut shape.text else { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: format!( + "element `{}` does not contain editable text", + args.element_id + ), + }); + }; + if !text.contains(&args.search) { + return Err(PresentationArtifactError::InvalidArgs { + action: request.action, + message: format!( + "text `{}` was not found in element `{}`", + args.search, args.element_id + ), + }); + } + *text = text.replace(&args.search, &args.replace); + } + other => { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: format!( + "element `{}` is `{}`; only text-bearing elements support `replace_text`", + args.element_id, + other.kind() + ), + }); + } + } + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Replaced text in element `{}`", args.element_id), + snapshot_for_document(document), + )) + } + + fn insert_text_after( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: InsertTextAfterArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let element = document.find_element_mut(&args.element_id, &request.action)?; + match element { + PresentationElement::Text(text) => { + let Some(index) = text.text.find(&args.after) else { + return Err(PresentationArtifactError::InvalidArgs { + action: request.action, + message: format!( + "text `{}` was not found in element `{}`", + args.after, args.element_id + ), + }); + }; + let insert_at = index + args.after.len(); + text.text.insert_str(insert_at, &args.insert); + } + PresentationElement::Shape(shape) => { + let Some(text) = &mut shape.text else { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: format!( + "element `{}` does not contain editable text", + args.element_id + ), + }); + }; + let Some(index) = text.find(&args.after) else { + return Err(PresentationArtifactError::InvalidArgs { + action: request.action, + message: format!( + "text `{}` was not found in element `{}`", + args.after, args.element_id + ), + }); + }; + let insert_at = index + args.after.len(); + text.insert_str(insert_at, &args.insert); + } + other => { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: format!( + "element `{}` is `{}`; only text-bearing elements support `insert_text_after`", + args.element_id, + other.kind() + ), + }); + } + } + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Inserted text in element `{}`", args.element_id), + snapshot_for_document(document), + )) + } + + fn set_hyperlink( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: SetHyperlinkArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let clear = args.clear.unwrap_or(false); + let hyperlink = if clear { + None + } else { + Some(parse_hyperlink_state(document, &args, &request.action)?) + }; + let element = document.find_element_mut(&args.element_id, &request.action)?; + match element { + PresentationElement::Text(text) => text.hyperlink = hyperlink, + PresentationElement::Shape(shape) => shape.hyperlink = hyperlink, + other => { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: format!( + "element `{}` is `{}`; only text boxes and shapes support `set_hyperlink`", + args.element_id, + other.kind() + ), + }); + } + } + Ok(PresentationArtifactResponse::new( + artifact_id, + "set_hyperlink".to_string(), + if clear { + format!("Cleared hyperlink for element `{}`", args.element_id) + } else { + format!("Updated hyperlink for element `{}`", args.element_id) + }, + snapshot_for_document(document), + )) + } + + fn update_shape_style( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: UpdateShapeStyleArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let fill = args + .fill + .as_deref() + .map(|value| normalize_color_with_document(document, value, &request.action, "fill")) + .transpose()?; + let stroke = args + .stroke + .clone() + .map(|value| parse_required_stroke(document, value, &request.action)) + .transpose()?; + let element = document.find_element_mut(&args.element_id, &request.action)?; + match element { + PresentationElement::Text(text) => { + if let Some(position) = args.position { + text.frame = apply_partial_position(text.frame, position); + } + if let Some(fill) = fill.clone() { + text.fill = Some(fill); + } + if args.stroke.is_some() + || args.rotation.is_some() + || args.flip_horizontal.is_some() + || args.flip_vertical.is_some() + { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: + "text elements support only `position`, `z_order`, and `fill` updates" + .to_string(), + }); + } + } + PresentationElement::Shape(shape) => { + let position_rotation = args + .position + .as_ref() + .and_then(|position| position.rotation); + let position_flip_horizontal = args + .position + .as_ref() + .and_then(|position| position.flip_horizontal); + let position_flip_vertical = args + .position + .as_ref() + .and_then(|position| position.flip_vertical); + if let Some(position) = args.position { + shape.frame = apply_partial_position(shape.frame, position); + } + if let Some(fill) = fill { + shape.fill = Some(fill); + } + if let Some(stroke) = stroke { + shape.stroke = Some(stroke); + } + if let Some(rotation) = args.rotation.or(position_rotation) { + shape.rotation_degrees = Some(rotation); + } + if let Some(flip_horizontal) = args.flip_horizontal.or(position_flip_horizontal) { + shape.flip_horizontal = flip_horizontal; + } + if let Some(flip_vertical) = args.flip_vertical.or(position_flip_vertical) { + shape.flip_vertical = flip_vertical; + } + } + PresentationElement::Connector(connector) => { + if args.fill.is_some() + || args.rotation.is_some() + || args.flip_horizontal.is_some() + || args.flip_vertical.is_some() + || args.fit.is_some() + || args.crop.is_some() + || args.lock_aspect_ratio.is_some() + { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: + "connector elements support only `position`, `stroke`, and `z_order` updates" + .to_string(), + }); + } + if let Some(position) = args.position { + let updated = apply_partial_position( + Rect { + left: connector.start.left, + top: connector.start.top, + width: connector.end.left.abs_diff(connector.start.left), + height: connector.end.top.abs_diff(connector.start.top), + }, + position, + ); + connector.start = PointArgs { + left: updated.left, + top: updated.top, + }; + connector.end = PointArgs { + left: updated.left.saturating_add(updated.width), + top: updated.top.saturating_add(updated.height), + }; + } + if let Some(stroke) = stroke { + connector.line = stroke; + } + } + PresentationElement::Image(image) => { + let position_rotation = args + .position + .as_ref() + .and_then(|position| position.rotation); + let position_flip_horizontal = args + .position + .as_ref() + .and_then(|position| position.flip_horizontal); + let position_flip_vertical = args + .position + .as_ref() + .and_then(|position| position.flip_vertical); + if args.fill.is_some() || args.stroke.is_some() { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: + "image elements support only `position`, `fit`, `crop`, `rotation`, `flip_horizontal`, `flip_vertical`, `lock_aspect_ratio`, and `z_order` updates" + .to_string(), + }); + } + if let Some(position) = args.position { + image.frame = apply_partial_position_to_image(image, position); + } + if let Some(fit) = args.fit { + image.fit_mode = fit; + if !matches!(fit, ImageFitMode::Stretch) && args.lock_aspect_ratio.is_none() { + image.lock_aspect_ratio = true; + } + } + if let Some(crop) = args.crop { + image.crop = Some(normalize_image_crop(crop, &request.action)?); + } + if let Some(rotation) = args.rotation.or(position_rotation) { + image.rotation_degrees = Some(rotation); + } + if let Some(flip_horizontal) = args.flip_horizontal.or(position_flip_horizontal) { + image.flip_horizontal = flip_horizontal; + } + if let Some(flip_vertical) = args.flip_vertical.or(position_flip_vertical) { + image.flip_vertical = flip_vertical; + } + if let Some(lock_aspect_ratio) = args.lock_aspect_ratio { + image.lock_aspect_ratio = lock_aspect_ratio; + } + } + PresentationElement::Table(table) => { + if args.fill.is_some() + || args.stroke.is_some() + || args.rotation.is_some() + || args.flip_horizontal.is_some() + || args.flip_vertical.is_some() + { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: "table elements support only `position` and `z_order` updates" + .to_string(), + }); + } + if let Some(position) = args.position { + table.frame = apply_partial_position(table.frame, position); + } + } + PresentationElement::Chart(chart) => { + if args.fill.is_some() + || args.stroke.is_some() + || args.rotation.is_some() + || args.flip_horizontal.is_some() + || args.flip_vertical.is_some() + { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: "chart elements support only `position` and `z_order` updates" + .to_string(), + }); + } + if let Some(position) = args.position { + chart.frame = apply_partial_position(chart.frame, position); + } + } + } + if let Some(z_order) = args.z_order { + document.set_z_order(&args.element_id, z_order as usize, &request.action)?; + } + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Updated style for element `{}`", args.element_id), + snapshot_for_document(document), + )) + } + + fn delete_element( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: ElementIdArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + document.remove_element(&args.element_id, &request.action)?; + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Deleted element `{}`", args.element_id), + snapshot_for_document(document), + )) + } + + fn bring_to_front( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: ElementIdArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let target_index = document.total_element_count(); + document.set_z_order(&args.element_id, target_index, &request.action)?; + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Brought `{}` to front", args.element_id), + snapshot_for_document(document), + )) + } + + fn send_to_back( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: ElementIdArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + document.set_z_order(&args.element_id, 0, &request.action)?; + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Sent `{}` to back", args.element_id), + snapshot_for_document(document), + )) + } + + fn delete_artifact( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let artifact_id = required_artifact_id(&request)?; + let removed = self.documents.remove(&artifact_id).ok_or_else(|| { + PresentationArtifactError::UnknownArtifactId { + action: request.action.clone(), + artifact_id: artifact_id.clone(), + } + })?; + Ok(PresentationArtifactResponse { + artifact_id, + action: request.action, + summary: format!( + "Deleted in-memory artifact `{}` with {} slides", + removed.artifact_id, + removed.slides.len() + ), + exported_paths: Vec::new(), + artifact_snapshot: None, + slide_list: None, + layout_list: None, + placeholder_list: None, + theme: None, + inspect_ndjson: None, + resolved_record: None, + proto_json: None, + patch: None, + active_slide_index: None, + }) + } + + fn get_document( + &self, + artifact_id: &str, + action: &str, + ) -> Result<&PresentationDocument, PresentationArtifactError> { + self.documents.get(artifact_id).ok_or_else(|| { + PresentationArtifactError::UnknownArtifactId { + action: action.to_string(), + artifact_id: artifact_id.to_string(), + } + }) + } + + fn get_document_mut( + &mut self, + artifact_id: &str, + action: &str, + ) -> Result<&mut PresentationDocument, PresentationArtifactError> { + self.documents.get_mut(artifact_id).ok_or_else(|| { + PresentationArtifactError::UnknownArtifactId { + action: action.to_string(), + artifact_id: artifact_id.to_string(), + } + }) + } + + fn normalize_patch( + &self, + request_artifact_id: Option<&str>, + operations: Vec, + action: &str, + ) -> Result { + if operations.is_empty() { + return Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: "`operations` must contain at least one entry".to_string(), + }); + } + let mut patch_artifact_id = request_artifact_id.map(str::to_owned); + let mut normalized_operations = Vec::with_capacity(operations.len()); + for operation in operations { + let operation_artifact_id = operation + .artifact_id + .or_else(|| request_artifact_id.map(str::to_owned)) + .ok_or_else(|| PresentationArtifactError::MissingArtifactId { + action: action.to_string(), + })?; + if let Some(existing_artifact_id) = &patch_artifact_id { + if existing_artifact_id != &operation_artifact_id { + return Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!( + "patch operations must target a single artifact, found both `{existing_artifact_id}` and `{operation_artifact_id}`" + ), + }); + } + } else { + patch_artifact_id = Some(operation_artifact_id.clone()); + } + if !patch_operation_supported(&operation.action) { + return Err(PresentationArtifactError::UnsupportedFeature { + action: action.to_string(), + message: format!( + "patch operations do not support nested action `{}`", + operation.action + ), + }); + } + normalized_operations.push(PatchOperation { + action: operation.action, + args: operation.args, + }); + } + let artifact_id = + patch_artifact_id.ok_or_else(|| PresentationArtifactError::MissingArtifactId { + action: action.to_string(), + })?; + self.get_document(&artifact_id, action)?; + Ok(PresentationPatch { + version: 1, + artifact_id, + operations: normalized_operations, + }) + } + + fn normalize_serialized_patch( + &self, + request_artifact_id: Option<&str>, + patch: PresentationPatch, + action: &str, + ) -> Result { + if patch.version != 1 { + return Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("unsupported patch version `{}`", patch.version), + }); + } + if patch.operations.is_empty() { + return Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: "`patch.operations` must contain at least one entry".to_string(), + }); + } + if let Some(request_artifact_id) = request_artifact_id + && request_artifact_id != patch.artifact_id + { + return Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!( + "request artifact `{request_artifact_id}` does not match patch artifact `{}`", + patch.artifact_id + ), + }); + } + for operation in &patch.operations { + if !patch_operation_supported(&operation.action) { + return Err(PresentationArtifactError::UnsupportedFeature { + action: action.to_string(), + message: format!( + "patch operations do not support nested action `{}`", + operation.action + ), + }); + } + } + self.get_document(&patch.artifact_id, action)?; + Ok(patch) + } +} diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/mod.rs b/codex-rs/artifact-presentation/src/presentation_artifact/mod.rs new file mode 100644 index 000000000..8497062a9 --- /dev/null +++ b/codex-rs/artifact-presentation/src/presentation_artifact/mod.rs @@ -0,0 +1,10 @@ +include!("api.rs"); +include!("manager.rs"); +include!("response.rs"); +include!("model.rs"); +include!("args.rs"); +include!("parsing.rs"); +include!("proto.rs"); +include!("inspect.rs"); +include!("pptx.rs"); +include!("snapshot.rs"); diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/model.rs b/codex-rs/artifact-presentation/src/presentation_artifact/model.rs new file mode 100644 index 000000000..80a8566fa --- /dev/null +++ b/codex-rs/artifact-presentation/src/presentation_artifact/model.rs @@ -0,0 +1,1691 @@ +#[derive(Debug, Clone, Default)] +struct ThemeState { + color_scheme: HashMap, + major_font: Option, + minor_font: Option, +} + +impl ThemeState { + fn resolve_color(&self, color: &str) -> Option { + let key = color.trim().to_ascii_lowercase(); + let alias = match key.as_str() { + "background1" => "bg1", + "background2" => "bg2", + "text1" => "tx1", + "text2" => "tx2", + "dark1" => "dk1", + "dark2" => "dk2", + "light1" => "lt1", + "light2" => "lt2", + other => other, + }; + self.color_scheme + .get(alias) + .or_else(|| self.color_scheme.get(&key)) + .cloned() + .map(|value| value.trim_start_matches('#').to_uppercase()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LayoutKind { + Layout, + Master, +} + +#[derive(Debug, Clone)] +struct LayoutDocument { + layout_id: String, + name: String, + kind: LayoutKind, + parent_layout_id: Option, + placeholders: Vec, +} + +#[derive(Debug, Clone)] +struct PlaceholderDefinition { + name: String, + placeholder_type: String, + index: Option, + text: Option, + geometry: ShapeGeometry, + frame: Rect, +} + +#[derive(Debug, Clone)] +struct ResolvedPlaceholder { + source_layout_id: String, + definition: PlaceholderDefinition, +} + +#[derive(Debug, Clone, Default)] +struct NotesState { + text: String, + visible: bool, +} + +#[derive(Debug, Clone, Default)] +struct TextStyle { + style_name: Option, + font_size: Option, + font_family: Option, + color: Option, + alignment: Option, + bold: bool, + italic: bool, + underline: bool, +} + +#[derive(Debug, Clone)] +struct NamedTextStyle { + name: String, + style: TextStyle, + built_in: bool, +} + +#[derive(Debug, Clone)] +struct HyperlinkState { + target: HyperlinkTarget, + tooltip: Option, + highlight_click: bool, +} + +#[derive(Debug, Clone)] +enum HyperlinkTarget { + Url(String), + Slide(u32), + FirstSlide, + LastSlide, + NextSlide, + PreviousSlide, + EndShow, + Email { + address: String, + subject: Option, + }, + File(String), +} + +impl HyperlinkTarget { + fn relationship_target(&self) -> String { + match self { + Self::Url(url) => url.clone(), + Self::Slide(slide_index) => format!("slide{}.xml", slide_index + 1), + Self::FirstSlide => "ppaction://hlinkshowjump?jump=firstslide".to_string(), + Self::LastSlide => "ppaction://hlinkshowjump?jump=lastslide".to_string(), + Self::NextSlide => "ppaction://hlinkshowjump?jump=nextslide".to_string(), + Self::PreviousSlide => "ppaction://hlinkshowjump?jump=previousslide".to_string(), + Self::EndShow => "ppaction://hlinkshowjump?jump=endshow".to_string(), + Self::Email { address, subject } => { + let mut mailto = format!("mailto:{address}"); + if let Some(subject) = subject { + mailto.push_str(&format!("?subject={subject}")); + } + mailto + } + Self::File(path) => format!("file:///{}", path.replace('\\', "/")), + } + } + + fn is_external(&self) -> bool { + matches!(self, Self::Url(_) | Self::Email { .. } | Self::File(_)) + } +} + +impl HyperlinkState { + fn to_ppt_rs(&self, relationship_id: &str) -> PptHyperlink { + let hyperlink = match &self.target { + HyperlinkTarget::Url(url) => PptHyperlink::new(PptHyperlinkAction::url(url)), + HyperlinkTarget::Slide(slide_index) => { + PptHyperlink::new(PptHyperlinkAction::slide(slide_index + 1)) + } + HyperlinkTarget::FirstSlide => PptHyperlink::new(PptHyperlinkAction::FirstSlide), + HyperlinkTarget::LastSlide => PptHyperlink::new(PptHyperlinkAction::LastSlide), + HyperlinkTarget::NextSlide => PptHyperlink::new(PptHyperlinkAction::NextSlide), + HyperlinkTarget::PreviousSlide => PptHyperlink::new(PptHyperlinkAction::PreviousSlide), + HyperlinkTarget::EndShow => PptHyperlink::new(PptHyperlinkAction::EndShow), + HyperlinkTarget::Email { address, subject } => PptHyperlink::new(match subject { + Some(subject) => PptHyperlinkAction::email_with_subject(address, subject), + None => PptHyperlinkAction::email(address), + }), + HyperlinkTarget::File(path) => PptHyperlink::new(PptHyperlinkAction::file(path)), + }; + let hyperlink = if let Some(tooltip) = &self.tooltip { + hyperlink.with_tooltip(tooltip) + } else { + hyperlink + }; + hyperlink + .with_highlight_click(self.highlight_click) + .with_r_id(relationship_id) + } + + fn to_json(&self) -> Value { + let mut record = match &self.target { + HyperlinkTarget::Url(url) => serde_json::json!({ + "type": "url", + "url": url, + }), + HyperlinkTarget::Slide(slide_index) => serde_json::json!({ + "type": "slide", + "slideIndex": slide_index, + }), + HyperlinkTarget::FirstSlide => serde_json::json!({ + "type": "firstSlide", + }), + HyperlinkTarget::LastSlide => serde_json::json!({ + "type": "lastSlide", + }), + HyperlinkTarget::NextSlide => serde_json::json!({ + "type": "nextSlide", + }), + HyperlinkTarget::PreviousSlide => serde_json::json!({ + "type": "previousSlide", + }), + HyperlinkTarget::EndShow => serde_json::json!({ + "type": "endShow", + }), + HyperlinkTarget::Email { address, subject } => serde_json::json!({ + "type": "email", + "address": address, + "subject": subject, + }), + HyperlinkTarget::File(path) => serde_json::json!({ + "type": "file", + "path": path, + }), + }; + record["tooltip"] = self + .tooltip + .as_ref() + .map(|tooltip| Value::String(tooltip.clone())) + .unwrap_or(Value::Null); + record["highlightClick"] = Value::Bool(self.highlight_click); + record + } + + fn relationship_xml(&self, relationship_id: &str) -> String { + let target_mode = if self.target.is_external() { + r#" TargetMode="External""# + } else { + "" + }; + format!( + r#""#, + ppt_rs::escape_xml(&self.target.relationship_target()), + ) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +enum TextAlignment { + Left, + Center, + Right, + Justify, +} + +#[derive(Debug, Clone)] +pub(crate) struct PlaceholderRef { + name: String, + placeholder_type: String, + index: Option, +} + +#[derive(Debug, Clone)] +struct TableMergeRegion { + start_row: usize, + end_row: usize, + start_column: usize, + end_column: usize, +} + +#[derive(Debug, Clone)] +struct TableCellSpec { + text: String, + text_style: TextStyle, + background_fill: Option, + alignment: Option, +} + +#[derive(Debug, Clone)] +struct PresentationDocument { + artifact_id: String, + name: Option, + slide_size: Rect, + theme: ThemeState, + custom_text_styles: HashMap, + layouts: Vec, + slides: Vec, + active_slide_index: Option, + next_slide_seq: u32, + next_element_seq: u32, + next_layout_seq: u32, +} + +impl PresentationDocument { + fn new(name: Option) -> Self { + Self { + artifact_id: format!("presentation_{}", Uuid::new_v4().simple()), + name, + slide_size: Rect { + left: 0, + top: 0, + width: DEFAULT_SLIDE_WIDTH_POINTS, + height: DEFAULT_SLIDE_HEIGHT_POINTS, + }, + theme: ThemeState::default(), + custom_text_styles: HashMap::new(), + layouts: Vec::new(), + slides: Vec::new(), + active_slide_index: None, + next_slide_seq: 1, + next_element_seq: 1, + next_layout_seq: 1, + } + } + + fn from_ppt_rs(presentation: Presentation) -> Self { + let mut document = Self::new( + (!presentation.get_title().is_empty()).then(|| presentation.get_title().to_string()), + ); + for imported_slide in presentation.slides() { + let mut slide = PresentationSlide { + slide_id: format!("slide_{}", document.next_slide_seq), + notes: NotesState { + text: imported_slide.notes.clone().unwrap_or_default(), + visible: true, + }, + background_fill: None, + layout_id: None, + elements: Vec::new(), + }; + document.next_slide_seq += 1; + + if !imported_slide.title.is_empty() { + slide.elements.push(PresentationElement::Text(TextElement { + element_id: document.next_element_id(), + text: imported_slide.title.clone(), + frame: Rect { + left: DEFAULT_IMPORTED_TITLE_LEFT, + top: DEFAULT_IMPORTED_TITLE_TOP, + width: DEFAULT_IMPORTED_TITLE_WIDTH, + height: DEFAULT_IMPORTED_TITLE_HEIGHT, + }, + fill: None, + style: TextStyle::default(), + hyperlink: None, + placeholder: None, + z_order: slide.elements.len(), + })); + } + + if !imported_slide.content.is_empty() { + slide.elements.push(PresentationElement::Text(TextElement { + element_id: document.next_element_id(), + text: imported_slide.content.join("\n"), + frame: Rect { + left: DEFAULT_IMPORTED_CONTENT_LEFT, + top: DEFAULT_IMPORTED_CONTENT_TOP, + width: DEFAULT_IMPORTED_CONTENT_WIDTH, + height: DEFAULT_IMPORTED_CONTENT_HEIGHT, + }, + fill: None, + style: TextStyle::default(), + hyperlink: None, + placeholder: None, + z_order: slide.elements.len(), + })); + } + + for imported_shape in &imported_slide.shapes { + slide + .elements + .push(PresentationElement::Shape(ShapeElement { + element_id: document.next_element_id(), + geometry: ShapeGeometry::from_shape_type(imported_shape.shape_type), + frame: Rect::from_emu( + imported_shape.x, + imported_shape.y, + imported_shape.width, + imported_shape.height, + ), + fill: imported_shape.fill.as_ref().map(|fill| fill.color.clone()), + stroke: imported_shape.line.as_ref().map(|line| StrokeStyle { + color: line.color.clone(), + width: emu_to_points(line.width), + style: LineStyle::Solid, + }), + text: imported_shape.text.clone(), + text_style: TextStyle::default(), + hyperlink: None, + placeholder: None, + rotation_degrees: imported_shape.rotation, + flip_horizontal: false, + flip_vertical: false, + z_order: slide.elements.len(), + })); + } + + if let Some(imported_table) = &imported_slide.table { + slide + .elements + .push(PresentationElement::Table(TableElement { + element_id: document.next_element_id(), + frame: Rect::from_emu( + imported_table.x, + imported_table.y, + imported_table.width(), + imported_table.height(), + ), + rows: imported_table + .rows + .iter() + .map(|row| { + row.cells + .iter() + .map(|text| TableCellSpec { + text: text.text.clone(), + text_style: TextStyle::default(), + background_fill: None, + alignment: None, + }) + .collect() + }) + .collect(), + column_widths: imported_table + .column_widths + .iter() + .copied() + .map(emu_to_points) + .collect(), + row_heights: imported_table + .rows + .iter() + .map(|row| row.height.map_or(400_000, |height| height)) + .map(emu_to_points) + .collect(), + style: None, + merges: Vec::new(), + z_order: slide.elements.len(), + })); + } + + document.slides.push(slide); + } + document.active_slide_index = (!document.slides.is_empty()).then_some(0); + document + } + + fn new_slide( + &mut self, + notes: Option, + background_fill: Option, + action: &str, + ) -> Result { + let normalized_fill = background_fill + .map(|value| { + normalize_color_with_palette(Some(&self.theme), &value, action, "background_fill") + }) + .transpose()?; + let slide = PresentationSlide { + slide_id: format!("slide_{}", self.next_slide_seq), + notes: NotesState { + text: notes.unwrap_or_default(), + visible: true, + }, + background_fill: normalized_fill, + layout_id: None, + elements: Vec::new(), + }; + self.next_slide_seq += 1; + Ok(slide) + } + + fn append_slide(&mut self, slide: PresentationSlide) -> usize { + let index = self.slides.len(); + self.slides.push(slide); + if self.active_slide_index.is_none() { + self.active_slide_index = Some(index); + } + index + } + + fn clone_slide(&mut self, slide: PresentationSlide) -> PresentationSlide { + let mut clone = slide; + clone.slide_id = format!("slide_{}", self.next_slide_seq); + self.next_slide_seq += 1; + for element in &mut clone.elements { + element.set_element_id(self.next_element_id()); + } + clone + } + + fn next_element_id(&mut self) -> String { + let element_id = format!("element_{}", self.next_element_seq); + self.next_element_seq += 1; + element_id + } + + fn total_element_count(&self) -> usize { + self.slides.iter().map(|slide| slide.elements.len()).sum() + } + + fn set_active_slide_index( + &mut self, + slide_index: usize, + action: &str, + ) -> Result<(), PresentationArtifactError> { + if slide_index >= self.slides.len() { + return Err(index_out_of_range(action, slide_index, self.slides.len())); + } + self.active_slide_index = Some(slide_index); + Ok(()) + } + + fn adjust_active_slide_for_insert(&mut self, inserted_index: usize) { + match self.active_slide_index { + None => self.active_slide_index = Some(inserted_index), + Some(active_index) if inserted_index <= active_index => { + self.active_slide_index = Some(active_index + 1); + } + Some(_) => {} + } + } + + fn adjust_active_slide_for_move(&mut self, from_index: usize, to_index: usize) { + if let Some(active_index) = self.active_slide_index { + self.active_slide_index = Some(if active_index == from_index { + to_index + } else if from_index < active_index && active_index <= to_index { + active_index - 1 + } else if to_index <= active_index && active_index < from_index { + active_index + 1 + } else { + active_index + }); + } + } + + fn adjust_active_slide_for_delete(&mut self, deleted_index: usize) { + self.active_slide_index = match self.active_slide_index { + None => None, + Some(_) if self.slides.is_empty() => None, + Some(active_index) if active_index == deleted_index => { + Some(deleted_index.min(self.slides.len() - 1)) + } + Some(active_index) if deleted_index < active_index => Some(active_index - 1), + Some(active_index) => Some(active_index), + }; + } + + fn next_layout_id(&mut self) -> String { + let layout_id = format!("layout_{}", self.next_layout_seq); + self.next_layout_seq += 1; + layout_id + } + + fn get_layout( + &self, + layout_ref: &str, + action: &str, + ) -> Result<&LayoutDocument, PresentationArtifactError> { + if let Some(layout) = self + .layouts + .iter() + .find(|layout| layout.layout_id == layout_ref) + { + return Ok(layout); + } + + let exact_name_matches = self + .layouts + .iter() + .filter(|layout| layout.name == layout_ref) + .collect::>(); + if exact_name_matches.len() > 1 { + return Err(PresentationArtifactError::UnsupportedFeature { + action: action.to_string(), + message: format!("layout name `{layout_ref}` is ambiguous"), + }); + } + if let Some(layout) = exact_name_matches.into_iter().next() { + return Ok(layout); + } + + let case_insensitive_name_matches = self + .layouts + .iter() + .filter(|layout| layout.name.eq_ignore_ascii_case(layout_ref)) + .collect::>(); + if case_insensitive_name_matches.len() > 1 { + return Err(PresentationArtifactError::UnsupportedFeature { + action: action.to_string(), + message: format!("layout name `{layout_ref}` is ambiguous"), + }); + } + case_insensitive_name_matches + .into_iter() + .next() + .ok_or_else(|| PresentationArtifactError::UnsupportedFeature { + action: action.to_string(), + message: format!("unknown layout id or name `{layout_ref}`"), + }) + } + + fn theme_snapshot(&self) -> ThemeSnapshot { + ThemeSnapshot { + color_scheme: self.theme.color_scheme.clone(), + hex_color_map: self.theme.color_scheme.clone(), + major_font: self.theme.major_font.clone(), + minor_font: self.theme.minor_font.clone(), + } + } + + fn named_text_styles(&self) -> Vec { + let mut styles = built_in_text_styles(&self.theme) + .into_iter() + .map(|(name, style)| NamedTextStyle { + name, + style, + built_in: true, + }) + .collect::>(); + styles.extend( + self.custom_text_styles + .iter() + .map(|(name, style)| NamedTextStyle { + name: name.clone(), + style: style.clone(), + built_in: false, + }), + ); + styles.sort_by_cached_key(|style| style.name.to_ascii_lowercase()); + styles + } + + fn resolve_named_text_style( + &self, + style_name: &str, + action: &str, + ) -> Result { + let normalized_style_name = style_name.trim().to_ascii_lowercase(); + if let Some(style) = self.custom_text_styles.get(&normalized_style_name) { + return Ok(style.clone()); + } + built_in_text_style(&self.theme, &normalized_style_name).ok_or_else(|| { + PresentationArtifactError::UnsupportedFeature { + action: action.to_string(), + message: format!("unknown text style `{style_name}`"), + } + }) + } + + fn find_element_mut( + &mut self, + element_id: &str, + action: &str, + ) -> Result<&mut PresentationElement, PresentationArtifactError> { + let element_id = normalize_element_lookup_id(element_id); + for slide in &mut self.slides { + if let Some(element) = slide + .elements + .iter_mut() + .find(|element| element.element_id() == element_id) + { + return Ok(element); + } + } + Err(PresentationArtifactError::UnsupportedFeature { + action: action.to_string(), + message: format!("unknown element id `{element_id}`"), + }) + } + + fn get_slide_mut( + &mut self, + slide_index: u32, + action: &str, + ) -> Result<&mut PresentationSlide, PresentationArtifactError> { + let index = slide_index as usize; + if index >= self.slides.len() { + return Err(index_out_of_range(action, index, self.slides.len())); + } + Ok(&mut self.slides[index]) + } + + fn remove_element( + &mut self, + element_id: &str, + action: &str, + ) -> Result<(), PresentationArtifactError> { + let element_id = normalize_element_lookup_id(element_id); + for slide in &mut self.slides { + if let Some(index) = slide + .elements + .iter() + .position(|element| element.element_id() == element_id) + { + slide.elements.remove(index); + resequence_z_order(slide); + return Ok(()); + } + } + Err(PresentationArtifactError::UnsupportedFeature { + action: action.to_string(), + message: format!("unknown element id `{element_id}`"), + }) + } + + fn set_z_order( + &mut self, + element_id: &str, + target_index: usize, + action: &str, + ) -> Result<(), PresentationArtifactError> { + let element_id = normalize_element_lookup_id(element_id); + for slide in &mut self.slides { + if let Some(current_index) = slide + .elements + .iter() + .position(|element| element.element_id() == element_id) + { + let destination = target_index.min(slide.elements.len().saturating_sub(1)); + let element = slide.elements.remove(current_index); + slide.elements.insert(destination, element); + resequence_z_order(slide); + return Ok(()); + } + } + Err(PresentationArtifactError::UnsupportedFeature { + action: action.to_string(), + message: format!("unknown element id `{element_id}`"), + }) + } + + fn to_ppt_rs(&self) -> Presentation { + let mut presentation = self + .name + .as_deref() + .map(Presentation::with_title) + .unwrap_or_default(); + for slide in &self.slides { + presentation = presentation.add_slide(slide.to_ppt_rs(self.slide_size)); + } + presentation + } +} + +#[derive(Debug, Clone)] +struct PresentationSlide { + slide_id: String, + notes: NotesState, + background_fill: Option, + layout_id: Option, + elements: Vec, +} + +struct ImportedPicture { + relationship_id: String, + frame: Rect, + crop: Option, + alt_text: Option, + rotation_degrees: Option, + flip_horizontal: bool, + flip_vertical: bool, + lock_aspect_ratio: bool, +} + +fn import_pptx_images( + path: &Path, + document: &mut PresentationDocument, + action: &str, +) -> Result<(), PresentationArtifactError> { + let file = + std::fs::File::open(path).map_err(|error| PresentationArtifactError::ImportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + let mut archive = + ZipArchive::new(file).map_err(|error| PresentationArtifactError::ImportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + for slide_index in 0..document.slides.len() { + let slide_number = slide_index + 1; + let slide_xml_path = format!("ppt/slides/slide{slide_number}.xml"); + let Some(slide_xml) = + zip_entry_string_if_exists(&mut archive, &slide_xml_path).map_err(|message| { + PresentationArtifactError::ImportFailed { + path: path.to_path_buf(), + message, + } + })? + else { + continue; + }; + let pictures = parse_imported_pictures(&slide_xml); + if pictures.is_empty() { + continue; + } + let relationships = zip_entry_string_if_exists( + &mut archive, + &format!("ppt/slides/_rels/slide{slide_number}.xml.rels"), + ) + .map_err(|message| PresentationArtifactError::ImportFailed { + path: path.to_path_buf(), + message, + })? + .map(|xml| parse_slide_image_relationship_targets(&xml)) + .unwrap_or_default(); + let mut imported_images = Vec::new(); + for picture in pictures { + let Some(target) = relationships.get(&picture.relationship_id) else { + continue; + }; + let media_path = resolve_zip_relative_path(&slide_xml_path, target); + let Some(bytes) = + zip_entry_bytes_if_exists(&mut archive, &media_path).map_err(|message| { + PresentationArtifactError::ImportFailed { + path: path.to_path_buf(), + message, + } + })? + else { + continue; + }; + let Some(filename) = Path::new(&media_path) + .file_name() + .and_then(|name| name.to_str()) + .map(str::to_owned) + else { + continue; + }; + let Ok(payload) = build_image_payload(bytes, filename, action) else { + continue; + }; + imported_images.push(ImageElement { + element_id: document.next_element_id(), + frame: picture.frame, + payload: Some(payload), + fit_mode: ImageFitMode::Stretch, + crop: picture.crop, + rotation_degrees: picture.rotation_degrees, + flip_horizontal: picture.flip_horizontal, + flip_vertical: picture.flip_vertical, + lock_aspect_ratio: picture.lock_aspect_ratio, + alt_text: picture.alt_text, + prompt: None, + is_placeholder: false, + placeholder: None, + z_order: 0, + }); + } + let slide = &mut document.slides[slide_index]; + for mut image in imported_images { + image.z_order = slide.elements.len(); + slide.elements.push(PresentationElement::Image(image)); + } + } + Ok(()) +} + +fn zip_entry_string_if_exists( + archive: &mut ZipArchive, + path: &str, +) -> Result, String> { + let Some(bytes) = zip_entry_bytes_if_exists(archive, path)? else { + return Ok(None); + }; + String::from_utf8(bytes) + .map(Some) + .map_err(|error| format!("zip entry `{path}` is not valid UTF-8: {error}")) +} + +fn zip_entry_bytes_if_exists( + archive: &mut ZipArchive, + path: &str, +) -> Result>, String> { + match archive.by_name(path) { + Ok(mut entry) => { + let mut bytes = Vec::new(); + entry + .read_to_end(&mut bytes) + .map_err(|error| format!("failed to read zip entry `{path}`: {error}"))?; + Ok(Some(bytes)) + } + Err(zip::result::ZipError::FileNotFound) => Ok(None), + Err(error) => Err(format!("failed to open zip entry `{path}`: {error}")), + } +} + +fn parse_imported_pictures(slide_xml: &str) -> Vec { + let mut pictures = Vec::new(); + let mut remaining = slide_xml; + while let Some(start) = remaining.find("") { + let block_start = start; + let Some(block_end_offset) = remaining[block_start..].find("") else { + break; + }; + let block_end = block_start + block_end_offset + "".len(); + let block = &remaining[block_start..block_end]; + remaining = &remaining[block_end..]; + + let Some(relationship_id) = xml_tag_attribute(block, "().unwrap_or(0.0) / 100_000.0, + xml_tag_attribute(block, "().ok()) + .unwrap_or(0.0) + / 100_000.0, + xml_tag_attribute(block, "().ok()) + .unwrap_or(0.0) + / 100_000.0, + xml_tag_attribute(block, "().ok()) + .unwrap_or(0.0) + / 100_000.0, + ) + }), + alt_text: xml_tag_attribute(block, "().ok()) + .map(|rotation| (rotation as f64 / 60_000.0).round() as i32), + flip_horizontal: xml_tag_attribute(block, " HashMap { + let mut relationships = HashMap::new(); + let mut remaining = rels_xml; + while let Some(start) = remaining.find("") else { + break; + }; + let tag_end = tag_start + tag_end_offset + 2; + let tag = &remaining[tag_start..tag_end]; + remaining = &remaining[tag_end..]; + if xml_attribute(tag, "Type").as_deref() + != Some("http://schemas.openxmlformats.org/officeDocument/2006/relationships/image") + { + continue; + } + let (Some(id), Some(target)) = (xml_attribute(tag, "Id"), xml_attribute(tag, "Target")) + else { + continue; + }; + relationships.insert(id, target); + } + relationships +} + +fn resolve_zip_relative_path(base_path: &str, target: &str) -> String { + let mut components = Path::new(base_path) + .parent() + .into_iter() + .flat_map(Path::components) + .filter_map(|component| match component { + std::path::Component::Normal(value) => Some(value.to_string_lossy().to_string()), + std::path::Component::CurDir => None, + std::path::Component::ParentDir => None, + std::path::Component::RootDir | std::path::Component::Prefix(_) => None, + }) + .collect::>(); + for component in Path::new(target).components() { + match component { + std::path::Component::Normal(value) => { + components.push(value.to_string_lossy().to_string()) + } + std::path::Component::ParentDir => { + components.pop(); + } + std::path::Component::CurDir => {} + std::path::Component::RootDir | std::path::Component::Prefix(_) => { + components.clear(); + } + } + } + components.join("/") +} + +fn xml_tag_attribute(xml: &str, tag_start: &str, attribute: &str) -> Option { + let start = xml.find(tag_start)?; + let tag = &xml[start..start + xml[start..].find('>')?]; + xml_attribute(tag, attribute) +} + +fn xml_attribute(tag: &str, attribute: &str) -> Option { + let pattern = format!(r#"{attribute}=""#); + let start = tag.find(&pattern)? + pattern.len(); + let end = start + tag[start..].find('"')?; + Some( + tag[start..end] + .replace(""", "\"") + .replace("'", "'") + .replace("<", "<") + .replace(">", ">") + .replace("&", "&"), + ) +} + +impl PresentationSlide { + fn to_ppt_rs(&self, slide_size: Rect) -> SlideContent { + let mut content = SlideContent::new("").layout(SlideLayout::Blank); + if self.notes.visible && !self.notes.text.is_empty() { + content = content.notes(&self.notes.text); + } + + if let Some(background_fill) = &self.background_fill { + content = content.add_shape( + Shape::new( + ShapeType::Rectangle, + 0, + 0, + points_to_emu(slide_size.width), + points_to_emu(slide_size.height), + ) + .with_fill(ShapeFill::new(background_fill)), + ); + } + + let mut ordered = self.elements.clone(); + ordered.sort_by_key(PresentationElement::z_order); + let mut hyperlink_seq = 1_u32; + for element in ordered { + match element { + PresentationElement::Text(text) => { + let mut shape = Shape::new( + ShapeType::Rectangle, + points_to_emu(text.frame.left), + points_to_emu(text.frame.top), + points_to_emu(text.frame.width), + points_to_emu(text.frame.height), + ) + .with_text(&text.text); + if let Some(fill) = text.fill { + shape = shape.with_fill(ShapeFill::new(&fill)); + } + if let Some(hyperlink) = &text.hyperlink { + let relationship_id = format!("rIdHyperlink{hyperlink_seq}"); + hyperlink_seq += 1; + shape = shape.with_hyperlink(hyperlink.to_ppt_rs(&relationship_id)); + } + content = content.add_shape(shape); + } + PresentationElement::Shape(shape) => { + let mut ppt_shape = Shape::new( + shape.geometry.to_ppt_rs(), + points_to_emu(shape.frame.left), + points_to_emu(shape.frame.top), + points_to_emu(shape.frame.width), + points_to_emu(shape.frame.height), + ); + if let Some(text) = shape.text { + ppt_shape = ppt_shape.with_text(&text); + } + if let Some(fill) = shape.fill { + ppt_shape = ppt_shape.with_fill(ShapeFill::new(&fill)); + } + if let Some(stroke) = shape.stroke { + ppt_shape = ppt_shape + .with_line(ShapeLine::new(&stroke.color, points_to_emu(stroke.width))); + } + if let Some(rotation) = shape.rotation_degrees { + ppt_shape = ppt_shape.with_rotation(rotation); + } + if let Some(hyperlink) = &shape.hyperlink { + let relationship_id = format!("rIdHyperlink{hyperlink_seq}"); + hyperlink_seq += 1; + ppt_shape = ppt_shape.with_hyperlink(hyperlink.to_ppt_rs(&relationship_id)); + } + content = content.add_shape(ppt_shape); + } + PresentationElement::Connector(connector) => { + let mut ppt_connector = Connector::new( + connector.connector_type.to_ppt_rs(), + points_to_emu(connector.start.left), + points_to_emu(connector.start.top), + points_to_emu(connector.end.left), + points_to_emu(connector.end.top), + ) + .with_line( + ConnectorLine::new( + &connector.line.color, + points_to_emu(connector.line.width), + ) + .with_dash(connector.line_style.to_ppt_rs()), + ) + .with_arrow_size(connector.arrow_size.to_ppt_rs()) + .with_start_arrow(connector.start_arrow.to_ppt_rs()) + .with_end_arrow(connector.end_arrow.to_ppt_rs()); + if let Some(label) = connector.label { + ppt_connector = ppt_connector.with_label(&label); + } + content = content.add_connector(ppt_connector); + } + PresentationElement::Image(image) => { + if let Some(ref payload) = image.payload { + let mut ppt_image = Image::from_bytes( + payload.bytes.clone(), + points_to_emu(image.frame.width), + points_to_emu(image.frame.height), + &payload.format, + ) + .position( + points_to_emu(image.frame.left), + points_to_emu(image.frame.top), + ); + if image.fit_mode != ImageFitMode::Stretch { + let (x, y, width, height, crop) = fit_image(&image); + ppt_image = Image::from_bytes( + payload.bytes.clone(), + points_to_emu(width), + points_to_emu(height), + &payload.format, + ) + .position(points_to_emu(x), points_to_emu(y)); + if let Some((left, top, right, bottom)) = crop { + ppt_image = ppt_image.with_crop(left, top, right, bottom); + } + } + if let Some((left, top, right, bottom)) = image.crop { + ppt_image = ppt_image.with_crop(left, top, right, bottom); + } + content = content.add_image(ppt_image); + } else { + let mut placeholder = Shape::new( + ShapeType::Rectangle, + points_to_emu(image.frame.left), + points_to_emu(image.frame.top), + points_to_emu(image.frame.width), + points_to_emu(image.frame.height), + ) + .with_text(image.prompt.as_deref().unwrap_or("Image placeholder")); + if let Some(rotation) = image.rotation_degrees { + placeholder = placeholder.with_rotation(rotation); + } + content = content.add_shape(placeholder); + } + } + PresentationElement::Table(table) => { + let mut builder = TableBuilder::new( + table + .column_widths + .iter() + .copied() + .map(points_to_emu) + .collect(), + ) + .position( + points_to_emu(table.frame.left), + points_to_emu(table.frame.top), + ); + for (row_index, row) in table.rows.into_iter().enumerate() { + let cells = row + .into_iter() + .enumerate() + .map(|(column_index, cell)| { + build_table_cell(cell, &table.merges, row_index, column_index) + }) + .collect::>(); + let mut table_row = TableRow::new(cells); + if let Some(height) = table.row_heights.get(row_index) { + table_row = table_row.with_height(points_to_emu(*height)); + } + builder = builder.add_row(table_row); + } + content = content.table(builder.build()); + } + PresentationElement::Chart(chart) => { + let mut ppt_chart = Chart::new( + chart.title.as_deref().unwrap_or("Chart"), + chart.chart_type.to_ppt_rs(), + chart.categories, + points_to_emu(chart.frame.left), + points_to_emu(chart.frame.top), + points_to_emu(chart.frame.width), + points_to_emu(chart.frame.height), + ); + for series in chart.series { + ppt_chart = + ppt_chart.add_series(ChartSeries::new(&series.name, series.values)); + } + content = content.add_chart(ppt_chart); + } + } + } + content + } +} + +#[derive(Debug, Clone)] +enum PresentationElement { + Text(TextElement), + Shape(ShapeElement), + Connector(ConnectorElement), + Image(ImageElement), + Table(TableElement), + Chart(ChartElement), +} + +impl PresentationElement { + fn element_id(&self) -> &str { + match self { + Self::Text(element) => &element.element_id, + Self::Shape(element) => &element.element_id, + Self::Connector(element) => &element.element_id, + Self::Image(element) => &element.element_id, + Self::Table(element) => &element.element_id, + Self::Chart(element) => &element.element_id, + } + } + + fn set_element_id(&mut self, new_id: String) { + match self { + Self::Text(element) => element.element_id = new_id, + Self::Shape(element) => element.element_id = new_id, + Self::Connector(element) => element.element_id = new_id, + Self::Image(element) => element.element_id = new_id, + Self::Table(element) => element.element_id = new_id, + Self::Chart(element) => element.element_id = new_id, + } + } + + fn kind(&self) -> &'static str { + match self { + Self::Text(_) => "text", + Self::Shape(_) => "shape", + Self::Connector(_) => "connector", + Self::Image(_) => "image", + Self::Table(_) => "table", + Self::Chart(_) => "chart", + } + } + + fn z_order(&self) -> usize { + match self { + Self::Text(element) => element.z_order, + Self::Shape(element) => element.z_order, + Self::Connector(element) => element.z_order, + Self::Image(element) => element.z_order, + Self::Table(element) => element.z_order, + Self::Chart(element) => element.z_order, + } + } + + fn set_z_order(&mut self, z_order: usize) { + match self { + Self::Text(element) => element.z_order = z_order, + Self::Shape(element) => element.z_order = z_order, + Self::Connector(element) => element.z_order = z_order, + Self::Image(element) => element.z_order = z_order, + Self::Table(element) => element.z_order = z_order, + Self::Chart(element) => element.z_order = z_order, + } + } +} + +#[derive(Debug, Clone)] +struct TextElement { + element_id: String, + text: String, + frame: Rect, + fill: Option, + style: TextStyle, + hyperlink: Option, + placeholder: Option, + z_order: usize, +} + +#[derive(Debug, Clone)] +struct ShapeElement { + element_id: String, + geometry: ShapeGeometry, + frame: Rect, + fill: Option, + stroke: Option, + text: Option, + text_style: TextStyle, + hyperlink: Option, + placeholder: Option, + rotation_degrees: Option, + flip_horizontal: bool, + flip_vertical: bool, + z_order: usize, +} + +#[derive(Debug, Clone)] +struct ConnectorElement { + element_id: String, + connector_type: ConnectorKind, + start: PointArgs, + end: PointArgs, + line: StrokeStyle, + line_style: LineStyle, + start_arrow: ConnectorArrowKind, + end_arrow: ConnectorArrowKind, + arrow_size: ConnectorArrowScale, + label: Option, + z_order: usize, +} + +#[derive(Debug, Clone)] +pub(crate) struct ImageElement { + pub(crate) element_id: String, + pub(crate) frame: Rect, + pub(crate) payload: Option, + pub(crate) fit_mode: ImageFitMode, + pub(crate) crop: Option, + pub(crate) rotation_degrees: Option, + pub(crate) flip_horizontal: bool, + pub(crate) flip_vertical: bool, + pub(crate) lock_aspect_ratio: bool, + pub(crate) alt_text: Option, + pub(crate) prompt: Option, + pub(crate) is_placeholder: bool, + pub(crate) placeholder: Option, + pub(crate) z_order: usize, +} + +#[derive(Debug, Clone)] +struct TableElement { + element_id: String, + frame: Rect, + rows: Vec>, + column_widths: Vec, + row_heights: Vec, + style: Option, + merges: Vec, + z_order: usize, +} + +#[derive(Debug, Clone)] +struct ChartElement { + element_id: String, + frame: Rect, + chart_type: ChartTypeSpec, + categories: Vec, + series: Vec, + title: Option, + z_order: usize, +} + +#[derive(Debug, Clone)] +pub(crate) struct ImagePayload { + pub(crate) bytes: Vec, + pub(crate) format: String, + pub(crate) width_px: u32, + pub(crate) height_px: u32, +} + +#[derive(Debug, Clone)] +struct ChartSeriesSpec { + name: String, + values: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ShapeGeometry { + Rectangle, + RoundedRectangle, + Ellipse, + Triangle, + RightTriangle, + Diamond, + Pentagon, + Hexagon, + Octagon, + Star4, + Star5, + Star6, + Star8, + RightArrow, + LeftArrow, + UpArrow, + DownArrow, + LeftRightArrow, + UpDownArrow, + Chevron, + Heart, + Cloud, + Wave, + FlowChartProcess, + FlowChartDecision, + FlowChartConnector, + Parallelogram, + Trapezoid, +} + +impl ShapeGeometry { + fn from_shape_type(shape_type: ShapeType) -> Self { + match shape_type { + ShapeType::RoundedRectangle => Self::RoundedRectangle, + ShapeType::Ellipse | ShapeType::Circle => Self::Ellipse, + ShapeType::Triangle => Self::Triangle, + ShapeType::RightTriangle => Self::RightTriangle, + ShapeType::Diamond => Self::Diamond, + ShapeType::Pentagon => Self::Pentagon, + ShapeType::Hexagon => Self::Hexagon, + ShapeType::Octagon => Self::Octagon, + ShapeType::Star4 => Self::Star4, + ShapeType::Star5 => Self::Star5, + ShapeType::Star6 => Self::Star6, + ShapeType::Star8 => Self::Star8, + ShapeType::RightArrow => Self::RightArrow, + ShapeType::LeftArrow => Self::LeftArrow, + ShapeType::UpArrow => Self::UpArrow, + ShapeType::DownArrow => Self::DownArrow, + ShapeType::LeftRightArrow => Self::LeftRightArrow, + ShapeType::UpDownArrow => Self::UpDownArrow, + ShapeType::ChevronArrow => Self::Chevron, + ShapeType::Heart => Self::Heart, + ShapeType::Cloud => Self::Cloud, + ShapeType::Wave => Self::Wave, + ShapeType::FlowChartProcess => Self::FlowChartProcess, + ShapeType::FlowChartDecision => Self::FlowChartDecision, + ShapeType::FlowChartConnector => Self::FlowChartConnector, + ShapeType::Parallelogram => Self::Parallelogram, + ShapeType::Trapezoid => Self::Trapezoid, + _ => Self::Rectangle, + } + } + + fn to_ppt_rs(self) -> ShapeType { + match self { + Self::Rectangle => ShapeType::Rectangle, + Self::RoundedRectangle => ShapeType::RoundedRectangle, + Self::Ellipse => ShapeType::Ellipse, + Self::Triangle => ShapeType::Triangle, + Self::RightTriangle => ShapeType::RightTriangle, + Self::Diamond => ShapeType::Diamond, + Self::Pentagon => ShapeType::Pentagon, + Self::Hexagon => ShapeType::Hexagon, + Self::Octagon => ShapeType::Octagon, + Self::Star4 => ShapeType::Star4, + Self::Star5 => ShapeType::Star5, + Self::Star6 => ShapeType::Star6, + Self::Star8 => ShapeType::Star8, + Self::RightArrow => ShapeType::RightArrow, + Self::LeftArrow => ShapeType::LeftArrow, + Self::UpArrow => ShapeType::UpArrow, + Self::DownArrow => ShapeType::DownArrow, + Self::LeftRightArrow => ShapeType::LeftRightArrow, + Self::UpDownArrow => ShapeType::UpDownArrow, + Self::Chevron => ShapeType::ChevronArrow, + Self::Heart => ShapeType::Heart, + Self::Cloud => ShapeType::Cloud, + Self::Wave => ShapeType::Wave, + Self::FlowChartProcess => ShapeType::FlowChartProcess, + Self::FlowChartDecision => ShapeType::FlowChartDecision, + Self::FlowChartConnector => ShapeType::FlowChartConnector, + Self::Parallelogram => ShapeType::Parallelogram, + Self::Trapezoid => ShapeType::Trapezoid, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ChartTypeSpec { + Bar, + BarHorizontal, + BarStacked, + BarStacked100, + Line, + LineMarkers, + LineStacked, + Pie, + Doughnut, + Area, + AreaStacked, + AreaStacked100, + Scatter, + ScatterLines, + ScatterSmooth, + Bubble, + Radar, + RadarFilled, + StockHlc, + StockOhlc, + Combo, +} + +impl ChartTypeSpec { + fn to_ppt_rs(self) -> ChartType { + match self { + Self::Bar => ChartType::Bar, + Self::BarHorizontal => ChartType::BarHorizontal, + Self::BarStacked => ChartType::BarStacked, + Self::BarStacked100 => ChartType::BarStacked100, + Self::Line => ChartType::Line, + Self::LineMarkers => ChartType::LineMarkers, + Self::LineStacked => ChartType::LineStacked, + Self::Pie => ChartType::Pie, + Self::Doughnut => ChartType::Doughnut, + Self::Area => ChartType::Area, + Self::AreaStacked => ChartType::AreaStacked, + Self::AreaStacked100 => ChartType::AreaStacked100, + Self::Scatter => ChartType::Scatter, + Self::ScatterLines => ChartType::ScatterLines, + Self::ScatterSmooth => ChartType::ScatterSmooth, + Self::Bubble => ChartType::Bubble, + Self::Radar => ChartType::Radar, + Self::RadarFilled => ChartType::RadarFilled, + Self::StockHlc => ChartType::StockHLC, + Self::StockOhlc => ChartType::StockOHLC, + Self::Combo => ChartType::Combo, + } + } +} + +impl ConnectorKind { + fn to_ppt_rs(self) -> ConnectorType { + match self { + Self::Straight => ConnectorType::Straight, + Self::Elbow => ConnectorType::Elbow, + Self::Curved => ConnectorType::Curved, + } + } +} + +impl ConnectorArrowKind { + fn to_ppt_rs(self) -> ArrowType { + match self { + Self::None => ArrowType::None, + Self::Triangle => ArrowType::Triangle, + Self::Stealth => ArrowType::Stealth, + Self::Diamond => ArrowType::Diamond, + Self::Oval => ArrowType::Oval, + Self::Open => ArrowType::Open, + } + } +} + +impl ConnectorArrowScale { + fn to_ppt_rs(self) -> ArrowSize { + match self { + Self::Small => ArrowSize::Small, + Self::Medium => ArrowSize::Medium, + Self::Large => ArrowSize::Large, + } + } +} + +impl LineStyle { + fn to_ppt_rs(self) -> LineDash { + match self { + Self::Solid => LineDash::Solid, + Self::Dashed => LineDash::Dash, + Self::Dotted => LineDash::Dot, + Self::DashDot => LineDash::DashDot, + Self::DashDotDot => LineDash::DashDotDot, + Self::LongDash => LineDash::LongDash, + Self::LongDashDot => LineDash::LongDashDot, + } + } + + fn to_ppt_xml(self) -> &'static str { + match self { + Self::Solid => "solid", + Self::Dashed => "dash", + Self::Dotted => "dot", + Self::DashDot => "dashDot", + Self::DashDotDot => "dashDotDot", + Self::LongDash => "lgDash", + Self::LongDashDot => "lgDashDot", + } + } + + fn as_api_str(self) -> &'static str { + match self { + Self::Solid => "solid", + Self::Dashed => "dashed", + Self::Dotted => "dotted", + Self::DashDot => "dash-dot", + Self::DashDotDot => "dash-dot-dot", + Self::LongDash => "long-dash", + Self::LongDashDot => "long-dash-dot", + } + } +} + +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub(crate) enum ImageFitMode { + Stretch, + Contain, + Cover, +} + +#[derive(Debug, Clone)] +struct StrokeStyle { + color: String, + width: u32, + style: LineStyle, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ConnectorKind { + Straight, + Elbow, + Curved, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ConnectorArrowKind { + None, + Triangle, + Stealth, + Diamond, + Oval, + Open, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ConnectorArrowScale { + Small, + Medium, + Large, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LineStyle { + Solid, + Dashed, + Dotted, + DashDot, + DashDotDot, + LongDash, + LongDashDot, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct Rect { + pub(crate) left: u32, + pub(crate) top: u32, + pub(crate) width: u32, + pub(crate) height: u32, +} + +impl Rect { + fn from_emu(left: u32, top: u32, width: u32, height: u32) -> Self { + Self { + left: emu_to_points(left), + top: emu_to_points(top), + width: emu_to_points(width), + height: emu_to_points(height), + } + } +} + +impl From for Rect { + fn from(value: PositionArgs) -> Self { + Self { + left: value.left, + top: value.top, + width: value.width, + height: value.height, + } + } +} + +fn apply_partial_position(rect: Rect, position: PartialPositionArgs) -> Rect { + Rect { + left: position.left.unwrap_or(rect.left), + top: position.top.unwrap_or(rect.top), + width: position.width.unwrap_or(rect.width), + height: position.height.unwrap_or(rect.height), + } +} + +fn apply_partial_position_to_image(image: &ImageElement, position: PartialPositionArgs) -> Rect { + let mut frame = apply_partial_position(image.frame, position.clone()); + if image.lock_aspect_ratio { + let base_ratio = image + .payload + .as_ref() + .map(|payload| payload.width_px as f64 / payload.height_px as f64) + .unwrap_or_else(|| image.frame.width as f64 / image.frame.height as f64); + if let Some(width) = position.width + && position.height.is_none() + { + frame.height = (width as f64 / base_ratio).round() as u32; + } else if let Some(height) = position.height + && position.width.is_none() + { + frame.width = (height as f64 * base_ratio).round() as u32; + } + } + frame +} diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/parsing.rs b/codex-rs/artifact-presentation/src/presentation_artifact/parsing.rs new file mode 100644 index 000000000..a5bc361e3 --- /dev/null +++ b/codex-rs/artifact-presentation/src/presentation_artifact/parsing.rs @@ -0,0 +1,864 @@ +fn parse_args(action: &str, value: &Value) -> Result +where + T: for<'de> Deserialize<'de>, +{ + serde_json::from_value(value.clone()).map_err(|error| PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: error.to_string(), + }) +} + +fn required_artifact_id( + request: &PresentationArtifactRequest, +) -> Result { + request + .artifact_id + .clone() + .ok_or_else(|| PresentationArtifactError::MissingArtifactId { + action: request.action.clone(), + }) +} + +fn is_read_only_action(action: &str) -> bool { + matches!( + action, + "get_summary" + | "list_slides" + | "list_layouts" + | "list_layout_placeholders" + | "list_slide_placeholders" + | "inspect" + | "resolve" + | "to_proto" + | "get_style" + | "describe_styles" + | "record_patch" + ) +} + +fn tracks_history(action: &str) -> bool { + !is_read_only_action(action) + && !matches!( + action, + "export_pptx" | "export_preview" | "undo" | "redo" | "apply_patch" + ) +} + +fn patch_operation_supported(action: &str) -> bool { + tracks_history(action) && !matches!(action, "create" | "import_pptx" | "delete_artifact") +} + +fn resolve_path(cwd: &Path, path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + cwd.join(path) + } +} + +fn normalize_color( + color: &str, + action: &str, + field: &str, +) -> Result { + normalize_color_with_palette(None, color, action, field) +} + +fn normalize_color_with_document( + document: &PresentationDocument, + color: &str, + action: &str, + field: &str, +) -> Result { + normalize_color_with_palette(Some(&document.theme), color, action, field) +} + +fn normalize_color_with_palette( + theme: Option<&ThemeState>, + color: &str, + action: &str, + field: &str, +) -> Result { + let trimmed = color.trim(); + let normalized = theme + .and_then(|palette| palette.resolve_color(trimmed)) + .unwrap_or_else(|| trimmed.trim_start_matches('#').to_uppercase()); + if normalized.len() != 6 + || !normalized + .chars() + .all(|character| character.is_ascii_hexdigit()) + { + return Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("field `{field}` must be a 6-digit RGB hex color"), + }); + } + Ok(normalized) +} + +fn parse_shape_geometry( + geometry: &str, + action: &str, +) -> Result { + match geometry { + "rectangle" | "rect" => Ok(ShapeGeometry::Rectangle), + "rounded_rectangle" | "roundedRect" => Ok(ShapeGeometry::RoundedRectangle), + "ellipse" | "circle" => Ok(ShapeGeometry::Ellipse), + "triangle" => Ok(ShapeGeometry::Triangle), + "right_triangle" => Ok(ShapeGeometry::RightTriangle), + "diamond" => Ok(ShapeGeometry::Diamond), + "pentagon" => Ok(ShapeGeometry::Pentagon), + "hexagon" => Ok(ShapeGeometry::Hexagon), + "octagon" => Ok(ShapeGeometry::Octagon), + "star4" => Ok(ShapeGeometry::Star4), + "star" | "star5" => Ok(ShapeGeometry::Star5), + "star6" => Ok(ShapeGeometry::Star6), + "star8" => Ok(ShapeGeometry::Star8), + "right_arrow" => Ok(ShapeGeometry::RightArrow), + "left_arrow" => Ok(ShapeGeometry::LeftArrow), + "up_arrow" => Ok(ShapeGeometry::UpArrow), + "down_arrow" => Ok(ShapeGeometry::DownArrow), + "left_right_arrow" | "leftRightArrow" => Ok(ShapeGeometry::LeftRightArrow), + "up_down_arrow" | "upDownArrow" => Ok(ShapeGeometry::UpDownArrow), + "chevron" => Ok(ShapeGeometry::Chevron), + "heart" => Ok(ShapeGeometry::Heart), + "cloud" => Ok(ShapeGeometry::Cloud), + "wave" => Ok(ShapeGeometry::Wave), + "flowChartProcess" | "flow_chart_process" => Ok(ShapeGeometry::FlowChartProcess), + "flowChartDecision" | "flow_chart_decision" => Ok(ShapeGeometry::FlowChartDecision), + "flowChartConnector" | "flow_chart_connector" => Ok(ShapeGeometry::FlowChartConnector), + "parallelogram" => Ok(ShapeGeometry::Parallelogram), + "trapezoid" => Ok(ShapeGeometry::Trapezoid), + _ => Err(PresentationArtifactError::UnsupportedFeature { + action: action.to_string(), + message: format!("geometry `{geometry}` is not supported"), + }), + } +} + +fn parse_chart_type( + chart_type: &str, + action: &str, +) -> Result { + match chart_type { + "bar" => Ok(ChartTypeSpec::Bar), + "bar_horizontal" => Ok(ChartTypeSpec::BarHorizontal), + "bar_stacked" => Ok(ChartTypeSpec::BarStacked), + "bar_stacked_100" => Ok(ChartTypeSpec::BarStacked100), + "line" => Ok(ChartTypeSpec::Line), + "line_markers" => Ok(ChartTypeSpec::LineMarkers), + "line_stacked" => Ok(ChartTypeSpec::LineStacked), + "pie" => Ok(ChartTypeSpec::Pie), + "doughnut" => Ok(ChartTypeSpec::Doughnut), + "area" => Ok(ChartTypeSpec::Area), + "area_stacked" => Ok(ChartTypeSpec::AreaStacked), + "area_stacked_100" => Ok(ChartTypeSpec::AreaStacked100), + "scatter" => Ok(ChartTypeSpec::Scatter), + "scatter_lines" => Ok(ChartTypeSpec::ScatterLines), + "scatter_smooth" => Ok(ChartTypeSpec::ScatterSmooth), + "bubble" => Ok(ChartTypeSpec::Bubble), + "radar" => Ok(ChartTypeSpec::Radar), + "radar_filled" => Ok(ChartTypeSpec::RadarFilled), + "stock_hlc" => Ok(ChartTypeSpec::StockHlc), + "stock_ohlc" => Ok(ChartTypeSpec::StockOhlc), + "combo" => Ok(ChartTypeSpec::Combo), + _ => Err(PresentationArtifactError::UnsupportedFeature { + action: action.to_string(), + message: format!("chart_type `{chart_type}` is not supported"), + }), + } +} + +fn parse_stroke( + document: &PresentationDocument, + stroke: Option, + action: &str, +) -> Result, PresentationArtifactError> { + stroke + .map(|value| parse_required_stroke(document, value, action)) + .transpose() +} + +fn parse_required_stroke( + document: &PresentationDocument, + stroke: StrokeArgs, + action: &str, +) -> Result { + Ok(StrokeStyle { + color: normalize_color_with_document(document, &stroke.color, action, "stroke.color")?, + width: stroke.width, + style: stroke + .style + .as_deref() + .map(|style| parse_line_style(style, action)) + .transpose()? + .unwrap_or(LineStyle::Solid), + }) +} + +fn parse_connector_kind( + connector_type: &str, + action: &str, +) -> Result { + match connector_type { + "straight" => Ok(ConnectorKind::Straight), + "elbow" => Ok(ConnectorKind::Elbow), + "curved" => Ok(ConnectorKind::Curved), + _ => Err(PresentationArtifactError::UnsupportedFeature { + action: action.to_string(), + message: format!("connector_type `{connector_type}` is not supported"), + }), + } +} + +fn parse_connector_arrow( + value: &str, + action: &str, +) -> Result { + match value { + "none" => Ok(ConnectorArrowKind::None), + "triangle" => Ok(ConnectorArrowKind::Triangle), + "stealth" => Ok(ConnectorArrowKind::Stealth), + "diamond" => Ok(ConnectorArrowKind::Diamond), + "oval" => Ok(ConnectorArrowKind::Oval), + "open" => Ok(ConnectorArrowKind::Open), + _ => Err(PresentationArtifactError::UnsupportedFeature { + action: action.to_string(), + message: format!("connector arrow `{value}` is not supported"), + }), + } +} + +fn parse_connector_arrow_size( + value: &str, + action: &str, +) -> Result { + match value { + "small" => Ok(ConnectorArrowScale::Small), + "medium" => Ok(ConnectorArrowScale::Medium), + "large" => Ok(ConnectorArrowScale::Large), + _ => Err(PresentationArtifactError::UnsupportedFeature { + action: action.to_string(), + message: format!("connector arrow_size `{value}` is not supported"), + }), + } +} + +fn parse_line_style(value: &str, action: &str) -> Result { + match value { + "solid" => Ok(LineStyle::Solid), + "dashed" => Ok(LineStyle::Dashed), + "dotted" => Ok(LineStyle::Dotted), + "dash-dot" | "dash_dot" => Ok(LineStyle::DashDot), + "dash-dot-dot" | "dash_dot_dot" => Ok(LineStyle::DashDotDot), + "long-dash" | "long_dash" => Ok(LineStyle::LongDash), + "long-dash-dot" | "long_dash_dot" => Ok(LineStyle::LongDashDot), + _ => Err(PresentationArtifactError::UnsupportedFeature { + action: action.to_string(), + message: format!("line style `{value}` is not supported"), + }), + } +} + +fn parse_connector_line( + document: &PresentationDocument, + line: Option, + action: &str, +) -> Result { + let line = line.unwrap_or_default(); + Ok(ParsedConnectorLine { + color: line + .color + .as_deref() + .map(|value| normalize_color_with_document(document, value, action, "line.color")) + .transpose()? + .unwrap_or_else(|| "000000".to_string()), + width: line.width.unwrap_or(1), + style: line + .style + .as_deref() + .map(|value| parse_line_style(value, action)) + .transpose()? + .unwrap_or(LineStyle::Solid), + }) +} + +struct ParsedConnectorLine { + color: String, + width: u32, + style: LineStyle, +} + +fn normalize_text_style_with_document( + document: &PresentationDocument, + styling: &TextStylingArgs, + action: &str, +) -> Result { + normalize_text_style_with_palette(Some(&document.theme), styling, action, |style_name| { + document.resolve_named_text_style(style_name, action) + }) +} + +fn normalize_text_style_with_palette( + theme: Option<&ThemeState>, + styling: &TextStylingArgs, + action: &str, + resolve_style_name: impl Fn(&str) -> Result, +) -> Result { + let mut style = styling + .style + .as_deref() + .map(resolve_style_name) + .transpose()? + .unwrap_or_default(); + style.font_size = styling.font_size.or(style.font_size); + style.font_family = styling.font_family.clone().or(style.font_family); + style.color = styling + .color + .as_deref() + .map(|value| normalize_color_with_palette(theme, value, action, "color")) + .transpose()? + .or(style.color); + style.alignment = styling + .alignment + .as_deref() + .map(|value| parse_alignment(value, action)) + .transpose()? + .or(style.alignment); + style.bold = styling.bold.unwrap_or(style.bold); + style.italic = styling.italic.unwrap_or(style.italic); + style.underline = styling.underline.unwrap_or(style.underline); + if let Some(style_name) = &styling.style { + style.style_name = Some(normalize_style_name(style_name, action)?); + } + Ok(style) +} + +fn parse_hyperlink_state( + document: &PresentationDocument, + args: &SetHyperlinkArgs, + action: &str, +) -> Result { + let link_type = + args.link_type + .as_deref() + .ok_or_else(|| PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: "`link_type` is required unless `clear` is true".to_string(), + })?; + let target = match link_type { + "url" => HyperlinkTarget::Url(required_hyperlink_field(&args.url, action, "url")?.clone()), + "slide" => { + let slide_index = + args.slide_index + .ok_or_else(|| PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: "`slide_index` is required for slide hyperlinks".to_string(), + })?; + if slide_index as usize >= document.slides.len() { + return Err(index_out_of_range( + action, + slide_index as usize, + document.slides.len(), + )); + } + HyperlinkTarget::Slide(slide_index) + } + "first_slide" => HyperlinkTarget::FirstSlide, + "last_slide" => HyperlinkTarget::LastSlide, + "next_slide" => HyperlinkTarget::NextSlide, + "previous_slide" => HyperlinkTarget::PreviousSlide, + "end_show" => HyperlinkTarget::EndShow, + "email" => HyperlinkTarget::Email { + address: required_hyperlink_field(&args.address, action, "address")?.clone(), + subject: args.subject.clone(), + }, + "file" => { + HyperlinkTarget::File(required_hyperlink_field(&args.path, action, "path")?.clone()) + } + other => { + return Err(PresentationArtifactError::UnsupportedFeature { + action: action.to_string(), + message: format!("hyperlink type `{other}` is not supported"), + }); + } + }; + Ok(HyperlinkState { + target, + tooltip: args.tooltip.clone(), + highlight_click: args.highlight_click.unwrap_or(true), + }) +} + +fn required_hyperlink_field<'a>( + value: &'a Option, + action: &str, + field: &str, +) -> Result<&'a String, PresentationArtifactError> { + value + .as_ref() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("`{field}` is required for this hyperlink type"), + }) +} + +fn coerce_table_rows( + rows: Vec>, + action: &str, +) -> Result>, PresentationArtifactError> { + if rows.is_empty() { + return Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: "`rows` must contain at least one row".to_string(), + }); + } + Ok(rows + .into_iter() + .map(|row| { + row.into_iter() + .map(|value| TableCellSpec { + text: cell_value_to_string(value), + text_style: TextStyle::default(), + background_fill: None, + alignment: None, + }) + .collect() + }) + .collect()) +} + +fn normalize_table_dimensions( + rows: &[Vec], + frame: Rect, + column_widths: Option>, + row_heights: Option>, + action: &str, +) -> Result<(Vec, Vec), PresentationArtifactError> { + let column_count = rows.iter().map(std::vec::Vec::len).max().unwrap_or(1); + let normalized_column_widths = match column_widths { + Some(widths) => { + if widths.len() != column_count { + return Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!( + "`column_widths` must contain {column_count} entries for this table" + ), + }); + } + widths + } + None => split_points(frame.width, column_count), + }; + let normalized_row_heights = match row_heights { + Some(heights) => { + if heights.len() != rows.len() { + return Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!( + "`row_heights` must contain {} entries for this table", + rows.len() + ), + }); + } + heights + } + None => split_points(frame.height, rows.len()), + }; + Ok((normalized_column_widths, normalized_row_heights)) +} + +fn split_points(total: u32, count: usize) -> Vec { + if count == 0 { + return Vec::new(); + } + let base = total / count as u32; + let remainder = total % count as u32; + (0..count) + .map(|index| base + u32::from(index < remainder as usize)) + .collect() +} + +fn parse_alignment(value: &str, action: &str) -> Result { + match value { + "left" => Ok(TextAlignment::Left), + "center" | "middle" => Ok(TextAlignment::Center), + "right" => Ok(TextAlignment::Right), + "justify" => Ok(TextAlignment::Justify), + _ => Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("unsupported alignment `{value}`"), + }), + } +} + +fn normalize_theme(args: ThemeArgs, action: &str) -> Result { + let color_scheme = args + .color_scheme + .into_iter() + .map(|(key, value)| { + normalize_color(&value, action, &key) + .map(|normalized| (key.to_ascii_lowercase(), normalized)) + }) + .collect::, _>>()?; + Ok(ThemeState { + color_scheme, + major_font: args.major_font, + minor_font: args.minor_font, + }) +} + +fn normalize_style_name( + style_name: &str, + action: &str, +) -> Result { + let normalized_style_name = style_name.trim().to_ascii_lowercase(); + if normalized_style_name.is_empty() { + return Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: "`name` must not be empty".to_string(), + }); + } + Ok(normalized_style_name) +} + +fn built_in_text_styles(theme: &ThemeState) -> HashMap { + ["title", "heading1", "body", "list", "numberedlist"] + .into_iter() + .filter_map(|name| built_in_text_style(theme, name).map(|style| (name.to_string(), style))) + .collect() +} + +fn built_in_text_style(theme: &ThemeState, style_name: &str) -> Option { + let default_color = theme.resolve_color("tx1"); + let default_font = theme + .major_font + .clone() + .or_else(|| theme.minor_font.clone()); + let body_font = theme + .minor_font + .clone() + .or_else(|| theme.major_font.clone()); + let style = match style_name { + "title" => TextStyle { + style_name: Some("title".to_string()), + font_size: Some(28), + font_family: default_font, + color: default_color, + alignment: Some(TextAlignment::Left), + bold: true, + italic: false, + underline: false, + }, + "heading1" => TextStyle { + style_name: Some("heading1".to_string()), + font_size: Some(22), + font_family: default_font, + color: default_color, + alignment: Some(TextAlignment::Left), + bold: true, + italic: false, + underline: false, + }, + "body" => TextStyle { + style_name: Some("body".to_string()), + font_size: Some(14), + font_family: body_font, + color: default_color, + alignment: Some(TextAlignment::Left), + bold: false, + italic: false, + underline: false, + }, + "list" => TextStyle { + style_name: Some("list".to_string()), + font_size: Some(14), + font_family: body_font, + color: default_color, + alignment: Some(TextAlignment::Left), + bold: false, + italic: false, + underline: false, + }, + "numberedlist" => TextStyle { + style_name: Some("numberedlist".to_string()), + font_size: Some(14), + font_family: body_font, + color: default_color, + alignment: Some(TextAlignment::Left), + bold: false, + italic: false, + underline: false, + }, + _ => return None, + }; + Some(style) +} + +fn named_text_style_to_json(style: &NamedTextStyle, id_prefix: &str) -> Value { + serde_json::json!({ + "kind": "textStyle", + "id": format!("{id_prefix}/{}", style.name), + "name": style.name, + "builtIn": style.built_in, + "style": text_style_to_proto(&style.style), + }) +} + +fn parse_slide_size(value: &Value, action: &str) -> Result { + #[derive(Deserialize)] + struct SlideSizeArgs { + width: u32, + height: u32, + } + + let slide_size: SlideSizeArgs = serde_json::from_value(value.clone()).map_err(|error| { + PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("invalid slide_size: {error}"), + } + })?; + Ok(Rect { + left: 0, + top: 0, + width: slide_size.width, + height: slide_size.height, + }) +} + +fn apply_layout_to_slide( + document: &mut PresentationDocument, + slide: &mut PresentationSlide, + layout_ref: &str, + action: &str, +) -> Result<(), PresentationArtifactError> { + let layout = document.get_layout(layout_ref, action)?.clone(); + let placeholders = resolved_layout_placeholders(document, &layout.layout_id, action)?; + slide.layout_id = Some(layout.layout_id); + for resolved in placeholders { + slide.elements.push(materialize_placeholder_element( + document.next_element_id(), + resolved.definition, + slide.elements.len(), + )); + } + Ok(()) +} + +fn materialize_placeholder_element( + element_id: String, + placeholder: PlaceholderDefinition, + z_order: usize, +) -> PresentationElement { + let placeholder_ref = Some(PlaceholderRef { + name: placeholder.name.clone(), + placeholder_type: placeholder.placeholder_type.clone(), + index: placeholder.index, + }); + if placeholder_is_image(&placeholder.placeholder_type) { + return PresentationElement::Image(ImageElement { + element_id, + frame: placeholder.frame, + payload: None, + fit_mode: ImageFitMode::Stretch, + crop: None, + rotation_degrees: None, + flip_horizontal: false, + flip_vertical: false, + lock_aspect_ratio: true, + alt_text: Some(placeholder.name.clone()), + prompt: placeholder + .text + .clone() + .or_else(|| Some(format!("Image placeholder: {}", placeholder.name))), + is_placeholder: true, + placeholder: placeholder_ref, + z_order, + }); + } + if placeholder.geometry == ShapeGeometry::Rectangle { + PresentationElement::Text(TextElement { + element_id, + text: placeholder.text.unwrap_or_default(), + frame: placeholder.frame, + fill: None, + style: TextStyle::default(), + hyperlink: None, + placeholder: placeholder_ref, + z_order, + }) + } else { + PresentationElement::Shape(ShapeElement { + element_id, + geometry: placeholder.geometry, + frame: placeholder.frame, + fill: None, + stroke: None, + text: placeholder.text, + text_style: TextStyle::default(), + hyperlink: None, + placeholder: placeholder_ref, + rotation_degrees: None, + flip_horizontal: false, + flip_vertical: false, + z_order, + }) + } +} + +fn resolved_layout_placeholders( + document: &PresentationDocument, + layout_id: &str, + action: &str, +) -> Result, PresentationArtifactError> { + let mut lineage = Vec::new(); + collect_layout_lineage( + document, + layout_id, + action, + &mut HashSet::new(), + &mut lineage, + )?; + let mut resolved: Vec = Vec::new(); + for layout in lineage { + for placeholder in &layout.placeholders { + if let Some(index) = resolved.iter().position(|entry| { + placeholder_key(&entry.definition) == placeholder_key(placeholder) + }) { + resolved[index] = ResolvedPlaceholder { + source_layout_id: layout.layout_id.clone(), + definition: placeholder.clone(), + }; + } else { + resolved.push(ResolvedPlaceholder { + source_layout_id: layout.layout_id.clone(), + definition: placeholder.clone(), + }); + } + } + } + Ok(resolved) +} + +fn collect_layout_lineage<'a>( + document: &'a PresentationDocument, + layout_id: &str, + action: &str, + seen: &mut HashSet, + lineage: &mut Vec<&'a LayoutDocument>, +) -> Result<(), PresentationArtifactError> { + if !seen.insert(layout_id.to_string()) { + return Err(PresentationArtifactError::UnsupportedFeature { + action: action.to_string(), + message: format!("layout inheritance cycle detected at `{layout_id}`"), + }); + } + let layout = document.get_layout(layout_id, action)?; + if let Some(parent_layout_id) = &layout.parent_layout_id { + collect_layout_lineage(document, parent_layout_id, action, seen, lineage)?; + } + lineage.push(layout); + Ok(()) +} + +fn placeholder_key(placeholder: &PlaceholderDefinition) -> (String, String, Option) { + ( + placeholder.name.to_ascii_lowercase(), + placeholder.placeholder_type.to_ascii_lowercase(), + placeholder.index, + ) +} + +fn layout_placeholder_list( + document: &PresentationDocument, + layout_id: &str, + action: &str, +) -> Result, PresentationArtifactError> { + resolved_layout_placeholders(document, layout_id, action).map(|placeholders| { + placeholders + .into_iter() + .map(|placeholder| PlaceholderListEntry { + scope: "layout".to_string(), + source_layout_id: Some(placeholder.source_layout_id), + slide_index: None, + element_id: None, + name: placeholder.definition.name, + placeholder_type: placeholder.definition.placeholder_type, + index: placeholder.definition.index, + geometry: Some(format!("{:?}", placeholder.definition.geometry)), + text_preview: placeholder.definition.text, + }) + .collect() + }) +} + +fn placeholder_is_image(placeholder_type: &str) -> bool { + matches!( + placeholder_type.to_ascii_lowercase().as_str(), + "image" | "picture" | "pic" | "photo" + ) +} + +fn slide_placeholder_list( + slide: &PresentationSlide, + slide_index: usize, +) -> Vec { + slide + .elements + .iter() + .filter_map(|element| match element { + PresentationElement::Text(text) => { + text.placeholder + .as_ref() + .map(|placeholder| PlaceholderListEntry { + scope: "slide".to_string(), + source_layout_id: slide.layout_id.clone(), + slide_index: Some(slide_index), + element_id: Some(text.element_id.clone()), + name: placeholder.name.clone(), + placeholder_type: placeholder.placeholder_type.clone(), + index: placeholder.index, + geometry: Some("Rectangle".to_string()), + text_preview: Some(text.text.clone()), + }) + } + PresentationElement::Shape(shape) => { + shape + .placeholder + .as_ref() + .map(|placeholder| PlaceholderListEntry { + scope: "slide".to_string(), + source_layout_id: slide.layout_id.clone(), + slide_index: Some(slide_index), + element_id: Some(shape.element_id.clone()), + name: placeholder.name.clone(), + placeholder_type: placeholder.placeholder_type.clone(), + index: placeholder.index, + geometry: Some(format!("{:?}", shape.geometry)), + text_preview: shape.text.clone(), + }) + } + PresentationElement::Image(image) => { + image + .placeholder + .as_ref() + .map(|placeholder| PlaceholderListEntry { + scope: "slide".to_string(), + source_layout_id: slide.layout_id.clone(), + slide_index: Some(slide_index), + element_id: Some(image.element_id.clone()), + name: placeholder.name.clone(), + placeholder_type: placeholder.placeholder_type.clone(), + index: placeholder.index, + geometry: Some("Image".to_string()), + text_preview: image.prompt.clone(), + }) + } + PresentationElement::Connector(_) + | PresentationElement::Table(_) + | PresentationElement::Chart(_) => None, + }) + .collect() +} + diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/pptx.rs b/codex-rs/artifact-presentation/src/presentation_artifact/pptx.rs new file mode 100644 index 000000000..1343ee84f --- /dev/null +++ b/codex-rs/artifact-presentation/src/presentation_artifact/pptx.rs @@ -0,0 +1,922 @@ +fn build_pptx_bytes(document: &PresentationDocument, action: &str) -> Result, String> { + let bytes = document + .to_ppt_rs() + .build() + .map_err(|error| format!("{action}: {error}"))?; + patch_pptx_package(bytes, document).map_err(|error| format!("{action}: {error}")) +} + +struct SlideImageAsset { + xml: String, + relationship_xml: String, + media_path: String, + media_bytes: Vec, + extension: String, +} + +fn normalized_image_extension(format: &str) -> String { + match format.to_ascii_lowercase().as_str() { + "jpeg" => "jpg".to_string(), + other => other.to_string(), + } +} + +fn image_relationship_xml(relationship_id: &str, target: &str) -> String { + format!( + r#""#, + ppt_rs::escape_xml(target) + ) +} + +fn image_picture_xml( + image: &ImageElement, + shape_id: usize, + relationship_id: &str, + frame: Rect, + crop: Option, +) -> String { + let blip_fill = if let Some((crop_left, crop_top, crop_right, crop_bottom)) = crop { + format!( + r#" + + + + + +"#, + (crop_left * 100_000.0).round() as u32, + (crop_top * 100_000.0).round() as u32, + (crop_right * 100_000.0).round() as u32, + (crop_bottom * 100_000.0).round() as u32, + ) + } else { + format!( + r#" + + + + +"# + ) + }; + let descr = image + .alt_text + .as_deref() + .map(|alt| format!(r#" descr="{}""#, ppt_rs::escape_xml(alt))) + .unwrap_or_default(); + let no_change_aspect = if image.lock_aspect_ratio { 1 } else { 0 }; + let rotation = image + .rotation_degrees + .map(|rotation| format!(r#" rot="{}""#, i64::from(rotation) * 60_000)) + .unwrap_or_default(); + let flip_horizontal = if image.flip_horizontal { + r#" flipH="1""# + } else { + "" + }; + let flip_vertical = if image.flip_vertical { + r#" flipV="1""# + } else { + "" + }; + format!( + r#" + + + + + + + +{blip_fill} + + + + + + + + + +"#, + points_to_emu(frame.left), + points_to_emu(frame.top), + points_to_emu(frame.width), + points_to_emu(frame.height), + ) +} + +fn slide_image_assets( + slide: &PresentationSlide, + next_media_index: &mut usize, +) -> Vec { + let mut ordered = slide.elements.iter().collect::>(); + ordered.sort_by_key(|element| element.z_order()); + let shape_count = ordered + .iter() + .filter(|element| { + matches!( + element, + PresentationElement::Text(_) + | PresentationElement::Shape(_) + | PresentationElement::Image(ImageElement { payload: None, .. }) + ) + }) + .count() + + usize::from(slide.background_fill.is_some()); + let mut image_index = 0_usize; + let mut assets = Vec::new(); + for element in ordered { + let PresentationElement::Image(image) = element else { + continue; + }; + let Some(payload) = &image.payload else { + continue; + }; + let (left, top, width, height, fitted_crop) = if image.fit_mode != ImageFitMode::Stretch { + fit_image(image) + } else { + ( + image.frame.left, + image.frame.top, + image.frame.width, + image.frame.height, + None, + ) + }; + image_index += 1; + let relationship_id = format!("rIdImage{image_index}"); + let extension = normalized_image_extension(&payload.format); + let media_name = format!("image{next_media_index}.{extension}"); + *next_media_index += 1; + assets.push(SlideImageAsset { + xml: image_picture_xml( + image, + 20 + shape_count + image_index - 1, + &relationship_id, + Rect { + left, + top, + width, + height, + }, + image.crop.or(fitted_crop), + ), + relationship_xml: image_relationship_xml( + &relationship_id, + &format!("../media/{media_name}"), + ), + media_path: format!("ppt/media/{media_name}"), + media_bytes: payload.bytes.clone(), + extension, + }); + } + assets +} + +fn patch_pptx_package( + source_bytes: Vec, + document: &PresentationDocument, +) -> Result, String> { + let mut archive = + ZipArchive::new(Cursor::new(source_bytes)).map_err(|error| error.to_string())?; + let mut writer = ZipWriter::new(Cursor::new(Vec::new())); + let mut next_media_index = 1_usize; + let mut pending_slide_relationships = HashMap::new(); + let mut pending_slide_images = HashMap::new(); + let mut pending_media = Vec::new(); + let mut image_extensions = BTreeSet::new(); + for (slide_index, slide) in document.slides.iter().enumerate() { + let slide_number = slide_index + 1; + let images = slide_image_assets(slide, &mut next_media_index); + let mut relationships = slide_hyperlink_relationships(slide); + relationships.extend(images.iter().map(|image| image.relationship_xml.clone())); + if !relationships.is_empty() { + pending_slide_relationships.insert(slide_number, relationships); + } + if !images.is_empty() { + image_extensions.extend(images.iter().map(|image| image.extension.clone())); + pending_media.extend( + images + .iter() + .map(|image| (image.media_path.clone(), image.media_bytes.clone())), + ); + pending_slide_images.insert(slide_number, images); + } + } + + for index in 0..archive.len() { + let mut file = archive.by_index(index).map_err(|error| error.to_string())?; + if file.is_dir() { + continue; + } + let name = file.name().to_string(); + let options = file.options(); + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes) + .map_err(|error| error.to_string())?; + writer + .start_file(&name, options) + .map_err(|error| error.to_string())?; + if name == "[Content_Types].xml" { + writer + .write_all(update_content_types_xml(bytes, &image_extensions)?.as_bytes()) + .map_err(|error| error.to_string())?; + continue; + } + if name == "ppt/presentation.xml" { + writer + .write_all( + update_presentation_xml_dimensions(bytes, document.slide_size)?.as_bytes(), + ) + .map_err(|error| error.to_string())?; + continue; + } + if let Some(slide_number) = parse_slide_xml_path(&name) { + writer + .write_all( + update_slide_xml( + bytes, + &document.slides[slide_number - 1], + pending_slide_images + .get(&slide_number) + .map(std::vec::Vec::as_slice) + .unwrap_or(&[]), + )? + .as_bytes(), + ) + .map_err(|error| error.to_string())?; + continue; + } + if let Some(slide_number) = parse_slide_relationships_path(&name) + && let Some(relationships) = pending_slide_relationships.remove(&slide_number) + { + writer + .write_all(update_slide_relationships_xml(bytes, &relationships)?.as_bytes()) + .map_err(|error| error.to_string())?; + continue; + } + writer + .write_all(&bytes) + .map_err(|error| error.to_string())?; + } + + for (slide_number, relationships) in pending_slide_relationships { + writer + .start_file( + format!("ppt/slides/_rels/slide{slide_number}.xml.rels"), + SimpleFileOptions::default(), + ) + .map_err(|error| error.to_string())?; + writer + .write_all(slide_relationships_xml(&relationships).as_bytes()) + .map_err(|error| error.to_string())?; + } + + for (path, bytes) in pending_media { + writer + .start_file(path, SimpleFileOptions::default()) + .map_err(|error| error.to_string())?; + writer + .write_all(&bytes) + .map_err(|error| error.to_string())?; + } + + writer + .finish() + .map_err(|error| error.to_string()) + .map(Cursor::into_inner) +} + +fn update_presentation_xml_dimensions( + existing_bytes: Vec, + slide_size: Rect, +) -> Result { + let existing = String::from_utf8(existing_bytes).map_err(|error| error.to_string())?; + let updated = replace_self_closing_xml_tag( + &existing, + "p:sldSz", + &format!( + r#""#, + points_to_emu(slide_size.width), + points_to_emu(slide_size.height) + ), + )?; + replace_self_closing_xml_tag( + &updated, + "p:notesSz", + &format!( + r#""#, + points_to_emu(slide_size.height), + points_to_emu(slide_size.width) + ), + ) +} + +fn replace_self_closing_xml_tag(xml: &str, tag: &str, replacement: &str) -> Result { + let start = xml + .find(&format!("<{tag} ")) + .ok_or_else(|| format!("presentation xml is missing `<{tag} .../>`"))?; + let end = xml[start..] + .find("/>") + .map(|offset| start + offset + 2) + .ok_or_else(|| format!("presentation xml tag `{tag}` is not self-closing"))?; + Ok(format!("{}{replacement}{}", &xml[..start], &xml[end..])) +} + +fn slide_hyperlink_relationships(slide: &PresentationSlide) -> Vec { + let mut ordered = slide.elements.iter().collect::>(); + ordered.sort_by_key(|element| element.z_order()); + let mut hyperlink_index = 1_u32; + let mut relationships = Vec::new(); + for element in ordered { + let Some(hyperlink) = (match element { + PresentationElement::Text(text) => text.hyperlink.as_ref(), + PresentationElement::Shape(shape) => shape.hyperlink.as_ref(), + PresentationElement::Connector(_) + | PresentationElement::Image(_) + | PresentationElement::Table(_) + | PresentationElement::Chart(_) => None, + }) else { + continue; + }; + let relationship_id = format!("rIdHyperlink{hyperlink_index}"); + hyperlink_index += 1; + relationships.push(hyperlink.relationship_xml(&relationship_id)); + } + relationships +} + +fn parse_slide_relationships_path(path: &str) -> Option { + path.strip_prefix("ppt/slides/_rels/slide")? + .strip_suffix(".xml.rels")? + .parse::() + .ok() +} + +fn parse_slide_xml_path(path: &str) -> Option { + path.strip_prefix("ppt/slides/slide")? + .strip_suffix(".xml")? + .parse::() + .ok() +} + +fn update_slide_relationships_xml( + existing_bytes: Vec, + relationships: &[String], +) -> Result { + let existing = String::from_utf8(existing_bytes).map_err(|error| error.to_string())?; + let injected = relationships.join("\n"); + existing + .contains("") + .then(|| existing.replace("", &format!("{injected}\n"))) + .ok_or_else(|| { + "slide relationships xml is missing a closing ``".to_string() + }) +} + +fn slide_relationships_xml(relationships: &[String]) -> String { + let body = relationships.join("\n"); + format!( + r#" + +{body} +"# + ) +} + +fn update_content_types_xml( + existing_bytes: Vec, + image_extensions: &BTreeSet, +) -> Result { + let existing = String::from_utf8(existing_bytes).map_err(|error| error.to_string())?; + if image_extensions.is_empty() { + return Ok(existing); + } + let existing_lower = existing.to_ascii_lowercase(); + let additions = image_extensions + .iter() + .filter(|extension| { + !existing_lower.contains(&format!( + r#"extension="{}""#, + extension.to_ascii_lowercase() + )) + }) + .map(|extension| generate_image_content_type(extension)) + .collect::>(); + if additions.is_empty() { + return Ok(existing); + } + existing + .contains("") + .then(|| existing.replace("", &format!("{}\n", additions.join("\n")))) + .ok_or_else(|| "content types xml is missing a closing ``".to_string()) +} + +fn update_slide_xml( + existing_bytes: Vec, + slide: &PresentationSlide, + slide_images: &[SlideImageAsset], +) -> Result { + let existing = String::from_utf8(existing_bytes).map_err(|error| error.to_string())?; + let existing = replace_image_placeholders(existing, slide_images)?; + let existing = apply_shape_block_patches(existing, slide)?; + let table_xml = slide_table_xml(slide); + if table_xml.is_empty() { + return Ok(existing); + } + existing + .contains("") + .then(|| existing.replace("", &format!("{table_xml}\n"))) + .ok_or_else(|| "slide xml is missing a closing ``".to_string()) +} + +fn replace_image_placeholders( + existing: String, + slide_images: &[SlideImageAsset], +) -> Result { + if slide_images.is_empty() { + return Ok(existing); + } + let mut updated = String::with_capacity(existing.len()); + let mut remaining = existing.as_str(); + for image in slide_images { + let marker = remaining + .find("name=\"Image Placeholder: ") + .ok_or_else(|| { + "slide xml is missing an image placeholder block for exported images".to_string() + })?; + let start = remaining[..marker].rfind("").ok_or_else(|| { + "slide xml is missing an opening `` for image placeholder".to_string() + })?; + let end = remaining[marker..] + .find("") + .map(|offset| marker + offset + "".len()) + .ok_or_else(|| { + "slide xml is missing a closing `` for image placeholder".to_string() + })?; + updated.push_str(&remaining[..start]); + updated.push_str(&image.xml); + remaining = &remaining[end..]; + } + updated.push_str(remaining); + Ok(updated) +} + +#[derive(Clone, Copy)] +struct ShapeXmlPatch { + line_style: Option, + flip_horizontal: bool, + flip_vertical: bool, +} + +fn apply_shape_block_patches( + existing: String, + slide: &PresentationSlide, +) -> Result { + let mut patches = Vec::new(); + if slide.background_fill.is_some() { + patches.push(None); + } + let mut ordered = slide.elements.iter().collect::>(); + ordered.sort_by_key(|element| element.z_order()); + for element in ordered { + match element { + PresentationElement::Text(_) => patches.push(None), + PresentationElement::Shape(shape) => patches.push(Some(ShapeXmlPatch { + line_style: shape + .stroke + .as_ref() + .map(|stroke| stroke.style) + .filter(|style| *style != LineStyle::Solid), + flip_horizontal: shape.flip_horizontal, + flip_vertical: shape.flip_vertical, + })), + PresentationElement::Image(ImageElement { payload: None, .. }) => patches.push(None), + PresentationElement::Connector(_) + | PresentationElement::Image(_) + | PresentationElement::Table(_) + | PresentationElement::Chart(_) => {} + } + } + if patches.iter().all(|patch| { + patch.is_none_or(|patch| { + patch.line_style.is_none() && !patch.flip_horizontal && !patch.flip_vertical + }) + }) { + return Ok(existing); + } + + let mut updated = String::with_capacity(existing.len()); + let mut remaining = existing.as_str(); + for patch in patches { + let Some(start) = remaining.find("") else { + return Err("slide xml is missing an expected `` block".to_string()); + }; + let end = remaining[start..] + .find("") + .map(|offset| start + offset + "".len()) + .ok_or_else(|| "slide xml is missing a closing `` block".to_string())?; + updated.push_str(&remaining[..start]); + let block = &remaining[start..end]; + if let Some(patch) = patch { + updated.push_str(&patch_shape_block(block, patch)?); + } else { + updated.push_str(block); + } + remaining = &remaining[end..]; + } + updated.push_str(remaining); + Ok(updated) +} + +fn patch_shape_block(block: &str, patch: ShapeXmlPatch) -> Result { + let block = if let Some(line_style) = patch.line_style { + patch_shape_block_dash(block, line_style)? + } else { + block.to_string() + }; + if patch.flip_horizontal || patch.flip_vertical { + patch_shape_block_flip(&block, patch.flip_horizontal, patch.flip_vertical) + } else { + Ok(block) + } +} + +fn patch_shape_block_dash(block: &str, line_style: LineStyle) -> Result { + let Some(line_start) = block.find("` entry for stroke styling".to_string()); + }; + if let Some(dash_start) = block[line_start..].find("") + .map(|offset| dash_start + offset + 2) + .ok_or_else(|| "shape line dash entry is missing a closing `/>`".to_string())?; + let mut patched = String::with_capacity(block.len() + 32); + patched.push_str(&block[..dash_start]); + patched.push_str(&format!( + r#""#, + line_style.to_ppt_xml() + )); + patched.push_str(&block[dash_end..]); + return Ok(patched); + } + + if let Some(line_end) = block[line_start..].find("") { + let line_end = line_start + line_end; + let mut patched = String::with_capacity(block.len() + 32); + patched.push_str(&block[..line_end]); + patched.push_str(&format!( + r#""#, + line_style.to_ppt_xml() + )); + patched.push_str(&block[line_end..]); + return Ok(patched); + } + + let line_end = block[line_start..] + .find("/>") + .map(|offset| line_start + offset + 2) + .ok_or_else(|| "shape line entry is missing a closing marker".to_string())?; + let line_tag = &block[line_start..line_end - 2]; + let mut patched = String::with_capacity(block.len() + 48); + patched.push_str(&block[..line_start]); + patched.push_str(line_tag); + patched.push('>'); + patched.push_str(&format!( + r#""#, + line_style.to_ppt_xml() + )); + patched.push_str(""); + patched.push_str(&block[line_end..]); + Ok(patched) +} + +fn patch_shape_block_flip( + block: &str, + flip_horizontal: bool, + flip_vertical: bool, +) -> Result { + let Some(xfrm_start) = block.find("` entry for flip styling".to_string()); + }; + let tag_end = block[xfrm_start..] + .find('>') + .map(|offset| xfrm_start + offset) + .ok_or_else(|| "shape transform entry is missing a closing `>`".to_string())?; + let tag = &block[xfrm_start..=tag_end]; + let mut patched_tag = tag.to_string(); + patched_tag = upsert_xml_attribute( + &patched_tag, + "flipH", + if flip_horizontal { "1" } else { "0" }, + ); + patched_tag = + upsert_xml_attribute(&patched_tag, "flipV", if flip_vertical { "1" } else { "0" }); + Ok(format!( + "{}{}{}", + &block[..xfrm_start], + patched_tag, + &block[tag_end + 1..] + )) +} + +fn upsert_xml_attribute(tag: &str, attribute: &str, value: &str) -> String { + let needle = format!(r#"{attribute}=""#); + if let Some(start) = tag.find(&needle) { + let value_start = start + needle.len(); + if let Some(end_offset) = tag[value_start..].find('"') { + let end = value_start + end_offset; + return format!("{}{}{}", &tag[..value_start], value, &tag[end..]); + } + } + let insert_at = tag.len() - 1; + format!(r#"{} {attribute}="{value}""#, &tag[..insert_at]) + &tag[insert_at..] +} + +fn slide_table_xml(slide: &PresentationSlide) -> String { + let mut ordered = slide.elements.iter().collect::>(); + ordered.sort_by_key(|element| element.z_order()); + let mut table_index = 0_usize; + ordered + .into_iter() + .filter_map(|element| { + let PresentationElement::Table(table) = element else { + return None; + }; + table_index += 1; + let rows = table + .rows + .clone() + .into_iter() + .enumerate() + .map(|(row_index, row)| { + let cells = row + .into_iter() + .enumerate() + .map(|(column_index, cell)| { + build_table_cell(cell, &table.merges, row_index, column_index) + }) + .collect::>(); + let mut table_row = TableRow::new(cells); + if let Some(height) = table.row_heights.get(row_index) { + table_row = table_row.with_height(points_to_emu(*height)); + } + Some(table_row) + }) + .collect::>>()?; + Some(ppt_rs::generator::table::generate_table_xml( + &ppt_rs::generator::table::Table::new( + rows, + table + .column_widths + .iter() + .copied() + .map(points_to_emu) + .collect(), + points_to_emu(table.frame.left), + points_to_emu(table.frame.top), + ), + 300 + table_index, + )) + }) + .collect::>() + .join("\n") +} + +fn write_preview_images( + document: &PresentationDocument, + output_dir: &Path, + action: &str, +) -> Result<(), PresentationArtifactError> { + let pptx_path = output_dir.join("preview.pptx"); + let bytes = build_pptx_bytes(document, action).map_err(|message| { + PresentationArtifactError::ExportFailed { + path: pptx_path.clone(), + message, + } + })?; + std::fs::write(&pptx_path, bytes).map_err(|error| PresentationArtifactError::ExportFailed { + path: pptx_path.clone(), + message: error.to_string(), + })?; + render_pptx_to_pngs(&pptx_path, output_dir, action) +} + +fn render_pptx_to_pngs( + pptx_path: &Path, + output_dir: &Path, + action: &str, +) -> Result<(), PresentationArtifactError> { + let soffice_cmd = if cfg!(target_os = "macos") + && Path::new("/Applications/LibreOffice.app/Contents/MacOS/soffice").exists() + { + "/Applications/LibreOffice.app/Contents/MacOS/soffice" + } else { + "soffice" + }; + let conversion = Command::new(soffice_cmd) + .arg("--headless") + .arg("--convert-to") + .arg("pdf") + .arg(pptx_path) + .arg("--outdir") + .arg(output_dir) + .output() + .map_err(|error| PresentationArtifactError::ExportFailed { + path: pptx_path.to_path_buf(), + message: format!("{action}: failed to execute LibreOffice: {error}"), + })?; + if !conversion.status.success() { + return Err(PresentationArtifactError::ExportFailed { + path: pptx_path.to_path_buf(), + message: format!( + "{action}: LibreOffice conversion failed: {}", + String::from_utf8_lossy(&conversion.stderr) + ), + }); + } + + let pdf_path = output_dir.join( + pptx_path + .file_stem() + .and_then(|stem| stem.to_str()) + .map(|stem| format!("{stem}.pdf")) + .ok_or_else(|| PresentationArtifactError::ExportFailed { + path: pptx_path.to_path_buf(), + message: format!("{action}: preview pptx filename is invalid"), + })?, + ); + let prefix = output_dir.join("slide"); + let conversion = Command::new("pdftoppm") + .arg("-png") + .arg(&pdf_path) + .arg(&prefix) + .output() + .map_err(|error| PresentationArtifactError::ExportFailed { + path: pdf_path.clone(), + message: format!("{action}: failed to execute pdftoppm: {error}"), + })?; + std::fs::remove_file(&pdf_path).ok(); + if !conversion.status.success() { + return Err(PresentationArtifactError::ExportFailed { + path: output_dir.to_path_buf(), + message: format!( + "{action}: pdftoppm conversion failed: {}", + String::from_utf8_lossy(&conversion.stderr) + ), + }); + } + Ok(()) +} + +pub(crate) fn write_preview_image( + source_path: &Path, + target_path: &Path, + format: PreviewOutputFormat, + scale: f32, + quality: u8, + action: &str, +) -> Result<(), PresentationArtifactError> { + if matches!(format, PreviewOutputFormat::Png) && scale == 1.0 { + std::fs::rename(source_path, target_path).map_err(|error| { + PresentationArtifactError::ExportFailed { + path: target_path.to_path_buf(), + message: error.to_string(), + } + })?; + return Ok(()); + } + let mut preview = + image::open(source_path).map_err(|error| PresentationArtifactError::ExportFailed { + path: source_path.to_path_buf(), + message: format!("{action}: {error}"), + })?; + if scale != 1.0 { + let width = (preview.width() as f32 * scale).round().max(1.0) as u32; + let height = (preview.height() as f32 * scale).round().max(1.0) as u32; + preview = preview.resize_exact(width, height, FilterType::Lanczos3); + } + let file = std::fs::File::create(target_path).map_err(|error| { + PresentationArtifactError::ExportFailed { + path: target_path.to_path_buf(), + message: error.to_string(), + } + })?; + let mut writer = std::io::BufWriter::new(file); + match format { + PreviewOutputFormat::Png => { + preview + .write_to(&mut writer, ImageFormat::Png) + .map_err(|error| PresentationArtifactError::ExportFailed { + path: target_path.to_path_buf(), + message: format!("{action}: {error}"), + })? + } + PreviewOutputFormat::Jpeg => { + let rgb = preview.to_rgb8(); + let mut encoder = JpegEncoder::new_with_quality(&mut writer, quality); + encoder.encode_image(&rgb).map_err(|error| { + PresentationArtifactError::ExportFailed { + path: target_path.to_path_buf(), + message: format!("{action}: {error}"), + } + })?; + } + PreviewOutputFormat::Svg => { + let mut png_bytes = Cursor::new(Vec::new()); + preview + .write_to(&mut png_bytes, ImageFormat::Png) + .map_err(|error| PresentationArtifactError::ExportFailed { + path: target_path.to_path_buf(), + message: format!("{action}: {error}"), + })?; + let embedded_png = BASE64_STANDARD.encode(png_bytes.into_inner()); + let svg = format!( + r#""#, + preview.width(), + preview.height(), + preview.width(), + preview.height(), + preview.width(), + preview.height(), + ); + writer.write_all(svg.as_bytes()).map_err(|error| { + PresentationArtifactError::ExportFailed { + path: target_path.to_path_buf(), + message: format!("{action}: {error}"), + } + })?; + } + } + std::fs::remove_file(source_path).ok(); + Ok(()) +} + +fn collect_pngs(output_dir: &Path) -> Result, PresentationArtifactError> { + let mut files = std::fs::read_dir(output_dir) + .map_err(|error| PresentationArtifactError::ExportFailed { + path: output_dir.to_path_buf(), + message: error.to_string(), + })? + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("png")) + .collect::>(); + files.sort(); + Ok(files) +} + +fn parse_preview_output_format( + format: Option<&str>, + path: &Path, + action: &str, +) -> Result { + let value = format + .map(str::to_owned) + .or_else(|| { + path.extension() + .and_then(|extension| extension.to_str()) + .map(str::to_owned) + }) + .unwrap_or_else(|| "png".to_string()); + match value.to_ascii_lowercase().as_str() { + "png" => Ok(PreviewOutputFormat::Png), + "jpg" | "jpeg" => Ok(PreviewOutputFormat::Jpeg), + "svg" => Ok(PreviewOutputFormat::Svg), + other => Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("preview format `{other}` is not supported"), + }), + } +} + +fn normalize_preview_scale( + scale: Option, + action: &str, +) -> Result { + let scale = scale.unwrap_or(1.0); + if !scale.is_finite() || scale <= 0.0 { + return Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: "`scale` must be a positive number".to_string(), + }); + } + Ok(scale) +} + +fn normalize_preview_quality( + quality: Option, + action: &str, +) -> Result { + let quality = quality.unwrap_or(90); + if quality == 0 || quality > 100 { + return Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: "`quality` must be between 1 and 100".to_string(), + }); + } + Ok(quality) +} + diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/proto.rs b/codex-rs/artifact-presentation/src/presentation_artifact/proto.rs new file mode 100644 index 000000000..23b6f7e2e --- /dev/null +++ b/codex-rs/artifact-presentation/src/presentation_artifact/proto.rs @@ -0,0 +1,356 @@ +fn document_to_proto( + document: &PresentationDocument, + action: &str, +) -> Result { + let layouts = document + .layouts + .iter() + .map(|layout| layout_to_proto(document, layout, action)) + .collect::, _>>()?; + let slides = document + .slides + .iter() + .enumerate() + .map(|(slide_index, slide)| slide_to_proto(slide, slide_index)) + .collect::>(); + Ok(serde_json::json!({ + "kind": "presentation", + "artifactId": document.artifact_id, + "anchor": format!("pr/{}", document.artifact_id), + "name": document.name, + "slideSize": rect_to_proto(document.slide_size), + "activeSlideIndex": document.active_slide_index, + "activeSlideId": document.active_slide_index.and_then(|index| document.slides.get(index)).map(|slide| slide.slide_id.clone()), + "theme": serde_json::json!({ + "colorScheme": document.theme.color_scheme, + "hexColorMap": document.theme.color_scheme, + "majorFont": document.theme.major_font, + "minorFont": document.theme.minor_font, + }), + "styles": document + .named_text_styles() + .iter() + .map(|style| named_text_style_to_json(style, "st")) + .collect::>(), + "masters": document.layouts.iter().filter(|layout| layout.kind == LayoutKind::Master).map(|layout| layout.layout_id.clone()).collect::>(), + "layouts": layouts, + "slides": slides, + })) +} + +fn layout_to_proto( + document: &PresentationDocument, + layout: &LayoutDocument, + action: &str, +) -> Result { + let placeholders = layout + .placeholders + .iter() + .map(placeholder_definition_to_proto) + .collect::>(); + let resolved_placeholders = resolved_layout_placeholders(document, &layout.layout_id, action)? + .into_iter() + .map(|placeholder| { + let mut value = placeholder_definition_to_proto(&placeholder.definition); + value["sourceLayoutId"] = Value::String(placeholder.source_layout_id); + value + }) + .collect::>(); + Ok(serde_json::json!({ + "layoutId": layout.layout_id, + "anchor": format!("ly/{}", layout.layout_id), + "name": layout.name, + "kind": match layout.kind { + LayoutKind::Layout => "layout", + LayoutKind::Master => "master", + }, + "parentLayoutId": layout.parent_layout_id, + "placeholders": placeholders, + "resolvedPlaceholders": resolved_placeholders, + })) +} + +fn placeholder_definition_to_proto(placeholder: &PlaceholderDefinition) -> Value { + serde_json::json!({ + "name": placeholder.name, + "placeholderType": placeholder.placeholder_type, + "index": placeholder.index, + "text": placeholder.text, + "geometry": format!("{:?}", placeholder.geometry), + "frame": rect_to_proto(placeholder.frame), + }) +} + +fn slide_to_proto(slide: &PresentationSlide, slide_index: usize) -> Value { + serde_json::json!({ + "slideId": slide.slide_id, + "anchor": format!("sl/{}", slide.slide_id), + "index": slide_index, + "layoutId": slide.layout_id, + "backgroundFill": slide.background_fill, + "notes": serde_json::json!({ + "anchor": format!("nt/{}", slide.slide_id), + "text": slide.notes.text, + "visible": slide.notes.visible, + "textPreview": slide.notes.text.replace('\n', " | "), + "textChars": slide.notes.text.chars().count(), + "textLines": slide.notes.text.lines().count(), + }), + "elements": slide.elements.iter().map(element_to_proto).collect::>(), + }) +} + +fn element_to_proto(element: &PresentationElement) -> Value { + match element { + PresentationElement::Text(text) => { + let mut record = serde_json::json!({ + "kind": "text", + "elementId": text.element_id, + "anchor": format!("sh/{}", text.element_id), + "frame": rect_to_proto(text.frame), + "text": text.text, + "textPreview": text.text.replace('\n', " | "), + "textChars": text.text.chars().count(), + "textLines": text.text.lines().count(), + "fill": text.fill, + "style": text_style_to_proto(&text.style), + "zOrder": text.z_order, + }); + if let Some(placeholder) = &text.placeholder { + record["placeholder"] = placeholder_ref_to_proto(placeholder); + } + if let Some(hyperlink) = &text.hyperlink { + record["hyperlink"] = hyperlink.to_json(); + } + record + } + PresentationElement::Shape(shape) => { + let mut record = serde_json::json!({ + "kind": "shape", + "elementId": shape.element_id, + "anchor": format!("sh/{}", shape.element_id), + "geometry": format!("{:?}", shape.geometry), + "frame": rect_to_proto(shape.frame), + "fill": shape.fill, + "stroke": shape.stroke.as_ref().map(stroke_to_proto), + "text": shape.text, + "textStyle": text_style_to_proto(&shape.text_style), + "rotation": shape.rotation_degrees, + "flipHorizontal": shape.flip_horizontal, + "flipVertical": shape.flip_vertical, + "zOrder": shape.z_order, + }); + if let Some(text) = &shape.text { + record["textPreview"] = Value::String(text.replace('\n', " | ")); + record["textChars"] = Value::from(text.chars().count()); + record["textLines"] = Value::from(text.lines().count()); + } + if let Some(placeholder) = &shape.placeholder { + record["placeholder"] = placeholder_ref_to_proto(placeholder); + } + if let Some(hyperlink) = &shape.hyperlink { + record["hyperlink"] = hyperlink.to_json(); + } + record + } + PresentationElement::Connector(connector) => serde_json::json!({ + "kind": "connector", + "elementId": connector.element_id, + "anchor": format!("cn/{}", connector.element_id), + "connectorType": format!("{:?}", connector.connector_type), + "start": serde_json::json!({ + "left": connector.start.left, + "top": connector.start.top, + "unit": "points", + }), + "end": serde_json::json!({ + "left": connector.end.left, + "top": connector.end.top, + "unit": "points", + }), + "line": stroke_to_proto(&connector.line), + "lineStyle": connector.line_style.as_api_str(), + "startArrow": format!("{:?}", connector.start_arrow), + "endArrow": format!("{:?}", connector.end_arrow), + "arrowSize": format!("{:?}", connector.arrow_size), + "label": connector.label, + "zOrder": connector.z_order, + }), + PresentationElement::Image(image) => { + let mut record = serde_json::json!({ + "kind": "image", + "elementId": image.element_id, + "anchor": format!("im/{}", image.element_id), + "frame": rect_to_proto(image.frame), + "fit": format!("{:?}", image.fit_mode), + "crop": image.crop.map(|(left, top, right, bottom)| serde_json::json!({ + "left": left, + "top": top, + "right": right, + "bottom": bottom, + })), + "rotation": image.rotation_degrees, + "flipHorizontal": image.flip_horizontal, + "flipVertical": image.flip_vertical, + "lockAspectRatio": image.lock_aspect_ratio, + "alt": image.alt_text, + "prompt": image.prompt, + "isPlaceholder": image.is_placeholder, + "payload": image.payload.as_ref().map(image_payload_to_proto), + "zOrder": image.z_order, + }); + if let Some(placeholder) = &image.placeholder { + record["placeholder"] = placeholder_ref_to_proto(placeholder); + } + record + } + PresentationElement::Table(table) => serde_json::json!({ + "kind": "table", + "elementId": table.element_id, + "anchor": format!("tb/{}", table.element_id), + "frame": rect_to_proto(table.frame), + "rows": table.rows.iter().map(|row| { + row.iter().map(table_cell_to_proto).collect::>() + }).collect::>(), + "columnWidths": table.column_widths, + "rowHeights": table.row_heights, + "style": table.style, + "merges": table.merges.iter().map(|merge| serde_json::json!({ + "startRow": merge.start_row, + "endRow": merge.end_row, + "startColumn": merge.start_column, + "endColumn": merge.end_column, + })).collect::>(), + "zOrder": table.z_order, + }), + PresentationElement::Chart(chart) => serde_json::json!({ + "kind": "chart", + "elementId": chart.element_id, + "anchor": format!("ch/{}", chart.element_id), + "frame": rect_to_proto(chart.frame), + "chartType": format!("{:?}", chart.chart_type), + "title": chart.title, + "categories": chart.categories, + "series": chart.series.iter().map(|series| serde_json::json!({ + "name": series.name, + "values": series.values, + })).collect::>(), + "zOrder": chart.z_order, + }), + } +} + +fn rect_to_proto(rect: Rect) -> Value { + serde_json::json!({ + "left": rect.left, + "top": rect.top, + "width": rect.width, + "height": rect.height, + "unit": "points", + }) +} + +fn stroke_to_proto(stroke: &StrokeStyle) -> Value { + serde_json::json!({ + "color": stroke.color, + "width": stroke.width, + "style": stroke.style.as_api_str(), + "unit": "points", + }) +} + +fn text_style_to_proto(style: &TextStyle) -> Value { + serde_json::json!({ + "styleName": style.style_name, + "fontSize": style.font_size, + "fontFamily": style.font_family, + "color": style.color, + "alignment": style.alignment, + "bold": style.bold, + "italic": style.italic, + "underline": style.underline, + }) +} + +fn placeholder_ref_to_proto(placeholder: &PlaceholderRef) -> Value { + serde_json::json!({ + "name": placeholder.name, + "placeholderType": placeholder.placeholder_type, + "index": placeholder.index, + }) +} + +fn image_payload_to_proto(payload: &ImagePayload) -> Value { + serde_json::json!({ + "format": payload.format, + "widthPx": payload.width_px, + "heightPx": payload.height_px, + "bytesBase64": BASE64_STANDARD.encode(&payload.bytes), + }) +} + +fn table_cell_to_proto(cell: &TableCellSpec) -> Value { + serde_json::json!({ + "text": cell.text, + "textStyle": text_style_to_proto(&cell.text_style), + "backgroundFill": cell.background_fill, + "alignment": cell.alignment, + }) +} + +fn build_table_cell( + cell: TableCellSpec, + merges: &[TableMergeRegion], + row_index: usize, + column_index: usize, +) -> TableCell { + let mut table_cell = TableCell::new(&cell.text); + if cell.text_style.bold { + table_cell = table_cell.bold(); + } + if cell.text_style.italic { + table_cell = table_cell.italic(); + } + if cell.text_style.underline { + table_cell = table_cell.underline(); + } + if let Some(color) = cell.text_style.color { + table_cell = table_cell.text_color(&color); + } + if let Some(fill) = cell.background_fill { + table_cell = table_cell.background_color(&fill); + } + if let Some(size) = cell.text_style.font_size { + table_cell = table_cell.font_size(size); + } + if let Some(font_family) = cell.text_style.font_family { + table_cell = table_cell.font_family(&font_family); + } + if let Some(alignment) = cell.alignment.or(cell.text_style.alignment) { + table_cell = match alignment { + TextAlignment::Left => table_cell.align_left(), + TextAlignment::Center => table_cell.align_center(), + TextAlignment::Right => table_cell.align_right(), + TextAlignment::Justify => table_cell.align(CellAlign::Justify), + }; + } + for merge in merges { + if row_index == merge.start_row && column_index == merge.start_column { + table_cell = table_cell + .grid_span((merge.end_column - merge.start_column + 1) as u32) + .row_span((merge.end_row - merge.start_row + 1) as u32); + } else if row_index >= merge.start_row + && row_index <= merge.end_row + && column_index >= merge.start_column + && column_index <= merge.end_column + { + if row_index == merge.start_row { + table_cell = table_cell.h_merge(); + } else { + table_cell = table_cell.v_merge(); + } + } + } + table_cell +} + diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/response.rs b/codex-rs/artifact-presentation/src/presentation_artifact/response.rs new file mode 100644 index 000000000..303f0b569 --- /dev/null +++ b/codex-rs/artifact-presentation/src/presentation_artifact/response.rs @@ -0,0 +1,135 @@ +#[derive(Debug, Clone, Serialize)] +pub struct PresentationArtifactResponse { + pub artifact_id: String, + pub action: String, + pub summary: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub exported_paths: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub artifact_snapshot: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub slide_list: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub layout_list: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub placeholder_list: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub theme: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub inspect_ndjson: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub resolved_record: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub proto_json: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub patch: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub active_slide_index: Option, +} + +impl PresentationArtifactResponse { + fn new( + artifact_id: String, + action: String, + summary: String, + artifact_snapshot: ArtifactSnapshot, + ) -> Self { + Self { + artifact_id, + action, + summary, + exported_paths: Vec::new(), + artifact_snapshot: Some(artifact_snapshot), + slide_list: None, + layout_list: None, + placeholder_list: None, + theme: None, + inspect_ndjson: None, + resolved_record: None, + proto_json: None, + patch: None, + active_slide_index: None, + } + } +} + +fn response_for_document_state( + artifact_id: String, + action: String, + summary: String, + document: Option<&PresentationDocument>, +) -> PresentationArtifactResponse { + PresentationArtifactResponse { + artifact_id, + action, + summary, + exported_paths: Vec::new(), + artifact_snapshot: document.map(snapshot_for_document), + slide_list: None, + layout_list: None, + placeholder_list: None, + theme: document.map(PresentationDocument::theme_snapshot), + inspect_ndjson: None, + resolved_record: None, + proto_json: None, + patch: None, + active_slide_index: document.and_then(|current| current.active_slide_index), + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct ArtifactSnapshot { + pub slide_count: usize, + pub slides: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SlideSnapshot { + pub slide_id: String, + pub index: usize, + pub element_ids: Vec, + pub element_types: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SlideListEntry { + pub slide_id: String, + pub index: usize, + pub is_active: bool, + pub notes: Option, + pub notes_visible: bool, + pub background_fill: Option, + pub layout_id: Option, + pub element_count: usize, +} + +#[derive(Debug, Clone, Serialize)] +pub struct LayoutListEntry { + pub layout_id: String, + pub name: String, + pub kind: String, + pub parent_layout_id: Option, + pub placeholder_count: usize, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PlaceholderListEntry { + pub scope: String, + pub source_layout_id: Option, + pub slide_index: Option, + pub element_id: Option, + pub name: String, + pub placeholder_type: String, + pub index: Option, + pub geometry: Option, + pub text_preview: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ThemeSnapshot { + pub color_scheme: HashMap, + pub hex_color_map: HashMap, + pub major_font: Option, + pub minor_font: Option, +} + diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/snapshot.rs b/codex-rs/artifact-presentation/src/presentation_artifact/snapshot.rs new file mode 100644 index 000000000..fde1ce85e --- /dev/null +++ b/codex-rs/artifact-presentation/src/presentation_artifact/snapshot.rs @@ -0,0 +1,338 @@ +fn cell_value_to_string(value: Value) -> String { + match value { + Value::Null => String::new(), + Value::String(text) => text, + Value::Bool(boolean) => boolean.to_string(), + Value::Number(number) => number.to_string(), + other => other.to_string(), + } +} + +fn snapshot_for_document(document: &PresentationDocument) -> ArtifactSnapshot { + ArtifactSnapshot { + slide_count: document.slides.len(), + slides: document + .slides + .iter() + .enumerate() + .map(|(index, slide)| SlideSnapshot { + slide_id: slide.slide_id.clone(), + index, + element_ids: slide + .elements + .iter() + .map(|element| element.element_id().to_string()) + .collect(), + element_types: slide + .elements + .iter() + .map(|element| element.kind().to_string()) + .collect(), + }) + .collect(), + } +} + +fn slide_list(document: &PresentationDocument) -> Vec { + document + .slides + .iter() + .enumerate() + .map(|(index, slide)| SlideListEntry { + slide_id: slide.slide_id.clone(), + index, + is_active: document.active_slide_index == Some(index), + notes: (!slide.notes.text.is_empty()).then(|| slide.notes.text.clone()), + notes_visible: slide.notes.visible, + background_fill: slide.background_fill.clone(), + layout_id: slide.layout_id.clone(), + element_count: slide.elements.len(), + }) + .collect() +} + +fn layout_list(document: &PresentationDocument) -> Vec { + document + .layouts + .iter() + .map(|layout| LayoutListEntry { + layout_id: layout.layout_id.clone(), + name: layout.name.clone(), + kind: match layout.kind { + LayoutKind::Layout => "layout".to_string(), + LayoutKind::Master => "master".to_string(), + }, + parent_layout_id: layout.parent_layout_id.clone(), + placeholder_count: layout.placeholders.len(), + }) + .collect() +} + +fn points_to_emu(points: u32) -> u32 { + points.saturating_mul(POINT_TO_EMU) +} + +fn emu_to_points(emu: u32) -> u32 { + emu / POINT_TO_EMU +} + +type ImageCrop = (f64, f64, f64, f64); +type FittedImage = (u32, u32, u32, u32, Option); + +pub(crate) fn fit_image(image: &ImageElement) -> FittedImage { + let Some(payload) = image.payload.as_ref() else { + return ( + image.frame.left, + image.frame.top, + image.frame.width, + image.frame.height, + None, + ); + }; + let frame = image.frame; + let source_width = payload.width_px as f64; + let source_height = payload.height_px as f64; + let target_width = frame.width as f64; + let target_height = frame.height as f64; + let source_ratio = source_width / source_height; + let target_ratio = target_width / target_height; + + match image.fit_mode { + ImageFitMode::Stretch => (frame.left, frame.top, frame.width, frame.height, None), + ImageFitMode::Contain => { + let scale = if source_ratio > target_ratio { + target_width / source_width + } else { + target_height / source_height + }; + let width = (source_width * scale).round() as u32; + let height = (source_height * scale).round() as u32; + let left = frame.left + frame.width.saturating_sub(width) / 2; + let top = frame.top + frame.height.saturating_sub(height) / 2; + (left, top, width, height, None) + } + ImageFitMode::Cover => { + let scale = if source_ratio > target_ratio { + target_height / source_height + } else { + target_width / source_width + }; + let width = source_width * scale; + let height = source_height * scale; + let crop_x = ((width - target_width).max(0.0) / width) / 2.0; + let crop_y = ((height - target_height).max(0.0) / height) / 2.0; + ( + frame.left, + frame.top, + frame.width, + frame.height, + Some((crop_x, crop_y, crop_x, crop_y)), + ) + } + } +} + +fn normalize_image_crop( + crop: ImageCropArgs, + action: &str, +) -> Result { + for (name, value) in [ + ("left", crop.left), + ("top", crop.top), + ("right", crop.right), + ("bottom", crop.bottom), + ] { + if !(0.0..=1.0).contains(&value) { + return Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("image crop `{name}` must be between 0.0 and 1.0"), + }); + } + } + Ok((crop.left, crop.top, crop.right, crop.bottom)) +} + +fn load_image_payload_from_path( + path: &Path, + action: &str, +) -> Result { + let bytes = std::fs::read(path).map_err(|error| PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("failed to read image `{}`: {error}", path.display()), + })?; + build_image_payload( + bytes, + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("image") + .to_string(), + action, + ) +} + +fn load_image_payload_from_data_url( + data_url: &str, + action: &str, +) -> Result { + let (header, payload) = + data_url + .split_once(',') + .ok_or_else(|| PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: "data_url must include a MIME header and base64 payload".to_string(), + })?; + let mime = header + .strip_prefix("data:") + .and_then(|prefix| prefix.strip_suffix(";base64")) + .ok_or_else(|| PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: "data_url must be base64-encoded".to_string(), + })?; + let bytes = BASE64_STANDARD.decode(payload).map_err(|error| { + PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("failed to decode image data_url: {error}"), + } + })?; + build_image_payload( + bytes, + format!("image.{}", image_extension_from_mime(mime)), + action, + ) +} + +fn load_image_payload_from_blob( + blob: &str, + action: &str, +) -> Result { + let bytes = BASE64_STANDARD.decode(blob.trim()).map_err(|error| { + PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("failed to decode image blob: {error}"), + } + })?; + let extension = image::guess_format(&bytes) + .ok() + .map(image_extension_from_format) + .unwrap_or("png"); + build_image_payload(bytes, format!("image.{extension}"), action) +} + +fn load_image_payload_from_uri( + uri: &str, + action: &str, +) -> Result { + let response = + reqwest::blocking::get(uri).map_err(|error| PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("failed to fetch image `{uri}`: {error}"), + })?; + let status = response.status(); + if !status.is_success() { + return Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("failed to fetch image `{uri}`: HTTP {status}"), + }); + } + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(|value| value.split(';').next().unwrap_or(value).trim().to_string()); + let bytes = response + .bytes() + .map_err(|error| PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("failed to read image `{uri}`: {error}"), + })?; + build_image_payload( + bytes.to_vec(), + infer_remote_image_filename(uri, content_type.as_deref()), + action, + ) +} + +fn infer_remote_image_filename(uri: &str, content_type: Option<&str>) -> String { + let path_name = reqwest::Url::parse(uri) + .ok() + .and_then(|url| { + url.path_segments() + .and_then(Iterator::last) + .map(str::to_owned) + }) + .filter(|segment| !segment.is_empty()); + match (path_name, content_type) { + (Some(path_name), _) if Path::new(&path_name).extension().is_some() => path_name, + (Some(path_name), Some(content_type)) => { + format!("{path_name}.{}", image_extension_from_mime(content_type)) + } + (Some(path_name), None) => path_name, + (None, Some(content_type)) => format!("image.{}", image_extension_from_mime(content_type)), + (None, None) => "image.png".to_string(), + } +} + +fn build_image_payload( + bytes: Vec, + filename: String, + action: &str, +) -> Result { + let image = image::load_from_memory(&bytes).map_err(|error| { + PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("failed to decode image bytes: {error}"), + } + })?; + let (width_px, height_px) = image.dimensions(); + let format = Path::new(&filename) + .extension() + .and_then(|extension| extension.to_str()) + .unwrap_or("png") + .to_uppercase(); + Ok(ImagePayload { + bytes, + format, + width_px, + height_px, + }) +} + +fn image_extension_from_mime(mime: &str) -> &'static str { + match mime { + "image/jpeg" => "jpg", + "image/gif" => "gif", + "image/webp" => "webp", + _ => "png", + } +} + +fn image_extension_from_format(format: image::ImageFormat) -> &'static str { + match format { + image::ImageFormat::Jpeg => "jpg", + image::ImageFormat::Gif => "gif", + image::ImageFormat::WebP => "webp", + image::ImageFormat::Bmp => "bmp", + image::ImageFormat::Tiff => "tiff", + _ => "png", + } +} + +fn index_out_of_range(action: &str, index: usize, len: usize) -> PresentationArtifactError { + PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("slide index {index} is out of range for {len} slides"), + } +} + +fn to_index(value: u32) -> Result { + usize::try_from(value).map_err(|_| PresentationArtifactError::InvalidArgs { + action: "insert_slide".to_string(), + message: "index does not fit in usize".to_string(), + }) +} + +fn resequence_z_order(slide: &mut PresentationSlide) { + for (index, element) in slide.elements.iter_mut().enumerate() { + element.set_z_order(index); + } +} diff --git a/codex-rs/artifact-presentation/src/tests.rs b/codex-rs/artifact-presentation/src/tests.rs index 87544c918..e9d0a75b6 100644 --- a/codex-rs/artifact-presentation/src/tests.rs +++ b/codex-rs/artifact-presentation/src/tests.rs @@ -1,4 +1,5 @@ use super::presentation_artifact::*; +use base64::Engine; use pretty_assertions::assert_eq; use std::io::Read; @@ -20,6 +21,14 @@ fn zip_entry_names(path: &std::path::Path) -> Result, Box Result, serde_json::Error> { + ndjson + .lines() + .filter(|line| !line.is_empty()) + .map(serde_json::from_str) + .collect() +} + #[test] fn manager_can_create_add_text_and_export() -> Result<(), Box> { let temp_dir = tempfile::tempdir()?; @@ -409,6 +418,7 @@ fn image_fit_contain_preserves_aspect_ratio() { alt_text: None, prompt: None, is_placeholder: false, + placeholder: None, z_order: 0, }; @@ -418,7 +428,7 @@ fn image_fit_contain_preserves_aspect_ratio() { } #[test] -fn preview_image_writer_supports_jpeg_and_scale() -> Result<(), Box> { +fn preview_image_writer_supports_jpeg_scale_and_svg() -> Result<(), Box> { let temp_dir = tempfile::tempdir()?; let source_path = temp_dir.path().join("preview.png"); image::RgbaImage::from_pixel(80, 40, image::Rgba([0x22, 0x66, 0xAA, 0xFF])) @@ -439,6 +449,23 @@ fn preview_image_writer_supports_jpeg_and_scale() -> Result<(), Box Result<(), Box Result<(), Box> { + let mut image_bytes = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 10, + 6, + image::Rgba([0xAA, 0x55, 0x22, 0xFF]), + )) + .write_to(&mut image_bytes, image::ImageFormat::Png)?; + let blob = base64::engine::general_purpose::STANDARD.encode(image_bytes.into_inner()); + + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Blob Images" }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + let added = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_image".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "blob": blob, + "position": { "left": 32, "top": 48, "width": 100, "height": 60 } + }), + }, + temp_dir.path(), + )?; + let image_id = added + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .and_then(|slide| slide.element_ids.first()) + .cloned() + .expect("image id"); + let proto = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id), + action: "to_proto".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + assert_eq!( + proto + .proto_json + .as_ref() + .and_then(|proto| proto.get("slides")) + .and_then(serde_json::Value::as_array) + .and_then(|slides| slides.first()) + .and_then(|slide| slide.get("elements")) + .and_then(serde_json::Value::as_array) + .and_then(|elements| elements.iter().find(|element| { + element.get("elementId").and_then(serde_json::Value::as_str) + == Some(image_id.as_str()) + })) + .and_then(|record| record.get("payload")) + .and_then(|payload| payload.get("format")) + .and_then(serde_json::Value::as_str), + Some("PNG") + ); + Ok(()) +} + #[test] fn active_slide_can_be_set_and_tracks_reorders() -> Result<(), Box> { let temp_dir = tempfile::tempdir()?; @@ -956,6 +1061,21 @@ fn manager_supports_layout_theme_notes_and_inspect() -> Result<(), Box Result<(), Box Result<(), Box Result<(), Box Result<(), Box Result<(), Box Result<(), Box Result<(), Box Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ + "name": "Styles", + "theme": { + "color_scheme": { + "tx1": "#222222" + }, + "major_font": "Aptos Display", + "minor_font": "Aptos" + } + }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id; + let described = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "describe_styles".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + assert_eq!( + described + .resolved_record + .as_ref() + .and_then(|record| record.get("styles")) + .and_then(serde_json::Value::as_array) + .map(Vec::len), + Some(5) + ); + let title_style = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "get_style".to_string(), + args: serde_json::json!({ "name": "title" }), + }, + temp_dir.path(), + )?; + assert_eq!( + title_style.resolved_record, + Some(serde_json::json!({ + "kind": "textStyle", + "id": "st/title", + "name": "title", + "builtIn": true, + "style": { + "styleName": "title", + "fontSize": 28, + "fontFamily": "Aptos Display", + "color": "222222", + "alignment": "left", + "bold": true, + "italic": false, + "underline": false + } + })) + ); + let custom_style = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_style".to_string(), + args: serde_json::json!({ + "name": "callout", + "font_size": 18, + "color": "#336699", + "italic": true, + "underline": true + }), + }, + temp_dir.path(), + )?; + assert_eq!( + custom_style.resolved_record, + Some(serde_json::json!({ + "kind": "textStyle", + "id": "st/callout", + "name": "callout", + "builtIn": false, + "style": { + "styleName": "callout", + "fontSize": 18, + "fontFamily": serde_json::Value::Null, + "color": "336699", + "alignment": serde_json::Value::Null, + "bold": false, + "italic": true, + "underline": true + } + })) + ); + + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + let text_added = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_text_shape".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "text": "Styled title", + "position": { "left": 40, "top": 40, "width": 220, "height": 50 }, + "style": "title", + "underline": true + }), + }, + temp_dir.path(), + )?; + let text_id = text_added + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .and_then(|slide| slide.element_ids.first()) + .cloned() + .expect("text id"); + let table_added = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_table".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "position": { "left": 40, "top": 120, "width": 180, "height": 80 }, + "rows": [["A"]], + }), + }, + temp_dir.path(), + )?; + let table_id = table_added + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .and_then(|slide| slide.element_ids.last()) + .cloned() + .expect("table id"); + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "update_table_cell".to_string(), + args: serde_json::json!({ + "element_id": table_id, + "row": 0, + "column": 0, + "value": "Styled cell", + "styling": { + "style": "callout" + } + }), + }, + temp_dir.path(), + )?; + + let resolved_text = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "resolve".to_string(), + args: serde_json::json!({ "id": format!("sh/{text_id}") }), + }, + temp_dir.path(), + )?; + assert_eq!( + resolved_text + .resolved_record + .as_ref() + .and_then(|record| record.get("textStyle")) + .cloned(), + Some(serde_json::json!({ + "styleName": "title", + "fontSize": 28, + "fontFamily": "Aptos Display", + "color": "222222", + "alignment": "left", + "bold": true, + "italic": false, + "underline": true + })) + ); + let resolved_table = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id), + action: "resolve".to_string(), + args: serde_json::json!({ "id": format!("tb/{table_id}") }), + }, + temp_dir.path(), + )?; + assert_eq!( + resolved_table + .resolved_record + .as_ref() + .and_then(|record| record.get("cellTextStyles")) + .cloned(), + Some(serde_json::json!([ + [ + { + "styleName": "callout", + "fontSize": 18, + "fontFamily": serde_json::Value::Null, + "color": "336699", + "alignment": serde_json::Value::Null, + "bold": false, + "italic": true, + "underline": true + } + ] + ])) + ); + Ok(()) +} + +#[test] +fn layout_names_resolve_in_slide_actions_and_insert_defaults_after_active_slide() +-> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Layouts by Name" }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id; + + let layout_created = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "create_layout".to_string(), + args: serde_json::json!({ "name": "Title Slide" }), + }, + temp_dir.path(), + )?; + let layout_id = layout_created + .layout_list + .as_ref() + .and_then(|layouts| layouts.first()) + .map(|layout| layout.layout_id.clone()) + .expect("layout id"); + + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_layout_placeholder".to_string(), + args: serde_json::json!({ + "layout_id": layout_id, + "name": "title", + "placeholder_type": "title", + "text": "Placeholder title" + }), + }, + temp_dir.path(), + )?; + + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({ "layout": "Title Slide" }), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "set_active_slide".to_string(), + args: serde_json::json!({ "slide_index": 0 }), + }, + temp_dir.path(), + )?; + + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "insert_slide".to_string(), + args: serde_json::json!({ "layout": "title slide" }), + }, + temp_dir.path(), + )?; + let inserted = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "list_slides".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + assert_eq!( + inserted.slide_list.as_ref().map(|slides| slides + .iter() + .map(|slide| slide.layout_id.clone()) + .collect::>()), + Some(vec![ + Some("layout_1".to_string()), + Some("layout_1".to_string()), + None + ]) + ); + assert_eq!( + inserted.slide_list.as_ref().map(|slides| slides + .iter() + .map(|slide| slide.is_active) + .collect::>()), + Some(vec![true, false, false]) + ); + + let placeholders = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id), + action: "list_layout_placeholders".to_string(), + args: serde_json::json!({ "layout_id": "TITLE SLIDE" }), + }, + temp_dir.path(), + )?; + assert_eq!( + placeholders.placeholder_list.as_ref().map(|entries| entries + .iter() + .map(|entry| entry.name.clone()) + .collect::>()), + Some(vec!["title".to_string()]) + ); + + let child_layout = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(placeholders.artifact_id), + action: "create_layout".to_string(), + args: serde_json::json!({ + "name": "Child Layout", + "parent_layout_id": "title slide" + }), + }, + temp_dir.path(), + )?; + assert_eq!( + child_layout.layout_list.as_ref().map(|layouts| layouts + .iter() + .find(|layout| layout.name == "Child Layout") + .and_then(|layout| layout.parent_layout_id.clone())), + Some(Some("layout_1".to_string())) + ); + Ok(()) +} + +#[test] +fn inspect_supports_filters_target_windows_and_shape_text_metadata() +-> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Inspect Filters" }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + + let first_text = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_text_shape".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "text": "First KPI", + "position": { "left": 10, "top": 10, "width": 120, "height": 40 } + }), + }, + temp_dir.path(), + )?; + let second_text = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_text_shape".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "text": "Second KPI", + "position": { "left": 10, "top": 60, "width": 120, "height": 40 } + }), + }, + temp_dir.path(), + )?; + let third_text = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_text_shape".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "text": "Third KPI", + "position": { "left": 10, "top": 110, "width": 120, "height": 40 } + }), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "set_notes".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "text": "Speaker note" + }), + }, + temp_dir.path(), + )?; + let shape = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_shape".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "geometry": "rectangle", + "position": { "left": 180, "top": 10, "width": 180, "height": 90 }, + "text": "Detailed\nShape KPI" + }), + }, + temp_dir.path(), + )?; + + let middle_text_id = second_text + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .and_then(|slide| slide.element_ids.get(1)) + .cloned() + .expect("middle text id"); + let first_text_id = first_text + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .and_then(|slide| slide.element_ids.first()) + .cloned() + .expect("first text id"); + let third_text_id = third_text + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .and_then(|slide| slide.element_ids.get(2)) + .cloned() + .expect("third text id"); + let shape_id = shape + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .and_then(|slide| slide.element_ids.get(3)) + .cloned() + .expect("shape id"); + let slide_id = shape + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .map(|slide| slide.slide_id.clone()) + .expect("slide id"); + + let filtered = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "inspect".to_string(), + args: serde_json::json!({ + "include": "shape,notes", + "exclude": "notes", + "search": "Detailed" + }), + }, + temp_dir.path(), + )?; + let filtered_records = parse_ndjson_lines( + filtered + .inspect_ndjson + .as_deref() + .expect("filtered inspect"), + )?; + assert_eq!(filtered_records.len(), 1); + assert_eq!( + filtered_records[0], + serde_json::json!({ + "kind": "shape", + "id": format!("sh/{shape_id}"), + "slide": 1, + "geometry": "Rectangle", + "text": "Detailed\nShape KPI", + "textStyle": { + "styleName": serde_json::Value::Null, + "fontSize": serde_json::Value::Null, + "fontFamily": serde_json::Value::Null, + "color": serde_json::Value::Null, + "alignment": serde_json::Value::Null, + "bold": false, + "italic": false, + "underline": false + }, + "rotation": serde_json::Value::Null, + "flipHorizontal": false, + "flipVertical": false, + "bbox": [180, 10, 180, 90], + "bboxUnit": "points", + "textPreview": "Detailed | Shape KPI", + "textChars": 18, + "textLines": 2 + }) + ); + + let targeted = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "inspect".to_string(), + args: serde_json::json!({ + "include": "textbox", + "target": { + "id": format!("sh/{middle_text_id}"), + "before_lines": 1, + "after_lines": 1 + } + }), + }, + temp_dir.path(), + )?; + let targeted_records = parse_ndjson_lines( + targeted + .inspect_ndjson + .as_deref() + .expect("targeted inspect"), + )?; + assert_eq!( + targeted_records + .iter() + .filter_map(|record| record.get("id").and_then(serde_json::Value::as_str)) + .map(str::to_owned) + .collect::>(), + vec![ + format!("sh/{first_text_id}"), + format!("sh/{middle_text_id}"), + format!("sh/{third_text_id}") + ] + ); + + let missing_target = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id), + action: "inspect".to_string(), + args: serde_json::json!({ + "include": "textbox", + "target": { + "id": "sh/missing", + "before_lines": 1, + "after_lines": 1 + } + }), + }, + temp_dir.path(), + )?; + let missing_target_records = parse_ndjson_lines( + missing_target + .inspect_ndjson + .as_deref() + .expect("missing target inspect"), + )?; + assert_eq!( + missing_target_records, + vec![serde_json::json!({ + "kind": "notice", + "noticeType": "targetNotFound", + "target": { + "id": "sh/missing", + "beforeLines": 1, + "afterLines": 1 + }, + "message": "No inspect records matched target `sh/missing`." + })] + ); + + let resolved_shape = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(missing_target.artifact_id.clone()), + action: "resolve".to_string(), + args: serde_json::json!({ "id": format!("sh/{shape_id}") }), + }, + temp_dir.path(), + )?; + assert_eq!( + resolved_shape + .resolved_record + .as_ref() + .and_then(|record| record.get("textPreview")) + .and_then(serde_json::Value::as_str), + Some("Detailed | Shape KPI") + ); + assert_eq!( + resolved_shape + .resolved_record + .as_ref() + .and_then(|record| record.get("textChars")) + .and_then(serde_json::Value::as_u64), + Some(18) + ); + assert_eq!( + resolved_shape + .resolved_record + .as_ref() + .and_then(|record| record.get("textLines")) + .and_then(serde_json::Value::as_u64), + Some(2) + ); + + let resolved_notes = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(missing_target.artifact_id), + action: "resolve".to_string(), + args: serde_json::json!({ "id": format!("nt/{slide_id}") }), + }, + temp_dir.path(), + )?; + assert_eq!( + resolved_notes + .resolved_record + .as_ref() + .and_then(|record| record.get("textPreview")) + .and_then(serde_json::Value::as_str), + Some("Speaker note") + ); + assert_eq!( + resolved_notes + .resolved_record + .as_ref() + .and_then(|record| record.get("textChars")) + .and_then(serde_json::Value::as_u64), + Some(12) + ); + assert_eq!( + resolved_notes + .resolved_record + .as_ref() + .and_then(|record| record.get("textLines")) + .and_then(serde_json::Value::as_u64), + Some(1) ); Ok(()) } @@ -1437,6 +2341,157 @@ fn connectors_support_arrows_and_inspect() -> Result<(), Box Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Shape Strokes" }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + let added_shape = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_shape".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "geometry": "rectangle", + "position": { + "left": 24, + "top": 24, + "width": 180, + "height": 120, + "rotation": 15, + "flip_horizontal": true, + "flip_vertical": true + }, + "stroke": { "color": "#ff0000", "width": 2, "style": "dash-dot" } + }), + }, + temp_dir.path(), + )?; + let shape_id = added_shape + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .and_then(|slide| slide.element_ids.first()) + .cloned() + .expect("shape id"); + + let resolved = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "resolve".to_string(), + args: serde_json::json!({ "id": format!("sh/{shape_id}") }), + }, + temp_dir.path(), + )?; + assert_eq!( + resolved + .resolved_record + .as_ref() + .and_then(|record| record.get("stroke")) + .and_then(|stroke| stroke.get("style")) + .and_then(serde_json::Value::as_str), + Some("dash-dot") + ); + assert_eq!( + resolved + .resolved_record + .as_ref() + .and_then(|record| record.get("rotation")) + .and_then(serde_json::Value::as_i64), + Some(15) + ); + assert_eq!( + resolved + .resolved_record + .as_ref() + .and_then(|record| record.get("flipHorizontal")) + .and_then(serde_json::Value::as_bool), + Some(true) + ); + assert_eq!( + resolved + .resolved_record + .as_ref() + .and_then(|record| record.get("flipVertical")) + .and_then(serde_json::Value::as_bool), + Some(true) + ); + + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "update_shape_style".to_string(), + args: serde_json::json!({ + "element_id": format!("sh/{shape_id}"), + "position": { + "rotation": 30, + "flip_horizontal": false, + "flip_vertical": true + } + }), + }, + temp_dir.path(), + )?; + let updated = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "resolve".to_string(), + args: serde_json::json!({ "id": format!("sh/{shape_id}") }), + }, + temp_dir.path(), + )?; + assert_eq!( + updated + .resolved_record + .as_ref() + .and_then(|record| record.get("rotation")) + .and_then(serde_json::Value::as_i64), + Some(30) + ); + assert_eq!( + updated + .resolved_record + .as_ref() + .and_then(|record| record.get("flipHorizontal")) + .and_then(serde_json::Value::as_bool), + Some(false) + ); + + let export_path = temp_dir.path().join("shape-strokes.pptx"); + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id), + action: "export_pptx".to_string(), + args: serde_json::json!({ "path": export_path }), + }, + temp_dir.path(), + )?; + + let slide_xml = zip_entry_text( + &temp_dir.path().join("shape-strokes.pptx"), + "ppt/slides/slide1.xml", + )?; + assert!(slide_xml.contains(r#""#)); + assert!(slide_xml.contains(r#""#)); + Ok(()) +} + #[test] fn z_order_helpers_resequence_elements() -> Result<(), Box> { let temp_dir = tempfile::tempdir()?; @@ -1681,3 +2736,382 @@ fn manager_supports_table_cell_updates_and_merges() -> Result<(), Box"#)); Ok(()) } + +#[test] +fn history_can_undo_and_redo_created_artifact() -> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "History" }), + }, + temp_dir.path(), + )?; + + let undone = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(created.artifact_id.clone()), + action: "undo".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + assert_eq!(undone.artifact_id, created.artifact_id); + assert!(undone.artifact_snapshot.is_none()); + assert!( + manager + .execute( + PresentationArtifactRequest { + artifact_id: Some(created.artifact_id.clone()), + action: "get_summary".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + ) + .is_err() + ); + + let redone = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(created.artifact_id.clone()), + action: "redo".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + assert_eq!(redone.artifact_id, created.artifact_id); + assert_eq!( + redone + .artifact_snapshot + .as_ref() + .map(|snapshot| snapshot.slide_count), + Some(0) + ); + Ok(()) +} + +#[test] +fn proto_and_patch_actions_work_and_patch_history_is_atomic() +-> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Proto Patch" }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + + let patch_ops = serde_json::json!([ + { + "action": "add_text_shape", + "args": { + "slide_index": 0, + "text": "Patch text", + "position": { "left": 40, "top": 60, "width": 180, "height": 50 } + } + }, + { + "action": "set_slide_background", + "args": { + "slide_index": 0, + "fill": "#ffeecc" + } + } + ]); + let recorded = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "record_patch".to_string(), + args: serde_json::json!({ "operations": patch_ops }), + }, + temp_dir.path(), + )?; + let expected_patch = serde_json::json!({ + "version": 1, + "artifactId": artifact_id, + "operations": [ + { + "action": "add_text_shape", + "args": { + "slide_index": 0, + "text": "Patch text", + "position": { "left": 40, "top": 60, "width": 180, "height": 50 } + } + }, + { + "action": "set_slide_background", + "args": { + "slide_index": 0, + "fill": "#ffeecc" + } + } + ] + }); + assert_eq!(recorded.patch.as_ref(), Some(&expected_patch)); + + let applied = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "apply_patch".to_string(), + args: serde_json::json!({ "patch": expected_patch }), + }, + temp_dir.path(), + )?; + let slide_snapshot = applied + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .cloned() + .expect("slide snapshot"); + let slide_id = slide_snapshot.slide_id.clone(); + let element_id = slide_snapshot + .element_ids + .first() + .cloned() + .expect("element id"); + + let proto = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "to_proto".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + assert_eq!( + proto.proto_json, + Some(serde_json::json!({ + "kind": "presentation", + "artifactId": artifact_id, + "anchor": format!("pr/{artifact_id}"), + "name": "Proto Patch", + "slideSize": { + "left": 0, + "top": 0, + "width": 720, + "height": 540, + "unit": "points" + }, + "activeSlideIndex": 0, + "activeSlideId": slide_id, + "theme": { + "colorScheme": {}, + "hexColorMap": {}, + "majorFont": serde_json::Value::Null, + "minorFont": serde_json::Value::Null + }, + "styles": [ + { + "kind": "textStyle", + "id": "st/body", + "name": "body", + "builtIn": true, + "style": { + "styleName": "body", + "fontSize": 14, + "fontFamily": serde_json::Value::Null, + "color": serde_json::Value::Null, + "alignment": "left", + "bold": false, + "italic": false, + "underline": false + } + }, + { + "kind": "textStyle", + "id": "st/heading1", + "name": "heading1", + "builtIn": true, + "style": { + "styleName": "heading1", + "fontSize": 22, + "fontFamily": serde_json::Value::Null, + "color": serde_json::Value::Null, + "alignment": "left", + "bold": true, + "italic": false, + "underline": false + } + }, + { + "kind": "textStyle", + "id": "st/list", + "name": "list", + "builtIn": true, + "style": { + "styleName": "list", + "fontSize": 14, + "fontFamily": serde_json::Value::Null, + "color": serde_json::Value::Null, + "alignment": "left", + "bold": false, + "italic": false, + "underline": false + } + }, + { + "kind": "textStyle", + "id": "st/numberedlist", + "name": "numberedlist", + "builtIn": true, + "style": { + "styleName": "numberedlist", + "fontSize": 14, + "fontFamily": serde_json::Value::Null, + "color": serde_json::Value::Null, + "alignment": "left", + "bold": false, + "italic": false, + "underline": false + } + }, + { + "kind": "textStyle", + "id": "st/title", + "name": "title", + "builtIn": true, + "style": { + "styleName": "title", + "fontSize": 28, + "fontFamily": serde_json::Value::Null, + "color": serde_json::Value::Null, + "alignment": "left", + "bold": true, + "italic": false, + "underline": false + } + } + ], + "masters": [], + "layouts": [], + "slides": [ + { + "slideId": slide_id, + "anchor": format!("sl/{slide_id}"), + "index": 0, + "layoutId": serde_json::Value::Null, + "backgroundFill": "FFEECC", + "notes": { + "anchor": format!("nt/{slide_id}"), + "text": "", + "visible": true, + "textPreview": "", + "textChars": 0, + "textLines": 0 + }, + "elements": [ + { + "kind": "text", + "elementId": element_id, + "anchor": format!("sh/{element_id}"), + "frame": { + "left": 40, + "top": 60, + "width": 180, + "height": 50, + "unit": "points" + }, + "text": "Patch text", + "textPreview": "Patch text", + "textChars": 10, + "textLines": 1, + "fill": serde_json::Value::Null, + "style": { + "styleName": serde_json::Value::Null, + "fontSize": serde_json::Value::Null, + "fontFamily": serde_json::Value::Null, + "color": serde_json::Value::Null, + "alignment": serde_json::Value::Null, + "bold": false, + "italic": false, + "underline": false + }, + "zOrder": 0 + } + ] + } + ] + })) + ); + + let undone = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "undo".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + assert_eq!( + undone + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .map(|slide| slide.element_ids.clone()), + Some(Vec::new()) + ); + let undone_proto = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "to_proto".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + assert_eq!( + undone_proto + .proto_json + .as_ref() + .and_then(|proto| proto.get("slides")) + .and_then(serde_json::Value::as_array) + .and_then(|slides| slides.first()) + .and_then(|slide| slide.get("backgroundFill")), + Some(&serde_json::Value::Null) + ); + assert_eq!( + undone_proto + .proto_json + .as_ref() + .and_then(|proto| proto.get("slides")) + .and_then(serde_json::Value::as_array) + .and_then(|slides| slides.first()) + .and_then(|slide| slide.get("elements")) + .and_then(serde_json::Value::as_array) + .map(Vec::len), + Some(0) + ); + + let redone = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "redo".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + assert_eq!(redone.patch, None); + let redone_proto = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id), + action: "to_proto".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + assert_eq!(redone_proto.proto_json, proto.proto_json); + Ok(()) +} diff --git a/codex-rs/artifact-spreadsheet/src/chart.rs b/codex-rs/artifact-spreadsheet/src/chart.rs new file mode 100644 index 000000000..34f6dd7c9 --- /dev/null +++ b/codex-rs/artifact-spreadsheet/src/chart.rs @@ -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, + pub category_sheet_name: Option, + pub category_range: String, + pub value_sheet_name: Option, + 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, + pub source_range: Option, + pub title: Option, + 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, +} + +#[derive(Debug, Clone, Default)] +pub struct SpreadsheetChartLookup<'a> { + pub id: Option, + pub index: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpreadsheetChartCreateOptions { + pub id: Option, + pub title: Option, + pub legend_visible: Option, + pub legend_position: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpreadsheetChartProperties { + pub title: Option, + pub legend_visible: Option, + pub legend_position: Option, +} + +impl SpreadsheetSheet { + pub fn list_charts( + &self, + range: Option<&CellRange>, + ) -> Result, 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, + source_range: &CellRange, + options: SpreadsheetChartCreateOptions, + ) -> Result { + 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::>(); + 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 { + 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(()) +} diff --git a/codex-rs/artifact-spreadsheet/src/conditional.rs b/codex-rs/artifact-spreadsheet/src/conditional.rs new file mode 100644 index 000000000..be6e9cd5b --- /dev/null +++ b/codex-rs/artifact-spreadsheet/src/conditional.rs @@ -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, + pub mid_type: Option, + pub max_type: Option, + pub min_value: Option, + pub mid_value: Option, + pub max_value: Option, + pub min_color: String, + pub mid_color: Option, + pub max_color: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SpreadsheetDataBar { + pub color: String, + pub min_length: Option, + pub max_length: Option, + pub show_value: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SpreadsheetIconSet { + pub style: String, + pub show_value: Option, + pub reverse_order: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SpreadsheetConditionalFormat { + pub id: u32, + pub range: String, + pub rule_type: SpreadsheetConditionalFormatType, + pub operator: Option, + #[serde(default)] + pub formulas: Vec, + pub text: Option, + pub dxf_id: Option, + pub stop_if_true: bool, + pub priority: u32, + pub rank: Option, + pub percent: Option, + pub time_period: Option, + pub above_average: Option, + pub equal_average: Option, + pub color_scale: Option, + pub data_bar: Option, + pub icon_set: Option, +} + +#[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::parse(&self.range) + } + + pub fn list( + &self, + artifact: &SpreadsheetArtifact, + ) -> Result, 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 { + 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 { + 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 { + 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(()) +} diff --git a/codex-rs/artifact-spreadsheet/src/lib.rs b/codex-rs/artifact-spreadsheet/src/lib.rs index b6fab70c4..f39c9cd42 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 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::*; diff --git a/codex-rs/artifact-spreadsheet/src/manager.rs b/codex-rs/artifact-spreadsheet/src/manager.rs index 2e2b1755d..98a2a045a 100644 --- a/codex-rs/artifact-spreadsheet/src/manager.rs +++ b/codex-rs/artifact-spreadsheet/src/manager.rs @@ -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 { + 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 { + 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 { + 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, #[serde(skip_serializing_if = "Option::is_none")] + pub rendered_html: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub row_height: Option, #[serde(skip_serializing_if = "Option::is_none")] pub serialized_dict: Option, @@ -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, } +#[derive(Debug, Deserialize)] +struct RenderArgs { + output_path: Option, + sheet_name: Option, + sheet_index: Option, + range: Option, + center_address: Option, + width: Option, + height: Option, + include_headers: Option, + scale: Option, + performance_mode: Option, +} + #[derive(Debug, Deserialize)] struct SheetLookupArgs { sheet_name: Option, @@ -2385,6 +2501,27 @@ fn normalize_formula(formula: String) -> String { } } +fn render_options_from_args( + args: RenderArgs, +) -> Result { + 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 { diff --git a/codex-rs/artifact-spreadsheet/src/pivot.rs b/codex-rs/artifact-spreadsheet/src/pivot.rs new file mode 100644 index 000000000..c0c42985c --- /dev/null +++ b/codex-rs/artifact-spreadsheet/src/pivot.rs @@ -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, + pub index: Option, + pub hidden: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpreadsheetPivotField { + pub index: u32, + pub name: Option, + pub axis: Option, + #[serde(default)] + pub items: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpreadsheetPivotFieldReference { + pub field_index: u32, + pub field_name: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpreadsheetPivotPageField { + pub field_index: u32, + pub field_name: Option, + pub selected_item: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpreadsheetPivotDataField { + pub field_index: u32, + pub field_name: Option, + pub name: Option, + pub subtotal: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpreadsheetPivotFilter { + pub field_index: Option, + pub field_name: Option, + pub filter_type: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpreadsheetPivotTable { + pub name: String, + pub cache_id: u32, + pub address: Option, + #[serde(default)] + pub row_fields: Vec, + #[serde(default)] + pub column_fields: Vec, + #[serde(default)] + pub page_fields: Vec, + #[serde(default)] + pub data_fields: Vec, + #[serde(default)] + pub filters: Vec, + #[serde(default)] + pub pivot_fields: Vec, + pub style_name: Option, + pub part_path: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct SpreadsheetPivotTableLookup<'a> { + pub name: Option<&'a str>, + pub index: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpreadsheetPivotCacheDefinition { + pub definition_path: String, + #[serde(default)] + pub field_names: Vec>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct SpreadsheetPivotPreservation { + #[serde(default)] + pub caches: BTreeMap, + #[serde(default)] + pub parts: BTreeMap, +} + +impl SpreadsheetPivotTable { + pub fn range(&self) -> Result, SpreadsheetArtifactError> { + self.address.as_deref().map(CellRange::parse).transpose() + } + + pub fn range_ref( + &self, + sheet_name: &str, + ) -> Result, 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, 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(()) + } +} diff --git a/codex-rs/artifact-spreadsheet/src/render.rs b/codex-rs/artifact-spreadsheet/src/render.rs new file mode 100644 index 000000000..05e41b608 --- /dev/null +++ b/codex-rs/artifact-spreadsheet/src/render.rs @@ -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, + pub center_address: Option, + pub width: Option, + pub height: Option, + 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 { + 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!( + "", + "{}", + "", + "", + "
", + "

{}

{}

", + "
", + "{}
", + "
" + ), + 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, 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 { + 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 { + 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, + options: &SpreadsheetRenderOptions, +) -> Result { + 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!["".to_string()]; + for column in range.start.column..=range.end.column { + header.push(format!( + "{}", + crate::column_index_to_letters(column) + )); + } + header.push("".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!("{row}")); + } + 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!( + "{}", + address.to_a1(), + view.style_index, + html_escape(&value) + )); + } + rows.push(format!("{}", 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 { + 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('\'', "'") +} diff --git a/codex-rs/artifact-spreadsheet/src/table.rs b/codex-rs/artifact-spreadsheet/src/table.rs new file mode 100644 index 000000000..b714a20c2 --- /dev/null +++ b/codex-rs/artifact-spreadsheet/src/table.rs @@ -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, + pub totals_row_function: Option, +} + +#[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, + 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, + #[serde(default)] + pub filters: BTreeMap, +} + +#[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, + pub data_body_range: Option, + pub totals_row_range: Option, + pub style_name: Option, + pub show_first_column: bool, + pub show_last_column: bool, + pub show_row_stripes: bool, + pub show_column_stripes: bool, + pub columns: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct SpreadsheetTableLookup<'a> { + pub name: Option<&'a str>, + pub display_name: Option<&'a str>, + pub id: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpreadsheetCreateTableOptions { + pub name: Option, + pub display_name: Option, + pub header_row_count: u32, + pub totals_row_count: u32, + pub style_name: Option, + 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, + pub show_first_column: Option, + pub show_last_column: Option, + pub show_row_stripes: Option, + pub show_column_stripes: Option, +} + +impl SpreadsheetTable { + pub fn range(&self) -> Result { + 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, 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, 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, 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 { + 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 { + 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, 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 { + 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, + column_name: Option<&str>, + new_name: String, + ) -> Result { + 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, + column_name: Option<&str>, + totals_row_label: Option, + totals_row_function: Option, + ) -> Result { + 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::>(); + 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 { + 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, +) -> 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 { + 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::>(); + 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::>(), + ); + 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) -> Vec { + let mut seen = BTreeMap::::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, + 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() + }, + }) +} diff --git a/codex-rs/artifact-spreadsheet/src/tests.rs b/codex-rs/artifact-spreadsheet/src/tests.rs index 8e7a37861..608ac7b39 100644 --- a/codex-rs/artifact-spreadsheet/src/tests.rs +++ b/codex-rs/artifact-spreadsheet/src/tests.rs @@ -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 Result<(), Box> { + 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("")); + 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("A")); + assert!(rendered.html.contains("data-address=\"B2\"")); + assert!(rendered.html.contains(">42")); + + 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("A")); + Ok(()) +} + #[test] fn sheet_refs_support_handle_and_field_apis() -> Result<(), Box> { 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> { + 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("A")) + ); + Ok(()) +} diff --git a/codex-rs/core/src/tools/handlers/presentation_artifact.rs b/codex-rs/core/src/tools/handlers/presentation_artifact.rs index 866d90de8..d89e5d7f1 100644 --- a/codex-rs/core/src/tools/handlers/presentation_artifact.rs +++ b/codex-rs/core/src/tools/handlers/presentation_artifact.rs @@ -49,6 +49,10 @@ impl ToolHandler for PresentationArtifactHandler { | "list_slide_placeholders" | "inspect" | "resolve" + | "to_proto" + | "get_style" + | "describe_styles" + | "record_patch" ) } diff --git a/codex-rs/core/templates/tools/presentation_artifact.md b/codex-rs/core/templates/tools/presentation_artifact.md index 1659ae7b1..9b3039072 100644 --- a/codex-rs/core/templates/tools/presentation_artifact.md +++ b/codex-rs/core/templates/tools/presentation_artifact.md @@ -17,11 +17,19 @@ Supported actions: - `list_slide_placeholders` - `inspect` - `resolve` +- `to_proto` +- `record_patch` +- `apply_patch` +- `undo` +- `redo` - `create_layout` - `add_layout_placeholder` - `set_slide_layout` - `update_placeholder_text` - `set_theme` +- `add_style` +- `get_style` +- `describe_styles` - `set_notes` - `append_notes` - `clear_notes` @@ -77,14 +85,51 @@ Example layout flow: `{"artifact_id":"presentation_x","action":"list_slide_placeholders","args":{"slide_index":0}}` +Layout references in `create_layout.parent_layout_id`, `add_layout_placeholder.layout_id`, `add_slide`, `insert_slide`, `set_slide_layout`, and `list_layout_placeholders` accept either a layout id or a layout name. Name matching prefers exact id, then exact name, then case-insensitive name. + +`insert_slide` accepts `index` or `after_slide_index`. If neither is provided, the new slide is inserted immediately after the active slide, or appended if no active slide is set yet. + Example inspect: -`{"artifact_id":"presentation_x","action":"inspect","args":{"kind":"deck,slide,textbox,shape,table,chart,image,notes,layoutList","max_chars":12000}}` +`{"artifact_id":"presentation_x","action":"inspect","args":{"include":"deck,slide,textbox,shape,table,chart,image,notes,layoutList","exclude":"notes","search":"roadmap","max_chars":12000}}` + +Example inspect target window: +`{"artifact_id":"presentation_x","action":"inspect","args":{"include":"textbox","target":{"id":"sh/element_3","before_lines":1,"after_lines":1}}}` Example resolve: `{"artifact_id":"presentation_x","action":"resolve","args":{"id":"sh/element_3"}}` +Example proto export: +`{"artifact_id":"presentation_x","action":"to_proto","args":{}}` + +`to_proto` returns a full JSON snapshot of the current in-memory presentation document, including slide/layout records, anchors, notes, theme state, and typed element payloads. + +Example patch recording: +`{"artifact_id":"presentation_x","action":"record_patch","args":{"operations":[{"action":"add_text_shape","args":{"slide_index":0,"text":"Headline","position":{"left":48,"top":48,"width":320,"height":72}}},{"action":"set_slide_background","args":{"slide_index":0,"fill":"#F7F1E8"}}]}}` + +Example patch application: +`{"artifact_id":"presentation_x","action":"apply_patch","args":{"patch":{"version":1,"artifactId":"presentation_x","operations":[{"action":"add_text_shape","args":{"slide_index":0,"text":"Headline","position":{"left":48,"top":48,"width":320,"height":72}}},{"action":"set_slide_background","args":{"slide_index":0,"fill":"#F7F1E8"}}]}}}` + +Patch payloads are single-artifact and currently support existing in-memory editing actions like slide/element/layout/theme/text updates. Lifecycle, import/export, and nested history actions are intentionally excluded. + +Example undo/redo: +`{"artifact_id":"presentation_x","action":"undo","args":{}}` + +`{"artifact_id":"presentation_x","action":"redo","args":{}}` + Deck summaries, slide listings, `inspect`, and `resolve` now include active-slide metadata. Use `set_active_slide` to change it explicitly. +Theme snapshots and `to_proto` both expose the deck theme hex color map via `hex_color_map` / `hexColorMap`. + +Named text styles are supported through `add_style`, `get_style`, and `describe_styles`. Built-in styles include `title`, `heading1`, `body`, `list`, and `numberedList`. + +Example style creation: +`{"artifact_id":"presentation_x","action":"add_style","args":{"name":"callout","font_size":18,"color":"#336699","italic":true,"underline":true}}` + +Example style lookup: +`{"artifact_id":"presentation_x","action":"get_style","args":{"name":"title"}}` + +Text styling payloads on `add_text_shape`, `add_shape.text_style`, `update_text.styling`, and `update_table_cell.styling` accept `style` and `underline` in addition to the existing whole-element fields. + Text-bearing elements also support literal `replace_text` and `insert_text_after` helpers for in-place edits without resending the full string. Text boxes and shapes support whole-element hyperlinks via `set_hyperlink`. Supported `link_type` values are `url`, `slide`, `first_slide`, `last_slide`, `next_slide`, `previous_slide`, `end_show`, `email`, and `file`. Use `clear: true` to remove an existing hyperlink. @@ -93,7 +138,9 @@ Notes visibility is honored on export: `set_notes_visibility` controls whether s Image placeholders can be prompt-only. `add_image` accepts `prompt` without `path`/`data_url`, and unresolved placeholders export as a visible placeholder box instead of failing. -Remote images are supported. `add_image` and `replace_image` accept `uri` in addition to local `path` and `data_url`. +Layout placeholders with `placeholder_type: "picture"` or `"image"` materialize as placeholder image elements on slides, so they appear in `list_slide_placeholders`, `inspect`, and `resolve` as `image` records rather than generic shapes. + +Remote images are supported. `add_image` and `replace_image` accept `uri` in addition to local `path`, raw base64 `blob`, and `data_url`. Image edits can target inspect/resolve anchors like `im/element_3`, and `update_shape_style` now accepts image `fit`, `crop`, `rotation`, `flip_horizontal`, `flip_vertical`, and `lock_aspect_ratio` updates. @@ -101,12 +148,16 @@ Image edits can target inspect/resolve anchors like `im/element_3`, and `update_ `update_shape_style.position` accepts partial updates, so you can move or resize an element without resending the full rect. +Shape strokes accept an optional `style` field such as `solid`, `dashed`, `dotted`, `dash-dot`, `dash-dot-dot`, `long-dash`, or `long-dash-dot`. This applies to ordinary shapes via `add_shape.stroke` and `update_shape_style.stroke`. + +`add_shape` also accepts optional `rotation`, `flip_horizontal`, and `flip_vertical` fields. Those same transform fields can also be provided inside the `position` object for `add_shape`, `add_image`, and `update_shape_style`. + Connectors are supported via `add_connector`, with straight/elbow/curved types plus dash styles and arrow heads. Example preview: `{"artifact_id":"presentation_x","action":"export_preview","args":{"slide_index":0,"path":"artifacts/q2-update-slide1.png"}}` -`export_preview` also accepts `format`, `scale`, and `quality` for rendered previews. `format` currently supports `png` and `jpeg`. +`export_preview` also accepts `format`, `scale`, and `quality` for rendered previews. `format` currently supports `png`, `jpeg`, and `svg`. Example JPEG preview: `{"artifact_id":"presentation_x","action":"export_preview","args":{"slide_index":0,"path":"artifacts/q2-update-slide1.jpg","format":"jpeg","scale":0.75,"quality":85}}`