feat: pres artifact part 5 (#13355)

Mostly written by Codex
This commit is contained in:
jif-oai 2026-03-03 14:08:01 +00:00 committed by GitHub
parent 821024f9c9
commit ad393fa753
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 11518 additions and 6292 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,177 @@
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use image::GenericImageView;
use image::ImageFormat;
use image::codecs::jpeg::JpegEncoder;
use image::imageops::FilterType;
use ppt_rs::Chart;
use ppt_rs::ChartSeries;
use ppt_rs::ChartType;
use ppt_rs::Hyperlink as PptHyperlink;
use ppt_rs::HyperlinkAction as PptHyperlinkAction;
use ppt_rs::Image;
use ppt_rs::Presentation;
use ppt_rs::Shape;
use ppt_rs::ShapeFill;
use ppt_rs::ShapeLine;
use ppt_rs::ShapeType;
use ppt_rs::SlideContent;
use ppt_rs::SlideLayout;
use ppt_rs::TableBuilder;
use ppt_rs::TableCell;
use ppt_rs::TableRow;
use ppt_rs::generator::ArrowSize;
use ppt_rs::generator::ArrowType;
use ppt_rs::generator::CellAlign;
use ppt_rs::generator::Connector;
use ppt_rs::generator::ConnectorLine;
use ppt_rs::generator::ConnectorType;
use ppt_rs::generator::LineDash;
use ppt_rs::generator::generate_image_content_type;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value;
use std::collections::BTreeSet;
use std::collections::HashMap;
use std::collections::HashSet;
use std::io::Cursor;
use std::io::Read;
use std::io::Seek;
use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use thiserror::Error;
use uuid::Uuid;
use zip::ZipArchive;
use zip::ZipWriter;
use zip::write::SimpleFileOptions;
const POINT_TO_EMU: u32 = 12_700;
const DEFAULT_SLIDE_WIDTH_POINTS: u32 = 720;
const DEFAULT_SLIDE_HEIGHT_POINTS: u32 = 540;
const DEFAULT_IMPORTED_TITLE_LEFT: u32 = 36;
const DEFAULT_IMPORTED_TITLE_TOP: u32 = 24;
const DEFAULT_IMPORTED_TITLE_WIDTH: u32 = 648;
const DEFAULT_IMPORTED_TITLE_HEIGHT: u32 = 48;
const DEFAULT_IMPORTED_CONTENT_LEFT: u32 = 48;
const DEFAULT_IMPORTED_CONTENT_TOP: u32 = 96;
const DEFAULT_IMPORTED_CONTENT_WIDTH: u32 = 624;
const DEFAULT_IMPORTED_CONTENT_HEIGHT: u32 = 324;
#[derive(Debug, Error)]
pub enum PresentationArtifactError {
#[error("missing `artifact_id` for action `{action}`")]
MissingArtifactId { action: String },
#[error("unknown artifact id `{artifact_id}` for action `{action}`")]
UnknownArtifactId { action: String, artifact_id: String },
#[error("unknown action `{0}`")]
UnknownAction(String),
#[error("invalid args for action `{action}`: {message}")]
InvalidArgs { action: String, message: String },
#[error("unsupported feature for action `{action}`: {message}")]
UnsupportedFeature { action: String, message: String },
#[error("failed to import PPTX `{path}`: {message}")]
ImportFailed { path: PathBuf, message: String },
#[error("failed to export PPTX `{path}`: {message}")]
ExportFailed { path: PathBuf, message: String },
}
#[derive(Debug, Clone, Deserialize)]
pub struct PresentationArtifactRequest {
pub artifact_id: Option<String>,
pub action: String,
#[serde(default)]
pub args: Value,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PathAccessKind {
Read,
Write,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PathAccessRequirement {
pub action: String,
pub kind: PathAccessKind,
pub path: PathBuf,
}
impl PresentationArtifactRequest {
pub fn required_path_accesses(
&self,
cwd: &Path,
) -> Result<Vec<PathAccessRequirement>, PresentationArtifactError> {
let access = match self.action.as_str() {
"import_pptx" => {
let args: ImportPptxArgs = parse_args(&self.action, &self.args)?;
vec![PathAccessRequirement {
action: self.action.clone(),
kind: PathAccessKind::Read,
path: resolve_path(cwd, &args.path),
}]
}
"export_pptx" => {
let args: ExportPptxArgs = parse_args(&self.action, &self.args)?;
vec![PathAccessRequirement {
action: self.action.clone(),
kind: PathAccessKind::Write,
path: resolve_path(cwd, &args.path),
}]
}
"export_preview" => {
let args: ExportPreviewArgs = parse_args(&self.action, &self.args)?;
vec![PathAccessRequirement {
action: self.action.clone(),
kind: PathAccessKind::Write,
path: resolve_path(cwd, &args.path),
}]
}
"add_image" => {
let args: AddImageArgs = parse_args(&self.action, &self.args)?;
match args.image_source()? {
ImageInputSource::Path(path) => vec![PathAccessRequirement {
action: self.action.clone(),
kind: PathAccessKind::Read,
path: resolve_path(cwd, &path),
}],
ImageInputSource::DataUrl(_)
| ImageInputSource::Blob(_)
| ImageInputSource::Uri(_)
| ImageInputSource::Placeholder => Vec::new(),
}
}
"replace_image" => {
let args: ReplaceImageArgs = parse_args(&self.action, &self.args)?;
match (
&args.path,
&args.data_url,
&args.blob,
&args.uri,
&args.prompt,
) {
(Some(path), None, None, None, None) => vec![PathAccessRequirement {
action: self.action.clone(),
kind: PathAccessKind::Read,
path: resolve_path(cwd, path),
}],
(None, Some(_), None, None, None)
| (None, None, Some(_), None, None)
| (None, None, None, Some(_), None)
| (None, None, None, None, Some(_)) => Vec::new(),
_ => {
return Err(PresentationArtifactError::InvalidArgs {
action: self.action.clone(),
message:
"provide exactly one of `path`, `data_url`, `blob`, or `uri`, or provide `prompt` for a placeholder image"
.to_string(),
});
}
}
}
_ => Vec::new(),
};
Ok(access)
}
}

View file

@ -0,0 +1,468 @@
#[derive(Debug, Deserialize)]
struct CreateArgs {
name: Option<String>,
slide_size: Option<Value>,
theme: Option<ThemeArgs>,
}
#[derive(Debug, Deserialize)]
struct ImportPptxArgs {
path: PathBuf,
}
#[derive(Debug, Deserialize)]
struct ExportPptxArgs {
path: PathBuf,
}
#[derive(Debug, Deserialize)]
struct ExportPreviewArgs {
path: PathBuf,
slide_index: Option<u32>,
format: Option<String>,
scale: Option<f32>,
quality: Option<u8>,
}
#[derive(Debug, Default, Deserialize)]
struct AddSlideArgs {
layout: Option<String>,
notes: Option<String>,
background_fill: Option<String>,
}
#[derive(Debug, Deserialize)]
struct CreateLayoutArgs {
name: String,
kind: Option<String>,
parent_layout_id: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PreviewOutputFormat {
Png,
Jpeg,
Svg,
}
impl PreviewOutputFormat {
fn extension(self) -> &'static str {
match self {
Self::Png => "png",
Self::Jpeg => "jpg",
Self::Svg => "svg",
}
}
}
#[derive(Debug, Deserialize)]
struct AddLayoutPlaceholderArgs {
layout_id: String,
name: String,
placeholder_type: String,
index: Option<u32>,
text: Option<String>,
geometry: Option<String>,
position: Option<PositionArgs>,
}
#[derive(Debug, Deserialize)]
struct LayoutIdArgs {
layout_id: String,
}
#[derive(Debug, Deserialize)]
struct SetSlideLayoutArgs {
slide_index: u32,
layout_id: String,
}
#[derive(Debug, Deserialize)]
struct UpdatePlaceholderTextArgs {
slide_index: u32,
name: String,
text: String,
}
#[derive(Debug, Deserialize)]
struct NotesArgs {
slide_index: u32,
text: Option<String>,
}
#[derive(Debug, Deserialize)]
struct NotesVisibilityArgs {
slide_index: u32,
visible: bool,
}
#[derive(Debug, Deserialize)]
struct ThemeArgs {
color_scheme: HashMap<String, String>,
major_font: Option<String>,
minor_font: Option<String>,
}
#[derive(Debug, Deserialize)]
struct StyleNameArgs {
name: String,
}
#[derive(Debug, Deserialize)]
struct AddStyleArgs {
name: String,
#[serde(flatten)]
styling: TextStylingArgs,
}
#[derive(Debug, Deserialize)]
struct InspectArgs {
kind: Option<String>,
include: Option<String>,
exclude: Option<String>,
search: Option<String>,
target_id: Option<String>,
target: Option<InspectTargetArgs>,
max_chars: Option<usize>,
}
#[derive(Debug, Clone, Deserialize)]
struct InspectTargetArgs {
id: String,
before_lines: Option<usize>,
after_lines: Option<usize>,
}
#[derive(Debug, Deserialize)]
struct ResolveArgs {
id: String,
}
#[derive(Debug, Clone, Deserialize)]
struct PatchOperationInput {
artifact_id: Option<String>,
action: String,
#[serde(default)]
args: Value,
}
#[derive(Debug, Deserialize)]
struct RecordPatchArgs {
operations: Vec<PatchOperationInput>,
}
#[derive(Debug, Deserialize)]
struct ApplyPatchArgs {
operations: Option<Vec<PatchOperationInput>>,
patch: Option<PresentationPatch>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PresentationPatch {
version: u32,
artifact_id: String,
operations: Vec<PatchOperation>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PatchOperation {
action: String,
#[serde(default)]
args: Value,
}
#[derive(Debug, Default, Deserialize)]
struct InsertSlideArgs {
index: Option<u32>,
after_slide_index: Option<u32>,
layout: Option<String>,
notes: Option<String>,
background_fill: Option<String>,
}
#[derive(Debug, Deserialize)]
struct SlideIndexArgs {
slide_index: u32,
}
#[derive(Debug, Deserialize)]
struct MoveSlideArgs {
from_index: u32,
to_index: u32,
}
#[derive(Debug, Deserialize)]
struct SetActiveSlideArgs {
slide_index: u32,
}
#[derive(Debug, Deserialize)]
struct SetSlideBackgroundArgs {
slide_index: u32,
fill: String,
}
#[derive(Debug, Clone, Deserialize)]
struct PositionArgs {
left: u32,
top: u32,
width: u32,
height: u32,
rotation: Option<i32>,
flip_horizontal: Option<bool>,
flip_vertical: Option<bool>,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct PartialPositionArgs {
left: Option<u32>,
top: Option<u32>,
width: Option<u32>,
height: Option<u32>,
rotation: Option<i32>,
flip_horizontal: Option<bool>,
flip_vertical: Option<bool>,
}
#[derive(Debug, Default, Deserialize)]
struct TextStylingArgs {
style: Option<String>,
font_size: Option<u32>,
font_family: Option<String>,
color: Option<String>,
fill: Option<String>,
alignment: Option<String>,
bold: Option<bool>,
italic: Option<bool>,
underline: Option<bool>,
}
#[derive(Debug, Deserialize)]
struct AddTextShapeArgs {
slide_index: u32,
text: String,
position: PositionArgs,
#[serde(flatten)]
styling: TextStylingArgs,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct StrokeArgs {
color: String,
width: u32,
style: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AddShapeArgs {
slide_index: u32,
geometry: String,
position: PositionArgs,
fill: Option<String>,
stroke: Option<StrokeArgs>,
text: Option<String>,
rotation: Option<i32>,
flip_horizontal: Option<bool>,
flip_vertical: Option<bool>,
#[serde(default)]
text_style: TextStylingArgs,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct ConnectorLineArgs {
color: Option<String>,
width: Option<u32>,
style: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct PointArgs {
left: u32,
top: u32,
}
#[derive(Debug, Deserialize)]
struct AddConnectorArgs {
slide_index: u32,
connector_type: String,
start: PointArgs,
end: PointArgs,
line: Option<ConnectorLineArgs>,
start_arrow: Option<String>,
end_arrow: Option<String>,
arrow_size: Option<String>,
label: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AddImageArgs {
slide_index: u32,
path: Option<PathBuf>,
data_url: Option<String>,
blob: Option<String>,
uri: Option<String>,
position: PositionArgs,
fit: Option<ImageFitMode>,
crop: Option<ImageCropArgs>,
rotation: Option<i32>,
flip_horizontal: Option<bool>,
flip_vertical: Option<bool>,
lock_aspect_ratio: Option<bool>,
alt: Option<String>,
prompt: Option<String>,
}
impl AddImageArgs {
fn image_source(&self) -> Result<ImageInputSource, PresentationArtifactError> {
match (&self.path, &self.data_url, &self.blob, &self.uri) {
(Some(path), None, None, None) => Ok(ImageInputSource::Path(path.clone())),
(None, Some(data_url), None, None) => Ok(ImageInputSource::DataUrl(data_url.clone())),
(None, None, Some(blob), None) => Ok(ImageInputSource::Blob(blob.clone())),
(None, None, None, Some(uri)) => Ok(ImageInputSource::Uri(uri.clone())),
(None, None, None, None) if self.prompt.is_some() => Ok(ImageInputSource::Placeholder),
_ => Err(PresentationArtifactError::InvalidArgs {
action: "add_image".to_string(),
message:
"provide exactly one of `path`, `data_url`, `blob`, or `uri`, or provide `prompt` for a placeholder image"
.to_string(),
}),
}
}
}
enum ImageInputSource {
Path(PathBuf),
DataUrl(String),
Blob(String),
Uri(String),
Placeholder,
}
#[derive(Debug, Clone, Deserialize)]
struct ImageCropArgs {
left: f64,
top: f64,
right: f64,
bottom: f64,
}
#[derive(Debug, Deserialize)]
struct AddTableArgs {
slide_index: u32,
position: PositionArgs,
rows: Vec<Vec<Value>>,
column_widths: Option<Vec<u32>>,
row_heights: Option<Vec<u32>>,
style: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AddChartArgs {
slide_index: u32,
position: PositionArgs,
chart_type: String,
categories: Vec<String>,
series: Vec<ChartSeriesArgs>,
title: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ChartSeriesArgs {
name: String,
values: Vec<f64>,
}
#[derive(Debug, Deserialize)]
struct UpdateTextArgs {
element_id: String,
text: String,
#[serde(default)]
styling: TextStylingArgs,
}
#[derive(Debug, Deserialize)]
struct ReplaceTextArgs {
element_id: String,
search: String,
replace: String,
}
#[derive(Debug, Deserialize)]
struct InsertTextAfterArgs {
element_id: String,
after: String,
insert: String,
}
#[derive(Debug, Deserialize)]
struct SetHyperlinkArgs {
element_id: String,
link_type: Option<String>,
url: Option<String>,
slide_index: Option<u32>,
address: Option<String>,
subject: Option<String>,
path: Option<String>,
tooltip: Option<String>,
highlight_click: Option<bool>,
clear: Option<bool>,
}
#[derive(Debug, Deserialize)]
struct UpdateShapeStyleArgs {
element_id: String,
position: Option<PartialPositionArgs>,
fill: Option<String>,
stroke: Option<StrokeArgs>,
rotation: Option<i32>,
flip_horizontal: Option<bool>,
flip_vertical: Option<bool>,
fit: Option<ImageFitMode>,
crop: Option<ImageCropArgs>,
lock_aspect_ratio: Option<bool>,
z_order: Option<u32>,
}
#[derive(Debug, Deserialize)]
struct ElementIdArgs {
element_id: String,
}
#[derive(Debug, Deserialize)]
struct ReplaceImageArgs {
element_id: String,
path: Option<PathBuf>,
data_url: Option<String>,
blob: Option<String>,
uri: Option<String>,
fit: Option<ImageFitMode>,
crop: Option<ImageCropArgs>,
rotation: Option<i32>,
flip_horizontal: Option<bool>,
flip_vertical: Option<bool>,
lock_aspect_ratio: Option<bool>,
alt: Option<String>,
prompt: Option<String>,
}
#[derive(Debug, Deserialize)]
struct UpdateTableCellArgs {
element_id: String,
row: u32,
column: u32,
value: Value,
#[serde(default)]
styling: TextStylingArgs,
background_fill: Option<String>,
alignment: Option<String>,
}
#[derive(Debug, Deserialize)]
struct MergeTableCellsArgs {
element_id: String,
start_row: u32,
end_row: u32,
start_column: u32,
end_column: u32,
}

View file

@ -0,0 +1,611 @@
fn inspect_document(document: &PresentationDocument, args: &InspectArgs) -> String {
let include_kinds = args
.include
.as_deref()
.or(args.kind.as_deref())
.unwrap_or("deck,slide,textbox,shape,connector,table,chart,image,notes,layoutList");
let included_kinds = include_kinds
.split(',')
.map(str::trim)
.filter(|entry| !entry.is_empty())
.collect::<HashSet<_>>();
let excluded_kinds = args
.exclude
.as_deref()
.unwrap_or_default()
.split(',')
.map(str::trim)
.filter(|entry| !entry.is_empty())
.collect::<HashSet<_>>();
let include = |name: &str| included_kinds.contains(name) && !excluded_kinds.contains(name);
let mut records: Vec<(Value, Option<String>)> = Vec::new();
if include("deck") {
records.push((
serde_json::json!({
"kind": "deck",
"id": format!("pr/{}", document.artifact_id),
"name": document.name,
"slides": document.slides.len(),
"styleIds": document
.named_text_styles()
.iter()
.map(|style| format!("st/{}", style.name))
.collect::<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)),
}),
None,
));
}
if include("styleList") {
for style in document.named_text_styles() {
records.push((named_text_style_to_json(&style, "st"), None));
}
}
if include("layoutList") {
for layout in &document.layouts {
let placeholders = resolved_layout_placeholders(document, &layout.layout_id, "inspect")
.unwrap_or_default()
.into_iter()
.map(|placeholder| {
serde_json::json!({
"name": placeholder.definition.name,
"type": placeholder.definition.placeholder_type,
"sourceLayoutId": placeholder.source_layout_id,
"textPreview": placeholder.definition.text,
})
})
.collect::<Vec<_>>();
records.push((
serde_json::json!({
"kind": "layout",
"id": format!("ly/{}", layout.layout_id),
"layoutId": layout.layout_id,
"name": layout.name,
"type": match layout.kind { LayoutKind::Layout => "layout", LayoutKind::Master => "master" },
"parentLayoutId": layout.parent_layout_id,
"placeholders": placeholders,
}),
None,
));
}
}
for (index, slide) in document.slides.iter().enumerate() {
let slide_id = format!("sl/{}", slide.slide_id);
if include("slide") {
records.push((
serde_json::json!({
"kind": "slide",
"id": slide_id,
"slide": index + 1,
"slideIndex": index,
"isActive": document.active_slide_index == Some(index),
"layoutId": slide.layout_id,
"elements": slide.elements.len(),
}),
Some(slide_id.clone()),
));
}
if include("notes") && !slide.notes.text.is_empty() {
records.push((
serde_json::json!({
"kind": "notes",
"id": format!("nt/{}", slide.slide_id),
"slide": index + 1,
"visible": slide.notes.visible,
"text": slide.notes.text,
"textPreview": slide.notes.text.replace('\n', " | "),
"textChars": slide.notes.text.chars().count(),
"textLines": slide.notes.text.lines().count(),
}),
Some(slide_id.clone()),
));
}
for element in &slide.elements {
let mut record = match element {
PresentationElement::Text(text) => {
if !include("textbox") {
continue;
}
serde_json::json!({
"kind": "textbox",
"id": format!("sh/{}", text.element_id),
"slide": index + 1,
"text": text.text,
"textStyle": text_style_to_proto(&text.style),
"textPreview": text.text.replace('\n', " | "),
"textChars": text.text.chars().count(),
"textLines": text.text.lines().count(),
"bbox": [text.frame.left, text.frame.top, text.frame.width, text.frame.height],
"bboxUnit": "points",
})
}
PresentationElement::Shape(shape) => {
if !(include("shape") || include("textbox") && shape.text.is_some()) {
continue;
}
let kind = if shape.text.is_some() && include("textbox") {
"textbox"
} else {
"shape"
};
let mut record = serde_json::json!({
"kind": kind,
"id": format!("sh/{}", shape.element_id),
"slide": index + 1,
"geometry": format!("{:?}", shape.geometry),
"text": shape.text,
"textStyle": text_style_to_proto(&shape.text_style),
"rotation": shape.rotation_degrees,
"flipHorizontal": shape.flip_horizontal,
"flipVertical": shape.flip_vertical,
"bbox": [shape.frame.left, shape.frame.top, shape.frame.width, shape.frame.height],
"bboxUnit": "points",
});
if let Some(text) = &shape.text {
record["textPreview"] = Value::String(text.replace('\n', " | "));
record["textChars"] = Value::from(text.chars().count());
record["textLines"] = Value::from(text.lines().count());
}
record
}
PresentationElement::Connector(connector) => {
if !include("shape") && !include("connector") {
continue;
}
serde_json::json!({
"kind": "connector",
"id": format!("cn/{}", connector.element_id),
"slide": index + 1,
"connectorType": format!("{:?}", connector.connector_type),
"start": [connector.start.left, connector.start.top],
"end": [connector.end.left, connector.end.top],
"lineStyle": format!("{:?}", connector.line_style),
"label": connector.label,
})
}
PresentationElement::Table(table) => {
if !include("table") {
continue;
}
serde_json::json!({
"kind": "table",
"id": format!("tb/{}", table.element_id),
"slide": index + 1,
"rows": table.rows.len(),
"cols": table.rows.iter().map(std::vec::Vec::len).max().unwrap_or(0),
"columnWidths": table.column_widths,
"rowHeights": table.row_heights,
"preview": table.rows.first().map(|row| row.iter().map(|cell| cell.text.clone()).collect::<Vec<_>>().join(" | ")),
"style": table.style,
"cellTextStyles": table
.rows
.iter()
.map(|row| row.iter().map(|cell| text_style_to_proto(&cell.text_style)).collect::<Vec<_>>())
.collect::<Vec<_>>(),
"bbox": [table.frame.left, table.frame.top, table.frame.width, table.frame.height],
"bboxUnit": "points",
})
}
PresentationElement::Chart(chart) => {
if !include("chart") {
continue;
}
serde_json::json!({
"kind": "chart",
"id": format!("ch/{}", chart.element_id),
"slide": index + 1,
"chartType": format!("{:?}", chart.chart_type),
"title": chart.title,
"bbox": [chart.frame.left, chart.frame.top, chart.frame.width, chart.frame.height],
"bboxUnit": "points",
})
}
PresentationElement::Image(image) => {
if !include("image") {
continue;
}
serde_json::json!({
"kind": "image",
"id": format!("im/{}", image.element_id),
"slide": index + 1,
"alt": image.alt_text,
"prompt": image.prompt,
"fit": format!("{:?}", image.fit_mode),
"rotation": image.rotation_degrees,
"flipHorizontal": image.flip_horizontal,
"flipVertical": image.flip_vertical,
"crop": image.crop.map(|(left, top, right, bottom)| serde_json::json!({
"left": left,
"top": top,
"right": right,
"bottom": bottom,
})),
"isPlaceholder": image.is_placeholder,
"lockAspectRatio": image.lock_aspect_ratio,
"bbox": [image.frame.left, image.frame.top, image.frame.width, image.frame.height],
"bboxUnit": "points",
})
}
};
if let Some(placeholder) = match element {
PresentationElement::Text(text) => text.placeholder.as_ref(),
PresentationElement::Shape(shape) => shape.placeholder.as_ref(),
PresentationElement::Connector(_)
| PresentationElement::Table(_)
| PresentationElement::Chart(_) => None,
PresentationElement::Image(image) => image.placeholder.as_ref(),
} {
record["placeholder"] = Value::String(placeholder.placeholder_type.clone());
record["placeholderName"] = Value::String(placeholder.name.clone());
record["placeholderIndex"] =
placeholder.index.map(Value::from).unwrap_or(Value::Null);
}
if let PresentationElement::Shape(shape) = element
&& let Some(stroke) = &shape.stroke
{
record["stroke"] = serde_json::json!({
"color": stroke.color,
"width": stroke.width,
"style": stroke.style.as_api_str(),
});
}
if let Some(hyperlink) = match element {
PresentationElement::Text(text) => text.hyperlink.as_ref(),
PresentationElement::Shape(shape) => shape.hyperlink.as_ref(),
PresentationElement::Connector(_)
| PresentationElement::Image(_)
| PresentationElement::Table(_)
| PresentationElement::Chart(_) => None,
} {
record["hyperlink"] = hyperlink.to_json();
}
records.push((record, Some(slide_id.clone())));
}
}
if let Some(target_id) = args.target_id.as_deref() {
records.retain(|(record, slide_id)| {
legacy_target_matches(target_id, record, slide_id.as_deref())
});
if records.is_empty() {
records.push((
serde_json::json!({
"kind": "notice",
"noticeType": "targetNotFound",
"target": { "id": target_id },
"message": format!("No inspect records matched target `{target_id}`."),
}),
None,
));
}
}
if let Some(search) = args.search.as_deref() {
let search_lowercase = search.to_ascii_lowercase();
records.retain(|(record, _)| {
record
.to_string()
.to_ascii_lowercase()
.contains(&search_lowercase)
});
if records.is_empty() {
records.push((
serde_json::json!({
"kind": "notice",
"noticeType": "noMatches",
"search": search,
"message": format!("No inspect records matched search `{search}`."),
}),
None,
));
}
}
if let Some(target) = args.target.as_ref() {
if let Some(target_index) = records.iter().position(|(record, _)| {
record.get("id").and_then(Value::as_str) == Some(target.id.as_str())
}) {
let start = target_index.saturating_sub(target.before_lines.unwrap_or(0));
let end = (target_index + target.after_lines.unwrap_or(0) + 1).min(records.len());
records = records.into_iter().skip(start).take(end - start).collect();
} else {
records = vec![(
serde_json::json!({
"kind": "notice",
"noticeType": "targetNotFound",
"target": {
"id": target.id,
"beforeLines": target.before_lines,
"afterLines": target.after_lines,
},
"message": format!("No inspect records matched target `{}`.", target.id),
}),
None,
)];
}
}
let mut lines = Vec::new();
let mut omitted_lines = 0usize;
let mut omitted_chars = 0usize;
for line in records.into_iter().map(|(record, _)| record.to_string()) {
let separator_len = usize::from(!lines.is_empty());
if let Some(max_chars) = args.max_chars
&& lines.iter().map(String::len).sum::<usize>() + separator_len + line.len() > max_chars
{
omitted_lines += 1;
omitted_chars += line.len();
continue;
}
lines.push(line);
}
if omitted_lines > 0 {
lines.push(
serde_json::json!({
"kind": "notice",
"noticeType": "truncation",
"maxChars": args.max_chars,
"omittedLines": omitted_lines,
"omittedChars": omitted_chars,
"message": format!(
"Truncated inspect output by omitting {omitted_lines} lines. Increase maxChars or narrow the filter."
),
})
.to_string(),
);
}
lines.join("\n")
}
fn legacy_target_matches(target_id: &str, record: &Value, slide_id: Option<&str>) -> bool {
record.get("id").and_then(Value::as_str) == Some(target_id) || slide_id == Some(target_id)
}
fn add_text_metadata(record: &mut Value, text: &str) {
record["textPreview"] = Value::String(text.replace('\n', " | "));
record["textChars"] = Value::from(text.chars().count());
record["textLines"] = Value::from(text.lines().count());
}
fn normalize_element_lookup_id(element_id: &str) -> &str {
element_id
.split_once('/')
.map(|(_, normalized)| normalized)
.unwrap_or(element_id)
}
fn resolve_anchor(
document: &PresentationDocument,
id: &str,
action: &str,
) -> Result<Value, PresentationArtifactError> {
if id == format!("pr/{}", document.artifact_id) {
return Ok(serde_json::json!({
"kind": "deck",
"id": id,
"artifactId": document.artifact_id,
"name": document.name,
"slideCount": document.slides.len(),
"styleIds": document
.named_text_styles()
.iter()
.map(|style| format!("st/{}", style.name))
.collect::<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)),
}));
}
if let Some(style_name) = id.strip_prefix("st/") {
let named_style = document
.named_text_styles()
.into_iter()
.find(|style| style.name == style_name)
.ok_or_else(|| PresentationArtifactError::UnsupportedFeature {
action: action.to_string(),
message: format!("unknown style id `{id}`"),
})?;
return Ok(named_text_style_to_json(&named_style, "st"));
}
for (slide_index, slide) in document.slides.iter().enumerate() {
let slide_id = format!("sl/{}", slide.slide_id);
if id == slide_id {
return Ok(serde_json::json!({
"kind": "slide",
"id": slide_id,
"slide": slide_index + 1,
"slideIndex": slide_index,
"isActive": document.active_slide_index == Some(slide_index),
"layoutId": slide.layout_id,
"notesId": (!slide.notes.text.is_empty()).then(|| format!("nt/{}", slide.slide_id)),
"elementIds": slide.elements.iter().map(|element| {
let prefix = match element {
PresentationElement::Text(_) | PresentationElement::Shape(_) => "sh",
PresentationElement::Connector(_) => "cn",
PresentationElement::Image(_) => "im",
PresentationElement::Table(_) => "tb",
PresentationElement::Chart(_) => "ch",
};
format!("{prefix}/{}", element.element_id())
}).collect::<Vec<_>>(),
}));
}
let notes_id = format!("nt/{}", slide.slide_id);
if id == notes_id {
let mut record = serde_json::json!({
"kind": "notes",
"id": notes_id,
"slide": slide_index + 1,
"slideIndex": slide_index,
"visible": slide.notes.visible,
"text": slide.notes.text,
});
add_text_metadata(&mut record, &slide.notes.text);
return Ok(record);
}
for element in &slide.elements {
let mut record = match element {
PresentationElement::Text(text) => {
let mut record = serde_json::json!({
"kind": "textbox",
"id": format!("sh/{}", text.element_id),
"elementId": text.element_id,
"slide": slide_index + 1,
"slideIndex": slide_index,
"text": text.text,
"textStyle": text_style_to_proto(&text.style),
"bbox": [text.frame.left, text.frame.top, text.frame.width, text.frame.height],
"bboxUnit": "points",
});
add_text_metadata(&mut record, &text.text);
record
}
PresentationElement::Shape(shape) => {
let mut record = serde_json::json!({
"kind": if shape.text.is_some() { "textbox" } else { "shape" },
"id": format!("sh/{}", shape.element_id),
"elementId": shape.element_id,
"slide": slide_index + 1,
"slideIndex": slide_index,
"geometry": format!("{:?}", shape.geometry),
"text": shape.text,
"textStyle": text_style_to_proto(&shape.text_style),
"rotation": shape.rotation_degrees,
"flipHorizontal": shape.flip_horizontal,
"flipVertical": shape.flip_vertical,
"bbox": [shape.frame.left, shape.frame.top, shape.frame.width, shape.frame.height],
"bboxUnit": "points",
});
if let Some(text) = &shape.text {
add_text_metadata(&mut record, text);
}
record
}
PresentationElement::Connector(connector) => serde_json::json!({
"kind": "connector",
"id": format!("cn/{}", connector.element_id),
"elementId": connector.element_id,
"slide": slide_index + 1,
"slideIndex": slide_index,
"connectorType": format!("{:?}", connector.connector_type),
"start": [connector.start.left, connector.start.top],
"end": [connector.end.left, connector.end.top],
"lineStyle": format!("{:?}", connector.line_style),
"label": connector.label,
}),
PresentationElement::Image(image) => serde_json::json!({
"kind": "image",
"id": format!("im/{}", image.element_id),
"elementId": image.element_id,
"slide": slide_index + 1,
"slideIndex": slide_index,
"alt": image.alt_text,
"prompt": image.prompt,
"fit": format!("{:?}", image.fit_mode),
"rotation": image.rotation_degrees,
"flipHorizontal": image.flip_horizontal,
"flipVertical": image.flip_vertical,
"crop": image.crop.map(|(left, top, right, bottom)| serde_json::json!({
"left": left,
"top": top,
"right": right,
"bottom": bottom,
})),
"isPlaceholder": image.is_placeholder,
"lockAspectRatio": image.lock_aspect_ratio,
"bbox": [image.frame.left, image.frame.top, image.frame.width, image.frame.height],
"bboxUnit": "points",
}),
PresentationElement::Table(table) => serde_json::json!({
"kind": "table",
"id": format!("tb/{}", table.element_id),
"elementId": table.element_id,
"slide": slide_index + 1,
"slideIndex": slide_index,
"rows": table.rows.len(),
"cols": table.rows.iter().map(std::vec::Vec::len).max().unwrap_or(0),
"columnWidths": table.column_widths,
"rowHeights": table.row_heights,
"cellTextStyles": table
.rows
.iter()
.map(|row| row.iter().map(|cell| text_style_to_proto(&cell.text_style)).collect::<Vec<_>>())
.collect::<Vec<_>>(),
"bbox": [table.frame.left, table.frame.top, table.frame.width, table.frame.height],
"bboxUnit": "points",
}),
PresentationElement::Chart(chart) => serde_json::json!({
"kind": "chart",
"id": format!("ch/{}", chart.element_id),
"elementId": chart.element_id,
"slide": slide_index + 1,
"slideIndex": slide_index,
"chartType": format!("{:?}", chart.chart_type),
"title": chart.title,
"bbox": [chart.frame.left, chart.frame.top, chart.frame.width, chart.frame.height],
"bboxUnit": "points",
}),
};
if let Some(hyperlink) = match element {
PresentationElement::Text(text) => text.hyperlink.as_ref(),
PresentationElement::Shape(shape) => shape.hyperlink.as_ref(),
PresentationElement::Connector(_)
| PresentationElement::Image(_)
| PresentationElement::Table(_)
| PresentationElement::Chart(_) => None,
} {
record["hyperlink"] = hyperlink.to_json();
}
if let PresentationElement::Shape(shape) = element
&& let Some(stroke) = &shape.stroke
{
record["stroke"] = serde_json::json!({
"color": stroke.color,
"width": stroke.width,
"style": stroke.style.as_api_str(),
});
}
if let Some(placeholder) = match element {
PresentationElement::Text(text) => text.placeholder.as_ref(),
PresentationElement::Shape(shape) => shape.placeholder.as_ref(),
PresentationElement::Image(image) => image.placeholder.as_ref(),
PresentationElement::Connector(_)
| PresentationElement::Table(_)
| PresentationElement::Chart(_) => None,
} {
record["placeholder"] = Value::String(placeholder.placeholder_type.clone());
record["placeholderName"] = Value::String(placeholder.name.clone());
record["placeholderIndex"] =
placeholder.index.map(Value::from).unwrap_or(Value::Null);
}
if record.get("id").and_then(Value::as_str) == Some(id) {
return Ok(record);
}
}
}
for layout in &document.layouts {
let layout_id = format!("ly/{}", layout.layout_id);
if id == layout_id {
return Ok(serde_json::json!({
"kind": "layout",
"id": layout_id,
"layoutId": layout.layout_id,
"name": layout.name,
"type": match layout.kind {
LayoutKind::Layout => "layout",
LayoutKind::Master => "master",
},
"parentLayoutId": layout.parent_layout_id,
"placeholders": layout_placeholder_list(document, &layout.layout_id, action)?,
}));
}
}
Err(PresentationArtifactError::UnsupportedFeature {
action: action.to_string(),
message: format!("unknown resolve id `{id}`"),
})
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,10 @@
include!("api.rs");
include!("manager.rs");
include!("response.rs");
include!("model.rs");
include!("args.rs");
include!("parsing.rs");
include!("proto.rs");
include!("inspect.rs");
include!("pptx.rs");
include!("snapshot.rs");

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,864 @@
fn parse_args<T>(action: &str, value: &Value) -> Result<T, PresentationArtifactError>
where
T: for<'de> Deserialize<'de>,
{
serde_json::from_value(value.clone()).map_err(|error| PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: error.to_string(),
})
}
fn required_artifact_id(
request: &PresentationArtifactRequest,
) -> Result<String, PresentationArtifactError> {
request
.artifact_id
.clone()
.ok_or_else(|| PresentationArtifactError::MissingArtifactId {
action: request.action.clone(),
})
}
fn is_read_only_action(action: &str) -> bool {
matches!(
action,
"get_summary"
| "list_slides"
| "list_layouts"
| "list_layout_placeholders"
| "list_slide_placeholders"
| "inspect"
| "resolve"
| "to_proto"
| "get_style"
| "describe_styles"
| "record_patch"
)
}
fn tracks_history(action: &str) -> bool {
!is_read_only_action(action)
&& !matches!(
action,
"export_pptx" | "export_preview" | "undo" | "redo" | "apply_patch"
)
}
fn patch_operation_supported(action: &str) -> bool {
tracks_history(action) && !matches!(action, "create" | "import_pptx" | "delete_artifact")
}
fn resolve_path(cwd: &Path, path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
cwd.join(path)
}
}
fn normalize_color(
color: &str,
action: &str,
field: &str,
) -> Result<String, PresentationArtifactError> {
normalize_color_with_palette(None, color, action, field)
}
fn normalize_color_with_document(
document: &PresentationDocument,
color: &str,
action: &str,
field: &str,
) -> Result<String, PresentationArtifactError> {
normalize_color_with_palette(Some(&document.theme), color, action, field)
}
fn normalize_color_with_palette(
theme: Option<&ThemeState>,
color: &str,
action: &str,
field: &str,
) -> Result<String, PresentationArtifactError> {
let trimmed = color.trim();
let normalized = theme
.and_then(|palette| palette.resolve_color(trimmed))
.unwrap_or_else(|| trimmed.trim_start_matches('#').to_uppercase());
if normalized.len() != 6
|| !normalized
.chars()
.all(|character| character.is_ascii_hexdigit())
{
return Err(PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("field `{field}` must be a 6-digit RGB hex color"),
});
}
Ok(normalized)
}
fn parse_shape_geometry(
geometry: &str,
action: &str,
) -> Result<ShapeGeometry, PresentationArtifactError> {
match geometry {
"rectangle" | "rect" => Ok(ShapeGeometry::Rectangle),
"rounded_rectangle" | "roundedRect" => Ok(ShapeGeometry::RoundedRectangle),
"ellipse" | "circle" => Ok(ShapeGeometry::Ellipse),
"triangle" => Ok(ShapeGeometry::Triangle),
"right_triangle" => Ok(ShapeGeometry::RightTriangle),
"diamond" => Ok(ShapeGeometry::Diamond),
"pentagon" => Ok(ShapeGeometry::Pentagon),
"hexagon" => Ok(ShapeGeometry::Hexagon),
"octagon" => Ok(ShapeGeometry::Octagon),
"star4" => Ok(ShapeGeometry::Star4),
"star" | "star5" => Ok(ShapeGeometry::Star5),
"star6" => Ok(ShapeGeometry::Star6),
"star8" => Ok(ShapeGeometry::Star8),
"right_arrow" => Ok(ShapeGeometry::RightArrow),
"left_arrow" => Ok(ShapeGeometry::LeftArrow),
"up_arrow" => Ok(ShapeGeometry::UpArrow),
"down_arrow" => Ok(ShapeGeometry::DownArrow),
"left_right_arrow" | "leftRightArrow" => Ok(ShapeGeometry::LeftRightArrow),
"up_down_arrow" | "upDownArrow" => Ok(ShapeGeometry::UpDownArrow),
"chevron" => Ok(ShapeGeometry::Chevron),
"heart" => Ok(ShapeGeometry::Heart),
"cloud" => Ok(ShapeGeometry::Cloud),
"wave" => Ok(ShapeGeometry::Wave),
"flowChartProcess" | "flow_chart_process" => Ok(ShapeGeometry::FlowChartProcess),
"flowChartDecision" | "flow_chart_decision" => Ok(ShapeGeometry::FlowChartDecision),
"flowChartConnector" | "flow_chart_connector" => Ok(ShapeGeometry::FlowChartConnector),
"parallelogram" => Ok(ShapeGeometry::Parallelogram),
"trapezoid" => Ok(ShapeGeometry::Trapezoid),
_ => Err(PresentationArtifactError::UnsupportedFeature {
action: action.to_string(),
message: format!("geometry `{geometry}` is not supported"),
}),
}
}
fn parse_chart_type(
chart_type: &str,
action: &str,
) -> Result<ChartTypeSpec, PresentationArtifactError> {
match chart_type {
"bar" => Ok(ChartTypeSpec::Bar),
"bar_horizontal" => Ok(ChartTypeSpec::BarHorizontal),
"bar_stacked" => Ok(ChartTypeSpec::BarStacked),
"bar_stacked_100" => Ok(ChartTypeSpec::BarStacked100),
"line" => Ok(ChartTypeSpec::Line),
"line_markers" => Ok(ChartTypeSpec::LineMarkers),
"line_stacked" => Ok(ChartTypeSpec::LineStacked),
"pie" => Ok(ChartTypeSpec::Pie),
"doughnut" => Ok(ChartTypeSpec::Doughnut),
"area" => Ok(ChartTypeSpec::Area),
"area_stacked" => Ok(ChartTypeSpec::AreaStacked),
"area_stacked_100" => Ok(ChartTypeSpec::AreaStacked100),
"scatter" => Ok(ChartTypeSpec::Scatter),
"scatter_lines" => Ok(ChartTypeSpec::ScatterLines),
"scatter_smooth" => Ok(ChartTypeSpec::ScatterSmooth),
"bubble" => Ok(ChartTypeSpec::Bubble),
"radar" => Ok(ChartTypeSpec::Radar),
"radar_filled" => Ok(ChartTypeSpec::RadarFilled),
"stock_hlc" => Ok(ChartTypeSpec::StockHlc),
"stock_ohlc" => Ok(ChartTypeSpec::StockOhlc),
"combo" => Ok(ChartTypeSpec::Combo),
_ => Err(PresentationArtifactError::UnsupportedFeature {
action: action.to_string(),
message: format!("chart_type `{chart_type}` is not supported"),
}),
}
}
fn parse_stroke(
document: &PresentationDocument,
stroke: Option<StrokeArgs>,
action: &str,
) -> Result<Option<StrokeStyle>, PresentationArtifactError> {
stroke
.map(|value| parse_required_stroke(document, value, action))
.transpose()
}
fn parse_required_stroke(
document: &PresentationDocument,
stroke: StrokeArgs,
action: &str,
) -> Result<StrokeStyle, PresentationArtifactError> {
Ok(StrokeStyle {
color: normalize_color_with_document(document, &stroke.color, action, "stroke.color")?,
width: stroke.width,
style: stroke
.style
.as_deref()
.map(|style| parse_line_style(style, action))
.transpose()?
.unwrap_or(LineStyle::Solid),
})
}
fn parse_connector_kind(
connector_type: &str,
action: &str,
) -> Result<ConnectorKind, PresentationArtifactError> {
match connector_type {
"straight" => Ok(ConnectorKind::Straight),
"elbow" => Ok(ConnectorKind::Elbow),
"curved" => Ok(ConnectorKind::Curved),
_ => Err(PresentationArtifactError::UnsupportedFeature {
action: action.to_string(),
message: format!("connector_type `{connector_type}` is not supported"),
}),
}
}
fn parse_connector_arrow(
value: &str,
action: &str,
) -> Result<ConnectorArrowKind, PresentationArtifactError> {
match value {
"none" => Ok(ConnectorArrowKind::None),
"triangle" => Ok(ConnectorArrowKind::Triangle),
"stealth" => Ok(ConnectorArrowKind::Stealth),
"diamond" => Ok(ConnectorArrowKind::Diamond),
"oval" => Ok(ConnectorArrowKind::Oval),
"open" => Ok(ConnectorArrowKind::Open),
_ => Err(PresentationArtifactError::UnsupportedFeature {
action: action.to_string(),
message: format!("connector arrow `{value}` is not supported"),
}),
}
}
fn parse_connector_arrow_size(
value: &str,
action: &str,
) -> Result<ConnectorArrowScale, PresentationArtifactError> {
match value {
"small" => Ok(ConnectorArrowScale::Small),
"medium" => Ok(ConnectorArrowScale::Medium),
"large" => Ok(ConnectorArrowScale::Large),
_ => Err(PresentationArtifactError::UnsupportedFeature {
action: action.to_string(),
message: format!("connector arrow_size `{value}` is not supported"),
}),
}
}
fn parse_line_style(value: &str, action: &str) -> Result<LineStyle, PresentationArtifactError> {
match value {
"solid" => Ok(LineStyle::Solid),
"dashed" => Ok(LineStyle::Dashed),
"dotted" => Ok(LineStyle::Dotted),
"dash-dot" | "dash_dot" => Ok(LineStyle::DashDot),
"dash-dot-dot" | "dash_dot_dot" => Ok(LineStyle::DashDotDot),
"long-dash" | "long_dash" => Ok(LineStyle::LongDash),
"long-dash-dot" | "long_dash_dot" => Ok(LineStyle::LongDashDot),
_ => Err(PresentationArtifactError::UnsupportedFeature {
action: action.to_string(),
message: format!("line style `{value}` is not supported"),
}),
}
}
fn parse_connector_line(
document: &PresentationDocument,
line: Option<ConnectorLineArgs>,
action: &str,
) -> Result<ParsedConnectorLine, PresentationArtifactError> {
let line = line.unwrap_or_default();
Ok(ParsedConnectorLine {
color: line
.color
.as_deref()
.map(|value| normalize_color_with_document(document, value, action, "line.color"))
.transpose()?
.unwrap_or_else(|| "000000".to_string()),
width: line.width.unwrap_or(1),
style: line
.style
.as_deref()
.map(|value| parse_line_style(value, action))
.transpose()?
.unwrap_or(LineStyle::Solid),
})
}
struct ParsedConnectorLine {
color: String,
width: u32,
style: LineStyle,
}
fn normalize_text_style_with_document(
document: &PresentationDocument,
styling: &TextStylingArgs,
action: &str,
) -> Result<TextStyle, PresentationArtifactError> {
normalize_text_style_with_palette(Some(&document.theme), styling, action, |style_name| {
document.resolve_named_text_style(style_name, action)
})
}
fn normalize_text_style_with_palette(
theme: Option<&ThemeState>,
styling: &TextStylingArgs,
action: &str,
resolve_style_name: impl Fn(&str) -> Result<TextStyle, PresentationArtifactError>,
) -> Result<TextStyle, PresentationArtifactError> {
let mut style = styling
.style
.as_deref()
.map(resolve_style_name)
.transpose()?
.unwrap_or_default();
style.font_size = styling.font_size.or(style.font_size);
style.font_family = styling.font_family.clone().or(style.font_family);
style.color = styling
.color
.as_deref()
.map(|value| normalize_color_with_palette(theme, value, action, "color"))
.transpose()?
.or(style.color);
style.alignment = styling
.alignment
.as_deref()
.map(|value| parse_alignment(value, action))
.transpose()?
.or(style.alignment);
style.bold = styling.bold.unwrap_or(style.bold);
style.italic = styling.italic.unwrap_or(style.italic);
style.underline = styling.underline.unwrap_or(style.underline);
if let Some(style_name) = &styling.style {
style.style_name = Some(normalize_style_name(style_name, action)?);
}
Ok(style)
}
fn parse_hyperlink_state(
document: &PresentationDocument,
args: &SetHyperlinkArgs,
action: &str,
) -> Result<HyperlinkState, PresentationArtifactError> {
let link_type =
args.link_type
.as_deref()
.ok_or_else(|| PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: "`link_type` is required unless `clear` is true".to_string(),
})?;
let target = match link_type {
"url" => HyperlinkTarget::Url(required_hyperlink_field(&args.url, action, "url")?.clone()),
"slide" => {
let slide_index =
args.slide_index
.ok_or_else(|| PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: "`slide_index` is required for slide hyperlinks".to_string(),
})?;
if slide_index as usize >= document.slides.len() {
return Err(index_out_of_range(
action,
slide_index as usize,
document.slides.len(),
));
}
HyperlinkTarget::Slide(slide_index)
}
"first_slide" => HyperlinkTarget::FirstSlide,
"last_slide" => HyperlinkTarget::LastSlide,
"next_slide" => HyperlinkTarget::NextSlide,
"previous_slide" => HyperlinkTarget::PreviousSlide,
"end_show" => HyperlinkTarget::EndShow,
"email" => HyperlinkTarget::Email {
address: required_hyperlink_field(&args.address, action, "address")?.clone(),
subject: args.subject.clone(),
},
"file" => {
HyperlinkTarget::File(required_hyperlink_field(&args.path, action, "path")?.clone())
}
other => {
return Err(PresentationArtifactError::UnsupportedFeature {
action: action.to_string(),
message: format!("hyperlink type `{other}` is not supported"),
});
}
};
Ok(HyperlinkState {
target,
tooltip: args.tooltip.clone(),
highlight_click: args.highlight_click.unwrap_or(true),
})
}
fn required_hyperlink_field<'a>(
value: &'a Option<String>,
action: &str,
field: &str,
) -> Result<&'a String, PresentationArtifactError> {
value
.as_ref()
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("`{field}` is required for this hyperlink type"),
})
}
fn coerce_table_rows(
rows: Vec<Vec<Value>>,
action: &str,
) -> Result<Vec<Vec<TableCellSpec>>, PresentationArtifactError> {
if rows.is_empty() {
return Err(PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: "`rows` must contain at least one row".to_string(),
});
}
Ok(rows
.into_iter()
.map(|row| {
row.into_iter()
.map(|value| TableCellSpec {
text: cell_value_to_string(value),
text_style: TextStyle::default(),
background_fill: None,
alignment: None,
})
.collect()
})
.collect())
}
fn normalize_table_dimensions(
rows: &[Vec<TableCellSpec>],
frame: Rect,
column_widths: Option<Vec<u32>>,
row_heights: Option<Vec<u32>>,
action: &str,
) -> Result<(Vec<u32>, Vec<u32>), PresentationArtifactError> {
let column_count = rows.iter().map(std::vec::Vec::len).max().unwrap_or(1);
let normalized_column_widths = match column_widths {
Some(widths) => {
if widths.len() != column_count {
return Err(PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: format!(
"`column_widths` must contain {column_count} entries for this table"
),
});
}
widths
}
None => split_points(frame.width, column_count),
};
let normalized_row_heights = match row_heights {
Some(heights) => {
if heights.len() != rows.len() {
return Err(PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: format!(
"`row_heights` must contain {} entries for this table",
rows.len()
),
});
}
heights
}
None => split_points(frame.height, rows.len()),
};
Ok((normalized_column_widths, normalized_row_heights))
}
fn split_points(total: u32, count: usize) -> Vec<u32> {
if count == 0 {
return Vec::new();
}
let base = total / count as u32;
let remainder = total % count as u32;
(0..count)
.map(|index| base + u32::from(index < remainder as usize))
.collect()
}
fn parse_alignment(value: &str, action: &str) -> Result<TextAlignment, PresentationArtifactError> {
match value {
"left" => Ok(TextAlignment::Left),
"center" | "middle" => Ok(TextAlignment::Center),
"right" => Ok(TextAlignment::Right),
"justify" => Ok(TextAlignment::Justify),
_ => Err(PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("unsupported alignment `{value}`"),
}),
}
}
fn normalize_theme(args: ThemeArgs, action: &str) -> Result<ThemeState, PresentationArtifactError> {
let color_scheme = args
.color_scheme
.into_iter()
.map(|(key, value)| {
normalize_color(&value, action, &key)
.map(|normalized| (key.to_ascii_lowercase(), normalized))
})
.collect::<Result<HashMap<_, _>, _>>()?;
Ok(ThemeState {
color_scheme,
major_font: args.major_font,
minor_font: args.minor_font,
})
}
fn normalize_style_name(
style_name: &str,
action: &str,
) -> Result<String, PresentationArtifactError> {
let normalized_style_name = style_name.trim().to_ascii_lowercase();
if normalized_style_name.is_empty() {
return Err(PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: "`name` must not be empty".to_string(),
});
}
Ok(normalized_style_name)
}
fn built_in_text_styles(theme: &ThemeState) -> HashMap<String, TextStyle> {
["title", "heading1", "body", "list", "numberedlist"]
.into_iter()
.filter_map(|name| built_in_text_style(theme, name).map(|style| (name.to_string(), style)))
.collect()
}
fn built_in_text_style(theme: &ThemeState, style_name: &str) -> Option<TextStyle> {
let default_color = theme.resolve_color("tx1");
let default_font = theme
.major_font
.clone()
.or_else(|| theme.minor_font.clone());
let body_font = theme
.minor_font
.clone()
.or_else(|| theme.major_font.clone());
let style = match style_name {
"title" => TextStyle {
style_name: Some("title".to_string()),
font_size: Some(28),
font_family: default_font,
color: default_color,
alignment: Some(TextAlignment::Left),
bold: true,
italic: false,
underline: false,
},
"heading1" => TextStyle {
style_name: Some("heading1".to_string()),
font_size: Some(22),
font_family: default_font,
color: default_color,
alignment: Some(TextAlignment::Left),
bold: true,
italic: false,
underline: false,
},
"body" => TextStyle {
style_name: Some("body".to_string()),
font_size: Some(14),
font_family: body_font,
color: default_color,
alignment: Some(TextAlignment::Left),
bold: false,
italic: false,
underline: false,
},
"list" => TextStyle {
style_name: Some("list".to_string()),
font_size: Some(14),
font_family: body_font,
color: default_color,
alignment: Some(TextAlignment::Left),
bold: false,
italic: false,
underline: false,
},
"numberedlist" => TextStyle {
style_name: Some("numberedlist".to_string()),
font_size: Some(14),
font_family: body_font,
color: default_color,
alignment: Some(TextAlignment::Left),
bold: false,
italic: false,
underline: false,
},
_ => return None,
};
Some(style)
}
fn named_text_style_to_json(style: &NamedTextStyle, id_prefix: &str) -> Value {
serde_json::json!({
"kind": "textStyle",
"id": format!("{id_prefix}/{}", style.name),
"name": style.name,
"builtIn": style.built_in,
"style": text_style_to_proto(&style.style),
})
}
fn parse_slide_size(value: &Value, action: &str) -> Result<Rect, PresentationArtifactError> {
#[derive(Deserialize)]
struct SlideSizeArgs {
width: u32,
height: u32,
}
let slide_size: SlideSizeArgs = serde_json::from_value(value.clone()).map_err(|error| {
PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("invalid slide_size: {error}"),
}
})?;
Ok(Rect {
left: 0,
top: 0,
width: slide_size.width,
height: slide_size.height,
})
}
fn apply_layout_to_slide(
document: &mut PresentationDocument,
slide: &mut PresentationSlide,
layout_ref: &str,
action: &str,
) -> Result<(), PresentationArtifactError> {
let layout = document.get_layout(layout_ref, action)?.clone();
let placeholders = resolved_layout_placeholders(document, &layout.layout_id, action)?;
slide.layout_id = Some(layout.layout_id);
for resolved in placeholders {
slide.elements.push(materialize_placeholder_element(
document.next_element_id(),
resolved.definition,
slide.elements.len(),
));
}
Ok(())
}
fn materialize_placeholder_element(
element_id: String,
placeholder: PlaceholderDefinition,
z_order: usize,
) -> PresentationElement {
let placeholder_ref = Some(PlaceholderRef {
name: placeholder.name.clone(),
placeholder_type: placeholder.placeholder_type.clone(),
index: placeholder.index,
});
if placeholder_is_image(&placeholder.placeholder_type) {
return PresentationElement::Image(ImageElement {
element_id,
frame: placeholder.frame,
payload: None,
fit_mode: ImageFitMode::Stretch,
crop: None,
rotation_degrees: None,
flip_horizontal: false,
flip_vertical: false,
lock_aspect_ratio: true,
alt_text: Some(placeholder.name.clone()),
prompt: placeholder
.text
.clone()
.or_else(|| Some(format!("Image placeholder: {}", placeholder.name))),
is_placeholder: true,
placeholder: placeholder_ref,
z_order,
});
}
if placeholder.geometry == ShapeGeometry::Rectangle {
PresentationElement::Text(TextElement {
element_id,
text: placeholder.text.unwrap_or_default(),
frame: placeholder.frame,
fill: None,
style: TextStyle::default(),
hyperlink: None,
placeholder: placeholder_ref,
z_order,
})
} else {
PresentationElement::Shape(ShapeElement {
element_id,
geometry: placeholder.geometry,
frame: placeholder.frame,
fill: None,
stroke: None,
text: placeholder.text,
text_style: TextStyle::default(),
hyperlink: None,
placeholder: placeholder_ref,
rotation_degrees: None,
flip_horizontal: false,
flip_vertical: false,
z_order,
})
}
}
fn resolved_layout_placeholders(
document: &PresentationDocument,
layout_id: &str,
action: &str,
) -> Result<Vec<ResolvedPlaceholder>, PresentationArtifactError> {
let mut lineage = Vec::new();
collect_layout_lineage(
document,
layout_id,
action,
&mut HashSet::new(),
&mut lineage,
)?;
let mut resolved: Vec<ResolvedPlaceholder> = Vec::new();
for layout in lineage {
for placeholder in &layout.placeholders {
if let Some(index) = resolved.iter().position(|entry| {
placeholder_key(&entry.definition) == placeholder_key(placeholder)
}) {
resolved[index] = ResolvedPlaceholder {
source_layout_id: layout.layout_id.clone(),
definition: placeholder.clone(),
};
} else {
resolved.push(ResolvedPlaceholder {
source_layout_id: layout.layout_id.clone(),
definition: placeholder.clone(),
});
}
}
}
Ok(resolved)
}
fn collect_layout_lineage<'a>(
document: &'a PresentationDocument,
layout_id: &str,
action: &str,
seen: &mut HashSet<String>,
lineage: &mut Vec<&'a LayoutDocument>,
) -> Result<(), PresentationArtifactError> {
if !seen.insert(layout_id.to_string()) {
return Err(PresentationArtifactError::UnsupportedFeature {
action: action.to_string(),
message: format!("layout inheritance cycle detected at `{layout_id}`"),
});
}
let layout = document.get_layout(layout_id, action)?;
if let Some(parent_layout_id) = &layout.parent_layout_id {
collect_layout_lineage(document, parent_layout_id, action, seen, lineage)?;
}
lineage.push(layout);
Ok(())
}
fn placeholder_key(placeholder: &PlaceholderDefinition) -> (String, String, Option<u32>) {
(
placeholder.name.to_ascii_lowercase(),
placeholder.placeholder_type.to_ascii_lowercase(),
placeholder.index,
)
}
fn layout_placeholder_list(
document: &PresentationDocument,
layout_id: &str,
action: &str,
) -> Result<Vec<PlaceholderListEntry>, PresentationArtifactError> {
resolved_layout_placeholders(document, layout_id, action).map(|placeholders| {
placeholders
.into_iter()
.map(|placeholder| PlaceholderListEntry {
scope: "layout".to_string(),
source_layout_id: Some(placeholder.source_layout_id),
slide_index: None,
element_id: None,
name: placeholder.definition.name,
placeholder_type: placeholder.definition.placeholder_type,
index: placeholder.definition.index,
geometry: Some(format!("{:?}", placeholder.definition.geometry)),
text_preview: placeholder.definition.text,
})
.collect()
})
}
fn placeholder_is_image(placeholder_type: &str) -> bool {
matches!(
placeholder_type.to_ascii_lowercase().as_str(),
"image" | "picture" | "pic" | "photo"
)
}
fn slide_placeholder_list(
slide: &PresentationSlide,
slide_index: usize,
) -> Vec<PlaceholderListEntry> {
slide
.elements
.iter()
.filter_map(|element| match element {
PresentationElement::Text(text) => {
text.placeholder
.as_ref()
.map(|placeholder| PlaceholderListEntry {
scope: "slide".to_string(),
source_layout_id: slide.layout_id.clone(),
slide_index: Some(slide_index),
element_id: Some(text.element_id.clone()),
name: placeholder.name.clone(),
placeholder_type: placeholder.placeholder_type.clone(),
index: placeholder.index,
geometry: Some("Rectangle".to_string()),
text_preview: Some(text.text.clone()),
})
}
PresentationElement::Shape(shape) => {
shape
.placeholder
.as_ref()
.map(|placeholder| PlaceholderListEntry {
scope: "slide".to_string(),
source_layout_id: slide.layout_id.clone(),
slide_index: Some(slide_index),
element_id: Some(shape.element_id.clone()),
name: placeholder.name.clone(),
placeholder_type: placeholder.placeholder_type.clone(),
index: placeholder.index,
geometry: Some(format!("{:?}", shape.geometry)),
text_preview: shape.text.clone(),
})
}
PresentationElement::Image(image) => {
image
.placeholder
.as_ref()
.map(|placeholder| PlaceholderListEntry {
scope: "slide".to_string(),
source_layout_id: slide.layout_id.clone(),
slide_index: Some(slide_index),
element_id: Some(image.element_id.clone()),
name: placeholder.name.clone(),
placeholder_type: placeholder.placeholder_type.clone(),
index: placeholder.index,
geometry: Some("Image".to_string()),
text_preview: image.prompt.clone(),
})
}
PresentationElement::Connector(_)
| PresentationElement::Table(_)
| PresentationElement::Chart(_) => None,
})
.collect()
}

View file

@ -0,0 +1,922 @@
fn build_pptx_bytes(document: &PresentationDocument, action: &str) -> Result<Vec<u8>, String> {
let bytes = document
.to_ppt_rs()
.build()
.map_err(|error| format!("{action}: {error}"))?;
patch_pptx_package(bytes, document).map_err(|error| format!("{action}: {error}"))
}
struct SlideImageAsset {
xml: String,
relationship_xml: String,
media_path: String,
media_bytes: Vec<u8>,
extension: String,
}
fn normalized_image_extension(format: &str) -> String {
match format.to_ascii_lowercase().as_str() {
"jpeg" => "jpg".to_string(),
other => other.to_string(),
}
}
fn image_relationship_xml(relationship_id: &str, target: &str) -> String {
format!(
r#"<Relationship Id="{relationship_id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="{}"/>"#,
ppt_rs::escape_xml(target)
)
}
fn image_picture_xml(
image: &ImageElement,
shape_id: usize,
relationship_id: &str,
frame: Rect,
crop: Option<ImageCrop>,
) -> String {
let blip_fill = if let Some((crop_left, crop_top, crop_right, crop_bottom)) = crop {
format!(
r#"<p:blipFill>
<a:blip r:embed="{relationship_id}"/>
<a:srcRect l="{}" t="{}" r="{}" b="{}"/>
<a:stretch>
<a:fillRect/>
</a:stretch>
</p:blipFill>"#,
(crop_left * 100_000.0).round() as u32,
(crop_top * 100_000.0).round() as u32,
(crop_right * 100_000.0).round() as u32,
(crop_bottom * 100_000.0).round() as u32,
)
} else {
format!(
r#"<p:blipFill>
<a:blip r:embed="{relationship_id}"/>
<a:stretch>
<a:fillRect/>
</a:stretch>
</p:blipFill>"#
)
};
let descr = image
.alt_text
.as_deref()
.map(|alt| format!(r#" descr="{}""#, ppt_rs::escape_xml(alt)))
.unwrap_or_default();
let no_change_aspect = if image.lock_aspect_ratio { 1 } else { 0 };
let rotation = image
.rotation_degrees
.map(|rotation| format!(r#" rot="{}""#, i64::from(rotation) * 60_000))
.unwrap_or_default();
let flip_horizontal = if image.flip_horizontal {
r#" flipH="1""#
} else {
""
};
let flip_vertical = if image.flip_vertical {
r#" flipV="1""#
} else {
""
};
format!(
r#"<p:pic>
<p:nvPicPr>
<p:cNvPr id="{shape_id}" name="Picture {shape_id}"{descr}/>
<p:cNvPicPr>
<a:picLocks noChangeAspect="{no_change_aspect}"/>
</p:cNvPicPr>
<p:nvPr/>
</p:nvPicPr>
{blip_fill}
<p:spPr>
<a:xfrm{rotation}{flip_horizontal}{flip_vertical}>
<a:off x="{}" y="{}"/>
<a:ext cx="{}" cy="{}"/>
</a:xfrm>
<a:prstGeom prst="rect">
<a:avLst/>
</a:prstGeom>
</p:spPr>
</p:pic>"#,
points_to_emu(frame.left),
points_to_emu(frame.top),
points_to_emu(frame.width),
points_to_emu(frame.height),
)
}
fn slide_image_assets(
slide: &PresentationSlide,
next_media_index: &mut usize,
) -> Vec<SlideImageAsset> {
let mut ordered = slide.elements.iter().collect::<Vec<_>>();
ordered.sort_by_key(|element| element.z_order());
let shape_count = ordered
.iter()
.filter(|element| {
matches!(
element,
PresentationElement::Text(_)
| PresentationElement::Shape(_)
| PresentationElement::Image(ImageElement { payload: None, .. })
)
})
.count()
+ usize::from(slide.background_fill.is_some());
let mut image_index = 0_usize;
let mut assets = Vec::new();
for element in ordered {
let PresentationElement::Image(image) = element else {
continue;
};
let Some(payload) = &image.payload else {
continue;
};
let (left, top, width, height, fitted_crop) = if image.fit_mode != ImageFitMode::Stretch {
fit_image(image)
} else {
(
image.frame.left,
image.frame.top,
image.frame.width,
image.frame.height,
None,
)
};
image_index += 1;
let relationship_id = format!("rIdImage{image_index}");
let extension = normalized_image_extension(&payload.format);
let media_name = format!("image{next_media_index}.{extension}");
*next_media_index += 1;
assets.push(SlideImageAsset {
xml: image_picture_xml(
image,
20 + shape_count + image_index - 1,
&relationship_id,
Rect {
left,
top,
width,
height,
},
image.crop.or(fitted_crop),
),
relationship_xml: image_relationship_xml(
&relationship_id,
&format!("../media/{media_name}"),
),
media_path: format!("ppt/media/{media_name}"),
media_bytes: payload.bytes.clone(),
extension,
});
}
assets
}
fn patch_pptx_package(
source_bytes: Vec<u8>,
document: &PresentationDocument,
) -> Result<Vec<u8>, String> {
let mut archive =
ZipArchive::new(Cursor::new(source_bytes)).map_err(|error| error.to_string())?;
let mut writer = ZipWriter::new(Cursor::new(Vec::new()));
let mut next_media_index = 1_usize;
let mut pending_slide_relationships = HashMap::new();
let mut pending_slide_images = HashMap::new();
let mut pending_media = Vec::new();
let mut image_extensions = BTreeSet::new();
for (slide_index, slide) in document.slides.iter().enumerate() {
let slide_number = slide_index + 1;
let images = slide_image_assets(slide, &mut next_media_index);
let mut relationships = slide_hyperlink_relationships(slide);
relationships.extend(images.iter().map(|image| image.relationship_xml.clone()));
if !relationships.is_empty() {
pending_slide_relationships.insert(slide_number, relationships);
}
if !images.is_empty() {
image_extensions.extend(images.iter().map(|image| image.extension.clone()));
pending_media.extend(
images
.iter()
.map(|image| (image.media_path.clone(), image.media_bytes.clone())),
);
pending_slide_images.insert(slide_number, images);
}
}
for index in 0..archive.len() {
let mut file = archive.by_index(index).map_err(|error| error.to_string())?;
if file.is_dir() {
continue;
}
let name = file.name().to_string();
let options = file.options();
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)
.map_err(|error| error.to_string())?;
writer
.start_file(&name, options)
.map_err(|error| error.to_string())?;
if name == "[Content_Types].xml" {
writer
.write_all(update_content_types_xml(bytes, &image_extensions)?.as_bytes())
.map_err(|error| error.to_string())?;
continue;
}
if name == "ppt/presentation.xml" {
writer
.write_all(
update_presentation_xml_dimensions(bytes, document.slide_size)?.as_bytes(),
)
.map_err(|error| error.to_string())?;
continue;
}
if let Some(slide_number) = parse_slide_xml_path(&name) {
writer
.write_all(
update_slide_xml(
bytes,
&document.slides[slide_number - 1],
pending_slide_images
.get(&slide_number)
.map(std::vec::Vec::as_slice)
.unwrap_or(&[]),
)?
.as_bytes(),
)
.map_err(|error| error.to_string())?;
continue;
}
if let Some(slide_number) = parse_slide_relationships_path(&name)
&& let Some(relationships) = pending_slide_relationships.remove(&slide_number)
{
writer
.write_all(update_slide_relationships_xml(bytes, &relationships)?.as_bytes())
.map_err(|error| error.to_string())?;
continue;
}
writer
.write_all(&bytes)
.map_err(|error| error.to_string())?;
}
for (slide_number, relationships) in pending_slide_relationships {
writer
.start_file(
format!("ppt/slides/_rels/slide{slide_number}.xml.rels"),
SimpleFileOptions::default(),
)
.map_err(|error| error.to_string())?;
writer
.write_all(slide_relationships_xml(&relationships).as_bytes())
.map_err(|error| error.to_string())?;
}
for (path, bytes) in pending_media {
writer
.start_file(path, SimpleFileOptions::default())
.map_err(|error| error.to_string())?;
writer
.write_all(&bytes)
.map_err(|error| error.to_string())?;
}
writer
.finish()
.map_err(|error| error.to_string())
.map(Cursor::into_inner)
}
fn update_presentation_xml_dimensions(
existing_bytes: Vec<u8>,
slide_size: Rect,
) -> Result<String, String> {
let existing = String::from_utf8(existing_bytes).map_err(|error| error.to_string())?;
let updated = replace_self_closing_xml_tag(
&existing,
"p:sldSz",
&format!(
r#"<p:sldSz cx="{}" cy="{}" type="screen4x3"/>"#,
points_to_emu(slide_size.width),
points_to_emu(slide_size.height)
),
)?;
replace_self_closing_xml_tag(
&updated,
"p:notesSz",
&format!(
r#"<p:notesSz cx="{}" cy="{}"/>"#,
points_to_emu(slide_size.height),
points_to_emu(slide_size.width)
),
)
}
fn replace_self_closing_xml_tag(xml: &str, tag: &str, replacement: &str) -> Result<String, String> {
let start = xml
.find(&format!("<{tag} "))
.ok_or_else(|| format!("presentation xml is missing `<{tag} .../>`"))?;
let end = xml[start..]
.find("/>")
.map(|offset| start + offset + 2)
.ok_or_else(|| format!("presentation xml tag `{tag}` is not self-closing"))?;
Ok(format!("{}{replacement}{}", &xml[..start], &xml[end..]))
}
fn slide_hyperlink_relationships(slide: &PresentationSlide) -> Vec<String> {
let mut ordered = slide.elements.iter().collect::<Vec<_>>();
ordered.sort_by_key(|element| element.z_order());
let mut hyperlink_index = 1_u32;
let mut relationships = Vec::new();
for element in ordered {
let Some(hyperlink) = (match element {
PresentationElement::Text(text) => text.hyperlink.as_ref(),
PresentationElement::Shape(shape) => shape.hyperlink.as_ref(),
PresentationElement::Connector(_)
| PresentationElement::Image(_)
| PresentationElement::Table(_)
| PresentationElement::Chart(_) => None,
}) else {
continue;
};
let relationship_id = format!("rIdHyperlink{hyperlink_index}");
hyperlink_index += 1;
relationships.push(hyperlink.relationship_xml(&relationship_id));
}
relationships
}
fn parse_slide_relationships_path(path: &str) -> Option<usize> {
path.strip_prefix("ppt/slides/_rels/slide")?
.strip_suffix(".xml.rels")?
.parse::<usize>()
.ok()
}
fn parse_slide_xml_path(path: &str) -> Option<usize> {
path.strip_prefix("ppt/slides/slide")?
.strip_suffix(".xml")?
.parse::<usize>()
.ok()
}
fn update_slide_relationships_xml(
existing_bytes: Vec<u8>,
relationships: &[String],
) -> Result<String, String> {
let existing = String::from_utf8(existing_bytes).map_err(|error| error.to_string())?;
let injected = relationships.join("\n");
existing
.contains("</Relationships>")
.then(|| existing.replace("</Relationships>", &format!("{injected}\n</Relationships>")))
.ok_or_else(|| {
"slide relationships xml is missing a closing `</Relationships>`".to_string()
})
}
fn slide_relationships_xml(relationships: &[String]) -> String {
let body = relationships.join("\n");
format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
{body}
</Relationships>"#
)
}
fn update_content_types_xml(
existing_bytes: Vec<u8>,
image_extensions: &BTreeSet<String>,
) -> Result<String, String> {
let existing = String::from_utf8(existing_bytes).map_err(|error| error.to_string())?;
if image_extensions.is_empty() {
return Ok(existing);
}
let existing_lower = existing.to_ascii_lowercase();
let additions = image_extensions
.iter()
.filter(|extension| {
!existing_lower.contains(&format!(
r#"extension="{}""#,
extension.to_ascii_lowercase()
))
})
.map(|extension| generate_image_content_type(extension))
.collect::<Vec<_>>();
if additions.is_empty() {
return Ok(existing);
}
existing
.contains("</Types>")
.then(|| existing.replace("</Types>", &format!("{}\n</Types>", additions.join("\n"))))
.ok_or_else(|| "content types xml is missing a closing `</Types>`".to_string())
}
fn update_slide_xml(
existing_bytes: Vec<u8>,
slide: &PresentationSlide,
slide_images: &[SlideImageAsset],
) -> Result<String, String> {
let existing = String::from_utf8(existing_bytes).map_err(|error| error.to_string())?;
let existing = replace_image_placeholders(existing, slide_images)?;
let existing = apply_shape_block_patches(existing, slide)?;
let table_xml = slide_table_xml(slide);
if table_xml.is_empty() {
return Ok(existing);
}
existing
.contains("</p:spTree>")
.then(|| existing.replace("</p:spTree>", &format!("{table_xml}\n</p:spTree>")))
.ok_or_else(|| "slide xml is missing a closing `</p:spTree>`".to_string())
}
fn replace_image_placeholders(
existing: String,
slide_images: &[SlideImageAsset],
) -> Result<String, String> {
if slide_images.is_empty() {
return Ok(existing);
}
let mut updated = String::with_capacity(existing.len());
let mut remaining = existing.as_str();
for image in slide_images {
let marker = remaining
.find("name=\"Image Placeholder: ")
.ok_or_else(|| {
"slide xml is missing an image placeholder block for exported images".to_string()
})?;
let start = remaining[..marker].rfind("<p:sp>").ok_or_else(|| {
"slide xml is missing an opening `<p:sp>` for image placeholder".to_string()
})?;
let end = remaining[marker..]
.find("</p:sp>")
.map(|offset| marker + offset + "</p:sp>".len())
.ok_or_else(|| {
"slide xml is missing a closing `</p:sp>` for image placeholder".to_string()
})?;
updated.push_str(&remaining[..start]);
updated.push_str(&image.xml);
remaining = &remaining[end..];
}
updated.push_str(remaining);
Ok(updated)
}
#[derive(Clone, Copy)]
struct ShapeXmlPatch {
line_style: Option<LineStyle>,
flip_horizontal: bool,
flip_vertical: bool,
}
fn apply_shape_block_patches(
existing: String,
slide: &PresentationSlide,
) -> Result<String, String> {
let mut patches = Vec::new();
if slide.background_fill.is_some() {
patches.push(None);
}
let mut ordered = slide.elements.iter().collect::<Vec<_>>();
ordered.sort_by_key(|element| element.z_order());
for element in ordered {
match element {
PresentationElement::Text(_) => patches.push(None),
PresentationElement::Shape(shape) => patches.push(Some(ShapeXmlPatch {
line_style: shape
.stroke
.as_ref()
.map(|stroke| stroke.style)
.filter(|style| *style != LineStyle::Solid),
flip_horizontal: shape.flip_horizontal,
flip_vertical: shape.flip_vertical,
})),
PresentationElement::Image(ImageElement { payload: None, .. }) => patches.push(None),
PresentationElement::Connector(_)
| PresentationElement::Image(_)
| PresentationElement::Table(_)
| PresentationElement::Chart(_) => {}
}
}
if patches.iter().all(|patch| {
patch.is_none_or(|patch| {
patch.line_style.is_none() && !patch.flip_horizontal && !patch.flip_vertical
})
}) {
return Ok(existing);
}
let mut updated = String::with_capacity(existing.len());
let mut remaining = existing.as_str();
for patch in patches {
let Some(start) = remaining.find("<p:sp>") else {
return Err("slide xml is missing an expected `<p:sp>` block".to_string());
};
let end = remaining[start..]
.find("</p:sp>")
.map(|offset| start + offset + "</p:sp>".len())
.ok_or_else(|| "slide xml is missing a closing `</p:sp>` block".to_string())?;
updated.push_str(&remaining[..start]);
let block = &remaining[start..end];
if let Some(patch) = patch {
updated.push_str(&patch_shape_block(block, patch)?);
} else {
updated.push_str(block);
}
remaining = &remaining[end..];
}
updated.push_str(remaining);
Ok(updated)
}
fn patch_shape_block(block: &str, patch: ShapeXmlPatch) -> Result<String, String> {
let block = if let Some(line_style) = patch.line_style {
patch_shape_block_dash(block, line_style)?
} else {
block.to_string()
};
if patch.flip_horizontal || patch.flip_vertical {
patch_shape_block_flip(&block, patch.flip_horizontal, patch.flip_vertical)
} else {
Ok(block)
}
}
fn patch_shape_block_dash(block: &str, line_style: LineStyle) -> Result<String, String> {
let Some(line_start) = block.find("<a:ln") else {
return Err("shape block is missing an `<a:ln>` entry for stroke styling".to_string());
};
if let Some(dash_start) = block[line_start..].find("<a:prstDash") {
let dash_start = line_start + dash_start;
let dash_end = block[dash_start..]
.find("/>")
.map(|offset| dash_start + offset + 2)
.ok_or_else(|| "shape line dash entry is missing a closing `/>`".to_string())?;
let mut patched = String::with_capacity(block.len() + 32);
patched.push_str(&block[..dash_start]);
patched.push_str(&format!(
r#"<a:prstDash val="{}"/>"#,
line_style.to_ppt_xml()
));
patched.push_str(&block[dash_end..]);
return Ok(patched);
}
if let Some(line_end) = block[line_start..].find("</a:ln>") {
let line_end = line_start + line_end;
let mut patched = String::with_capacity(block.len() + 32);
patched.push_str(&block[..line_end]);
patched.push_str(&format!(
r#"<a:prstDash val="{}"/>"#,
line_style.to_ppt_xml()
));
patched.push_str(&block[line_end..]);
return Ok(patched);
}
let line_end = block[line_start..]
.find("/>")
.map(|offset| line_start + offset + 2)
.ok_or_else(|| "shape line entry is missing a closing marker".to_string())?;
let line_tag = &block[line_start..line_end - 2];
let mut patched = String::with_capacity(block.len() + 48);
patched.push_str(&block[..line_start]);
patched.push_str(line_tag);
patched.push('>');
patched.push_str(&format!(
r#"<a:prstDash val="{}"/>"#,
line_style.to_ppt_xml()
));
patched.push_str("</a:ln>");
patched.push_str(&block[line_end..]);
Ok(patched)
}
fn patch_shape_block_flip(
block: &str,
flip_horizontal: bool,
flip_vertical: bool,
) -> Result<String, String> {
let Some(xfrm_start) = block.find("<a:xfrm") else {
return Err("shape block is missing an `<a:xfrm>` entry for flip styling".to_string());
};
let tag_end = block[xfrm_start..]
.find('>')
.map(|offset| xfrm_start + offset)
.ok_or_else(|| "shape transform entry is missing a closing `>`".to_string())?;
let tag = &block[xfrm_start..=tag_end];
let mut patched_tag = tag.to_string();
patched_tag = upsert_xml_attribute(
&patched_tag,
"flipH",
if flip_horizontal { "1" } else { "0" },
);
patched_tag =
upsert_xml_attribute(&patched_tag, "flipV", if flip_vertical { "1" } else { "0" });
Ok(format!(
"{}{}{}",
&block[..xfrm_start],
patched_tag,
&block[tag_end + 1..]
))
}
fn upsert_xml_attribute(tag: &str, attribute: &str, value: &str) -> String {
let needle = format!(r#"{attribute}=""#);
if let Some(start) = tag.find(&needle) {
let value_start = start + needle.len();
if let Some(end_offset) = tag[value_start..].find('"') {
let end = value_start + end_offset;
return format!("{}{}{}", &tag[..value_start], value, &tag[end..]);
}
}
let insert_at = tag.len() - 1;
format!(r#"{} {attribute}="{value}""#, &tag[..insert_at]) + &tag[insert_at..]
}
fn slide_table_xml(slide: &PresentationSlide) -> String {
let mut ordered = slide.elements.iter().collect::<Vec<_>>();
ordered.sort_by_key(|element| element.z_order());
let mut table_index = 0_usize;
ordered
.into_iter()
.filter_map(|element| {
let PresentationElement::Table(table) = element else {
return None;
};
table_index += 1;
let rows = table
.rows
.clone()
.into_iter()
.enumerate()
.map(|(row_index, row)| {
let cells = row
.into_iter()
.enumerate()
.map(|(column_index, cell)| {
build_table_cell(cell, &table.merges, row_index, column_index)
})
.collect::<Vec<_>>();
let mut table_row = TableRow::new(cells);
if let Some(height) = table.row_heights.get(row_index) {
table_row = table_row.with_height(points_to_emu(*height));
}
Some(table_row)
})
.collect::<Option<Vec<_>>>()?;
Some(ppt_rs::generator::table::generate_table_xml(
&ppt_rs::generator::table::Table::new(
rows,
table
.column_widths
.iter()
.copied()
.map(points_to_emu)
.collect(),
points_to_emu(table.frame.left),
points_to_emu(table.frame.top),
),
300 + table_index,
))
})
.collect::<Vec<_>>()
.join("\n")
}
fn write_preview_images(
document: &PresentationDocument,
output_dir: &Path,
action: &str,
) -> Result<(), PresentationArtifactError> {
let pptx_path = output_dir.join("preview.pptx");
let bytes = build_pptx_bytes(document, action).map_err(|message| {
PresentationArtifactError::ExportFailed {
path: pptx_path.clone(),
message,
}
})?;
std::fs::write(&pptx_path, bytes).map_err(|error| PresentationArtifactError::ExportFailed {
path: pptx_path.clone(),
message: error.to_string(),
})?;
render_pptx_to_pngs(&pptx_path, output_dir, action)
}
fn render_pptx_to_pngs(
pptx_path: &Path,
output_dir: &Path,
action: &str,
) -> Result<(), PresentationArtifactError> {
let soffice_cmd = if cfg!(target_os = "macos")
&& Path::new("/Applications/LibreOffice.app/Contents/MacOS/soffice").exists()
{
"/Applications/LibreOffice.app/Contents/MacOS/soffice"
} else {
"soffice"
};
let conversion = Command::new(soffice_cmd)
.arg("--headless")
.arg("--convert-to")
.arg("pdf")
.arg(pptx_path)
.arg("--outdir")
.arg(output_dir)
.output()
.map_err(|error| PresentationArtifactError::ExportFailed {
path: pptx_path.to_path_buf(),
message: format!("{action}: failed to execute LibreOffice: {error}"),
})?;
if !conversion.status.success() {
return Err(PresentationArtifactError::ExportFailed {
path: pptx_path.to_path_buf(),
message: format!(
"{action}: LibreOffice conversion failed: {}",
String::from_utf8_lossy(&conversion.stderr)
),
});
}
let pdf_path = output_dir.join(
pptx_path
.file_stem()
.and_then(|stem| stem.to_str())
.map(|stem| format!("{stem}.pdf"))
.ok_or_else(|| PresentationArtifactError::ExportFailed {
path: pptx_path.to_path_buf(),
message: format!("{action}: preview pptx filename is invalid"),
})?,
);
let prefix = output_dir.join("slide");
let conversion = Command::new("pdftoppm")
.arg("-png")
.arg(&pdf_path)
.arg(&prefix)
.output()
.map_err(|error| PresentationArtifactError::ExportFailed {
path: pdf_path.clone(),
message: format!("{action}: failed to execute pdftoppm: {error}"),
})?;
std::fs::remove_file(&pdf_path).ok();
if !conversion.status.success() {
return Err(PresentationArtifactError::ExportFailed {
path: output_dir.to_path_buf(),
message: format!(
"{action}: pdftoppm conversion failed: {}",
String::from_utf8_lossy(&conversion.stderr)
),
});
}
Ok(())
}
pub(crate) fn write_preview_image(
source_path: &Path,
target_path: &Path,
format: PreviewOutputFormat,
scale: f32,
quality: u8,
action: &str,
) -> Result<(), PresentationArtifactError> {
if matches!(format, PreviewOutputFormat::Png) && scale == 1.0 {
std::fs::rename(source_path, target_path).map_err(|error| {
PresentationArtifactError::ExportFailed {
path: target_path.to_path_buf(),
message: error.to_string(),
}
})?;
return Ok(());
}
let mut preview =
image::open(source_path).map_err(|error| PresentationArtifactError::ExportFailed {
path: source_path.to_path_buf(),
message: format!("{action}: {error}"),
})?;
if scale != 1.0 {
let width = (preview.width() as f32 * scale).round().max(1.0) as u32;
let height = (preview.height() as f32 * scale).round().max(1.0) as u32;
preview = preview.resize_exact(width, height, FilterType::Lanczos3);
}
let file = std::fs::File::create(target_path).map_err(|error| {
PresentationArtifactError::ExportFailed {
path: target_path.to_path_buf(),
message: error.to_string(),
}
})?;
let mut writer = std::io::BufWriter::new(file);
match format {
PreviewOutputFormat::Png => {
preview
.write_to(&mut writer, ImageFormat::Png)
.map_err(|error| PresentationArtifactError::ExportFailed {
path: target_path.to_path_buf(),
message: format!("{action}: {error}"),
})?
}
PreviewOutputFormat::Jpeg => {
let rgb = preview.to_rgb8();
let mut encoder = JpegEncoder::new_with_quality(&mut writer, quality);
encoder.encode_image(&rgb).map_err(|error| {
PresentationArtifactError::ExportFailed {
path: target_path.to_path_buf(),
message: format!("{action}: {error}"),
}
})?;
}
PreviewOutputFormat::Svg => {
let mut png_bytes = Cursor::new(Vec::new());
preview
.write_to(&mut png_bytes, ImageFormat::Png)
.map_err(|error| PresentationArtifactError::ExportFailed {
path: target_path.to_path_buf(),
message: format!("{action}: {error}"),
})?;
let embedded_png = BASE64_STANDARD.encode(png_bytes.into_inner());
let svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="{}" height="{}" viewBox="0 0 {} {}"><image href="data:image/png;base64,{embedded_png}" width="{}" height="{}"/></svg>"#,
preview.width(),
preview.height(),
preview.width(),
preview.height(),
preview.width(),
preview.height(),
);
writer.write_all(svg.as_bytes()).map_err(|error| {
PresentationArtifactError::ExportFailed {
path: target_path.to_path_buf(),
message: format!("{action}: {error}"),
}
})?;
}
}
std::fs::remove_file(source_path).ok();
Ok(())
}
fn collect_pngs(output_dir: &Path) -> Result<Vec<PathBuf>, PresentationArtifactError> {
let mut files = std::fs::read_dir(output_dir)
.map_err(|error| PresentationArtifactError::ExportFailed {
path: output_dir.to_path_buf(),
message: error.to_string(),
})?
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("png"))
.collect::<Vec<_>>();
files.sort();
Ok(files)
}
fn parse_preview_output_format(
format: Option<&str>,
path: &Path,
action: &str,
) -> Result<PreviewOutputFormat, PresentationArtifactError> {
let value = format
.map(str::to_owned)
.or_else(|| {
path.extension()
.and_then(|extension| extension.to_str())
.map(str::to_owned)
})
.unwrap_or_else(|| "png".to_string());
match value.to_ascii_lowercase().as_str() {
"png" => Ok(PreviewOutputFormat::Png),
"jpg" | "jpeg" => Ok(PreviewOutputFormat::Jpeg),
"svg" => Ok(PreviewOutputFormat::Svg),
other => Err(PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("preview format `{other}` is not supported"),
}),
}
}
fn normalize_preview_scale(
scale: Option<f32>,
action: &str,
) -> Result<f32, PresentationArtifactError> {
let scale = scale.unwrap_or(1.0);
if !scale.is_finite() || scale <= 0.0 {
return Err(PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: "`scale` must be a positive number".to_string(),
});
}
Ok(scale)
}
fn normalize_preview_quality(
quality: Option<u8>,
action: &str,
) -> Result<u8, PresentationArtifactError> {
let quality = quality.unwrap_or(90);
if quality == 0 || quality > 100 {
return Err(PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: "`quality` must be between 1 and 100".to_string(),
});
}
Ok(quality)
}

View file

@ -0,0 +1,356 @@
fn document_to_proto(
document: &PresentationDocument,
action: &str,
) -> Result<Value, PresentationArtifactError> {
let layouts = document
.layouts
.iter()
.map(|layout| layout_to_proto(document, layout, action))
.collect::<Result<Vec<_>, _>>()?;
let slides = document
.slides
.iter()
.enumerate()
.map(|(slide_index, slide)| slide_to_proto(slide, slide_index))
.collect::<Vec<_>>();
Ok(serde_json::json!({
"kind": "presentation",
"artifactId": document.artifact_id,
"anchor": format!("pr/{}", document.artifact_id),
"name": document.name,
"slideSize": rect_to_proto(document.slide_size),
"activeSlideIndex": document.active_slide_index,
"activeSlideId": document.active_slide_index.and_then(|index| document.slides.get(index)).map(|slide| slide.slide_id.clone()),
"theme": serde_json::json!({
"colorScheme": document.theme.color_scheme,
"hexColorMap": document.theme.color_scheme,
"majorFont": document.theme.major_font,
"minorFont": document.theme.minor_font,
}),
"styles": document
.named_text_styles()
.iter()
.map(|style| named_text_style_to_json(style, "st"))
.collect::<Vec<_>>(),
"masters": document.layouts.iter().filter(|layout| layout.kind == LayoutKind::Master).map(|layout| layout.layout_id.clone()).collect::<Vec<_>>(),
"layouts": layouts,
"slides": slides,
}))
}
fn layout_to_proto(
document: &PresentationDocument,
layout: &LayoutDocument,
action: &str,
) -> Result<Value, PresentationArtifactError> {
let placeholders = layout
.placeholders
.iter()
.map(placeholder_definition_to_proto)
.collect::<Vec<_>>();
let resolved_placeholders = resolved_layout_placeholders(document, &layout.layout_id, action)?
.into_iter()
.map(|placeholder| {
let mut value = placeholder_definition_to_proto(&placeholder.definition);
value["sourceLayoutId"] = Value::String(placeholder.source_layout_id);
value
})
.collect::<Vec<_>>();
Ok(serde_json::json!({
"layoutId": layout.layout_id,
"anchor": format!("ly/{}", layout.layout_id),
"name": layout.name,
"kind": match layout.kind {
LayoutKind::Layout => "layout",
LayoutKind::Master => "master",
},
"parentLayoutId": layout.parent_layout_id,
"placeholders": placeholders,
"resolvedPlaceholders": resolved_placeholders,
}))
}
fn placeholder_definition_to_proto(placeholder: &PlaceholderDefinition) -> Value {
serde_json::json!({
"name": placeholder.name,
"placeholderType": placeholder.placeholder_type,
"index": placeholder.index,
"text": placeholder.text,
"geometry": format!("{:?}", placeholder.geometry),
"frame": rect_to_proto(placeholder.frame),
})
}
fn slide_to_proto(slide: &PresentationSlide, slide_index: usize) -> Value {
serde_json::json!({
"slideId": slide.slide_id,
"anchor": format!("sl/{}", slide.slide_id),
"index": slide_index,
"layoutId": slide.layout_id,
"backgroundFill": slide.background_fill,
"notes": serde_json::json!({
"anchor": format!("nt/{}", slide.slide_id),
"text": slide.notes.text,
"visible": slide.notes.visible,
"textPreview": slide.notes.text.replace('\n', " | "),
"textChars": slide.notes.text.chars().count(),
"textLines": slide.notes.text.lines().count(),
}),
"elements": slide.elements.iter().map(element_to_proto).collect::<Vec<_>>(),
})
}
fn element_to_proto(element: &PresentationElement) -> Value {
match element {
PresentationElement::Text(text) => {
let mut record = serde_json::json!({
"kind": "text",
"elementId": text.element_id,
"anchor": format!("sh/{}", text.element_id),
"frame": rect_to_proto(text.frame),
"text": text.text,
"textPreview": text.text.replace('\n', " | "),
"textChars": text.text.chars().count(),
"textLines": text.text.lines().count(),
"fill": text.fill,
"style": text_style_to_proto(&text.style),
"zOrder": text.z_order,
});
if let Some(placeholder) = &text.placeholder {
record["placeholder"] = placeholder_ref_to_proto(placeholder);
}
if let Some(hyperlink) = &text.hyperlink {
record["hyperlink"] = hyperlink.to_json();
}
record
}
PresentationElement::Shape(shape) => {
let mut record = serde_json::json!({
"kind": "shape",
"elementId": shape.element_id,
"anchor": format!("sh/{}", shape.element_id),
"geometry": format!("{:?}", shape.geometry),
"frame": rect_to_proto(shape.frame),
"fill": shape.fill,
"stroke": shape.stroke.as_ref().map(stroke_to_proto),
"text": shape.text,
"textStyle": text_style_to_proto(&shape.text_style),
"rotation": shape.rotation_degrees,
"flipHorizontal": shape.flip_horizontal,
"flipVertical": shape.flip_vertical,
"zOrder": shape.z_order,
});
if let Some(text) = &shape.text {
record["textPreview"] = Value::String(text.replace('\n', " | "));
record["textChars"] = Value::from(text.chars().count());
record["textLines"] = Value::from(text.lines().count());
}
if let Some(placeholder) = &shape.placeholder {
record["placeholder"] = placeholder_ref_to_proto(placeholder);
}
if let Some(hyperlink) = &shape.hyperlink {
record["hyperlink"] = hyperlink.to_json();
}
record
}
PresentationElement::Connector(connector) => serde_json::json!({
"kind": "connector",
"elementId": connector.element_id,
"anchor": format!("cn/{}", connector.element_id),
"connectorType": format!("{:?}", connector.connector_type),
"start": serde_json::json!({
"left": connector.start.left,
"top": connector.start.top,
"unit": "points",
}),
"end": serde_json::json!({
"left": connector.end.left,
"top": connector.end.top,
"unit": "points",
}),
"line": stroke_to_proto(&connector.line),
"lineStyle": connector.line_style.as_api_str(),
"startArrow": format!("{:?}", connector.start_arrow),
"endArrow": format!("{:?}", connector.end_arrow),
"arrowSize": format!("{:?}", connector.arrow_size),
"label": connector.label,
"zOrder": connector.z_order,
}),
PresentationElement::Image(image) => {
let mut record = serde_json::json!({
"kind": "image",
"elementId": image.element_id,
"anchor": format!("im/{}", image.element_id),
"frame": rect_to_proto(image.frame),
"fit": format!("{:?}", image.fit_mode),
"crop": image.crop.map(|(left, top, right, bottom)| serde_json::json!({
"left": left,
"top": top,
"right": right,
"bottom": bottom,
})),
"rotation": image.rotation_degrees,
"flipHorizontal": image.flip_horizontal,
"flipVertical": image.flip_vertical,
"lockAspectRatio": image.lock_aspect_ratio,
"alt": image.alt_text,
"prompt": image.prompt,
"isPlaceholder": image.is_placeholder,
"payload": image.payload.as_ref().map(image_payload_to_proto),
"zOrder": image.z_order,
});
if let Some(placeholder) = &image.placeholder {
record["placeholder"] = placeholder_ref_to_proto(placeholder);
}
record
}
PresentationElement::Table(table) => serde_json::json!({
"kind": "table",
"elementId": table.element_id,
"anchor": format!("tb/{}", table.element_id),
"frame": rect_to_proto(table.frame),
"rows": table.rows.iter().map(|row| {
row.iter().map(table_cell_to_proto).collect::<Vec<_>>()
}).collect::<Vec<_>>(),
"columnWidths": table.column_widths,
"rowHeights": table.row_heights,
"style": table.style,
"merges": table.merges.iter().map(|merge| serde_json::json!({
"startRow": merge.start_row,
"endRow": merge.end_row,
"startColumn": merge.start_column,
"endColumn": merge.end_column,
})).collect::<Vec<_>>(),
"zOrder": table.z_order,
}),
PresentationElement::Chart(chart) => serde_json::json!({
"kind": "chart",
"elementId": chart.element_id,
"anchor": format!("ch/{}", chart.element_id),
"frame": rect_to_proto(chart.frame),
"chartType": format!("{:?}", chart.chart_type),
"title": chart.title,
"categories": chart.categories,
"series": chart.series.iter().map(|series| serde_json::json!({
"name": series.name,
"values": series.values,
})).collect::<Vec<_>>(),
"zOrder": chart.z_order,
}),
}
}
fn rect_to_proto(rect: Rect) -> Value {
serde_json::json!({
"left": rect.left,
"top": rect.top,
"width": rect.width,
"height": rect.height,
"unit": "points",
})
}
fn stroke_to_proto(stroke: &StrokeStyle) -> Value {
serde_json::json!({
"color": stroke.color,
"width": stroke.width,
"style": stroke.style.as_api_str(),
"unit": "points",
})
}
fn text_style_to_proto(style: &TextStyle) -> Value {
serde_json::json!({
"styleName": style.style_name,
"fontSize": style.font_size,
"fontFamily": style.font_family,
"color": style.color,
"alignment": style.alignment,
"bold": style.bold,
"italic": style.italic,
"underline": style.underline,
})
}
fn placeholder_ref_to_proto(placeholder: &PlaceholderRef) -> Value {
serde_json::json!({
"name": placeholder.name,
"placeholderType": placeholder.placeholder_type,
"index": placeholder.index,
})
}
fn image_payload_to_proto(payload: &ImagePayload) -> Value {
serde_json::json!({
"format": payload.format,
"widthPx": payload.width_px,
"heightPx": payload.height_px,
"bytesBase64": BASE64_STANDARD.encode(&payload.bytes),
})
}
fn table_cell_to_proto(cell: &TableCellSpec) -> Value {
serde_json::json!({
"text": cell.text,
"textStyle": text_style_to_proto(&cell.text_style),
"backgroundFill": cell.background_fill,
"alignment": cell.alignment,
})
}
fn build_table_cell(
cell: TableCellSpec,
merges: &[TableMergeRegion],
row_index: usize,
column_index: usize,
) -> TableCell {
let mut table_cell = TableCell::new(&cell.text);
if cell.text_style.bold {
table_cell = table_cell.bold();
}
if cell.text_style.italic {
table_cell = table_cell.italic();
}
if cell.text_style.underline {
table_cell = table_cell.underline();
}
if let Some(color) = cell.text_style.color {
table_cell = table_cell.text_color(&color);
}
if let Some(fill) = cell.background_fill {
table_cell = table_cell.background_color(&fill);
}
if let Some(size) = cell.text_style.font_size {
table_cell = table_cell.font_size(size);
}
if let Some(font_family) = cell.text_style.font_family {
table_cell = table_cell.font_family(&font_family);
}
if let Some(alignment) = cell.alignment.or(cell.text_style.alignment) {
table_cell = match alignment {
TextAlignment::Left => table_cell.align_left(),
TextAlignment::Center => table_cell.align_center(),
TextAlignment::Right => table_cell.align_right(),
TextAlignment::Justify => table_cell.align(CellAlign::Justify),
};
}
for merge in merges {
if row_index == merge.start_row && column_index == merge.start_column {
table_cell = table_cell
.grid_span((merge.end_column - merge.start_column + 1) as u32)
.row_span((merge.end_row - merge.start_row + 1) as u32);
} else if row_index >= merge.start_row
&& row_index <= merge.end_row
&& column_index >= merge.start_column
&& column_index <= merge.end_column
{
if row_index == merge.start_row {
table_cell = table_cell.h_merge();
} else {
table_cell = table_cell.v_merge();
}
}
}
table_cell
}

View file

@ -0,0 +1,135 @@
#[derive(Debug, Clone, Serialize)]
pub struct PresentationArtifactResponse {
pub artifact_id: String,
pub action: String,
pub summary: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub exported_paths: Vec<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub artifact_snapshot: Option<ArtifactSnapshot>,
#[serde(skip_serializing_if = "Option::is_none")]
pub slide_list: Option<Vec<SlideListEntry>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub layout_list: Option<Vec<LayoutListEntry>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub placeholder_list: Option<Vec<PlaceholderListEntry>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub theme: Option<ThemeSnapshot>,
#[serde(skip_serializing_if = "Option::is_none")]
pub inspect_ndjson: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resolved_record: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub proto_json: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub patch: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub active_slide_index: Option<usize>,
}
impl PresentationArtifactResponse {
fn new(
artifact_id: String,
action: String,
summary: String,
artifact_snapshot: ArtifactSnapshot,
) -> Self {
Self {
artifact_id,
action,
summary,
exported_paths: Vec::new(),
artifact_snapshot: Some(artifact_snapshot),
slide_list: None,
layout_list: None,
placeholder_list: None,
theme: None,
inspect_ndjson: None,
resolved_record: None,
proto_json: None,
patch: None,
active_slide_index: None,
}
}
}
fn response_for_document_state(
artifact_id: String,
action: String,
summary: String,
document: Option<&PresentationDocument>,
) -> PresentationArtifactResponse {
PresentationArtifactResponse {
artifact_id,
action,
summary,
exported_paths: Vec::new(),
artifact_snapshot: document.map(snapshot_for_document),
slide_list: None,
layout_list: None,
placeholder_list: None,
theme: document.map(PresentationDocument::theme_snapshot),
inspect_ndjson: None,
resolved_record: None,
proto_json: None,
patch: None,
active_slide_index: document.and_then(|current| current.active_slide_index),
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ArtifactSnapshot {
pub slide_count: usize,
pub slides: Vec<SlideSnapshot>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SlideSnapshot {
pub slide_id: String,
pub index: usize,
pub element_ids: Vec<String>,
pub element_types: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SlideListEntry {
pub slide_id: String,
pub index: usize,
pub is_active: bool,
pub notes: Option<String>,
pub notes_visible: bool,
pub background_fill: Option<String>,
pub layout_id: Option<String>,
pub element_count: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct LayoutListEntry {
pub layout_id: String,
pub name: String,
pub kind: String,
pub parent_layout_id: Option<String>,
pub placeholder_count: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct PlaceholderListEntry {
pub scope: String,
pub source_layout_id: Option<String>,
pub slide_index: Option<usize>,
pub element_id: Option<String>,
pub name: String,
pub placeholder_type: String,
pub index: Option<u32>,
pub geometry: Option<String>,
pub text_preview: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ThemeSnapshot {
pub color_scheme: HashMap<String, String>,
pub hex_color_map: HashMap<String, String>,
pub major_font: Option<String>,
pub minor_font: Option<String>,
}

View file

@ -0,0 +1,338 @@
fn cell_value_to_string(value: Value) -> String {
match value {
Value::Null => String::new(),
Value::String(text) => text,
Value::Bool(boolean) => boolean.to_string(),
Value::Number(number) => number.to_string(),
other => other.to_string(),
}
}
fn snapshot_for_document(document: &PresentationDocument) -> ArtifactSnapshot {
ArtifactSnapshot {
slide_count: document.slides.len(),
slides: document
.slides
.iter()
.enumerate()
.map(|(index, slide)| SlideSnapshot {
slide_id: slide.slide_id.clone(),
index,
element_ids: slide
.elements
.iter()
.map(|element| element.element_id().to_string())
.collect(),
element_types: slide
.elements
.iter()
.map(|element| element.kind().to_string())
.collect(),
})
.collect(),
}
}
fn slide_list(document: &PresentationDocument) -> Vec<SlideListEntry> {
document
.slides
.iter()
.enumerate()
.map(|(index, slide)| SlideListEntry {
slide_id: slide.slide_id.clone(),
index,
is_active: document.active_slide_index == Some(index),
notes: (!slide.notes.text.is_empty()).then(|| slide.notes.text.clone()),
notes_visible: slide.notes.visible,
background_fill: slide.background_fill.clone(),
layout_id: slide.layout_id.clone(),
element_count: slide.elements.len(),
})
.collect()
}
fn layout_list(document: &PresentationDocument) -> Vec<LayoutListEntry> {
document
.layouts
.iter()
.map(|layout| LayoutListEntry {
layout_id: layout.layout_id.clone(),
name: layout.name.clone(),
kind: match layout.kind {
LayoutKind::Layout => "layout".to_string(),
LayoutKind::Master => "master".to_string(),
},
parent_layout_id: layout.parent_layout_id.clone(),
placeholder_count: layout.placeholders.len(),
})
.collect()
}
fn points_to_emu(points: u32) -> u32 {
points.saturating_mul(POINT_TO_EMU)
}
fn emu_to_points(emu: u32) -> u32 {
emu / POINT_TO_EMU
}
type ImageCrop = (f64, f64, f64, f64);
type FittedImage = (u32, u32, u32, u32, Option<ImageCrop>);
pub(crate) fn fit_image(image: &ImageElement) -> FittedImage {
let Some(payload) = image.payload.as_ref() else {
return (
image.frame.left,
image.frame.top,
image.frame.width,
image.frame.height,
None,
);
};
let frame = image.frame;
let source_width = payload.width_px as f64;
let source_height = payload.height_px as f64;
let target_width = frame.width as f64;
let target_height = frame.height as f64;
let source_ratio = source_width / source_height;
let target_ratio = target_width / target_height;
match image.fit_mode {
ImageFitMode::Stretch => (frame.left, frame.top, frame.width, frame.height, None),
ImageFitMode::Contain => {
let scale = if source_ratio > target_ratio {
target_width / source_width
} else {
target_height / source_height
};
let width = (source_width * scale).round() as u32;
let height = (source_height * scale).round() as u32;
let left = frame.left + frame.width.saturating_sub(width) / 2;
let top = frame.top + frame.height.saturating_sub(height) / 2;
(left, top, width, height, None)
}
ImageFitMode::Cover => {
let scale = if source_ratio > target_ratio {
target_height / source_height
} else {
target_width / source_width
};
let width = source_width * scale;
let height = source_height * scale;
let crop_x = ((width - target_width).max(0.0) / width) / 2.0;
let crop_y = ((height - target_height).max(0.0) / height) / 2.0;
(
frame.left,
frame.top,
frame.width,
frame.height,
Some((crop_x, crop_y, crop_x, crop_y)),
)
}
}
}
fn normalize_image_crop(
crop: ImageCropArgs,
action: &str,
) -> Result<ImageCrop, PresentationArtifactError> {
for (name, value) in [
("left", crop.left),
("top", crop.top),
("right", crop.right),
("bottom", crop.bottom),
] {
if !(0.0..=1.0).contains(&value) {
return Err(PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("image crop `{name}` must be between 0.0 and 1.0"),
});
}
}
Ok((crop.left, crop.top, crop.right, crop.bottom))
}
fn load_image_payload_from_path(
path: &Path,
action: &str,
) -> Result<ImagePayload, PresentationArtifactError> {
let bytes = std::fs::read(path).map_err(|error| PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("failed to read image `{}`: {error}", path.display()),
})?;
build_image_payload(
bytes,
path.file_name()
.and_then(|name| name.to_str())
.unwrap_or("image")
.to_string(),
action,
)
}
fn load_image_payload_from_data_url(
data_url: &str,
action: &str,
) -> Result<ImagePayload, PresentationArtifactError> {
let (header, payload) =
data_url
.split_once(',')
.ok_or_else(|| PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: "data_url must include a MIME header and base64 payload".to_string(),
})?;
let mime = header
.strip_prefix("data:")
.and_then(|prefix| prefix.strip_suffix(";base64"))
.ok_or_else(|| PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: "data_url must be base64-encoded".to_string(),
})?;
let bytes = BASE64_STANDARD.decode(payload).map_err(|error| {
PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("failed to decode image data_url: {error}"),
}
})?;
build_image_payload(
bytes,
format!("image.{}", image_extension_from_mime(mime)),
action,
)
}
fn load_image_payload_from_blob(
blob: &str,
action: &str,
) -> Result<ImagePayload, PresentationArtifactError> {
let bytes = BASE64_STANDARD.decode(blob.trim()).map_err(|error| {
PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("failed to decode image blob: {error}"),
}
})?;
let extension = image::guess_format(&bytes)
.ok()
.map(image_extension_from_format)
.unwrap_or("png");
build_image_payload(bytes, format!("image.{extension}"), action)
}
fn load_image_payload_from_uri(
uri: &str,
action: &str,
) -> Result<ImagePayload, PresentationArtifactError> {
let response =
reqwest::blocking::get(uri).map_err(|error| PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("failed to fetch image `{uri}`: {error}"),
})?;
let status = response.status();
if !status.is_success() {
return Err(PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("failed to fetch image `{uri}`: HTTP {status}"),
});
}
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(|value| value.split(';').next().unwrap_or(value).trim().to_string());
let bytes = response
.bytes()
.map_err(|error| PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("failed to read image `{uri}`: {error}"),
})?;
build_image_payload(
bytes.to_vec(),
infer_remote_image_filename(uri, content_type.as_deref()),
action,
)
}
fn infer_remote_image_filename(uri: &str, content_type: Option<&str>) -> String {
let path_name = reqwest::Url::parse(uri)
.ok()
.and_then(|url| {
url.path_segments()
.and_then(Iterator::last)
.map(str::to_owned)
})
.filter(|segment| !segment.is_empty());
match (path_name, content_type) {
(Some(path_name), _) if Path::new(&path_name).extension().is_some() => path_name,
(Some(path_name), Some(content_type)) => {
format!("{path_name}.{}", image_extension_from_mime(content_type))
}
(Some(path_name), None) => path_name,
(None, Some(content_type)) => format!("image.{}", image_extension_from_mime(content_type)),
(None, None) => "image.png".to_string(),
}
}
fn build_image_payload(
bytes: Vec<u8>,
filename: String,
action: &str,
) -> Result<ImagePayload, PresentationArtifactError> {
let image = image::load_from_memory(&bytes).map_err(|error| {
PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("failed to decode image bytes: {error}"),
}
})?;
let (width_px, height_px) = image.dimensions();
let format = Path::new(&filename)
.extension()
.and_then(|extension| extension.to_str())
.unwrap_or("png")
.to_uppercase();
Ok(ImagePayload {
bytes,
format,
width_px,
height_px,
})
}
fn image_extension_from_mime(mime: &str) -> &'static str {
match mime {
"image/jpeg" => "jpg",
"image/gif" => "gif",
"image/webp" => "webp",
_ => "png",
}
}
fn image_extension_from_format(format: image::ImageFormat) -> &'static str {
match format {
image::ImageFormat::Jpeg => "jpg",
image::ImageFormat::Gif => "gif",
image::ImageFormat::WebP => "webp",
image::ImageFormat::Bmp => "bmp",
image::ImageFormat::Tiff => "tiff",
_ => "png",
}
}
fn index_out_of_range(action: &str, index: usize, len: usize) -> PresentationArtifactError {
PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("slide index {index} is out of range for {len} slides"),
}
}
fn to_index(value: u32) -> Result<usize, PresentationArtifactError> {
usize::try_from(value).map_err(|_| PresentationArtifactError::InvalidArgs {
action: "insert_slide".to_string(),
message: "index does not fit in usize".to_string(),
})
}
fn resequence_z_order(slide: &mut PresentationSlide) {
for (index, element) in slide.elements.iter_mut().enumerate() {
element.set_z_order(index);
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,357 @@
use serde::Deserialize;
use serde::Serialize;
use crate::CellAddress;
use crate::CellRange;
use crate::SpreadsheetArtifactError;
use crate::SpreadsheetSheet;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SpreadsheetChartType {
Area,
Bar,
Doughnut,
Line,
Pie,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SpreadsheetChartLegendPosition {
Bottom,
Top,
Left,
Right,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpreadsheetChartLegend {
pub visible: bool,
pub position: SpreadsheetChartLegendPosition,
pub overlay: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpreadsheetChartAxis {
pub linked_number_format: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpreadsheetChartSeries {
pub id: u32,
pub name: Option<String>,
pub category_sheet_name: Option<String>,
pub category_range: String,
pub value_sheet_name: Option<String>,
pub value_range: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpreadsheetChart {
pub id: u32,
pub chart_type: SpreadsheetChartType,
pub source_sheet_name: Option<String>,
pub source_range: Option<String>,
pub title: Option<String>,
pub style_index: u32,
pub display_blanks_as: String,
pub legend: SpreadsheetChartLegend,
pub category_axis: SpreadsheetChartAxis,
pub value_axis: SpreadsheetChartAxis,
#[serde(default)]
pub series: Vec<SpreadsheetChartSeries>,
}
#[derive(Debug, Clone, Default)]
pub struct SpreadsheetChartLookup<'a> {
pub id: Option<u32>,
pub index: Option<usize>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpreadsheetChartCreateOptions {
pub id: Option<u32>,
pub title: Option<String>,
pub legend_visible: Option<bool>,
pub legend_position: Option<SpreadsheetChartLegendPosition>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpreadsheetChartProperties {
pub title: Option<String>,
pub legend_visible: Option<bool>,
pub legend_position: Option<SpreadsheetChartLegendPosition>,
}
impl SpreadsheetSheet {
pub fn list_charts(
&self,
range: Option<&CellRange>,
) -> Result<Vec<SpreadsheetChart>, SpreadsheetArtifactError> {
Ok(self
.charts
.iter()
.filter(|chart| {
range.is_none_or(|target| {
chart
.source_range
.as_deref()
.map(CellRange::parse)
.transpose()
.ok()
.flatten()
.flatten()
.is_some_and(|chart_range| chart_range.intersects(target))
})
})
.cloned()
.collect())
}
pub fn get_chart(
&self,
action: &str,
lookup: SpreadsheetChartLookup<'_>,
) -> Result<&SpreadsheetChart, SpreadsheetArtifactError> {
if let Some(id) = lookup.id {
return self
.charts
.iter()
.find(|chart| chart.id == id)
.ok_or_else(|| SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("chart id `{id}` was not found"),
});
}
if let Some(index) = lookup.index {
return self.charts.get(index).ok_or_else(|| {
SpreadsheetArtifactError::IndexOutOfRange {
action: action.to_string(),
index,
len: self.charts.len(),
}
});
}
Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "chart id or index is required".to_string(),
})
}
pub fn create_chart(
&mut self,
action: &str,
chart_type: SpreadsheetChartType,
source_sheet_name: Option<String>,
source_range: &CellRange,
options: SpreadsheetChartCreateOptions,
) -> Result<u32, SpreadsheetArtifactError> {
if source_range.width() < 2 {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "chart source range must include at least two columns".to_string(),
});
}
let id = if let Some(id) = options.id {
if self.charts.iter().any(|chart| chart.id == id) {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("chart id `{id}` already exists"),
});
}
id
} else {
self.charts.iter().map(|chart| chart.id).max().unwrap_or(0) + 1
};
let series = (source_range.start.column + 1..=source_range.end.column)
.enumerate()
.map(|(index, value_column)| SpreadsheetChartSeries {
id: index as u32 + 1,
name: None,
category_sheet_name: source_sheet_name.clone(),
category_range: CellRange::from_start_end(
source_range.start,
CellAddress {
column: source_range.start.column,
row: source_range.end.row,
},
)
.to_a1(),
value_sheet_name: source_sheet_name.clone(),
value_range: CellRange::from_start_end(
CellAddress {
column: value_column,
row: source_range.start.row,
},
CellAddress {
column: value_column,
row: source_range.end.row,
},
)
.to_a1(),
})
.collect::<Vec<_>>();
self.charts.push(SpreadsheetChart {
id,
chart_type,
source_sheet_name,
source_range: Some(source_range.to_a1()),
title: options.title,
style_index: 102,
display_blanks_as: "gap".to_string(),
legend: SpreadsheetChartLegend {
visible: options.legend_visible.unwrap_or(true),
position: options
.legend_position
.unwrap_or(SpreadsheetChartLegendPosition::Bottom),
overlay: false,
},
category_axis: SpreadsheetChartAxis {
linked_number_format: true,
},
value_axis: SpreadsheetChartAxis {
linked_number_format: true,
},
series,
});
Ok(id)
}
pub fn add_chart_series(
&mut self,
action: &str,
lookup: SpreadsheetChartLookup<'_>,
mut series: SpreadsheetChartSeries,
) -> Result<u32, SpreadsheetArtifactError> {
validate_chart_series(action, &series)?;
let chart = self.get_chart_mut(action, lookup)?;
let next_id = chart.series.iter().map(|entry| entry.id).max().unwrap_or(0) + 1;
series.id = next_id;
chart.series.push(series);
Ok(next_id)
}
pub fn delete_chart(
&mut self,
action: &str,
lookup: SpreadsheetChartLookup<'_>,
) -> Result<(), SpreadsheetArtifactError> {
let index = if let Some(id) = lookup.id {
self.charts
.iter()
.position(|chart| chart.id == id)
.ok_or_else(|| SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("chart id `{id}` was not found"),
})?
} else if let Some(index) = lookup.index {
if index >= self.charts.len() {
return Err(SpreadsheetArtifactError::IndexOutOfRange {
action: action.to_string(),
index,
len: self.charts.len(),
});
}
index
} else {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "chart id or index is required".to_string(),
});
};
self.charts.remove(index);
Ok(())
}
pub fn set_chart_properties(
&mut self,
action: &str,
lookup: SpreadsheetChartLookup<'_>,
properties: SpreadsheetChartProperties,
) -> Result<(), SpreadsheetArtifactError> {
let chart = self.get_chart_mut(action, lookup)?;
if let Some(title) = properties.title {
chart.title = Some(title);
}
if let Some(visible) = properties.legend_visible {
chart.legend.visible = visible;
}
if let Some(position) = properties.legend_position {
chart.legend.position = position;
}
Ok(())
}
pub fn validate_charts(&self, action: &str) -> Result<(), SpreadsheetArtifactError> {
for chart in &self.charts {
if let Some(source_range) = &chart.source_range {
let range = CellRange::parse(source_range)?;
if range.width() < 2 {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: format!(
"chart `{}` source range `{source_range}` is too narrow",
chart.id
),
});
}
}
for series in &chart.series {
validate_chart_series(action, series)?;
}
}
Ok(())
}
fn get_chart_mut(
&mut self,
action: &str,
lookup: SpreadsheetChartLookup<'_>,
) -> Result<&mut SpreadsheetChart, SpreadsheetArtifactError> {
if let Some(id) = lookup.id {
return self
.charts
.iter_mut()
.find(|chart| chart.id == id)
.ok_or_else(|| SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("chart id `{id}` was not found"),
});
}
if let Some(index) = lookup.index {
return self.charts.get_mut(index).ok_or_else(|| {
SpreadsheetArtifactError::IndexOutOfRange {
action: action.to_string(),
index,
len: self.charts.len(),
}
});
}
Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "chart id or index is required".to_string(),
})
}
}
fn validate_chart_series(
action: &str,
series: &SpreadsheetChartSeries,
) -> Result<(), SpreadsheetArtifactError> {
let category_range = CellRange::parse(&series.category_range)?;
let value_range = CellRange::parse(&series.value_range)?;
if !category_range.is_single_column() || !value_range.is_single_column() {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "chart category and value ranges must be single-column ranges".to_string(),
});
}
if category_range.height() != value_range.height() {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "chart category and value series lengths must match".to_string(),
});
}
Ok(())
}

View file

@ -0,0 +1,296 @@
use serde::Deserialize;
use serde::Serialize;
use crate::CellRange;
use crate::SpreadsheetArtifact;
use crate::SpreadsheetArtifactError;
use crate::SpreadsheetSheet;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum SpreadsheetConditionalFormatType {
Expression,
CellIs,
ColorScale,
DataBar,
IconSet,
Top10,
UniqueValues,
DuplicateValues,
ContainsText,
NotContainsText,
BeginsWith,
EndsWith,
ContainsBlanks,
NotContainsBlanks,
ContainsErrors,
NotContainsErrors,
TimePeriod,
AboveAverage,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SpreadsheetColorScale {
pub min_type: Option<String>,
pub mid_type: Option<String>,
pub max_type: Option<String>,
pub min_value: Option<String>,
pub mid_value: Option<String>,
pub max_value: Option<String>,
pub min_color: String,
pub mid_color: Option<String>,
pub max_color: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SpreadsheetDataBar {
pub color: String,
pub min_length: Option<u8>,
pub max_length: Option<u8>,
pub show_value: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SpreadsheetIconSet {
pub style: String,
pub show_value: Option<bool>,
pub reverse_order: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SpreadsheetConditionalFormat {
pub id: u32,
pub range: String,
pub rule_type: SpreadsheetConditionalFormatType,
pub operator: Option<String>,
#[serde(default)]
pub formulas: Vec<String>,
pub text: Option<String>,
pub dxf_id: Option<u32>,
pub stop_if_true: bool,
pub priority: u32,
pub rank: Option<u32>,
pub percent: Option<bool>,
pub time_period: Option<String>,
pub above_average: Option<bool>,
pub equal_average: Option<bool>,
pub color_scale: Option<SpreadsheetColorScale>,
pub data_bar: Option<SpreadsheetDataBar>,
pub icon_set: Option<SpreadsheetIconSet>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SpreadsheetConditionalFormatCollection {
pub sheet_name: String,
pub range: String,
}
impl SpreadsheetConditionalFormatCollection {
pub fn new(sheet_name: String, range: &CellRange) -> Self {
Self {
sheet_name,
range: range.to_a1(),
}
}
pub fn range(&self) -> Result<CellRange, SpreadsheetArtifactError> {
CellRange::parse(&self.range)
}
pub fn list(
&self,
artifact: &SpreadsheetArtifact,
) -> Result<Vec<SpreadsheetConditionalFormat>, SpreadsheetArtifactError> {
let sheet = artifact.sheet_lookup(
"conditional_format_collection",
Some(&self.sheet_name),
None,
)?;
Ok(sheet.list_conditional_formats(Some(&self.range()?)))
}
pub fn add(
&self,
artifact: &mut SpreadsheetArtifact,
mut format: SpreadsheetConditionalFormat,
) -> Result<u32, SpreadsheetArtifactError> {
format.range = self.range.clone();
artifact.add_conditional_format("conditional_format_collection", &self.sheet_name, format)
}
pub fn delete(
&self,
artifact: &mut SpreadsheetArtifact,
id: u32,
) -> Result<(), SpreadsheetArtifactError> {
artifact.delete_conditional_format("conditional_format_collection", &self.sheet_name, id)
}
}
impl SpreadsheetArtifact {
pub fn add_conditional_format(
&mut self,
action: &str,
sheet_name: &str,
mut format: SpreadsheetConditionalFormat,
) -> Result<u32, SpreadsheetArtifactError> {
validate_conditional_format(self, &format, action)?;
let sheet = self.sheet_lookup_mut(action, Some(sheet_name), None)?;
let next_id = sheet
.conditional_formats
.iter()
.map(|entry| entry.id)
.max()
.unwrap_or(0)
+ 1;
format.id = next_id;
format.priority = if format.priority == 0 {
next_id
} else {
format.priority
};
sheet.conditional_formats.push(format);
Ok(next_id)
}
pub fn delete_conditional_format(
&mut self,
action: &str,
sheet_name: &str,
id: u32,
) -> Result<(), SpreadsheetArtifactError> {
let sheet = self.sheet_lookup_mut(action, Some(sheet_name), None)?;
let previous_len = sheet.conditional_formats.len();
sheet.conditional_formats.retain(|entry| entry.id != id);
if sheet.conditional_formats.len() == previous_len {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("conditional format `{id}` was not found"),
});
}
Ok(())
}
}
impl SpreadsheetSheet {
pub fn conditional_format_collection(
&self,
range: &CellRange,
) -> SpreadsheetConditionalFormatCollection {
SpreadsheetConditionalFormatCollection::new(self.name.clone(), range)
}
pub fn list_conditional_formats(
&self,
range: Option<&CellRange>,
) -> Vec<SpreadsheetConditionalFormat> {
self.conditional_formats
.iter()
.filter(|entry| {
range.is_none_or(|target| {
CellRange::parse(&entry.range)
.map(|entry_range| entry_range.intersects(target))
.unwrap_or(false)
})
})
.cloned()
.collect()
}
}
fn validate_conditional_format(
artifact: &SpreadsheetArtifact,
format: &SpreadsheetConditionalFormat,
action: &str,
) -> Result<(), SpreadsheetArtifactError> {
CellRange::parse(&format.range)?;
if let Some(dxf_id) = format.dxf_id
&& artifact.get_differential_format(dxf_id).is_none()
{
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("differential format `{dxf_id}` was not found"),
});
}
let has_style = format.dxf_id.is_some();
let has_intrinsic_visual =
format.color_scale.is_some() || format.data_bar.is_some() || format.icon_set.is_some();
match format.rule_type {
SpreadsheetConditionalFormatType::Expression | SpreadsheetConditionalFormatType::CellIs => {
if format.formulas.is_empty() {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "conditional format formulas are required".to_string(),
});
}
}
SpreadsheetConditionalFormatType::ContainsText
| SpreadsheetConditionalFormatType::NotContainsText
| SpreadsheetConditionalFormatType::BeginsWith
| SpreadsheetConditionalFormatType::EndsWith => {
if format.text.as_deref().unwrap_or_default().is_empty() {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "conditional format text is required".to_string(),
});
}
}
SpreadsheetConditionalFormatType::ColorScale => {
if format.color_scale.is_none() {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "color scale settings are required".to_string(),
});
}
}
SpreadsheetConditionalFormatType::DataBar => {
if format.data_bar.is_none() {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "data bar settings are required".to_string(),
});
}
}
SpreadsheetConditionalFormatType::IconSet => {
if format.icon_set.is_none() {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "icon set settings are required".to_string(),
});
}
}
SpreadsheetConditionalFormatType::Top10 => {
if format.rank.is_none() {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "top10 rank is required".to_string(),
});
}
}
SpreadsheetConditionalFormatType::TimePeriod => {
if format.time_period.as_deref().unwrap_or_default().is_empty() {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "time period is required".to_string(),
});
}
}
SpreadsheetConditionalFormatType::AboveAverage => {}
SpreadsheetConditionalFormatType::UniqueValues
| SpreadsheetConditionalFormatType::DuplicateValues
| SpreadsheetConditionalFormatType::ContainsBlanks
| SpreadsheetConditionalFormatType::NotContainsBlanks
| SpreadsheetConditionalFormatType::ContainsErrors
| SpreadsheetConditionalFormatType::NotContainsErrors => {}
}
if !has_style && !has_intrinsic_visual {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "conditional formatting requires at least one style component".to_string(),
});
}
Ok(())
}

View file

@ -3,6 +3,7 @@ mod error;
mod formula;
mod manager;
mod model;
mod render;
mod style;
mod xlsx;
@ -13,4 +14,5 @@ pub use address::*;
pub use error::*;
pub use manager::*;
pub use model::*;
pub use render::*;
pub use style::*;

View file

@ -72,6 +72,18 @@ impl SpreadsheetArtifactRequest {
path: resolve_path(cwd, &args.path),
}]
}
"render_workbook" | "render_sheet" | "render_range" => {
let args: RenderArgs = parse_args(&self.action, &self.args)?;
args.output_path
.map(|path| {
vec![PathAccessRequirement {
action: self.action.clone(),
kind: PathAccessKind::Write,
path: resolve_path(cwd, &path),
}]
})
.unwrap_or_default()
}
_ => Vec::new(),
};
Ok(access)
@ -94,6 +106,9 @@ impl SpreadsheetArtifactManager {
"import_xlsx" | "load" | "read" => self.import_xlsx(request, cwd),
"export_xlsx" => self.export_xlsx(request, cwd),
"save" => self.save(request, cwd),
"render_workbook" => self.render_workbook(request, cwd),
"render_sheet" => self.render_sheet(request, cwd),
"render_range" => self.render_range(request, cwd),
"get_summary" => self.get_summary(request),
"list_sheets" => self.list_sheets(request),
"get_sheet" => self.get_sheet(request),
@ -275,6 +290,90 @@ impl SpreadsheetArtifactManager {
Ok(response)
}
fn render_workbook(
&mut self,
request: SpreadsheetArtifactRequest,
cwd: &Path,
) -> Result<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
let args: RenderArgs = parse_args(&request.action, &request.args)?;
let artifact_id = required_artifact_id(&request)?;
let artifact = self.get_artifact(&artifact_id, &request.action)?;
let rendered = artifact.render_workbook_previews(cwd, &render_options_from_args(args)?)?;
let mut response = SpreadsheetArtifactResponse::new(
artifact_id,
request.action,
format!("Rendered workbook to {} preview files", rendered.len()),
snapshot_for_artifact(artifact),
);
response.exported_paths = rendered.into_iter().map(|output| output.path).collect();
Ok(response)
}
fn render_sheet(
&mut self,
request: SpreadsheetArtifactRequest,
cwd: &Path,
) -> Result<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
let args: RenderArgs = parse_args(&request.action, &request.args)?;
let artifact_id = required_artifact_id(&request)?;
let artifact = self.get_artifact(&artifact_id, &request.action)?;
let sheet = artifact.sheet_lookup(
&request.action,
args.sheet_name.as_deref(),
args.sheet_index.map(|value| value as usize),
)?;
let rendered =
artifact.render_sheet_preview(cwd, sheet, &render_options_from_args(args)?)?;
let mut response = SpreadsheetArtifactResponse::new(
artifact_id,
request.action,
format!("Rendered sheet `{}`", sheet.name),
snapshot_for_artifact(artifact),
);
response.sheet_ref = Some(sheet_reference(sheet));
response.exported_paths.push(rendered.path);
response.rendered_html = Some(rendered.html);
response.rendered_text = Some(sheet.to_rendered_text(None));
Ok(response)
}
fn render_range(
&mut self,
request: SpreadsheetArtifactRequest,
cwd: &Path,
) -> Result<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
let args: RenderArgs = parse_args(&request.action, &request.args)?;
let artifact_id = required_artifact_id(&request)?;
let artifact = self.get_artifact(&artifact_id, &request.action)?;
let sheet = artifact.sheet_lookup(
&request.action,
args.sheet_name.as_deref(),
args.sheet_index.map(|value| value as usize),
)?;
let range_text =
args.range
.clone()
.ok_or_else(|| SpreadsheetArtifactError::InvalidArgs {
action: request.action.clone(),
message: "range is required".to_string(),
})?;
let range = CellRange::parse(&range_text)?;
let rendered =
artifact.render_range_preview(cwd, sheet, &range, &render_options_from_args(args)?)?;
let mut response = SpreadsheetArtifactResponse::new(
artifact_id,
request.action,
format!("Rendered range `{range_text}` from `{}`", sheet.name),
snapshot_for_artifact(artifact),
);
response.sheet_ref = Some(sheet_reference(sheet));
response.range_ref = Some(SpreadsheetCellRangeRef::new(sheet.name.clone(), &range));
response.exported_paths.push(rendered.path);
response.rendered_html = Some(rendered.html);
response.rendered_text = Some(sheet.to_rendered_text(Some(&range)));
Ok(response)
}
fn list_sheets(
&mut self,
request: SpreadsheetArtifactRequest,
@ -1931,6 +2030,8 @@ pub struct SpreadsheetArtifactResponse {
#[serde(skip_serializing_if = "Option::is_none")]
pub rendered_text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rendered_html: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub row_height: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub serialized_dict: Option<Value>,
@ -1967,6 +2068,7 @@ impl SpreadsheetArtifactResponse {
top_left_style_index: None,
cell_format_summary: None,
rendered_text: None,
rendered_html: None,
row_height: None,
serialized_dict: None,
serialized_json: None,
@ -2014,6 +2116,20 @@ struct SaveArgs {
file_type: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RenderArgs {
output_path: Option<PathBuf>,
sheet_name: Option<String>,
sheet_index: Option<u32>,
range: Option<String>,
center_address: Option<String>,
width: Option<u32>,
height: Option<u32>,
include_headers: Option<bool>,
scale: Option<f64>,
performance_mode: Option<bool>,
}
#[derive(Debug, Deserialize)]
struct SheetLookupArgs {
sheet_name: Option<String>,
@ -2385,6 +2501,27 @@ fn normalize_formula(formula: String) -> String {
}
}
fn render_options_from_args(
args: RenderArgs,
) -> Result<crate::SpreadsheetRenderOptions, SpreadsheetArtifactError> {
let scale = args.scale.unwrap_or(1.0);
if scale <= 0.0 {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: "render".to_string(),
message: "render scale must be positive".to_string(),
});
}
Ok(crate::SpreadsheetRenderOptions {
output_path: args.output_path,
center_address: args.center_address,
width: args.width,
height: args.height,
include_headers: args.include_headers.unwrap_or(true),
scale,
performance_mode: args.performance_mode.unwrap_or(false),
})
}
fn required_artifact_id(
request: &SpreadsheetArtifactRequest,
) -> Result<String, SpreadsheetArtifactError> {

View file

@ -0,0 +1,177 @@
use std::collections::BTreeMap;
use serde::Deserialize;
use serde::Serialize;
use crate::CellRange;
use crate::SpreadsheetArtifactError;
use crate::SpreadsheetCellRangeRef;
use crate::SpreadsheetSheet;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpreadsheetPivotFieldItem {
pub item_type: Option<String>,
pub index: Option<u32>,
pub hidden: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpreadsheetPivotField {
pub index: u32,
pub name: Option<String>,
pub axis: Option<String>,
#[serde(default)]
pub items: Vec<SpreadsheetPivotFieldItem>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpreadsheetPivotFieldReference {
pub field_index: u32,
pub field_name: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpreadsheetPivotPageField {
pub field_index: u32,
pub field_name: Option<String>,
pub selected_item: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpreadsheetPivotDataField {
pub field_index: u32,
pub field_name: Option<String>,
pub name: Option<String>,
pub subtotal: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpreadsheetPivotFilter {
pub field_index: Option<u32>,
pub field_name: Option<String>,
pub filter_type: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpreadsheetPivotTable {
pub name: String,
pub cache_id: u32,
pub address: Option<String>,
#[serde(default)]
pub row_fields: Vec<SpreadsheetPivotFieldReference>,
#[serde(default)]
pub column_fields: Vec<SpreadsheetPivotFieldReference>,
#[serde(default)]
pub page_fields: Vec<SpreadsheetPivotPageField>,
#[serde(default)]
pub data_fields: Vec<SpreadsheetPivotDataField>,
#[serde(default)]
pub filters: Vec<SpreadsheetPivotFilter>,
#[serde(default)]
pub pivot_fields: Vec<SpreadsheetPivotField>,
pub style_name: Option<String>,
pub part_path: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct SpreadsheetPivotTableLookup<'a> {
pub name: Option<&'a str>,
pub index: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpreadsheetPivotCacheDefinition {
pub definition_path: String,
#[serde(default)]
pub field_names: Vec<Option<String>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct SpreadsheetPivotPreservation {
#[serde(default)]
pub caches: BTreeMap<u32, SpreadsheetPivotCacheDefinition>,
#[serde(default)]
pub parts: BTreeMap<String, String>,
}
impl SpreadsheetPivotTable {
pub fn range(&self) -> Result<Option<CellRange>, SpreadsheetArtifactError> {
self.address.as_deref().map(CellRange::parse).transpose()
}
pub fn range_ref(
&self,
sheet_name: &str,
) -> Result<Option<SpreadsheetCellRangeRef>, SpreadsheetArtifactError> {
Ok(self
.range()?
.map(|range| SpreadsheetCellRangeRef::new(sheet_name.to_string(), &range)))
}
}
impl SpreadsheetSheet {
pub fn list_pivot_tables(
&self,
range: Option<&CellRange>,
) -> Result<Vec<SpreadsheetPivotTable>, SpreadsheetArtifactError> {
Ok(self
.pivot_tables
.iter()
.filter(|pivot_table| {
range.is_none_or(|target| {
pivot_table
.range()
.ok()
.flatten()
.is_some_and(|pivot_range| pivot_range.intersects(target))
})
})
.cloned()
.collect())
}
pub fn get_pivot_table(
&self,
action: &str,
lookup: SpreadsheetPivotTableLookup,
) -> Result<&SpreadsheetPivotTable, SpreadsheetArtifactError> {
if let Some(name) = lookup.name {
return self
.pivot_tables
.iter()
.find(|pivot_table| pivot_table.name == name)
.ok_or_else(|| SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("pivot table `{name}` was not found"),
});
}
if let Some(index) = lookup.index {
return self.pivot_tables.get(index).ok_or_else(|| {
SpreadsheetArtifactError::IndexOutOfRange {
action: action.to_string(),
index,
len: self.pivot_tables.len(),
}
});
}
Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "pivot table name or index is required".to_string(),
})
}
pub fn validate_pivot_tables(&self, action: &str) -> Result<(), SpreadsheetArtifactError> {
for pivot_table in &self.pivot_tables {
if pivot_table.name.trim().is_empty() {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "pivot table name cannot be empty".to_string(),
});
}
if let Some(address) = &pivot_table.address {
CellRange::parse(address)?;
}
}
Ok(())
}
}

View file

@ -0,0 +1,373 @@
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use serde::Deserialize;
use serde::Serialize;
use crate::CellAddress;
use crate::CellRange;
use crate::SpreadsheetArtifact;
use crate::SpreadsheetArtifactError;
use crate::SpreadsheetSheet;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SpreadsheetRenderOptions {
pub output_path: Option<PathBuf>,
pub center_address: Option<String>,
pub width: Option<u32>,
pub height: Option<u32>,
pub include_headers: bool,
pub scale: f64,
pub performance_mode: bool,
}
impl Default for SpreadsheetRenderOptions {
fn default() -> Self {
Self {
output_path: None,
center_address: None,
width: None,
height: None,
include_headers: true,
scale: 1.0,
performance_mode: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SpreadsheetRenderedOutput {
pub path: PathBuf,
pub html: String,
}
impl SpreadsheetSheet {
pub fn render_html(
&self,
range: Option<&CellRange>,
options: &SpreadsheetRenderOptions,
) -> Result<String, SpreadsheetArtifactError> {
let center = options
.center_address
.as_deref()
.map(CellAddress::parse)
.transpose()?;
let viewport = render_viewport(self, range, center, options)?;
let title = range
.map(CellRange::to_a1)
.unwrap_or_else(|| self.name.clone());
Ok(format!(
concat!(
"<!doctype html><html><head><meta charset=\"utf-8\">",
"<title>{}</title>",
"<style>{}</style>",
"</head><body>",
"<section class=\"spreadsheet-preview\" data-sheet=\"{}\" data-performance-mode=\"{}\">",
"<header><h1>{}</h1><p>{}</p></header>",
"<div class=\"viewport\" style=\"{}\">",
"<table>{}</table>",
"</div></section></body></html>"
),
html_escape(&title),
preview_css(),
html_escape(&self.name),
options.performance_mode,
html_escape(&title),
html_escape(&viewport.to_a1()),
viewport_style(options),
render_table(self, &viewport, options),
))
}
}
impl SpreadsheetArtifact {
pub fn render_workbook_previews(
&self,
cwd: &Path,
options: &SpreadsheetRenderOptions,
) -> Result<Vec<SpreadsheetRenderedOutput>, SpreadsheetArtifactError> {
let sheets = if self.sheets.is_empty() {
vec![SpreadsheetSheet::new("Sheet1".to_string())]
} else {
self.sheets.clone()
};
let output_paths = workbook_output_paths(self, cwd, options, &sheets);
sheets
.iter()
.zip(output_paths)
.map(|(sheet, path)| {
let html = sheet.render_html(None, options)?;
write_rendered_output(&path, &html)?;
Ok(SpreadsheetRenderedOutput { path, html })
})
.collect()
}
pub fn render_sheet_preview(
&self,
cwd: &Path,
sheet: &SpreadsheetSheet,
options: &SpreadsheetRenderOptions,
) -> Result<SpreadsheetRenderedOutput, SpreadsheetArtifactError> {
let path = single_output_path(
cwd,
self,
options.output_path.as_deref(),
&format!("render_{}", sanitize_file_component(&sheet.name)),
);
let html = sheet.render_html(None, options)?;
write_rendered_output(&path, &html)?;
Ok(SpreadsheetRenderedOutput { path, html })
}
pub fn render_range_preview(
&self,
cwd: &Path,
sheet: &SpreadsheetSheet,
range: &CellRange,
options: &SpreadsheetRenderOptions,
) -> Result<SpreadsheetRenderedOutput, SpreadsheetArtifactError> {
let path = single_output_path(
cwd,
self,
options.output_path.as_deref(),
&format!(
"render_{}_{}",
sanitize_file_component(&sheet.name),
sanitize_file_component(&range.to_a1())
),
);
let html = sheet.render_html(Some(range), options)?;
write_rendered_output(&path, &html)?;
Ok(SpreadsheetRenderedOutput { path, html })
}
}
fn render_viewport(
sheet: &SpreadsheetSheet,
range: Option<&CellRange>,
center: Option<CellAddress>,
options: &SpreadsheetRenderOptions,
) -> Result<CellRange, SpreadsheetArtifactError> {
let base = range
.cloned()
.or_else(|| sheet.minimum_range())
.unwrap_or_else(|| {
CellRange::from_start_end(
CellAddress { column: 1, row: 1 },
CellAddress { column: 1, row: 1 },
)
});
let Some(center) = center else {
return Ok(base);
};
let visible_columns = options
.width
.map(|width| estimated_visible_count(width, 96.0, options.scale))
.unwrap_or(base.width() as u32);
let visible_rows = options
.height
.map(|height| estimated_visible_count(height, 28.0, options.scale))
.unwrap_or(base.height() as u32);
let half_columns = visible_columns / 2;
let half_rows = visible_rows / 2;
let start_column = center
.column
.saturating_sub(half_columns)
.max(base.start.column);
let start_row = center.row.saturating_sub(half_rows).max(base.start.row);
let end_column = (start_column + visible_columns.saturating_sub(1)).min(base.end.column);
let end_row = (start_row + visible_rows.saturating_sub(1)).min(base.end.row);
Ok(CellRange::from_start_end(
CellAddress {
column: start_column,
row: start_row,
},
CellAddress {
column: end_column.max(start_column),
row: end_row.max(start_row),
},
))
}
fn estimated_visible_count(dimension: u32, cell_size: f64, scale: f64) -> u32 {
((dimension as f64 / (cell_size * scale.max(0.1))).floor() as u32).max(1)
}
fn render_table(
sheet: &SpreadsheetSheet,
range: &CellRange,
options: &SpreadsheetRenderOptions,
) -> String {
let mut rows = Vec::new();
if options.include_headers {
let mut header = vec!["<tr><th class=\"corner\"></th>".to_string()];
for column in range.start.column..=range.end.column {
header.push(format!(
"<th>{}</th>",
crate::column_index_to_letters(column)
));
}
header.push("</tr>".to_string());
rows.push(header.join(""));
}
for row in range.start.row..=range.end.row {
let mut cells = Vec::new();
if options.include_headers {
cells.push(format!("<th>{row}</th>"));
}
for column in range.start.column..=range.end.column {
let address = CellAddress { column, row };
let view = sheet.get_cell_view(address);
let value = view
.data
.as_ref()
.map(render_data_value)
.unwrap_or_default();
cells.push(format!(
"<td data-address=\"{}\" data-style-index=\"{}\">{}</td>",
address.to_a1(),
view.style_index,
html_escape(&value)
));
}
rows.push(format!("<tr>{}</tr>", cells.join("")));
}
rows.join("")
}
fn render_data_value(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(value) => value.clone(),
serde_json::Value::Bool(value) => value.to_string(),
serde_json::Value::Number(value) => value.to_string(),
serde_json::Value::Null => String::new(),
other => other.to_string(),
}
}
fn viewport_style(options: &SpreadsheetRenderOptions) -> String {
let mut style = vec![
format!("--scale: {}", options.scale.max(0.1)),
format!(
"--headers: {}",
if options.include_headers { "1" } else { "0" }
),
];
if let Some(width) = options.width {
style.push(format!("width: {width}px"));
}
if let Some(height) = options.height {
style.push(format!("height: {height}px"));
}
style.push("overflow: auto".to_string());
style.join("; ")
}
fn preview_css() -> &'static str {
concat!(
"body{margin:0;padding:24px;background:#f5f3ee;color:#1e1e1e;font-family:Georgia,serif;}",
".spreadsheet-preview{display:flex;flex-direction:column;gap:16px;}",
"header h1{margin:0;font-size:24px;}header p{margin:0;color:#6b6257;font-size:13px;}",
".viewport{border:1px solid #d6d0c7;background:#fff;box-shadow:0 12px 30px rgba(0,0,0,.08);}",
"table{border-collapse:collapse;transform:scale(var(--scale));transform-origin:top left;}",
"th,td{border:1px solid #ddd3c6;padding:6px 10px;min-width:72px;max-width:240px;font-size:13px;text-align:left;vertical-align:top;}",
"th{background:#f0ebe3;font-weight:600;position:sticky;top:0;z-index:1;}",
".corner{background:#e7e0d6;left:0;z-index:2;}",
"td{white-space:pre-wrap;}"
)
}
fn write_rendered_output(path: &Path, html: &str) -> Result<(), SpreadsheetArtifactError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|error| SpreadsheetArtifactError::ExportFailed {
path: path.to_path_buf(),
message: error.to_string(),
})?;
}
fs::write(path, html).map_err(|error| SpreadsheetArtifactError::ExportFailed {
path: path.to_path_buf(),
message: error.to_string(),
})
}
fn workbook_output_paths(
artifact: &SpreadsheetArtifact,
cwd: &Path,
options: &SpreadsheetRenderOptions,
sheets: &[SpreadsheetSheet],
) -> Vec<PathBuf> {
if let Some(output_path) = options.output_path.as_deref() {
if output_path.extension().is_some_and(|ext| ext == "html") {
let stem = output_path
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("render");
let parent = output_path.parent().unwrap_or(cwd);
return sheets
.iter()
.map(|sheet| {
parent.join(format!(
"{}_{}.html",
stem,
sanitize_file_component(&sheet.name)
))
})
.collect();
}
return sheets
.iter()
.map(|sheet| output_path.join(format!("{}.html", sanitize_file_component(&sheet.name))))
.collect();
}
sheets
.iter()
.map(|sheet| {
cwd.join(format!(
"{}_render_{}.html",
artifact.artifact_id,
sanitize_file_component(&sheet.name)
))
})
.collect()
}
fn single_output_path(
cwd: &Path,
artifact: &SpreadsheetArtifact,
output_path: Option<&Path>,
suffix: &str,
) -> PathBuf {
if let Some(output_path) = output_path {
return if output_path.extension().is_some_and(|ext| ext == "html") {
output_path.to_path_buf()
} else {
output_path.join(format!("{suffix}.html"))
};
}
cwd.join(format!("{}_{}.html", artifact.artifact_id, suffix))
}
fn sanitize_file_component(value: &str) -> String {
value
.chars()
.map(|character| {
if character.is_ascii_alphanumeric() {
character
} else {
'_'
}
})
.collect()
}
fn html_escape(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}

View file

@ -0,0 +1,619 @@
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use serde::Deserialize;
use serde::Serialize;
use crate::CellAddress;
use crate::CellRange;
use crate::SpreadsheetArtifactError;
use crate::SpreadsheetCellValue;
use crate::SpreadsheetSheet;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SpreadsheetTableColumn {
pub id: u32,
pub name: String,
pub totals_row_label: Option<String>,
pub totals_row_function: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SpreadsheetTable {
pub id: u32,
pub name: String,
pub display_name: String,
pub range: String,
pub header_row_count: u32,
pub totals_row_count: u32,
pub style_name: Option<String>,
pub show_first_column: bool,
pub show_last_column: bool,
pub show_row_stripes: bool,
pub show_column_stripes: bool,
#[serde(default)]
pub columns: Vec<SpreadsheetTableColumn>,
#[serde(default)]
pub filters: BTreeMap<u32, String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SpreadsheetTableView {
pub id: u32,
pub name: String,
pub display_name: String,
pub address: String,
pub full_range: String,
pub header_row_count: u32,
pub totals_row_count: u32,
pub totals_row_visible: bool,
pub header_row_range: Option<String>,
pub data_body_range: Option<String>,
pub totals_row_range: Option<String>,
pub style_name: Option<String>,
pub show_first_column: bool,
pub show_last_column: bool,
pub show_row_stripes: bool,
pub show_column_stripes: bool,
pub columns: Vec<SpreadsheetTableColumn>,
}
#[derive(Debug, Clone, Default)]
pub struct SpreadsheetTableLookup<'a> {
pub name: Option<&'a str>,
pub display_name: Option<&'a str>,
pub id: Option<u32>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpreadsheetCreateTableOptions {
pub name: Option<String>,
pub display_name: Option<String>,
pub header_row_count: u32,
pub totals_row_count: u32,
pub style_name: Option<String>,
pub show_first_column: bool,
pub show_last_column: bool,
pub show_row_stripes: bool,
pub show_column_stripes: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpreadsheetTableStyleOptions {
pub style_name: Option<String>,
pub show_first_column: Option<bool>,
pub show_last_column: Option<bool>,
pub show_row_stripes: Option<bool>,
pub show_column_stripes: Option<bool>,
}
impl SpreadsheetTable {
pub fn range(&self) -> Result<CellRange, SpreadsheetArtifactError> {
CellRange::parse(&self.range)
}
pub fn address(&self) -> String {
self.range.clone()
}
pub fn full_range(&self) -> String {
self.range.clone()
}
pub fn totals_row_visible(&self) -> bool {
self.totals_row_count > 0
}
pub fn header_row_range(&self) -> Result<Option<CellRange>, SpreadsheetArtifactError> {
if self.header_row_count == 0 {
return Ok(None);
}
let range = self.range()?;
Ok(Some(CellRange::from_start_end(
range.start,
CellAddress {
column: range.end.column,
row: range.start.row + self.header_row_count - 1,
},
)))
}
pub fn data_body_range(&self) -> Result<Option<CellRange>, SpreadsheetArtifactError> {
let range = self.range()?;
let start_row = range.start.row + self.header_row_count;
let end_row = range.end.row.saturating_sub(self.totals_row_count);
if start_row > end_row {
return Ok(None);
}
Ok(Some(CellRange::from_start_end(
CellAddress {
column: range.start.column,
row: start_row,
},
CellAddress {
column: range.end.column,
row: end_row,
},
)))
}
pub fn totals_row_range(&self) -> Result<Option<CellRange>, SpreadsheetArtifactError> {
if self.totals_row_count == 0 {
return Ok(None);
}
let range = self.range()?;
Ok(Some(CellRange::from_start_end(
CellAddress {
column: range.start.column,
row: range.end.row - self.totals_row_count + 1,
},
range.end,
)))
}
pub fn view(&self) -> Result<SpreadsheetTableView, SpreadsheetArtifactError> {
Ok(SpreadsheetTableView {
id: self.id,
name: self.name.clone(),
display_name: self.display_name.clone(),
address: self.address(),
full_range: self.full_range(),
header_row_count: self.header_row_count,
totals_row_count: self.totals_row_count,
totals_row_visible: self.totals_row_visible(),
header_row_range: self.header_row_range()?.map(|range| range.to_a1()),
data_body_range: self.data_body_range()?.map(|range| range.to_a1()),
totals_row_range: self.totals_row_range()?.map(|range| range.to_a1()),
style_name: self.style_name.clone(),
show_first_column: self.show_first_column,
show_last_column: self.show_last_column,
show_row_stripes: self.show_row_stripes,
show_column_stripes: self.show_column_stripes,
columns: self.columns.clone(),
})
}
}
impl SpreadsheetSheet {
pub fn create_table(
&mut self,
action: &str,
range: &CellRange,
options: SpreadsheetCreateTableOptions,
) -> Result<u32, SpreadsheetArtifactError> {
validate_table_geometry(action, range, options.header_row_count, options.totals_row_count)?;
for table in &self.tables {
let table_range = table.range()?;
if table_range.intersects(range) {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: format!(
"table range `{}` intersects existing table `{}`",
range.to_a1(),
table.name
),
});
}
}
let next_id = self.tables.iter().map(|table| table.id).max().unwrap_or(0) + 1;
let name = options.name.unwrap_or_else(|| format!("Table{next_id}"));
if name.trim().is_empty() {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "table name cannot be empty".to_string(),
});
}
let display_name = options.display_name.unwrap_or_else(|| name.clone());
if display_name.trim().is_empty() {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "table display_name cannot be empty".to_string(),
});
}
ensure_unique_table_name(&self.tables, action, &name, &display_name, None)?;
let columns = build_table_columns(self, range, options.header_row_count);
self.tables.push(SpreadsheetTable {
id: next_id,
name,
display_name,
range: range.to_a1(),
header_row_count: options.header_row_count,
totals_row_count: options.totals_row_count,
style_name: options.style_name,
show_first_column: options.show_first_column,
show_last_column: options.show_last_column,
show_row_stripes: options.show_row_stripes,
show_column_stripes: options.show_column_stripes,
columns,
filters: BTreeMap::new(),
});
Ok(next_id)
}
pub fn list_tables(
&self,
range: Option<&CellRange>,
) -> Result<Vec<SpreadsheetTableView>, SpreadsheetArtifactError> {
self.tables
.iter()
.filter(|table| {
range.is_none_or(|target| {
table
.range()
.map(|table_range| table_range.intersects(target))
.unwrap_or(false)
})
})
.map(SpreadsheetTable::view)
.collect()
}
pub fn get_table(
&self,
action: &str,
lookup: SpreadsheetTableLookup<'_>,
) -> Result<&SpreadsheetTable, SpreadsheetArtifactError> {
self.table_lookup_internal(action, lookup)
}
pub fn get_table_view(
&self,
action: &str,
lookup: SpreadsheetTableLookup<'_>,
) -> Result<SpreadsheetTableView, SpreadsheetArtifactError> {
self.get_table(action, lookup)?.view()
}
pub fn delete_table(
&mut self,
action: &str,
lookup: SpreadsheetTableLookup<'_>,
) -> Result<(), SpreadsheetArtifactError> {
let index = self.table_index(action, lookup)?;
self.tables.remove(index);
Ok(())
}
pub fn set_table_style(
&mut self,
action: &str,
lookup: SpreadsheetTableLookup<'_>,
options: SpreadsheetTableStyleOptions,
) -> Result<(), SpreadsheetArtifactError> {
let table = self.table_lookup_mut(action, lookup)?;
table.style_name = options.style_name;
if let Some(value) = options.show_first_column {
table.show_first_column = value;
}
if let Some(value) = options.show_last_column {
table.show_last_column = value;
}
if let Some(value) = options.show_row_stripes {
table.show_row_stripes = value;
}
if let Some(value) = options.show_column_stripes {
table.show_column_stripes = value;
}
Ok(())
}
pub fn clear_table_filters(
&mut self,
action: &str,
lookup: SpreadsheetTableLookup<'_>,
) -> Result<(), SpreadsheetArtifactError> {
self.table_lookup_mut(action, lookup)?.filters.clear();
Ok(())
}
pub fn reapply_table_filters(
&mut self,
action: &str,
lookup: SpreadsheetTableLookup<'_>,
) -> Result<(), SpreadsheetArtifactError> {
let _ = self.table_lookup_mut(action, lookup)?;
Ok(())
}
pub fn rename_table_column(
&mut self,
action: &str,
lookup: SpreadsheetTableLookup<'_>,
column_id: Option<u32>,
column_name: Option<&str>,
new_name: String,
) -> Result<SpreadsheetTableColumn, SpreadsheetArtifactError> {
if new_name.trim().is_empty() {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "table column name cannot be empty".to_string(),
});
}
let table = self.table_lookup_mut(action, lookup)?;
if table
.columns
.iter()
.any(|column| column.name == new_name && Some(column.id) != column_id)
{
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("table column `{new_name}` already exists"),
});
}
let column = table_column_lookup_mut(&mut table.columns, action, column_id, column_name)?;
column.name = new_name;
Ok(column.clone())
}
pub fn set_table_column_totals(
&mut self,
action: &str,
lookup: SpreadsheetTableLookup<'_>,
column_id: Option<u32>,
column_name: Option<&str>,
totals_row_label: Option<String>,
totals_row_function: Option<String>,
) -> Result<SpreadsheetTableColumn, SpreadsheetArtifactError> {
let table = self.table_lookup_mut(action, lookup)?;
let column = table_column_lookup_mut(&mut table.columns, action, column_id, column_name)?;
column.totals_row_label = totals_row_label;
column.totals_row_function = totals_row_function;
Ok(column.clone())
}
pub fn validate_tables(&self, action: &str) -> Result<(), SpreadsheetArtifactError> {
let mut seen_names = BTreeSet::new();
let mut seen_display_names = BTreeSet::new();
for table in &self.tables {
let range = table.range()?;
validate_table_geometry(action, &range, table.header_row_count, table.totals_row_count)?;
if !seen_names.insert(table.name.clone()) {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("duplicate table name `{}`", table.name),
});
}
if !seen_display_names.insert(table.display_name.clone()) {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("duplicate table display_name `{}`", table.display_name),
});
}
let column_names = table
.columns
.iter()
.map(|column| column.name.clone())
.collect::<BTreeSet<_>>();
if column_names.len() != table.columns.len() {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("table `{}` has duplicate column names", table.name),
});
}
}
for index in 0..self.tables.len() {
for other in index + 1..self.tables.len() {
if self.tables[index]
.range()?
.intersects(&self.tables[other].range()?)
{
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: format!(
"table `{}` intersects table `{}`",
self.tables[index].name, self.tables[other].name
),
});
}
}
}
Ok(())
}
fn table_index(
&self,
action: &str,
lookup: SpreadsheetTableLookup<'_>,
) -> Result<usize, SpreadsheetArtifactError> {
self.tables
.iter()
.position(|table| table_matches_lookup(table, lookup.clone()))
.ok_or_else(|| SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: describe_missing_table(lookup),
})
}
fn table_lookup_internal(
&self,
action: &str,
lookup: SpreadsheetTableLookup<'_>,
) -> Result<&SpreadsheetTable, SpreadsheetArtifactError> {
self.tables
.iter()
.find(|table| table_matches_lookup(table, lookup.clone()))
.ok_or_else(|| SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: describe_missing_table(lookup),
})
}
fn table_lookup_mut(
&mut self,
action: &str,
lookup: SpreadsheetTableLookup<'_>,
) -> Result<&mut SpreadsheetTable, SpreadsheetArtifactError> {
let index = self.table_index(action, lookup)?;
Ok(&mut self.tables[index])
}
}
fn table_matches_lookup(table: &SpreadsheetTable, lookup: SpreadsheetTableLookup<'_>) -> bool {
if let Some(name) = lookup.name {
table.name == name
} else if let Some(display_name) = lookup.display_name {
table.display_name == display_name
} else if let Some(id) = lookup.id {
table.id == id
} else {
false
}
}
fn describe_missing_table(lookup: SpreadsheetTableLookup<'_>) -> String {
if let Some(name) = lookup.name {
format!("table name `{name}` was not found")
} else if let Some(display_name) = lookup.display_name {
format!("table display_name `{display_name}` was not found")
} else if let Some(id) = lookup.id {
format!("table id `{id}` was not found")
} else {
"table name, display_name, or id is required".to_string()
}
}
fn ensure_unique_table_name(
tables: &[SpreadsheetTable],
action: &str,
name: &str,
display_name: &str,
exclude_id: Option<u32>,
) -> Result<(), SpreadsheetArtifactError> {
if tables.iter().any(|table| {
Some(table.id) != exclude_id && (table.name == name || table.display_name == name)
}) {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("table name `{name}` already exists"),
});
}
if tables.iter().any(|table| {
Some(table.id) != exclude_id
&& (table.display_name == display_name || table.name == display_name)
}) {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("table display_name `{display_name}` already exists"),
});
}
Ok(())
}
fn validate_table_geometry(
action: &str,
range: &CellRange,
header_row_count: u32,
totals_row_count: u32,
) -> Result<(), SpreadsheetArtifactError> {
if range.width() == 0 {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "table range must include at least one column".to_string(),
});
}
if header_row_count + totals_row_count > range.height() as u32 {
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: "table range is smaller than header and totals rows".to_string(),
});
}
Ok(())
}
fn build_table_columns(
sheet: &SpreadsheetSheet,
range: &CellRange,
header_row_count: u32,
) -> Vec<SpreadsheetTableColumn> {
let header_row = range.start.row + header_row_count.saturating_sub(1);
let default_names = (0..range.width())
.map(|index| format!("Column{}", index + 1))
.collect::<Vec<_>>();
let names = unique_table_column_names(
(range.start.column..=range.end.column)
.enumerate()
.map(|(index, column)| {
if header_row_count == 0 {
return default_names[index].clone();
}
sheet
.get_cell(CellAddress {
column,
row: header_row,
})
.and_then(|cell| cell.value.as_ref())
.map(cell_value_to_table_header)
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| default_names[index].clone())
})
.collect::<Vec<_>>(),
);
names
.into_iter()
.enumerate()
.map(|(index, name)| SpreadsheetTableColumn {
id: index as u32 + 1,
name,
totals_row_label: None,
totals_row_function: None,
})
.collect()
}
fn unique_table_column_names(names: Vec<String>) -> Vec<String> {
let mut seen = BTreeMap::<String, u32>::new();
names.into_iter()
.map(|name| {
let entry = seen.entry(name.clone()).or_insert(0);
*entry += 1;
if *entry == 1 {
name
} else {
format!("{name}_{}", *entry)
}
})
.collect()
}
fn cell_value_to_table_header(value: &SpreadsheetCellValue) -> String {
match value {
SpreadsheetCellValue::Bool(value) => value.to_string(),
SpreadsheetCellValue::Integer(value) => value.to_string(),
SpreadsheetCellValue::Float(value) => value.to_string(),
SpreadsheetCellValue::String(value)
| SpreadsheetCellValue::DateTime(value)
| SpreadsheetCellValue::Error(value) => value.clone(),
}
}
fn table_column_lookup_mut<'a>(
columns: &'a mut [SpreadsheetTableColumn],
action: &str,
column_id: Option<u32>,
column_name: Option<&str>,
) -> Result<&'a mut SpreadsheetTableColumn, SpreadsheetArtifactError> {
columns
.iter_mut()
.find(|column| {
if let Some(column_id) = column_id {
column.id == column_id
} else if let Some(column_name) = column_name {
column.name == column_name
} else {
false
}
})
.ok_or_else(|| SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: if let Some(column_id) = column_id {
format!("table column id `{column_id}` was not found")
} else if let Some(column_name) = column_name {
format!("table column `{column_name}` was not found")
} else {
"table column id or name is required".to_string()
},
})
}

View file

@ -15,6 +15,7 @@ use crate::SpreadsheetFileType;
use crate::SpreadsheetFill;
use crate::SpreadsheetFontFace;
use crate::SpreadsheetNumberFormat;
use crate::SpreadsheetRenderOptions;
use crate::SpreadsheetSheet;
use crate::SpreadsheetSheetReference;
use crate::SpreadsheetTextStyle;
@ -164,6 +165,72 @@ fn path_accesses_cover_import_and_export() -> Result<(), Box<dyn std::error::Err
Ok(())
}
#[test]
fn render_options_write_deterministic_html_previews() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let mut artifact = SpreadsheetArtifact::new(Some("Preview".to_string()));
artifact.create_sheet("Sheet 1".to_string())?;
{
let sheet = artifact
.get_sheet_mut(Some("Sheet 1"), None)
.expect("sheet");
sheet.set_value(
CellAddress::parse("A1")?,
Some(SpreadsheetCellValue::String("Name".to_string())),
)?;
sheet.set_value(
CellAddress::parse("B1")?,
Some(SpreadsheetCellValue::String("Value".to_string())),
)?;
sheet.set_value(
CellAddress::parse("A2")?,
Some(SpreadsheetCellValue::String("Alpha".to_string())),
)?;
sheet.set_value(
CellAddress::parse("B2")?,
Some(SpreadsheetCellValue::Integer(42)),
)?;
}
let rendered = artifact.render_range_preview(
temp_dir.path(),
artifact.get_sheet(Some("Sheet 1"), None).expect("sheet"),
&CellRange::parse("A1:B2")?,
&SpreadsheetRenderOptions {
output_path: Some(temp_dir.path().join("range-preview.html")),
width: Some(320),
height: Some(200),
include_headers: true,
scale: 1.25,
performance_mode: true,
..Default::default()
},
)?;
assert!(rendered.path.exists());
assert_eq!(std::fs::read_to_string(&rendered.path)?, rendered.html);
assert!(rendered.html.contains("<!doctype html>"));
assert!(rendered.html.contains("data-performance-mode=\"true\""));
assert!(rendered.html.contains(
"style=\"--scale: 1.25; --headers: 1; width: 320px; height: 200px; overflow: auto\""
));
assert!(rendered.html.contains("<th>A</th>"));
assert!(rendered.html.contains("data-address=\"B2\""));
assert!(rendered.html.contains(">42</td>"));
let workbook = artifact.render_workbook_previews(
temp_dir.path(),
&SpreadsheetRenderOptions {
output_path: Some(temp_dir.path().join("workbook")),
include_headers: false,
..Default::default()
},
)?;
assert_eq!(workbook.len(), 1);
assert!(workbook[0].path.ends_with("Sheet_1.html"));
assert!(!workbook[0].html.contains("<th>A</th>"));
Ok(())
}
#[test]
fn sheet_refs_support_handle_and_field_apis() -> Result<(), Box<dyn std::error::Error>> {
let mut artifact = SpreadsheetArtifact::new(Some("Handles".to_string()));
@ -1049,3 +1116,113 @@ fn manager_get_reference_and_xlsx_import_preserve_workbook_name()
);
Ok(())
}
#[test]
fn manager_render_actions_support_workbook_sheet_and_range()
-> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let mut manager = SpreadsheetArtifactManager::default();
let created = manager.execute(
SpreadsheetArtifactRequest {
artifact_id: None,
action: "create".to_string(),
args: serde_json::json!({ "name": "Render" }),
},
temp_dir.path(),
)?;
let artifact_id = created.artifact_id;
manager.execute(
SpreadsheetArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "create_sheet".to_string(),
args: serde_json::json!({ "name": "Sheet1" }),
},
temp_dir.path(),
)?;
manager.execute(
SpreadsheetArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "set_range_values".to_string(),
args: serde_json::json!({
"sheet_name": "Sheet1",
"range": "A1:C4",
"values": [
["h1", "h2", "h3"],
["a", 1, 2],
["b", 3, 4],
["c", 5, 6]
]
}),
},
temp_dir.path(),
)?;
let workbook = manager.execute(
SpreadsheetArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "render_workbook".to_string(),
args: serde_json::json!({
"output_path": temp_dir.path().join("workbook-previews"),
"include_headers": false
}),
},
temp_dir.path(),
)?;
assert_eq!(workbook.exported_paths.len(), 1);
assert!(workbook.exported_paths[0].exists());
let sheet = manager.execute(
SpreadsheetArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "render_sheet".to_string(),
args: serde_json::json!({
"sheet_name": "Sheet1",
"output_path": temp_dir.path().join("sheet-preview.html"),
"center_address": "B3",
"width": 220,
"height": 90,
"scale": 1.5,
"performance_mode": true
}),
},
temp_dir.path(),
)?;
assert_eq!(sheet.exported_paths.len(), 1);
assert!(sheet.exported_paths[0].exists());
assert!(
sheet
.rendered_html
.as_ref()
.is_some_and(|html| html.contains("data-performance-mode=\"true\""))
);
let range = manager.execute(
SpreadsheetArtifactRequest {
artifact_id: Some(artifact_id),
action: "render_range".to_string(),
args: serde_json::json!({
"sheet_name": "Sheet1",
"range": "A2:C4",
"output_path": temp_dir.path().join("range-preview.html"),
"include_headers": true
}),
},
temp_dir.path(),
)?;
assert_eq!(range.exported_paths.len(), 1);
assert_eq!(
range
.range_ref
.as_ref()
.map(|range_ref| range_ref.address.clone()),
Some("A2:C4".to_string())
);
assert!(
range
.rendered_html
.as_ref()
.is_some_and(|html| html.contains("<th>A</th>"))
);
Ok(())
}

View file

@ -49,6 +49,10 @@ impl ToolHandler for PresentationArtifactHandler {
| "list_slide_placeholders"
| "inspect"
| "resolve"
| "to_proto"
| "get_style"
| "describe_styles"
| "record_patch"
)
}

View file

@ -17,11 +17,19 @@ Supported actions:
- `list_slide_placeholders`
- `inspect`
- `resolve`
- `to_proto`
- `record_patch`
- `apply_patch`
- `undo`
- `redo`
- `create_layout`
- `add_layout_placeholder`
- `set_slide_layout`
- `update_placeholder_text`
- `set_theme`
- `add_style`
- `get_style`
- `describe_styles`
- `set_notes`
- `append_notes`
- `clear_notes`
@ -77,14 +85,51 @@ Example layout flow:
`{"artifact_id":"presentation_x","action":"list_slide_placeholders","args":{"slide_index":0}}`
Layout references in `create_layout.parent_layout_id`, `add_layout_placeholder.layout_id`, `add_slide`, `insert_slide`, `set_slide_layout`, and `list_layout_placeholders` accept either a layout id or a layout name. Name matching prefers exact id, then exact name, then case-insensitive name.
`insert_slide` accepts `index` or `after_slide_index`. If neither is provided, the new slide is inserted immediately after the active slide, or appended if no active slide is set yet.
Example inspect:
`{"artifact_id":"presentation_x","action":"inspect","args":{"kind":"deck,slide,textbox,shape,table,chart,image,notes,layoutList","max_chars":12000}}`
`{"artifact_id":"presentation_x","action":"inspect","args":{"include":"deck,slide,textbox,shape,table,chart,image,notes,layoutList","exclude":"notes","search":"roadmap","max_chars":12000}}`
Example inspect target window:
`{"artifact_id":"presentation_x","action":"inspect","args":{"include":"textbox","target":{"id":"sh/element_3","before_lines":1,"after_lines":1}}}`
Example resolve:
`{"artifact_id":"presentation_x","action":"resolve","args":{"id":"sh/element_3"}}`
Example proto export:
`{"artifact_id":"presentation_x","action":"to_proto","args":{}}`
`to_proto` returns a full JSON snapshot of the current in-memory presentation document, including slide/layout records, anchors, notes, theme state, and typed element payloads.
Example patch recording:
`{"artifact_id":"presentation_x","action":"record_patch","args":{"operations":[{"action":"add_text_shape","args":{"slide_index":0,"text":"Headline","position":{"left":48,"top":48,"width":320,"height":72}}},{"action":"set_slide_background","args":{"slide_index":0,"fill":"#F7F1E8"}}]}}`
Example patch application:
`{"artifact_id":"presentation_x","action":"apply_patch","args":{"patch":{"version":1,"artifactId":"presentation_x","operations":[{"action":"add_text_shape","args":{"slide_index":0,"text":"Headline","position":{"left":48,"top":48,"width":320,"height":72}}},{"action":"set_slide_background","args":{"slide_index":0,"fill":"#F7F1E8"}}]}}}`
Patch payloads are single-artifact and currently support existing in-memory editing actions like slide/element/layout/theme/text updates. Lifecycle, import/export, and nested history actions are intentionally excluded.
Example undo/redo:
`{"artifact_id":"presentation_x","action":"undo","args":{}}`
`{"artifact_id":"presentation_x","action":"redo","args":{}}`
Deck summaries, slide listings, `inspect`, and `resolve` now include active-slide metadata. Use `set_active_slide` to change it explicitly.
Theme snapshots and `to_proto` both expose the deck theme hex color map via `hex_color_map` / `hexColorMap`.
Named text styles are supported through `add_style`, `get_style`, and `describe_styles`. Built-in styles include `title`, `heading1`, `body`, `list`, and `numberedList`.
Example style creation:
`{"artifact_id":"presentation_x","action":"add_style","args":{"name":"callout","font_size":18,"color":"#336699","italic":true,"underline":true}}`
Example style lookup:
`{"artifact_id":"presentation_x","action":"get_style","args":{"name":"title"}}`
Text styling payloads on `add_text_shape`, `add_shape.text_style`, `update_text.styling`, and `update_table_cell.styling` accept `style` and `underline` in addition to the existing whole-element fields.
Text-bearing elements also support literal `replace_text` and `insert_text_after` helpers for in-place edits without resending the full string.
Text boxes and shapes support whole-element hyperlinks via `set_hyperlink`. Supported `link_type` values are `url`, `slide`, `first_slide`, `last_slide`, `next_slide`, `previous_slide`, `end_show`, `email`, and `file`. Use `clear: true` to remove an existing hyperlink.
@ -93,7 +138,9 @@ Notes visibility is honored on export: `set_notes_visibility` controls whether s
Image placeholders can be prompt-only. `add_image` accepts `prompt` without `path`/`data_url`, and unresolved placeholders export as a visible placeholder box instead of failing.
Remote images are supported. `add_image` and `replace_image` accept `uri` in addition to local `path` and `data_url`.
Layout placeholders with `placeholder_type: "picture"` or `"image"` materialize as placeholder image elements on slides, so they appear in `list_slide_placeholders`, `inspect`, and `resolve` as `image` records rather than generic shapes.
Remote images are supported. `add_image` and `replace_image` accept `uri` in addition to local `path`, raw base64 `blob`, and `data_url`.
Image edits can target inspect/resolve anchors like `im/element_3`, and `update_shape_style` now accepts image `fit`, `crop`, `rotation`, `flip_horizontal`, `flip_vertical`, and `lock_aspect_ratio` updates.
@ -101,12 +148,16 @@ Image edits can target inspect/resolve anchors like `im/element_3`, and `update_
`update_shape_style.position` accepts partial updates, so you can move or resize an element without resending the full rect.
Shape strokes accept an optional `style` field such as `solid`, `dashed`, `dotted`, `dash-dot`, `dash-dot-dot`, `long-dash`, or `long-dash-dot`. This applies to ordinary shapes via `add_shape.stroke` and `update_shape_style.stroke`.
`add_shape` also accepts optional `rotation`, `flip_horizontal`, and `flip_vertical` fields. Those same transform fields can also be provided inside the `position` object for `add_shape`, `add_image`, and `update_shape_style`.
Connectors are supported via `add_connector`, with straight/elbow/curved types plus dash styles and arrow heads.
Example preview:
`{"artifact_id":"presentation_x","action":"export_preview","args":{"slide_index":0,"path":"artifacts/q2-update-slide1.png"}}`
`export_preview` also accepts `format`, `scale`, and `quality` for rendered previews. `format` currently supports `png` and `jpeg`.
`export_preview` also accepts `format`, `scale`, and `quality` for rendered previews. `format` currently supports `png`, `jpeg`, and `svg`.
Example JPEG preview:
`{"artifact_id":"presentation_x","action":"export_preview","args":{"slide_index":0,"path":"artifacts/q2-update-slide1.jpg","format":"jpeg","scale":0.75,"quality":85}}`