diff --git a/codex-rs/artifact-presentation/src/presentation_artifact.rs b/codex-rs/artifact-presentation/src/presentation_artifact.rs index 93b551b3c..f3872fb80 100644 --- a/codex-rs/artifact-presentation/src/presentation_artifact.rs +++ b/codex-rs/artifact-presentation/src/presentation_artifact.rs @@ -27,13 +27,16 @@ 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; @@ -265,7 +268,8 @@ impl PresentationArtifactManager { message: error.to_string(), } })?; - let document = PresentationDocument::from_ppt_rs(imported); + 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); @@ -1238,6 +1242,9 @@ impl PresentationArtifactManager { 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, @@ -1308,6 +1315,15 @@ impl PresentationArtifactManager { 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; @@ -1736,7 +1752,11 @@ impl PresentationArtifactManager { if let Some(fill) = fill.clone() { text.fill = Some(fill); } - if args.stroke.is_some() || args.rotation.is_some() { + 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: @@ -1755,6 +1775,14 @@ impl PresentationArtifactManager { 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); } @@ -1762,6 +1790,8 @@ impl PresentationArtifactManager { 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() @@ -1797,11 +1827,11 @@ impl PresentationArtifactManager { } } PresentationElement::Image(image) => { - if args.fill.is_some() || args.stroke.is_some() || args.rotation.is_some() { + if args.fill.is_some() || args.stroke.is_some() { return Err(PresentationArtifactError::UnsupportedFeature { action: request.action, message: - "image elements support only `position`, `fit`, `crop`, `lock_aspect_ratio`, and `z_order` updates" + "image elements support only `position`, `fit`, `crop`, `rotation`, `flip_horizontal`, `flip_vertical`, `lock_aspect_ratio`, and `z_order` updates" .to_string(), }); } @@ -1817,12 +1847,26 @@ impl PresentationArtifactManager { 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() { + 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" @@ -1834,7 +1878,12 @@ impl PresentationArtifactManager { } } PresentationElement::Chart(chart) => { - if args.fill.is_some() || args.stroke.is_some() || args.rotation.is_some() { + 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" @@ -2709,6 +2758,279 @@ struct PresentationSlide { 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); @@ -2834,16 +3156,18 @@ impl PresentationSlide { } content = content.add_image(ppt_image); } else { - content = content.add_shape( - 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")), - ); + 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) => { @@ -3013,6 +3337,9 @@ pub(crate) struct ImageElement { 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, @@ -3601,6 +3928,9 @@ struct AddImageArgs { position: PositionArgs, fit: Option, crop: Option, + rotation: Option, + flip_horizontal: Option, + flip_vertical: Option, lock_aspect_ratio: Option, alt: Option, prompt: Option, @@ -3707,6 +4037,8 @@ struct UpdateShapeStyleArgs { fill: Option, stroke: Option, rotation: Option, + flip_horizontal: Option, + flip_vertical: Option, fit: Option, crop: Option, lock_aspect_ratio: Option, @@ -3726,6 +4058,9 @@ struct ReplaceImageArgs { uri: Option, fit: Option, crop: Option, + rotation: Option, + flip_horizontal: Option, + flip_vertical: Option, lock_aspect_ratio: Option, alt: Option, prompt: Option, @@ -4647,6 +4982,9 @@ fn inspect_document( "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, @@ -4825,6 +5163,9 @@ fn resolve_anchor( "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, @@ -4909,6 +5250,174 @@ fn build_pptx_bytes(document: &PresentationDocument, action: &str) -> Result, + 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, @@ -4916,15 +5425,29 @@ fn patch_pptx_package( 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 pending_slide_relationships = document - .slides - .iter() - .enumerate() - .filter_map(|(slide_index, slide)| { - let relationships = slide_hyperlink_relationships(slide); - (!relationships.is_empty()).then_some((slide_index + 1, relationships)) - }) - .collect::>(); + 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())?; @@ -4939,6 +5462,12 @@ fn patch_pptx_package( 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( @@ -4949,7 +5478,17 @@ fn patch_pptx_package( } if let Some(slide_number) = parse_slide_xml_path(&name) { writer - .write_all(update_slide_xml(bytes, &document.slides[slide_number - 1])?.as_bytes()) + .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; } @@ -4978,6 +5517,15 @@ fn patch_pptx_package( .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()) @@ -5081,8 +5629,41 @@ fn slide_relationships_xml(relationships: &[String]) -> String { ) } -fn update_slide_xml(existing_bytes: Vec, slide: &PresentationSlide) -> Result { +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); @@ -5093,6 +5674,38 @@ fn update_slide_xml(existing_bytes: Vec, slide: &PresentationSlide) -> Resul .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()); diff --git a/codex-rs/artifact-presentation/src/tests.rs b/codex-rs/artifact-presentation/src/tests.rs index aef6afeb2..87544c918 100644 --- a/codex-rs/artifact-presentation/src/tests.rs +++ b/codex-rs/artifact-presentation/src/tests.rs @@ -14,6 +14,12 @@ fn zip_entry_text( Ok(text) } +fn zip_entry_names(path: &std::path::Path) -> Result, Box> { + let file = std::fs::File::open(path)?; + let archive = zip::ZipArchive::new(file)?; + Ok(archive.file_names().map(str::to_owned).collect()) +} + #[test] fn manager_can_create_add_text_and_export() -> Result<(), Box> { let temp_dir = tempfile::tempdir()?; @@ -175,6 +181,209 @@ fn custom_slide_size_is_written_to_exported_pptx() -> Result<(), Box Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let source_path = temp_dir.path().join("source.png"); + image::RgbaImage::from_pixel(24, 16, image::Rgba([0x20, 0x90, 0xD0, 0xFF])) + .save(&source_path)?; + + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Image Export" }), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(created.artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(created.artifact_id.clone()), + action: "add_image".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "path": "source.png", + "position": { "left": 36, "top": 48, "width": 144, "height": 96 }, + "rotation": 15, + "flip_horizontal": true, + "alt": "Company logo" + }), + }, + temp_dir.path(), + )?; + + let export_path = temp_dir.path().join("images.pptx"); + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(created.artifact_id), + action: "export_pptx".to_string(), + args: serde_json::json!({ "path": export_path }), + }, + temp_dir.path(), + )?; + + let pptx_path = temp_dir.path().join("images.pptx"); + let slide_xml = zip_entry_text(&pptx_path, "ppt/slides/slide1.xml")?; + let rels_xml = zip_entry_text(&pptx_path, "ppt/slides/_rels/slide1.xml.rels")?; + let content_types_xml = zip_entry_text(&pptx_path, "[Content_Types].xml")?; + let entry_names = zip_entry_names(&pptx_path)?; + + assert!(slide_xml.contains("")); + assert!(slide_xml.contains(r#"descr="Company logo""#)); + assert!(slide_xml.contains(r#"r:embed="rIdImage1""#)); + assert!(slide_xml.contains(r#""#)); + assert!(!slide_xml.contains("Image Placeholder:")); + assert!(rels_xml.contains("relationships/image")); + assert!(rels_xml.contains(r#"Target="../media/image1.png""#)); + assert!(content_types_xml.contains(r#"Extension="png" ContentType="image/png""#)); + assert!(entry_names.contains(&"ppt/media/image1.png".to_string())); + Ok(()) +} + +#[test] +fn imported_pptx_surfaces_image_elements() -> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let source_path = temp_dir.path().join("import-source.png"); + image::RgbaImage::from_pixel(20, 20, image::Rgba([0xD0, 0x60, 0x20, 0xFF])) + .save(&source_path)?; + + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Image Import" }), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(created.artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(created.artifact_id.clone()), + action: "add_image".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "path": "import-source.png", + "position": { "left": 40, "top": 52, "width": 120, "height": 120 }, + "crop": { "left": 0.1, "top": 0.0, "right": 0.05, "bottom": 0.0 }, + "rotation": -10, + "flip_horizontal": true, + "flip_vertical": true, + "lock_aspect_ratio": true, + "alt": "Imported logo" + }), + }, + temp_dir.path(), + )?; + let export_path = temp_dir.path().join("image-import-roundtrip.pptx"); + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(created.artifact_id), + action: "export_pptx".to_string(), + args: serde_json::json!({ "path": export_path }), + }, + temp_dir.path(), + )?; + + let imported = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "import_pptx".to_string(), + args: serde_json::json!({ "path": "image-import-roundtrip.pptx" }), + }, + temp_dir.path(), + )?; + assert_eq!( + imported + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .map(|slide| slide.element_types.clone()), + Some(vec!["image".to_string()]) + ); + let image_anchor = imported + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .and_then(|slide| slide.element_ids.first()) + .map(|id| format!("im/{id}")) + .expect("image anchor"); + let resolved = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(imported.artifact_id), + action: "resolve".to_string(), + args: serde_json::json!({ "id": image_anchor }), + }, + temp_dir.path(), + )?; + assert_eq!( + resolved + .resolved_record + .as_ref() + .and_then(|record| record.get("alt")) + .and_then(serde_json::Value::as_str), + Some("Imported logo") + ); + assert_eq!( + resolved + .resolved_record + .as_ref() + .and_then(|record| record.get("rotation")) + .and_then(serde_json::Value::as_i64), + Some(-10) + ); + 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) + ); + assert_eq!( + resolved + .resolved_record + .as_ref() + .and_then(|record| record.get("lockAspectRatio")) + .and_then(serde_json::Value::as_bool), + Some(true) + ); + assert_eq!( + resolved + .resolved_record + .as_ref() + .and_then(|record| record.get("crop")) + .and_then(|crop| crop.get("left")) + .and_then(serde_json::Value::as_f64), + Some(0.1) + ); + Ok(()) +} + #[test] fn image_fit_contain_preserves_aspect_ratio() { let image = ImageElement { @@ -193,6 +402,9 @@ fn image_fit_contain_preserves_aspect_ratio() { }), fit_mode: ImageFitMode::Contain, crop: None, + rotation_degrees: None, + flip_horizontal: false, + flip_vertical: false, lock_aspect_ratio: true, alt_text: None, prompt: None, @@ -1023,6 +1235,8 @@ fn image_placeholders_and_anchor_updates_work() -> Result<(), Box Result<(), Box