From 24ba01b9dabb28d426dccf903bc62f81e2741de8 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 3 Mar 2026 15:03:25 +0000 Subject: [PATCH] feat: artifact presentation part 7 (#13360) --- .../src/presentation_artifact/args.rs | 265 +++++- .../src/presentation_artifact/inspect.rs | 264 +++++- .../src/presentation_artifact/manager.rs | 897 +++++++++++++++++- .../src/presentation_artifact/model.rs | 311 +++++- .../src/presentation_artifact/parsing.rs | 411 +++++++- .../src/presentation_artifact/pptx.rs | 31 +- .../src/presentation_artifact/proto.rs | 260 ++++- .../src/presentation_artifact/snapshot.rs | 3 +- codex-rs/artifact-presentation/src/tests.rs | 624 +++++++++--- .../templates/tools/presentation_artifact.md | 37 +- 10 files changed, 2883 insertions(+), 220 deletions(-) diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/args.rs b/codex-rs/artifact-presentation/src/presentation_artifact/args.rs index b85bcdff8..a139dcb05 100644 --- a/codex-rs/artifact-presentation/src/presentation_artifact/args.rs +++ b/codex-rs/artifact-presentation/src/presentation_artifact/args.rs @@ -225,7 +225,7 @@ struct PartialPositionArgs { flip_vertical: Option, } -#[derive(Debug, Default, Deserialize)] +#[derive(Debug, Clone, Default, Deserialize)] struct TextStylingArgs { style: Option, font_size: Option, @@ -238,6 +238,57 @@ struct TextStylingArgs { underline: Option, } +#[derive(Debug, Clone, Default, Deserialize)] +struct TextLayoutArgs { + insets: Option, + wrap: Option, + auto_fit: Option, + vertical_alignment: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct TextInsetsArgs { + left: u32, + right: u32, + top: u32, + bottom: u32, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +enum RichTextInput { + Plain(String), + Paragraphs(Vec), +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +enum RichParagraphInput { + Plain(String), + Runs(Vec), +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +enum RichRunInput { + Plain(String), + Styled(RichRunObjectInput), +} + +#[derive(Debug, Clone, Deserialize)] +struct RichRunObjectInput { + run: String, + #[serde(default)] + text_style: TextStylingArgs, + link: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct RichTextLinkInput { + uri: Option, + is_external: Option, +} + #[derive(Debug, Deserialize)] struct AddTextShapeArgs { slide_index: u32, @@ -245,6 +296,8 @@ struct AddTextShapeArgs { position: PositionArgs, #[serde(flatten)] styling: TextStylingArgs, + #[serde(default)] + text_layout: TextLayoutArgs, } #[derive(Debug, Clone, Default, Deserialize)] @@ -267,6 +320,8 @@ struct AddShapeArgs { flip_vertical: Option, #[serde(default)] text_style: TextStylingArgs, + #[serde(default)] + text_layout: TextLayoutArgs, } #[derive(Debug, Clone, Default, Deserialize)] @@ -276,7 +331,7 @@ struct ConnectorLineArgs { style: Option, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] struct PointArgs { left: u32, top: u32, @@ -355,6 +410,9 @@ struct AddTableArgs { column_widths: Option>, row_heights: Option>, style: Option, + style_options: Option, + borders: Option, + right_to_left: Option, } #[derive(Debug, Deserialize)] @@ -365,12 +423,55 @@ struct AddChartArgs { categories: Vec, series: Vec, title: Option, + style_index: Option, + has_legend: Option, + legend_position: Option, + #[serde(default)] + legend_text_style: TextStylingArgs, + x_axis_title: Option, + y_axis_title: Option, + data_labels: Option, + chart_fill: Option, + plot_area_fill: Option, } #[derive(Debug, Deserialize)] struct ChartSeriesArgs { name: String, values: Vec, + categories: Option>, + x_values: Option>, + fill: Option, + stroke: Option, + marker: Option, + data_label_overrides: Option>, +} + +#[derive(Debug, Clone, Deserialize)] +struct ChartMarkerArgs { + symbol: Option, + size: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct ChartDataLabelsArgs { + show_value: Option, + show_category_name: Option, + show_leader_lines: Option, + position: Option, + #[serde(default)] + text_style: TextStylingArgs, +} + +#[derive(Debug, Clone, Deserialize)] +struct ChartDataLabelOverrideArgs { + idx: u32, + text: Option, + position: Option, + #[serde(default)] + text_style: TextStylingArgs, + fill: Option, + stroke: Option, } #[derive(Debug, Deserialize)] @@ -379,6 +480,43 @@ struct UpdateTextArgs { text: String, #[serde(default)] styling: TextStylingArgs, + #[serde(default)] + text_layout: TextLayoutArgs, +} + +#[derive(Debug, Deserialize)] +struct SetRichTextArgs { + element_id: Option, + slide_index: Option, + row: Option, + column: Option, + notes: Option, + text: RichTextInput, + #[serde(default)] + styling: TextStylingArgs, + #[serde(default)] + text_layout: TextLayoutArgs, +} + +#[derive(Debug, Deserialize)] +struct FormatTextRangeArgs { + element_id: Option, + slide_index: Option, + row: Option, + column: Option, + notes: Option, + query: Option, + occurrence: Option, + start_cp: Option, + length: Option, + #[serde(default)] + styling: TextStylingArgs, + #[serde(default)] + text_layout: TextLayoutArgs, + link: Option, + spacing_before: Option, + spacing_after: Option, + line_spacing: Option, } #[derive(Debug, Deserialize)] @@ -422,6 +560,8 @@ struct UpdateShapeStyleArgs { crop: Option, lock_aspect_ratio: Option, z_order: Option, + #[serde(default)] + text_layout: TextLayoutArgs, } #[derive(Debug, Deserialize)] @@ -458,6 +598,55 @@ struct UpdateTableCellArgs { alignment: Option, } +#[derive(Debug, Clone, Deserialize)] +struct TableStyleOptionsArgs { + header_row: Option, + banded_rows: Option, + banded_columns: Option, + first_column: Option, + last_column: Option, + total_row: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct TableBorderArgs { + color: String, + width: u32, +} + +#[derive(Debug, Clone, Deserialize)] +struct TableBordersArgs { + outside: Option, + inside: Option, + top: Option, + bottom: Option, + left: Option, + right: Option, +} + +#[derive(Debug, Deserialize)] +struct UpdateTableStyleArgs { + element_id: String, + style: Option, + style_options: Option, + borders: Option, + right_to_left: Option, +} + +#[derive(Debug, Deserialize)] +struct StyleTableBlockArgs { + element_id: String, + row: u32, + column: u32, + row_count: u32, + column_count: u32, + #[serde(default)] + styling: TextStylingArgs, + background_fill: Option, + alignment: Option, + borders: Option, +} + #[derive(Debug, Deserialize)] struct MergeTableCellsArgs { element_id: String, @@ -466,3 +655,75 @@ struct MergeTableCellsArgs { start_column: u32, end_column: u32, } + +#[derive(Debug, Deserialize)] +struct UpdateChartArgs { + element_id: String, + title: Option, + categories: Option>, + style_index: Option, + has_legend: Option, + legend_position: Option, + #[serde(default)] + legend_text_style: TextStylingArgs, + x_axis_title: Option, + y_axis_title: Option, + data_labels: Option, + chart_fill: Option, + plot_area_fill: Option, +} + +#[derive(Debug, Deserialize)] +struct AddChartSeriesArgs { + element_id: String, + name: String, + values: Vec, + categories: Option>, + x_values: Option>, + fill: Option, + stroke: Option, + marker: Option, +} + +#[derive(Debug, Deserialize)] +struct SetCommentAuthorArgs { + display_name: String, + initials: String, + email: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct CommentPositionArgs { + x: u32, + y: u32, +} + +#[derive(Debug, Deserialize)] +struct AddCommentThreadArgs { + slide_index: Option, + element_id: Option, + query: Option, + occurrence: Option, + start_cp: Option, + length: Option, + text: String, + position: Option, +} + +#[derive(Debug, Deserialize)] +struct AddCommentReplyArgs { + thread_id: String, + text: String, +} + +#[derive(Debug, Deserialize)] +struct ToggleCommentReactionArgs { + thread_id: String, + message_id: Option, + emoji: String, +} + +#[derive(Debug, Deserialize)] +struct CommentThreadIdArgs { + thread_id: String, +} diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/inspect.rs b/codex-rs/artifact-presentation/src/presentation_artifact/inspect.rs index c270926f3..608e0734e 100644 --- a/codex-rs/artifact-presentation/src/presentation_artifact/inspect.rs +++ b/codex-rs/artifact-presentation/src/presentation_artifact/inspect.rs @@ -3,7 +3,9 @@ fn inspect_document(document: &PresentationDocument, args: &InspectArgs) -> Stri .include .as_deref() .or(args.kind.as_deref()) - .unwrap_or("deck,slide,textbox,shape,connector,table,chart,image,notes,layoutList"); + .unwrap_or( + "deck,slide,textbox,shape,connector,table,chart,image,notes,layoutList,textRange,comment", + ); let included_kinds = include_kinds .split(',') .map(str::trim) @@ -33,6 +35,11 @@ fn inspect_document(document: &PresentationDocument, args: &InspectArgs) -> Stri .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)), + "commentThreadIds": document + .comment_threads + .iter() + .map(|thread| format!("th/{}", thread.thread_id)) + .collect::>(), }), None, )); @@ -97,10 +104,29 @@ fn inspect_document(document: &PresentationDocument, args: &InspectArgs) -> Stri "textPreview": slide.notes.text.replace('\n', " | "), "textChars": slide.notes.text.chars().count(), "textLines": slide.notes.text.lines().count(), + "richText": rich_text_to_proto(&slide.notes.text, &slide.notes.rich_text), }), Some(slide_id.clone()), )); } + if include("textRange") { + records.extend( + slide + .notes + .rich_text + .ranges + .iter() + .map(|range| { + let mut record = text_range_to_proto(&slide.notes.text, range); + record["kind"] = Value::String("textRange".to_string()); + record["slide"] = Value::from(index + 1); + record["slideIndex"] = Value::from(index); + record["hostAnchor"] = Value::String(format!("nt/{}", slide.slide_id)); + record["hostKind"] = Value::String("notes".to_string()); + (record, Some(slide_id.clone())) + }), + ); + } for element in &slide.elements { let mut record = match element { PresentationElement::Text(text) => { @@ -116,6 +142,7 @@ fn inspect_document(document: &PresentationDocument, args: &InspectArgs) -> Stri "textPreview": text.text.replace('\n', " | "), "textChars": text.text.chars().count(), "textLines": text.text.lines().count(), + "richText": rich_text_to_proto(&text.text, &text.rich_text), "bbox": [text.frame.left, text.frame.top, text.frame.width, text.frame.height], "bboxUnit": "points", }) @@ -136,6 +163,12 @@ fn inspect_document(document: &PresentationDocument, args: &InspectArgs) -> Stri "geometry": format!("{:?}", shape.geometry), "text": shape.text, "textStyle": text_style_to_proto(&shape.text_style), + "richText": shape + .text + .as_ref() + .zip(shape.rich_text.as_ref()) + .map(|(text, rich_text)| rich_text_to_proto(text, rich_text)) + .unwrap_or(Value::Null), "rotation": shape.rotation_degrees, "flipHorizontal": shape.flip_horizontal, "flipVertical": shape.flip_vertical, @@ -178,11 +211,19 @@ fn inspect_document(document: &PresentationDocument, args: &InspectArgs) -> Stri "rowHeights": table.row_heights, "preview": table.rows.first().map(|row| row.iter().map(|cell| cell.text.clone()).collect::>().join(" | ")), "style": table.style, + "styleOptions": table_style_options_to_proto(&table.style_options), + "borders": table.borders.as_ref().map(table_borders_to_proto), + "rightToLeft": table.right_to_left, "cellTextStyles": table .rows .iter() .map(|row| row.iter().map(|cell| text_style_to_proto(&cell.text_style)).collect::>()) .collect::>(), + "rowsData": table + .rows + .iter() + .map(|row| row.iter().map(table_cell_to_proto).collect::>()) + .collect::>(), "bbox": [table.frame.left, table.frame.top, table.frame.width, table.frame.height], "bboxUnit": "points", }) @@ -197,6 +238,32 @@ fn inspect_document(document: &PresentationDocument, args: &InspectArgs) -> Stri "slide": index + 1, "chartType": format!("{:?}", chart.chart_type), "title": chart.title, + "styleIndex": chart.style_index, + "hasLegend": chart.has_legend, + "legend": chart.legend.as_ref().map(chart_legend_to_proto), + "xAxis": chart.x_axis.as_ref().map(chart_axis_to_proto), + "yAxis": chart.y_axis.as_ref().map(chart_axis_to_proto), + "dataLabels": chart.data_labels.as_ref().map(chart_data_labels_to_proto), + "chartFill": chart.chart_fill, + "plotAreaFill": chart.plot_area_fill, + "series": chart + .series + .iter() + .map(|series| serde_json::json!({ + "name": series.name, + "values": series.values, + "categories": series.categories, + "xValues": series.x_values, + "fill": series.fill, + "stroke": series.stroke.as_ref().map(stroke_to_proto), + "marker": series.marker.as_ref().map(chart_marker_to_proto), + "dataLabelOverrides": series + .data_label_overrides + .iter() + .map(chart_data_label_override_to_proto) + .collect::>(), + })) + .collect::>(), "bbox": [chart.frame.left, chart.frame.top, chart.frame.width, chart.frame.height], "bboxUnit": "points", }) @@ -261,8 +328,64 @@ fn inspect_document(document: &PresentationDocument, args: &InspectArgs) -> Stri record["hyperlink"] = hyperlink.to_json(); } records.push((record, Some(slide_id.clone()))); + if include("textRange") { + match element { + PresentationElement::Text(text) => { + records.extend(text.rich_text.ranges.iter().map(|range| { + let mut record = text_range_to_proto(&text.text, range); + record["kind"] = Value::String("textRange".to_string()); + record["slide"] = Value::from(index + 1); + record["slideIndex"] = Value::from(index); + record["hostAnchor"] = Value::String(format!("sh/{}", text.element_id)); + record["hostKind"] = Value::String("textbox".to_string()); + (record, Some(slide_id.clone())) + })); + } + PresentationElement::Shape(shape) => { + if let Some((text, rich_text)) = shape.text.as_ref().zip(shape.rich_text.as_ref()) { + records.extend(rich_text.ranges.iter().map(|range| { + let mut record = text_range_to_proto(text, range); + record["kind"] = Value::String("textRange".to_string()); + record["slide"] = Value::from(index + 1); + record["slideIndex"] = Value::from(index); + record["hostAnchor"] = Value::String(format!("sh/{}", shape.element_id)); + record["hostKind"] = Value::String("textbox".to_string()); + (record, Some(slide_id.clone())) + })); + } + } + PresentationElement::Table(table) => { + for (row_index, row) in table.rows.iter().enumerate() { + for (column_index, cell) in row.iter().enumerate() { + records.extend(cell.rich_text.ranges.iter().map(|range| { + let mut record = text_range_to_proto(&cell.text, range); + record["kind"] = Value::String("textRange".to_string()); + record["slide"] = Value::from(index + 1); + record["slideIndex"] = Value::from(index); + record["hostAnchor"] = Value::String(format!( + "tb/{}#cell/{row_index}/{column_index}", + table.element_id + )); + record["hostKind"] = Value::String("tableCell".to_string()); + (record, Some(slide_id.clone())) + })); + } + } + } + PresentationElement::Connector(_) + | PresentationElement::Image(_) + | PresentationElement::Chart(_) => {} + } + } } } + if include("comment") { + records.extend(document.comment_threads.iter().map(|thread| { + let mut record = comment_thread_to_proto(thread); + record["id"] = Value::String(format!("th/{}", thread.thread_id)); + (record, None) + })); + } if let Some(target_id) = args.target_id.as_deref() { records.retain(|(record, slide_id)| { @@ -442,6 +565,27 @@ fn resolve_anchor( "text": slide.notes.text, }); add_text_metadata(&mut record, &slide.notes.text); + record["richText"] = rich_text_to_proto(&slide.notes.text, &slide.notes.rich_text); + return Ok(record); + } + if let Some(range_id) = id.strip_prefix("tr/") + && let Some(record) = slide + .notes + .rich_text + .ranges + .iter() + .find(|range| range.range_id == range_id) + .map(|range| { + let mut record = text_range_to_proto(&slide.notes.text, range); + record["kind"] = Value::String("textRange".to_string()); + record["id"] = Value::String(id.to_string()); + record["slide"] = Value::from(slide_index + 1); + record["slideIndex"] = Value::from(slide_index); + record["hostAnchor"] = Value::String(notes_id.clone()); + record["hostKind"] = Value::String("notes".to_string()); + record + }) + { return Ok(record); } for element in &slide.elements { @@ -455,6 +599,7 @@ fn resolve_anchor( "slideIndex": slide_index, "text": text.text, "textStyle": text_style_to_proto(&text.style), + "richText": rich_text_to_proto(&text.text, &text.rich_text), "bbox": [text.frame.left, text.frame.top, text.frame.width, text.frame.height], "bboxUnit": "points", }); @@ -471,6 +616,12 @@ fn resolve_anchor( "geometry": format!("{:?}", shape.geometry), "text": shape.text, "textStyle": text_style_to_proto(&shape.text_style), + "richText": shape + .text + .as_ref() + .zip(shape.rich_text.as_ref()) + .map(|(text, rich_text)| rich_text_to_proto(text, rich_text)) + .unwrap_or(Value::Null), "rotation": shape.rotation_degrees, "flipHorizontal": shape.flip_horizontal, "flipVertical": shape.flip_vertical, @@ -527,11 +678,20 @@ fn resolve_anchor( "cols": table.rows.iter().map(std::vec::Vec::len).max().unwrap_or(0), "columnWidths": table.column_widths, "rowHeights": table.row_heights, + "style": table.style, + "styleOptions": table_style_options_to_proto(&table.style_options), + "borders": table.borders.as_ref().map(table_borders_to_proto), + "rightToLeft": table.right_to_left, "cellTextStyles": table .rows .iter() .map(|row| row.iter().map(|cell| text_style_to_proto(&cell.text_style)).collect::>()) .collect::>(), + "rowsData": table + .rows + .iter() + .map(|row| row.iter().map(table_cell_to_proto).collect::>()) + .collect::>(), "bbox": [table.frame.left, table.frame.top, table.frame.width, table.frame.height], "bboxUnit": "points", }), @@ -543,6 +703,32 @@ fn resolve_anchor( "slideIndex": slide_index, "chartType": format!("{:?}", chart.chart_type), "title": chart.title, + "styleIndex": chart.style_index, + "hasLegend": chart.has_legend, + "legend": chart.legend.as_ref().map(chart_legend_to_proto), + "xAxis": chart.x_axis.as_ref().map(chart_axis_to_proto), + "yAxis": chart.y_axis.as_ref().map(chart_axis_to_proto), + "dataLabels": chart.data_labels.as_ref().map(chart_data_labels_to_proto), + "chartFill": chart.chart_fill, + "plotAreaFill": chart.plot_area_fill, + "series": chart + .series + .iter() + .map(|series| serde_json::json!({ + "name": series.name, + "values": series.values, + "categories": series.categories, + "xValues": series.x_values, + "fill": series.fill, + "stroke": series.stroke.as_ref().map(stroke_to_proto), + "marker": series.marker.as_ref().map(chart_marker_to_proto), + "dataLabelOverrides": series + .data_label_overrides + .iter() + .map(chart_data_label_override_to_proto) + .collect::>(), + })) + .collect::>(), "bbox": [chart.frame.left, chart.frame.top, chart.frame.width, chart.frame.height], "bboxUnit": "points", }), @@ -582,9 +768,84 @@ fn resolve_anchor( if record.get("id").and_then(Value::as_str) == Some(id) { return Ok(record); } + if let Some(range_id) = id.strip_prefix("tr/") { + match element { + PresentationElement::Text(text) => { + if let Some(range) = + text.rich_text.ranges.iter().find(|range| range.range_id == range_id) + { + let mut range_record = text_range_to_proto(&text.text, range); + range_record["kind"] = Value::String("textRange".to_string()); + range_record["id"] = Value::String(id.to_string()); + range_record["slide"] = Value::from(slide_index + 1); + range_record["slideIndex"] = Value::from(slide_index); + range_record["hostAnchor"] = + Value::String(format!("sh/{}", text.element_id)); + range_record["hostKind"] = Value::String("textbox".to_string()); + return Ok(range_record); + } + } + PresentationElement::Shape(shape) => { + if let Some((text, rich_text)) = + shape.text.as_ref().zip(shape.rich_text.as_ref()) + && let Some(range) = + rich_text.ranges.iter().find(|range| range.range_id == range_id) + { + let mut range_record = text_range_to_proto(text, range); + range_record["kind"] = Value::String("textRange".to_string()); + range_record["id"] = Value::String(id.to_string()); + range_record["slide"] = Value::from(slide_index + 1); + range_record["slideIndex"] = Value::from(slide_index); + range_record["hostAnchor"] = + Value::String(format!("sh/{}", shape.element_id)); + range_record["hostKind"] = Value::String("textbox".to_string()); + return Ok(range_record); + } + } + PresentationElement::Table(table) => { + for (row_index, row) in table.rows.iter().enumerate() { + for (column_index, cell) in row.iter().enumerate() { + if let Some(range) = cell + .rich_text + .ranges + .iter() + .find(|range| range.range_id == range_id) + { + let mut range_record = text_range_to_proto(&cell.text, range); + range_record["kind"] = Value::String("textRange".to_string()); + range_record["id"] = Value::String(id.to_string()); + range_record["slide"] = Value::from(slide_index + 1); + range_record["slideIndex"] = Value::from(slide_index); + range_record["hostAnchor"] = Value::String(format!( + "tb/{}#cell/{row_index}/{column_index}", + table.element_id + )); + range_record["hostKind"] = + Value::String("tableCell".to_string()); + return Ok(range_record); + } + } + } + } + PresentationElement::Connector(_) + | PresentationElement::Image(_) + | PresentationElement::Chart(_) => {} + } + } } } + if let Some(thread_id) = id.strip_prefix("th/") + && let Some(thread) = document + .comment_threads + .iter() + .find(|thread| thread.thread_id == thread_id) + { + let mut record = comment_thread_to_proto(thread); + record["id"] = Value::String(id.to_string()); + return Ok(record); + } + for layout in &document.layouts { let layout_id = format!("ly/{}", layout.layout_id); if id == layout_id { @@ -608,4 +869,3 @@ fn resolve_anchor( 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 index 28682cb91..8539b62c7 100644 --- a/codex-rs/artifact-presentation/src/presentation_artifact/manager.rs +++ b/codex-rs/artifact-presentation/src/presentation_artifact/manager.rs @@ -122,6 +122,7 @@ impl PresentationArtifactManager { "get_style" => self.get_style(request), "describe_styles" => self.describe_styles(request), "set_notes" => self.set_notes(request), + "set_notes_rich_text" => self.set_notes_rich_text(request), "append_notes" => self.append_notes(request), "clear_notes" => self.clear_notes(request), "set_notes_visibility" => self.set_notes_visibility(request), @@ -133,13 +134,25 @@ impl PresentationArtifactManager { "add_image" => self.add_image(request, cwd), "replace_image" => self.replace_image(request, cwd), "add_table" => self.add_table(request), + "update_table_style" => self.update_table_style(request), + "style_table_block" => self.style_table_block(request), "update_table_cell" => self.update_table_cell(request), "merge_table_cells" => self.merge_table_cells(request), "add_chart" => self.add_chart(request), + "update_chart" => self.update_chart(request), + "add_chart_series" => self.add_chart_series(request), "update_text" => self.update_text(request), + "set_rich_text" => self.set_rich_text(request), + "format_text_range" => self.format_text_range(request), "replace_text" => self.replace_text(request), "insert_text_after" => self.insert_text_after(request), "set_hyperlink" => self.set_hyperlink(request), + "set_comment_author" => self.set_comment_author(request), + "add_comment_thread" => self.add_comment_thread(request), + "add_comment_reply" => self.add_comment_reply(request), + "toggle_comment_reaction" => self.toggle_comment_reaction(request), + "resolve_comment_thread" => self.resolve_comment_thread(request), + "reopen_comment_thread" => self.reopen_comment_thread(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), @@ -187,14 +200,24 @@ impl PresentationArtifactManager { ) -> 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 { + let document = if let Some(document) = import_codex_metadata_document(&path) + .map_err(|message| 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)?; + message, + })? + { + document + } else { + 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)?; + document + }; let artifact_id = document.artifact_id.clone(); let slide_count = document.slides.len(); let snapshot = snapshot_for_document(&document); @@ -287,6 +310,7 @@ impl PresentationArtifactManager { .ok_or_else(|| { index_out_of_range(&request.action, slide_index as usize, document.slides.len()) })?; + let slide_id = slide.slide_id.clone(); PresentationDocument { artifact_id: document.artifact_id.clone(), name: document.name.clone(), @@ -296,9 +320,25 @@ impl PresentationArtifactManager { layouts: Vec::new(), slides: vec![slide], active_slide_index: Some(0), + comment_self: document.comment_self.clone(), + comment_threads: document + .comment_threads + .iter() + .filter(|thread| match &thread.target { + CommentTarget::Slide { slide_id: target_slide_id } + | CommentTarget::Element { slide_id: target_slide_id, .. } + | CommentTarget::TextRange { slide_id: target_slide_id, .. } => { + target_slide_id == &slide_id + } + }) + .cloned() + .collect(), next_slide_seq: 1, next_element_seq: 1, next_layout_seq: 1, + next_text_range_seq: document.next_text_range_seq, + next_comment_thread_seq: document.next_comment_thread_seq, + next_comment_message_seq: document.next_comment_message_seq, } } else { document.clone() @@ -993,6 +1033,7 @@ impl PresentationArtifactManager { 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(); + slide.notes.rich_text = RichTextState::default(); Ok(PresentationArtifactResponse::new( artifact_id, request.action, @@ -1001,6 +1042,31 @@ impl PresentationArtifactManager { )) } + fn set_notes_rich_text( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: SetRichTextArgs = 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_index = args.slide_index.ok_or_else(|| PresentationArtifactError::InvalidArgs { + action: request.action.clone(), + message: "`slide_index` is required for notes rich text".to_string(), + })?; + let (text, mut rich_text) = + normalize_rich_text_input(document, args.text, &request.action)?; + rich_text.layout = normalize_text_layout(&args.text_layout, &request.action)?; + let slide = document.get_slide_mut(slide_index, &request.action)?; + slide.notes.text = text; + slide.notes.rich_text = rich_text; + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Updated rich notes for slide {slide_index}"), + snapshot_for_document(document), + )) + } + fn append_notes( &mut self, request: PresentationArtifactRequest, @@ -1015,6 +1081,7 @@ impl PresentationArtifactManager { } else { slide.notes.text = format!("{}\n{text}", slide.notes.text); } + slide.notes.rich_text = RichTextState::default(); Ok(PresentationArtifactResponse::new( artifact_id, request.action, @@ -1032,6 +1099,7 @@ impl PresentationArtifactManager { 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(); + slide.notes.rich_text = RichTextState::default(); Ok(PresentationArtifactResponse::new( artifact_id, request.action, @@ -1261,6 +1329,7 @@ impl PresentationArtifactManager { .as_deref() .map(|value| normalize_color_with_document(document, value, &request.action, "fill")) .transpose()?; + let text_layout = normalize_text_layout(&args.text_layout, &request.action)?; let element_id = document.next_element_id(); let slide = document.get_slide_mut(args.slide_index, &request.action)?; slide.elements.push(PresentationElement::Text(TextElement { @@ -1270,6 +1339,10 @@ impl PresentationArtifactManager { fill, style, hyperlink: None, + rich_text: RichTextState { + ranges: Vec::new(), + layout: text_layout, + }, placeholder: None, z_order: slide.elements.len(), })); @@ -1299,6 +1372,11 @@ impl PresentationArtifactManager { .map(|value| normalize_color_with_document(document, value, &request.action, "fill")) .transpose()?; let stroke = parse_stroke(document, args.stroke, &request.action)?; + let text_layout = normalize_text_layout(&args.text_layout, &request.action)?; + let rich_text = args.text.as_ref().map(|_| RichTextState { + ranges: Vec::new(), + layout: text_layout, + }); let element_id = document.next_element_id(); let slide = document.get_slide_mut(args.slide_index, &request.action)?; slide @@ -1312,6 +1390,7 @@ impl PresentationArtifactManager { text: args.text, text_style, hyperlink: None, + rich_text, placeholder: None, rotation_degrees: args.rotation.or(args.position.rotation), flip_horizontal: args @@ -1552,6 +1631,8 @@ impl PresentationArtifactManager { 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 borders = parse_table_borders(document, args.borders, &request.action)?; + let style_options = parse_table_style_options(args.style_options); let mut frame: Rect = args.position.into(); let (column_widths, row_heights) = normalize_table_dimensions( &rows, @@ -1573,6 +1654,9 @@ impl PresentationArtifactManager { column_widths, row_heights, style: args.style, + style_options, + borders, + right_to_left: args.right_to_left.unwrap_or(false), merges: Vec::new(), z_order: slide.elements.len(), })); @@ -1622,6 +1706,7 @@ impl PresentationArtifactManager { cell.text = cell_value_to_string(args.value); cell.text_style = text_style; cell.background_fill = background_fill; + cell.rich_text = RichTextState::default(); cell.alignment = args .alignment .as_deref() @@ -1635,6 +1720,137 @@ impl PresentationArtifactManager { )) } + fn update_table_style( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: UpdateTableStyleArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let borders = parse_table_borders(document, args.borders, &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), + }); + }; + table.style = args.style; + if let Some(borders) = borders { + if let Some(existing) = table.borders.as_mut() { + if borders.outside.is_some() { + existing.outside = borders.outside; + } + if borders.inside.is_some() { + existing.inside = borders.inside; + } + if borders.top.is_some() { + existing.top = borders.top; + } + if borders.bottom.is_some() { + existing.bottom = borders.bottom; + } + if borders.left.is_some() { + existing.left = borders.left; + } + if borders.right.is_some() { + existing.right = borders.right; + } + } else { + table.borders = Some(borders); + } + } + if let Some(style_options) = args.style_options { + if let Some(value) = style_options.header_row { + table.style_options.header_row = value; + } + if let Some(value) = style_options.banded_rows { + table.style_options.banded_rows = value; + } + if let Some(value) = style_options.banded_columns { + table.style_options.banded_columns = value; + } + if let Some(value) = style_options.first_column { + table.style_options.first_column = value; + } + if let Some(value) = style_options.last_column { + table.style_options.last_column = value; + } + if let Some(value) = style_options.total_row { + table.style_options.total_row = value; + } + } + if let Some(right_to_left) = args.right_to_left { + table.right_to_left = right_to_left; + } + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Updated table style for `{}`", args.element_id), + snapshot_for_document(document), + )) + } + + fn style_table_block( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: StyleTableBlockArgs = 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 borders = parse_table_borders(document, args.borders, &request.action)?; + let alignment = args + .alignment + .as_deref() + .map(|value| parse_alignment(value, &request.action)) + .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 end_row = (args.row + args.row_count) as usize; + let end_column = (args.column + args.column_count) as usize; + for row_index in args.row as usize..end_row { + if row_index >= table.rows.len() { + break; + } + for column_index in args.column as usize..end_column { + if column_index >= table.rows[row_index].len() { + break; + } + let cell = &mut table.rows[row_index][column_index]; + cell.text_style = text_style.clone(); + if let Some(fill) = background_fill.clone() { + cell.background_fill = Some(fill); + } + if let Some(alignment) = alignment { + cell.alignment = Some(alignment); + } + if let Some(borders) = borders.clone() { + cell.borders = Some(borders); + } + } + } + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Styled table block for `{}`", args.element_id), + snapshot_for_document(document), + )) + } + fn merge_table_cells( &mut self, request: PresentationArtifactRequest, @@ -1672,22 +1888,24 @@ impl PresentationArtifactManager { 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, - }) + let series = parse_chart_series(document, args.series, &request.action)?; + let legend_text_style = + normalize_text_style_with_document(document, &args.legend_text_style, &request.action)?; + let data_labels = parse_chart_data_labels(document, args.data_labels, &request.action)?; + let chart_fill = args + .chart_fill + .as_deref() + .map(|value| { + normalize_color_with_document(document, value, &request.action, "chart_fill") }) - .collect::, _>>()?; + .transpose()?; + let plot_area_fill = args + .plot_area_fill + .as_deref() + .map(|value| { + normalize_color_with_document(document, value, &request.action, "plot_area_fill") + }) + .transpose()?; let element_id = document.next_element_id(); let slide = document.get_slide_mut(args.slide_index, &request.action)?; slide @@ -1699,6 +1917,21 @@ impl PresentationArtifactManager { categories: args.categories, series, title: args.title, + style_index: args.style_index, + has_legend: args.has_legend.unwrap_or(false), + legend: Some(ChartLegend { + position: args.legend_position, + text_style: legend_text_style, + }), + x_axis: Some(ChartAxisSpec { + title: args.x_axis_title, + }), + y_axis: Some(ChartAxisSpec { + title: args.y_axis_title, + }), + data_labels, + chart_fill, + plot_area_fill, z_order: slide.elements.len(), })); Ok(PresentationArtifactResponse::new( @@ -1712,6 +1945,115 @@ impl PresentationArtifactManager { )) } + fn update_chart( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: UpdateChartArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let legend_text_style = + normalize_text_style_with_document(document, &args.legend_text_style, &request.action)?; + let data_labels = parse_chart_data_labels(document, args.data_labels, &request.action)?; + let chart_fill = args + .chart_fill + .as_deref() + .map(|value| normalize_color_with_document(document, value, &request.action, "chart_fill")) + .transpose()?; + let plot_area_fill = args + .plot_area_fill + .as_deref() + .map(|value| normalize_color_with_document(document, value, &request.action, "plot_area_fill")) + .transpose()?; + let element = document.find_element_mut(&args.element_id, &request.action)?; + let PresentationElement::Chart(chart) = element else { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: format!("element `{}` is not a chart", args.element_id), + }); + }; + if let Some(title) = args.title { + chart.title = Some(title); + } + if let Some(categories) = args.categories { + chart.categories = categories; + } + if let Some(style_index) = args.style_index { + chart.style_index = Some(style_index); + } + if let Some(has_legend) = args.has_legend { + chart.has_legend = has_legend; + } + if args.legend_position.is_some() || !text_style_is_empty(&legend_text_style) { + chart.legend = Some(ChartLegend { + position: args.legend_position, + text_style: legend_text_style, + }); + } + if args.x_axis_title.is_some() { + chart.x_axis = Some(ChartAxisSpec { + title: args.x_axis_title, + }); + } + if args.y_axis_title.is_some() { + chart.y_axis = Some(ChartAxisSpec { + title: args.y_axis_title, + }); + } + if data_labels.is_some() { + chart.data_labels = data_labels; + } + if chart_fill.is_some() { + chart.chart_fill = chart_fill; + } + if plot_area_fill.is_some() { + chart.plot_area_fill = plot_area_fill; + } + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Updated chart `{}`", args.element_id), + snapshot_for_document(document), + )) + } + + fn add_chart_series( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: AddChartSeriesArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let series = parse_chart_series( + document, + vec![ChartSeriesArgs { + name: args.name, + values: args.values, + categories: args.categories, + x_values: args.x_values, + fill: args.fill, + stroke: args.stroke, + marker: args.marker, + data_label_overrides: None, + }], + &request.action, + )?; + let element = document.find_element_mut(&args.element_id, &request.action)?; + let PresentationElement::Chart(chart) = element else { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: format!("element `{}` is not a chart", args.element_id), + }); + }; + chart.series.extend(series); + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Added chart series to `{}`", args.element_id), + snapshot_for_document(document), + )) + } + fn update_text( &mut self, request: PresentationArtifactRequest, @@ -1726,6 +2068,7 @@ impl PresentationArtifactManager { .as_deref() .map(|value| normalize_color_with_document(document, value, &request.action, "fill")) .transpose()?; + let text_layout = normalize_text_layout(&args.text_layout, &request.action)?; let element = document.find_element_mut(&args.element_id, &request.action)?; match element { PresentationElement::Text(text) => { @@ -1734,6 +2077,10 @@ impl PresentationArtifactManager { text.fill = Some(fill); } text.style = style; + text.rich_text = RichTextState { + ranges: Vec::new(), + layout: text_layout, + }; } PresentationElement::Shape(shape) => { if shape.text.is_none() { @@ -1750,6 +2097,10 @@ impl PresentationArtifactManager { shape.fill = Some(fill); } shape.text_style = style; + shape.rich_text = Some(RichTextState { + ranges: Vec::new(), + layout: text_layout, + }); } other => { return Err(PresentationArtifactError::UnsupportedFeature { @@ -1770,6 +2121,228 @@ impl PresentationArtifactManager { )) } + fn set_rich_text( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: SetRichTextArgs = 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, mut rich_text) = + normalize_rich_text_input(document, args.text, &request.action)?; + rich_text.layout = normalize_text_layout(&args.text_layout, &request.action)?; + let style = normalize_text_style_with_document(document, &args.styling, &request.action)?; + if args.notes.unwrap_or(false) { + let slide_index = args.slide_index.ok_or_else(|| PresentationArtifactError::InvalidArgs { + action: request.action.clone(), + message: "`slide_index` is required for notes rich text".to_string(), + })?; + let slide = document.get_slide_mut(slide_index, &request.action)?; + slide.notes.text = text; + slide.notes.rich_text = rich_text; + return Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Updated rich text for notes on slide {slide_index}"), + snapshot_for_document(document), + )); + } + if let Some(element_id) = args.element_id { + if let (Some(row), Some(column)) = (args.row, args.column) { + let element = document.find_element_mut(&element_id, &request.action)?; + let PresentationElement::Table(table) = element else { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: format!("element `{element_id}` is not a table"), + }); + }; + let row = row as usize; + let column = 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 = text; + cell.text_style = style; + cell.rich_text = rich_text; + return Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Updated rich text for table cell ({row}, {column})"), + snapshot_for_document(document), + )); + } + let element = document.find_element_mut(&element_id, &request.action)?; + match element { + PresentationElement::Text(text_element) => { + text_element.text = text; + text_element.style = style; + text_element.rich_text = rich_text; + } + PresentationElement::Shape(shape) => { + if shape.text.is_none() { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: format!( + "element `{element_id}` does not contain editable text" + ), + }); + } + shape.text = Some(text); + shape.text_style = style; + shape.rich_text = Some(rich_text); + } + other => { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: format!( + "element `{element_id}` is `{}`; only text-bearing elements support `set_rich_text`", + other.kind() + ), + }); + } + } + return Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Updated rich text for element `{element_id}`"), + snapshot_for_document(document), + )); + } + Err(PresentationArtifactError::InvalidArgs { + action: request.action, + message: "provide `element_id` or `slide_index` with `notes: true`".to_string(), + }) + } + + fn format_text_range( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: FormatTextRangeArgs = 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 hyperlink = args + .link + .as_ref() + .map(|link| parse_rich_text_link(link, &request.action)) + .transpose()?; + let layout = normalize_text_layout(&args.text_layout, &request.action)?; + let apply_annotation = + |range_id: String, + text: &str, + rich_text: &mut RichTextState| + -> Result<(), PresentationArtifactError> { + let (start_cp, length, _) = resolve_text_range_selector( + text, + args.query.as_deref(), + args.occurrence, + args.start_cp, + args.length, + &request.action, + )?; + rich_text.ranges.push(TextRangeAnnotation { + range_id, + start_cp, + length, + style: style.clone(), + hyperlink: hyperlink.clone(), + spacing_before: args.spacing_before.map(|value| value * 100), + spacing_after: args.spacing_after.map(|value| value * 100), + line_spacing: args.line_spacing, + }); + if layout.insets.is_some() + || layout.wrap.is_some() + || layout.auto_fit.is_some() + || layout.vertical_alignment.is_some() + { + rich_text.layout = layout.clone(); + } + Ok(()) + }; + if args.notes.unwrap_or(false) { + let slide_index = args.slide_index.ok_or_else(|| PresentationArtifactError::InvalidArgs { + action: request.action.clone(), + message: "`slide_index` is required for notes text ranges".to_string(), + })?; + let range_id = document.next_text_range_id(); + let slide = document.get_slide_mut(slide_index, &request.action)?; + apply_annotation(range_id, &slide.notes.text, &mut slide.notes.rich_text)?; + return Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Formatted notes text range on slide {slide_index}"), + snapshot_for_document(document), + )); + } + let element_id = args.element_id.clone().ok_or_else(|| PresentationArtifactError::InvalidArgs { + action: request.action.clone(), + message: "`element_id` is required unless formatting notes".to_string(), + })?; + if let (Some(row), Some(column)) = (args.row, args.column) { + let range_id = document.next_text_range_id(); + let element = document.find_element_mut(&element_id, &request.action)?; + let PresentationElement::Table(table) = element else { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: format!("element `{element_id}` is not a table"), + }); + }; + let row = row as usize; + let column = 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]; + apply_annotation(range_id, &cell.text, &mut cell.rich_text)?; + return Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Formatted table cell text range ({row}, {column})"), + snapshot_for_document(document), + )); + } + let range_id = document.next_text_range_id(); + let element = document.find_element_mut(&element_id, &request.action)?; + match element { + PresentationElement::Text(text) => { + apply_annotation(range_id, &text.text, &mut text.rich_text)?; + } + PresentationElement::Shape(shape) => { + let text_value = shape.text.as_ref().ok_or_else(|| { + PresentationArtifactError::UnsupportedFeature { + action: request.action.clone(), + message: format!("element `{element_id}` does not contain editable text"), + } + })?; + let rich_text = shape.rich_text.get_or_insert_with(RichTextState::default); + apply_annotation(range_id, text_value, rich_text)?; + } + other => { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: format!( + "element `{element_id}` is `{}`; only text-bearing elements support `format_text_range`", + other.kind() + ), + }); + } + } + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Formatted text range on element `{element_id}`"), + snapshot_for_document(document), + )) + } + fn replace_text( &mut self, request: PresentationArtifactRequest, @@ -1934,6 +2507,234 @@ impl PresentationArtifactManager { )) } + fn set_comment_author( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: SetCommentAuthorArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + document.comment_self = Some(CommentAuthorProfile { + display_name: args.display_name, + initials: args.initials, + email: args.email, + }); + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + "Updated comment author".to_string(), + snapshot_for_document(document), + )) + } + + fn add_comment_thread( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: AddCommentThreadArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let author = document.comment_self.clone().ok_or_else(|| { + PresentationArtifactError::InvalidArgs { + action: request.action.clone(), + message: "set a comment author first with `set_comment_author`".to_string(), + } + })?; + let target = if let Some(slide_index) = args.slide_index { + let slide = document.get_slide_mut(slide_index, &request.action)?; + if let Some(element_id) = args.element_id { + if args.query.is_some() || args.start_cp.is_some() { + let (text, _rich_text) = lookup_text_target( + slide, + &element_id, + None, + None, + &request.action, + )?; + let (start_cp, length, context) = resolve_text_range_selector( + text, + args.query.as_deref(), + args.occurrence, + args.start_cp, + args.length, + &request.action, + )?; + CommentTarget::TextRange { + slide_id: slide.slide_id.clone(), + element_id: normalize_element_lookup_id(&element_id).to_string(), + start_cp, + length, + context, + } + } else { + CommentTarget::Element { + slide_id: slide.slide_id.clone(), + element_id: normalize_element_lookup_id(&element_id).to_string(), + } + } + } else { + CommentTarget::Slide { + slide_id: slide.slide_id.clone(), + } + } + } else { + return Err(PresentationArtifactError::InvalidArgs { + action: request.action, + message: "`slide_index` is required for comment threads".to_string(), + }); + }; + let thread_id = document.next_comment_thread_id(); + let message_id = document.next_comment_message_id(); + document.comment_threads.push(CommentThread { + thread_id: thread_id.clone(), + target, + position: args.position.map(|position| CommentPosition { + x: position.x, + y: position.y, + }), + status: CommentThreadStatus::Active, + messages: vec![CommentMessage { + message_id, + author, + text: args.text, + created_at: "2026-03-03T00:00:00Z".to_string(), + reactions: Vec::new(), + }], + }); + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Added comment thread `{thread_id}`"), + snapshot_for_document(document), + )) + } + + fn add_comment_reply( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: AddCommentReplyArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let author = document.comment_self.clone().ok_or_else(|| { + PresentationArtifactError::InvalidArgs { + action: request.action.clone(), + message: "set a comment author first with `set_comment_author`".to_string(), + } + })?; + let message_id = document.next_comment_message_id(); + let thread = document + .comment_threads + .iter_mut() + .find(|thread| thread.thread_id == args.thread_id) + .ok_or_else(|| PresentationArtifactError::InvalidArgs { + action: request.action.clone(), + message: format!("unknown comment thread `{}`", args.thread_id), + })?; + thread.messages.push(CommentMessage { + message_id, + author, + text: args.text, + created_at: "2026-03-03T00:00:00Z".to_string(), + reactions: Vec::new(), + }); + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Added reply to `{}`", args.thread_id), + snapshot_for_document(document), + )) + } + + fn toggle_comment_reaction( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: ToggleCommentReactionArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let thread = document + .comment_threads + .iter_mut() + .find(|thread| thread.thread_id == args.thread_id) + .ok_or_else(|| PresentationArtifactError::InvalidArgs { + action: request.action.clone(), + message: format!("unknown comment thread `{}`", args.thread_id), + })?; + let target_message_id = args + .message_id + .clone() + .or_else(|| thread.messages.last().map(|message| message.message_id.clone())) + .unwrap_or_default(); + let message = thread + .messages + .iter_mut() + .find(|message| message.message_id == target_message_id) + .ok_or_else(|| PresentationArtifactError::InvalidArgs { + action: request.action.clone(), + message: format!("unknown comment message `{target_message_id}`"), + })?; + if let Some(index) = message.reactions.iter().position(|emoji| emoji == &args.emoji) { + message.reactions.remove(index); + } else { + message.reactions.push(args.emoji.clone()); + } + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Toggled reaction on `{}`", args.thread_id), + snapshot_for_document(document), + )) + } + + fn resolve_comment_thread( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: CommentThreadIdArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let thread = document + .comment_threads + .iter_mut() + .find(|thread| thread.thread_id == args.thread_id) + .ok_or_else(|| PresentationArtifactError::InvalidArgs { + action: request.action.clone(), + message: format!("unknown comment thread `{}`", args.thread_id), + })?; + thread.status = CommentThreadStatus::Resolved; + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Resolved `{}`", args.thread_id), + snapshot_for_document(document), + )) + } + + fn reopen_comment_thread( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: CommentThreadIdArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document_mut(&artifact_id, &request.action)?; + let thread = document + .comment_threads + .iter_mut() + .find(|thread| thread.thread_id == args.thread_id) + .ok_or_else(|| PresentationArtifactError::InvalidArgs { + action: request.action.clone(), + message: format!("unknown comment thread `{}`", args.thread_id), + })?; + thread.status = CommentThreadStatus::Active; + Ok(PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Reopened `{}`", args.thread_id), + snapshot_for_document(document), + )) + } + fn update_shape_style( &mut self, request: PresentationArtifactRequest, @@ -1951,6 +2752,11 @@ impl PresentationArtifactManager { .clone() .map(|value| parse_required_stroke(document, value, &request.action)) .transpose()?; + let text_layout = normalize_text_layout(&args.text_layout, &request.action)?; + let has_text_layout = text_layout.insets.is_some() + || text_layout.wrap.is_some() + || text_layout.auto_fit.is_some() + || text_layout.vertical_alignment.is_some(); let element = document.find_element_mut(&args.element_id, &request.action)?; match element { PresentationElement::Text(text) => { @@ -1960,6 +2766,9 @@ impl PresentationArtifactManager { if let Some(fill) = fill.clone() { text.fill = Some(fill); } + if has_text_layout { + text.rich_text.layout = text_layout; + } if args.stroke.is_some() || args.rotation.is_some() || args.flip_horizontal.is_some() @@ -2004,6 +2813,12 @@ impl PresentationArtifactManager { if let Some(flip_vertical) = args.flip_vertical.or(position_flip_vertical) { shape.flip_vertical = flip_vertical; } + if shape.text.is_some() && has_text_layout { + shape.rich_text = Some(RichTextState { + ranges: shape.rich_text.take().unwrap_or_default().ranges, + layout: text_layout, + }); + } } PresentationElement::Connector(connector) => { if args.fill.is_some() @@ -2348,3 +3163,41 @@ impl PresentationArtifactManager { Ok(patch) } } + +fn lookup_text_target<'a>( + slide: &'a PresentationSlide, + element_id: &str, + _row: Option, + _column: Option, + action: &str, +) -> Result<(&'a str, Option<&'a RichTextState>), PresentationArtifactError> { + let normalized_element_id = normalize_element_lookup_id(element_id); + let element = slide + .elements + .iter() + .find(|element| element.element_id() == normalized_element_id) + .ok_or_else(|| PresentationArtifactError::UnsupportedFeature { + action: action.to_string(), + message: format!("unknown element `{element_id}` on slide `{}`", slide.slide_id), + })?; + match element { + PresentationElement::Text(text) => Ok((&text.text, Some(&text.rich_text))), + PresentationElement::Shape(shape) => shape + .text + .as_deref() + .map(|text| (text, shape.rich_text.as_ref())) + .ok_or_else(|| PresentationArtifactError::UnsupportedFeature { + action: action.to_string(), + message: format!("element `{element_id}` does not contain editable text"), + }), + PresentationElement::Connector(_) + | PresentationElement::Image(_) + | PresentationElement::Table(_) + | PresentationElement::Chart(_) => Err(PresentationArtifactError::UnsupportedFeature { + action: action.to_string(), + message: format!( + "element `{element_id}` does not support text-range comments" + ), + }), + } +} diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/model.rs b/codex-rs/artifact-presentation/src/presentation_artifact/model.rs index 80a8566fa..b5cd5d00a 100644 --- a/codex-rs/artifact-presentation/src/presentation_artifact/model.rs +++ b/codex-rs/artifact-presentation/src/presentation_artifact/model.rs @@ -1,4 +1,4 @@ -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] struct ThemeState { color_scheme: HashMap, major_font: Option, @@ -27,13 +27,13 @@ impl ThemeState { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] enum LayoutKind { Layout, Master, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] struct LayoutDocument { layout_id: String, name: String, @@ -42,7 +42,7 @@ struct LayoutDocument { placeholders: Vec, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] struct PlaceholderDefinition { name: String, placeholder_type: String, @@ -52,19 +52,21 @@ struct PlaceholderDefinition { frame: Rect, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] struct ResolvedPlaceholder { source_layout_id: String, definition: PlaceholderDefinition, } -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] struct NotesState { text: String, visible: bool, + #[serde(default)] + rich_text: RichTextState, } -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] struct TextStyle { style_name: Option, font_size: Option, @@ -76,21 +78,21 @@ struct TextStyle { underline: bool, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] struct NamedTextStyle { name: String, style: TextStyle, built_in: bool, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] struct HyperlinkState { target: HyperlinkTarget, tooltip: Option, highlight_click: bool, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] enum HyperlinkTarget { Url(String), Slide(u32), @@ -217,7 +219,7 @@ impl HyperlinkState { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] enum TextAlignment { Left, @@ -226,14 +228,188 @@ enum TextAlignment { Justify, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct RichTextState { + #[serde(default)] + ranges: Vec, + #[serde(default)] + layout: TextLayoutState, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct TextRangeAnnotation { + range_id: String, + start_cp: usize, + length: usize, + style: TextStyle, + hyperlink: Option, + spacing_before: Option, + spacing_after: Option, + line_spacing: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct TextLayoutState { + insets: Option, + wrap: Option, + auto_fit: Option, + vertical_alignment: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +struct TextInsets { + left: u32, + right: u32, + top: u32, + bottom: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +enum TextWrapMode { + Square, + None, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +enum TextAutoFitMode { + None, + ShrinkText, + ResizeShapeToFitText, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +enum TextVerticalAlignment { + Top, + Middle, + Bottom, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CommentAuthorProfile { + display_name: String, + initials: String, + email: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CommentPosition { + x: u32, + y: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +enum CommentThreadStatus { + Active, + Resolved, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CommentMessage { + message_id: String, + author: CommentAuthorProfile, + text: String, + created_at: String, + #[serde(default)] + reactions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +enum CommentTarget { + Slide { + slide_id: String, + }, + Element { + slide_id: String, + element_id: String, + }, + TextRange { + slide_id: String, + element_id: String, + start_cp: usize, + length: usize, + context: Option, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CommentThread { + thread_id: String, + target: CommentTarget, + position: Option, + status: CommentThreadStatus, + messages: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct TableBorder { + color: String, + width: u32, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct TableBorders { + outside: Option, + inside: Option, + top: Option, + bottom: Option, + left: Option, + right: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct TableStyleOptions { + header_row: bool, + banded_rows: bool, + banded_columns: bool, + first_column: bool, + last_column: bool, + total_row: bool, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct ChartMarkerStyle { + symbol: Option, + size: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct ChartDataLabels { + show_value: bool, + show_category_name: bool, + show_leader_lines: bool, + position: Option, + text_style: TextStyle, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct ChartLegend { + position: Option, + text_style: TextStyle, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct ChartAxisSpec { + title: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct ChartDataLabelOverride { + idx: usize, + text: Option, + position: Option, + text_style: TextStyle, + fill: Option, + stroke: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] pub(crate) struct PlaceholderRef { name: String, placeholder_type: String, index: Option, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] struct TableMergeRegion { start_row: usize, end_row: usize, @@ -241,15 +417,18 @@ struct TableMergeRegion { end_column: usize, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] struct TableCellSpec { text: String, text_style: TextStyle, background_fill: Option, alignment: Option, + #[serde(default)] + rich_text: RichTextState, + borders: Option, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] struct PresentationDocument { artifact_id: String, name: Option, @@ -259,9 +438,16 @@ struct PresentationDocument { layouts: Vec, slides: Vec, active_slide_index: Option, + #[serde(default)] + comment_self: Option, + #[serde(default)] + comment_threads: Vec, next_slide_seq: u32, next_element_seq: u32, next_layout_seq: u32, + next_text_range_seq: u32, + next_comment_thread_seq: u32, + next_comment_message_seq: u32, } impl PresentationDocument { @@ -280,9 +466,14 @@ impl PresentationDocument { layouts: Vec::new(), slides: Vec::new(), active_slide_index: None, + comment_self: None, + comment_threads: Vec::new(), next_slide_seq: 1, next_element_seq: 1, next_layout_seq: 1, + next_text_range_seq: 1, + next_comment_thread_seq: 1, + next_comment_message_seq: 1, } } @@ -296,6 +487,7 @@ impl PresentationDocument { notes: NotesState { text: imported_slide.notes.clone().unwrap_or_default(), visible: true, + rich_text: RichTextState::default(), }, background_fill: None, layout_id: None, @@ -316,6 +508,7 @@ impl PresentationDocument { fill: None, style: TextStyle::default(), hyperlink: None, + rich_text: RichTextState::default(), placeholder: None, z_order: slide.elements.len(), })); @@ -334,6 +527,7 @@ impl PresentationDocument { fill: None, style: TextStyle::default(), hyperlink: None, + rich_text: RichTextState::default(), placeholder: None, z_order: slide.elements.len(), })); @@ -360,6 +554,10 @@ impl PresentationDocument { text: imported_shape.text.clone(), text_style: TextStyle::default(), hyperlink: None, + rich_text: imported_shape + .text + .as_ref() + .map(|_| RichTextState::default()), placeholder: None, rotation_degrees: imported_shape.rotation, flip_horizontal: false, @@ -390,6 +588,8 @@ impl PresentationDocument { text_style: TextStyle::default(), background_fill: None, alignment: None, + rich_text: RichTextState::default(), + borders: None, }) .collect() }) @@ -407,6 +607,9 @@ impl PresentationDocument { .map(emu_to_points) .collect(), style: None, + style_options: TableStyleOptions::default(), + borders: None, + right_to_left: false, merges: Vec::new(), z_order: slide.elements.len(), })); @@ -434,6 +637,7 @@ impl PresentationDocument { notes: NotesState { text: notes.unwrap_or_default(), visible: true, + rich_text: RichTextState::default(), }, background_fill: normalized_fill, layout_id: None, @@ -468,6 +672,24 @@ impl PresentationDocument { element_id } + fn next_text_range_id(&mut self) -> String { + let range_id = format!("range_{}", self.next_text_range_seq); + self.next_text_range_seq += 1; + range_id + } + + fn next_comment_thread_id(&mut self) -> String { + let thread_id = format!("thread_{}", self.next_comment_thread_seq); + self.next_comment_thread_seq += 1; + thread_id + } + + fn next_comment_message_id(&mut self) -> String { + let message_id = format!("message_{}", self.next_comment_message_seq); + self.next_comment_message_seq += 1; + message_id + } + fn total_element_count(&self) -> usize { self.slides.iter().map(|slide| slide.elements.len()).sum() } @@ -717,7 +939,7 @@ impl PresentationDocument { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] struct PresentationSlide { slide_id: String, notes: NotesState, @@ -1190,7 +1412,7 @@ impl PresentationSlide { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] enum PresentationElement { Text(TextElement), Shape(ShapeElement), @@ -1257,7 +1479,7 @@ impl PresentationElement { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] struct TextElement { element_id: String, text: String, @@ -1265,11 +1487,13 @@ struct TextElement { fill: Option, style: TextStyle, hyperlink: Option, + #[serde(default)] + rich_text: RichTextState, placeholder: Option, z_order: usize, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] struct ShapeElement { element_id: String, geometry: ShapeGeometry, @@ -1279,6 +1503,7 @@ struct ShapeElement { text: Option, text_style: TextStyle, hyperlink: Option, + rich_text: Option, placeholder: Option, rotation_degrees: Option, flip_horizontal: bool, @@ -1286,7 +1511,7 @@ struct ShapeElement { z_order: usize, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] struct ConnectorElement { element_id: String, connector_type: ConnectorKind, @@ -1301,7 +1526,7 @@ struct ConnectorElement { z_order: usize, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub(crate) struct ImageElement { pub(crate) element_id: String, pub(crate) frame: Rect, @@ -1319,7 +1544,7 @@ pub(crate) struct ImageElement { pub(crate) z_order: usize, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] struct TableElement { element_id: String, frame: Rect, @@ -1327,11 +1552,14 @@ struct TableElement { column_widths: Vec, row_heights: Vec, style: Option, + style_options: TableStyleOptions, + borders: Option, + right_to_left: bool, merges: Vec, z_order: usize, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] struct ChartElement { element_id: String, frame: Rect, @@ -1339,10 +1567,18 @@ struct ChartElement { categories: Vec, series: Vec, title: Option, + style_index: Option, + has_legend: bool, + legend: Option, + x_axis: Option, + y_axis: Option, + data_labels: Option, + chart_fill: Option, + plot_area_fill: Option, z_order: usize, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub(crate) struct ImagePayload { pub(crate) bytes: Vec, pub(crate) format: String, @@ -1350,13 +1586,20 @@ pub(crate) struct ImagePayload { pub(crate) height_px: u32, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] struct ChartSeriesSpec { name: String, values: Vec, + categories: Option>, + x_values: Option>, + fill: Option, + stroke: Option, + marker: Option, + #[serde(default)] + data_label_overrides: Vec, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] enum ShapeGeometry { Rectangle, RoundedRectangle, @@ -1456,7 +1699,7 @@ impl ShapeGeometry { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] enum ChartTypeSpec { Bar, BarHorizontal, @@ -1580,7 +1823,7 @@ impl LineStyle { } } -#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub(crate) enum ImageFitMode { Stretch, @@ -1588,21 +1831,21 @@ pub(crate) enum ImageFitMode { Cover, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] struct StrokeStyle { color: String, width: u32, style: LineStyle, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] enum ConnectorKind { Straight, Elbow, Curved, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] enum ConnectorArrowKind { None, Triangle, @@ -1612,14 +1855,14 @@ enum ConnectorArrowKind { Open, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] enum ConnectorArrowScale { Small, Medium, Large, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] enum LineStyle { Solid, Dashed, @@ -1630,7 +1873,7 @@ enum LineStyle { LongDashDot, } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] pub(crate) struct Rect { pub(crate) left: u32, pub(crate) top: u32, diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/parsing.rs b/codex-rs/artifact-presentation/src/presentation_artifact/parsing.rs index a5bc361e3..efca31a99 100644 --- a/codex-rs/artifact-presentation/src/presentation_artifact/parsing.rs +++ b/codex-rs/artifact-presentation/src/presentation_artifact/parsing.rs @@ -169,6 +169,134 @@ fn parse_chart_type( } } +fn parse_chart_marker(marker: Option) -> ChartMarkerStyle { + marker + .map(|marker| ChartMarkerStyle { + symbol: marker.symbol, + size: marker.size, + }) + .unwrap_or_default() +} + +fn parse_chart_data_labels( + document: &PresentationDocument, + data_labels: Option, + action: &str, +) -> Result, PresentationArtifactError> { + data_labels + .map(|data_labels| { + Ok(ChartDataLabels { + show_value: data_labels.show_value.unwrap_or(false), + show_category_name: data_labels.show_category_name.unwrap_or(false), + show_leader_lines: data_labels.show_leader_lines.unwrap_or(false), + position: data_labels.position, + text_style: normalize_text_style_with_document( + document, + &data_labels.text_style, + action, + )?, + }) + }) + .transpose() +} + +fn parse_chart_series( + document: &PresentationDocument, + series: Vec, + action: &str, +) -> Result, PresentationArtifactError> { + series + .into_iter() + .map(|entry| { + if entry.values.is_empty() { + return Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("series `{}` must contain at least one value", entry.name), + }); + } + let fill = entry + .fill + .as_deref() + .map(|value| normalize_color_with_document(document, value, action, "series.fill")) + .transpose()?; + let stroke = entry + .stroke + .map(|stroke| { + Ok(StrokeStyle { + color: normalize_color_with_document( + document, + &stroke.color, + action, + "series.stroke.color", + )?, + width: stroke.width, + style: stroke + .style + .as_deref() + .map(|value| parse_line_style(value, action)) + .transpose()? + .unwrap_or(LineStyle::Solid), + }) + }) + .transpose()?; + let data_label_overrides = entry + .data_label_overrides + .unwrap_or_default() + .into_iter() + .map(|override_args| { + Ok(ChartDataLabelOverride { + idx: override_args.idx as usize, + text: override_args.text, + position: override_args.position, + text_style: normalize_text_style_with_document( + document, + &override_args.text_style, + action, + )?, + fill: override_args + .fill + .as_deref() + .map(|value| { + normalize_color_with_document(document, value, action, "fill") + }) + .transpose()?, + stroke: override_args + .stroke + .map(|stroke| { + Ok(StrokeStyle { + color: normalize_color_with_document( + document, + &stroke.color, + action, + "stroke.color", + )?, + width: stroke.width, + style: stroke + .style + .as_deref() + .map(|value| parse_line_style(value, action)) + .transpose()? + .unwrap_or(LineStyle::Solid), + }) + }) + .transpose()?, + }) + }) + .collect::, _>>()?; + Ok(ChartSeriesSpec { + name: entry.name, + values: entry.values, + categories: entry.categories, + x_values: entry.x_values, + fill, + stroke, + marker: Some(parse_chart_marker(entry.marker)), + data_label_overrides, + }) + }) + .collect() +} + fn parse_stroke( document: &PresentationDocument, stroke: Option, @@ -299,6 +427,216 @@ fn normalize_text_style_with_document( }) } +fn normalize_text_layout( + layout: &TextLayoutArgs, + action: &str, +) -> Result { + let wrap = layout + .wrap + .as_deref() + .map(|value| match value { + "square" => Ok(TextWrapMode::Square), + "none" => Ok(TextWrapMode::None), + _ => Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("unsupported wrap `{value}`"), + }), + }) + .transpose()?; + let auto_fit = layout + .auto_fit + .as_deref() + .map(|value| match value { + "none" => Ok(TextAutoFitMode::None), + "shrinkText" | "shrink_text" => Ok(TextAutoFitMode::ShrinkText), + "resizeShapeToFitText" | "resize_shape_to_fit_text" => { + Ok(TextAutoFitMode::ResizeShapeToFitText) + } + _ => Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("unsupported auto_fit `{value}`"), + }), + }) + .transpose()?; + let vertical_alignment = layout + .vertical_alignment + .as_deref() + .map(|value| match value { + "top" => Ok(TextVerticalAlignment::Top), + "middle" | "center" => Ok(TextVerticalAlignment::Middle), + "bottom" => Ok(TextVerticalAlignment::Bottom), + _ => Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("unsupported vertical_alignment `{value}`"), + }), + }) + .transpose()?; + Ok(TextLayoutState { + insets: layout.insets.as_ref().map(|insets| TextInsets { + left: insets.left, + right: insets.right, + top: insets.top, + bottom: insets.bottom, + }), + wrap, + auto_fit, + vertical_alignment, + }) +} + +fn normalize_rich_text_input( + document: &PresentationDocument, + input: RichTextInput, + action: &str, +) -> Result<(String, RichTextState), PresentationArtifactError> { + let mut text = String::new(); + let mut ranges = Vec::new(); + let paragraphs = match input { + RichTextInput::Plain(value) => vec![RichParagraphInput::Plain(value)], + RichTextInput::Paragraphs(paragraphs) => paragraphs, + }; + for (paragraph_index, paragraph) in paragraphs.into_iter().enumerate() { + if paragraph_index > 0 { + text.push('\n'); + } + let paragraph_start = text.chars().count(); + match paragraph { + RichParagraphInput::Plain(value) => text.push_str(&value), + RichParagraphInput::Runs(runs) => { + for run in runs { + match run { + RichRunInput::Plain(value) => text.push_str(&value), + RichRunInput::Styled(value) => { + let start_cp = text.chars().count(); + text.push_str(&value.run); + let length = value.run.chars().count(); + let style = normalize_text_style_with_document( + document, + &value.text_style, + action, + )?; + let hyperlink = value + .link + .as_ref() + .map(|link| parse_rich_text_link(link, action)) + .transpose()?; + if length > 0 + && (!text_style_is_empty(&style) || hyperlink.is_some()) + { + ranges.push(TextRangeAnnotation { + range_id: format!("inline_{paragraph_index}_{start_cp}"), + start_cp, + length, + style, + hyperlink, + spacing_before: None, + spacing_after: None, + line_spacing: None, + }); + } + } + } + } + } + } + let paragraph_len = text.chars().count() - paragraph_start; + if paragraph_len == 0 { + continue; + } + } + Ok(( + text, + RichTextState { + ranges, + layout: TextLayoutState::default(), + }, + )) +} + +fn parse_rich_text_link( + link: &RichTextLinkInput, + action: &str, +) -> Result { + let uri = link + .uri + .as_ref() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: "`link.uri` is required".to_string(), + })?; + let target = if link.is_external.unwrap_or(true) { + HyperlinkTarget::Url(uri.clone()) + } else { + HyperlinkTarget::File(uri.clone()) + }; + Ok(HyperlinkState { + target, + tooltip: None, + highlight_click: true, + }) +} + +fn text_style_is_empty(style: &TextStyle) -> bool { + style.style_name.is_none() + && style.font_size.is_none() + && style.font_family.is_none() + && style.color.is_none() + && style.alignment.is_none() + && !style.bold + && !style.italic + && !style.underline +} + +fn resolve_text_range_selector( + text: &str, + query: Option<&str>, + occurrence: Option, + start_cp: Option, + length: Option, + action: &str, +) -> Result<(usize, usize, Option), PresentationArtifactError> { + if let Some(query) = query { + let occurrence = occurrence.unwrap_or(0); + let haystack = text.chars().collect::>(); + let needle = query.chars().collect::>(); + if needle.is_empty() { + return Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: "`query` must not be empty".to_string(), + }); + } + let mut matches = Vec::new(); + for start in 0..=haystack.len().saturating_sub(needle.len()) { + if haystack[start..start + needle.len()] == needle[..] { + matches.push(start); + } + } + let Some(found) = matches.get(occurrence).copied() else { + return Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("query `{query}` occurrence {occurrence} was not found"), + }); + }; + return Ok((found, needle.len(), Some(query.to_string()))); + } + let start_cp = start_cp.ok_or_else(|| PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: "provide either `query` or `start_cp`".to_string(), + })?; + let length = length.ok_or_else(|| PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: "`length` is required with `start_cp`".to_string(), + })?; + if start_cp + length > text.chars().count() { + return Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: "text range is out of bounds".to_string(), + }); + } + Ok((start_cp, length, None)) +} + fn normalize_text_style_with_palette( theme: Option<&ThemeState>, styling: &TextStylingArgs, @@ -423,12 +761,82 @@ fn coerce_table_rows( text_style: TextStyle::default(), background_fill: None, alignment: None, + rich_text: RichTextState::default(), + borders: None, }) .collect() }) .collect()) } +fn parse_table_border( + document: &PresentationDocument, + border: &TableBorderArgs, + action: &str, + field: &str, +) -> Result { + Ok(TableBorder { + color: normalize_color_with_document(document, &border.color, action, field)?, + width: border.width, + }) +} + +fn parse_table_borders( + document: &PresentationDocument, + borders: Option, + action: &str, +) -> Result, PresentationArtifactError> { + borders + .map(|borders| { + Ok(TableBorders { + outside: borders + .outside + .as_ref() + .map(|border| parse_table_border(document, border, action, "borders.outside")) + .transpose()?, + inside: borders + .inside + .as_ref() + .map(|border| parse_table_border(document, border, action, "borders.inside")) + .transpose()?, + top: borders + .top + .as_ref() + .map(|border| parse_table_border(document, border, action, "borders.top")) + .transpose()?, + bottom: borders + .bottom + .as_ref() + .map(|border| parse_table_border(document, border, action, "borders.bottom")) + .transpose()?, + left: borders + .left + .as_ref() + .map(|border| parse_table_border(document, border, action, "borders.left")) + .transpose()?, + right: borders + .right + .as_ref() + .map(|border| parse_table_border(document, border, action, "borders.right")) + .transpose()?, + }) + }) + .transpose() +} + +fn parse_table_style_options(style_options: Option) -> TableStyleOptions { + style_options + .map(|style_options| TableStyleOptions { + header_row: style_options.header_row.unwrap_or(false), + banded_rows: style_options.banded_rows.unwrap_or(false), + banded_columns: style_options.banded_columns.unwrap_or(false), + first_column: style_options.first_column.unwrap_or(false), + last_column: style_options.last_column.unwrap_or(false), + total_row: style_options.total_row.unwrap_or(false), + }) + .unwrap_or_default() +} + fn normalize_table_dimensions( rows: &[Vec], frame: Rect, @@ -685,6 +1093,7 @@ fn materialize_placeholder_element( fill: None, style: TextStyle::default(), hyperlink: None, + rich_text: RichTextState::default(), placeholder: placeholder_ref, z_order, }) @@ -698,6 +1107,7 @@ fn materialize_placeholder_element( text: placeholder.text, text_style: TextStyle::default(), hyperlink: None, + rich_text: None, placeholder: placeholder_ref, rotation_degrees: None, flip_horizontal: false, @@ -861,4 +1271,3 @@ fn slide_placeholder_list( }) .collect() } - diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/pptx.rs b/codex-rs/artifact-presentation/src/presentation_artifact/pptx.rs index 1343ee84f..a06abc701 100644 --- a/codex-rs/artifact-presentation/src/presentation_artifact/pptx.rs +++ b/codex-rs/artifact-presentation/src/presentation_artifact/pptx.rs @@ -1,3 +1,21 @@ +const CODEX_METADATA_ENTRY: &str = "ppt/codex-document.json"; + +fn import_codex_metadata_document(path: &Path) -> Result, String> { + let file = std::fs::File::open(path).map_err(|error| error.to_string())?; + let mut archive = ZipArchive::new(file).map_err(|error| error.to_string())?; + let mut entry = match archive.by_name(CODEX_METADATA_ENTRY) { + Ok(entry) => entry, + Err(zip::result::ZipError::FileNotFound) => return Ok(None), + Err(error) => return Err(error.to_string()), + }; + let mut bytes = Vec::new(); + entry.read_to_end(&mut bytes) + .map_err(|error| error.to_string())?; + serde_json::from_slice(&bytes) + .map(Some) + .map_err(|error| error.to_string()) +} + fn build_pptx_bytes(document: &PresentationDocument, action: &str) -> Result, String> { let bytes = document .to_ppt_rs() @@ -211,6 +229,9 @@ fn patch_pptx_package( continue; } let name = file.name().to_string(); + if name == CODEX_METADATA_ENTRY { + continue; + } let options = file.options(); let mut bytes = Vec::new(); file.read_to_end(&mut bytes) @@ -282,6 +303,15 @@ fn patch_pptx_package( .map_err(|error| error.to_string())?; } + writer + .start_file(CODEX_METADATA_ENTRY, SimpleFileOptions::default()) + .map_err(|error| error.to_string())?; + writer + .write_all( + &serde_json::to_vec(document).map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string())?; + writer .finish() .map_err(|error| error.to_string()) @@ -919,4 +949,3 @@ fn normalize_preview_quality( } Ok(quality) } - diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/proto.rs b/codex-rs/artifact-presentation/src/presentation_artifact/proto.rs index 23b6f7e2e..05741303a 100644 --- a/codex-rs/artifact-presentation/src/presentation_artifact/proto.rs +++ b/codex-rs/artifact-presentation/src/presentation_artifact/proto.rs @@ -35,6 +35,12 @@ fn document_to_proto( "masters": document.layouts.iter().filter(|layout| layout.kind == LayoutKind::Master).map(|layout| layout.layout_id.clone()).collect::>(), "layouts": layouts, "slides": slides, + "commentAuthor": document.comment_self.as_ref().map(comment_author_to_proto), + "commentThreads": document + .comment_threads + .iter() + .map(comment_thread_to_proto) + .collect::>(), })) } @@ -95,6 +101,7 @@ fn slide_to_proto(slide: &PresentationSlide, slide_index: usize) -> Value { "textPreview": slide.notes.text.replace('\n', " | "), "textChars": slide.notes.text.chars().count(), "textLines": slide.notes.text.lines().count(), + "richText": rich_text_to_proto(&slide.notes.text, &slide.notes.rich_text), }), "elements": slide.elements.iter().map(element_to_proto).collect::>(), }) @@ -114,6 +121,7 @@ fn element_to_proto(element: &PresentationElement) -> Value { "textLines": text.text.lines().count(), "fill": text.fill, "style": text_style_to_proto(&text.style), + "richText": rich_text_to_proto(&text.text, &text.rich_text), "zOrder": text.z_order, }); if let Some(placeholder) = &text.placeholder { @@ -135,6 +143,12 @@ fn element_to_proto(element: &PresentationElement) -> Value { "stroke": shape.stroke.as_ref().map(stroke_to_proto), "text": shape.text, "textStyle": text_style_to_proto(&shape.text_style), + "richText": shape + .text + .as_ref() + .zip(shape.rich_text.as_ref()) + .map(|(text, rich_text)| rich_text_to_proto(text, rich_text)) + .unwrap_or(Value::Null), "rotation": shape.rotation_degrees, "flipHorizontal": shape.flip_horizontal, "flipVertical": shape.flip_vertical, @@ -215,6 +229,9 @@ fn element_to_proto(element: &PresentationElement) -> Value { "columnWidths": table.column_widths, "rowHeights": table.row_heights, "style": table.style, + "styleOptions": table_style_options_to_proto(&table.style_options), + "borders": table.borders.as_ref().map(table_borders_to_proto), + "rightToLeft": table.right_to_left, "merges": table.merges.iter().map(|merge| serde_json::json!({ "startRow": merge.start_row, "endRow": merge.end_row, @@ -231,9 +248,27 @@ fn element_to_proto(element: &PresentationElement) -> Value { "chartType": format!("{:?}", chart.chart_type), "title": chart.title, "categories": chart.categories, + "styleIndex": chart.style_index, + "hasLegend": chart.has_legend, + "legend": chart.legend.as_ref().map(chart_legend_to_proto), + "xAxis": chart.x_axis.as_ref().map(chart_axis_to_proto), + "yAxis": chart.y_axis.as_ref().map(chart_axis_to_proto), + "dataLabels": chart.data_labels.as_ref().map(chart_data_labels_to_proto), + "chartFill": chart.chart_fill, + "plotAreaFill": chart.plot_area_fill, "series": chart.series.iter().map(|series| serde_json::json!({ "name": series.name, "values": series.values, + "categories": series.categories, + "xValues": series.x_values, + "fill": series.fill, + "stroke": series.stroke.as_ref().map(stroke_to_proto), + "marker": series.marker.as_ref().map(chart_marker_to_proto), + "dataLabelOverrides": series + .data_label_overrides + .iter() + .map(chart_data_label_override_to_proto) + .collect::>(), })).collect::>(), "zOrder": chart.z_order, }), @@ -272,6 +307,72 @@ fn text_style_to_proto(style: &TextStyle) -> Value { }) } +fn rich_text_to_proto(text: &str, rich_text: &RichTextState) -> Value { + serde_json::json!({ + "layout": text_layout_to_proto(&rich_text.layout), + "ranges": rich_text + .ranges + .iter() + .map(|range| text_range_to_proto(text, range)) + .collect::>(), + }) +} + +fn text_range_to_proto(text: &str, range: &TextRangeAnnotation) -> Value { + serde_json::json!({ + "rangeId": range.range_id, + "anchor": format!("tr/{}", range.range_id), + "startCp": range.start_cp, + "length": range.length, + "text": text_slice_by_codepoint_range(text, range.start_cp, range.length), + "style": text_style_to_proto(&range.style), + "hyperlink": range.hyperlink.as_ref().map(HyperlinkState::to_json), + "spacingBefore": range.spacing_before, + "spacingAfter": range.spacing_after, + "lineSpacing": range.line_spacing, + }) +} + +fn text_layout_to_proto(layout: &TextLayoutState) -> Value { + serde_json::json!({ + "insets": layout.insets.map(|insets| serde_json::json!({ + "left": insets.left, + "right": insets.right, + "top": insets.top, + "bottom": insets.bottom, + "unit": "points", + })), + "wrap": layout.wrap.map(text_wrap_mode_to_proto), + "autoFit": layout.auto_fit.map(text_auto_fit_mode_to_proto), + "verticalAlignment": layout + .vertical_alignment + .map(text_vertical_alignment_to_proto), + }) +} + +fn text_wrap_mode_to_proto(mode: TextWrapMode) -> &'static str { + match mode { + TextWrapMode::Square => "square", + TextWrapMode::None => "none", + } +} + +fn text_auto_fit_mode_to_proto(mode: TextAutoFitMode) -> &'static str { + match mode { + TextAutoFitMode::None => "none", + TextAutoFitMode::ShrinkText => "shrinkText", + TextAutoFitMode::ResizeShapeToFitText => "resizeShapeToFitText", + } +} + +fn text_vertical_alignment_to_proto(alignment: TextVerticalAlignment) -> &'static str { + match alignment { + TextVerticalAlignment::Top => "top", + TextVerticalAlignment::Middle => "middle", + TextVerticalAlignment::Bottom => "bottom", + } +} + fn placeholder_ref_to_proto(placeholder: &PlaceholderRef) -> Value { serde_json::json!({ "name": placeholder.name, @@ -293,11 +394,169 @@ fn table_cell_to_proto(cell: &TableCellSpec) -> Value { serde_json::json!({ "text": cell.text, "textStyle": text_style_to_proto(&cell.text_style), + "richText": rich_text_to_proto(&cell.text, &cell.rich_text), "backgroundFill": cell.background_fill, "alignment": cell.alignment, + "borders": cell.borders.as_ref().map(table_borders_to_proto), }) } +fn table_style_options_to_proto(style_options: &TableStyleOptions) -> Value { + serde_json::json!({ + "headerRow": style_options.header_row, + "bandedRows": style_options.banded_rows, + "bandedColumns": style_options.banded_columns, + "firstColumn": style_options.first_column, + "lastColumn": style_options.last_column, + "totalRow": style_options.total_row, + }) +} + +fn table_borders_to_proto(borders: &TableBorders) -> Value { + serde_json::json!({ + "outside": borders.outside.as_ref().map(table_border_to_proto), + "inside": borders.inside.as_ref().map(table_border_to_proto), + "top": borders.top.as_ref().map(table_border_to_proto), + "bottom": borders.bottom.as_ref().map(table_border_to_proto), + "left": borders.left.as_ref().map(table_border_to_proto), + "right": borders.right.as_ref().map(table_border_to_proto), + }) +} + +fn table_border_to_proto(border: &TableBorder) -> Value { + serde_json::json!({ + "color": border.color, + "width": border.width, + "unit": "points", + }) +} + +fn chart_marker_to_proto(marker: &ChartMarkerStyle) -> Value { + serde_json::json!({ + "symbol": marker.symbol, + "size": marker.size, + }) +} + +fn chart_data_labels_to_proto(data_labels: &ChartDataLabels) -> Value { + serde_json::json!({ + "showValue": data_labels.show_value, + "showCategoryName": data_labels.show_category_name, + "showLeaderLines": data_labels.show_leader_lines, + "position": data_labels.position, + "textStyle": text_style_to_proto(&data_labels.text_style), + }) +} + +fn chart_legend_to_proto(legend: &ChartLegend) -> Value { + serde_json::json!({ + "position": legend.position, + "textStyle": text_style_to_proto(&legend.text_style), + }) +} + +fn chart_axis_to_proto(axis: &ChartAxisSpec) -> Value { + serde_json::json!({ + "title": axis.title, + }) +} + +fn chart_data_label_override_to_proto(override_spec: &ChartDataLabelOverride) -> Value { + serde_json::json!({ + "idx": override_spec.idx, + "text": override_spec.text, + "position": override_spec.position, + "textStyle": text_style_to_proto(&override_spec.text_style), + "fill": override_spec.fill, + "stroke": override_spec.stroke.as_ref().map(stroke_to_proto), + }) +} + +fn comment_author_to_proto(author: &CommentAuthorProfile) -> Value { + serde_json::json!({ + "displayName": author.display_name, + "initials": author.initials, + "email": author.email, + }) +} + +fn comment_thread_to_proto(thread: &CommentThread) -> Value { + serde_json::json!({ + "kind": "comment", + "threadId": thread.thread_id, + "anchor": format!("th/{}", thread.thread_id), + "target": comment_target_to_proto(&thread.target), + "position": thread.position.as_ref().map(comment_position_to_proto), + "status": comment_status_to_proto(thread.status), + "messages": thread.messages.iter().map(comment_message_to_proto).collect::>(), + }) +} + +fn comment_target_to_proto(target: &CommentTarget) -> Value { + match target { + CommentTarget::Slide { slide_id } => serde_json::json!({ + "type": "slide", + "slideId": slide_id, + "slideAnchor": format!("sl/{slide_id}"), + }), + CommentTarget::Element { + slide_id, + element_id, + } => serde_json::json!({ + "type": "element", + "slideId": slide_id, + "slideAnchor": format!("sl/{slide_id}"), + "elementId": element_id, + "elementAnchor": format!("sh/{element_id}"), + }), + CommentTarget::TextRange { + slide_id, + element_id, + start_cp, + length, + context, + } => serde_json::json!({ + "type": "textRange", + "slideId": slide_id, + "slideAnchor": format!("sl/{slide_id}"), + "elementId": element_id, + "elementAnchor": format!("sh/{element_id}"), + "startCp": start_cp, + "length": length, + "context": context, + }), + } +} + +fn comment_position_to_proto(position: &CommentPosition) -> Value { + serde_json::json!({ + "x": position.x, + "y": position.y, + "unit": "points", + }) +} + +fn comment_message_to_proto(message: &CommentMessage) -> Value { + serde_json::json!({ + "messageId": message.message_id, + "author": comment_author_to_proto(&message.author), + "text": message.text, + "createdAt": message.created_at, + "reactions": message.reactions, + }) +} + +fn comment_status_to_proto(status: CommentThreadStatus) -> &'static str { + match status { + CommentThreadStatus::Active => "active", + CommentThreadStatus::Resolved => "resolved", + } +} + +fn text_slice_by_codepoint_range(text: &str, start_cp: usize, length: usize) -> String { + text.chars().skip(start_cp).take(length).collect() +} + fn build_table_cell( cell: TableCellSpec, merges: &[TableMergeRegion], @@ -353,4 +612,3 @@ fn build_table_cell( } table_cell } - diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/snapshot.rs b/codex-rs/artifact-presentation/src/presentation_artifact/snapshot.rs index fde1ce85e..8606649c3 100644 --- a/codex-rs/artifact-presentation/src/presentation_artifact/snapshot.rs +++ b/codex-rs/artifact-presentation/src/presentation_artifact/snapshot.rs @@ -42,7 +42,8 @@ fn slide_list(document: &PresentationDocument) -> Vec { 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: (slide.notes.visible && !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(), diff --git a/codex-rs/artifact-presentation/src/tests.rs b/codex-rs/artifact-presentation/src/tests.rs index 493e67a17..2ced6ad64 100644 --- a/codex-rs/artifact-presentation/src/tests.rs +++ b/codex-rs/artifact-presentation/src/tests.rs @@ -1954,6 +1954,15 @@ fn inspect_supports_filters_target_windows_and_shape_text_metadata() "italic": false, "underline": false }, + "richText": { + "layout": { + "insets": serde_json::Value::Null, + "wrap": serde_json::Value::Null, + "autoFit": serde_json::Value::Null, + "verticalAlignment": serde_json::Value::Null + }, + "ranges": [] + }, "rotation": serde_json::Value::Null, "flipHorizontal": false, "flipVertical": false, @@ -2816,6 +2825,441 @@ fn manager_supports_table_cell_updates_and_merges() -> 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": "Parity Roundtrip" }), + }, + 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 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": "Placeholder", + "position": { "left": 32, "top": 28, "width": 280, "height": 72 } + }), + }, + 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"); + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "set_rich_text".to_string(), + args: serde_json::json!({ + "element_id": text_id, + "text": [[ + { + "run": "Quarterly ", + "text_style": { "bold": true, "color": "#114488" } + }, + "update pipeline" + ]], + "text_layout": { + "wrap": "square", + "auto_fit": "shrinkText", + "vertical_alignment": "middle", + "insets": { "left": 6, "right": 6, "top": 4, "bottom": 4 } + } + }), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "format_text_range".to_string(), + args: serde_json::json!({ + "element_id": text_id, + "query": "update", + "styling": { "italic": true }, + "link": { "uri": "https://example.com/update", "is_external": true } + }), + }, + temp_dir.path(), + )?; + + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "set_comment_author".to_string(), + args: serde_json::json!({ + "display_name": "Jamie Fox", + "initials": "JF", + "email": "jamie@example.com" + }), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_comment_thread".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "element_id": text_id, + "query": "Quarterly", + "text": "Tighten this headline", + "position": { "x": 240, "y": 44 } + }), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_comment_reply".to_string(), + args: serde_json::json!({ + "thread_id": "thread_1", + "text": "Applied to the slide draft." + }), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "toggle_comment_reaction".to_string(), + args: serde_json::json!({ + "thread_id": "thread_1", + "emoji": "eyes" + }), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "resolve_comment_thread".to_string(), + args: serde_json::json!({ "thread_id": "thread_1" }), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "reopen_comment_thread".to_string(), + args: serde_json::json!({ "thread_id": "thread_1" }), + }, + temp_dir.path(), + )?; + + 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": 32, "top": 124, "width": 320, "height": 120 }, + "rows": [["Metric", "Value"], ["Status", "Beta"]], + "style": "TableStyleMedium2", + "style_options": { + "header_row": true, + "banded_rows": true, + "first_column": true + }, + "borders": { + "outside": { "color": "#222222", "width": 2 } + }, + "right_to_left": true + }), + }, + 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_style".to_string(), + args: serde_json::json!({ + "element_id": table_id, + "style_options": { "last_column": true, "total_row": true }, + "borders": { + "inside": { "color": "#999999", "width": 1 } + } + }), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "style_table_block".to_string(), + args: serde_json::json!({ + "element_id": table_id, + "row": 1, + "column": 0, + "row_count": 1, + "column_count": 2, + "background_fill": "#FFF2CC", + "alignment": "center", + "styling": { "bold": true } + }), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "format_text_range".to_string(), + args: serde_json::json!({ + "element_id": table_id, + "row": 1, + "column": 1, + "query": "Beta", + "styling": { "italic": true, "color": "#AA0000" } + }), + }, + temp_dir.path(), + )?; + + let chart_added = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_chart".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "position": { "left": 372, "top": 120, "width": 280, "height": 210 }, + "chart_type": "bar", + "categories": ["Q1", "Q2"], + "series": [{ + "name": "Revenue", + "values": [10.0, 12.0], + "fill": "#4472C4", + "stroke": { "color": "#1F4E79", "width": 2 }, + "marker": { "symbol": "circle", "size": 7 }, + "data_label_overrides": [{ + "idx": 1, + "text": "12M", + "position": "outsideEnd", + "fill": "#FFFFFF" + }] + }], + "title": "Revenue", + "style_index": 7, + "has_legend": true, + "legend_position": "right", + "legend_text_style": { "italic": true }, + "x_axis_title": "Quarter", + "y_axis_title": "USD", + "data_labels": { + "show_value": true, + "position": "outsideEnd", + "text_style": { "bold": true } + }, + "chart_fill": "#F8F8F8", + "plot_area_fill": "#FFFFFF" + }), + }, + temp_dir.path(), + )?; + let chart_id = chart_added + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .and_then(|slide| slide.element_ids.last()) + .cloned() + .expect("chart id"); + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "update_chart".to_string(), + args: serde_json::json!({ + "element_id": chart_id, + "title": "Revenue outlook", + "style_index": 12, + "legend_position": "bottom", + "y_axis_title": "USD (millions)" + }), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_chart_series".to_string(), + args: serde_json::json!({ + "element_id": chart_id, + "name": "Target", + "values": [11.0, 13.0], + "fill": "#70AD47", + "marker": { "symbol": "diamond", "size": 6 } + }), + }, + temp_dir.path(), + )?; + + let export_path = temp_dir.path().join("parity-roundtrip.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 metadata = zip_entry_text( + &temp_dir.path().join("parity-roundtrip.pptx"), + "ppt/codex-document.json", + )?; + assert!(metadata.contains("\"thread_id\":\"thread_1\"")); + assert!(metadata.contains("\"style_index\":12")); + assert!(metadata.contains("\"right_to_left\":true")); + + let imported = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "import_pptx".to_string(), + args: serde_json::json!({ "path": "parity-roundtrip.pptx" }), + }, + temp_dir.path(), + )?; + let imported_artifact_id = imported.artifact_id; + + let proto = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(imported_artifact_id.clone()), + action: "to_proto".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + let proto = proto.proto_json.expect("proto"); + let comments = proto["commentThreads"].as_array().expect("comments"); + assert_eq!(comments.len(), 1); + assert_eq!(comments[0]["status"], "active"); + assert_eq!(comments[0]["messages"].as_array().map(Vec::len), Some(2)); + assert_eq!( + comments[0]["messages"][1]["reactions"], + serde_json::json!(["eyes"]) + ); + + let elements = proto["slides"][0]["elements"].as_array().expect("elements"); + let text_record = elements + .iter() + .find(|element| element["elementId"] == text_id) + .expect("text record"); + assert_eq!(text_record["richText"]["layout"]["wrap"], "square"); + assert_eq!(text_record["richText"]["layout"]["autoFit"], "shrinkText"); + assert_eq!( + text_record["richText"]["layout"]["verticalAlignment"], + "middle" + ); + assert_eq!( + text_record["richText"]["ranges"].as_array().map(Vec::len), + Some(2) + ); + assert!( + text_record["richText"]["ranges"] + .as_array() + .expect("text ranges") + .iter() + .any(|range| range["text"] == "update") + ); + + let table_record = elements + .iter() + .find(|element| element["elementId"] == table_id) + .expect("table record"); + assert_eq!(table_record["styleOptions"]["headerRow"], true); + assert_eq!(table_record["styleOptions"]["lastColumn"], true); + assert_eq!(table_record["rightToLeft"], true); + assert_eq!(table_record["borders"]["outside"]["color"], "222222"); + assert_eq!( + table_record["rows"][1][1]["richText"]["ranges"] + .as_array() + .map(Vec::len), + Some(1) + ); + + let chart_record = elements + .iter() + .find(|element| element["elementId"] == chart_id) + .expect("chart record"); + assert_eq!(chart_record["styleIndex"], 12); + assert_eq!(chart_record["legend"]["position"], "bottom"); + assert_eq!(chart_record["series"].as_array().map(Vec::len), Some(2)); + assert_eq!(chart_record["series"][1]["name"], "Target"); + + let inspect = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(imported_artifact_id.clone()), + action: "inspect".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + let inspect_ndjson = inspect.inspect_ndjson.expect("inspect"); + assert!(inspect_ndjson.contains("\"kind\":\"comment\"")); + assert!(inspect_ndjson.contains("\"kind\":\"textRange\"")); + assert!(inspect_ndjson.contains("\"chartType\":\"Bar\"")); + + let resolved_thread = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(imported_artifact_id.clone()), + action: "resolve".to_string(), + args: serde_json::json!({ "id": "th/thread_1" }), + }, + temp_dir.path(), + )?; + assert_eq!( + resolved_thread + .resolved_record + .as_ref() + .and_then(|record| record.get("target")) + .and_then(|target| target.get("type")), + Some(&serde_json::json!("textRange")) + ); + + let resolved_range = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(imported_artifact_id), + action: "resolve".to_string(), + args: serde_json::json!({ "id": "tr/range_1" }), + }, + temp_dir.path(), + )?; + assert_eq!( + resolved_range + .resolved_record + .as_ref() + .and_then(|record| record.get("text")), + Some(&serde_json::json!("update")) + ); + Ok(()) +} + #[test] fn history_can_undo_and_redo_created_artifact() -> Result<(), Box> { let temp_dir = tempfile::tempdir()?; @@ -2971,160 +3415,32 @@ fn proto_and_patch_actions_work_and_patch_history_is_atomic() }, temp_dir.path(), )?; + let proto = proto.proto_json.expect("proto"); + assert_eq!(proto["kind"], "presentation"); + assert_eq!(proto["artifactId"], artifact_id); + assert_eq!(proto["activeSlideId"], slide_id); + assert_eq!(proto["commentAuthor"], serde_json::Value::Null); + assert_eq!(proto["commentThreads"], serde_json::json!([])); + assert_eq!(proto["masters"], serde_json::json!([])); + assert_eq!(proto["layouts"], serde_json::json!([])); + assert_eq!(proto["theme"]["hexColorMap"], serde_json::json!({})); + assert_eq!(proto["styles"].as_array().map(Vec::len), Some(5)); + assert_eq!(proto["slides"].as_array().map(Vec::len), Some(1)); + assert_eq!(proto["slides"][0]["slideId"], slide_id); + assert_eq!(proto["slides"][0]["backgroundFill"], "FFEECC"); 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 - } - ] - } - ] - })) + proto["slides"][0]["notes"]["richText"]["ranges"], + serde_json::json!([]) + ); + assert_eq!( + proto["slides"][0]["elements"].as_array().map(Vec::len), + Some(1) + ); + assert_eq!(proto["slides"][0]["elements"][0]["elementId"], element_id); + assert_eq!(proto["slides"][0]["elements"][0]["text"], "Patch text"); + assert_eq!( + proto["slides"][0]["elements"][0]["richText"]["ranges"], + serde_json::json!([]) ); let undone = manager.execute( @@ -3191,6 +3507,6 @@ fn proto_and_patch_actions_work_and_patch_history_is_atomic() }, temp_dir.path(), )?; - assert_eq!(redone_proto.proto_json, proto.proto_json); + assert_eq!(redone_proto.proto_json, Some(proto)); Ok(()) } diff --git a/codex-rs/core/templates/tools/presentation_artifact.md b/codex-rs/core/templates/tools/presentation_artifact.md index 5c63c2e04..45a91bc43 100644 --- a/codex-rs/core/templates/tools/presentation_artifact.md +++ b/codex-rs/core/templates/tools/presentation_artifact.md @@ -32,6 +32,7 @@ Supported actions: - `get_style` - `describe_styles` - `set_notes` +- `set_notes_rich_text` - `append_notes` - `clear_notes` - `set_notes_visibility` @@ -48,13 +49,25 @@ Supported actions: - `add_image` - `replace_image` - `add_table` +- `update_table_style` +- `style_table_block` - `update_table_cell` - `merge_table_cells` - `add_chart` +- `update_chart` +- `add_chart_series` - `update_text` +- `set_rich_text` +- `format_text_range` - `replace_text` - `insert_text_after` - `set_hyperlink` +- `set_comment_author` +- `add_comment_thread` +- `add_comment_reply` +- `toggle_comment_reaction` +- `resolve_comment_thread` +- `reopen_comment_thread` - `update_shape_style` - `bring_to_front` - `send_to_back` @@ -73,7 +86,7 @@ Example edit: Example sequential batch: `{"actions":[{"action":"create","args":{"name":"Quarterly Update"}},{"action":"add_slide","args":{}},{"action":"add_text_shape","args":{"slide_index":0,"text":"Revenue up 24%","position":{"left":48,"top":72,"width":260,"height":80}}}]}` -Table creation also accepts optional `column_widths` and `row_heights` arrays in points when you need explicit table sizing instead of even splits. +Table creation also accepts optional `column_widths` and `row_heights` arrays in points when you need explicit table sizing instead of even splits. Tables also support `style_options`, `borders`, and `right_to_left`, with `update_table_style` and `style_table_block` available for incremental styling after creation. Example export: `{"artifact_id":"presentation_x","actions":[{"action":"export_pptx","args":{"path":"artifacts/q2-update.pptx"}}]}` @@ -94,7 +107,7 @@ Layout references in `create_layout.parent_layout_id`, `add_layout_placeholder.l `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","actions":[{"action":"inspect","args":{"include":"deck,slide,textbox,shape,table,chart,image,notes,layoutList","exclude":"notes","search":"roadmap","max_chars":12000}}]}` +`{"artifact_id":"presentation_x","actions":[{"action":"inspect","args":{"include":"deck,slide,textbox,shape,table,chart,image,notes,layoutList,textRange,comment","exclude":"notes","search":"roadmap","max_chars":12000}}]}` Example inspect target window: `{"artifact_id":"presentation_x","actions":[{"action":"inspect","args":{"include":"textbox","target":{"id":"sh/element_3","before_lines":1,"after_lines":1}}}]}` @@ -107,6 +120,14 @@ Example proto export: `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. +Rich text is supported on notes, text boxes, shapes with text, and table cells. Use `set_rich_text` to replace a full rich-text payload, `set_notes_rich_text` for speaker notes, and `format_text_range` to annotate a substring by `query` or explicit codepoint range. `inspect`, `resolve`, and `to_proto` surface text-range anchors as `tr/`. + +Comment threads are supported through `set_comment_author`, `add_comment_thread`, `add_comment_reply`, `toggle_comment_reaction`, `resolve_comment_thread`, and `reopen_comment_thread`. Thread anchors resolve as `th/`, and comment records appear in both `inspect` and `to_proto`. + +Charts support richer series metadata plus `update_chart` and `add_chart_series`, including legend, axis, data-label, marker, fill, and per-point override state. + +Exported PPTX files embed Codex metadata so rich text, comment threads, and advanced table/chart state round-trip through `export_pptx` and `import_pptx` even when the base OOXML representation is lossy. + Example patch recording: `{"artifact_id":"presentation_x","actions":[{"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"}}]}}]}` @@ -136,10 +157,19 @@ Text styling payloads on `add_text_shape`, `add_shape.text_style`, `update_text. Text-bearing elements also support literal `replace_text` and `insert_text_after` helpers for in-place edits without resending the full string. +Example rich text update: +`{"artifact_id":"presentation_x","actions":[{"action":"set_rich_text","args":{"element_id":"element_3","text":[[{"run":"Quarterly ","text_style":{"bold":true}},"update pipeline"]],"text_layout":{"wrap":"square","auto_fit":"shrinkText","vertical_alignment":"middle","insets":{"left":6,"right":6,"top":4,"bottom":4}}}}]}` + +Example substring formatting: +`{"artifact_id":"presentation_x","actions":[{"action":"format_text_range","args":{"element_id":"element_3","query":"update","styling":{"italic":true},"link":{"uri":"https://example.com/update","is_external":true}}}]}` + 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. Notes visibility is honored on export: `set_notes_visibility` controls whether speaker notes are emitted into exported PPTX output. +Example comment thread: +`{"artifact_id":"presentation_x","actions":[{"action":"set_comment_author","args":{"display_name":"Jamie Fox","initials":"JF"}},{"action":"add_comment_thread","args":{"slide_index":0,"element_id":"element_3","query":"Quarterly","text":"Tighten this headline"}},{"action":"add_comment_reply","args":{"thread_id":"thread_1","text":"Applied to the draft."}}]}` + 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. 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. @@ -158,6 +188,9 @@ Shape strokes accept an optional `style` field such as `solid`, `dashed`, `dotte Connectors are supported via `add_connector`, with straight/elbow/curved types plus dash styles and arrow heads. +Example chart update: +`{"artifact_id":"presentation_x","actions":[{"action":"update_chart","args":{"element_id":"element_7","style_index":12,"legend_position":"bottom","y_axis_title":"USD (millions)"}},{"action":"add_chart_series","args":{"element_id":"element_7","name":"Target","values":[11,13],"fill":"#70AD47","marker":{"symbol":"diamond","size":6}}}]}` + Example preview: `{"artifact_id":"presentation_x","actions":[{"action":"export_preview","args":{"slide_index":0,"path":"artifacts/q2-update-slide1.png"}}]}`