Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ crossterm = "0.28"
ratatui = "0.29"
unicode-width = "0.2"
pulldown-cmark = { version = "0.11", default-features = false }
math-core = { version = "0.6", default-features = false }
syntect = { version = "5", default-features = false, features = ["default-syntaxes", "default-themes", "regex-onig"] }
once_cell = "1"
libc = "0.2"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ use crate::agentic::tools::ToolPathOperation;
use crate::util::errors::{OpenBitFunError, OpenBitFunResult};
use async_trait::async_trait;
use openbitfun_office_documents::{
collect_image_sources, guide, operation_recovery_hint, operation_schema,
validate_operation_headers, Detail, ImageSources, OfficeDocument, OfficeError, OfficeFormat,
OpenOptions, ReadRequest,
collect_image_sources, guide, operation_recovery_hint, operation_schema, settings_schema,
validate_font_input, validate_operation_headers, Detail, ImageSources, OfficeDocument,
OfficeError, OfficeFormat, OpenOptions, ReadRequest,
};
use serde_json::{json, Value};

Expand Down Expand Up @@ -144,6 +144,9 @@ impl GenOfficeFileTool {
return Err("`sheet` and `range` only apply to .xlsx files".to_string());
}
validate_operation_headers(format, &operations).map_err(Self::office_error_message)?;
if let Some(settings) = input.get("settings") {
validate_font_input(settings, "settings").map_err(Self::office_error_message)?;
}
Ok((action, format))
}

Expand Down Expand Up @@ -265,7 +268,7 @@ impl GenOfficeFileTool {
format.as_str(),
document.revision()
);
text.push_str("\nFile written; visual rendering was not performed by this tool.");
text.push_str("\nGeneration complete. Font names were written as supplied; fonts were not embedded or verified in the target viewer. Use read to verify changed content and references. Visual rendering is optional and was not performed; do not probe/install/repair an Office package or build a renderer to complete this tool workflow.");
if format == OfficeFormat::Pptx {
text.push_str(" Layout warnings are geometric/text-size estimates.");
}
Expand All @@ -291,6 +294,8 @@ impl GenOfficeFileTool {

Workflow: call guide once for the format, create a first batch, then edit in small sections/slides/row chunks. Send text as ordinary Unicode strings; the tool writes UTF-8 XML. Do not manually re-encode, HTML-escape, or replace non-Latin characters.

Fonts: omit font choices unless the user or template requires a particular family; a request for a professional appearance alone does not require custom fonts. Names must match the intended viewer, not the generation host's OS. Fonts are neither embedded nor verified. DOCX/PPTX separate global Latin/East Asian defaults; local font overrides all scripts; use local latin_font/east_asia_font/complex_script_font for one script. XLSX uses style.font_name. Read the guide for inheritance and resetting overrides. lang is a language hint, not encoding or a font repair.

Actions:
- guide: pass format (docx | xlsx | pptx) for fields, defaults and examples.
- create: absolute file_path, optional settings and operations. Existing files require explicit overwrite:true.
Expand All @@ -300,11 +305,11 @@ Actions:
Each operation is {op, ...fields}. Operations run in array order against the result of earlier operations. Block/slide/element indexes are 0-based and shift after insert/delete; move `to` is the position after removing the source. A failed batch writes nothing. Use the error's field/index guidance, read the target if uncertain, and send corrected input; do not repeat an unchanged failed call or replay a successful batch.

Common edits:
- DOCX: insert_blocks {markdown} to append; replace_blocks {index, count?, markdown} to replace.
- XLSX: add_sheet {name, rows}; append_rows {sheet, rows}; set_cells {sheet, start, rows}; format_range {sheet, range, style}.
- DOCX: insert_blocks {markdown} to append; replace_blocks {index, count?, markdown} to replace. Markdown math becomes editable Word equations; read the guide for paper and formula inputs.
- XLSX: add_sheet {name, rows}; set_cells {sheet, start, rows}; format_range {sheet, range, style}; add_chart {sheet, chart} for native column/bar/line/pie charts; replace_chart {sheet, index, chart} / delete_chart {sheet, index}.
- PPTX: insert_slides {slides} to append; replace_slide {index, slide} to replace one complete slide; update_slide {index, update_elements:[{index, ...changed fields}]} for element patches (no nested op); update_slide {index, elements} to replace all elements while keeping slide metadata. Use set_theme for deck defaults. Canvas: 1280×720 px, or 960×720 for 4:3.

PPTX warnings estimate text overflow and off-canvas geometry; read/audit is not visual rendering. If visual verification is needed, use an available renderer and report what was actually checked. An unavailable preview renderer does not prevent file generation. For CJK text, choose an east_asia_font available on the target viewer; language is inferred unless lang is supplied in the guide's supported locations.
Completion for ALL formats: finish edits, inspect warnings, then read back changed content and references. This is structural verification, not visual rendering; PPTX layout warnings are estimates. External rendering is optional, not a generation dependency. Do not automatically probe/install/repair LibreOffice/soffice or other Office packages, or build a substitute renderer. When visual inspection is required, use a known available viewer on the file-owning machine; otherwise deliver and state the unverified scope. Never infer compatibility with one viewer from another. Choose fonts for the target environment; DOCX/PPTX language can be overridden with lang.

Images accept absolute workspace paths or data URLs (PNG/JPEG/GIF/BMP); download web images first. Reuse embedded asset names from read. Only files created by this tool are editable. External modifications are preserved by refusing edits; discard_external_changes:true explicitly rebuilds from the last embedded model and drops those modifications."#
.to_string()
Expand Down Expand Up @@ -348,11 +353,7 @@ impl Tool for GenOfficeFileTool {
"description": "create/edit: ordered atomic batch. Each item needs op AND that operation's fields. Indexes refer to the state after preceding operations. Prefer in-place replacement for content edits.",
"items": operation_schema()
},
"settings": {
"type": "object",
"description": "create only: document defaults. docx: page, styles, header, footer, properties. pptx: slide_size (16:9 | 4:3), theme {font, east_asia_font, text_color, lang?}, properties. xlsx: properties. lang is an optional BCP 47 override; omitted PPTX language follows the text.",
"additionalProperties": true
},
"settings": settings_schema(),
"overwrite": {
"type": "boolean",
"description": "create only: replace an existing file at file_path."
Expand Down Expand Up @@ -385,6 +386,7 @@ impl Tool for GenOfficeFileTool {
"changes": { "type": "array", "items": { "type": "string" } },
"warnings": { "type": "array", "items": { "type": "string" } },
"visual_rendered": { "type": "boolean" },
"fonts_verified": { "type": "boolean", "description":"Whether this tool verified fonts in the target viewer. Always false for structural generation/read." },
"view": { "type": "object" },
"guide": { "type": "string" }
},
Expand Down Expand Up @@ -587,13 +589,15 @@ impl Tool for GenOfficeFileTool {
view["audit"] = json!(findings);
}
}
let text = serde_json::to_string_pretty(&view).unwrap_or_default();
let text = format!("{}\nStructural read only; fonts and visual rendering were not verified in the target viewer.", serde_json::to_string_pretty(&view).unwrap_or_default());
return Ok(vec![ToolResult::ok(
json!({
"action": "read",
"format": format.as_str(),
"file_path": resolved.logical_path,
"view": view,
"visual_rendered": false,
"fonts_verified": false,
}),
Some(text),
)]);
Expand Down Expand Up @@ -674,6 +678,7 @@ impl Tool for GenOfficeFileTool {
"changes": report.changes,
"warnings": report.warnings,
"visual_rendered": false,
"fonts_verified": false,
}),
Some(text),
)])
Expand Down Expand Up @@ -904,6 +909,9 @@ mod tests {
json!({"action": "read", "file_path": "/a/b.docx", "operations": [{"op": "x"}]}),
json!({"action": "write", "file_path": "/a/b.docx"}),
json!({"action": "guide"}),
json!({"action":"create","file_path":"/a/b.docx","settings":{"styles":{"east_asia_font":" "}}}),
json!({"action":"edit","file_path":"/a/b.xlsx","operations":[{"op":"format_range","range":"A1","style":{"font_name":42}}]}),
json!({"action":"create","file_path":"/a/b.pptx","operations":[{"op":"insert_slides","slides":[{"elements":[{"type":"text","text":"中文","latin_font":"bad\nfont"}]}]}]}),
];
for case in cases {
assert!(GenOfficeFileTool::check_input(&case).is_err(), "{case}");
Expand Down Expand Up @@ -1023,4 +1031,119 @@ mod tests {
assert_eq!(read["view"]["slides"][0]["notes"], "Keep notes");
}
}

#[cfg(unix)]
#[tokio::test]
async fn all_formats_complete_through_workspace_io_without_an_office_installation() {
for remote in [false, true] {
let fs = Arc::new(MemoryFs::default());
let context = context(fs.clone(), remote);
let tool = GenOfficeFileTool::new();
for (format, initial, edit, bad) in [
(
"docx",
json!([
{"op":"insert_blocks","blocks":[{"type":"paragraph","runs":[{"text":"旧","bold":true,"latin_font":"Arial"},{"text":"项目"}]}]},
{"op":"insert_blocks","markdown":"论文公式 $E=mc^2$"}
]),
json!([
{"op":"replace_text","find":"旧项目","replace":"新产品"}
]),
json!({"op":"replace_blocks","index":99,"markdown":"invalid"}),
),
(
"xlsx",
json!([
{"op":"add_sheet","name":"数据","rows":[["项目","数量"],["中文",10],["日本語",20]]},
{"op":"format_range","range":"A1:B3","style":{"font_name":"Required Font"}},
{"op":"add_chart","chart":{"type":"column","anchor":"D1","series":[{"categories":{"range":"A2:A3"},"values":{"range":"B2:B3"}}]}}
]),
json!([
{"op":"set_cells","start":"B2","rows":[[{"style":{"bold":true}}]]},
{"op":"format_range","range":"A1:B3","reset_font":true,"style":{}},
{"op":"rename_sheet","name":"新产品"}
]),
json!({"op":"add_chart","chart":{"type":"column","anchor":"D1","series":[{"categories":{"range":"A1:A3"},"values":{"range":"B1:B2"}}]}}),
),
(
"pptx",
json!([
{"op":"insert_slides","slides":[{"title":"旧项目","elements":[]}]}
]),
json!([
{"op":"replace_slide","index":0,"slide":{"title":"新产品","elements":[{"type":"text","x":40,"y":40,"w":900,"h":100,"text":"新产品 中文","latin_font":"Arial"}]}}
]),
json!({"op":"delete_slides","index":99}),
),
] {
let path = format!("/remote/workspace/out/workflow.{format}");
let guide = data(
tool.call_impl(&json!({"action":"guide","format":format}), &context)
.await
.unwrap(),
);
let guide = guide["guide"].as_str().unwrap();
assert!(guide.contains("default completion path"));
assert!(guide.contains("Do not automatically probe, install or repair"));
for (action, operations) in [("create", initial), ("edit", edit)] {
let results = tool
.call_impl(
&json!({"action":action,"file_path":path,"operations":operations}),
&context,
)
.await
.unwrap();
match &results[0] {
ToolResult::Result {
data,
result_for_assistant: Some(text),
..
} => {
assert_eq!(data["visual_rendered"], false);
assert_eq!(data["fonts_verified"], false);
assert!(text.contains("Generation complete"));
assert!(text.contains("Use read"));
}
other => panic!("missing completion evidence: {other:?}"),
}
}
let saved = fs.files.lock().unwrap()[&path].clone();
let failure = tool
.call_impl(
&json!({"action":"edit","file_path":path,"operations":[bad]}),
&context,
)
.await
.unwrap_err();
assert!(failure.to_string().contains("Nothing was written"));
assert_eq!(fs.files.lock().unwrap()[&path], saved);
let mut input = json!({"action":"read","file_path":path,"detail":"full"});
if format == "xlsx" {
input["sheet"] = json!("新产品");
}
let read = data(tool.call_impl(&input, &context).await.unwrap());
assert_eq!(read["fonts_verified"], false);
assert_eq!(read["visual_rendered"], false);
assert!(read["view"].to_string().contains("新产品"));
if format == "docx" {
assert_eq!(read["view"]["blocks"][0]["runs"][0]["bold"], true);
assert_eq!(read["view"]["blocks"][0]["runs"][0]["latin_font"], "Arial");
assert_eq!(read["view"]["blocks"][1]["runs"][1]["math"], "inline");
assert_eq!(read["view"]["blocks"][1]["runs"][1]["text"], "E=mc^2");
let invalid = tool.call_impl(
&json!({"action":"edit","file_path":path,"operations":[{"op":"insert_blocks","markdown":r"$\unknownCommand{x}$"}]}),
&context,
).await.unwrap_err();
assert!(invalid.to_string().contains("Nothing was written"));
assert_eq!(fs.files.lock().unwrap()[&path], saved);
} else if format == "xlsx" {
assert_eq!(read["view"]["rows"][1]["cells"][1], 10);
assert_eq!(
read["view"]["charts"][0]["series"][0]["values"]["sheet"],
"新产品"
);
}
}
}
}
}
4 changes: 3 additions & 1 deletion src/crates/execution/office-documents/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ shapes (Markdown/blocks for Word, the workbook cell DSL for Excel, the
- Pure in-memory bytes: no file, network, or process IO. The tool in
`assembly/core` resolves paths, loads referenced images through the
session's workspace file system (local or remote), and writes the result.
- Depends only on serialization, zip, Markdown parsing, and hashing crates.
- Depends only on serialization, zip, Markdown/math/XML parsing, and hashing crates.
Math conversion stays in memory; unsupported constructs fail without spawning
an Office or TeX process.
Do not add Core, services, or runtime dependencies.

## Contracts
Expand Down
5 changes: 2 additions & 3 deletions src/crates/execution/office-documents/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,12 @@ crate-type = ["rlib"]
[dependencies]
base64 = { workspace = true }
pulldown-cmark = { workspace = true }
math-core = { workspace = true }
roxmltree = { workspace = true, features = ["std"] }
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
zip = { workspace = true }

[dev-dependencies]
roxmltree = { workspace = true, features = ["std"] }

[lints]
workspace = true
Loading
Loading