feat: artifact presentation part 7 (#13360)

This commit is contained in:
jif-oai 2026-03-03 15:03:25 +00:00 committed by GitHub
parent 1df040e62b
commit 24ba01b9da
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 2883 additions and 220 deletions

View file

@ -225,7 +225,7 @@ struct PartialPositionArgs {
flip_vertical: Option<bool>,
}
#[derive(Debug, Default, Deserialize)]
#[derive(Debug, Clone, Default, Deserialize)]
struct TextStylingArgs {
style: Option<String>,
font_size: Option<u32>,
@ -238,6 +238,57 @@ struct TextStylingArgs {
underline: Option<bool>,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct TextLayoutArgs {
insets: Option<TextInsetsArgs>,
wrap: Option<String>,
auto_fit: Option<String>,
vertical_alignment: Option<String>,
}
#[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<RichParagraphInput>),
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
enum RichParagraphInput {
Plain(String),
Runs(Vec<RichRunInput>),
}
#[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<RichTextLinkInput>,
}
#[derive(Debug, Clone, Deserialize)]
struct RichTextLinkInput {
uri: Option<String>,
is_external: Option<bool>,
}
#[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<bool>,
#[serde(default)]
text_style: TextStylingArgs,
#[serde(default)]
text_layout: TextLayoutArgs,
}
#[derive(Debug, Clone, Default, Deserialize)]
@ -276,7 +331,7 @@ struct ConnectorLineArgs {
style: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PointArgs {
left: u32,
top: u32,
@ -355,6 +410,9 @@ struct AddTableArgs {
column_widths: Option<Vec<u32>>,
row_heights: Option<Vec<u32>>,
style: Option<String>,
style_options: Option<TableStyleOptionsArgs>,
borders: Option<TableBordersArgs>,
right_to_left: Option<bool>,
}
#[derive(Debug, Deserialize)]
@ -365,12 +423,55 @@ struct AddChartArgs {
categories: Vec<String>,
series: Vec<ChartSeriesArgs>,
title: Option<String>,
style_index: Option<u32>,
has_legend: Option<bool>,
legend_position: Option<String>,
#[serde(default)]
legend_text_style: TextStylingArgs,
x_axis_title: Option<String>,
y_axis_title: Option<String>,
data_labels: Option<ChartDataLabelsArgs>,
chart_fill: Option<String>,
plot_area_fill: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ChartSeriesArgs {
name: String,
values: Vec<f64>,
categories: Option<Vec<String>>,
x_values: Option<Vec<f64>>,
fill: Option<String>,
stroke: Option<StrokeArgs>,
marker: Option<ChartMarkerArgs>,
data_label_overrides: Option<Vec<ChartDataLabelOverrideArgs>>,
}
#[derive(Debug, Clone, Deserialize)]
struct ChartMarkerArgs {
symbol: Option<String>,
size: Option<u32>,
}
#[derive(Debug, Clone, Deserialize)]
struct ChartDataLabelsArgs {
show_value: Option<bool>,
show_category_name: Option<bool>,
show_leader_lines: Option<bool>,
position: Option<String>,
#[serde(default)]
text_style: TextStylingArgs,
}
#[derive(Debug, Clone, Deserialize)]
struct ChartDataLabelOverrideArgs {
idx: u32,
text: Option<String>,
position: Option<String>,
#[serde(default)]
text_style: TextStylingArgs,
fill: Option<String>,
stroke: Option<StrokeArgs>,
}
#[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<String>,
slide_index: Option<u32>,
row: Option<u32>,
column: Option<u32>,
notes: Option<bool>,
text: RichTextInput,
#[serde(default)]
styling: TextStylingArgs,
#[serde(default)]
text_layout: TextLayoutArgs,
}
#[derive(Debug, Deserialize)]
struct FormatTextRangeArgs {
element_id: Option<String>,
slide_index: Option<u32>,
row: Option<u32>,
column: Option<u32>,
notes: Option<bool>,
query: Option<String>,
occurrence: Option<usize>,
start_cp: Option<usize>,
length: Option<usize>,
#[serde(default)]
styling: TextStylingArgs,
#[serde(default)]
text_layout: TextLayoutArgs,
link: Option<RichTextLinkInput>,
spacing_before: Option<u32>,
spacing_after: Option<u32>,
line_spacing: Option<f32>,
}
#[derive(Debug, Deserialize)]
@ -422,6 +560,8 @@ struct UpdateShapeStyleArgs {
crop: Option<ImageCropArgs>,
lock_aspect_ratio: Option<bool>,
z_order: Option<u32>,
#[serde(default)]
text_layout: TextLayoutArgs,
}
#[derive(Debug, Deserialize)]
@ -458,6 +598,55 @@ struct UpdateTableCellArgs {
alignment: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct TableStyleOptionsArgs {
header_row: Option<bool>,
banded_rows: Option<bool>,
banded_columns: Option<bool>,
first_column: Option<bool>,
last_column: Option<bool>,
total_row: Option<bool>,
}
#[derive(Debug, Clone, Deserialize)]
struct TableBorderArgs {
color: String,
width: u32,
}
#[derive(Debug, Clone, Deserialize)]
struct TableBordersArgs {
outside: Option<TableBorderArgs>,
inside: Option<TableBorderArgs>,
top: Option<TableBorderArgs>,
bottom: Option<TableBorderArgs>,
left: Option<TableBorderArgs>,
right: Option<TableBorderArgs>,
}
#[derive(Debug, Deserialize)]
struct UpdateTableStyleArgs {
element_id: String,
style: Option<String>,
style_options: Option<TableStyleOptionsArgs>,
borders: Option<TableBordersArgs>,
right_to_left: Option<bool>,
}
#[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<String>,
alignment: Option<String>,
borders: Option<TableBordersArgs>,
}
#[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<String>,
categories: Option<Vec<String>>,
style_index: Option<u32>,
has_legend: Option<bool>,
legend_position: Option<String>,
#[serde(default)]
legend_text_style: TextStylingArgs,
x_axis_title: Option<String>,
y_axis_title: Option<String>,
data_labels: Option<ChartDataLabelsArgs>,
chart_fill: Option<String>,
plot_area_fill: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AddChartSeriesArgs {
element_id: String,
name: String,
values: Vec<f64>,
categories: Option<Vec<String>>,
x_values: Option<Vec<f64>>,
fill: Option<String>,
stroke: Option<StrokeArgs>,
marker: Option<ChartMarkerArgs>,
}
#[derive(Debug, Deserialize)]
struct SetCommentAuthorArgs {
display_name: String,
initials: String,
email: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct CommentPositionArgs {
x: u32,
y: u32,
}
#[derive(Debug, Deserialize)]
struct AddCommentThreadArgs {
slide_index: Option<u32>,
element_id: Option<String>,
query: Option<String>,
occurrence: Option<usize>,
start_cp: Option<usize>,
length: Option<usize>,
text: String,
position: Option<CommentPositionArgs>,
}
#[derive(Debug, Deserialize)]
struct AddCommentReplyArgs {
thread_id: String,
text: String,
}
#[derive(Debug, Deserialize)]
struct ToggleCommentReactionArgs {
thread_id: String,
message_id: Option<String>,
emoji: String,
}
#[derive(Debug, Deserialize)]
struct CommentThreadIdArgs {
thread_id: String,
}

View file

@ -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::<Vec<_>>(),
"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::<Vec<_>>(),
}),
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::<Vec<_>>().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::<Vec<_>>())
.collect::<Vec<_>>(),
"rowsData": table
.rows
.iter()
.map(|row| row.iter().map(table_cell_to_proto).collect::<Vec<_>>())
.collect::<Vec<_>>(),
"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::<Vec<_>>(),
}))
.collect::<Vec<_>>(),
"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::<Vec<_>>())
.collect::<Vec<_>>(),
"rowsData": table
.rows
.iter()
.map(|row| row.iter().map(table_cell_to_proto).collect::<Vec<_>>())
.collect::<Vec<_>>(),
"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::<Vec<_>>(),
}))
.collect::<Vec<_>>(),
"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}`"),
})
}

View file

@ -1,4 +1,4 @@
#[derive(Debug, Clone, Default)]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct ThemeState {
color_scheme: HashMap<String, String>,
major_font: Option<String>,
@ -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<PlaceholderDefinition>,
}
#[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<String>,
font_size: Option<u32>,
@ -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<String>,
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<TextRangeAnnotation>,
#[serde(default)]
layout: TextLayoutState,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct TextRangeAnnotation {
range_id: String,
start_cp: usize,
length: usize,
style: TextStyle,
hyperlink: Option<HyperlinkState>,
spacing_before: Option<u32>,
spacing_after: Option<u32>,
line_spacing: Option<f32>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct TextLayoutState {
insets: Option<TextInsets>,
wrap: Option<TextWrapMode>,
auto_fit: Option<TextAutoFitMode>,
vertical_alignment: Option<TextVerticalAlignment>,
}
#[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<String>,
}
#[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<String>,
}
#[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<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CommentThread {
thread_id: String,
target: CommentTarget,
position: Option<CommentPosition>,
status: CommentThreadStatus,
messages: Vec<CommentMessage>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct TableBorder {
color: String,
width: u32,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct TableBorders {
outside: Option<TableBorder>,
inside: Option<TableBorder>,
top: Option<TableBorder>,
bottom: Option<TableBorder>,
left: Option<TableBorder>,
right: Option<TableBorder>,
}
#[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<String>,
size: Option<u32>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct ChartDataLabels {
show_value: bool,
show_category_name: bool,
show_leader_lines: bool,
position: Option<String>,
text_style: TextStyle,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct ChartLegend {
position: Option<String>,
text_style: TextStyle,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct ChartAxisSpec {
title: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct ChartDataLabelOverride {
idx: usize,
text: Option<String>,
position: Option<String>,
text_style: TextStyle,
fill: Option<String>,
stroke: Option<StrokeStyle>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct PlaceholderRef {
name: String,
placeholder_type: String,
index: Option<u32>,
}
#[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<String>,
alignment: Option<TextAlignment>,
#[serde(default)]
rich_text: RichTextState,
borders: Option<TableBorders>,
}
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PresentationDocument {
artifact_id: String,
name: Option<String>,
@ -259,9 +438,16 @@ struct PresentationDocument {
layouts: Vec<LayoutDocument>,
slides: Vec<PresentationSlide>,
active_slide_index: Option<usize>,
#[serde(default)]
comment_self: Option<CommentAuthorProfile>,
#[serde(default)]
comment_threads: Vec<CommentThread>,
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<String>,
style: TextStyle,
hyperlink: Option<HyperlinkState>,
#[serde(default)]
rich_text: RichTextState,
placeholder: Option<PlaceholderRef>,
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<String>,
text_style: TextStyle,
hyperlink: Option<HyperlinkState>,
rich_text: Option<RichTextState>,
placeholder: Option<PlaceholderRef>,
rotation_degrees: Option<i32>,
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<u32>,
row_heights: Vec<u32>,
style: Option<String>,
style_options: TableStyleOptions,
borders: Option<TableBorders>,
right_to_left: bool,
merges: Vec<TableMergeRegion>,
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<String>,
series: Vec<ChartSeriesSpec>,
title: Option<String>,
style_index: Option<u32>,
has_legend: bool,
legend: Option<ChartLegend>,
x_axis: Option<ChartAxisSpec>,
y_axis: Option<ChartAxisSpec>,
data_labels: Option<ChartDataLabels>,
chart_fill: Option<String>,
plot_area_fill: Option<String>,
z_order: usize,
}
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct ImagePayload {
pub(crate) bytes: Vec<u8>,
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<f64>,
categories: Option<Vec<String>>,
x_values: Option<Vec<f64>>,
fill: Option<String>,
stroke: Option<StrokeStyle>,
marker: Option<ChartMarkerStyle>,
#[serde(default)]
data_label_overrides: Vec<ChartDataLabelOverride>,
}
#[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,

View file

@ -169,6 +169,134 @@ fn parse_chart_type(
}
}
fn parse_chart_marker(marker: Option<ChartMarkerArgs>) -> ChartMarkerStyle {
marker
.map(|marker| ChartMarkerStyle {
symbol: marker.symbol,
size: marker.size,
})
.unwrap_or_default()
}
fn parse_chart_data_labels(
document: &PresentationDocument,
data_labels: Option<ChartDataLabelsArgs>,
action: &str,
) -> Result<Option<ChartDataLabels>, 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<ChartSeriesArgs>,
action: &str,
) -> Result<Vec<ChartSeriesSpec>, 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::<Result<Vec<_>, _>>()?;
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<StrokeArgs>,
@ -299,6 +427,216 @@ fn normalize_text_style_with_document(
})
}
fn normalize_text_layout(
layout: &TextLayoutArgs,
action: &str,
) -> Result<TextLayoutState, PresentationArtifactError> {
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<HyperlinkState, PresentationArtifactError> {
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<usize>,
start_cp: Option<usize>,
length: Option<usize>,
action: &str,
) -> Result<(usize, usize, Option<String>), PresentationArtifactError> {
if let Some(query) = query {
let occurrence = occurrence.unwrap_or(0);
let haystack = text.chars().collect::<Vec<_>>();
let needle = query.chars().collect::<Vec<_>>();
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<TableBorder, PresentationArtifactError> {
Ok(TableBorder {
color: normalize_color_with_document(document, &border.color, action, field)?,
width: border.width,
})
}
fn parse_table_borders(
document: &PresentationDocument,
borders: Option<TableBordersArgs>,
action: &str,
) -> Result<Option<TableBorders>, 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<TableStyleOptionsArgs>) -> 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<TableCellSpec>],
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()
}

View file

@ -1,3 +1,21 @@
const CODEX_METADATA_ENTRY: &str = "ppt/codex-document.json";
fn import_codex_metadata_document(path: &Path) -> Result<Option<PresentationDocument>, 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<Vec<u8>, 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)
}

View file

@ -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::<Vec<_>>(),
"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::<Vec<_>>(),
}))
}
@ -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::<Vec<_>>(),
})
@ -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::<Vec<_>>(),
})).collect::<Vec<_>>(),
"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::<Vec<_>>(),
})
}
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::<Vec<_>>(),
})
}
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
}

View file

@ -42,7 +42,8 @@ fn slide_list(document: &PresentationDocument) -> Vec<SlideListEntry> {
slide_id: slide.slide_id.clone(),
index,
is_active: document.active_slide_index == Some(index),
notes: (!slide.notes.text.is_empty()).then(|| slide.notes.text.clone()),
notes: (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(),

View file

@ -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<dyn std::e
Ok(())
}
#[test]
fn rich_text_comments_tables_and_charts_roundtrip_through_metadata()
-> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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(())
}

View file

@ -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/<range_id>`.
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/<thread_id>`, 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"}}]}`