mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
feat: external artifacts builder (#13485)
This PR reverts the built-in artifact render while a decision is being reached. No impact expected on any features
This commit is contained in:
Generated
+3
-41
File diff suppressed because one or more lines are too long
Generated
+121
-577
File diff suppressed because it is too large
Load Diff
+7
-7
@@ -33,8 +33,6 @@ members = [
|
||||
"mcp-server",
|
||||
"network-proxy",
|
||||
"ollama",
|
||||
"artifact-presentation",
|
||||
"artifact-spreadsheet",
|
||||
"process-hardening",
|
||||
"protocol",
|
||||
"rmcp-client",
|
||||
@@ -66,6 +64,8 @@ members = [
|
||||
"state",
|
||||
"codex-experimental-api-macros",
|
||||
"test-macros",
|
||||
"package-manager",
|
||||
"artifacts",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
@@ -83,6 +83,8 @@ license = "Apache-2.0"
|
||||
app_test_support = { path = "app-server/tests/common" }
|
||||
codex-ansi-escape = { path = "ansi-escape" }
|
||||
codex-api = { path = "codex-api" }
|
||||
codex-artifacts = { path = "artifacts" }
|
||||
codex-package-manager = { path = "package-manager" }
|
||||
codex-app-server = { path = "app-server" }
|
||||
codex-app-server-protocol = { path = "app-server-protocol" }
|
||||
codex-app-server-test-client = { path = "app-server-test-client" }
|
||||
@@ -111,8 +113,6 @@ codex-mcp-server = { path = "mcp-server" }
|
||||
codex-network-proxy = { path = "network-proxy" }
|
||||
codex-ollama = { path = "ollama" }
|
||||
codex-otel = { path = "otel" }
|
||||
codex-artifact-presentation = { path = "artifact-presentation" }
|
||||
codex-artifact-spreadsheet = { path = "artifact-spreadsheet" }
|
||||
codex-process-hardening = { path = "process-hardening" }
|
||||
codex-protocol = { path = "protocol" }
|
||||
codex-responses-api-proxy = { path = "responses-api-proxy" }
|
||||
@@ -181,6 +181,7 @@ encoding_rs = "0.8.35"
|
||||
env-flags = "0.1.1"
|
||||
env_logger = "0.11.9"
|
||||
eventsource-stream = "0.2.3"
|
||||
flate2 = "1.1.4"
|
||||
futures = { version = "0.3", default-features = false }
|
||||
gethostname = "1.1.0"
|
||||
globset = "0.4"
|
||||
@@ -219,7 +220,6 @@ owo-colors = "4.3.0"
|
||||
path-absolutize = "3.1.1"
|
||||
pathdiff = "0.2"
|
||||
portable-pty = "0.9.0"
|
||||
ppt-rs = "0.2.6"
|
||||
predicates = "3"
|
||||
pretty_assertions = "1.4.1"
|
||||
pulldown-cmark = "0.10"
|
||||
@@ -267,6 +267,7 @@ supports-color = "3.0.2"
|
||||
syntect = "5"
|
||||
sys-locale = "0.3.2"
|
||||
tempfile = "3.23.0"
|
||||
tar = "0.4.44"
|
||||
test-log = "0.2.19"
|
||||
textwrap = "0.16.2"
|
||||
thiserror = "2.0.17"
|
||||
@@ -353,8 +354,7 @@ ignored = [
|
||||
"icu_provider",
|
||||
"openssl-sys",
|
||||
"codex-utils-readiness",
|
||||
"codex-secrets",
|
||||
"codex-artifact-spreadsheet"
|
||||
"codex-secrets"
|
||||
]
|
||||
|
||||
[profile.release]
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "artifact-presentation",
|
||||
crate_name = "codex_artifact_presentation",
|
||||
)
|
||||
@@ -1,28 +0,0 @@
|
||||
[package]
|
||||
name = "codex-artifact-presentation"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "codex_artifact_presentation"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
base64 = { workspace = true }
|
||||
image = { workspace = true, features = ["jpeg", "png"] }
|
||||
ppt-rs = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["blocking"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
uuid = { workspace = true, features = ["v4"] }
|
||||
zip = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
tiny_http = { workspace = true }
|
||||
@@ -1,6 +0,0 @@
|
||||
mod presentation_artifact;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use presentation_artifact::*;
|
||||
@@ -1,249 +0,0 @@
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use image::GenericImageView;
|
||||
use image::ImageFormat;
|
||||
use image::codecs::jpeg::JpegEncoder;
|
||||
use image::imageops::FilterType;
|
||||
use ppt_rs::Chart;
|
||||
use ppt_rs::ChartSeries;
|
||||
use ppt_rs::ChartType;
|
||||
use ppt_rs::Hyperlink as PptHyperlink;
|
||||
use ppt_rs::HyperlinkAction as PptHyperlinkAction;
|
||||
use ppt_rs::Image;
|
||||
use ppt_rs::Presentation;
|
||||
use ppt_rs::Shape;
|
||||
use ppt_rs::ShapeFill;
|
||||
use ppt_rs::ShapeLine;
|
||||
use ppt_rs::ShapeType;
|
||||
use ppt_rs::SlideContent;
|
||||
use ppt_rs::SlideLayout;
|
||||
use ppt_rs::TableBuilder;
|
||||
use ppt_rs::TableCell;
|
||||
use ppt_rs::TableRow;
|
||||
use ppt_rs::generator::ArrowSize;
|
||||
use ppt_rs::generator::ArrowType;
|
||||
use ppt_rs::generator::CellAlign;
|
||||
use ppt_rs::generator::Connector;
|
||||
use ppt_rs::generator::ConnectorLine;
|
||||
use ppt_rs::generator::ConnectorType;
|
||||
use ppt_rs::generator::LineDash;
|
||||
use ppt_rs::generator::generate_image_content_type;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::io::Cursor;
|
||||
use std::io::Read;
|
||||
use std::io::Seek;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
use zip::ZipArchive;
|
||||
use zip::ZipWriter;
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
const POINT_TO_EMU: u32 = 12_700;
|
||||
const DEFAULT_SLIDE_WIDTH_POINTS: u32 = 720;
|
||||
const DEFAULT_SLIDE_HEIGHT_POINTS: u32 = 540;
|
||||
const DEFAULT_IMPORTED_TITLE_LEFT: u32 = 36;
|
||||
const DEFAULT_IMPORTED_TITLE_TOP: u32 = 24;
|
||||
const DEFAULT_IMPORTED_TITLE_WIDTH: u32 = 648;
|
||||
const DEFAULT_IMPORTED_TITLE_HEIGHT: u32 = 48;
|
||||
const DEFAULT_IMPORTED_CONTENT_LEFT: u32 = 48;
|
||||
const DEFAULT_IMPORTED_CONTENT_TOP: u32 = 96;
|
||||
const DEFAULT_IMPORTED_CONTENT_WIDTH: u32 = 624;
|
||||
const DEFAULT_IMPORTED_CONTENT_HEIGHT: u32 = 324;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum PresentationArtifactError {
|
||||
#[error("missing `artifact_id` for action `{action}`")]
|
||||
MissingArtifactId { action: String },
|
||||
#[error("unknown artifact id `{artifact_id}` for action `{action}`")]
|
||||
UnknownArtifactId { action: String, artifact_id: String },
|
||||
#[error("unknown action `{0}`")]
|
||||
UnknownAction(String),
|
||||
#[error("invalid args for action `{action}`: {message}")]
|
||||
InvalidArgs { action: String, message: String },
|
||||
#[error("unsupported feature for action `{action}`: {message}")]
|
||||
UnsupportedFeature { action: String, message: String },
|
||||
#[error("failed to import PPTX `{path}`: {message}")]
|
||||
ImportFailed { path: PathBuf, message: String },
|
||||
#[error("failed to export PPTX `{path}`: {message}")]
|
||||
ExportFailed { path: PathBuf, message: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct PresentationArtifactRequest {
|
||||
pub artifact_id: Option<String>,
|
||||
pub action: String,
|
||||
#[serde(default)]
|
||||
pub args: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PresentationArtifactToolRequest {
|
||||
pub artifact_id: Option<String>,
|
||||
pub actions: Vec<PresentationArtifactToolAction>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PresentationArtifactExecutionRequest {
|
||||
pub artifact_id: Option<String>,
|
||||
pub requests: Vec<PresentationArtifactRequest>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PresentationArtifactToolAction {
|
||||
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 is_mutating(&self) -> bool {
|
||||
!is_read_only_action(&self.action)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
impl PresentationArtifactToolRequest {
|
||||
pub fn is_mutating(&self) -> Result<bool, PresentationArtifactError> {
|
||||
Ok(self.actions.iter().any(|request| !is_read_only_action(&request.action)))
|
||||
}
|
||||
|
||||
pub fn into_execution_request(
|
||||
self,
|
||||
) -> Result<PresentationArtifactExecutionRequest, PresentationArtifactError> {
|
||||
if self.actions.is_empty() {
|
||||
return Err(PresentationArtifactError::InvalidArgs {
|
||||
action: "presentation_artifact".to_string(),
|
||||
message: "`actions` must contain at least one item".to_string(),
|
||||
});
|
||||
}
|
||||
Ok(PresentationArtifactExecutionRequest {
|
||||
artifact_id: self.artifact_id,
|
||||
requests: self
|
||||
.actions
|
||||
.into_iter()
|
||||
.map(|request| PresentationArtifactRequest {
|
||||
artifact_id: None,
|
||||
action: request.action,
|
||||
args: request.args,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn required_path_accesses(
|
||||
&self,
|
||||
cwd: &Path,
|
||||
) -> Result<Vec<PathAccessRequirement>, PresentationArtifactError> {
|
||||
let mut accesses = Vec::new();
|
||||
for request in &self.actions {
|
||||
accesses.extend(
|
||||
PresentationArtifactRequest {
|
||||
artifact_id: None,
|
||||
action: request.action.clone(),
|
||||
args: request.args.clone(),
|
||||
}
|
||||
.required_path_accesses(cwd)?,
|
||||
);
|
||||
}
|
||||
Ok(accesses)
|
||||
}
|
||||
}
|
||||
@@ -1,729 +0,0 @@
|
||||
#[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, Clone, 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, Clone, Default, Deserialize)]
|
||||
struct TextLayoutArgs {
|
||||
insets: Option<TextInsetsArgs>,
|
||||
wrap: Option<String>,
|
||||
auto_fit: Option<String>,
|
||||
vertical_alignment: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct TextInsetsArgs {
|
||||
left: u32,
|
||||
right: u32,
|
||||
top: u32,
|
||||
bottom: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum RichTextInput {
|
||||
Plain(String),
|
||||
Paragraphs(Vec<RichParagraphInput>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum RichParagraphInput {
|
||||
Plain(String),
|
||||
Runs(Vec<RichRunInput>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum RichRunInput {
|
||||
Plain(String),
|
||||
Styled(RichRunObjectInput),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct RichRunObjectInput {
|
||||
run: String,
|
||||
#[serde(default)]
|
||||
text_style: TextStylingArgs,
|
||||
link: Option<RichTextLinkInput>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct RichTextLinkInput {
|
||||
uri: Option<String>,
|
||||
is_external: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AddTextShapeArgs {
|
||||
slide_index: u32,
|
||||
text: String,
|
||||
position: PositionArgs,
|
||||
#[serde(flatten)]
|
||||
styling: TextStylingArgs,
|
||||
#[serde(default)]
|
||||
text_layout: TextLayoutArgs,
|
||||
}
|
||||
|
||||
#[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,
|
||||
#[serde(default)]
|
||||
text_layout: TextLayoutArgs,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
struct ConnectorLineArgs {
|
||||
color: Option<String>,
|
||||
width: Option<u32>,
|
||||
style: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, 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>,
|
||||
style_options: Option<TableStyleOptionsArgs>,
|
||||
borders: Option<TableBordersArgs>,
|
||||
right_to_left: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AddChartArgs {
|
||||
slide_index: u32,
|
||||
position: PositionArgs,
|
||||
chart_type: String,
|
||||
categories: Vec<String>,
|
||||
series: Vec<ChartSeriesArgs>,
|
||||
title: Option<String>,
|
||||
style_index: Option<u32>,
|
||||
has_legend: Option<bool>,
|
||||
legend_position: Option<String>,
|
||||
#[serde(default)]
|
||||
legend_text_style: TextStylingArgs,
|
||||
x_axis_title: Option<String>,
|
||||
y_axis_title: Option<String>,
|
||||
data_labels: Option<ChartDataLabelsArgs>,
|
||||
chart_fill: Option<String>,
|
||||
plot_area_fill: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ChartSeriesArgs {
|
||||
name: String,
|
||||
values: Vec<f64>,
|
||||
categories: Option<Vec<String>>,
|
||||
x_values: Option<Vec<f64>>,
|
||||
fill: Option<String>,
|
||||
stroke: Option<StrokeArgs>,
|
||||
marker: Option<ChartMarkerArgs>,
|
||||
data_label_overrides: Option<Vec<ChartDataLabelOverrideArgs>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct ChartMarkerArgs {
|
||||
symbol: Option<String>,
|
||||
size: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct ChartDataLabelsArgs {
|
||||
show_value: Option<bool>,
|
||||
show_category_name: Option<bool>,
|
||||
show_leader_lines: Option<bool>,
|
||||
position: Option<String>,
|
||||
#[serde(default)]
|
||||
text_style: TextStylingArgs,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct ChartDataLabelOverrideArgs {
|
||||
idx: u32,
|
||||
text: Option<String>,
|
||||
position: Option<String>,
|
||||
#[serde(default)]
|
||||
text_style: TextStylingArgs,
|
||||
fill: Option<String>,
|
||||
stroke: Option<StrokeArgs>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct UpdateTextArgs {
|
||||
element_id: String,
|
||||
text: String,
|
||||
#[serde(default)]
|
||||
styling: TextStylingArgs,
|
||||
#[serde(default)]
|
||||
text_layout: TextLayoutArgs,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SetRichTextArgs {
|
||||
element_id: Option<String>,
|
||||
slide_index: Option<u32>,
|
||||
row: Option<u32>,
|
||||
column: Option<u32>,
|
||||
notes: Option<bool>,
|
||||
text: RichTextInput,
|
||||
#[serde(default)]
|
||||
styling: TextStylingArgs,
|
||||
#[serde(default)]
|
||||
text_layout: TextLayoutArgs,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct FormatTextRangeArgs {
|
||||
element_id: Option<String>,
|
||||
slide_index: Option<u32>,
|
||||
row: Option<u32>,
|
||||
column: Option<u32>,
|
||||
notes: Option<bool>,
|
||||
query: Option<String>,
|
||||
occurrence: Option<usize>,
|
||||
start_cp: Option<usize>,
|
||||
length: Option<usize>,
|
||||
#[serde(default)]
|
||||
styling: TextStylingArgs,
|
||||
#[serde(default)]
|
||||
text_layout: TextLayoutArgs,
|
||||
link: Option<RichTextLinkInput>,
|
||||
spacing_before: Option<u32>,
|
||||
spacing_after: Option<u32>,
|
||||
line_spacing: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
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>,
|
||||
#[serde(default)]
|
||||
text_layout: TextLayoutArgs,
|
||||
}
|
||||
|
||||
#[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, Clone, Deserialize)]
|
||||
struct TableStyleOptionsArgs {
|
||||
header_row: Option<bool>,
|
||||
banded_rows: Option<bool>,
|
||||
banded_columns: Option<bool>,
|
||||
first_column: Option<bool>,
|
||||
last_column: Option<bool>,
|
||||
total_row: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct TableBorderArgs {
|
||||
color: String,
|
||||
width: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct TableBordersArgs {
|
||||
outside: Option<TableBorderArgs>,
|
||||
inside: Option<TableBorderArgs>,
|
||||
top: Option<TableBorderArgs>,
|
||||
bottom: Option<TableBorderArgs>,
|
||||
left: Option<TableBorderArgs>,
|
||||
right: Option<TableBorderArgs>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct UpdateTableStyleArgs {
|
||||
element_id: String,
|
||||
style: Option<String>,
|
||||
style_options: Option<TableStyleOptionsArgs>,
|
||||
borders: Option<TableBordersArgs>,
|
||||
right_to_left: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct StyleTableBlockArgs {
|
||||
element_id: String,
|
||||
row: u32,
|
||||
column: u32,
|
||||
row_count: u32,
|
||||
column_count: u32,
|
||||
#[serde(default)]
|
||||
styling: TextStylingArgs,
|
||||
background_fill: Option<String>,
|
||||
alignment: Option<String>,
|
||||
borders: Option<TableBordersArgs>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct MergeTableCellsArgs {
|
||||
element_id: String,
|
||||
start_row: u32,
|
||||
end_row: u32,
|
||||
start_column: u32,
|
||||
end_column: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct UpdateChartArgs {
|
||||
element_id: String,
|
||||
title: Option<String>,
|
||||
categories: Option<Vec<String>>,
|
||||
style_index: Option<u32>,
|
||||
has_legend: Option<bool>,
|
||||
legend_position: Option<String>,
|
||||
#[serde(default)]
|
||||
legend_text_style: TextStylingArgs,
|
||||
x_axis_title: Option<String>,
|
||||
y_axis_title: Option<String>,
|
||||
data_labels: Option<ChartDataLabelsArgs>,
|
||||
chart_fill: Option<String>,
|
||||
plot_area_fill: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AddChartSeriesArgs {
|
||||
element_id: String,
|
||||
name: String,
|
||||
values: Vec<f64>,
|
||||
categories: Option<Vec<String>>,
|
||||
x_values: Option<Vec<f64>>,
|
||||
fill: Option<String>,
|
||||
stroke: Option<StrokeArgs>,
|
||||
marker: Option<ChartMarkerArgs>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SetCommentAuthorArgs {
|
||||
display_name: String,
|
||||
initials: String,
|
||||
email: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct CommentPositionArgs {
|
||||
x: u32,
|
||||
y: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AddCommentThreadArgs {
|
||||
slide_index: Option<u32>,
|
||||
element_id: Option<String>,
|
||||
query: Option<String>,
|
||||
occurrence: Option<usize>,
|
||||
start_cp: Option<usize>,
|
||||
length: Option<usize>,
|
||||
text: String,
|
||||
position: Option<CommentPositionArgs>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AddCommentReplyArgs {
|
||||
thread_id: String,
|
||||
text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ToggleCommentReactionArgs {
|
||||
thread_id: String,
|
||||
message_id: Option<String>,
|
||||
emoji: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CommentThreadIdArgs {
|
||||
thread_id: String,
|
||||
}
|
||||
@@ -1,871 +0,0 @@
|
||||
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,textRange,comment",
|
||||
);
|
||||
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)),
|
||||
"commentThreadIds": document
|
||||
.comment_threads
|
||||
.iter()
|
||||
.map(|thread| format!("th/{}", thread.thread_id))
|
||||
.collect::<Vec<_>>(),
|
||||
}),
|
||||
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(),
|
||||
"richText": rich_text_to_proto(&slide.notes.text, &slide.notes.rich_text),
|
||||
}),
|
||||
Some(slide_id.clone()),
|
||||
));
|
||||
}
|
||||
if include("textRange") {
|
||||
records.extend(
|
||||
slide
|
||||
.notes
|
||||
.rich_text
|
||||
.ranges
|
||||
.iter()
|
||||
.map(|range| {
|
||||
let mut record = text_range_to_proto(&slide.notes.text, range);
|
||||
record["kind"] = Value::String("textRange".to_string());
|
||||
record["slide"] = Value::from(index + 1);
|
||||
record["slideIndex"] = Value::from(index);
|
||||
record["hostAnchor"] = Value::String(format!("nt/{}", slide.slide_id));
|
||||
record["hostKind"] = Value::String("notes".to_string());
|
||||
(record, Some(slide_id.clone()))
|
||||
}),
|
||||
);
|
||||
}
|
||||
for element in &slide.elements {
|
||||
let mut record = match element {
|
||||
PresentationElement::Text(text) => {
|
||||
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(),
|
||||
"richText": rich_text_to_proto(&text.text, &text.rich_text),
|
||||
"bbox": [text.frame.left, text.frame.top, text.frame.width, text.frame.height],
|
||||
"bboxUnit": "points",
|
||||
})
|
||||
}
|
||||
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),
|
||||
"richText": shape
|
||||
.text
|
||||
.as_ref()
|
||||
.zip(shape.rich_text.as_ref())
|
||||
.map(|(text, rich_text)| rich_text_to_proto(text, rich_text))
|
||||
.unwrap_or(Value::Null),
|
||||
"rotation": shape.rotation_degrees,
|
||||
"flipHorizontal": shape.flip_horizontal,
|
||||
"flipVertical": shape.flip_vertical,
|
||||
"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,
|
||||
"styleOptions": table_style_options_to_proto(&table.style_options),
|
||||
"borders": table.borders.as_ref().map(table_borders_to_proto),
|
||||
"rightToLeft": table.right_to_left,
|
||||
"cellTextStyles": table
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| row.iter().map(|cell| text_style_to_proto(&cell.text_style)).collect::<Vec<_>>())
|
||||
.collect::<Vec<_>>(),
|
||||
"rowsData": table
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| row.iter().map(table_cell_to_proto).collect::<Vec<_>>())
|
||||
.collect::<Vec<_>>(),
|
||||
"bbox": [table.frame.left, table.frame.top, table.frame.width, table.frame.height],
|
||||
"bboxUnit": "points",
|
||||
})
|
||||
}
|
||||
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,
|
||||
"styleIndex": chart.style_index,
|
||||
"hasLegend": chart.has_legend,
|
||||
"legend": chart.legend.as_ref().map(chart_legend_to_proto),
|
||||
"xAxis": chart.x_axis.as_ref().map(chart_axis_to_proto),
|
||||
"yAxis": chart.y_axis.as_ref().map(chart_axis_to_proto),
|
||||
"dataLabels": chart.data_labels.as_ref().map(chart_data_labels_to_proto),
|
||||
"chartFill": chart.chart_fill,
|
||||
"plotAreaFill": chart.plot_area_fill,
|
||||
"series": chart
|
||||
.series
|
||||
.iter()
|
||||
.map(|series| serde_json::json!({
|
||||
"name": series.name,
|
||||
"values": series.values,
|
||||
"categories": series.categories,
|
||||
"xValues": series.x_values,
|
||||
"fill": series.fill,
|
||||
"stroke": series.stroke.as_ref().map(stroke_to_proto),
|
||||
"marker": series.marker.as_ref().map(chart_marker_to_proto),
|
||||
"dataLabelOverrides": series
|
||||
.data_label_overrides
|
||||
.iter()
|
||||
.map(chart_data_label_override_to_proto)
|
||||
.collect::<Vec<_>>(),
|
||||
}))
|
||||
.collect::<Vec<_>>(),
|
||||
"bbox": [chart.frame.left, chart.frame.top, chart.frame.width, chart.frame.height],
|
||||
"bboxUnit": "points",
|
||||
})
|
||||
}
|
||||
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 include("textRange") {
|
||||
match element {
|
||||
PresentationElement::Text(text) => {
|
||||
records.extend(text.rich_text.ranges.iter().map(|range| {
|
||||
let mut record = text_range_to_proto(&text.text, range);
|
||||
record["kind"] = Value::String("textRange".to_string());
|
||||
record["slide"] = Value::from(index + 1);
|
||||
record["slideIndex"] = Value::from(index);
|
||||
record["hostAnchor"] = Value::String(format!("sh/{}", text.element_id));
|
||||
record["hostKind"] = Value::String("textbox".to_string());
|
||||
(record, Some(slide_id.clone()))
|
||||
}));
|
||||
}
|
||||
PresentationElement::Shape(shape) => {
|
||||
if let Some((text, rich_text)) = shape.text.as_ref().zip(shape.rich_text.as_ref()) {
|
||||
records.extend(rich_text.ranges.iter().map(|range| {
|
||||
let mut record = text_range_to_proto(text, range);
|
||||
record["kind"] = Value::String("textRange".to_string());
|
||||
record["slide"] = Value::from(index + 1);
|
||||
record["slideIndex"] = Value::from(index);
|
||||
record["hostAnchor"] = Value::String(format!("sh/{}", shape.element_id));
|
||||
record["hostKind"] = Value::String("textbox".to_string());
|
||||
(record, Some(slide_id.clone()))
|
||||
}));
|
||||
}
|
||||
}
|
||||
PresentationElement::Table(table) => {
|
||||
for (row_index, row) in table.rows.iter().enumerate() {
|
||||
for (column_index, cell) in row.iter().enumerate() {
|
||||
records.extend(cell.rich_text.ranges.iter().map(|range| {
|
||||
let mut record = text_range_to_proto(&cell.text, range);
|
||||
record["kind"] = Value::String("textRange".to_string());
|
||||
record["slide"] = Value::from(index + 1);
|
||||
record["slideIndex"] = Value::from(index);
|
||||
record["hostAnchor"] = Value::String(format!(
|
||||
"tb/{}#cell/{row_index}/{column_index}",
|
||||
table.element_id
|
||||
));
|
||||
record["hostKind"] = Value::String("tableCell".to_string());
|
||||
(record, Some(slide_id.clone()))
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
PresentationElement::Connector(_)
|
||||
| PresentationElement::Image(_)
|
||||
| PresentationElement::Chart(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if include("comment") {
|
||||
records.extend(document.comment_threads.iter().map(|thread| {
|
||||
let mut record = comment_thread_to_proto(thread);
|
||||
record["id"] = Value::String(format!("th/{}", thread.thread_id));
|
||||
(record, None)
|
||||
}));
|
||||
}
|
||||
|
||||
if let Some(target_id) = args.target_id.as_deref() {
|
||||
records.retain(|(record, slide_id)| {
|
||||
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);
|
||||
record["richText"] = rich_text_to_proto(&slide.notes.text, &slide.notes.rich_text);
|
||||
return Ok(record);
|
||||
}
|
||||
if let Some(range_id) = id.strip_prefix("tr/")
|
||||
&& let Some(record) = slide
|
||||
.notes
|
||||
.rich_text
|
||||
.ranges
|
||||
.iter()
|
||||
.find(|range| range.range_id == range_id)
|
||||
.map(|range| {
|
||||
let mut record = text_range_to_proto(&slide.notes.text, range);
|
||||
record["kind"] = Value::String("textRange".to_string());
|
||||
record["id"] = Value::String(id.to_string());
|
||||
record["slide"] = Value::from(slide_index + 1);
|
||||
record["slideIndex"] = Value::from(slide_index);
|
||||
record["hostAnchor"] = Value::String(notes_id.clone());
|
||||
record["hostKind"] = Value::String("notes".to_string());
|
||||
record
|
||||
})
|
||||
{
|
||||
return Ok(record);
|
||||
}
|
||||
for element in &slide.elements {
|
||||
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),
|
||||
"richText": rich_text_to_proto(&text.text, &text.rich_text),
|
||||
"bbox": [text.frame.left, text.frame.top, text.frame.width, text.frame.height],
|
||||
"bboxUnit": "points",
|
||||
});
|
||||
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),
|
||||
"richText": shape
|
||||
.text
|
||||
.as_ref()
|
||||
.zip(shape.rich_text.as_ref())
|
||||
.map(|(text, rich_text)| rich_text_to_proto(text, rich_text))
|
||||
.unwrap_or(Value::Null),
|
||||
"rotation": shape.rotation_degrees,
|
||||
"flipHorizontal": shape.flip_horizontal,
|
||||
"flipVertical": shape.flip_vertical,
|
||||
"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,
|
||||
"style": table.style,
|
||||
"styleOptions": table_style_options_to_proto(&table.style_options),
|
||||
"borders": table.borders.as_ref().map(table_borders_to_proto),
|
||||
"rightToLeft": table.right_to_left,
|
||||
"cellTextStyles": table
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| row.iter().map(|cell| text_style_to_proto(&cell.text_style)).collect::<Vec<_>>())
|
||||
.collect::<Vec<_>>(),
|
||||
"rowsData": table
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| row.iter().map(table_cell_to_proto).collect::<Vec<_>>())
|
||||
.collect::<Vec<_>>(),
|
||||
"bbox": [table.frame.left, table.frame.top, table.frame.width, table.frame.height],
|
||||
"bboxUnit": "points",
|
||||
}),
|
||||
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,
|
||||
"styleIndex": chart.style_index,
|
||||
"hasLegend": chart.has_legend,
|
||||
"legend": chart.legend.as_ref().map(chart_legend_to_proto),
|
||||
"xAxis": chart.x_axis.as_ref().map(chart_axis_to_proto),
|
||||
"yAxis": chart.y_axis.as_ref().map(chart_axis_to_proto),
|
||||
"dataLabels": chart.data_labels.as_ref().map(chart_data_labels_to_proto),
|
||||
"chartFill": chart.chart_fill,
|
||||
"plotAreaFill": chart.plot_area_fill,
|
||||
"series": chart
|
||||
.series
|
||||
.iter()
|
||||
.map(|series| serde_json::json!({
|
||||
"name": series.name,
|
||||
"values": series.values,
|
||||
"categories": series.categories,
|
||||
"xValues": series.x_values,
|
||||
"fill": series.fill,
|
||||
"stroke": series.stroke.as_ref().map(stroke_to_proto),
|
||||
"marker": series.marker.as_ref().map(chart_marker_to_proto),
|
||||
"dataLabelOverrides": series
|
||||
.data_label_overrides
|
||||
.iter()
|
||||
.map(chart_data_label_override_to_proto)
|
||||
.collect::<Vec<_>>(),
|
||||
}))
|
||||
.collect::<Vec<_>>(),
|
||||
"bbox": [chart.frame.left, chart.frame.top, chart.frame.width, chart.frame.height],
|
||||
"bboxUnit": "points",
|
||||
}),
|
||||
};
|
||||
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);
|
||||
}
|
||||
if let Some(range_id) = id.strip_prefix("tr/") {
|
||||
match element {
|
||||
PresentationElement::Text(text) => {
|
||||
if let Some(range) =
|
||||
text.rich_text.ranges.iter().find(|range| range.range_id == range_id)
|
||||
{
|
||||
let mut range_record = text_range_to_proto(&text.text, range);
|
||||
range_record["kind"] = Value::String("textRange".to_string());
|
||||
range_record["id"] = Value::String(id.to_string());
|
||||
range_record["slide"] = Value::from(slide_index + 1);
|
||||
range_record["slideIndex"] = Value::from(slide_index);
|
||||
range_record["hostAnchor"] =
|
||||
Value::String(format!("sh/{}", text.element_id));
|
||||
range_record["hostKind"] = Value::String("textbox".to_string());
|
||||
return Ok(range_record);
|
||||
}
|
||||
}
|
||||
PresentationElement::Shape(shape) => {
|
||||
if let Some((text, rich_text)) =
|
||||
shape.text.as_ref().zip(shape.rich_text.as_ref())
|
||||
&& let Some(range) =
|
||||
rich_text.ranges.iter().find(|range| range.range_id == range_id)
|
||||
{
|
||||
let mut range_record = text_range_to_proto(text, range);
|
||||
range_record["kind"] = Value::String("textRange".to_string());
|
||||
range_record["id"] = Value::String(id.to_string());
|
||||
range_record["slide"] = Value::from(slide_index + 1);
|
||||
range_record["slideIndex"] = Value::from(slide_index);
|
||||
range_record["hostAnchor"] =
|
||||
Value::String(format!("sh/{}", shape.element_id));
|
||||
range_record["hostKind"] = Value::String("textbox".to_string());
|
||||
return Ok(range_record);
|
||||
}
|
||||
}
|
||||
PresentationElement::Table(table) => {
|
||||
for (row_index, row) in table.rows.iter().enumerate() {
|
||||
for (column_index, cell) in row.iter().enumerate() {
|
||||
if let Some(range) = cell
|
||||
.rich_text
|
||||
.ranges
|
||||
.iter()
|
||||
.find(|range| range.range_id == range_id)
|
||||
{
|
||||
let mut range_record = text_range_to_proto(&cell.text, range);
|
||||
range_record["kind"] = Value::String("textRange".to_string());
|
||||
range_record["id"] = Value::String(id.to_string());
|
||||
range_record["slide"] = Value::from(slide_index + 1);
|
||||
range_record["slideIndex"] = Value::from(slide_index);
|
||||
range_record["hostAnchor"] = Value::String(format!(
|
||||
"tb/{}#cell/{row_index}/{column_index}",
|
||||
table.element_id
|
||||
));
|
||||
range_record["hostKind"] =
|
||||
Value::String("tableCell".to_string());
|
||||
return Ok(range_record);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
PresentationElement::Connector(_)
|
||||
| PresentationElement::Image(_)
|
||||
| PresentationElement::Chart(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(thread_id) = id.strip_prefix("th/")
|
||||
&& let Some(thread) = document
|
||||
.comment_threads
|
||||
.iter()
|
||||
.find(|thread| thread.thread_id == thread_id)
|
||||
{
|
||||
let mut record = comment_thread_to_proto(thread);
|
||||
record["id"] = Value::String(id.to_string());
|
||||
return Ok(record);
|
||||
}
|
||||
|
||||
for layout in &document.layouts {
|
||||
let layout_id = format!("ly/{}", layout.layout_id);
|
||||
if id == layout_id {
|
||||
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
@@ -1,10 +0,0 @@
|
||||
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
File diff suppressed because it is too large
Load Diff
@@ -1,951 +0,0 @@
|
||||
const CODEX_METADATA_ENTRY: &str = "ppt/codex-document.json";
|
||||
|
||||
fn import_codex_metadata_document(path: &Path) -> Result<Option<PresentationDocument>, String> {
|
||||
let file = std::fs::File::open(path).map_err(|error| error.to_string())?;
|
||||
let mut archive = ZipArchive::new(file).map_err(|error| error.to_string())?;
|
||||
let mut entry = match archive.by_name(CODEX_METADATA_ENTRY) {
|
||||
Ok(entry) => entry,
|
||||
Err(zip::result::ZipError::FileNotFound) => return Ok(None),
|
||||
Err(error) => return Err(error.to_string()),
|
||||
};
|
||||
let mut bytes = Vec::new();
|
||||
entry.read_to_end(&mut bytes)
|
||||
.map_err(|error| error.to_string())?;
|
||||
serde_json::from_slice(&bytes)
|
||||
.map(Some)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn build_pptx_bytes(document: &PresentationDocument, action: &str) -> Result<Vec<u8>, String> {
|
||||
let bytes = document
|
||||
.to_ppt_rs()
|
||||
.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();
|
||||
if name == CODEX_METADATA_ENTRY {
|
||||
continue;
|
||||
}
|
||||
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
|
||||
.start_file(CODEX_METADATA_ENTRY, SimpleFileOptions::default())
|
||||
.map_err(|error| error.to_string())?;
|
||||
writer
|
||||
.write_all(
|
||||
&serde_json::to_vec(document).map_err(|error| error.to_string())?,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
writer
|
||||
.finish()
|
||||
.map_err(|error| error.to_string())
|
||||
.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)
|
||||
}
|
||||
@@ -1,614 +0,0 @@
|
||||
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,
|
||||
"commentAuthor": document.comment_self.as_ref().map(comment_author_to_proto),
|
||||
"commentThreads": document
|
||||
.comment_threads
|
||||
.iter()
|
||||
.map(comment_thread_to_proto)
|
||||
.collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
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(),
|
||||
"richText": rich_text_to_proto(&slide.notes.text, &slide.notes.rich_text),
|
||||
}),
|
||||
"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),
|
||||
"richText": rich_text_to_proto(&text.text, &text.rich_text),
|
||||
"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),
|
||||
"richText": shape
|
||||
.text
|
||||
.as_ref()
|
||||
.zip(shape.rich_text.as_ref())
|
||||
.map(|(text, rich_text)| rich_text_to_proto(text, rich_text))
|
||||
.unwrap_or(Value::Null),
|
||||
"rotation": shape.rotation_degrees,
|
||||
"flipHorizontal": shape.flip_horizontal,
|
||||
"flipVertical": shape.flip_vertical,
|
||||
"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,
|
||||
"styleOptions": table_style_options_to_proto(&table.style_options),
|
||||
"borders": table.borders.as_ref().map(table_borders_to_proto),
|
||||
"rightToLeft": table.right_to_left,
|
||||
"merges": table.merges.iter().map(|merge| serde_json::json!({
|
||||
"startRow": merge.start_row,
|
||||
"endRow": merge.end_row,
|
||||
"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,
|
||||
"styleIndex": chart.style_index,
|
||||
"hasLegend": chart.has_legend,
|
||||
"legend": chart.legend.as_ref().map(chart_legend_to_proto),
|
||||
"xAxis": chart.x_axis.as_ref().map(chart_axis_to_proto),
|
||||
"yAxis": chart.y_axis.as_ref().map(chart_axis_to_proto),
|
||||
"dataLabels": chart.data_labels.as_ref().map(chart_data_labels_to_proto),
|
||||
"chartFill": chart.chart_fill,
|
||||
"plotAreaFill": chart.plot_area_fill,
|
||||
"series": chart.series.iter().map(|series| serde_json::json!({
|
||||
"name": series.name,
|
||||
"values": series.values,
|
||||
"categories": series.categories,
|
||||
"xValues": series.x_values,
|
||||
"fill": series.fill,
|
||||
"stroke": series.stroke.as_ref().map(stroke_to_proto),
|
||||
"marker": series.marker.as_ref().map(chart_marker_to_proto),
|
||||
"dataLabelOverrides": series
|
||||
.data_label_overrides
|
||||
.iter()
|
||||
.map(chart_data_label_override_to_proto)
|
||||
.collect::<Vec<_>>(),
|
||||
})).collect::<Vec<_>>(),
|
||||
"zOrder": chart.z_order,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
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 rich_text_to_proto(text: &str, rich_text: &RichTextState) -> Value {
|
||||
serde_json::json!({
|
||||
"layout": text_layout_to_proto(&rich_text.layout),
|
||||
"ranges": rich_text
|
||||
.ranges
|
||||
.iter()
|
||||
.map(|range| text_range_to_proto(text, range))
|
||||
.collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
fn text_range_to_proto(text: &str, range: &TextRangeAnnotation) -> Value {
|
||||
serde_json::json!({
|
||||
"rangeId": range.range_id,
|
||||
"anchor": format!("tr/{}", range.range_id),
|
||||
"startCp": range.start_cp,
|
||||
"length": range.length,
|
||||
"text": text_slice_by_codepoint_range(text, range.start_cp, range.length),
|
||||
"style": text_style_to_proto(&range.style),
|
||||
"hyperlink": range.hyperlink.as_ref().map(HyperlinkState::to_json),
|
||||
"spacingBefore": range.spacing_before,
|
||||
"spacingAfter": range.spacing_after,
|
||||
"lineSpacing": range.line_spacing,
|
||||
})
|
||||
}
|
||||
|
||||
fn text_layout_to_proto(layout: &TextLayoutState) -> Value {
|
||||
serde_json::json!({
|
||||
"insets": layout.insets.map(|insets| serde_json::json!({
|
||||
"left": insets.left,
|
||||
"right": insets.right,
|
||||
"top": insets.top,
|
||||
"bottom": insets.bottom,
|
||||
"unit": "points",
|
||||
})),
|
||||
"wrap": layout.wrap.map(text_wrap_mode_to_proto),
|
||||
"autoFit": layout.auto_fit.map(text_auto_fit_mode_to_proto),
|
||||
"verticalAlignment": layout
|
||||
.vertical_alignment
|
||||
.map(text_vertical_alignment_to_proto),
|
||||
})
|
||||
}
|
||||
|
||||
fn text_wrap_mode_to_proto(mode: TextWrapMode) -> &'static str {
|
||||
match mode {
|
||||
TextWrapMode::Square => "square",
|
||||
TextWrapMode::None => "none",
|
||||
}
|
||||
}
|
||||
|
||||
fn text_auto_fit_mode_to_proto(mode: TextAutoFitMode) -> &'static str {
|
||||
match mode {
|
||||
TextAutoFitMode::None => "none",
|
||||
TextAutoFitMode::ShrinkText => "shrinkText",
|
||||
TextAutoFitMode::ResizeShapeToFitText => "resizeShapeToFitText",
|
||||
}
|
||||
}
|
||||
|
||||
fn text_vertical_alignment_to_proto(alignment: TextVerticalAlignment) -> &'static str {
|
||||
match alignment {
|
||||
TextVerticalAlignment::Top => "top",
|
||||
TextVerticalAlignment::Middle => "middle",
|
||||
TextVerticalAlignment::Bottom => "bottom",
|
||||
}
|
||||
}
|
||||
|
||||
fn placeholder_ref_to_proto(placeholder: &PlaceholderRef) -> Value {
|
||||
serde_json::json!({
|
||||
"name": placeholder.name,
|
||||
"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),
|
||||
"richText": rich_text_to_proto(&cell.text, &cell.rich_text),
|
||||
"backgroundFill": cell.background_fill,
|
||||
"alignment": cell.alignment,
|
||||
"borders": cell.borders.as_ref().map(table_borders_to_proto),
|
||||
})
|
||||
}
|
||||
|
||||
fn table_style_options_to_proto(style_options: &TableStyleOptions) -> Value {
|
||||
serde_json::json!({
|
||||
"headerRow": style_options.header_row,
|
||||
"bandedRows": style_options.banded_rows,
|
||||
"bandedColumns": style_options.banded_columns,
|
||||
"firstColumn": style_options.first_column,
|
||||
"lastColumn": style_options.last_column,
|
||||
"totalRow": style_options.total_row,
|
||||
})
|
||||
}
|
||||
|
||||
fn table_borders_to_proto(borders: &TableBorders) -> Value {
|
||||
serde_json::json!({
|
||||
"outside": borders.outside.as_ref().map(table_border_to_proto),
|
||||
"inside": borders.inside.as_ref().map(table_border_to_proto),
|
||||
"top": borders.top.as_ref().map(table_border_to_proto),
|
||||
"bottom": borders.bottom.as_ref().map(table_border_to_proto),
|
||||
"left": borders.left.as_ref().map(table_border_to_proto),
|
||||
"right": borders.right.as_ref().map(table_border_to_proto),
|
||||
})
|
||||
}
|
||||
|
||||
fn table_border_to_proto(border: &TableBorder) -> Value {
|
||||
serde_json::json!({
|
||||
"color": border.color,
|
||||
"width": border.width,
|
||||
"unit": "points",
|
||||
})
|
||||
}
|
||||
|
||||
fn chart_marker_to_proto(marker: &ChartMarkerStyle) -> Value {
|
||||
serde_json::json!({
|
||||
"symbol": marker.symbol,
|
||||
"size": marker.size,
|
||||
})
|
||||
}
|
||||
|
||||
fn chart_data_labels_to_proto(data_labels: &ChartDataLabels) -> Value {
|
||||
serde_json::json!({
|
||||
"showValue": data_labels.show_value,
|
||||
"showCategoryName": data_labels.show_category_name,
|
||||
"showLeaderLines": data_labels.show_leader_lines,
|
||||
"position": data_labels.position,
|
||||
"textStyle": text_style_to_proto(&data_labels.text_style),
|
||||
})
|
||||
}
|
||||
|
||||
fn chart_legend_to_proto(legend: &ChartLegend) -> Value {
|
||||
serde_json::json!({
|
||||
"position": legend.position,
|
||||
"textStyle": text_style_to_proto(&legend.text_style),
|
||||
})
|
||||
}
|
||||
|
||||
fn chart_axis_to_proto(axis: &ChartAxisSpec) -> Value {
|
||||
serde_json::json!({
|
||||
"title": axis.title,
|
||||
})
|
||||
}
|
||||
|
||||
fn chart_data_label_override_to_proto(override_spec: &ChartDataLabelOverride) -> Value {
|
||||
serde_json::json!({
|
||||
"idx": override_spec.idx,
|
||||
"text": override_spec.text,
|
||||
"position": override_spec.position,
|
||||
"textStyle": text_style_to_proto(&override_spec.text_style),
|
||||
"fill": override_spec.fill,
|
||||
"stroke": override_spec.stroke.as_ref().map(stroke_to_proto),
|
||||
})
|
||||
}
|
||||
|
||||
fn comment_author_to_proto(author: &CommentAuthorProfile) -> Value {
|
||||
serde_json::json!({
|
||||
"displayName": author.display_name,
|
||||
"initials": author.initials,
|
||||
"email": author.email,
|
||||
})
|
||||
}
|
||||
|
||||
fn comment_thread_to_proto(thread: &CommentThread) -> Value {
|
||||
serde_json::json!({
|
||||
"kind": "comment",
|
||||
"threadId": thread.thread_id,
|
||||
"anchor": format!("th/{}", thread.thread_id),
|
||||
"target": comment_target_to_proto(&thread.target),
|
||||
"position": thread.position.as_ref().map(comment_position_to_proto),
|
||||
"status": comment_status_to_proto(thread.status),
|
||||
"messages": thread.messages.iter().map(comment_message_to_proto).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
fn comment_target_to_proto(target: &CommentTarget) -> Value {
|
||||
match target {
|
||||
CommentTarget::Slide { slide_id } => serde_json::json!({
|
||||
"type": "slide",
|
||||
"slideId": slide_id,
|
||||
"slideAnchor": format!("sl/{slide_id}"),
|
||||
}),
|
||||
CommentTarget::Element {
|
||||
slide_id,
|
||||
element_id,
|
||||
} => serde_json::json!({
|
||||
"type": "element",
|
||||
"slideId": slide_id,
|
||||
"slideAnchor": format!("sl/{slide_id}"),
|
||||
"elementId": element_id,
|
||||
"elementAnchor": format!("sh/{element_id}"),
|
||||
}),
|
||||
CommentTarget::TextRange {
|
||||
slide_id,
|
||||
element_id,
|
||||
start_cp,
|
||||
length,
|
||||
context,
|
||||
} => serde_json::json!({
|
||||
"type": "textRange",
|
||||
"slideId": slide_id,
|
||||
"slideAnchor": format!("sl/{slide_id}"),
|
||||
"elementId": element_id,
|
||||
"elementAnchor": format!("sh/{element_id}"),
|
||||
"startCp": start_cp,
|
||||
"length": length,
|
||||
"context": context,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn comment_position_to_proto(position: &CommentPosition) -> Value {
|
||||
serde_json::json!({
|
||||
"x": position.x,
|
||||
"y": position.y,
|
||||
"unit": "points",
|
||||
})
|
||||
}
|
||||
|
||||
fn comment_message_to_proto(message: &CommentMessage) -> Value {
|
||||
serde_json::json!({
|
||||
"messageId": message.message_id,
|
||||
"author": comment_author_to_proto(&message.author),
|
||||
"text": message.text,
|
||||
"createdAt": message.created_at,
|
||||
"reactions": message.reactions,
|
||||
})
|
||||
}
|
||||
|
||||
fn comment_status_to_proto(status: CommentThreadStatus) -> &'static str {
|
||||
match status {
|
||||
CommentThreadStatus::Active => "active",
|
||||
CommentThreadStatus::Resolved => "resolved",
|
||||
}
|
||||
}
|
||||
|
||||
fn text_slice_by_codepoint_range(text: &str, start_cp: usize, length: usize) -> String {
|
||||
text.chars().skip(start_cp).take(length).collect()
|
||||
}
|
||||
|
||||
fn build_table_cell(
|
||||
cell: TableCellSpec,
|
||||
merges: &[TableMergeRegion],
|
||||
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
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PresentationArtifactResponse {
|
||||
pub artifact_id: String,
|
||||
pub action: String,
|
||||
pub summary: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub executed_actions: Option<Vec<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,
|
||||
executed_actions: None,
|
||||
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,
|
||||
executed_actions: None,
|
||||
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>,
|
||||
}
|
||||
@@ -1,339 +0,0 @@
|
||||
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.visible && !slide.notes.text.is_empty())
|
||||
.then(|| slide.notes.text.clone()),
|
||||
notes_visible: slide.notes.visible,
|
||||
background_fill: slide.background_fill.clone(),
|
||||
layout_id: slide.layout_id.clone(),
|
||||
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
@@ -1,6 +0,0 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "artifact-spreadsheet",
|
||||
crate_name = "codex_artifact_spreadsheet",
|
||||
)
|
||||
@@ -1,25 +0,0 @@
|
||||
[package]
|
||||
name = "codex-artifact-spreadsheet"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "codex_artifact_spreadsheet"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
base64 = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
uuid = { workspace = true, features = ["v4"] }
|
||||
zip = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
@@ -1,245 +0,0 @@
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::SpreadsheetArtifactError;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub struct CellAddress {
|
||||
pub column: u32,
|
||||
pub row: u32,
|
||||
}
|
||||
|
||||
impl CellAddress {
|
||||
pub fn parse(address: &str) -> Result<Self, SpreadsheetArtifactError> {
|
||||
let trimmed = address.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(SpreadsheetArtifactError::InvalidAddress {
|
||||
address: address.to_string(),
|
||||
message: "address is empty".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut split = 0usize;
|
||||
for (index, ch) in trimmed.char_indices() {
|
||||
if ch.is_ascii_alphabetic() {
|
||||
split = index + ch.len_utf8();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let (letters, digits) = trimmed.split_at(split);
|
||||
if letters.is_empty() || digits.is_empty() {
|
||||
return Err(SpreadsheetArtifactError::InvalidAddress {
|
||||
address: address.to_string(),
|
||||
message: "expected A1-style address".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if !letters.chars().all(|ch| ch.is_ascii_alphabetic())
|
||||
|| !digits.chars().all(|ch| ch.is_ascii_digit())
|
||||
{
|
||||
return Err(SpreadsheetArtifactError::InvalidAddress {
|
||||
address: address.to_string(),
|
||||
message: "expected letters followed by digits".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let column = column_letters_to_index(letters)?;
|
||||
let row = digits
|
||||
.parse::<u32>()
|
||||
.map_err(|_| SpreadsheetArtifactError::InvalidAddress {
|
||||
address: address.to_string(),
|
||||
message: "row must be a positive integer".to_string(),
|
||||
})?;
|
||||
|
||||
if row == 0 {
|
||||
return Err(SpreadsheetArtifactError::InvalidAddress {
|
||||
address: address.to_string(),
|
||||
message: "row must be positive".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Self { column, row })
|
||||
}
|
||||
|
||||
pub fn to_a1(self) -> String {
|
||||
format!("{}{}", column_index_to_letters(self.column), self.row)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CellRange {
|
||||
pub start: CellAddress,
|
||||
pub end: CellAddress,
|
||||
}
|
||||
|
||||
impl CellRange {
|
||||
pub fn parse(address: &str) -> Result<Self, SpreadsheetArtifactError> {
|
||||
let trimmed = address.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(SpreadsheetArtifactError::InvalidAddress {
|
||||
address: address.to_string(),
|
||||
message: "range is empty".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let (start, end) = if let Some((left, right)) = trimmed.split_once(':') {
|
||||
(CellAddress::parse(left)?, CellAddress::parse(right)?)
|
||||
} else {
|
||||
let cell = CellAddress::parse(trimmed)?;
|
||||
(cell, cell)
|
||||
};
|
||||
|
||||
let normalized = Self {
|
||||
start: CellAddress {
|
||||
column: start.column.min(end.column),
|
||||
row: start.row.min(end.row),
|
||||
},
|
||||
end: CellAddress {
|
||||
column: start.column.max(end.column),
|
||||
row: start.row.max(end.row),
|
||||
},
|
||||
};
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
pub fn from_start_end(start: CellAddress, end: CellAddress) -> Self {
|
||||
Self {
|
||||
start: CellAddress {
|
||||
column: start.column.min(end.column),
|
||||
row: start.row.min(end.row),
|
||||
},
|
||||
end: CellAddress {
|
||||
column: start.column.max(end.column),
|
||||
row: start.row.max(end.row),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_a1(&self) -> String {
|
||||
if self.is_single_cell() {
|
||||
self.start.to_a1()
|
||||
} else {
|
||||
format!("{}:{}", self.start.to_a1(), self.end.to_a1())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_single_cell(&self) -> bool {
|
||||
self.start == self.end
|
||||
}
|
||||
|
||||
pub fn is_single_row(&self) -> bool {
|
||||
self.start.row == self.end.row
|
||||
}
|
||||
|
||||
pub fn is_single_column(&self) -> bool {
|
||||
self.start.column == self.end.column
|
||||
}
|
||||
|
||||
pub fn width(&self) -> usize {
|
||||
(self.end.column - self.start.column + 1) as usize
|
||||
}
|
||||
|
||||
pub fn height(&self) -> usize {
|
||||
(self.end.row - self.start.row + 1) as usize
|
||||
}
|
||||
|
||||
pub fn contains(&self, address: CellAddress) -> bool {
|
||||
self.start.column <= address.column
|
||||
&& address.column <= self.end.column
|
||||
&& self.start.row <= address.row
|
||||
&& address.row <= self.end.row
|
||||
}
|
||||
|
||||
pub fn contains_range(&self, other: &CellRange) -> bool {
|
||||
self.contains(other.start) && self.contains(other.end)
|
||||
}
|
||||
|
||||
pub fn intersects(&self, other: &CellRange) -> bool {
|
||||
!(self.end.column < other.start.column
|
||||
|| other.end.column < self.start.column
|
||||
|| self.end.row < other.start.row
|
||||
|| other.end.row < self.start.row)
|
||||
}
|
||||
|
||||
pub fn addresses(&self) -> impl Iterator<Item = CellAddress> {
|
||||
let range = self.clone();
|
||||
(range.start.row..=range.end.row).flat_map(move |row| {
|
||||
let range = range.clone();
|
||||
(range.start.column..=range.end.column).map(move |column| CellAddress { column, row })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn column_letters_to_index(column: &str) -> Result<u32, SpreadsheetArtifactError> {
|
||||
let trimmed = column.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(SpreadsheetArtifactError::InvalidAddress {
|
||||
address: column.to_string(),
|
||||
message: "column is empty".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut result = 0u32;
|
||||
for ch in trimmed.chars() {
|
||||
if !ch.is_ascii_alphabetic() {
|
||||
return Err(SpreadsheetArtifactError::InvalidAddress {
|
||||
address: column.to_string(),
|
||||
message: "column must contain only letters".to_string(),
|
||||
});
|
||||
}
|
||||
result = result
|
||||
.checked_mul(26)
|
||||
.and_then(|value| value.checked_add((ch.to_ascii_uppercase() as u8 - b'A' + 1) as u32))
|
||||
.ok_or_else(|| SpreadsheetArtifactError::InvalidAddress {
|
||||
address: column.to_string(),
|
||||
message: "column is too large".to_string(),
|
||||
})?;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn column_index_to_letters(mut index: u32) -> String {
|
||||
if index == 0 {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut letters = Vec::new();
|
||||
while index > 0 {
|
||||
let remainder = (index - 1) % 26;
|
||||
letters.push((b'A' + remainder as u8) as char);
|
||||
index = (index - 1) / 26;
|
||||
}
|
||||
letters.iter().rev().collect()
|
||||
}
|
||||
|
||||
pub fn parse_column_reference(reference: &str) -> Result<(u32, u32), SpreadsheetArtifactError> {
|
||||
let trimmed = reference.trim();
|
||||
if let Some((left, right)) = trimmed.split_once(':') {
|
||||
let start = column_letters_to_index(left)?;
|
||||
let end = column_letters_to_index(right)?;
|
||||
Ok((start.min(end), start.max(end)))
|
||||
} else {
|
||||
let column = column_letters_to_index(trimmed)?;
|
||||
Ok((column, column))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_valid_cell_reference(address: &str) -> bool {
|
||||
CellAddress::parse(address).is_ok()
|
||||
}
|
||||
|
||||
pub fn is_valid_range_reference(address: &str) -> bool {
|
||||
CellRange::parse(address).is_ok()
|
||||
}
|
||||
|
||||
pub fn is_valid_row_reference(address: &str) -> bool {
|
||||
CellRange::parse(address)
|
||||
.map(|range| range.is_single_row())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn is_valid_column_reference(address: &str) -> bool {
|
||||
parse_column_reference(address).is_ok()
|
||||
}
|
||||
@@ -1,357 +0,0 @@
|
||||
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 {
|
||||
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()
|
||||
.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 {
|
||||
let len = self.charts.len();
|
||||
return self.charts.get_mut(index).ok_or_else(|| {
|
||||
SpreadsheetArtifactError::IndexOutOfRange {
|
||||
action: action.to_string(),
|
||||
index,
|
||||
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(())
|
||||
}
|
||||
@@ -1,308 +0,0 @@
|
||||
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 validate_conditional_formats(
|
||||
&self,
|
||||
action: &str,
|
||||
sheet_name: &str,
|
||||
) -> Result<(), SpreadsheetArtifactError> {
|
||||
let sheet = self.sheet_lookup(action, Some(sheet_name), None)?;
|
||||
for format in &sheet.conditional_formats {
|
||||
validate_conditional_format(self, format, action)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum SpreadsheetArtifactError {
|
||||
#[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("invalid address `{address}`: {message}")]
|
||||
InvalidAddress { address: String, message: String },
|
||||
#[error("sheet lookup failed for action `{action}`: {message}")]
|
||||
SheetLookup { action: String, message: String },
|
||||
#[error("index `{index}` is out of range for action `{action}`; len={len}")]
|
||||
IndexOutOfRange {
|
||||
action: String,
|
||||
index: usize,
|
||||
len: usize,
|
||||
},
|
||||
#[error("merge conflict for action `{action}` on range `{range}` with `{conflict}`")]
|
||||
MergeConflict {
|
||||
action: String,
|
||||
range: String,
|
||||
conflict: String,
|
||||
},
|
||||
#[error("formula error at `{location}`: {message}")]
|
||||
Formula { location: String, message: String },
|
||||
#[error("serialization failed: {message}")]
|
||||
Serialization { message: String },
|
||||
#[error("failed to import XLSX `{path}`: {message}")]
|
||||
ImportFailed { path: PathBuf, message: String },
|
||||
#[error("failed to export XLSX `{path}`: {message}")]
|
||||
ExportFailed { path: PathBuf, message: String },
|
||||
}
|
||||
@@ -1,535 +0,0 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use crate::CellAddress;
|
||||
use crate::CellRange;
|
||||
use crate::SpreadsheetArtifact;
|
||||
use crate::SpreadsheetArtifactError;
|
||||
use crate::SpreadsheetCellValue;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum Token {
|
||||
Number(f64),
|
||||
Cell(String),
|
||||
Ident(String),
|
||||
Plus,
|
||||
Minus,
|
||||
Star,
|
||||
Slash,
|
||||
LParen,
|
||||
RParen,
|
||||
Colon,
|
||||
Comma,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum Expr {
|
||||
Number(f64),
|
||||
Cell(CellAddress),
|
||||
Range(CellRange),
|
||||
UnaryMinus(Box<Expr>),
|
||||
Binary {
|
||||
op: BinaryOp,
|
||||
left: Box<Expr>,
|
||||
right: Box<Expr>,
|
||||
},
|
||||
Function {
|
||||
name: String,
|
||||
args: Vec<Expr>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum BinaryOp {
|
||||
Add,
|
||||
Subtract,
|
||||
Multiply,
|
||||
Divide,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum EvalValue {
|
||||
Scalar(Option<SpreadsheetCellValue>),
|
||||
Range(Vec<Option<SpreadsheetCellValue>>),
|
||||
}
|
||||
|
||||
pub(crate) fn recalculate_workbook(artifact: &mut SpreadsheetArtifact) {
|
||||
let updates = artifact
|
||||
.sheets
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(sheet_index, sheet)| {
|
||||
sheet.cells.iter().filter_map(move |(address, cell)| {
|
||||
cell.formula
|
||||
.as_ref()
|
||||
.map(|formula| (sheet_index, *address, formula.clone()))
|
||||
})
|
||||
})
|
||||
.map(|(sheet_index, address, formula)| {
|
||||
let mut stack = BTreeSet::new();
|
||||
let value = evaluate_formula(artifact, sheet_index, &formula, &mut stack)
|
||||
.unwrap_or_else(|error| {
|
||||
Some(SpreadsheetCellValue::Error(map_error_to_code(&error)))
|
||||
});
|
||||
(sheet_index, address, value)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for (sheet_index, address, value) in updates {
|
||||
if let Some(sheet) = artifact.sheets.get_mut(sheet_index)
|
||||
&& let Some(cell) = sheet.cells.get_mut(&address)
|
||||
{
|
||||
cell.value = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn evaluate_formula(
|
||||
artifact: &SpreadsheetArtifact,
|
||||
sheet_index: usize,
|
||||
formula: &str,
|
||||
stack: &mut BTreeSet<(usize, CellAddress)>,
|
||||
) -> Result<Option<SpreadsheetCellValue>, SpreadsheetArtifactError> {
|
||||
let source = formula.trim().trim_start_matches('=');
|
||||
let tokens = tokenize(source)?;
|
||||
let mut parser = Parser::new(tokens);
|
||||
let expr = parser.parse_expression()?;
|
||||
if parser.has_remaining() {
|
||||
return Err(SpreadsheetArtifactError::Formula {
|
||||
location: formula.to_string(),
|
||||
message: "unexpected trailing tokens".to_string(),
|
||||
});
|
||||
}
|
||||
match evaluate_expr(artifact, sheet_index, &expr, stack)? {
|
||||
EvalValue::Scalar(value) => Ok(value),
|
||||
EvalValue::Range(_) => Err(SpreadsheetArtifactError::Formula {
|
||||
location: formula.to_string(),
|
||||
message: "range expressions are only allowed inside functions".to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn evaluate_expr(
|
||||
artifact: &SpreadsheetArtifact,
|
||||
sheet_index: usize,
|
||||
expr: &Expr,
|
||||
stack: &mut BTreeSet<(usize, CellAddress)>,
|
||||
) -> Result<EvalValue, SpreadsheetArtifactError> {
|
||||
match expr {
|
||||
Expr::Number(value) => Ok(EvalValue::Scalar(Some(number_to_value(*value)))),
|
||||
Expr::Cell(address) => evaluate_cell_reference(artifact, sheet_index, *address, stack),
|
||||
Expr::Range(range) => {
|
||||
let sheet = artifact.sheets.get(sheet_index).ok_or_else(|| {
|
||||
SpreadsheetArtifactError::Formula {
|
||||
location: range.to_a1(),
|
||||
message: "sheet index was not found".to_string(),
|
||||
}
|
||||
})?;
|
||||
let values = range
|
||||
.addresses()
|
||||
.map(|address| sheet.get_cell(address).and_then(|cell| cell.value.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
Ok(EvalValue::Range(values))
|
||||
}
|
||||
Expr::UnaryMinus(inner) => {
|
||||
let value = evaluate_scalar(artifact, sheet_index, inner, stack)?;
|
||||
Ok(EvalValue::Scalar(match value {
|
||||
None => Some(SpreadsheetCellValue::Integer(0)),
|
||||
Some(SpreadsheetCellValue::Integer(value)) => {
|
||||
Some(SpreadsheetCellValue::Integer(-value))
|
||||
}
|
||||
Some(SpreadsheetCellValue::Float(value)) => {
|
||||
Some(SpreadsheetCellValue::Float(-value))
|
||||
}
|
||||
Some(SpreadsheetCellValue::Error(value)) => {
|
||||
Some(SpreadsheetCellValue::Error(value))
|
||||
}
|
||||
Some(_) => Some(SpreadsheetCellValue::Error("#VALUE!".to_string())),
|
||||
}))
|
||||
}
|
||||
Expr::Binary { op, left, right } => {
|
||||
let left = evaluate_scalar(artifact, sheet_index, left, stack)?;
|
||||
let right = evaluate_scalar(artifact, sheet_index, right, stack)?;
|
||||
Ok(EvalValue::Scalar(Some(apply_binary_op(*op, left, right)?)))
|
||||
}
|
||||
Expr::Function { name, args } => {
|
||||
let mut numeric = Vec::new();
|
||||
for arg in args {
|
||||
match evaluate_expr(artifact, sheet_index, arg, stack)? {
|
||||
EvalValue::Scalar(value) => {
|
||||
if let Some(number) = scalar_to_number(value.clone())? {
|
||||
numeric.push(number);
|
||||
}
|
||||
}
|
||||
EvalValue::Range(values) => {
|
||||
for value in values {
|
||||
if let Some(number) = scalar_to_number(value.clone())? {
|
||||
numeric.push(number);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let upper = name.to_ascii_uppercase();
|
||||
let result = match upper.as_str() {
|
||||
"SUM" => numeric.iter().sum::<f64>(),
|
||||
"AVERAGE" => {
|
||||
if numeric.is_empty() {
|
||||
return Ok(EvalValue::Scalar(None));
|
||||
}
|
||||
numeric.iter().sum::<f64>() / numeric.len() as f64
|
||||
}
|
||||
"MIN" => numeric.iter().copied().reduce(f64::min).unwrap_or(0.0),
|
||||
"MAX" => numeric.iter().copied().reduce(f64::max).unwrap_or(0.0),
|
||||
_ => {
|
||||
return Ok(EvalValue::Scalar(Some(SpreadsheetCellValue::Error(
|
||||
"#NAME?".to_string(),
|
||||
))));
|
||||
}
|
||||
};
|
||||
Ok(EvalValue::Scalar(Some(number_to_value(result))))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn evaluate_scalar(
|
||||
artifact: &SpreadsheetArtifact,
|
||||
sheet_index: usize,
|
||||
expr: &Expr,
|
||||
stack: &mut BTreeSet<(usize, CellAddress)>,
|
||||
) -> Result<Option<SpreadsheetCellValue>, SpreadsheetArtifactError> {
|
||||
match evaluate_expr(artifact, sheet_index, expr, stack)? {
|
||||
EvalValue::Scalar(value) => Ok(value),
|
||||
EvalValue::Range(_) => Err(SpreadsheetArtifactError::Formula {
|
||||
location: format!("{expr:?}"),
|
||||
message: "expected a scalar expression".to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn evaluate_cell_reference(
|
||||
artifact: &SpreadsheetArtifact,
|
||||
sheet_index: usize,
|
||||
address: CellAddress,
|
||||
stack: &mut BTreeSet<(usize, CellAddress)>,
|
||||
) -> Result<EvalValue, SpreadsheetArtifactError> {
|
||||
let Some(sheet) = artifact.sheets.get(sheet_index) else {
|
||||
return Err(SpreadsheetArtifactError::Formula {
|
||||
location: address.to_a1(),
|
||||
message: "sheet index was not found".to_string(),
|
||||
});
|
||||
};
|
||||
let key = (sheet_index, address);
|
||||
if !stack.insert(key) {
|
||||
return Ok(EvalValue::Scalar(Some(SpreadsheetCellValue::Error(
|
||||
"#CYCLE!".to_string(),
|
||||
))));
|
||||
}
|
||||
|
||||
let value = if let Some(cell) = sheet.get_cell(address) {
|
||||
if let Some(formula) = &cell.formula {
|
||||
evaluate_formula(artifact, sheet_index, formula, stack)?
|
||||
} else {
|
||||
cell.value.clone()
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
stack.remove(&key);
|
||||
Ok(EvalValue::Scalar(value))
|
||||
}
|
||||
|
||||
fn apply_binary_op(
|
||||
op: BinaryOp,
|
||||
left: Option<SpreadsheetCellValue>,
|
||||
right: Option<SpreadsheetCellValue>,
|
||||
) -> Result<SpreadsheetCellValue, SpreadsheetArtifactError> {
|
||||
if let Some(SpreadsheetCellValue::Error(value)) = &left {
|
||||
return Ok(SpreadsheetCellValue::Error(value.clone()));
|
||||
}
|
||||
if let Some(SpreadsheetCellValue::Error(value)) = &right {
|
||||
return Ok(SpreadsheetCellValue::Error(value.clone()));
|
||||
}
|
||||
|
||||
let left = scalar_to_number(left)?;
|
||||
let right = scalar_to_number(right)?;
|
||||
let left = left.unwrap_or(0.0);
|
||||
let right = right.unwrap_or(0.0);
|
||||
let result = match op {
|
||||
BinaryOp::Add => left + right,
|
||||
BinaryOp::Subtract => left - right,
|
||||
BinaryOp::Multiply => left * right,
|
||||
BinaryOp::Divide => {
|
||||
if right == 0.0 {
|
||||
return Ok(SpreadsheetCellValue::Error("#DIV/0!".to_string()));
|
||||
}
|
||||
left / right
|
||||
}
|
||||
};
|
||||
Ok(number_to_value(result))
|
||||
}
|
||||
|
||||
fn scalar_to_number(
|
||||
value: Option<SpreadsheetCellValue>,
|
||||
) -> Result<Option<f64>, SpreadsheetArtifactError> {
|
||||
match value {
|
||||
None => Ok(None),
|
||||
Some(SpreadsheetCellValue::Integer(value)) => Ok(Some(value as f64)),
|
||||
Some(SpreadsheetCellValue::Float(value)) => Ok(Some(value)),
|
||||
Some(SpreadsheetCellValue::Bool(value)) => Ok(Some(if value { 1.0 } else { 0.0 })),
|
||||
Some(SpreadsheetCellValue::Error(value)) => Err(SpreadsheetArtifactError::Formula {
|
||||
location: value,
|
||||
message: "encountered error value".to_string(),
|
||||
}),
|
||||
Some(other) => Err(SpreadsheetArtifactError::Formula {
|
||||
location: format!("{other:?}"),
|
||||
message: "value is not numeric".to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn number_to_value(number: f64) -> SpreadsheetCellValue {
|
||||
if number.fract() == 0.0 {
|
||||
SpreadsheetCellValue::Integer(number as i64)
|
||||
} else {
|
||||
SpreadsheetCellValue::Float(number)
|
||||
}
|
||||
}
|
||||
|
||||
fn map_error_to_code(error: &SpreadsheetArtifactError) -> String {
|
||||
match error {
|
||||
SpreadsheetArtifactError::Formula { message, .. } => {
|
||||
if message.contains("cycle") {
|
||||
"#CYCLE!".to_string()
|
||||
} else if message.contains("not numeric") || message.contains("scalar") {
|
||||
"#VALUE!".to_string()
|
||||
} else {
|
||||
"#ERROR!".to_string()
|
||||
}
|
||||
}
|
||||
SpreadsheetArtifactError::InvalidAddress { .. } => "#REF!".to_string(),
|
||||
_ => "#ERROR!".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn tokenize(source: &str) -> Result<Vec<Token>, SpreadsheetArtifactError> {
|
||||
let chars = source.chars().collect::<Vec<_>>();
|
||||
let mut index = 0usize;
|
||||
let mut tokens = Vec::new();
|
||||
while index < chars.len() {
|
||||
let ch = chars[index];
|
||||
if ch.is_ascii_whitespace() {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
match ch {
|
||||
'+' => {
|
||||
tokens.push(Token::Plus);
|
||||
index += 1;
|
||||
}
|
||||
'-' => {
|
||||
tokens.push(Token::Minus);
|
||||
index += 1;
|
||||
}
|
||||
'*' => {
|
||||
tokens.push(Token::Star);
|
||||
index += 1;
|
||||
}
|
||||
'/' => {
|
||||
tokens.push(Token::Slash);
|
||||
index += 1;
|
||||
}
|
||||
'(' => {
|
||||
tokens.push(Token::LParen);
|
||||
index += 1;
|
||||
}
|
||||
')' => {
|
||||
tokens.push(Token::RParen);
|
||||
index += 1;
|
||||
}
|
||||
':' => {
|
||||
tokens.push(Token::Colon);
|
||||
index += 1;
|
||||
}
|
||||
',' => {
|
||||
tokens.push(Token::Comma);
|
||||
index += 1;
|
||||
}
|
||||
'0'..='9' | '.' => {
|
||||
let start = index;
|
||||
index += 1;
|
||||
while index < chars.len() && (chars[index].is_ascii_digit() || chars[index] == '.')
|
||||
{
|
||||
index += 1;
|
||||
}
|
||||
let number = source[start..index].parse::<f64>().map_err(|_| {
|
||||
SpreadsheetArtifactError::Formula {
|
||||
location: source.to_string(),
|
||||
message: "invalid numeric literal".to_string(),
|
||||
}
|
||||
})?;
|
||||
tokens.push(Token::Number(number));
|
||||
}
|
||||
'A'..='Z' | 'a'..='z' | '_' => {
|
||||
let start = index;
|
||||
index += 1;
|
||||
while index < chars.len()
|
||||
&& (chars[index].is_ascii_alphanumeric() || chars[index] == '_')
|
||||
{
|
||||
index += 1;
|
||||
}
|
||||
let text = source[start..index].to_string();
|
||||
if text.chars().any(|part| part.is_ascii_digit())
|
||||
&& text.chars().any(|part| part.is_ascii_alphabetic())
|
||||
{
|
||||
tokens.push(Token::Cell(text));
|
||||
} else {
|
||||
tokens.push(Token::Ident(text));
|
||||
}
|
||||
}
|
||||
other => {
|
||||
return Err(SpreadsheetArtifactError::Formula {
|
||||
location: source.to_string(),
|
||||
message: format!("unsupported token `{other}`"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(tokens)
|
||||
}
|
||||
|
||||
struct Parser {
|
||||
tokens: Vec<Token>,
|
||||
index: usize,
|
||||
}
|
||||
|
||||
impl Parser {
|
||||
fn new(tokens: Vec<Token>) -> Self {
|
||||
Self { tokens, index: 0 }
|
||||
}
|
||||
|
||||
fn has_remaining(&self) -> bool {
|
||||
self.index < self.tokens.len()
|
||||
}
|
||||
|
||||
fn parse_expression(&mut self) -> Result<Expr, SpreadsheetArtifactError> {
|
||||
let mut expr = self.parse_term()?;
|
||||
while let Some(token) = self.peek() {
|
||||
let op = match token {
|
||||
Token::Plus => BinaryOp::Add,
|
||||
Token::Minus => BinaryOp::Subtract,
|
||||
_ => break,
|
||||
};
|
||||
self.index += 1;
|
||||
let right = self.parse_term()?;
|
||||
expr = Expr::Binary {
|
||||
op,
|
||||
left: Box::new(expr),
|
||||
right: Box::new(right),
|
||||
};
|
||||
}
|
||||
Ok(expr)
|
||||
}
|
||||
|
||||
fn parse_term(&mut self) -> Result<Expr, SpreadsheetArtifactError> {
|
||||
let mut expr = self.parse_factor()?;
|
||||
while let Some(token) = self.peek() {
|
||||
let op = match token {
|
||||
Token::Star => BinaryOp::Multiply,
|
||||
Token::Slash => BinaryOp::Divide,
|
||||
_ => break,
|
||||
};
|
||||
self.index += 1;
|
||||
let right = self.parse_factor()?;
|
||||
expr = Expr::Binary {
|
||||
op,
|
||||
left: Box::new(expr),
|
||||
right: Box::new(right),
|
||||
};
|
||||
}
|
||||
Ok(expr)
|
||||
}
|
||||
|
||||
fn parse_factor(&mut self) -> Result<Expr, SpreadsheetArtifactError> {
|
||||
match self.peek() {
|
||||
Some(Token::Minus) => {
|
||||
self.index += 1;
|
||||
Ok(Expr::UnaryMinus(Box::new(self.parse_factor()?)))
|
||||
}
|
||||
_ => self.parse_primary(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_primary(&mut self) -> Result<Expr, SpreadsheetArtifactError> {
|
||||
match self.next().cloned() {
|
||||
Some(Token::Number(value)) => Ok(Expr::Number(value)),
|
||||
Some(Token::Cell(address)) => {
|
||||
let start = CellAddress::parse(&address)?;
|
||||
if matches!(self.peek(), Some(Token::Colon)) {
|
||||
self.index += 1;
|
||||
let Some(Token::Cell(end)) = self.next().cloned() else {
|
||||
return Err(SpreadsheetArtifactError::Formula {
|
||||
location: address,
|
||||
message: "expected cell after `:`".to_string(),
|
||||
});
|
||||
};
|
||||
Ok(Expr::Range(CellRange::from_start_end(
|
||||
start,
|
||||
CellAddress::parse(&end)?,
|
||||
)))
|
||||
} else {
|
||||
Ok(Expr::Cell(start))
|
||||
}
|
||||
}
|
||||
Some(Token::Ident(name)) => {
|
||||
if !matches!(self.next(), Some(Token::LParen)) {
|
||||
return Err(SpreadsheetArtifactError::Formula {
|
||||
location: name,
|
||||
message: "expected `(` after function name".to_string(),
|
||||
});
|
||||
}
|
||||
let mut args = Vec::new();
|
||||
if !matches!(self.peek(), Some(Token::RParen)) {
|
||||
loop {
|
||||
args.push(self.parse_expression()?);
|
||||
if matches!(self.peek(), Some(Token::Comma)) {
|
||||
self.index += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !matches!(self.next(), Some(Token::RParen)) {
|
||||
return Err(SpreadsheetArtifactError::Formula {
|
||||
location: name,
|
||||
message: "expected `)`".to_string(),
|
||||
});
|
||||
}
|
||||
Ok(Expr::Function { name, args })
|
||||
}
|
||||
Some(Token::LParen) => {
|
||||
let expr = self.parse_expression()?;
|
||||
if !matches!(self.next(), Some(Token::RParen)) {
|
||||
return Err(SpreadsheetArtifactError::Formula {
|
||||
location: format!("{expr:?}"),
|
||||
message: "expected `)`".to_string(),
|
||||
});
|
||||
}
|
||||
Ok(expr)
|
||||
}
|
||||
other => Err(SpreadsheetArtifactError::Formula {
|
||||
location: format!("{other:?}"),
|
||||
message: "unexpected token".to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn peek(&self) -> Option<&Token> {
|
||||
self.tokens.get(self.index)
|
||||
}
|
||||
|
||||
fn next(&mut self) -> Option<&Token> {
|
||||
let token = self.tokens.get(self.index);
|
||||
self.index += usize::from(token.is_some());
|
||||
token
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
mod address;
|
||||
mod chart;
|
||||
mod conditional;
|
||||
mod error;
|
||||
mod formula;
|
||||
mod manager;
|
||||
mod model;
|
||||
mod pivot;
|
||||
mod render;
|
||||
mod style;
|
||||
mod table;
|
||||
mod xlsx;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use address::*;
|
||||
pub use chart::*;
|
||||
pub use conditional::*;
|
||||
pub use error::*;
|
||||
pub use manager::*;
|
||||
pub use model::*;
|
||||
pub use pivot::*;
|
||||
pub use render::*;
|
||||
pub use style::*;
|
||||
pub use table::*;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,177 +0,0 @@
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -1,373 +0,0 @@
|
||||
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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
@@ -1,580 +0,0 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::CellRange;
|
||||
use crate::SpreadsheetArtifact;
|
||||
use crate::SpreadsheetArtifactError;
|
||||
use crate::SpreadsheetCellRangeRef;
|
||||
use crate::SpreadsheetSheet;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct SpreadsheetFontFace {
|
||||
pub font_family: Option<String>,
|
||||
pub font_scheme: Option<String>,
|
||||
pub typeface: Option<String>,
|
||||
}
|
||||
|
||||
impl SpreadsheetFontFace {
|
||||
fn merge(&self, patch: &Self) -> Self {
|
||||
Self {
|
||||
font_family: patch
|
||||
.font_family
|
||||
.clone()
|
||||
.or_else(|| self.font_family.clone()),
|
||||
font_scheme: patch
|
||||
.font_scheme
|
||||
.clone()
|
||||
.or_else(|| self.font_scheme.clone()),
|
||||
typeface: patch.typeface.clone().or_else(|| self.typeface.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub struct SpreadsheetTextStyle {
|
||||
pub bold: Option<bool>,
|
||||
pub italic: Option<bool>,
|
||||
pub underline: Option<bool>,
|
||||
pub font_size: Option<f64>,
|
||||
pub font_color: Option<String>,
|
||||
pub text_alignment: Option<String>,
|
||||
pub anchor: Option<String>,
|
||||
pub vertical_text_orientation: Option<String>,
|
||||
pub text_rotation: Option<i32>,
|
||||
pub paragraph_spacing: Option<bool>,
|
||||
pub bottom_inset: Option<f64>,
|
||||
pub left_inset: Option<f64>,
|
||||
pub right_inset: Option<f64>,
|
||||
pub top_inset: Option<f64>,
|
||||
pub font_family: Option<String>,
|
||||
pub font_scheme: Option<String>,
|
||||
pub typeface: Option<String>,
|
||||
pub font_face: Option<SpreadsheetFontFace>,
|
||||
}
|
||||
|
||||
impl SpreadsheetTextStyle {
|
||||
fn merge(&self, patch: &Self) -> Self {
|
||||
Self {
|
||||
bold: patch.bold.or(self.bold),
|
||||
italic: patch.italic.or(self.italic),
|
||||
underline: patch.underline.or(self.underline),
|
||||
font_size: patch.font_size.or(self.font_size),
|
||||
font_color: patch.font_color.clone().or_else(|| self.font_color.clone()),
|
||||
text_alignment: patch
|
||||
.text_alignment
|
||||
.clone()
|
||||
.or_else(|| self.text_alignment.clone()),
|
||||
anchor: patch.anchor.clone().or_else(|| self.anchor.clone()),
|
||||
vertical_text_orientation: patch
|
||||
.vertical_text_orientation
|
||||
.clone()
|
||||
.or_else(|| self.vertical_text_orientation.clone()),
|
||||
text_rotation: patch.text_rotation.or(self.text_rotation),
|
||||
paragraph_spacing: patch.paragraph_spacing.or(self.paragraph_spacing),
|
||||
bottom_inset: patch.bottom_inset.or(self.bottom_inset),
|
||||
left_inset: patch.left_inset.or(self.left_inset),
|
||||
right_inset: patch.right_inset.or(self.right_inset),
|
||||
top_inset: patch.top_inset.or(self.top_inset),
|
||||
font_family: patch
|
||||
.font_family
|
||||
.clone()
|
||||
.or_else(|| self.font_family.clone()),
|
||||
font_scheme: patch
|
||||
.font_scheme
|
||||
.clone()
|
||||
.or_else(|| self.font_scheme.clone()),
|
||||
typeface: patch.typeface.clone().or_else(|| self.typeface.clone()),
|
||||
font_face: match (&self.font_face, &patch.font_face) {
|
||||
(Some(base), Some(update)) => Some(base.merge(update)),
|
||||
(None, Some(update)) => Some(update.clone()),
|
||||
(Some(base), None) => Some(base.clone()),
|
||||
(None, None) => None,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SpreadsheetGradientStop {
|
||||
pub position: f64,
|
||||
pub color: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SpreadsheetFillRectangle {
|
||||
pub left: f64,
|
||||
pub right: f64,
|
||||
pub top: f64,
|
||||
pub bottom: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub struct SpreadsheetFill {
|
||||
pub solid_fill_color: Option<String>,
|
||||
pub pattern_type: Option<String>,
|
||||
pub pattern_foreground_color: Option<String>,
|
||||
pub pattern_background_color: Option<String>,
|
||||
#[serde(default)]
|
||||
pub color_transforms: Vec<String>,
|
||||
pub gradient_fill_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub gradient_stops: Vec<SpreadsheetGradientStop>,
|
||||
pub gradient_kind: Option<String>,
|
||||
pub angle: Option<f64>,
|
||||
pub scaled: Option<bool>,
|
||||
pub path_type: Option<String>,
|
||||
pub fill_rectangle: Option<SpreadsheetFillRectangle>,
|
||||
pub image_reference: Option<String>,
|
||||
}
|
||||
|
||||
impl SpreadsheetFill {
|
||||
fn merge(&self, patch: &Self) -> Self {
|
||||
Self {
|
||||
solid_fill_color: patch
|
||||
.solid_fill_color
|
||||
.clone()
|
||||
.or_else(|| self.solid_fill_color.clone()),
|
||||
pattern_type: patch
|
||||
.pattern_type
|
||||
.clone()
|
||||
.or_else(|| self.pattern_type.clone()),
|
||||
pattern_foreground_color: patch
|
||||
.pattern_foreground_color
|
||||
.clone()
|
||||
.or_else(|| self.pattern_foreground_color.clone()),
|
||||
pattern_background_color: patch
|
||||
.pattern_background_color
|
||||
.clone()
|
||||
.or_else(|| self.pattern_background_color.clone()),
|
||||
color_transforms: if patch.color_transforms.is_empty() {
|
||||
self.color_transforms.clone()
|
||||
} else {
|
||||
patch.color_transforms.clone()
|
||||
},
|
||||
gradient_fill_type: patch
|
||||
.gradient_fill_type
|
||||
.clone()
|
||||
.or_else(|| self.gradient_fill_type.clone()),
|
||||
gradient_stops: if patch.gradient_stops.is_empty() {
|
||||
self.gradient_stops.clone()
|
||||
} else {
|
||||
patch.gradient_stops.clone()
|
||||
},
|
||||
gradient_kind: patch
|
||||
.gradient_kind
|
||||
.clone()
|
||||
.or_else(|| self.gradient_kind.clone()),
|
||||
angle: patch.angle.or(self.angle),
|
||||
scaled: patch.scaled.or(self.scaled),
|
||||
path_type: patch.path_type.clone().or_else(|| self.path_type.clone()),
|
||||
fill_rectangle: patch
|
||||
.fill_rectangle
|
||||
.clone()
|
||||
.or_else(|| self.fill_rectangle.clone()),
|
||||
image_reference: patch
|
||||
.image_reference
|
||||
.clone()
|
||||
.or_else(|| self.image_reference.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct SpreadsheetBorderLine {
|
||||
pub style: Option<String>,
|
||||
pub color: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct SpreadsheetBorder {
|
||||
pub top: Option<SpreadsheetBorderLine>,
|
||||
pub right: Option<SpreadsheetBorderLine>,
|
||||
pub bottom: Option<SpreadsheetBorderLine>,
|
||||
pub left: Option<SpreadsheetBorderLine>,
|
||||
}
|
||||
|
||||
impl SpreadsheetBorder {
|
||||
fn merge(&self, patch: &Self) -> Self {
|
||||
Self {
|
||||
top: patch.top.clone().or_else(|| self.top.clone()),
|
||||
right: patch.right.clone().or_else(|| self.right.clone()),
|
||||
bottom: patch.bottom.clone().or_else(|| self.bottom.clone()),
|
||||
left: patch.left.clone().or_else(|| self.left.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct SpreadsheetAlignment {
|
||||
pub horizontal: Option<String>,
|
||||
pub vertical: Option<String>,
|
||||
}
|
||||
|
||||
impl SpreadsheetAlignment {
|
||||
fn merge(&self, patch: &Self) -> Self {
|
||||
Self {
|
||||
horizontal: patch.horizontal.clone().or_else(|| self.horizontal.clone()),
|
||||
vertical: patch.vertical.clone().or_else(|| self.vertical.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct SpreadsheetNumberFormat {
|
||||
pub format_id: Option<u32>,
|
||||
pub format_code: Option<String>,
|
||||
}
|
||||
|
||||
impl SpreadsheetNumberFormat {
|
||||
fn merge(&self, patch: &Self) -> Self {
|
||||
Self {
|
||||
format_id: patch.format_id.or(self.format_id),
|
||||
format_code: patch
|
||||
.format_code
|
||||
.clone()
|
||||
.or_else(|| self.format_code.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalized(mut self) -> Self {
|
||||
if self.format_code.is_none() {
|
||||
self.format_code = self.format_id.and_then(builtin_number_format_code);
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub struct SpreadsheetCellFormat {
|
||||
pub text_style_id: Option<u32>,
|
||||
pub fill_id: Option<u32>,
|
||||
pub border_id: Option<u32>,
|
||||
pub alignment: Option<SpreadsheetAlignment>,
|
||||
pub number_format_id: Option<u32>,
|
||||
pub wrap_text: Option<bool>,
|
||||
pub base_cell_style_format_id: Option<u32>,
|
||||
}
|
||||
|
||||
impl SpreadsheetCellFormat {
|
||||
pub fn wrap(mut self) -> Self {
|
||||
self.wrap_text = Some(true);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn unwrap(mut self) -> Self {
|
||||
self.wrap_text = Some(false);
|
||||
self
|
||||
}
|
||||
|
||||
fn merge(&self, patch: &Self) -> Self {
|
||||
Self {
|
||||
text_style_id: patch.text_style_id.or(self.text_style_id),
|
||||
fill_id: patch.fill_id.or(self.fill_id),
|
||||
border_id: patch.border_id.or(self.border_id),
|
||||
alignment: match (&self.alignment, &patch.alignment) {
|
||||
(Some(base), Some(update)) => Some(base.merge(update)),
|
||||
(None, Some(update)) => Some(update.clone()),
|
||||
(Some(base), None) => Some(base.clone()),
|
||||
(None, None) => None,
|
||||
},
|
||||
number_format_id: patch.number_format_id.or(self.number_format_id),
|
||||
wrap_text: patch.wrap_text.or(self.wrap_text),
|
||||
base_cell_style_format_id: patch
|
||||
.base_cell_style_format_id
|
||||
.or(self.base_cell_style_format_id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub struct SpreadsheetDifferentialFormat {
|
||||
pub text_style_id: Option<u32>,
|
||||
pub fill_id: Option<u32>,
|
||||
pub border_id: Option<u32>,
|
||||
pub alignment: Option<SpreadsheetAlignment>,
|
||||
pub number_format_id: Option<u32>,
|
||||
pub wrap_text: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SpreadsheetCellFormatSummary {
|
||||
pub style_index: u32,
|
||||
pub text_style: Option<SpreadsheetTextStyle>,
|
||||
pub fill: Option<SpreadsheetFill>,
|
||||
pub border: Option<SpreadsheetBorder>,
|
||||
pub alignment: Option<SpreadsheetAlignment>,
|
||||
pub number_format: Option<SpreadsheetNumberFormat>,
|
||||
pub wrap_text: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SpreadsheetRangeFormat {
|
||||
pub sheet_name: String,
|
||||
pub range: String,
|
||||
}
|
||||
|
||||
impl SpreadsheetRangeFormat {
|
||||
pub fn new(sheet_name: String, range: &CellRange) -> Self {
|
||||
Self {
|
||||
sheet_name,
|
||||
range: range.to_a1(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn range_ref(&self) -> Result<SpreadsheetCellRangeRef, SpreadsheetArtifactError> {
|
||||
let range = CellRange::parse(&self.range)?;
|
||||
Ok(SpreadsheetCellRangeRef::new(
|
||||
self.sheet_name.clone(),
|
||||
&range,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn top_left_style_index(
|
||||
&self,
|
||||
sheet: &SpreadsheetSheet,
|
||||
) -> Result<u32, SpreadsheetArtifactError> {
|
||||
self.range_ref()?.top_left_style_index(sheet)
|
||||
}
|
||||
|
||||
pub fn top_left_cell_format(
|
||||
&self,
|
||||
artifact: &SpreadsheetArtifact,
|
||||
sheet: &SpreadsheetSheet,
|
||||
) -> Result<Option<SpreadsheetCellFormatSummary>, SpreadsheetArtifactError> {
|
||||
let range = self.range_ref()?.range()?;
|
||||
Ok(artifact.cell_format_summary(sheet.top_left_style_index(&range)))
|
||||
}
|
||||
}
|
||||
|
||||
impl SpreadsheetArtifact {
|
||||
pub fn create_text_style(
|
||||
&mut self,
|
||||
style: SpreadsheetTextStyle,
|
||||
source_style_id: Option<u32>,
|
||||
merge_with_existing_components: bool,
|
||||
) -> Result<u32, SpreadsheetArtifactError> {
|
||||
let created = if let Some(source_style_id) = source_style_id {
|
||||
let source = self
|
||||
.text_styles
|
||||
.get(&source_style_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| SpreadsheetArtifactError::InvalidArgs {
|
||||
action: "create_text_style".to_string(),
|
||||
message: format!("text style `{source_style_id}` was not found"),
|
||||
})?;
|
||||
if merge_with_existing_components {
|
||||
source.merge(&style)
|
||||
} else {
|
||||
style
|
||||
}
|
||||
} else {
|
||||
style
|
||||
};
|
||||
Ok(insert_with_next_id(&mut self.text_styles, created))
|
||||
}
|
||||
|
||||
pub fn get_text_style(&self, style_id: u32) -> Option<&SpreadsheetTextStyle> {
|
||||
self.text_styles.get(&style_id)
|
||||
}
|
||||
|
||||
pub fn create_fill(
|
||||
&mut self,
|
||||
fill: SpreadsheetFill,
|
||||
source_fill_id: Option<u32>,
|
||||
merge_with_existing_components: bool,
|
||||
) -> Result<u32, SpreadsheetArtifactError> {
|
||||
let created = if let Some(source_fill_id) = source_fill_id {
|
||||
let source = self.fills.get(&source_fill_id).cloned().ok_or_else(|| {
|
||||
SpreadsheetArtifactError::InvalidArgs {
|
||||
action: "create_fill".to_string(),
|
||||
message: format!("fill `{source_fill_id}` was not found"),
|
||||
}
|
||||
})?;
|
||||
if merge_with_existing_components {
|
||||
source.merge(&fill)
|
||||
} else {
|
||||
fill
|
||||
}
|
||||
} else {
|
||||
fill
|
||||
};
|
||||
Ok(insert_with_next_id(&mut self.fills, created))
|
||||
}
|
||||
|
||||
pub fn get_fill(&self, fill_id: u32) -> Option<&SpreadsheetFill> {
|
||||
self.fills.get(&fill_id)
|
||||
}
|
||||
|
||||
pub fn create_border(
|
||||
&mut self,
|
||||
border: SpreadsheetBorder,
|
||||
source_border_id: Option<u32>,
|
||||
merge_with_existing_components: bool,
|
||||
) -> Result<u32, SpreadsheetArtifactError> {
|
||||
let created = if let Some(source_border_id) = source_border_id {
|
||||
let source = self
|
||||
.borders
|
||||
.get(&source_border_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| SpreadsheetArtifactError::InvalidArgs {
|
||||
action: "create_border".to_string(),
|
||||
message: format!("border `{source_border_id}` was not found"),
|
||||
})?;
|
||||
if merge_with_existing_components {
|
||||
source.merge(&border)
|
||||
} else {
|
||||
border
|
||||
}
|
||||
} else {
|
||||
border
|
||||
};
|
||||
Ok(insert_with_next_id(&mut self.borders, created))
|
||||
}
|
||||
|
||||
pub fn get_border(&self, border_id: u32) -> Option<&SpreadsheetBorder> {
|
||||
self.borders.get(&border_id)
|
||||
}
|
||||
|
||||
pub fn create_number_format(
|
||||
&mut self,
|
||||
format: SpreadsheetNumberFormat,
|
||||
source_number_format_id: Option<u32>,
|
||||
merge_with_existing_components: bool,
|
||||
) -> Result<u32, SpreadsheetArtifactError> {
|
||||
let created = if let Some(source_number_format_id) = source_number_format_id {
|
||||
let source = self
|
||||
.number_formats
|
||||
.get(&source_number_format_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| SpreadsheetArtifactError::InvalidArgs {
|
||||
action: "create_number_format".to_string(),
|
||||
message: format!("number format `{source_number_format_id}` was not found"),
|
||||
})?;
|
||||
if merge_with_existing_components {
|
||||
source.merge(&format)
|
||||
} else {
|
||||
format
|
||||
}
|
||||
} else {
|
||||
format
|
||||
};
|
||||
Ok(insert_with_next_id(
|
||||
&mut self.number_formats,
|
||||
created.normalized(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn get_number_format(&self, number_format_id: u32) -> Option<&SpreadsheetNumberFormat> {
|
||||
self.number_formats.get(&number_format_id)
|
||||
}
|
||||
|
||||
pub fn create_cell_format(
|
||||
&mut self,
|
||||
format: SpreadsheetCellFormat,
|
||||
source_format_id: Option<u32>,
|
||||
merge_with_existing_components: bool,
|
||||
) -> Result<u32, SpreadsheetArtifactError> {
|
||||
let created = if let Some(source_format_id) = source_format_id {
|
||||
let source = self
|
||||
.cell_formats
|
||||
.get(&source_format_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| SpreadsheetArtifactError::InvalidArgs {
|
||||
action: "create_cell_format".to_string(),
|
||||
message: format!("cell format `{source_format_id}` was not found"),
|
||||
})?;
|
||||
if merge_with_existing_components {
|
||||
source.merge(&format)
|
||||
} else {
|
||||
format
|
||||
}
|
||||
} else {
|
||||
format
|
||||
};
|
||||
Ok(insert_with_next_id(&mut self.cell_formats, created))
|
||||
}
|
||||
|
||||
pub fn get_cell_format(&self, format_id: u32) -> Option<&SpreadsheetCellFormat> {
|
||||
self.cell_formats.get(&format_id)
|
||||
}
|
||||
|
||||
pub fn create_differential_format(&mut self, format: SpreadsheetDifferentialFormat) -> u32 {
|
||||
insert_with_next_id(&mut self.differential_formats, format)
|
||||
}
|
||||
|
||||
pub fn get_differential_format(
|
||||
&self,
|
||||
format_id: u32,
|
||||
) -> Option<&SpreadsheetDifferentialFormat> {
|
||||
self.differential_formats.get(&format_id)
|
||||
}
|
||||
|
||||
pub fn resolve_cell_format(&self, style_index: u32) -> Option<SpreadsheetCellFormat> {
|
||||
let format = self.cell_formats.get(&style_index)?.clone();
|
||||
resolve_cell_format_recursive(&self.cell_formats, &format, 0)
|
||||
}
|
||||
|
||||
pub fn cell_format_summary(&self, style_index: u32) -> Option<SpreadsheetCellFormatSummary> {
|
||||
let resolved = self.resolve_cell_format(style_index)?;
|
||||
Some(SpreadsheetCellFormatSummary {
|
||||
style_index,
|
||||
text_style: resolved
|
||||
.text_style_id
|
||||
.and_then(|id| self.text_styles.get(&id).cloned()),
|
||||
fill: resolved.fill_id.and_then(|id| self.fills.get(&id).cloned()),
|
||||
border: resolved
|
||||
.border_id
|
||||
.and_then(|id| self.borders.get(&id).cloned()),
|
||||
alignment: resolved.alignment,
|
||||
number_format: resolved
|
||||
.number_format_id
|
||||
.and_then(|id| self.number_formats.get(&id).cloned()),
|
||||
wrap_text: resolved.wrap_text,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl SpreadsheetSheet {
|
||||
pub fn range_format(&self, range: &CellRange) -> SpreadsheetRangeFormat {
|
||||
SpreadsheetRangeFormat::new(self.name.clone(), range)
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_with_next_id<T>(map: &mut BTreeMap<u32, T>, value: T) -> u32 {
|
||||
let next_id = map.last_key_value().map(|(key, _)| key + 1).unwrap_or(1);
|
||||
map.insert(next_id, value);
|
||||
next_id
|
||||
}
|
||||
|
||||
fn resolve_cell_format_recursive(
|
||||
cell_formats: &BTreeMap<u32, SpreadsheetCellFormat>,
|
||||
format: &SpreadsheetCellFormat,
|
||||
depth: usize,
|
||||
) -> Option<SpreadsheetCellFormat> {
|
||||
if depth > 32 {
|
||||
return None;
|
||||
}
|
||||
let base = format
|
||||
.base_cell_style_format_id
|
||||
.and_then(|id| cell_formats.get(&id))
|
||||
.and_then(|base| resolve_cell_format_recursive(cell_formats, base, depth + 1));
|
||||
Some(match base {
|
||||
Some(base) => base.merge(format),
|
||||
None => format.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn builtin_number_format_code(format_id: u32) -> Option<String> {
|
||||
match format_id {
|
||||
0 => Some("General".to_string()),
|
||||
1 => Some("0".to_string()),
|
||||
2 => Some("0.00".to_string()),
|
||||
3 => Some("#,##0".to_string()),
|
||||
4 => Some("#,##0.00".to_string()),
|
||||
9 => Some("0%".to_string()),
|
||||
10 => Some("0.00%".to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1,630 +0,0 @@
|
||||
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()
|
||||
},
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,860 +0,0 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs::File;
|
||||
use std::io::Read;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use regex::Regex;
|
||||
use zip::ZipArchive;
|
||||
use zip::ZipWriter;
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
use crate::CellAddress;
|
||||
use crate::CellRange;
|
||||
use crate::SpreadsheetArtifact;
|
||||
use crate::SpreadsheetArtifactError;
|
||||
use crate::SpreadsheetCell;
|
||||
use crate::SpreadsheetCellValue;
|
||||
use crate::SpreadsheetSheet;
|
||||
|
||||
pub(crate) fn write_xlsx(
|
||||
artifact: &mut SpreadsheetArtifact,
|
||||
path: &Path,
|
||||
) -> Result<PathBuf, SpreadsheetArtifactError> {
|
||||
if artifact.auto_recalculate {
|
||||
artifact.recalculate();
|
||||
}
|
||||
for sheet in &mut artifact.sheets {
|
||||
sheet.cleanup_and_validate_sheet()?;
|
||||
}
|
||||
|
||||
let file = File::create(path).map_err(|error| SpreadsheetArtifactError::ExportFailed {
|
||||
path: path.to_path_buf(),
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
let mut zip = ZipWriter::new(file);
|
||||
let options = SimpleFileOptions::default();
|
||||
|
||||
let sheet_count = artifact.sheets.len().max(1);
|
||||
zip.start_file("[Content_Types].xml", options)
|
||||
.map_err(|error| SpreadsheetArtifactError::ExportFailed {
|
||||
path: path.to_path_buf(),
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
zip.write_all(content_types_xml(sheet_count).as_bytes())
|
||||
.map_err(|error| SpreadsheetArtifactError::ExportFailed {
|
||||
path: path.to_path_buf(),
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
|
||||
zip.add_directory("_rels/", options).map_err(|error| {
|
||||
SpreadsheetArtifactError::ExportFailed {
|
||||
path: path.to_path_buf(),
|
||||
message: error.to_string(),
|
||||
}
|
||||
})?;
|
||||
zip.start_file("_rels/.rels", options).map_err(|error| {
|
||||
SpreadsheetArtifactError::ExportFailed {
|
||||
path: path.to_path_buf(),
|
||||
message: error.to_string(),
|
||||
}
|
||||
})?;
|
||||
zip.write_all(root_relationships_xml().as_bytes())
|
||||
.map_err(|error| SpreadsheetArtifactError::ExportFailed {
|
||||
path: path.to_path_buf(),
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
|
||||
zip.add_directory("docProps/", options).map_err(|error| {
|
||||
SpreadsheetArtifactError::ExportFailed {
|
||||
path: path.to_path_buf(),
|
||||
message: error.to_string(),
|
||||
}
|
||||
})?;
|
||||
zip.start_file("docProps/app.xml", options)
|
||||
.map_err(|error| SpreadsheetArtifactError::ExportFailed {
|
||||
path: path.to_path_buf(),
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
zip.write_all(app_xml(artifact).as_bytes())
|
||||
.map_err(|error| SpreadsheetArtifactError::ExportFailed {
|
||||
path: path.to_path_buf(),
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
|
||||
zip.start_file("docProps/core.xml", options)
|
||||
.map_err(|error| SpreadsheetArtifactError::ExportFailed {
|
||||
path: path.to_path_buf(),
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
zip.write_all(core_xml(artifact).as_bytes())
|
||||
.map_err(|error| SpreadsheetArtifactError::ExportFailed {
|
||||
path: path.to_path_buf(),
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
|
||||
zip.add_directory("xl/", options)
|
||||
.map_err(|error| SpreadsheetArtifactError::ExportFailed {
|
||||
path: path.to_path_buf(),
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
zip.start_file("xl/workbook.xml", options)
|
||||
.map_err(|error| SpreadsheetArtifactError::ExportFailed {
|
||||
path: path.to_path_buf(),
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
zip.write_all(workbook_xml(artifact).as_bytes())
|
||||
.map_err(|error| SpreadsheetArtifactError::ExportFailed {
|
||||
path: path.to_path_buf(),
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
|
||||
zip.add_directory("xl/_rels/", options).map_err(|error| {
|
||||
SpreadsheetArtifactError::ExportFailed {
|
||||
path: path.to_path_buf(),
|
||||
message: error.to_string(),
|
||||
}
|
||||
})?;
|
||||
zip.start_file("xl/_rels/workbook.xml.rels", options)
|
||||
.map_err(|error| SpreadsheetArtifactError::ExportFailed {
|
||||
path: path.to_path_buf(),
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
zip.write_all(workbook_relationships_xml(artifact).as_bytes())
|
||||
.map_err(|error| SpreadsheetArtifactError::ExportFailed {
|
||||
path: path.to_path_buf(),
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
|
||||
zip.start_file("xl/styles.xml", options).map_err(|error| {
|
||||
SpreadsheetArtifactError::ExportFailed {
|
||||
path: path.to_path_buf(),
|
||||
message: error.to_string(),
|
||||
}
|
||||
})?;
|
||||
zip.write_all(styles_xml(artifact).as_bytes())
|
||||
.map_err(|error| SpreadsheetArtifactError::ExportFailed {
|
||||
path: path.to_path_buf(),
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
|
||||
zip.add_directory("xl/worksheets/", options)
|
||||
.map_err(|error| SpreadsheetArtifactError::ExportFailed {
|
||||
path: path.to_path_buf(),
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
if artifact.sheets.is_empty() {
|
||||
let empty = SpreadsheetSheet::new("Sheet1".to_string());
|
||||
zip.start_file("xl/worksheets/sheet1.xml", options)
|
||||
.map_err(|error| SpreadsheetArtifactError::ExportFailed {
|
||||
path: path.to_path_buf(),
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
zip.write_all(sheet_xml(&empty).as_bytes())
|
||||
.map_err(|error| SpreadsheetArtifactError::ExportFailed {
|
||||
path: path.to_path_buf(),
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
} else {
|
||||
for (index, sheet) in artifact.sheets.iter().enumerate() {
|
||||
zip.start_file(format!("xl/worksheets/sheet{}.xml", index + 1), options)
|
||||
.map_err(|error| SpreadsheetArtifactError::ExportFailed {
|
||||
path: path.to_path_buf(),
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
zip.write_all(sheet_xml(sheet).as_bytes())
|
||||
.map_err(|error| SpreadsheetArtifactError::ExportFailed {
|
||||
path: path.to_path_buf(),
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
zip.finish()
|
||||
.map_err(|error| SpreadsheetArtifactError::ExportFailed {
|
||||
path: path.to_path_buf(),
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
Ok(path.to_path_buf())
|
||||
}
|
||||
|
||||
pub(crate) fn import_xlsx(
|
||||
path: &Path,
|
||||
artifact_id: Option<String>,
|
||||
) -> Result<SpreadsheetArtifact, SpreadsheetArtifactError> {
|
||||
let file = File::open(path).map_err(|error| SpreadsheetArtifactError::ImportFailed {
|
||||
path: path.to_path_buf(),
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
let mut archive =
|
||||
ZipArchive::new(file).map_err(|error| SpreadsheetArtifactError::ImportFailed {
|
||||
path: path.to_path_buf(),
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
|
||||
let workbook_xml = read_zip_entry(&mut archive, "xl/workbook.xml", path)?;
|
||||
let workbook_rels = read_zip_entry(&mut archive, "xl/_rels/workbook.xml.rels", path)?;
|
||||
let workbook_name = if archive.by_name("docProps/core.xml").is_ok() {
|
||||
let title =
|
||||
extract_workbook_title(&read_zip_entry(&mut archive, "docProps/core.xml", path)?);
|
||||
(!title.trim().is_empty()).then_some(title)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let shared_strings = if archive.by_name("xl/sharedStrings.xml").is_ok() {
|
||||
Some(parse_shared_strings(&read_zip_entry(
|
||||
&mut archive,
|
||||
"xl/sharedStrings.xml",
|
||||
path,
|
||||
)?)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let relationships = parse_relationships(&workbook_rels)?;
|
||||
let sheets = parse_sheet_definitions(&workbook_xml)?
|
||||
.into_iter()
|
||||
.map(|(name, relation)| {
|
||||
let target = relationships.get(&relation).ok_or_else(|| {
|
||||
SpreadsheetArtifactError::ImportFailed {
|
||||
path: path.to_path_buf(),
|
||||
message: format!("missing relationship `{relation}` for sheet `{name}`"),
|
||||
}
|
||||
})?;
|
||||
let normalized = if target.starts_with('/') {
|
||||
target.trim_start_matches('/').to_string()
|
||||
} else if target.starts_with("xl/") {
|
||||
target.clone()
|
||||
} else {
|
||||
format!("xl/{target}")
|
||||
};
|
||||
Ok((name, normalized))
|
||||
})
|
||||
.collect::<Result<Vec<_>, SpreadsheetArtifactError>>()?;
|
||||
|
||||
let mut artifact = SpreadsheetArtifact::new(workbook_name.or_else(|| {
|
||||
path.file_stem()
|
||||
.and_then(|value| value.to_str())
|
||||
.map(str::to_string)
|
||||
}));
|
||||
if let Some(artifact_id) = artifact_id {
|
||||
artifact.artifact_id = artifact_id;
|
||||
}
|
||||
artifact.sheets.clear();
|
||||
|
||||
for (name, target) in sheets {
|
||||
let xml = read_zip_entry(&mut archive, &target, path)?;
|
||||
let sheet = parse_sheet(&name, &xml, shared_strings.as_deref())?;
|
||||
artifact.sheets.push(sheet);
|
||||
}
|
||||
|
||||
Ok(artifact)
|
||||
}
|
||||
|
||||
fn read_zip_entry(
|
||||
archive: &mut ZipArchive<File>,
|
||||
entry: &str,
|
||||
path: &Path,
|
||||
) -> Result<String, SpreadsheetArtifactError> {
|
||||
let mut file =
|
||||
archive
|
||||
.by_name(entry)
|
||||
.map_err(|error| SpreadsheetArtifactError::ImportFailed {
|
||||
path: path.to_path_buf(),
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
let mut text = String::new();
|
||||
file.read_to_string(&mut text)
|
||||
.map_err(|error| SpreadsheetArtifactError::ImportFailed {
|
||||
path: path.to_path_buf(),
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
fn parse_sheet_definitions(
|
||||
workbook_xml: &str,
|
||||
) -> Result<Vec<(String, String)>, SpreadsheetArtifactError> {
|
||||
let regex = Regex::new(r#"<sheet\b([^>]*)/?>"#).map_err(|error| {
|
||||
SpreadsheetArtifactError::Serialization {
|
||||
message: error.to_string(),
|
||||
}
|
||||
})?;
|
||||
let mut sheets = Vec::new();
|
||||
for captures in regex.captures_iter(workbook_xml) {
|
||||
let Some(attributes) = captures.get(1).map(|value| value.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
let Some(name) = extract_attribute(attributes, "name") else {
|
||||
continue;
|
||||
};
|
||||
let relation = extract_attribute(attributes, "r:id")
|
||||
.or_else(|| extract_attribute(attributes, "id"))
|
||||
.unwrap_or_default();
|
||||
sheets.push((xml_unescape(&name), relation));
|
||||
}
|
||||
Ok(sheets)
|
||||
}
|
||||
|
||||
fn parse_relationships(xml: &str) -> Result<BTreeMap<String, String>, SpreadsheetArtifactError> {
|
||||
let regex = Regex::new(r#"<Relationship\b([^>]*)/?>"#).map_err(|error| {
|
||||
SpreadsheetArtifactError::Serialization {
|
||||
message: error.to_string(),
|
||||
}
|
||||
})?;
|
||||
Ok(regex
|
||||
.captures_iter(xml)
|
||||
.filter_map(|captures| {
|
||||
let attributes = captures.get(1)?.as_str();
|
||||
let id = extract_attribute(attributes, "Id")?;
|
||||
let target = extract_attribute(attributes, "Target")?;
|
||||
Some((id, target))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn parse_shared_strings(xml: &str) -> Result<Vec<String>, SpreadsheetArtifactError> {
|
||||
let regex = Regex::new(r#"(?s)<si\b[^>]*>(.*?)</si>"#).map_err(|error| {
|
||||
SpreadsheetArtifactError::Serialization {
|
||||
message: error.to_string(),
|
||||
}
|
||||
})?;
|
||||
regex
|
||||
.captures_iter(xml)
|
||||
.filter_map(|captures| captures.get(1).map(|value| value.as_str()))
|
||||
.map(all_text_nodes)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_sheet(
|
||||
name: &str,
|
||||
xml: &str,
|
||||
shared_strings: Option<&[String]>,
|
||||
) -> Result<SpreadsheetSheet, SpreadsheetArtifactError> {
|
||||
let mut sheet = SpreadsheetSheet::new(name.to_string());
|
||||
|
||||
if let Some(sheet_view) = first_tag_attributes(xml, "sheetView")
|
||||
&& let Some(show_grid_lines) = extract_attribute(&sheet_view, "showGridLines")
|
||||
{
|
||||
sheet.show_grid_lines = show_grid_lines != "0";
|
||||
}
|
||||
if let Some(format_pr) = first_tag_attributes(xml, "sheetFormatPr") {
|
||||
sheet.default_row_height = extract_attribute(&format_pr, "defaultRowHeight")
|
||||
.and_then(|value| value.parse::<f64>().ok());
|
||||
sheet.default_column_width = extract_attribute(&format_pr, "defaultColWidth")
|
||||
.and_then(|value| value.parse::<f64>().ok());
|
||||
}
|
||||
|
||||
let col_regex = Regex::new(r#"<col\b([^>]*)/?>"#).map_err(|error| {
|
||||
SpreadsheetArtifactError::Serialization {
|
||||
message: error.to_string(),
|
||||
}
|
||||
})?;
|
||||
for captures in col_regex.captures_iter(xml) {
|
||||
let Some(attributes) = captures.get(1).map(|value| value.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
let Some(min) =
|
||||
extract_attribute(attributes, "min").and_then(|value| value.parse::<u32>().ok())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(max) =
|
||||
extract_attribute(attributes, "max").and_then(|value| value.parse::<u32>().ok())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(width) =
|
||||
extract_attribute(attributes, "width").and_then(|value| value.parse::<f64>().ok())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for column in min..=max {
|
||||
sheet.column_widths.insert(column, width);
|
||||
}
|
||||
}
|
||||
|
||||
let row_regex = Regex::new(r#"(?s)<row\b([^>]*)>(.*?)</row>"#).map_err(|error| {
|
||||
SpreadsheetArtifactError::Serialization {
|
||||
message: error.to_string(),
|
||||
}
|
||||
})?;
|
||||
let cell_regex = Regex::new(r#"(?s)<c\b([^>]*)>(.*?)</c>"#).map_err(|error| {
|
||||
SpreadsheetArtifactError::Serialization {
|
||||
message: error.to_string(),
|
||||
}
|
||||
})?;
|
||||
for row_captures in row_regex.captures_iter(xml) {
|
||||
let row_attributes = row_captures
|
||||
.get(1)
|
||||
.map(|value| value.as_str())
|
||||
.unwrap_or_default();
|
||||
if let Some(row_index) =
|
||||
extract_attribute(row_attributes, "r").and_then(|value| value.parse::<u32>().ok())
|
||||
&& let Some(height) =
|
||||
extract_attribute(row_attributes, "ht").and_then(|value| value.parse::<f64>().ok())
|
||||
&& row_index > 0
|
||||
&& height > 0.0
|
||||
{
|
||||
sheet.row_heights.insert(row_index, height);
|
||||
}
|
||||
let Some(row_body) = row_captures.get(2).map(|value| value.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
for cell_captures in cell_regex.captures_iter(row_body) {
|
||||
let Some(attributes) = cell_captures.get(1).map(|value| value.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
let Some(body) = cell_captures.get(2).map(|value| value.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
let Some(address) = extract_attribute(attributes, "r") else {
|
||||
continue;
|
||||
};
|
||||
let address = CellAddress::parse(&address)?;
|
||||
let style_index = extract_attribute(attributes, "s")
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
.unwrap_or(0);
|
||||
let cell_type = extract_attribute(attributes, "t").unwrap_or_default();
|
||||
let formula = first_tag_text(body, "f").map(|value| format!("={value}"));
|
||||
let value = parse_cell_value(body, &cell_type, shared_strings)?;
|
||||
|
||||
let cell = SpreadsheetCell {
|
||||
value,
|
||||
formula,
|
||||
style_index,
|
||||
citations: Vec::new(),
|
||||
};
|
||||
if !cell.is_empty() {
|
||||
sheet.cells.insert(address, cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let merge_regex = Regex::new(r#"<mergeCell\b([^>]*)/?>"#).map_err(|error| {
|
||||
SpreadsheetArtifactError::Serialization {
|
||||
message: error.to_string(),
|
||||
}
|
||||
})?;
|
||||
for captures in merge_regex.captures_iter(xml) {
|
||||
let Some(attributes) = captures.get(1).map(|value| value.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(reference) = extract_attribute(attributes, "ref") {
|
||||
sheet.merged_ranges.push(CellRange::parse(&reference)?);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(sheet)
|
||||
}
|
||||
|
||||
fn parse_cell_value(
|
||||
body: &str,
|
||||
cell_type: &str,
|
||||
shared_strings: Option<&[String]>,
|
||||
) -> Result<Option<SpreadsheetCellValue>, SpreadsheetArtifactError> {
|
||||
let inline_text = first_tag_text(body, "t").map(|value| xml_unescape(&value));
|
||||
let raw_value = first_tag_text(body, "v").map(|value| xml_unescape(&value));
|
||||
|
||||
let parsed = match cell_type {
|
||||
"inlineStr" => inline_text.map(SpreadsheetCellValue::String),
|
||||
"s" => raw_value
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.and_then(|index| shared_strings.and_then(|entries| entries.get(index).cloned()))
|
||||
.map(SpreadsheetCellValue::String),
|
||||
"b" => raw_value.map(|value| SpreadsheetCellValue::Bool(value == "1")),
|
||||
"str" => raw_value.map(SpreadsheetCellValue::String),
|
||||
"e" => raw_value.map(SpreadsheetCellValue::Error),
|
||||
_ => match raw_value {
|
||||
Some(value) => {
|
||||
if let Ok(integer) = value.parse::<i64>() {
|
||||
Some(SpreadsheetCellValue::Integer(integer))
|
||||
} else if let Ok(float) = value.parse::<f64>() {
|
||||
Some(SpreadsheetCellValue::Float(float))
|
||||
} else {
|
||||
Some(SpreadsheetCellValue::String(value))
|
||||
}
|
||||
}
|
||||
None => None,
|
||||
},
|
||||
};
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
fn content_types_xml(sheet_count: usize) -> String {
|
||||
let mut overrides = String::new();
|
||||
for index in 1..=sheet_count {
|
||||
overrides.push_str(&format!(
|
||||
r#"<Override PartName="/xl/worksheets/sheet{index}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>"#
|
||||
));
|
||||
}
|
||||
format!(
|
||||
"{}{}{}{}{}{}{}{}{}{}",
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>"#,
|
||||
r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">"#,
|
||||
r#"<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>"#,
|
||||
r#"<Default Extension="xml" ContentType="application/xml"/>"#,
|
||||
r#"<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>"#,
|
||||
r#"<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>"#,
|
||||
r#"<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>"#,
|
||||
r#"<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>"#,
|
||||
overrides,
|
||||
r#"</Types>"#
|
||||
)
|
||||
}
|
||||
|
||||
fn root_relationships_xml() -> &'static str {
|
||||
concat!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>"#,
|
||||
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">"#,
|
||||
r#"<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>"#,
|
||||
r#"<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>"#,
|
||||
r#"<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>"#,
|
||||
r#"</Relationships>"#
|
||||
)
|
||||
}
|
||||
|
||||
fn app_xml(artifact: &SpreadsheetArtifact) -> String {
|
||||
let title = artifact
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Spreadsheet".to_string());
|
||||
format!(
|
||||
concat!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>"#,
|
||||
r#"<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">"#,
|
||||
r#"<Application>Codex</Application>"#,
|
||||
r#"<DocSecurity>0</DocSecurity>"#,
|
||||
r#"<ScaleCrop>false</ScaleCrop>"#,
|
||||
r#"<HeadingPairs><vt:vector size="2" baseType="variant"><vt:variant><vt:lpstr>Worksheets</vt:lpstr></vt:variant><vt:variant><vt:i4>{}</vt:i4></vt:variant></vt:vector></HeadingPairs>"#,
|
||||
r#"<TitlesOfParts><vt:vector size="{}" baseType="lpstr">{}</vt:vector></TitlesOfParts>"#,
|
||||
r#"<Company>OpenAI</Company>"#,
|
||||
r#"<Manager>{}</Manager>"#,
|
||||
r#"</Properties>"#
|
||||
),
|
||||
artifact.sheets.len(),
|
||||
artifact.sheets.len(),
|
||||
artifact
|
||||
.sheets
|
||||
.iter()
|
||||
.map(|sheet| format!(r#"<vt:lpstr>{}</vt:lpstr>"#, xml_escape(&sheet.name)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(""),
|
||||
xml_escape(&title),
|
||||
)
|
||||
}
|
||||
|
||||
fn core_xml(artifact: &SpreadsheetArtifact) -> String {
|
||||
let title = artifact
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| artifact.artifact_id.clone());
|
||||
format!(
|
||||
concat!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>"#,
|
||||
r#"<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:dcmitype="http://purl.org/dc/dcmitype/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">"#,
|
||||
r#"<dc:title>{}</dc:title>"#,
|
||||
r#"<dc:creator>Codex</dc:creator>"#,
|
||||
r#"<cp:lastModifiedBy>Codex</cp:lastModifiedBy>"#,
|
||||
r#"</cp:coreProperties>"#
|
||||
),
|
||||
xml_escape(&title),
|
||||
)
|
||||
}
|
||||
|
||||
fn workbook_xml(artifact: &SpreadsheetArtifact) -> String {
|
||||
let sheets = if artifact.sheets.is_empty() {
|
||||
r#"<sheet name="Sheet1" sheetId="1" r:id="rId1"/>"#.to_string()
|
||||
} else {
|
||||
artifact
|
||||
.sheets
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, sheet)| {
|
||||
format!(
|
||||
r#"<sheet name="{}" sheetId="{}" r:id="rId{}"/>"#,
|
||||
xml_escape(&sheet.name),
|
||||
index + 1,
|
||||
index + 1
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
};
|
||||
format!(
|
||||
"{}{}{}<sheets>{}</sheets>{}",
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>"#,
|
||||
r#"<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">"#,
|
||||
r#"<bookViews><workbookView/></bookViews>"#,
|
||||
sheets,
|
||||
r#"</workbook>"#
|
||||
)
|
||||
}
|
||||
|
||||
fn workbook_relationships_xml(artifact: &SpreadsheetArtifact) -> String {
|
||||
let sheet_relationships = if artifact.sheets.is_empty() {
|
||||
r#"<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>"#.to_string()
|
||||
} else {
|
||||
artifact
|
||||
.sheets
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, _)| {
|
||||
format!(
|
||||
r#"<Relationship Id="rId{}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet{}.xml"/>"#,
|
||||
index + 1,
|
||||
index + 1
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
};
|
||||
let style_relation_id = artifact.sheets.len().max(1) + 1;
|
||||
format!(
|
||||
"{}{}{}<Relationship Id=\"rId{}\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles\" Target=\"styles.xml\"/>{}",
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>"#,
|
||||
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">"#,
|
||||
sheet_relationships,
|
||||
style_relation_id,
|
||||
r#"</Relationships>"#
|
||||
)
|
||||
}
|
||||
|
||||
fn styles_xml(artifact: &SpreadsheetArtifact) -> String {
|
||||
let max_style_index = artifact
|
||||
.sheets
|
||||
.iter()
|
||||
.flat_map(|sheet| sheet.cells.values().map(|cell| cell.style_index))
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let cell_xfs = (0..=max_style_index)
|
||||
.map(|_| r#"<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>"#)
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
format!(
|
||||
concat!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>"#,
|
||||
r#"<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">"#,
|
||||
r#"<fonts count="1"><font/></fonts>"#,
|
||||
r#"<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>"#,
|
||||
r#"<borders count="1"><border/></borders>"#,
|
||||
r#"<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>"#,
|
||||
r#"<cellXfs count="{}">{}</cellXfs>"#,
|
||||
r#"<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>"#,
|
||||
r#"</styleSheet>"#
|
||||
),
|
||||
max_style_index + 1,
|
||||
cell_xfs,
|
||||
)
|
||||
}
|
||||
|
||||
fn sheet_xml(sheet: &SpreadsheetSheet) -> String {
|
||||
let mut rows = BTreeMap::<u32, Vec<(CellAddress, &SpreadsheetCell)>>::new();
|
||||
for row_index in sheet.row_heights.keys() {
|
||||
rows.entry(*row_index).or_default();
|
||||
}
|
||||
for (address, cell) in &sheet.cells {
|
||||
rows.entry(address.row).or_default().push((*address, cell));
|
||||
}
|
||||
|
||||
let sheet_data = rows
|
||||
.into_iter()
|
||||
.map(|(row_index, mut entries)| {
|
||||
entries.sort_by_key(|(address, _)| address.column);
|
||||
let cells = entries
|
||||
.into_iter()
|
||||
.map(|(address, cell)| cell_xml(address, cell))
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
let height = sheet
|
||||
.row_heights
|
||||
.get(&row_index)
|
||||
.map(|value| format!(r#" ht="{value}" customHeight="1""#))
|
||||
.unwrap_or_default();
|
||||
format!(r#"<row r="{row_index}"{height}>{cells}</row>"#)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
|
||||
let cols = if sheet.column_widths.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
let mut groups = Vec::new();
|
||||
let mut iter = sheet.column_widths.iter().peekable();
|
||||
while let Some((&start, &width)) = iter.next() {
|
||||
let mut end = start;
|
||||
while let Some((next_column, next_width)) =
|
||||
iter.peek().map(|(column, width)| (**column, **width))
|
||||
{
|
||||
if next_column == end + 1 && (next_width - width).abs() < f64::EPSILON {
|
||||
end = next_column;
|
||||
iter.next();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
groups.push(format!(
|
||||
r#"<col min="{start}" max="{end}" width="{width}" customWidth="1"/>"#
|
||||
));
|
||||
}
|
||||
format!("<cols>{}</cols>", groups.join(""))
|
||||
};
|
||||
|
||||
let merge_cells = if sheet.merged_ranges.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(
|
||||
r#"<mergeCells count="{}">{}</mergeCells>"#,
|
||||
sheet.merged_ranges.len(),
|
||||
sheet
|
||||
.merged_ranges
|
||||
.iter()
|
||||
.map(|range| format!(r#"<mergeCell ref="{}"/>"#, range.to_a1()))
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
)
|
||||
};
|
||||
|
||||
let default_row_height = sheet.default_row_height.unwrap_or(15.0);
|
||||
let default_column_width = sheet.default_column_width.unwrap_or(8.43);
|
||||
let grid_lines = if sheet.show_grid_lines { "1" } else { "0" };
|
||||
|
||||
format!(
|
||||
"{}{}<sheetViews><sheetView workbookViewId=\"0\" showGridLines=\"{}\"/></sheetViews><sheetFormatPr defaultRowHeight=\"{}\" defaultColWidth=\"{}\"/>{}<sheetData>{}</sheetData>{}{}",
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>"#,
|
||||
r#"<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">"#,
|
||||
grid_lines,
|
||||
default_row_height,
|
||||
default_column_width,
|
||||
cols,
|
||||
sheet_data,
|
||||
merge_cells,
|
||||
r#"</worksheet>"#
|
||||
)
|
||||
}
|
||||
|
||||
fn cell_xml(address: CellAddress, cell: &SpreadsheetCell) -> String {
|
||||
let style = if cell.style_index == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
format!(r#" s="{}""#, cell.style_index)
|
||||
};
|
||||
|
||||
if let Some(formula) = &cell.formula {
|
||||
let formula = xml_escape(formula.trim_start_matches('='));
|
||||
let value_xml = match &cell.value {
|
||||
Some(SpreadsheetCellValue::Bool(value)) => {
|
||||
format!(
|
||||
r#" t="b"><f>{formula}</f><v>{}</v></c>"#,
|
||||
usize::from(*value)
|
||||
)
|
||||
}
|
||||
Some(SpreadsheetCellValue::Integer(value)) => {
|
||||
format!(r#"><f>{formula}</f><v>{value}</v></c>"#)
|
||||
}
|
||||
Some(SpreadsheetCellValue::Float(value)) => {
|
||||
format!(r#"><f>{formula}</f><v>{value}</v></c>"#)
|
||||
}
|
||||
Some(SpreadsheetCellValue::String(value))
|
||||
| Some(SpreadsheetCellValue::DateTime(value)) => format!(
|
||||
r#" t="str"><f>{formula}</f><v>{}</v></c>"#,
|
||||
xml_escape(value)
|
||||
),
|
||||
Some(SpreadsheetCellValue::Error(value)) => {
|
||||
format!(r#" t="e"><f>{formula}</f><v>{}</v></c>"#, xml_escape(value))
|
||||
}
|
||||
None => format!(r#"><f>{formula}</f></c>"#),
|
||||
};
|
||||
return format!(r#"<c r="{}"{style}{value_xml}"#, address.to_a1());
|
||||
}
|
||||
|
||||
match &cell.value {
|
||||
Some(SpreadsheetCellValue::Bool(value)) => format!(
|
||||
r#"<c r="{}"{style} t="b"><v>{}</v></c>"#,
|
||||
address.to_a1(),
|
||||
usize::from(*value)
|
||||
),
|
||||
Some(SpreadsheetCellValue::Integer(value)) => {
|
||||
format!(r#"<c r="{}"{style}><v>{value}</v></c>"#, address.to_a1())
|
||||
}
|
||||
Some(SpreadsheetCellValue::Float(value)) => {
|
||||
format!(r#"<c r="{}"{style}><v>{value}</v></c>"#, address.to_a1())
|
||||
}
|
||||
Some(SpreadsheetCellValue::String(value)) | Some(SpreadsheetCellValue::DateTime(value)) => {
|
||||
format!(
|
||||
r#"<c r="{}"{style} t="inlineStr"><is><t>{}</t></is></c>"#,
|
||||
address.to_a1(),
|
||||
xml_escape(value)
|
||||
)
|
||||
}
|
||||
Some(SpreadsheetCellValue::Error(value)) => format!(
|
||||
r#"<c r="{}"{style} t="e"><v>{}</v></c>"#,
|
||||
address.to_a1(),
|
||||
xml_escape(value)
|
||||
),
|
||||
None => format!(r#"<c r="{}"{style}/>"#, address.to_a1()),
|
||||
}
|
||||
}
|
||||
|
||||
fn first_tag_attributes(xml: &str, tag: &str) -> Option<String> {
|
||||
let regex = Regex::new(&format!(r#"<{tag}\b([^>]*)/?>"#)).ok()?;
|
||||
let captures = regex.captures(xml)?;
|
||||
captures.get(1).map(|value| value.as_str().to_string())
|
||||
}
|
||||
|
||||
fn first_tag_text(xml: &str, tag: &str) -> Option<String> {
|
||||
let regex = Regex::new(&format!(r#"(?s)<{tag}\b[^>]*>(.*?)</{tag}>"#)).ok()?;
|
||||
let captures = regex.captures(xml)?;
|
||||
captures.get(1).map(|value| value.as_str().to_string())
|
||||
}
|
||||
|
||||
fn extract_workbook_title(xml: &str) -> String {
|
||||
let Ok(regex) =
|
||||
Regex::new(r#"(?s)<(?:[A-Za-z0-9_]+:)?title\b[^>]*>(.*?)</(?:[A-Za-z0-9_]+:)?title>"#)
|
||||
else {
|
||||
return String::new();
|
||||
};
|
||||
regex
|
||||
.captures(xml)
|
||||
.and_then(|captures| captures.get(1).map(|value| xml_unescape(value.as_str())))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn all_text_nodes(xml: &str) -> Result<String, SpreadsheetArtifactError> {
|
||||
let regex = Regex::new(r#"(?s)<t\b[^>]*>(.*?)</t>"#).map_err(|error| {
|
||||
SpreadsheetArtifactError::Serialization {
|
||||
message: error.to_string(),
|
||||
}
|
||||
})?;
|
||||
Ok(regex
|
||||
.captures_iter(xml)
|
||||
.filter_map(|captures| captures.get(1).map(|value| xml_unescape(value.as_str())))
|
||||
.collect::<Vec<_>>()
|
||||
.join(""))
|
||||
}
|
||||
|
||||
fn extract_attribute(attributes: &str, name: &str) -> Option<String> {
|
||||
let pattern = format!(r#"{name}="([^"]*)""#);
|
||||
let regex = Regex::new(&pattern).ok()?;
|
||||
let captures = regex.captures(attributes)?;
|
||||
captures.get(1).map(|value| xml_unescape(value.as_str()))
|
||||
}
|
||||
|
||||
fn xml_escape(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
fn xml_unescape(value: &str) -> String {
|
||||
value
|
||||
.replace("'", "'")
|
||||
.replace(""", "\"")
|
||||
.replace(">", ">")
|
||||
.replace("<", "<")
|
||||
.replace("&", "&")
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "artifacts",
|
||||
crate_name = "codex_artifacts",
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "codex-artifacts"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
codex-package-manager = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["fs", "io-util", "process", "time"] }
|
||||
url = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
tokio = { workspace = true, features = ["fs", "io-util", "macros", "process", "rt", "rt-multi-thread", "time"] }
|
||||
wiremock = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
@@ -0,0 +1,446 @@
|
||||
use crate::ArtifactRuntimeError;
|
||||
use crate::ArtifactRuntimeManager;
|
||||
use crate::InstalledArtifactRuntime;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
use thiserror::Error;
|
||||
use tokio::fs;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::process::Command;
|
||||
use tokio::time::timeout;
|
||||
|
||||
const DEFAULT_EXECUTION_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtifactsClient {
|
||||
runtime_source: RuntimeSource,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum RuntimeSource {
|
||||
Managed(ArtifactRuntimeManager),
|
||||
Installed(InstalledArtifactRuntime),
|
||||
}
|
||||
|
||||
impl ArtifactsClient {
|
||||
pub fn from_runtime_manager(runtime_manager: ArtifactRuntimeManager) -> Self {
|
||||
Self {
|
||||
runtime_source: RuntimeSource::Managed(runtime_manager),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_installed_runtime(runtime: InstalledArtifactRuntime) -> Self {
|
||||
Self {
|
||||
runtime_source: RuntimeSource::Installed(runtime),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn execute_build(
|
||||
&self,
|
||||
request: ArtifactBuildRequest,
|
||||
) -> Result<ArtifactCommandOutput, ArtifactsError> {
|
||||
let runtime = self.resolve_runtime().await?;
|
||||
let staging_dir = TempDir::new().map_err(|source| ArtifactsError::Io {
|
||||
context: "failed to create build staging directory".to_string(),
|
||||
source,
|
||||
})?;
|
||||
let script_path = staging_dir.path().join("artifact-build.mjs");
|
||||
let wrapped_script = build_wrapped_script(&request.source);
|
||||
fs::write(&script_path, wrapped_script)
|
||||
.await
|
||||
.map_err(|source| ArtifactsError::Io {
|
||||
context: format!("failed to write {}", script_path.display()),
|
||||
source,
|
||||
})?;
|
||||
|
||||
let mut command = Command::new(runtime.node_path());
|
||||
command
|
||||
.arg(&script_path)
|
||||
.current_dir(&request.cwd)
|
||||
.env("CODEX_ARTIFACT_BUILD_ENTRYPOINT", runtime.build_js_path())
|
||||
.env(
|
||||
"CODEX_ARTIFACT_RENDER_ENTRYPOINT",
|
||||
runtime.render_cli_path(),
|
||||
)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
for (key, value) in &request.env {
|
||||
command.env(key, value);
|
||||
}
|
||||
|
||||
run_command(
|
||||
command,
|
||||
request.timeout.unwrap_or(DEFAULT_EXECUTION_TIMEOUT),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn execute_render(
|
||||
&self,
|
||||
request: ArtifactRenderCommandRequest,
|
||||
) -> Result<ArtifactCommandOutput, ArtifactsError> {
|
||||
let runtime = self.resolve_runtime().await?;
|
||||
let mut command = Command::new(runtime.node_path());
|
||||
command
|
||||
.arg(runtime.render_cli_path())
|
||||
.args(request.target.to_args())
|
||||
.current_dir(&request.cwd)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
for (key, value) in &request.env {
|
||||
command.env(key, value);
|
||||
}
|
||||
|
||||
run_command(
|
||||
command,
|
||||
request.timeout.unwrap_or(DEFAULT_EXECUTION_TIMEOUT),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn resolve_runtime(&self) -> Result<InstalledArtifactRuntime, ArtifactsError> {
|
||||
match &self.runtime_source {
|
||||
RuntimeSource::Installed(runtime) => Ok(runtime.clone()),
|
||||
RuntimeSource::Managed(manager) => manager.ensure_installed().await.map_err(Into::into),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ArtifactBuildRequest {
|
||||
pub source: String,
|
||||
pub cwd: PathBuf,
|
||||
pub timeout: Option<Duration>,
|
||||
pub env: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtifactRenderCommandRequest {
|
||||
pub cwd: PathBuf,
|
||||
pub timeout: Option<Duration>,
|
||||
pub env: BTreeMap<String, String>,
|
||||
pub target: ArtifactRenderTarget,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ArtifactRenderTarget {
|
||||
Presentation(PresentationRenderTarget),
|
||||
Spreadsheet(SpreadsheetRenderTarget),
|
||||
}
|
||||
|
||||
impl ArtifactRenderTarget {
|
||||
pub fn to_args(&self) -> Vec<String> {
|
||||
match self {
|
||||
Self::Presentation(target) => {
|
||||
vec![
|
||||
"pptx".to_string(),
|
||||
"render".to_string(),
|
||||
"--in".to_string(),
|
||||
target.input_path.display().to_string(),
|
||||
"--slide".to_string(),
|
||||
target.slide_number.to_string(),
|
||||
"--out".to_string(),
|
||||
target.output_path.display().to_string(),
|
||||
]
|
||||
}
|
||||
Self::Spreadsheet(target) => {
|
||||
let mut args = vec![
|
||||
"xlsx".to_string(),
|
||||
"render".to_string(),
|
||||
"--in".to_string(),
|
||||
target.input_path.display().to_string(),
|
||||
"--sheet".to_string(),
|
||||
target.sheet_name.clone(),
|
||||
"--out".to_string(),
|
||||
target.output_path.display().to_string(),
|
||||
];
|
||||
if let Some(range) = &target.range {
|
||||
args.push("--range".to_string());
|
||||
args.push(range.clone());
|
||||
}
|
||||
args
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PresentationRenderTarget {
|
||||
pub input_path: PathBuf,
|
||||
pub output_path: PathBuf,
|
||||
pub slide_number: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SpreadsheetRenderTarget {
|
||||
pub input_path: PathBuf,
|
||||
pub output_path: PathBuf,
|
||||
pub sheet_name: String,
|
||||
pub range: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ArtifactCommandOutput {
|
||||
pub exit_code: Option<i32>,
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
}
|
||||
|
||||
impl ArtifactCommandOutput {
|
||||
pub fn success(&self) -> bool {
|
||||
self.exit_code == Some(0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ArtifactsError {
|
||||
#[error(transparent)]
|
||||
Runtime(#[from] ArtifactRuntimeError),
|
||||
#[error("{context}")]
|
||||
Io {
|
||||
context: String,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("artifact command timed out after {timeout:?}")]
|
||||
TimedOut { timeout: Duration },
|
||||
}
|
||||
|
||||
fn build_wrapped_script(source: &str) -> String {
|
||||
format!(
|
||||
concat!(
|
||||
"import {{ pathToFileURL }} from \"node:url\";\n",
|
||||
"const artifactTool = await import(pathToFileURL(process.env.CODEX_ARTIFACT_BUILD_ENTRYPOINT).href);\n",
|
||||
"globalThis.artifactTool = artifactTool;\n",
|
||||
"globalThis.artifacts = artifactTool;\n",
|
||||
"globalThis.codexArtifacts = artifactTool;\n",
|
||||
"for (const [name, value] of Object.entries(artifactTool)) {{\n",
|
||||
" if (name === \"default\" || Object.prototype.hasOwnProperty.call(globalThis, name)) {{\n",
|
||||
" continue;\n",
|
||||
" }}\n",
|
||||
" globalThis[name] = value;\n",
|
||||
"}}\n\n",
|
||||
"{}\n"
|
||||
),
|
||||
source
|
||||
)
|
||||
}
|
||||
|
||||
async fn run_command(
|
||||
mut command: Command,
|
||||
execution_timeout: Duration,
|
||||
) -> Result<ArtifactCommandOutput, ArtifactsError> {
|
||||
let mut child = command.spawn().map_err(|source| ArtifactsError::Io {
|
||||
context: "failed to spawn artifact command".to_string(),
|
||||
source,
|
||||
})?;
|
||||
let mut stdout = child.stdout.take().ok_or_else(|| ArtifactsError::Io {
|
||||
context: "artifact command stdout was not captured".to_string(),
|
||||
source: std::io::Error::other("missing stdout pipe"),
|
||||
})?;
|
||||
let mut stderr = child.stderr.take().ok_or_else(|| ArtifactsError::Io {
|
||||
context: "artifact command stderr was not captured".to_string(),
|
||||
source: std::io::Error::other("missing stderr pipe"),
|
||||
})?;
|
||||
let stdout_task = tokio::spawn(async move {
|
||||
let mut bytes = Vec::new();
|
||||
stdout.read_to_end(&mut bytes).await.map(|_| bytes)
|
||||
});
|
||||
let stderr_task = tokio::spawn(async move {
|
||||
let mut bytes = Vec::new();
|
||||
stderr.read_to_end(&mut bytes).await.map(|_| bytes)
|
||||
});
|
||||
|
||||
let status = match timeout(execution_timeout, child.wait()).await {
|
||||
Ok(result) => result.map_err(|source| ArtifactsError::Io {
|
||||
context: "failed while waiting for artifact command".to_string(),
|
||||
source,
|
||||
})?,
|
||||
Err(_) => {
|
||||
let _ = child.kill().await;
|
||||
let _ = child.wait().await;
|
||||
return Err(ArtifactsError::TimedOut {
|
||||
timeout: execution_timeout,
|
||||
});
|
||||
}
|
||||
};
|
||||
let stdout_bytes = stdout_task
|
||||
.await
|
||||
.map_err(|source| ArtifactsError::Io {
|
||||
context: "failed to join stdout reader".to_string(),
|
||||
source: std::io::Error::other(source.to_string()),
|
||||
})?
|
||||
.map_err(|source| ArtifactsError::Io {
|
||||
context: "failed to read artifact command stdout".to_string(),
|
||||
source,
|
||||
})?;
|
||||
let stderr_bytes = stderr_task
|
||||
.await
|
||||
.map_err(|source| ArtifactsError::Io {
|
||||
context: "failed to join stderr reader".to_string(),
|
||||
source: std::io::Error::other(source.to_string()),
|
||||
})?
|
||||
.map_err(|source| ArtifactsError::Io {
|
||||
context: "failed to read artifact command stderr".to_string(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
Ok(ArtifactCommandOutput {
|
||||
exit_code: status.code(),
|
||||
stdout: String::from_utf8_lossy(&stdout_bytes).into_owned(),
|
||||
stderr: String::from_utf8_lossy(&stderr_bytes).into_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[cfg(unix)]
|
||||
use crate::ArtifactRuntimePlatform;
|
||||
#[cfg(unix)]
|
||||
use crate::ExtractedRuntimeManifest;
|
||||
#[cfg(unix)]
|
||||
use crate::RuntimeEntrypoints;
|
||||
#[cfg(unix)]
|
||||
use crate::RuntimePathEntry;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
#[test]
|
||||
fn wrapped_build_script_exposes_artifact_tool_surface() {
|
||||
let wrapped = build_wrapped_script("console.log(Object.keys(artifactTool).length);");
|
||||
assert!(wrapped.contains("const artifactTool = await import("));
|
||||
assert!(wrapped.contains("globalThis.artifactTool = artifactTool;"));
|
||||
assert!(wrapped.contains("globalThis.artifacts = artifactTool;"));
|
||||
assert!(wrapped.contains("globalThis.codexArtifacts = artifactTool;"));
|
||||
assert!(wrapped.contains("Object.entries(artifactTool)"));
|
||||
assert!(wrapped.contains("globalThis[name] = value;"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presentation_render_target_builds_expected_args() {
|
||||
let args = ArtifactRenderTarget::Presentation(PresentationRenderTarget {
|
||||
input_path: PathBuf::from("deck.pptx"),
|
||||
output_path: PathBuf::from("slide.png"),
|
||||
slide_number: 2,
|
||||
})
|
||||
.to_args();
|
||||
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"pptx",
|
||||
"render",
|
||||
"--in",
|
||||
"deck.pptx",
|
||||
"--slide",
|
||||
"2",
|
||||
"--out",
|
||||
"slide.png"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spreadsheet_render_target_builds_expected_args() {
|
||||
let args = ArtifactRenderTarget::Spreadsheet(SpreadsheetRenderTarget {
|
||||
input_path: PathBuf::from("book.xlsx"),
|
||||
output_path: PathBuf::from("sheet.png"),
|
||||
sheet_name: "Summary".to_string(),
|
||||
range: Some("A1:C3".to_string()),
|
||||
})
|
||||
.to_args();
|
||||
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"xlsx",
|
||||
"render",
|
||||
"--in",
|
||||
"book.xlsx",
|
||||
"--sheet",
|
||||
"Summary",
|
||||
"--out",
|
||||
"sheet.png",
|
||||
"--range",
|
||||
"A1:C3"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn execute_build_invokes_runtime_node_with_expected_environment() {
|
||||
let temp = TempDir::new().unwrap_or_else(|error| panic!("{error}"));
|
||||
let cwd = temp.path().join("cwd");
|
||||
fs::create_dir_all(&cwd)
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
let log_path = temp.path().join("build.log");
|
||||
let fake_node = temp.path().join("fake-node.sh");
|
||||
let build_entrypoint = temp.path().join("artifact_tool.mjs");
|
||||
let render_entrypoint = temp.path().join("render_cli.mjs");
|
||||
fs::write(
|
||||
&fake_node,
|
||||
format!(
|
||||
"#!/bin/sh\nprintf '%s\\n' \"$1\" > \"{}\"\nprintf '%s\\n' \"$CODEX_ARTIFACT_BUILD_ENTRYPOINT\" >> \"{}\"\n",
|
||||
log_path.display(),
|
||||
log_path.display()
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
std::fs::set_permissions(&fake_node, std::fs::Permissions::from_mode(0o755))
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
|
||||
let runtime = InstalledArtifactRuntime::new(
|
||||
temp.path().join("runtime"),
|
||||
"0.1.0".to_string(),
|
||||
ArtifactRuntimePlatform::LinuxX64,
|
||||
sample_manifest("0.1.0"),
|
||||
fake_node.clone(),
|
||||
build_entrypoint.clone(),
|
||||
render_entrypoint,
|
||||
);
|
||||
let client = ArtifactsClient::from_installed_runtime(runtime);
|
||||
|
||||
let output = client
|
||||
.execute_build(ArtifactBuildRequest {
|
||||
source: "console.log('hello');".to_string(),
|
||||
cwd: cwd.clone(),
|
||||
timeout: Some(Duration::from_secs(5)),
|
||||
env: BTreeMap::new(),
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
|
||||
assert!(output.success());
|
||||
let logged = fs::read_to_string(&log_path)
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
assert!(logged.contains("artifact-build.mjs"));
|
||||
assert!(logged.contains(&build_entrypoint.display().to_string()));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn sample_manifest(runtime_version: &str) -> ExtractedRuntimeManifest {
|
||||
ExtractedRuntimeManifest {
|
||||
schema_version: 1,
|
||||
runtime_version: runtime_version.to_string(),
|
||||
node: RuntimePathEntry {
|
||||
relative_path: "node/bin/node".to_string(),
|
||||
},
|
||||
entrypoints: RuntimeEntrypoints {
|
||||
build_js: RuntimePathEntry {
|
||||
relative_path: "artifact-tool/dist/artifact_tool.mjs".to_string(),
|
||||
},
|
||||
render_cli: RuntimePathEntry {
|
||||
relative_path: "granola-render/dist/cli.mjs".to_string(),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
mod client;
|
||||
mod runtime;
|
||||
|
||||
pub use client::ArtifactBuildRequest;
|
||||
pub use client::ArtifactCommandOutput;
|
||||
pub use client::ArtifactRenderCommandRequest;
|
||||
pub use client::ArtifactRenderTarget;
|
||||
pub use client::ArtifactsClient;
|
||||
pub use client::ArtifactsError;
|
||||
pub use client::PresentationRenderTarget;
|
||||
pub use client::SpreadsheetRenderTarget;
|
||||
pub use runtime::ArtifactRuntimeError;
|
||||
pub use runtime::ArtifactRuntimeManager;
|
||||
pub use runtime::ArtifactRuntimeManagerConfig;
|
||||
pub use runtime::ArtifactRuntimePlatform;
|
||||
pub use runtime::ArtifactRuntimeReleaseLocator;
|
||||
pub use runtime::DEFAULT_CACHE_ROOT_RELATIVE;
|
||||
pub use runtime::DEFAULT_RELEASE_TAG_PREFIX;
|
||||
pub use runtime::ExtractedRuntimeManifest;
|
||||
pub use runtime::InstalledArtifactRuntime;
|
||||
pub use runtime::ReleaseManifest;
|
||||
pub use runtime::RuntimeEntrypoints;
|
||||
pub use runtime::RuntimePathEntry;
|
||||
@@ -0,0 +1,522 @@
|
||||
use codex_package_manager::ManagedPackage;
|
||||
use codex_package_manager::PackageManager;
|
||||
use codex_package_manager::PackageManagerConfig;
|
||||
use codex_package_manager::PackageManagerError;
|
||||
pub use codex_package_manager::PackagePlatform as ArtifactRuntimePlatform;
|
||||
use codex_package_manager::PackageReleaseArchive;
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Component;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use thiserror::Error;
|
||||
use url::Url;
|
||||
|
||||
pub const DEFAULT_RELEASE_TAG_PREFIX: &str = "artifact-runtime-v";
|
||||
pub const DEFAULT_CACHE_ROOT_RELATIVE: &str = "packages/artifacts";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ArtifactRuntimeReleaseLocator {
|
||||
base_url: Url,
|
||||
runtime_version: String,
|
||||
release_tag_prefix: String,
|
||||
}
|
||||
|
||||
impl ArtifactRuntimeReleaseLocator {
|
||||
pub fn new(base_url: Url, runtime_version: impl Into<String>) -> Self {
|
||||
Self {
|
||||
base_url,
|
||||
runtime_version: runtime_version.into(),
|
||||
release_tag_prefix: DEFAULT_RELEASE_TAG_PREFIX.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_tag_prefix(mut self, release_tag_prefix: impl Into<String>) -> Self {
|
||||
self.release_tag_prefix = release_tag_prefix.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn base_url(&self) -> &Url {
|
||||
&self.base_url
|
||||
}
|
||||
|
||||
pub fn runtime_version(&self) -> &str {
|
||||
&self.runtime_version
|
||||
}
|
||||
|
||||
pub fn release_tag(&self) -> String {
|
||||
format!("{}{}", self.release_tag_prefix, self.runtime_version)
|
||||
}
|
||||
|
||||
pub fn manifest_file_name(&self) -> String {
|
||||
format!("{}-manifest.json", self.release_tag())
|
||||
}
|
||||
|
||||
pub fn manifest_url(&self) -> Result<Url, PackageManagerError> {
|
||||
self.base_url
|
||||
.join(&self.manifest_file_name())
|
||||
.map_err(PackageManagerError::InvalidBaseUrl)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ArtifactRuntimeManagerConfig {
|
||||
package_manager: PackageManagerConfig<ArtifactRuntimePackage>,
|
||||
}
|
||||
|
||||
impl ArtifactRuntimeManagerConfig {
|
||||
pub fn new(codex_home: PathBuf, release: ArtifactRuntimeReleaseLocator) -> Self {
|
||||
Self {
|
||||
package_manager: PackageManagerConfig::new(
|
||||
codex_home,
|
||||
ArtifactRuntimePackage::new(release),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_cache_root(mut self, cache_root: PathBuf) -> Self {
|
||||
self.package_manager = self.package_manager.with_cache_root(cache_root);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn cache_root(&self) -> PathBuf {
|
||||
self.package_manager.cache_root()
|
||||
}
|
||||
|
||||
pub fn release(&self) -> &ArtifactRuntimeReleaseLocator {
|
||||
&self.package_manager.package().release
|
||||
}
|
||||
|
||||
pub fn codex_home(&self) -> &Path {
|
||||
self.package_manager.codex_home()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtifactRuntimeManager {
|
||||
package_manager: PackageManager<ArtifactRuntimePackage>,
|
||||
config: ArtifactRuntimeManagerConfig,
|
||||
}
|
||||
|
||||
impl ArtifactRuntimeManager {
|
||||
pub fn new(config: ArtifactRuntimeManagerConfig) -> Self {
|
||||
let package_manager = PackageManager::new(config.package_manager.clone());
|
||||
Self {
|
||||
package_manager,
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_client(config: ArtifactRuntimeManagerConfig, client: Client) -> Self {
|
||||
let package_manager = PackageManager::with_client(config.package_manager.clone(), client);
|
||||
Self {
|
||||
package_manager,
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn config(&self) -> &ArtifactRuntimeManagerConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
pub async fn resolve_cached(
|
||||
&self,
|
||||
) -> Result<Option<InstalledArtifactRuntime>, ArtifactRuntimeError> {
|
||||
self.package_manager.resolve_cached().await
|
||||
}
|
||||
|
||||
pub async fn ensure_installed(&self) -> Result<InstalledArtifactRuntime, ArtifactRuntimeError> {
|
||||
self.package_manager.ensure_installed().await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct ArtifactRuntimePackage {
|
||||
release: ArtifactRuntimeReleaseLocator,
|
||||
}
|
||||
|
||||
impl ArtifactRuntimePackage {
|
||||
fn new(release: ArtifactRuntimeReleaseLocator) -> Self {
|
||||
Self { release }
|
||||
}
|
||||
}
|
||||
|
||||
impl ManagedPackage for ArtifactRuntimePackage {
|
||||
type Error = ArtifactRuntimeError;
|
||||
type Installed = InstalledArtifactRuntime;
|
||||
type ReleaseManifest = ReleaseManifest;
|
||||
|
||||
fn default_cache_root_relative(&self) -> &str {
|
||||
DEFAULT_CACHE_ROOT_RELATIVE
|
||||
}
|
||||
|
||||
fn version(&self) -> &str {
|
||||
self.release.runtime_version()
|
||||
}
|
||||
|
||||
fn manifest_url(&self) -> Result<Url, PackageManagerError> {
|
||||
self.release.manifest_url()
|
||||
}
|
||||
|
||||
fn archive_url(&self, archive: &PackageReleaseArchive) -> Result<Url, PackageManagerError> {
|
||||
self.release
|
||||
.base_url()
|
||||
.join(&archive.archive)
|
||||
.map_err(PackageManagerError::InvalidBaseUrl)
|
||||
}
|
||||
|
||||
fn release_version<'a>(&self, manifest: &'a Self::ReleaseManifest) -> &'a str {
|
||||
&manifest.runtime_version
|
||||
}
|
||||
|
||||
fn platform_archive(
|
||||
&self,
|
||||
manifest: &Self::ReleaseManifest,
|
||||
platform: ArtifactRuntimePlatform,
|
||||
) -> Result<PackageReleaseArchive, Self::Error> {
|
||||
manifest
|
||||
.platforms
|
||||
.get(platform.as_str())
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
PackageManagerError::MissingPlatform(platform.as_str().to_string()).into()
|
||||
})
|
||||
}
|
||||
|
||||
fn install_dir(&self, cache_root: &Path, platform: ArtifactRuntimePlatform) -> PathBuf {
|
||||
cache_root.join(self.version()).join(platform.as_str())
|
||||
}
|
||||
|
||||
fn installed_version<'a>(&self, package: &'a Self::Installed) -> &'a str {
|
||||
package.runtime_version()
|
||||
}
|
||||
|
||||
fn load_installed(
|
||||
&self,
|
||||
root_dir: PathBuf,
|
||||
platform: ArtifactRuntimePlatform,
|
||||
) -> Result<Self::Installed, Self::Error> {
|
||||
InstalledArtifactRuntime::load(root_dir, platform)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct ReleaseManifest {
|
||||
pub schema_version: u32,
|
||||
pub runtime_version: String,
|
||||
pub release_tag: String,
|
||||
#[serde(default)]
|
||||
pub node_version: Option<String>,
|
||||
pub platforms: BTreeMap<String, PackageReleaseArchive>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct ExtractedRuntimeManifest {
|
||||
pub schema_version: u32,
|
||||
pub runtime_version: String,
|
||||
pub node: RuntimePathEntry,
|
||||
pub entrypoints: RuntimeEntrypoints,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct RuntimePathEntry {
|
||||
pub relative_path: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct RuntimeEntrypoints {
|
||||
pub build_js: RuntimePathEntry,
|
||||
pub render_cli: RuntimePathEntry,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct InstalledArtifactRuntime {
|
||||
root_dir: PathBuf,
|
||||
runtime_version: String,
|
||||
platform: ArtifactRuntimePlatform,
|
||||
manifest: ExtractedRuntimeManifest,
|
||||
node_path: PathBuf,
|
||||
build_js_path: PathBuf,
|
||||
render_cli_path: PathBuf,
|
||||
}
|
||||
|
||||
impl InstalledArtifactRuntime {
|
||||
pub fn new(
|
||||
root_dir: PathBuf,
|
||||
runtime_version: String,
|
||||
platform: ArtifactRuntimePlatform,
|
||||
manifest: ExtractedRuntimeManifest,
|
||||
node_path: PathBuf,
|
||||
build_js_path: PathBuf,
|
||||
render_cli_path: PathBuf,
|
||||
) -> Self {
|
||||
Self {
|
||||
root_dir,
|
||||
runtime_version,
|
||||
platform,
|
||||
manifest,
|
||||
node_path,
|
||||
build_js_path,
|
||||
render_cli_path,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load(
|
||||
root_dir: PathBuf,
|
||||
platform: ArtifactRuntimePlatform,
|
||||
) -> Result<Self, ArtifactRuntimeError> {
|
||||
let manifest_path = root_dir.join("manifest.json");
|
||||
let manifest_bytes =
|
||||
std::fs::read(&manifest_path).map_err(|source| ArtifactRuntimeError::Io {
|
||||
context: format!("failed to read {}", manifest_path.display()),
|
||||
source,
|
||||
})?;
|
||||
let manifest = serde_json::from_slice::<ExtractedRuntimeManifest>(&manifest_bytes)
|
||||
.map_err(|source| ArtifactRuntimeError::InvalidManifest {
|
||||
path: manifest_path,
|
||||
source,
|
||||
})?;
|
||||
let node_path = resolve_relative_runtime_path(&root_dir, &manifest.node.relative_path)?;
|
||||
let build_js_path =
|
||||
resolve_relative_runtime_path(&root_dir, &manifest.entrypoints.build_js.relative_path)?;
|
||||
let render_cli_path = resolve_relative_runtime_path(
|
||||
&root_dir,
|
||||
&manifest.entrypoints.render_cli.relative_path,
|
||||
)?;
|
||||
|
||||
Ok(Self::new(
|
||||
root_dir,
|
||||
manifest.runtime_version.clone(),
|
||||
platform,
|
||||
manifest,
|
||||
node_path,
|
||||
build_js_path,
|
||||
render_cli_path,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn root_dir(&self) -> &Path {
|
||||
&self.root_dir
|
||||
}
|
||||
|
||||
pub fn runtime_version(&self) -> &str {
|
||||
&self.runtime_version
|
||||
}
|
||||
|
||||
pub fn platform(&self) -> ArtifactRuntimePlatform {
|
||||
self.platform
|
||||
}
|
||||
|
||||
pub fn manifest(&self) -> &ExtractedRuntimeManifest {
|
||||
&self.manifest
|
||||
}
|
||||
|
||||
pub fn node_path(&self) -> &Path {
|
||||
&self.node_path
|
||||
}
|
||||
|
||||
pub fn build_js_path(&self) -> &Path {
|
||||
&self.build_js_path
|
||||
}
|
||||
|
||||
pub fn render_cli_path(&self) -> &Path {
|
||||
&self.render_cli_path
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ArtifactRuntimeError {
|
||||
#[error(transparent)]
|
||||
PackageManager(#[from] PackageManagerError),
|
||||
#[error("{context}")]
|
||||
Io {
|
||||
context: String,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("invalid manifest at {path}")]
|
||||
InvalidManifest {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: serde_json::Error,
|
||||
},
|
||||
#[error("runtime path `{0}` is invalid")]
|
||||
InvalidRuntimePath(String),
|
||||
}
|
||||
|
||||
fn resolve_relative_runtime_path(
|
||||
root_dir: &Path,
|
||||
relative_path: &str,
|
||||
) -> Result<PathBuf, ArtifactRuntimeError> {
|
||||
let relative = Path::new(relative_path);
|
||||
if relative.as_os_str().is_empty() || relative.is_absolute() {
|
||||
return Err(ArtifactRuntimeError::InvalidRuntimePath(
|
||||
relative_path.to_string(),
|
||||
));
|
||||
}
|
||||
if relative.components().any(|component| {
|
||||
matches!(
|
||||
component,
|
||||
Component::ParentDir | Component::Prefix(_) | Component::RootDir
|
||||
)
|
||||
}) {
|
||||
return Err(ArtifactRuntimeError::InvalidRuntimePath(
|
||||
relative_path.to_string(),
|
||||
));
|
||||
}
|
||||
Ok(root_dir.join(relative))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use sha2::Digest;
|
||||
use sha2::Sha256;
|
||||
use std::io::Cursor;
|
||||
use std::io::Write;
|
||||
use tempfile::TempDir;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
use zip::ZipWriter;
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
#[test]
|
||||
fn release_locator_builds_manifest_url() {
|
||||
let locator = ArtifactRuntimeReleaseLocator::new(
|
||||
Url::parse("https://example.test/releases/").unwrap_or_else(|error| panic!("{error}")),
|
||||
"0.1.0",
|
||||
);
|
||||
let url = locator
|
||||
.manifest_url()
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
assert_eq!(
|
||||
url.as_str(),
|
||||
"https://example.test/releases/artifact-runtime-v0.1.0-manifest.json"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_installed_downloads_and_extracts_zip_runtime() {
|
||||
let server = MockServer::start().await;
|
||||
let runtime_version = "0.1.0";
|
||||
let platform =
|
||||
ArtifactRuntimePlatform::detect_current().unwrap_or_else(|error| panic!("{error}"));
|
||||
let archive_name = format!(
|
||||
"artifact-runtime-v{runtime_version}-{}.zip",
|
||||
platform.as_str()
|
||||
);
|
||||
let archive_bytes = build_zip_archive(runtime_version);
|
||||
let archive_sha = format!("{:x}", Sha256::digest(&archive_bytes));
|
||||
let manifest = ReleaseManifest {
|
||||
schema_version: 1,
|
||||
runtime_version: runtime_version.to_string(),
|
||||
release_tag: format!("artifact-runtime-v{runtime_version}"),
|
||||
node_version: Some("22.0.0".to_string()),
|
||||
platforms: BTreeMap::from([(
|
||||
platform.as_str().to_string(),
|
||||
PackageReleaseArchive {
|
||||
archive: archive_name.clone(),
|
||||
sha256: archive_sha,
|
||||
format: codex_package_manager::ArchiveFormat::Zip,
|
||||
size_bytes: Some(archive_bytes.len() as u64),
|
||||
},
|
||||
)]),
|
||||
};
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!(
|
||||
"/artifact-runtime-v{runtime_version}-manifest.json"
|
||||
)))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(&manifest))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/{archive_name}")))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_bytes(archive_bytes))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let codex_home = TempDir::new().unwrap_or_else(|error| panic!("{error}"));
|
||||
let locator = ArtifactRuntimeReleaseLocator::new(
|
||||
Url::parse(&format!("{}/", server.uri())).unwrap_or_else(|error| panic!("{error}")),
|
||||
runtime_version,
|
||||
);
|
||||
let manager = ArtifactRuntimeManager::new(ArtifactRuntimeManagerConfig::new(
|
||||
codex_home.path().to_path_buf(),
|
||||
locator,
|
||||
));
|
||||
|
||||
let runtime = manager
|
||||
.ensure_installed()
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
|
||||
assert_eq!(runtime.runtime_version(), runtime_version);
|
||||
assert_eq!(runtime.platform(), platform);
|
||||
assert!(runtime.node_path().ends_with(Path::new("node/bin/node")));
|
||||
assert!(
|
||||
runtime
|
||||
.build_js_path()
|
||||
.ends_with(Path::new("artifact-tool/dist/artifact_tool.mjs"))
|
||||
);
|
||||
assert!(
|
||||
runtime
|
||||
.render_cli_path()
|
||||
.ends_with(Path::new("granola-render/dist/cli.mjs"))
|
||||
);
|
||||
}
|
||||
|
||||
fn build_zip_archive(runtime_version: &str) -> Vec<u8> {
|
||||
let mut bytes = Cursor::new(Vec::new());
|
||||
{
|
||||
let mut zip = ZipWriter::new(&mut bytes);
|
||||
let options = SimpleFileOptions::default();
|
||||
let manifest = serde_json::to_vec(&sample_extracted_manifest(runtime_version))
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.start_file("artifact-runtime/manifest.json", options)
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.write_all(&manifest)
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.start_file("artifact-runtime/node/bin/node", options)
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.write_all(b"#!/bin/sh\n")
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.start_file(
|
||||
"artifact-runtime/artifact-tool/dist/artifact_tool.mjs",
|
||||
options,
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.write_all(b"export const ok = true;\n")
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.start_file("artifact-runtime/granola-render/dist/cli.mjs", options)
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.write_all(b"export const ok = true;\n")
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.finish().unwrap_or_else(|error| panic!("{error}"));
|
||||
}
|
||||
bytes.into_inner()
|
||||
}
|
||||
|
||||
fn sample_extracted_manifest(runtime_version: &str) -> ExtractedRuntimeManifest {
|
||||
ExtractedRuntimeManifest {
|
||||
schema_version: 1,
|
||||
runtime_version: runtime_version.to_string(),
|
||||
node: RuntimePathEntry {
|
||||
relative_path: "node/bin/node".to_string(),
|
||||
},
|
||||
entrypoints: RuntimeEntrypoints {
|
||||
build_js: RuntimePathEntry {
|
||||
relative_path: "artifact-tool/dist/artifact_tool.mjs".to_string(),
|
||||
},
|
||||
render_cli: RuntimePathEntry {
|
||||
relative_path: "granola-render/dist/cli.mjs".to_string(),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,8 +42,7 @@ codex-hooks = { workspace = true }
|
||||
codex-keyring-store = { workspace = true }
|
||||
codex-network-proxy = { workspace = true }
|
||||
codex-otel = { workspace = true }
|
||||
codex-artifact-presentation = { workspace = true }
|
||||
codex-artifact-spreadsheet = { workspace = true }
|
||||
codex-artifacts = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
codex-rmcp-client = { workspace = true }
|
||||
codex-state = { workspace = true }
|
||||
|
||||
@@ -55,12 +55,6 @@ use async_channel::Receiver;
|
||||
use async_channel::Sender;
|
||||
use chrono::Local;
|
||||
use chrono::Utc;
|
||||
use codex_artifact_presentation::PresentationArtifactError;
|
||||
use codex_artifact_presentation::PresentationArtifactExecutionRequest;
|
||||
use codex_artifact_presentation::PresentationArtifactResponse;
|
||||
use codex_artifact_spreadsheet::SpreadsheetArtifactError;
|
||||
use codex_artifact_spreadsheet::SpreadsheetArtifactRequest;
|
||||
use codex_artifact_spreadsheet::SpreadsheetArtifactResponse;
|
||||
use codex_hooks::HookEvent;
|
||||
use codex_hooks::HookEventAfterAgent;
|
||||
use codex_hooks::HookPayload;
|
||||
@@ -1798,24 +1792,6 @@ impl Session {
|
||||
state.clear_connector_selection();
|
||||
}
|
||||
|
||||
pub(crate) async fn execute_presentation_artifact(
|
||||
&self,
|
||||
request: PresentationArtifactExecutionRequest,
|
||||
cwd: &Path,
|
||||
) -> Result<PresentationArtifactResponse, PresentationArtifactError> {
|
||||
let mut state = self.state.lock().await;
|
||||
state.artifacts.presentation.execute_requests(request, cwd)
|
||||
}
|
||||
|
||||
pub(crate) async fn execute_spreadsheet_artifact(
|
||||
&self,
|
||||
request: SpreadsheetArtifactRequest,
|
||||
cwd: &Path,
|
||||
) -> Result<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
|
||||
let mut state = self.state.lock().await;
|
||||
state.artifacts.spreadsheet.execute(request, cwd)
|
||||
}
|
||||
|
||||
async fn record_initial_history(&self, conversation_history: InitialHistory) {
|
||||
let turn_context = self.new_default_turn().await;
|
||||
self.clear_mcp_tool_selection().await;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
//! Session-wide mutable state.
|
||||
|
||||
use codex_artifact_presentation::PresentationArtifactManager;
|
||||
use codex_artifact_spreadsheet::SpreadsheetArtifactManager;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
@@ -18,12 +16,6 @@ use crate::tasks::RegularTask;
|
||||
use crate::truncate::TruncationPolicy;
|
||||
use codex_protocol::protocol::TurnContextItem;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct SessionArtifacts {
|
||||
pub(crate) presentation: PresentationArtifactManager,
|
||||
pub(crate) spreadsheet: SpreadsheetArtifactManager,
|
||||
}
|
||||
|
||||
/// Persistent, session-scoped state previously stored directly on `Session`.
|
||||
pub(crate) struct SessionState {
|
||||
pub(crate) session_configuration: SessionConfiguration,
|
||||
@@ -40,7 +32,6 @@ pub(crate) struct SessionState {
|
||||
pub(crate) startup_regular_task: Option<JoinHandle<CodexResult<RegularTask>>>,
|
||||
pub(crate) active_mcp_tool_selection: Option<Vec<String>>,
|
||||
pub(crate) active_connector_selection: HashSet<String>,
|
||||
pub(crate) artifacts: SessionArtifacts,
|
||||
}
|
||||
|
||||
impl SessionState {
|
||||
@@ -58,7 +49,6 @@ impl SessionState {
|
||||
startup_regular_task: None,
|
||||
active_mcp_tool_selection: None,
|
||||
active_connector_selection: HashSet::new(),
|
||||
artifacts: SessionArtifacts::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
use async_trait::async_trait;
|
||||
use codex_artifacts::ArtifactBuildRequest;
|
||||
use codex_artifacts::ArtifactCommandOutput;
|
||||
use codex_artifacts::ArtifactRuntimeError;
|
||||
use codex_artifacts::ArtifactRuntimePlatform;
|
||||
use codex_artifacts::ArtifactsClient;
|
||||
use codex_artifacts::ArtifactsError;
|
||||
use codex_artifacts::InstalledArtifactRuntime;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::exec::ExecToolCallOutput;
|
||||
use crate::exec::StreamOutput;
|
||||
use crate::features::Feature;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::protocol::ExecCommandSource;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolOutput;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::events::ToolEmitter;
|
||||
use crate::tools::events::ToolEventCtx;
|
||||
use crate::tools::events::ToolEventFailure;
|
||||
use crate::tools::events::ToolEventStage;
|
||||
use crate::tools::registry::ToolHandler;
|
||||
use crate::tools::registry::ToolKind;
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
|
||||
const ARTIFACTS_TOOL_NAME: &str = "artifacts";
|
||||
const ARTIFACTS_PRAGMA_PREFIXES: [&str; 2] = ["// codex-artifacts:", "// codex-artifact-tool:"];
|
||||
const PINNED_ARTIFACT_RUNTIME_VERSION: &str = "2.4.0";
|
||||
const DEFAULT_EXECUTION_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
pub struct ArtifactsHandler;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct ArtifactsToolArgs {
|
||||
source: String,
|
||||
timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolHandler for ArtifactsHandler {
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
fn matches_kind(&self, payload: &ToolPayload) -> bool {
|
||||
matches!(payload, ToolPayload::Custom { .. })
|
||||
}
|
||||
|
||||
async fn is_mutating(&self, _invocation: &ToolInvocation) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, FunctionCallError> {
|
||||
let ToolInvocation {
|
||||
session,
|
||||
turn,
|
||||
payload,
|
||||
call_id,
|
||||
..
|
||||
} = invocation;
|
||||
|
||||
if !session.enabled(Feature::Artifact) {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"artifacts is disabled by feature flag".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let args = match payload {
|
||||
ToolPayload::Custom { input } => parse_freeform_args(&input)?,
|
||||
_ => {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"artifacts expects freeform JavaScript input authored against the preloaded @oai/artifact-tool surface".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let runtime = resolve_preinstalled_runtime(&turn.config.codex_home)
|
||||
.await
|
||||
.map_err(artifacts_error)?;
|
||||
let client = ArtifactsClient::from_installed_runtime(runtime);
|
||||
|
||||
let started_at = Instant::now();
|
||||
emit_exec_begin(session.as_ref(), turn.as_ref(), &call_id).await;
|
||||
|
||||
let result = client
|
||||
.execute_build(ArtifactBuildRequest {
|
||||
source: args.source,
|
||||
cwd: turn.cwd.clone(),
|
||||
timeout: Some(Duration::from_millis(
|
||||
args.timeout_ms
|
||||
.unwrap_or(DEFAULT_EXECUTION_TIMEOUT.as_millis() as u64),
|
||||
)),
|
||||
env: Default::default(),
|
||||
})
|
||||
.await;
|
||||
|
||||
let (success, output) = match result {
|
||||
Ok(output) => (output.success(), output),
|
||||
Err(error) => (false, error_output(&error)),
|
||||
};
|
||||
|
||||
emit_exec_end(
|
||||
session.as_ref(),
|
||||
turn.as_ref(),
|
||||
&call_id,
|
||||
&output,
|
||||
started_at.elapsed(),
|
||||
success,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(ToolOutput::Function {
|
||||
body: FunctionCallOutputBody::Text(format_artifact_output(&output)),
|
||||
success: Some(success),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_preinstalled_runtime(
|
||||
codex_home: &Path,
|
||||
) -> Result<InstalledArtifactRuntime, ArtifactsError> {
|
||||
let platform = ArtifactRuntimePlatform::detect_current()
|
||||
.map_err(ArtifactRuntimeError::from)
|
||||
.map_err(ArtifactsError::Runtime)?;
|
||||
let install_dir = codex_home
|
||||
.join("packages")
|
||||
.join("artifacts")
|
||||
.join(PINNED_ARTIFACT_RUNTIME_VERSION)
|
||||
.join(platform.as_str());
|
||||
if !install_dir.exists() {
|
||||
return Err(ArtifactsError::Io {
|
||||
context: format!(
|
||||
"artifact runtime {} is not installed at {}",
|
||||
PINNED_ARTIFACT_RUNTIME_VERSION,
|
||||
install_dir.display()
|
||||
),
|
||||
source: std::io::Error::new(std::io::ErrorKind::NotFound, "missing artifact runtime"),
|
||||
});
|
||||
}
|
||||
|
||||
InstalledArtifactRuntime::load(install_dir, platform).map_err(ArtifactsError::Runtime)
|
||||
}
|
||||
|
||||
fn parse_freeform_args(input: &str) -> Result<ArtifactsToolArgs, FunctionCallError> {
|
||||
if input.trim().is_empty() {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"artifacts expects raw JavaScript source text (non-empty) authored against the preloaded @oai/artifact-tool surface. Provide JS only, optionally with first-line `// codex-artifacts: timeout_ms=15000` or `// codex-artifact-tool: timeout_ms=15000`."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut args = ArtifactsToolArgs {
|
||||
source: input.to_string(),
|
||||
timeout_ms: None,
|
||||
};
|
||||
|
||||
let mut lines = input.splitn(2, '\n');
|
||||
let first_line = lines.next().unwrap_or_default();
|
||||
let rest = lines.next().unwrap_or_default();
|
||||
let trimmed = first_line.trim_start();
|
||||
let Some(pragma) = parse_pragma_prefix(trimmed) else {
|
||||
reject_json_or_quoted_source(&args.source)?;
|
||||
return Ok(args);
|
||||
};
|
||||
|
||||
let mut timeout_ms = None;
|
||||
let directive = pragma.trim();
|
||||
if !directive.is_empty() {
|
||||
for token in directive.split_whitespace() {
|
||||
let (key, value) = token.split_once('=').ok_or_else(|| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"artifacts pragma expects space-separated key=value pairs (supported keys: timeout_ms); got `{token}`"
|
||||
))
|
||||
})?;
|
||||
match key {
|
||||
"timeout_ms" => {
|
||||
if timeout_ms.is_some() {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"artifacts pragma specifies timeout_ms more than once".to_string(),
|
||||
));
|
||||
}
|
||||
let parsed = value.parse::<u64>().map_err(|_| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"artifacts pragma timeout_ms must be an integer; got `{value}`"
|
||||
))
|
||||
})?;
|
||||
timeout_ms = Some(parsed);
|
||||
}
|
||||
_ => {
|
||||
return Err(FunctionCallError::RespondToModel(format!(
|
||||
"artifacts pragma only supports timeout_ms; got `{key}`"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if rest.trim().is_empty() {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"artifacts pragma must be followed by JavaScript source on subsequent lines"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
reject_json_or_quoted_source(rest)?;
|
||||
args.source = rest.to_string();
|
||||
args.timeout_ms = timeout_ms;
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
fn reject_json_or_quoted_source(code: &str) -> Result<(), FunctionCallError> {
|
||||
let trimmed = code.trim();
|
||||
if trimmed.starts_with("```") {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"artifacts expects raw JavaScript source, not markdown code fences. Resend plain JS only (optional first line `// codex-artifacts: ...` or `// codex-artifact-tool: ...`)."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
let Ok(value) = serde_json::from_str::<JsonValue>(trimmed) else {
|
||||
return Ok(());
|
||||
};
|
||||
match value {
|
||||
JsonValue::Object(_) | JsonValue::String(_) => Err(FunctionCallError::RespondToModel(
|
||||
"artifacts is a freeform tool and expects raw JavaScript source authored against the preloaded @oai/artifact-tool surface. Resend plain JS only (optional first line `// codex-artifacts: ...` or `// codex-artifact-tool: ...`); do not send JSON (`{\"code\":...}`), quoted code, or markdown fences."
|
||||
.to_string(),
|
||||
)),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_pragma_prefix(line: &str) -> Option<&str> {
|
||||
ARTIFACTS_PRAGMA_PREFIXES
|
||||
.iter()
|
||||
.find_map(|prefix| line.strip_prefix(prefix))
|
||||
}
|
||||
|
||||
fn artifacts_error(error: ArtifactsError) -> FunctionCallError {
|
||||
FunctionCallError::RespondToModel(error.to_string())
|
||||
}
|
||||
|
||||
async fn emit_exec_begin(session: &Session, turn: &TurnContext, call_id: &str) {
|
||||
let emitter = ToolEmitter::shell(
|
||||
vec![ARTIFACTS_TOOL_NAME.to_string()],
|
||||
turn.cwd.clone(),
|
||||
ExecCommandSource::Agent,
|
||||
true,
|
||||
);
|
||||
let ctx = ToolEventCtx::new(session, turn, call_id, None);
|
||||
emitter.emit(ctx, ToolEventStage::Begin).await;
|
||||
}
|
||||
|
||||
async fn emit_exec_end(
|
||||
session: &Session,
|
||||
turn: &TurnContext,
|
||||
call_id: &str,
|
||||
output: &ArtifactCommandOutput,
|
||||
duration: Duration,
|
||||
success: bool,
|
||||
) {
|
||||
let exec_output = ExecToolCallOutput {
|
||||
exit_code: output.exit_code.unwrap_or(1),
|
||||
stdout: StreamOutput::new(output.stdout.clone()),
|
||||
stderr: StreamOutput::new(output.stderr.clone()),
|
||||
aggregated_output: StreamOutput::new(format_artifact_output(output)),
|
||||
duration,
|
||||
timed_out: false,
|
||||
};
|
||||
let emitter = ToolEmitter::shell(
|
||||
vec![ARTIFACTS_TOOL_NAME.to_string()],
|
||||
turn.cwd.clone(),
|
||||
ExecCommandSource::Agent,
|
||||
true,
|
||||
);
|
||||
let ctx = ToolEventCtx::new(session, turn, call_id, None);
|
||||
let stage = if success {
|
||||
ToolEventStage::Success(exec_output)
|
||||
} else {
|
||||
ToolEventStage::Failure(ToolEventFailure::Output(exec_output))
|
||||
};
|
||||
emitter.emit(ctx, stage).await;
|
||||
}
|
||||
|
||||
fn format_artifact_output(output: &ArtifactCommandOutput) -> String {
|
||||
let stdout = output.stdout.trim();
|
||||
let stderr = output.stderr.trim();
|
||||
let mut sections = vec![format!(
|
||||
"exit_code: {}",
|
||||
output
|
||||
.exit_code
|
||||
.map(|code| code.to_string())
|
||||
.unwrap_or_else(|| "null".to_string())
|
||||
)];
|
||||
if !stdout.is_empty() {
|
||||
sections.push(format!("stdout:\n{stdout}"));
|
||||
}
|
||||
if !stderr.is_empty() {
|
||||
sections.push(format!("stderr:\n{stderr}"));
|
||||
}
|
||||
if stdout.is_empty() && stderr.is_empty() && output.success() {
|
||||
sections.push("artifact JS completed successfully.".to_string());
|
||||
}
|
||||
sections.join("\n\n")
|
||||
}
|
||||
|
||||
fn error_output(error: &ArtifactsError) -> ArtifactCommandOutput {
|
||||
ArtifactCommandOutput {
|
||||
exit_code: Some(1),
|
||||
stdout: String::new(),
|
||||
stderr: error.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_artifacts::RuntimeEntrypoints;
|
||||
use codex_artifacts::RuntimePathEntry;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn parse_freeform_args_without_pragma() {
|
||||
let args = parse_freeform_args("console.log('ok');").expect("parse args");
|
||||
assert_eq!(args.source, "console.log('ok');");
|
||||
assert_eq!(args.timeout_ms, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_freeform_args_with_pragma() {
|
||||
let args = parse_freeform_args("// codex-artifacts: timeout_ms=45000\nconsole.log('ok');")
|
||||
.expect("parse args");
|
||||
assert_eq!(args.source, "console.log('ok');");
|
||||
assert_eq!(args.timeout_ms, Some(45_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_freeform_args_with_artifact_tool_pragma() {
|
||||
let args =
|
||||
parse_freeform_args("// codex-artifact-tool: timeout_ms=45000\nconsole.log('ok');")
|
||||
.expect("parse args");
|
||||
assert_eq!(args.source, "console.log('ok');");
|
||||
assert_eq!(args.timeout_ms, Some(45_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_freeform_args_rejects_json_wrapped_code() {
|
||||
let err =
|
||||
parse_freeform_args("{\"code\":\"console.log('ok')\"}").expect_err("expected error");
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("artifacts is a freeform tool and expects raw JavaScript source")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_preinstalled_runtime_reads_pinned_cache_path() {
|
||||
let codex_home = TempDir::new().expect("create temp codex home");
|
||||
let platform = ArtifactRuntimePlatform::detect_current().expect("detect platform");
|
||||
let install_dir = codex_home
|
||||
.path()
|
||||
.join("packages")
|
||||
.join("artifacts")
|
||||
.join(PINNED_ARTIFACT_RUNTIME_VERSION)
|
||||
.join(platform.as_str());
|
||||
std::fs::create_dir_all(&install_dir).expect("create install dir");
|
||||
std::fs::write(
|
||||
install_dir.join("manifest.json"),
|
||||
serde_json::json!({
|
||||
"schema_version": 1,
|
||||
"runtime_version": PINNED_ARTIFACT_RUNTIME_VERSION,
|
||||
"node": { "relative_path": "node/bin/node" },
|
||||
"entrypoints": {
|
||||
"build_js": { "relative_path": "artifact-tool/dist/artifact_tool.mjs" },
|
||||
"render_cli": { "relative_path": "granola-render/dist/render_cli.mjs" }
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("write manifest");
|
||||
|
||||
let runtime = resolve_preinstalled_runtime(codex_home.path())
|
||||
.await
|
||||
.expect("resolve runtime");
|
||||
assert_eq!(runtime.runtime_version(), PINNED_ARTIFACT_RUNTIME_VERSION);
|
||||
assert_eq!(
|
||||
runtime.manifest().entrypoints,
|
||||
RuntimeEntrypoints {
|
||||
build_js: RuntimePathEntry {
|
||||
relative_path: "artifact-tool/dist/artifact_tool.mjs".to_string(),
|
||||
},
|
||||
render_cli: RuntimePathEntry {
|
||||
relative_path: "granola-render/dist/render_cli.mjs".to_string(),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_artifact_output_includes_success_message_when_silent() {
|
||||
let formatted = format_artifact_output(&ArtifactCommandOutput {
|
||||
exit_code: Some(0),
|
||||
stdout: String::new(),
|
||||
stderr: String::new(),
|
||||
});
|
||||
assert!(formatted.contains("artifact JS completed successfully."));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub(crate) mod agent_jobs;
|
||||
pub mod apply_patch;
|
||||
mod artifacts;
|
||||
mod dynamic;
|
||||
mod grep_files;
|
||||
mod js_repl;
|
||||
@@ -8,12 +9,10 @@ mod mcp;
|
||||
mod mcp_resource;
|
||||
pub(crate) mod multi_agents;
|
||||
mod plan;
|
||||
mod presentation_artifact;
|
||||
mod read_file;
|
||||
mod request_user_input;
|
||||
mod search_tool_bm25;
|
||||
mod shell;
|
||||
mod spreadsheet_artifact;
|
||||
mod test_sync;
|
||||
pub(crate) mod unified_exec;
|
||||
mod view_image;
|
||||
@@ -29,6 +28,7 @@ use crate::function_tool::FunctionCallError;
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
use crate::sandboxing::normalize_additional_permissions;
|
||||
pub use apply_patch::ApplyPatchHandler;
|
||||
pub use artifacts::ArtifactsHandler;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
pub use dynamic::DynamicToolHandler;
|
||||
@@ -40,7 +40,6 @@ pub use mcp::McpHandler;
|
||||
pub use mcp_resource::McpResourceHandler;
|
||||
pub use multi_agents::MultiAgentHandler;
|
||||
pub use plan::PlanHandler;
|
||||
pub use presentation_artifact::PresentationArtifactHandler;
|
||||
pub use read_file::ReadFileHandler;
|
||||
pub use request_user_input::RequestUserInputHandler;
|
||||
pub(crate) use request_user_input::request_user_input_tool_description;
|
||||
@@ -49,7 +48,6 @@ pub(crate) use search_tool_bm25::SEARCH_TOOL_BM25_TOOL_NAME;
|
||||
pub use search_tool_bm25::SearchToolBm25Handler;
|
||||
pub use shell::ShellCommandHandler;
|
||||
pub use shell::ShellHandler;
|
||||
pub use spreadsheet_artifact::SpreadsheetArtifactHandler;
|
||||
pub use test_sync::TestSyncHandler;
|
||||
pub use unified_exec::UnifiedExecHandler;
|
||||
pub use view_image::ViewImageHandler;
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
use async_trait::async_trait;
|
||||
use codex_artifact_presentation::PathAccessKind;
|
||||
use codex_artifact_presentation::PathAccessRequirement;
|
||||
use codex_artifact_presentation::PresentationArtifactError;
|
||||
use codex_artifact_presentation::PresentationArtifactToolRequest;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use serde_json::to_string;
|
||||
use std::path::Component;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::features::Feature;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::path_utils::normalize_for_path_comparison;
|
||||
use crate::path_utils::resolve_symlink_write_paths;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolOutput;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::handlers::parse_arguments;
|
||||
use crate::tools::registry::ToolHandler;
|
||||
use crate::tools::registry::ToolKind;
|
||||
use crate::tools::sandboxing::with_cached_approval;
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
|
||||
pub struct PresentationArtifactHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl ToolHandler for PresentationArtifactHandler {
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
async fn is_mutating(&self, invocation: &ToolInvocation) -> bool {
|
||||
let ToolPayload::Function { arguments } = &invocation.payload else {
|
||||
return true;
|
||||
};
|
||||
let Ok(request) = parse_arguments::<PresentationArtifactToolRequest>(arguments) else {
|
||||
return true;
|
||||
};
|
||||
request.is_mutating().unwrap_or(true)
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, FunctionCallError> {
|
||||
let ToolInvocation {
|
||||
session,
|
||||
turn,
|
||||
payload,
|
||||
call_id,
|
||||
..
|
||||
} = invocation;
|
||||
|
||||
if !session.enabled(Feature::Artifact) {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"presentation_artifact is disabled by feature flag".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let arguments = match payload {
|
||||
ToolPayload::Function { arguments } => arguments,
|
||||
_ => {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"presentation_artifact handler received unsupported payload".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let request: PresentationArtifactToolRequest = parse_arguments(&arguments)?;
|
||||
for access in request
|
||||
.required_path_accesses(&turn.cwd)
|
||||
.map_err(presentation_error)?
|
||||
{
|
||||
authorize_path_access(session.as_ref(), turn.as_ref(), &call_id, &access).await?;
|
||||
}
|
||||
|
||||
let response = session
|
||||
.execute_presentation_artifact(
|
||||
request
|
||||
.into_execution_request()
|
||||
.map_err(presentation_error)?,
|
||||
&turn.cwd,
|
||||
)
|
||||
.await
|
||||
.map_err(presentation_error)?;
|
||||
|
||||
Ok(ToolOutput::Function {
|
||||
body: FunctionCallOutputBody::Text(to_string(&response).map_err(|error| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"failed to serialize presentation_artifact response: {error}"
|
||||
))
|
||||
})?),
|
||||
success: Some(true),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn presentation_error(error: PresentationArtifactError) -> FunctionCallError {
|
||||
FunctionCallError::RespondToModel(error.to_string())
|
||||
}
|
||||
|
||||
async fn authorize_path_access(
|
||||
session: &Session,
|
||||
turn: &TurnContext,
|
||||
call_id: &str,
|
||||
access: &PathAccessRequirement,
|
||||
) -> Result<(), FunctionCallError> {
|
||||
let effective_path = match access.kind {
|
||||
PathAccessKind::Read => effective_read_path(&access.path),
|
||||
PathAccessKind::Write => effective_write_path(&access.path),
|
||||
};
|
||||
let allowed = match access.kind {
|
||||
PathAccessKind::Read => path_is_readable(turn, &effective_path),
|
||||
PathAccessKind::Write => path_is_writable(turn, &effective_path),
|
||||
};
|
||||
if allowed {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let approval_policy = turn.approval_policy.value();
|
||||
if !matches!(
|
||||
approval_policy,
|
||||
AskForApproval::OnRequest | AskForApproval::UnlessTrusted
|
||||
) {
|
||||
return Err(FunctionCallError::RespondToModel(format!(
|
||||
"{} path `{}` is outside the current sandbox policy",
|
||||
access_kind_label(access.kind),
|
||||
access.path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
let key = format!(
|
||||
"presentation_artifact:{:?}:{}",
|
||||
access.kind,
|
||||
effective_path.display()
|
||||
);
|
||||
let path = access.path.clone();
|
||||
let action = access.action.clone();
|
||||
let decision = with_cached_approval(
|
||||
&session.services,
|
||||
"presentation_artifact",
|
||||
vec![key],
|
||||
|| {
|
||||
let path = path.clone();
|
||||
let action = action.clone();
|
||||
async move {
|
||||
session
|
||||
.request_command_approval(
|
||||
turn,
|
||||
call_id.to_string(),
|
||||
None,
|
||||
vec![
|
||||
"presentation_artifact".to_string(),
|
||||
action,
|
||||
path.display().to_string(),
|
||||
],
|
||||
turn.cwd.clone(),
|
||||
Some(format!(
|
||||
"Allow presentation_artifact to {} `{}`?",
|
||||
access_kind_verb(access.kind),
|
||||
path.display()
|
||||
)),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
if matches!(
|
||||
decision,
|
||||
ReviewDecision::Approved
|
||||
| ReviewDecision::ApprovedForSession
|
||||
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
|
||||
) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(FunctionCallError::RespondToModel(format!(
|
||||
"{} path `{}` was not approved",
|
||||
access_kind_label(access.kind),
|
||||
access.path.display()
|
||||
)))
|
||||
}
|
||||
|
||||
fn path_is_readable(turn: &TurnContext, path: &Path) -> bool {
|
||||
if turn.sandbox_policy.has_full_disk_read_access() {
|
||||
return true;
|
||||
}
|
||||
|
||||
turn.sandbox_policy
|
||||
.get_readable_roots_with_cwd(&turn.cwd)
|
||||
.iter()
|
||||
.any(|root| path.starts_with(root.as_path()))
|
||||
}
|
||||
|
||||
fn path_is_writable(turn: &TurnContext, path: &Path) -> bool {
|
||||
if turn.sandbox_policy.has_full_disk_write_access() {
|
||||
return true;
|
||||
}
|
||||
|
||||
turn.sandbox_policy
|
||||
.get_writable_roots_with_cwd(&turn.cwd)
|
||||
.iter()
|
||||
.any(|root| root.is_path_writable(path))
|
||||
}
|
||||
|
||||
fn effective_read_path(path: &Path) -> PathBuf {
|
||||
normalize_for_path_comparison(path).unwrap_or_else(|_| normalize_without_fs(path))
|
||||
}
|
||||
|
||||
fn effective_write_path(path: &Path) -> PathBuf {
|
||||
let write_path = resolve_symlink_write_paths(path)
|
||||
.map(|paths| paths.write_path)
|
||||
.unwrap_or_else(|_| path.to_path_buf());
|
||||
normalize_for_path_comparison(&write_path).unwrap_or_else(|_| normalize_without_fs(&write_path))
|
||||
}
|
||||
|
||||
fn normalize_without_fs(path: &Path) -> PathBuf {
|
||||
let mut normalized = PathBuf::new();
|
||||
for component in path.components() {
|
||||
match component {
|
||||
Component::ParentDir => {
|
||||
normalized.pop();
|
||||
}
|
||||
Component::CurDir => {}
|
||||
other => normalized.push(other.as_os_str()),
|
||||
}
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
fn access_kind_label(kind: PathAccessKind) -> &'static str {
|
||||
match kind {
|
||||
PathAccessKind::Read => "read",
|
||||
PathAccessKind::Write => "write",
|
||||
}
|
||||
}
|
||||
|
||||
fn access_kind_verb(kind: PathAccessKind) -> &'static str {
|
||||
match kind {
|
||||
PathAccessKind::Read => "read from",
|
||||
PathAccessKind::Write => "write to",
|
||||
}
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
use async_trait::async_trait;
|
||||
use codex_artifact_spreadsheet::PathAccessKind;
|
||||
use codex_artifact_spreadsheet::PathAccessRequirement;
|
||||
use codex_artifact_spreadsheet::SpreadsheetArtifactError;
|
||||
use codex_artifact_spreadsheet::SpreadsheetArtifactRequest;
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use serde_json::to_string;
|
||||
use std::path::Component;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::features::Feature;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::path_utils::normalize_for_path_comparison;
|
||||
use crate::path_utils::resolve_symlink_write_paths;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolOutput;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::handlers::parse_arguments;
|
||||
use crate::tools::registry::ToolHandler;
|
||||
use crate::tools::registry::ToolKind;
|
||||
use crate::tools::sandboxing::with_cached_approval;
|
||||
|
||||
pub struct SpreadsheetArtifactHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl ToolHandler for SpreadsheetArtifactHandler {
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
async fn is_mutating(&self, _invocation: &ToolInvocation) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, FunctionCallError> {
|
||||
let ToolInvocation {
|
||||
session,
|
||||
turn,
|
||||
payload,
|
||||
call_id,
|
||||
..
|
||||
} = invocation;
|
||||
|
||||
if !session.enabled(Feature::Artifact) {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"spreadsheet_artifact is disabled by feature flag".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let arguments = match payload {
|
||||
ToolPayload::Function { arguments } => arguments,
|
||||
_ => {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"spreadsheet_artifact handler received unsupported payload".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let request: SpreadsheetArtifactRequest = parse_arguments(&arguments)?;
|
||||
for access in request
|
||||
.required_path_accesses(&turn.cwd)
|
||||
.map_err(spreadsheet_error)?
|
||||
{
|
||||
authorize_path_access(session.as_ref(), turn.as_ref(), &call_id, &access).await?;
|
||||
}
|
||||
|
||||
let response = session
|
||||
.execute_spreadsheet_artifact(request, &turn.cwd)
|
||||
.await
|
||||
.map_err(spreadsheet_error)?;
|
||||
|
||||
Ok(ToolOutput::Function {
|
||||
body: FunctionCallOutputBody::Text(to_string(&response).map_err(|error| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"failed to serialize spreadsheet_artifact response: {error}"
|
||||
))
|
||||
})?),
|
||||
success: Some(true),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn spreadsheet_error(error: SpreadsheetArtifactError) -> FunctionCallError {
|
||||
FunctionCallError::RespondToModel(error.to_string())
|
||||
}
|
||||
|
||||
async fn authorize_path_access(
|
||||
session: &Session,
|
||||
turn: &TurnContext,
|
||||
call_id: &str,
|
||||
access: &PathAccessRequirement,
|
||||
) -> Result<(), FunctionCallError> {
|
||||
let effective_path = match access.kind {
|
||||
PathAccessKind::Read => effective_read_path(&access.path),
|
||||
PathAccessKind::Write => effective_write_path(&access.path),
|
||||
};
|
||||
let allowed = match access.kind {
|
||||
PathAccessKind::Read => path_is_readable(turn, &effective_path),
|
||||
PathAccessKind::Write => path_is_writable(turn, &effective_path),
|
||||
};
|
||||
if allowed {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let approval_policy = turn.approval_policy.value();
|
||||
if !matches!(
|
||||
approval_policy,
|
||||
AskForApproval::OnRequest | AskForApproval::UnlessTrusted
|
||||
) {
|
||||
return Err(FunctionCallError::RespondToModel(format!(
|
||||
"{} path `{}` is outside the current sandbox policy",
|
||||
access_kind_label(access.kind),
|
||||
access.path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
let key = format!(
|
||||
"spreadsheet_artifact:{:?}:{}",
|
||||
access.kind,
|
||||
effective_path.display()
|
||||
);
|
||||
let path = access.path.clone();
|
||||
let action = access.action.clone();
|
||||
let decision =
|
||||
with_cached_approval(&session.services, "spreadsheet_artifact", vec![key], || {
|
||||
let path = path.clone();
|
||||
let action = action.clone();
|
||||
async move {
|
||||
session
|
||||
.request_command_approval(
|
||||
turn,
|
||||
call_id.to_string(),
|
||||
None,
|
||||
vec![
|
||||
"spreadsheet_artifact".to_string(),
|
||||
action,
|
||||
path.display().to_string(),
|
||||
],
|
||||
turn.cwd.clone(),
|
||||
Some(format!(
|
||||
"Allow spreadsheet_artifact to {} `{}`?",
|
||||
access_kind_verb(access.kind),
|
||||
path.display()
|
||||
)),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
if matches!(
|
||||
decision,
|
||||
ReviewDecision::Approved
|
||||
| ReviewDecision::ApprovedForSession
|
||||
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
|
||||
) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(FunctionCallError::RespondToModel(format!(
|
||||
"{} path `{}` was not approved",
|
||||
access_kind_label(access.kind),
|
||||
access.path.display()
|
||||
)))
|
||||
}
|
||||
|
||||
fn path_is_readable(turn: &TurnContext, path: &Path) -> bool {
|
||||
if turn.sandbox_policy.has_full_disk_read_access() {
|
||||
return true;
|
||||
}
|
||||
|
||||
turn.sandbox_policy
|
||||
.get_readable_roots_with_cwd(&turn.cwd)
|
||||
.iter()
|
||||
.any(|root| path.starts_with(root.as_path()))
|
||||
}
|
||||
|
||||
fn path_is_writable(turn: &TurnContext, path: &Path) -> bool {
|
||||
if turn.sandbox_policy.has_full_disk_write_access() {
|
||||
return true;
|
||||
}
|
||||
|
||||
turn.sandbox_policy
|
||||
.get_writable_roots_with_cwd(&turn.cwd)
|
||||
.iter()
|
||||
.any(|root| root.is_path_writable(path))
|
||||
}
|
||||
|
||||
fn effective_read_path(path: &Path) -> PathBuf {
|
||||
normalize_for_path_comparison(path).unwrap_or_else(|_| normalize_without_fs(path))
|
||||
}
|
||||
|
||||
fn effective_write_path(path: &Path) -> PathBuf {
|
||||
let write_path = resolve_symlink_write_paths(path)
|
||||
.map(|paths| paths.write_path)
|
||||
.unwrap_or_else(|_| path.to_path_buf());
|
||||
normalize_for_path_comparison(&write_path).unwrap_or_else(|_| normalize_without_fs(&write_path))
|
||||
}
|
||||
|
||||
fn normalize_without_fs(path: &Path) -> PathBuf {
|
||||
let mut normalized = PathBuf::new();
|
||||
for component in path.components() {
|
||||
match component {
|
||||
Component::ParentDir => {
|
||||
normalized.pop();
|
||||
}
|
||||
Component::CurDir => {}
|
||||
other => normalized.push(other.as_os_str()),
|
||||
}
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
fn access_kind_label(kind: PathAccessKind) -> &'static str {
|
||||
match kind {
|
||||
PathAccessKind::Read => "read",
|
||||
PathAccessKind::Write => "write",
|
||||
}
|
||||
}
|
||||
|
||||
fn access_kind_verb(kind: PathAccessKind) -> &'static str {
|
||||
match kind {
|
||||
PathAccessKind::Read => "read from",
|
||||
PathAccessKind::Write => "write to",
|
||||
}
|
||||
}
|
||||
+32
-100
@@ -575,97 +575,6 @@ fn create_view_image_tool() -> ToolSpec {
|
||||
})
|
||||
}
|
||||
|
||||
fn create_presentation_artifact_tool() -> ToolSpec {
|
||||
let action_step_schema = JsonSchema::Object {
|
||||
properties: BTreeMap::from([
|
||||
(
|
||||
"action".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("Action name to run for this step.".to_string()),
|
||||
},
|
||||
),
|
||||
(
|
||||
"args".to_string(),
|
||||
JsonSchema::Object {
|
||||
properties: BTreeMap::new(),
|
||||
required: None,
|
||||
additional_properties: Some(true.into()),
|
||||
},
|
||||
),
|
||||
]),
|
||||
required: Some(vec!["action".to_string(), "args".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
};
|
||||
let properties = BTreeMap::from([
|
||||
(
|
||||
"artifact_id".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Artifact id returned by an earlier presentation_artifact call.".to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"actions".to_string(),
|
||||
JsonSchema::Array {
|
||||
items: Box::new(action_step_schema),
|
||||
description: Some(
|
||||
"Array of `(action, args)` steps to execute sequentially.".to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "presentation_artifact".to_string(),
|
||||
description: "Create or edit a presentation artifact for the current thread.".to_string(),
|
||||
strict: false,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["actions".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn create_spreadsheet_artifact_tool() -> ToolSpec {
|
||||
let properties = BTreeMap::from([
|
||||
(
|
||||
"artifact_id".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Artifact id returned by an earlier spreadsheet_artifact call.".to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"action".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("Action name to run for this request.".to_string()),
|
||||
},
|
||||
),
|
||||
(
|
||||
"args".to_string(),
|
||||
JsonSchema::Object {
|
||||
properties: BTreeMap::new(),
|
||||
required: None,
|
||||
additional_properties: Some(true.into()),
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "spreadsheet_artifact".to_string(),
|
||||
description: "Create or edit a spreadsheet artifact for the current thread.".to_string(),
|
||||
strict: false,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["action".to_string(), "args".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn create_collab_input_items_schema() -> JsonSchema {
|
||||
let properties = BTreeMap::from([
|
||||
(
|
||||
@@ -1461,6 +1370,33 @@ JS_SOURCE: /(?:\s*)(?:[^\s{\"`]|`[^`]|``[^`])[\s\S]*/
|
||||
})
|
||||
}
|
||||
|
||||
fn create_artifacts_tool() -> ToolSpec {
|
||||
const ARTIFACTS_FREEFORM_GRAMMAR: &str = r#"
|
||||
start: pragma_source | plain_source
|
||||
|
||||
pragma_source: PRAGMA_LINE NEWLINE js_source
|
||||
plain_source: PLAIN_JS_SOURCE
|
||||
|
||||
js_source: JS_SOURCE
|
||||
|
||||
PRAGMA_LINE: /[ \t]*\/\/ codex-artifacts:[^\r\n]*/ | /[ \t]*\/\/ codex-artifact-tool:[^\r\n]*/
|
||||
NEWLINE: /\r?\n/
|
||||
PLAIN_JS_SOURCE: /(?:\s*)(?:[^\s{\"`]|`[^`]|``[^`])[\s\S]*/
|
||||
JS_SOURCE: /(?:\s*)(?:[^\s{\"`]|`[^`]|``[^`])[\s\S]*/
|
||||
"#;
|
||||
|
||||
ToolSpec::Freeform(FreeformTool {
|
||||
name: "artifacts".to_string(),
|
||||
description: "Runs raw JavaScript against the preinstalled Codex @oai/artifact-tool runtime for creating presentations or spreadsheets. This is plain JavaScript executed by Node with top-level await, not TypeScript: do not use type annotations, `interface`, `type`, or `import type`. Author code the same way you would for `import { Presentation, Workbook, PresentationFile, SpreadsheetFile, FileBlob, ... } from \"@oai/artifact-tool\"`, but omit that import line because the package surface is already preloaded. Named exports are available directly on `globalThis`, and the full module is available as `globalThis.artifactTool` (also aliased as `globalThis.artifacts` and `globalThis.codexArtifacts`). Node built-ins such as `node:fs/promises` may still be imported when needed for saving preview bytes. This is a freeform tool: send raw JavaScript source text, optionally with a first-line pragma like `// codex-artifacts: timeout_ms=15000` or `// codex-artifact-tool: timeout_ms=15000`; do not send JSON/quotes/markdown fences."
|
||||
.to_string(),
|
||||
format: FreeformToolFormat {
|
||||
r#type: "grammar".to_string(),
|
||||
syntax: "lark".to_string(),
|
||||
definition: ARTIFACTS_FREEFORM_GRAMMAR.to_string(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn create_js_repl_reset_tool() -> ToolSpec {
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "js_repl_reset".to_string(),
|
||||
@@ -1781,6 +1717,7 @@ pub(crate) fn build_specs(
|
||||
dynamic_tools: &[DynamicToolSpec],
|
||||
) -> ToolRegistryBuilder {
|
||||
use crate::tools::handlers::ApplyPatchHandler;
|
||||
use crate::tools::handlers::ArtifactsHandler;
|
||||
use crate::tools::handlers::DynamicToolHandler;
|
||||
use crate::tools::handlers::GrepFilesHandler;
|
||||
use crate::tools::handlers::JsReplHandler;
|
||||
@@ -1790,13 +1727,11 @@ pub(crate) fn build_specs(
|
||||
use crate::tools::handlers::McpResourceHandler;
|
||||
use crate::tools::handlers::MultiAgentHandler;
|
||||
use crate::tools::handlers::PlanHandler;
|
||||
use crate::tools::handlers::PresentationArtifactHandler;
|
||||
use crate::tools::handlers::ReadFileHandler;
|
||||
use crate::tools::handlers::RequestUserInputHandler;
|
||||
use crate::tools::handlers::SearchToolBm25Handler;
|
||||
use crate::tools::handlers::ShellCommandHandler;
|
||||
use crate::tools::handlers::ShellHandler;
|
||||
use crate::tools::handlers::SpreadsheetArtifactHandler;
|
||||
use crate::tools::handlers::TestSyncHandler;
|
||||
use crate::tools::handlers::UnifiedExecHandler;
|
||||
use crate::tools::handlers::ViewImageHandler;
|
||||
@@ -1819,8 +1754,7 @@ pub(crate) fn build_specs(
|
||||
let search_tool_handler = Arc::new(SearchToolBm25Handler);
|
||||
let js_repl_handler = Arc::new(JsReplHandler);
|
||||
let js_repl_reset_handler = Arc::new(JsReplResetHandler);
|
||||
let presentation_artifact_handler = Arc::new(PresentationArtifactHandler);
|
||||
let spreadsheet_artifact_handler = Arc::new(SpreadsheetArtifactHandler);
|
||||
let artifacts_handler = Arc::new(ArtifactsHandler);
|
||||
let request_permission_enabled = config.request_permission_enabled;
|
||||
|
||||
match &config.shell_type {
|
||||
@@ -1965,10 +1899,8 @@ pub(crate) fn build_specs(
|
||||
builder.register_handler("view_image", view_image_handler);
|
||||
|
||||
if config.artifact_tools {
|
||||
builder.push_spec(create_presentation_artifact_tool());
|
||||
builder.push_spec(create_spreadsheet_artifact_tool());
|
||||
builder.register_handler("presentation_artifact", presentation_artifact_handler);
|
||||
builder.register_handler("spreadsheet_artifact", spreadsheet_artifact_handler);
|
||||
builder.push_spec(create_artifacts_tool());
|
||||
builder.register_handler("artifacts", artifacts_handler);
|
||||
}
|
||||
|
||||
if config.collab_tools {
|
||||
@@ -2301,7 +2233,7 @@ mod tests {
|
||||
session_source: SessionSource::Cli,
|
||||
});
|
||||
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
|
||||
assert_contains_tool_names(&tools, &["presentation_artifact", "spreadsheet_artifact"]);
|
||||
assert_contains_tool_names(&tools, &["artifacts"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -78,7 +78,6 @@ ignore = [
|
||||
# TODO(fcoury): remove this exception when syntect drops yaml-rust and bincode, or updates to versions that have fixed the vulnerabilities.
|
||||
{ id = "RUSTSEC-2024-0320", reason = "yaml-rust is unmaintained; pulled in via syntect v5.3.0 used by codex-tui for syntax highlighting; no fixed release yet" },
|
||||
{ id = "RUSTSEC-2025-0141", reason = "bincode is unmaintained; pulled in via syntect v5.3.0 used by codex-tui for syntax highlighting; no fixed release yet" },
|
||||
{ id = "RUSTSEC-2025-0134", reason = "rustls-pemfile is unmaintained; pulled in via reqwest -> ppt-rs -> codex-artifact-presentation; no safe upgrade available yet" },
|
||||
]
|
||||
# If this is true, then cargo deny will use the git executable to fetch advisory database.
|
||||
# If this is false, then it uses a built-in git library.
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "package-manager",
|
||||
crate_name = "codex_package_manager",
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "codex-package-manager"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
flate2 = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["json", "stream"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
sha2 = { workspace = true }
|
||||
tar = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["fs", "rt", "sync"] }
|
||||
url = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["fs", "macros", "rt", "rt-multi-thread"] }
|
||||
wiremock = { workspace = true }
|
||||
@@ -0,0 +1,793 @@
|
||||
use flate2::read::GzDecoder;
|
||||
use reqwest::Client;
|
||||
use serde::de::DeserializeOwned;
|
||||
use sha2::Digest;
|
||||
use sha2::Sha256;
|
||||
use std::fs::File;
|
||||
use std::path::Component;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tar::Archive;
|
||||
use tempfile::tempdir_in;
|
||||
use thiserror::Error;
|
||||
use tokio::fs;
|
||||
use url::Url;
|
||||
use zip::ZipArchive;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PackageManagerConfig<P> {
|
||||
codex_home: PathBuf,
|
||||
package: P,
|
||||
cache_root: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl<P> PackageManagerConfig<P> {
|
||||
pub fn new(codex_home: PathBuf, package: P) -> Self {
|
||||
Self {
|
||||
codex_home,
|
||||
package,
|
||||
cache_root: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_cache_root(mut self, cache_root: PathBuf) -> Self {
|
||||
self.cache_root = Some(cache_root);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn codex_home(&self) -> &Path {
|
||||
&self.codex_home
|
||||
}
|
||||
|
||||
pub fn package(&self) -> &P {
|
||||
&self.package
|
||||
}
|
||||
}
|
||||
|
||||
impl<P: ManagedPackage> PackageManagerConfig<P> {
|
||||
pub fn cache_root(&self) -> PathBuf {
|
||||
self.cache_root.clone().unwrap_or_else(|| {
|
||||
self.codex_home.join(
|
||||
self.package
|
||||
.default_cache_root_relative()
|
||||
.replace('/', std::path::MAIN_SEPARATOR_STR),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PackageManager<P> {
|
||||
client: Client,
|
||||
config: PackageManagerConfig<P>,
|
||||
}
|
||||
|
||||
impl<P> PackageManager<P> {
|
||||
pub fn new(config: PackageManagerConfig<P>) -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_client(config: PackageManagerConfig<P>, client: Client) -> Self {
|
||||
Self { client, config }
|
||||
}
|
||||
|
||||
pub fn config(&self) -> &PackageManagerConfig<P> {
|
||||
&self.config
|
||||
}
|
||||
}
|
||||
|
||||
impl<P: ManagedPackage> PackageManager<P> {
|
||||
pub async fn resolve_cached(&self) -> Result<Option<P::Installed>, P::Error> {
|
||||
let platform = PackagePlatform::detect_current().map_err(P::Error::from)?;
|
||||
let install_dir = self
|
||||
.config
|
||||
.package()
|
||||
.install_dir(&self.config.cache_root(), platform);
|
||||
if !fs::try_exists(&install_dir)
|
||||
.await
|
||||
.map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to read {}", install_dir.display()),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)?
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let package = self
|
||||
.config
|
||||
.package()
|
||||
.load_installed(install_dir, platform)?;
|
||||
if self.config.package().installed_version(&package) != self.config.package().version() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(package))
|
||||
}
|
||||
|
||||
pub async fn ensure_installed(&self) -> Result<P::Installed, P::Error> {
|
||||
if let Some(package) = self.resolve_cached().await? {
|
||||
return Ok(package);
|
||||
}
|
||||
|
||||
let platform = PackagePlatform::detect_current().map_err(P::Error::from)?;
|
||||
let manifest = self.fetch_release_manifest().await?;
|
||||
if self.config.package().release_version(&manifest) != self.config.package().version() {
|
||||
return Err(PackageManagerError::UnexpectedPackageVersion {
|
||||
expected: self.config.package().version().to_string(),
|
||||
actual: self.config.package().release_version(&manifest).to_string(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
|
||||
let platform_archive = self
|
||||
.config
|
||||
.package()
|
||||
.platform_archive(&manifest, platform)?;
|
||||
let archive_url = self
|
||||
.config
|
||||
.package()
|
||||
.archive_url(&platform_archive)
|
||||
.map_err(P::Error::from)?;
|
||||
let archive_bytes = self.download_bytes(&archive_url).await?;
|
||||
verify_sha256(&archive_bytes, &platform_archive.sha256).map_err(P::Error::from)?;
|
||||
|
||||
let install_dir = self
|
||||
.config
|
||||
.package()
|
||||
.install_dir(&self.config.cache_root(), platform);
|
||||
if fs::try_exists(&install_dir)
|
||||
.await
|
||||
.map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to read {}", install_dir.display()),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)?
|
||||
{
|
||||
fs::remove_dir_all(&install_dir)
|
||||
.await
|
||||
.map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to remove {}", install_dir.display()),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)?;
|
||||
}
|
||||
|
||||
let cache_root = self.config.cache_root();
|
||||
fs::create_dir_all(&cache_root)
|
||||
.await
|
||||
.map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to create {}", cache_root.display()),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)?;
|
||||
|
||||
let staging_dir = tempdir_in(&cache_root)
|
||||
.map_err(|source| PackageManagerError::Io {
|
||||
context: format!(
|
||||
"failed to create staging directory in {}",
|
||||
cache_root.display()
|
||||
),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)?;
|
||||
let archive_path = staging_dir.path().join(&platform_archive.archive);
|
||||
fs::write(&archive_path, &archive_bytes)
|
||||
.await
|
||||
.map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to write {}", archive_path.display()),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)?;
|
||||
let extraction_root = staging_dir.path().join("extract");
|
||||
fs::create_dir_all(&extraction_root)
|
||||
.await
|
||||
.map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to create {}", extraction_root.display()),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)?;
|
||||
|
||||
extract_archive(&archive_path, &extraction_root, platform_archive.format)
|
||||
.map_err(P::Error::from)?;
|
||||
let extracted_root = self
|
||||
.config
|
||||
.package()
|
||||
.detect_extracted_root(&extraction_root)?;
|
||||
let package = self
|
||||
.config
|
||||
.package()
|
||||
.load_installed(extracted_root.clone(), platform)?;
|
||||
if self.config.package().installed_version(&package) != self.config.package().version() {
|
||||
return Err(PackageManagerError::UnexpectedPackageVersion {
|
||||
expected: self.config.package().version().to_string(),
|
||||
actual: self
|
||||
.config
|
||||
.package()
|
||||
.installed_version(&package)
|
||||
.to_string(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
|
||||
if let Some(parent) = install_dir.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.await
|
||||
.map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to create {}", parent.display()),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)?;
|
||||
}
|
||||
|
||||
fs::rename(&extracted_root, &install_dir)
|
||||
.await
|
||||
.map_err(|source| PackageManagerError::Io {
|
||||
context: format!(
|
||||
"failed to move {} to {}",
|
||||
extracted_root.display(),
|
||||
install_dir.display()
|
||||
),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)?;
|
||||
|
||||
self.config.package().load_installed(install_dir, platform)
|
||||
}
|
||||
|
||||
async fn fetch_release_manifest(&self) -> Result<P::ReleaseManifest, P::Error> {
|
||||
let manifest_url = self
|
||||
.config
|
||||
.package()
|
||||
.manifest_url()
|
||||
.map_err(P::Error::from)?;
|
||||
let response = self
|
||||
.client
|
||||
.get(manifest_url.clone())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|source| PackageManagerError::Http {
|
||||
context: format!("failed to fetch {manifest_url}"),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)?
|
||||
.error_for_status()
|
||||
.map_err(|source| PackageManagerError::Http {
|
||||
context: format!("manifest request failed for {manifest_url}"),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)?;
|
||||
|
||||
response
|
||||
.json::<P::ReleaseManifest>()
|
||||
.await
|
||||
.map_err(|source| PackageManagerError::Http {
|
||||
context: format!("failed to decode manifest from {manifest_url}"),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)
|
||||
}
|
||||
|
||||
async fn download_bytes(&self, url: &Url) -> Result<Vec<u8>, P::Error> {
|
||||
let response = self
|
||||
.client
|
||||
.get(url.clone())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|source| PackageManagerError::Http {
|
||||
context: format!("failed to download {url}"),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)?
|
||||
.error_for_status()
|
||||
.map_err(|source| PackageManagerError::Http {
|
||||
context: format!("archive request failed for {url}"),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)?;
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|source| PackageManagerError::Http {
|
||||
context: format!("failed to read response body for {url}"),
|
||||
source,
|
||||
})
|
||||
.map_err(P::Error::from)?;
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ManagedPackage: Clone {
|
||||
type Error: From<PackageManagerError>;
|
||||
type Installed: Clone;
|
||||
type ReleaseManifest: DeserializeOwned;
|
||||
|
||||
fn default_cache_root_relative(&self) -> &str;
|
||||
fn version(&self) -> &str;
|
||||
fn manifest_url(&self) -> Result<Url, PackageManagerError>;
|
||||
fn archive_url(&self, archive: &PackageReleaseArchive) -> Result<Url, PackageManagerError>;
|
||||
fn release_version<'a>(&self, manifest: &'a Self::ReleaseManifest) -> &'a str;
|
||||
fn platform_archive(
|
||||
&self,
|
||||
manifest: &Self::ReleaseManifest,
|
||||
platform: PackagePlatform,
|
||||
) -> Result<PackageReleaseArchive, Self::Error>;
|
||||
fn install_dir(&self, cache_root: &Path, platform: PackagePlatform) -> PathBuf;
|
||||
fn installed_version<'a>(&self, package: &'a Self::Installed) -> &'a str;
|
||||
fn load_installed(
|
||||
&self,
|
||||
root_dir: PathBuf,
|
||||
platform: PackagePlatform,
|
||||
) -> Result<Self::Installed, Self::Error>;
|
||||
|
||||
fn detect_extracted_root(&self, extraction_root: &Path) -> Result<PathBuf, Self::Error> {
|
||||
detect_single_package_root(extraction_root).map_err(Self::Error::from)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum PackagePlatform {
|
||||
DarwinArm64,
|
||||
DarwinX64,
|
||||
LinuxArm64,
|
||||
LinuxX64,
|
||||
WindowsArm64,
|
||||
WindowsX64,
|
||||
}
|
||||
|
||||
impl PackagePlatform {
|
||||
pub fn detect_current() -> Result<Self, PackageManagerError> {
|
||||
match (std::env::consts::OS, std::env::consts::ARCH) {
|
||||
("macos", "aarch64") | ("macos", "arm64") => Ok(Self::DarwinArm64),
|
||||
("macos", "x86_64") => Ok(Self::DarwinX64),
|
||||
("linux", "aarch64") | ("linux", "arm64") => Ok(Self::LinuxArm64),
|
||||
("linux", "x86_64") => Ok(Self::LinuxX64),
|
||||
("windows", "aarch64") | ("windows", "arm64") => Ok(Self::WindowsArm64),
|
||||
("windows", "x86_64") => Ok(Self::WindowsX64),
|
||||
(os, arch) => Err(PackageManagerError::UnsupportedPlatform {
|
||||
os: os.to_string(),
|
||||
arch: arch.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::DarwinArm64 => "darwin-arm64",
|
||||
Self::DarwinX64 => "darwin-x64",
|
||||
Self::LinuxArm64 => "linux-arm64",
|
||||
Self::LinuxX64 => "linux-x64",
|
||||
Self::WindowsArm64 => "windows-arm64",
|
||||
Self::WindowsX64 => "windows-x64",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
|
||||
pub struct PackageReleaseArchive {
|
||||
pub archive: String,
|
||||
pub sha256: String,
|
||||
pub format: ArchiveFormat,
|
||||
pub size_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
|
||||
pub enum ArchiveFormat {
|
||||
#[serde(rename = "zip")]
|
||||
Zip,
|
||||
#[serde(rename = "tar.gz")]
|
||||
TarGz,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum PackageManagerError {
|
||||
#[error("unsupported platform: {os}-{arch}")]
|
||||
UnsupportedPlatform { os: String, arch: String },
|
||||
#[error("invalid release base url")]
|
||||
InvalidBaseUrl(#[source] url::ParseError),
|
||||
#[error("{context}")]
|
||||
Http {
|
||||
context: String,
|
||||
#[source]
|
||||
source: reqwest::Error,
|
||||
},
|
||||
#[error("{context}")]
|
||||
Io {
|
||||
context: String,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("missing platform entry `{0}` in release manifest")]
|
||||
MissingPlatform(String),
|
||||
#[error("unexpected package version: expected `{expected}`, got `{actual}`")]
|
||||
UnexpectedPackageVersion { expected: String, actual: String },
|
||||
#[error("checksum mismatch: expected `{expected}`, got `{actual}`")]
|
||||
ChecksumMismatch { expected: String, actual: String },
|
||||
#[error("archive extraction failed: {0}")]
|
||||
ArchiveExtraction(String),
|
||||
#[error("archive did not contain a package root with manifest.json under {0}")]
|
||||
MissingPackageRoot(PathBuf),
|
||||
}
|
||||
|
||||
pub fn detect_single_package_root(extraction_root: &Path) -> Result<PathBuf, PackageManagerError> {
|
||||
let direct_manifest = extraction_root.join("manifest.json");
|
||||
if direct_manifest.exists() {
|
||||
return Ok(extraction_root.to_path_buf());
|
||||
}
|
||||
|
||||
let mut directory_candidates = Vec::new();
|
||||
for entry in std::fs::read_dir(extraction_root).map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to read {}", extraction_root.display()),
|
||||
source,
|
||||
})? {
|
||||
let entry = entry.map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to read entry in {}", extraction_root.display()),
|
||||
source,
|
||||
})?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
directory_candidates.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
if directory_candidates.len() == 1 {
|
||||
let candidate = &directory_candidates[0];
|
||||
if candidate.join("manifest.json").exists() {
|
||||
return Ok(candidate.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Err(PackageManagerError::MissingPackageRoot(
|
||||
extraction_root.to_path_buf(),
|
||||
))
|
||||
}
|
||||
|
||||
fn verify_sha256(bytes: &[u8], expected: &str) -> Result<(), PackageManagerError> {
|
||||
let actual = format!("{:x}", Sha256::digest(bytes));
|
||||
if actual == expected.to_ascii_lowercase() {
|
||||
return Ok(());
|
||||
}
|
||||
Err(PackageManagerError::ChecksumMismatch {
|
||||
expected: expected.to_string(),
|
||||
actual,
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_archive(
|
||||
archive_path: &Path,
|
||||
destination: &Path,
|
||||
format: ArchiveFormat,
|
||||
) -> Result<(), PackageManagerError> {
|
||||
match format {
|
||||
ArchiveFormat::Zip => extract_zip_archive(archive_path, destination),
|
||||
ArchiveFormat::TarGz => extract_tar_gz_archive(archive_path, destination),
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_zip_archive(archive_path: &Path, destination: &Path) -> Result<(), PackageManagerError> {
|
||||
let file = File::open(archive_path).map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to open {}", archive_path.display()),
|
||||
source,
|
||||
})?;
|
||||
let mut archive = ZipArchive::new(file)
|
||||
.map_err(|error| PackageManagerError::ArchiveExtraction(error.to_string()))?;
|
||||
for index in 0..archive.len() {
|
||||
let mut entry = archive
|
||||
.by_index(index)
|
||||
.map_err(|error| PackageManagerError::ArchiveExtraction(error.to_string()))?;
|
||||
let Some(relative_path) = entry.enclosed_name() else {
|
||||
return Err(PackageManagerError::ArchiveExtraction(format!(
|
||||
"zip entry `{}` escapes extraction root",
|
||||
entry.name()
|
||||
)));
|
||||
};
|
||||
let output_path = destination.join(relative_path);
|
||||
if entry.is_dir() {
|
||||
std::fs::create_dir_all(&output_path).map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to create {}", output_path.display()),
|
||||
source,
|
||||
})?;
|
||||
continue;
|
||||
}
|
||||
if let Some(parent) = output_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to create {}", parent.display()),
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
let mut output = File::create(&output_path).map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to create {}", output_path.display()),
|
||||
source,
|
||||
})?;
|
||||
std::io::copy(&mut entry, &mut output).map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to write {}", output_path.display()),
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_tar_gz_archive(
|
||||
archive_path: &Path,
|
||||
destination: &Path,
|
||||
) -> Result<(), PackageManagerError> {
|
||||
let file = File::open(archive_path).map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to open {}", archive_path.display()),
|
||||
source,
|
||||
})?;
|
||||
let decoder = GzDecoder::new(file);
|
||||
let mut archive = Archive::new(decoder);
|
||||
for entry in archive
|
||||
.entries()
|
||||
.map_err(|error| PackageManagerError::ArchiveExtraction(error.to_string()))?
|
||||
{
|
||||
let mut entry =
|
||||
entry.map_err(|error| PackageManagerError::ArchiveExtraction(error.to_string()))?;
|
||||
let path = entry
|
||||
.path()
|
||||
.map_err(|error| PackageManagerError::ArchiveExtraction(error.to_string()))?;
|
||||
let output_path = safe_extract_path(destination, path.as_ref())?;
|
||||
if let Some(parent) = output_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|source| PackageManagerError::Io {
|
||||
context: format!("failed to create {}", parent.display()),
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
entry
|
||||
.unpack(&output_path)
|
||||
.map_err(|error| PackageManagerError::ArchiveExtraction(error.to_string()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn safe_extract_path(root: &Path, relative_path: &Path) -> Result<PathBuf, PackageManagerError> {
|
||||
let mut clean_relative = PathBuf::new();
|
||||
for component in relative_path.components() {
|
||||
match component {
|
||||
Component::Normal(segment) => clean_relative.push(segment),
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
|
||||
return Err(PackageManagerError::ArchiveExtraction(format!(
|
||||
"entry `{}` escapes extraction root",
|
||||
relative_path.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if clean_relative.as_os_str().is_empty() {
|
||||
return Err(PackageManagerError::ArchiveExtraction(
|
||||
"archive entry had an empty path".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(root.join(clean_relative))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde::Deserialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Cursor;
|
||||
use std::io::Write;
|
||||
use tempfile::TempDir;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
use zip::ZipWriter;
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct TestPackage {
|
||||
base_url: Url,
|
||||
version: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
struct TestReleaseManifest {
|
||||
package_version: String,
|
||||
platforms: BTreeMap<String, PackageReleaseArchive>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct TestInstalledPackage {
|
||||
version: String,
|
||||
platform: PackagePlatform,
|
||||
root_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl ManagedPackage for TestPackage {
|
||||
type Error = PackageManagerError;
|
||||
type Installed = TestInstalledPackage;
|
||||
type ReleaseManifest = TestReleaseManifest;
|
||||
|
||||
fn default_cache_root_relative(&self) -> &str {
|
||||
"packages/test-package"
|
||||
}
|
||||
|
||||
fn version(&self) -> &str {
|
||||
&self.version
|
||||
}
|
||||
|
||||
fn manifest_url(&self) -> Result<Url, PackageManagerError> {
|
||||
self.base_url
|
||||
.join(&format!("test-package-v{}-manifest.json", self.version))
|
||||
.map_err(PackageManagerError::InvalidBaseUrl)
|
||||
}
|
||||
|
||||
fn archive_url(&self, archive: &PackageReleaseArchive) -> Result<Url, PackageManagerError> {
|
||||
self.base_url
|
||||
.join(&archive.archive)
|
||||
.map_err(PackageManagerError::InvalidBaseUrl)
|
||||
}
|
||||
|
||||
fn release_version<'a>(&self, manifest: &'a Self::ReleaseManifest) -> &'a str {
|
||||
&manifest.package_version
|
||||
}
|
||||
|
||||
fn platform_archive(
|
||||
&self,
|
||||
manifest: &Self::ReleaseManifest,
|
||||
platform: PackagePlatform,
|
||||
) -> Result<PackageReleaseArchive, Self::Error> {
|
||||
manifest
|
||||
.platforms
|
||||
.get(platform.as_str())
|
||||
.cloned()
|
||||
.ok_or_else(|| PackageManagerError::MissingPlatform(platform.as_str().to_string()))
|
||||
}
|
||||
|
||||
fn install_dir(&self, cache_root: &Path, platform: PackagePlatform) -> PathBuf {
|
||||
cache_root.join(self.version()).join(platform.as_str())
|
||||
}
|
||||
|
||||
fn installed_version<'a>(&self, package: &'a Self::Installed) -> &'a str {
|
||||
&package.version
|
||||
}
|
||||
|
||||
fn load_installed(
|
||||
&self,
|
||||
root_dir: PathBuf,
|
||||
platform: PackagePlatform,
|
||||
) -> Result<Self::Installed, Self::Error> {
|
||||
let version =
|
||||
std::fs::read_to_string(root_dir.join("manifest.json")).map_err(|source| {
|
||||
PackageManagerError::Io {
|
||||
context: format!(
|
||||
"failed to read {}",
|
||||
root_dir.join("manifest.json").display()
|
||||
),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
Ok(TestInstalledPackage {
|
||||
version: version.trim().to_string(),
|
||||
platform,
|
||||
root_dir,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_installed_downloads_and_extracts_zip_package() {
|
||||
let server = MockServer::start().await;
|
||||
let version = "0.1.0";
|
||||
let platform = PackagePlatform::detect_current().unwrap_or_else(|error| panic!("{error}"));
|
||||
let archive_name = format!("test-package-v{version}-{}.zip", platform.as_str());
|
||||
let archive_bytes = build_zip_archive(version);
|
||||
let archive_sha = format!("{:x}", Sha256::digest(&archive_bytes));
|
||||
let manifest = serde_json::json!({
|
||||
"package_version": version,
|
||||
"platforms": {
|
||||
platform.as_str(): {
|
||||
"archive": archive_name,
|
||||
"sha256": archive_sha,
|
||||
"format": "zip",
|
||||
"size_bytes": archive_bytes.len(),
|
||||
}
|
||||
}
|
||||
});
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/test-package-v{version}-manifest.json")))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(&manifest))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/{archive_name}")))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_bytes(archive_bytes))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let codex_home = TempDir::new().unwrap_or_else(|error| panic!("{error}"));
|
||||
let package = TestPackage {
|
||||
base_url: Url::parse(&format!("{}/", server.uri()))
|
||||
.unwrap_or_else(|error| panic!("{error}")),
|
||||
version: version.to_string(),
|
||||
};
|
||||
let manager = PackageManager::new(PackageManagerConfig::new(
|
||||
codex_home.path().to_path_buf(),
|
||||
package,
|
||||
));
|
||||
|
||||
let installed = manager
|
||||
.ensure_installed()
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
|
||||
assert_eq!(
|
||||
installed,
|
||||
TestInstalledPackage {
|
||||
version: version.to_string(),
|
||||
platform,
|
||||
root_dir: codex_home
|
||||
.path()
|
||||
.join("packages")
|
||||
.join("test-package")
|
||||
.join(version)
|
||||
.join(platform.as_str()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tar_gz_extraction_supports_default_package_root_detection() {
|
||||
let temp = TempDir::new().unwrap_or_else(|error| panic!("{error}"));
|
||||
let archive_path = temp.path().join("package.tar.gz");
|
||||
let extraction_root = temp.path().join("extract");
|
||||
std::fs::create_dir_all(&extraction_root).unwrap_or_else(|error| panic!("{error}"));
|
||||
write_tar_gz_archive(&archive_path, "0.2.0");
|
||||
|
||||
extract_archive(&archive_path, &extraction_root, ArchiveFormat::TarGz)
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
let package_root =
|
||||
detect_single_package_root(&extraction_root).unwrap_or_else(|error| panic!("{error}"));
|
||||
|
||||
assert!(package_root.join("manifest.json").exists());
|
||||
}
|
||||
|
||||
fn build_zip_archive(version: &str) -> Vec<u8> {
|
||||
let mut bytes = Cursor::new(Vec::new());
|
||||
{
|
||||
let mut zip = ZipWriter::new(&mut bytes);
|
||||
let options = SimpleFileOptions::default();
|
||||
zip.start_file("test-package/manifest.json", options)
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.write_all(version.as_bytes())
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
zip.finish().unwrap_or_else(|error| panic!("{error}"));
|
||||
}
|
||||
bytes.into_inner()
|
||||
}
|
||||
|
||||
fn write_tar_gz_archive(archive_path: &Path, version: &str) {
|
||||
let file = File::create(archive_path).unwrap_or_else(|error| panic!("{error}"));
|
||||
let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default());
|
||||
let mut builder = tar::Builder::new(encoder);
|
||||
|
||||
append_tar_file(
|
||||
&mut builder,
|
||||
"test-package/manifest.json",
|
||||
version.as_bytes(),
|
||||
);
|
||||
builder.finish().unwrap_or_else(|error| panic!("{error}"));
|
||||
}
|
||||
|
||||
fn append_tar_file(
|
||||
builder: &mut tar::Builder<flate2::write::GzEncoder<File>>,
|
||||
path: &str,
|
||||
contents: &[u8],
|
||||
) {
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(contents.len() as u64);
|
||||
header.set_mode(0o755);
|
||||
header.set_cksum();
|
||||
builder
|
||||
.append_data(&mut header, path, contents)
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user