From ef1beb39728716ebfbec2e1dc5d845b163787d2b Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Fri, 6 Nov 2026 14:55:30 +0800 Subject: [PATCH 1/2] feat(office): add Excel charts and Word equations --- Cargo.lock | 36 ++ Cargo.toml | 1 + .../implementations/gen_office_file_tool.rs | 117 +++++- .../execution/office-documents/AGENTS.md | 4 +- .../execution/office-documents/Cargo.toml | 5 +- .../execution/office-documents/src/common.rs | 24 ++ .../office-documents/src/docx/markdown.rs | 43 ++- .../office-documents/src/docx/math.rs | 184 +++++++++ .../office-documents/src/docx/mod.rs | 1 + .../office-documents/src/docx/model.rs | 68 +++- .../office-documents/src/docx/ops.rs | 167 +++++++-- .../office-documents/src/docx/render.rs | 43 ++- .../execution/office-documents/src/guide.rs | 18 +- .../office-documents/src/guides/docx.md | 22 +- .../src/guides/verification.md | 7 + .../office-documents/src/guides/xlsx.md | 17 +- .../src/operation_contract.rs | 39 +- .../office-documents/src/pptx/model.rs | 10 + .../office-documents/src/pptx/render.rs | 15 +- .../office-documents/src/xlsx/chart_render.rs | 239 ++++++++++++ .../office-documents/src/xlsx/charts.rs | 152 ++++++++ .../office-documents/src/xlsx/mod.rs | 9 +- .../office-documents/src/xlsx/model.rs | 17 +- .../office-documents/src/xlsx/ops.rs | 120 +++++- .../office-documents/src/xlsx/render.rs | 18 +- .../office-documents/tests/end_to_end.rs | 353 ++++++++++++++++++ 26 files changed, 1645 insertions(+), 84 deletions(-) create mode 100644 src/crates/execution/office-documents/src/docx/math.rs create mode 100644 src/crates/execution/office-documents/src/guides/verification.md create mode 100644 src/crates/execution/office-documents/src/xlsx/chart_render.rs create mode 100644 src/crates/execution/office-documents/src/xlsx/charts.rs diff --git a/Cargo.lock b/Cargo.lock index db0762a26e..d953651bb8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4796,6 +4796,35 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8863b587001c1b9a8a4e36008cebc6b3612cb1226fe2de94858e06092687b608" +[[package]] +name = "math-core" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366819bd33e32c670b4bfcfa4df435095b6a6a67ef459b6b82de90ecb844c1d4" +dependencies = [ + "math-core-renderer-internal", + "memchr", + "phf 0.13.1", + "rustc-hash 2.1.3", + "static_assertions", + "strum 0.28.0", + "strum_macros 0.28.0", +] + +[[package]] +name = "math-core-renderer-internal" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97b035992b263a6ec499b9bacfedc2e2c9c390381fc9c36c1fbe6d84c20d68da" +dependencies = [ + "bitflags 2.11.1", + "dtoa", + "stable-arena", + "static_assertions", + "strum 0.28.0", + "strum_macros 0.28.0", +] + [[package]] name = "md-5" version = "0.10.6" @@ -6387,6 +6416,7 @@ name = "openbitfun-office-documents" version = "1.0.9" dependencies = [ "base64 0.22.1", + "math-core", "pulldown-cmark", "roxmltree", "serde", @@ -9795,6 +9825,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "stable-arena" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a6572b16faf73db61fab0b1d848ce108e20d026dc20dcb043a3f83c73ad1c09" + [[package]] name = "stable_deref_trait" version = "1.2.1" diff --git a/Cargo.toml b/Cargo.toml index 7003b594c6..4eaa7094fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/gen_office_file_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/gen_office_file_tool.rs index 2b33e05b58..3d167a5008 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/gen_office_file_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/gen_office_file_tool.rs @@ -265,7 +265,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. 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."); } @@ -300,11 +300,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() @@ -1023,4 +1023,113 @@ 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},{"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":"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":"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":[]}} + ]), + 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!(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!(read["view"].to_string().contains("新产品")); + if format == "docx" { + assert_eq!(read["view"]["blocks"][0]["runs"][0]["bold"], true); + 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"], + "新产品" + ); + } + } + } + } } diff --git a/src/crates/execution/office-documents/AGENTS.md b/src/crates/execution/office-documents/AGENTS.md index 188ca50814..350f07c38b 100644 --- a/src/crates/execution/office-documents/AGENTS.md +++ b/src/crates/execution/office-documents/AGENTS.md @@ -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 diff --git a/src/crates/execution/office-documents/Cargo.toml b/src/crates/execution/office-documents/Cargo.toml index ffe79dd699..c8f3aeff4a 100644 --- a/src/crates/execution/office-documents/Cargo.toml +++ b/src/crates/execution/office-documents/Cargo.toml @@ -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 diff --git a/src/crates/execution/office-documents/src/common.rs b/src/crates/execution/office-documents/src/common.rs index 3fae1dd82c..6a8907971e 100644 --- a/src/crates/execution/office-documents/src/common.rs +++ b/src/crates/execution/office-documents/src/common.rs @@ -144,12 +144,22 @@ impl DocumentProperties { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MathMode { + Inline, + Display, +} + /// One formatted span of text. Shared by Word paragraphs, slide paragraphs, /// and table cells; each writer ignores the fields its format cannot show. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct Run { #[serde(default)] pub text: String, + /// DOCX equation source is kept as text for inspection and legacy readers. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub math: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub bold: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -224,6 +234,20 @@ impl Run { } } +/// Script-based fallback shared by Word and PowerPoint. Han-only content is +/// ambiguous; callers can override the language without forcing a font. +pub(crate) fn inferred_cjk_language(text: &str) -> Option<&'static str> { + if text.chars().any(|c| matches!(c as u32, 0x3040..=0x30FF | 0x31F0..=0x31FF | 0xFF66..=0xFF9D | 0x1B000..=0x1B16F)) { + Some("ja-JP") + } else if text.chars().any(|c| matches!(c as u32, 0x1100..=0x11FF | 0x3130..=0x318F | 0xA960..=0xA97F | 0xAC00..=0xD7FF | 0xFFA0..=0xFFDC)) { + Some("ko-KR") + } else if text.chars().any(|c| matches!(c as u32, 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF | 0x20000..=0x2FA1F | 0x30000..=0x323AF)) { + Some("zh-CN") + } else { + None + } +} + /// A syntax check, not a locale allowlist: Office language tags are independent /// of the product UI's supported locales. pub(crate) fn validate_language(lang: &str) -> OfficeResult<()> { diff --git a/src/crates/execution/office-documents/src/docx/markdown.rs b/src/crates/execution/office-documents/src/docx/markdown.rs index dce38be320..98bdd9bafb 100644 --- a/src/crates/execution/office-documents/src/docx/markdown.rs +++ b/src/crates/execution/office-documents/src/docx/markdown.rs @@ -5,7 +5,7 @@ use super::model::{ Block, CodeBlock, EmptyBlock, HeadingBlock, ImageBlock, ListBlock, ListItem, ParagraphBlock, QuoteBlock, TableBlock, TocBlock, }; -use crate::common::{Align, Extra, Run, TableCell}; +use crate::common::{Align, Extra, MathMode, Run, TableCell}; use pulldown_cmark::{Alignment, CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd}; #[derive(Default, Clone)] @@ -35,7 +35,10 @@ fn push_run(runs: &mut Vec, run: Run) { if run.text.is_empty() { return; } - if let Some(last) = runs.last_mut() { + if let Some(last) = runs + .last_mut() + .filter(|last| last.math.is_none() && run.math.is_none()) + { let mut probe = last.clone(); probe.text = run.text.clone(); if probe == run { @@ -73,8 +76,10 @@ struct TableFrame { /// Parses Markdown into blocks. Image references are returned as image /// blocks whose `src` the caller resolves. pub fn markdown_to_blocks(markdown: &str) -> Vec { - let options = - Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TASKLISTS; + let options = Options::ENABLE_TABLES + | Options::ENABLE_STRIKETHROUGH + | Options::ENABLE_TASKLISTS + | Options::ENABLE_MATH; let parser = Parser::new_ext(markdown, options); let mut blocks: Vec = Vec::new(); @@ -363,8 +368,34 @@ pub fn markdown_to_blocks(markdown: &str) -> Vec { }, Event::Text(text) => push_run(&mut runs, inline.run(&text, false)), Event::Code(text) => push_run(&mut runs, inline.run(&text, true)), - Event::InlineMath(text) | Event::DisplayMath(text) => { - push_run(&mut runs, inline.run(&text, false)) + Event::InlineMath(text) => { + let mut run = inline.run(&text, false); + run.math = Some(MathMode::Inline); + push_run(&mut runs, run); + } + Event::DisplayMath(text) => { + let mut run = inline.run(&text, false); + // A display equation occupies its own paragraph. Inside lists + // or tables it remains inline in the owning text container. + if stack.iter().any(|container| { + matches!( + container, + Container::List(_) + | Container::Table(_) + | Container::Heading(_) + | Container::Quote { .. } + ) + }) { + run.math = Some(MathMode::Inline); + push_run(&mut runs, run); + } else { + flush_paragraph(&mut blocks, &mut stack, &mut runs); + run.math = Some(MathMode::Display); + blocks.push(Block::Paragraph(ParagraphBlock { + runs: vec![run], + ..Default::default() + })); + } } Event::SoftBreak => push_run(&mut runs, inline.run(" ", false)), Event::HardBreak => push_run(&mut runs, inline.run("\n", false)), diff --git a/src/crates/execution/office-documents/src/docx/math.rs b/src/crates/execution/office-documents/src/docx/math.rs new file mode 100644 index 0000000000..a799c907b8 --- /dev/null +++ b/src/crates/execution/office-documents/src/docx/math.rs @@ -0,0 +1,184 @@ +//! LaTeX math → MathML → native, editable Office Math. All steps are in memory. +//! Unsupported structures fail explicitly; no image or process fallback exists. + +use crate::common::MathMode; +use crate::xml::esc; +use crate::{OfficeError, OfficeResult}; +use math_core::{LatexToMathML, MathCoreConfig, MathDisplay}; +use roxmltree::Node; + +pub(super) fn render(source: &str, mode: MathMode) -> OfficeResult { + if source.trim().is_empty() { + return Err(OfficeError::invalid( + "equation text must contain LaTeX math", + )); + } + let converter = LatexToMathML::new(MathCoreConfig::default()).map_err(|error| { + OfficeError::invalid(format!("cannot initialize math parser: {error:?}")) + })?; + let converted = converter.convert_with_local_counter(source, match mode { + MathMode::Inline => MathDisplay::Inline, + MathMode::Display => MathDisplay::Block, + }).map_err(|error| OfficeError::invalid(format!("invalid or unsupported LaTeX math: {error}; correct the equation source or use an explicit code block to show literal TeX")))?; + let doc = roxmltree::Document::parse(&converted) + .map_err(|error| OfficeError::invalid(format!("invalid equation MathML: {error}")))?; + let content = node(doc.root_element())?; + let math = format!("{content}"); + Ok(if mode == MathMode::Display { + format!("{math}") + } else { + math + }) +} + +fn children(element: Node<'_, '_>) -> OfficeResult { + element + .children() + .filter(Node::is_element) + .map(node) + .collect() +} + +fn wrap(tag: &str, content: &str) -> String { + format!("{content}") +} + +fn text(value: &str, normal: bool) -> String { + let style = if normal { + "" + } else { + "" + }; + format!("{style}{}", esc(value)) +} + +fn delimiter(element: Node<'_, '_>) -> bool { + // MathML's operator dictionary makes these delimiters stretchy by default; + // math-core only emits an attribute when it overrides that default. + element.has_tag_name("mo") + && element.attribute("stretchy") != Some("false") + && (element.attribute("stretchy") == Some("true") + || matches!( + element.text().unwrap_or(""), + "(" | ")" + | "[" + | "]" + | "{" + | "}" + | "|" + | "‖" + | "⟨" + | "⟩" + | "⌈" + | "⌉" + | "⌊" + | "⌋" + | "\u{2063}" + )) +} + +fn delimiter_text<'a, 'input>(element: Node<'a, 'input>) -> &'a str { + let value = element.text().unwrap_or(""); + if value == "\u{2063}" { + "" + } else { + value + } +} + +fn node(element: Node<'_, '_>) -> OfficeResult { + let name = element.tag_name().name(); + let nodes: Vec<_> = element.children().filter(Node::is_element).collect(); + let child = |index: usize| -> OfficeResult { + nodes.get(index).copied().map(node).unwrap_or_else(|| { + Err(OfficeError::invalid(format!( + "incomplete equation element {name}" + ))) + }) + }; + Ok(match name { + "math" | "mstyle" | "semantics" => children(element)?, + "annotation" => String::new(), + "mrow" => { + // Preserve scalable fenced matrices/fractions as Office delimiters. + if nodes.len() >= 2 && nodes.first().copied().is_some_and(delimiter) + && nodes.last().copied().is_some_and(delimiter) { + let inner: String = nodes[1..nodes.len()-1].iter().copied().map(node).collect::>()?; + format!("{inner}", esc(delimiter_text(nodes[0])), esc(delimiter_text(nodes[nodes.len()-1]))) + } else { children(element)? } + }, + "mi" | "mn" | "mo" | "mtext" | "ms" => { + text(element.text().unwrap_or(""), name != "mi" || element.attribute("mathvariant") == Some("normal")) + }, + "mspace" => text(" ", true), + "mfrac" => { + let props = if element.attribute("linethickness") == Some("0") { "" } else { "" }; + format!("{props}{}{}", child(0)?, child(1)?) + }, + "msqrt" => format!("{}", children(element)?), + "mroot" => format!("{}{}", child(1)?, child(0)?), + "msub" | "msup" | "msubsup" => { + let tag = match name { "msub" => "sSub", "msup" => "sSup", _ => "sSubSup" }; + let mut content = wrap("e", &child(0)?); + content.push_str(&wrap(if name == "msup" {"sup"} else {"sub"}, &child(1)?)); + if name == "msubsup" { content.push_str(&wrap("sup", &child(2)?)); } + wrap(tag, &content) + }, + "munder" | "mover" | "munderover" => { + let base = child(0)?; + if name == "mover" && nodes.get(1).is_some_and(|n| n.has_tag_name("mo")) && element.attribute("accent") == Some("true") { + format!("{base}", esc(nodes[1].text().unwrap_or(""))) + } else { + let lower = if name == "mover" { base } else { format!("{base}{}", child(1)?) }; + if name == "munder" { lower } else { format!("{lower}{}", child(if name == "mover" {1} else {2})?) } + } + }, + "mtable" => { + let columns = nodes.first().map(|row| row.children().filter(Node::is_element).count()).unwrap_or(0); + if columns == 0 || nodes.iter().any(|row| row.children().filter(Node::is_element).count() != columns) { + return Err(OfficeError::invalid("equation matrices must have equally sized rows")); + } + format!("{}", children(element)?) + }, + "mtr" => wrap("mr", &children(element)?), + "mtd" => wrap("e", &children(element)?), + _ => return Err(OfficeError::invalid(format!("LaTeX math uses unsupported Word equation structure `{name}`; simplify the expression or show it explicitly as code"))), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn converts_native_equation_structures_and_rejects_unknown_commands() { + for (source, expected) in [ + (r"\frac{-b\pm\sqrt{b^2-4ac}}{2a}", ""), + (r"\sum_{i=1}^{n} x_i^2", ""), + (r"\int_0^1 x^2\,dx=\frac{1}{3}", ""), + (r"\begin{pmatrix}a&b\\c&d\end{pmatrix}", ""), + (r"\hat{x}+\alpha+\beta", ""), + (r"x0", "<"), + ] { + let xml = render(source, MathMode::Display).unwrap(); + assert!(xml.contains(expected), "{source}: {xml}"); + let wrapper = format!("{xml}"); + roxmltree::Document::parse(&wrapper).unwrap(); + } + for source in [ + r"\begin{pmatrix}a&b\\c&d\end{pmatrix}", + r"\left(\frac{1}{2}\right)", + r"\begin{cases}x&x>0\\0&x=0\end{cases}", + ] { + let xml = render(source, MathMode::Display).unwrap(); + assert!( + xml.contains(""), + "scalable delimiters missing: {source}" + ); + assert!(xml.contains("")); + } + assert!(!render("(x)", MathMode::Inline).unwrap().contains("")); + assert!(render(r"\unknownCommand{x}", MathMode::Inline).is_err()); + assert!(render(r"\frac{1}", MathMode::Inline).is_err()); + } +} diff --git a/src/crates/execution/office-documents/src/docx/mod.rs b/src/crates/execution/office-documents/src/docx/mod.rs index c7b42982ed..6053b0230b 100644 --- a/src/crates/execution/office-documents/src/docx/mod.rs +++ b/src/crates/execution/office-documents/src/docx/mod.rs @@ -1,6 +1,7 @@ //! Word documents (`.docx`). mod markdown; +mod math; pub(crate) mod model; pub(crate) mod ops; pub(crate) mod render; diff --git a/src/crates/execution/office-documents/src/docx/model.rs b/src/crates/execution/office-documents/src/docx/model.rs index df2d6c20ad..9d630baf54 100644 --- a/src/crates/execution/office-documents/src/docx/model.rs +++ b/src/crates/execution/office-documents/src/docx/model.rs @@ -146,6 +146,8 @@ pub(crate) fn cm_to_twips(cm: f64) -> u32 { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct DocxStyles { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lang: Option, /// Latin body font; Word's default when absent. #[serde(default, skip_serializing_if = "Option::is_none")] pub font: Option, @@ -191,6 +193,7 @@ fn default_table_header_fill() -> String { impl Default for DocxStyles { fn default() -> Self { Self { + lang: None, font: None, east_asia_font: None, font_size_pt: default_body_size(), @@ -207,6 +210,9 @@ impl Default for DocxStyles { impl DocxStyles { pub fn validate(&self) -> OfficeResult<()> { + if let Some(lang) = &self.lang { + crate::common::validate_language(lang)?; + } if !(6.0..=72.0).contains(&self.font_size_pt) { return Err(OfficeError::invalid(format!( "styles.font_size_pt must be between 6 and 72, got {}", @@ -528,28 +534,59 @@ impl Block { } } - /// Every run of the block, for text replacement. - pub fn runs_mut(&mut self) -> Vec<&mut Run> { + pub fn run_groups(&self) -> Vec<&[Run]> { match self { - Block::Heading(block) => block.runs.iter_mut().collect(), - Block::Paragraph(block) => block.runs.iter_mut().collect(), - Block::Quote(block) => block.runs.iter_mut().collect(), + Block::Heading(block) => vec![&block.runs], + Block::Paragraph(block) => vec![&block.runs], + Block::Quote(block) => vec![&block.runs], Block::List(block) => block .items - .iter_mut() - .flat_map(|item| item.runs.iter_mut()) + .iter() + .map(|item| item.runs.as_slice()) .collect(), + Block::Table(block) => block + .rows + .iter() + .flatten() + .map(|cell| cell.runs.as_slice()) + .collect(), + _ => Vec::new(), + } + } + + /// Every run of the block, for text replacement. + /// Separate text containers; replacements never cross paragraphs, list + /// items or table cells, even when their text happens to be adjacent. + pub fn run_groups_mut(&mut self) -> Vec<&mut Vec> { + match self { + Block::Heading(block) => vec![&mut block.runs], + Block::Paragraph(block) => vec![&mut block.runs], + Block::Quote(block) => vec![&mut block.runs], + Block::List(block) => block.items.iter_mut().map(|item| &mut item.runs).collect(), Block::Table(block) => block .rows .iter_mut() .flat_map(|row| row.iter_mut()) - .flat_map(|cell| cell.runs.iter_mut()) + .map(|cell| &mut cell.runs) .collect(), _ => Vec::new(), } } pub fn validate(&self, path: &str) -> OfficeResult<()> { + for runs in self.run_groups() { + for run in runs { + if let Some(mode) = run.math { + if mode == crate::common::MathMode::Display && runs.len() != 1 { + return Err(OfficeError::invalid(format!( + "{path}: display math must be the only run in its paragraph or cell" + ))); + } + super::math::render(&run.text, mode) + .map_err(|err| prefix(err, path, "math"))?; + } + } + } match self { Block::Heading(block) => { if !(1..=6).contains(&block.level) { @@ -598,7 +635,12 @@ impl Block { ))); } for (row_index, row) in block.rows.iter().enumerate() { - if row.len() > 63 { + if row + .iter() + .map(|cell| cell.colspan.unwrap_or(1) as u64) + .sum::() + > 63 + { return Err(OfficeError::limit(format!( "{path}.rows[{row_index}]: Word tables are limited to 63 columns" ))); @@ -607,6 +649,14 @@ impl Block { cell.validate(&format!("{path}.rows[{row_index}][{cell_index}]"))?; } } + if block + .font_size_pt + .is_some_and(|size| !(1.0..=400.0).contains(&size)) + { + return Err(OfficeError::invalid(format!( + "{path}.font_size_pt must be between 1 and 400" + ))); + } if let Some(widths) = &block.column_widths_cm { if widths.iter().any(|width| !(0.2..=60.0).contains(width)) { return Err(OfficeError::invalid(format!( diff --git a/src/crates/execution/office-documents/src/docx/ops.rs b/src/crates/execution/office-documents/src/docx/ops.rs index 162e75df83..cf1eea0537 100644 --- a/src/crates/execution/office-documents/src/docx/ops.rs +++ b/src/crates/execution/office-documents/src/docx/ops.rs @@ -4,7 +4,8 @@ use super::markdown::markdown_to_blocks; use super::model::{Block, DocxDocument, DocxStyles, HeaderFooter, PageSetup}; use crate::common::{ existing_span, insertion_index, merge_patch, move_span, parse_operation, report_extra, - report_list, unknown_operation, Align, DocumentProperties, Extra, UnknownFields, + report_list, runs_text, unknown_operation, Align, DocumentProperties, Extra, Run, + UnknownFields, }; use crate::error::{OfficeError, OfficeResult}; use crate::ApplyContext; @@ -80,6 +81,8 @@ struct ReplaceText { replace: String, #[serde(default = "default_true")] match_case: bool, + #[serde(default)] + include_headers_footers: bool, #[serde(flatten)] extra: Extra, } @@ -196,18 +199,43 @@ pub(crate) fn apply( } let mut replaced = 0; for block in &mut doc.blocks { - for run in block.runs_mut() { - let (text, count) = - replace_all(&run.text, &input.find, &input.replace, input.match_case); - if count > 0 { - run.text = text; - replaced += count; + for runs in block.run_groups_mut() { + for text_runs in runs.split_mut(|run| run.math.is_some()) { + replaced += + replace_runs(text_runs, &input.find, &input.replace, input.match_case); } } + let mut texts = Vec::new(); + match block { + Block::Code(block) => texts.push(&mut block.text), + Block::Table(block) => texts.extend(block.caption.as_mut()), + Block::Image(block) => texts.extend(block.caption.as_mut()), + Block::Toc(block) => texts.extend(block.title.as_mut()), + _ => {} + } + for text in texts { + let (updated, count) = + replace_all(text, &input.find, &input.replace, input.match_case); + *text = updated; + replaced += count; + } + } + if input.include_headers_footers { + for text in doc + .header + .iter_mut() + .chain(doc.footer.iter_mut()) + .map(|part| &mut part.text) + { + let (updated, count) = + replace_all(text, &input.find, &input.replace, input.match_case); + *text = updated; + replaced += count; + } } if replaced == 0 { ctx.warn(format!( - "replace_text found no match for `{}` (a match split across differently formatted runs is not found)", + "replace_text found no match for `{}`; read the target text before retrying", input.find )); } else { @@ -358,6 +386,76 @@ fn prepare_blocks(content: BlockContent, ctx: &mut ApplyContext) -> OfficeResult Ok(blocks) } +/// Byte ranges in the original string, including when Unicode lowercase +/// expands a character. Only complete original characters can match. +fn matching_ranges(text: &str, find: &str, match_case: bool) -> Vec<(usize, usize)> { + if find.is_empty() { + return Vec::new(); + } + if match_case { + return text + .match_indices(find) + .map(|(start, value)| (start, start + value.len())) + .collect(); + } + let mut lower = String::new(); + let mut boundaries = Vec::new(); + for (offset, character) in text.char_indices() { + boundaries.push((lower.len(), offset)); + lower.extend(character.to_lowercase()); + } + boundaries.push((lower.len(), text.len())); + let needle = find.to_lowercase(); + lower + .match_indices(&needle) + .filter_map(|(start, matched)| { + let from = boundaries + .binary_search_by_key(&start, |(lower, _)| *lower) + .ok()?; + let to = boundaries + .binary_search_by_key(&(start + matched.len()), |(lower, _)| *lower) + .ok()?; + Some((boundaries[from].1, boundaries[to].1)) + }) + .collect() +} + +/// Replacements inherit the formatting/link of their first matched character; +/// untouched prefixes and suffixes retain their own run formatting. +fn replace_runs(runs: &mut [Run], find: &str, replace: &str, match_case: bool) -> usize { + let text = runs_text(runs); + let matches = matching_ranges(&text, find, match_case); + let mut match_index = 0; + let mut run_start = 0; + for run in runs { + let run_end = run_start + run.text.len(); + let mut cursor = run_start; + let mut updated = String::new(); + while let Some(&(start, end)) = matches.get(match_index) { + if start >= run_end { + break; + } + if end <= run_start { + match_index += 1; + continue; + } + if start >= run_start { + updated.push_str(&text[cursor..start]); + updated.push_str(replace); + } + cursor = end.min(run_end); + if end > run_end { + break; + } + match_index += 1; + } + updated.push_str(&text[cursor..run_end]); + run.text = updated; + run_start = run_end; + } + matches.len() +} + /// Replaces every occurrence, returning the new text and the match count. pub(crate) fn replace_all( text: &str, @@ -365,34 +463,22 @@ pub(crate) fn replace_all( replace: &str, match_case: bool, ) -> (String, usize) { - if match_case { - let count = text.matches(find).count(); - return (text.replace(find, replace), count); - } - let lower_text = text.to_lowercase(); - let lower_find = find.to_lowercase(); - // Lowercasing can change byte lengths; fall back to exact matching then. - if lower_text.len() != text.len() || lower_find.len() != find.len() { - let count = text.matches(find).count(); - return (text.replace(find, replace), count); - } + let matches = matching_ranges(text, find, match_case); let mut out = String::with_capacity(text.len()); - let mut count = 0; let mut cursor = 0; - while let Some(position) = lower_text[cursor..].find(&lower_find) { - let start = cursor + position; + for &(start, end) in &matches { out.push_str(&text[cursor..start]); out.push_str(replace); - cursor = start + find.len(); - count += 1; + cursor = end; } out.push_str(&text[cursor..]); - (out, count) + (out, matches.len()) } #[cfg(test)] mod tests { - use super::replace_all; + use super::{replace_all, replace_runs}; + use crate::common::Run; #[test] fn replaces_case_insensitively() { @@ -404,5 +490,34 @@ mod tests { replace_all("Foo foo", "foo", "bar", true), ("Foo bar".to_string(), 1) ); + assert_eq!( + replace_all("İstanbul İSTANBUL", "İstanbul", "City", false), + ("City City".to_string(), 2) + ); + // Lowercasing changes byte lengths; matching must not slice at offsets + // from the lowercased string or silently fall back to case-sensitive. + assert_eq!( + replace_all("Kelvin kelvin", "KELVIN", "K", false), + ("K K".to_string(), 2) + ); + } + + #[test] + fn replaces_multiple_spans_across_runs_and_preserves_unmatched_styles() { + let mut runs = vec![ + Run::plain(""), + Run::plain("前ab"), + Run::plain("c中a"), + Run::plain("bc后"), + ]; + runs[1].bold = Some(true); + runs[2].italic = Some(true); + assert_eq!(replace_runs(&mut runs, "abc", "X", true), 2); + assert_eq!( + runs.iter().map(|run| run.text.as_str()).collect::>(), + ["", "前X", "中X", "后"] + ); + assert_eq!(runs[1].bold, Some(true)); + assert_eq!(runs[2].italic, Some(true)); } } diff --git a/src/crates/execution/office-documents/src/docx/render.rs b/src/crates/execution/office-documents/src/docx/render.rs index 81c3cad923..e3f70afacf 100644 --- a/src/crates/execution/office-documents/src/docx/render.rs +++ b/src/crates/execution/office-documents/src/docx/render.rs @@ -11,7 +11,7 @@ use crate::package::{add_common_parts, ContentTypes, Parts, Relationships}; use crate::xml::{esc, needs_preserve, XML_HEADER}; use std::collections::BTreeMap; -const NS: &str = "xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\" xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing\" xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" xmlns:pic=\"http://schemas.openxmlformats.org/drawingml/2006/picture\""; +const NS: &str = "xmlns:m=\"http://schemas.openxmlformats.org/officeDocument/2006/math\" xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\" xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing\" xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" xmlns:pic=\"http://schemas.openxmlformats.org/drawingml/2006/picture\""; const REL_STYLES: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles"; const REL_SETTINGS: &str = @@ -40,6 +40,7 @@ struct Writer<'a> { lists: Vec<(u32, bool, u32)>, drawing_id: u32, has_toc: bool, + equations: BTreeMap<(String, bool), String>, } pub(crate) fn render( @@ -48,6 +49,17 @@ pub(crate) fn render( created: Option<&str>, modified: Option<&str>, ) -> OfficeResult { + let mut equations = BTreeMap::new(); + for block in &doc.blocks { + for run in block.run_groups().into_iter().flatten() { + if let Some(mode) = run.math { + let key = (run.text.clone(), mode == crate::common::MathMode::Display); + if !equations.contains_key(&key) { + equations.insert(key, super::math::render(&run.text, mode)?); + } + } + } + } let mut writer = Writer { doc, assets, @@ -58,6 +70,7 @@ pub(crate) fn render( lists: Vec::new(), drawing_id: 0, has_toc: false, + equations, }; writer.rels.add(REL_STYLES, "styles.xml"); writer.rels.add(REL_SETTINGS, "settings.xml"); @@ -246,6 +259,10 @@ impl Writer<'_> { } fn run(&mut self, run: &Run, defaults: Option<&RunDefaults>) -> String { + if let Some(mode) = run.math { + return self.equations[&(run.text.clone(), mode == crate::common::MathMode::Display)] + .clone(); + } let link = run.link.as_ref().map(|target| { self.link_rels .entry(target.clone()) @@ -262,8 +279,13 @@ impl Writer<'_> { .or_else(|| (run.code == Some(true)).then_some("Consolas")); if let Some(font) = font { let font = esc(font); + let east_asia = if run.font.is_some() { + format!(" w:eastAsia=\"{font}\"") + } else { + String::new() + }; rpr.push_str(&format!( - "" + "" )); } let bold = run.bold.or(defaults.and_then(|d| d.bold)); @@ -309,7 +331,12 @@ impl Writer<'_> { } else if run.subscript == Some(true) { rpr.push_str(""); } - if let Some(lang) = &run.lang { + if let Some(lang) = run + .lang + .as_deref() + .or(self.doc.styles.lang.as_deref()) + .or_else(|| crate::common::inferred_cjk_language(&run.text)) + { let lang = esc(lang); rpr.push_str(&format!( "" @@ -765,13 +792,21 @@ fn styles_xml(doc: &DocxDocument) -> String { .unwrap_or_default(); let line = (styles.line_spacing * 240.0).round() as i64; let after = pt_twips(styles.paragraph_space_after_pt); + let language = styles + .lang + .as_deref() + .map(|lang| { + let lang = esc(lang); + format!("") + }) + .unwrap_or_default(); let mut out = String::from(XML_HEADER); out.push_str( "", ); out.push_str(&format!( - "{text_color}" + "{text_color}{language}" )); out.push_str(""); out.push_str(""); diff --git a/src/crates/execution/office-documents/src/guide.rs b/src/crates/execution/office-documents/src/guide.rs index 5d15b722ce..a8eb297008 100644 --- a/src/crates/execution/office-documents/src/guide.rs +++ b/src/crates/execution/office-documents/src/guide.rs @@ -2,9 +2,21 @@ use crate::OfficeFormat; -const DOCX: &str = include_str!("guides/docx.md"); -const XLSX: &str = include_str!("guides/xlsx.md"); -const PPTX: &str = include_str!("guides/pptx.md"); +const DOCX: &str = concat!( + include_str!("guides/docx.md"), + "\n", + include_str!("guides/verification.md") +); +const XLSX: &str = concat!( + include_str!("guides/xlsx.md"), + "\n", + include_str!("guides/verification.md") +); +const PPTX: &str = concat!( + include_str!("guides/pptx.md"), + "\n", + include_str!("guides/verification.md") +); /// The full operation and field reference for one format. pub fn guide(format: OfficeFormat) -> &'static str { diff --git a/src/crates/execution/office-documents/src/guides/docx.md b/src/crates/execution/office-documents/src/guides/docx.md index cd3c3ec5b5..56b2a5050e 100644 --- a/src/crates/execution/office-documents/src/guides/docx.md +++ b/src/crates/execution/office-documents/src/guides/docx.md @@ -19,13 +19,21 @@ Every block is an object with `type`. Text fields take either `text` (plain stri | `quote` | `text`/`runs` | | `page_break` | — | | `horizontal_rule` | — | -| `toc` | `title`, `levels` (default 3). Lists the headings; Word refreshes page numbers on open | +| `toc` | `title`, `levels` (default 3). Lists headings; Word computes page numbers. Refresh the field after pagination if needed | -**Run** fields: `text`, `bold`, `italic`, `underline`, `strike`, `code`, `superscript`, `subscript`, `color` (`#RRGGBB`), `highlight` (`#RRGGBB`), `size_pt`, `font`, `lang` (optional BCP 47 language, e.g. `zh-CN`), `link` (http/https/mailto URL). +**Run** fields: `text`, `math` (`inline` or `display`, treats `text` as LaTeX math), `bold`, `italic`, `underline`, `strike`, `code`, `superscript`, `subscript`, `color` (`#RRGGBB`), `highlight` (`#RRGGBB`), `size_pt`, `font`, `lang` (optional BCP 47 language, e.g. `zh-CN`), `link` (http/https/mailto URL). ### Markdown -`insert_blocks` and `replace_blocks` accept `markdown` instead of `blocks`. Supported: headings, paragraphs with `**bold**` / `*italic*` / `~~strike~~` / `` `code` `` / `[links](https://…)`, bullet / numbered / nested / task lists, GFM tables (with column alignment), fenced code, block quotes, `---` rules, images `![alt](/abs/path.png)`, `[TOC]` on its own line, and ``. +`insert_blocks` and `replace_blocks` accept `markdown` instead of `blocks`. Supported: headings, paragraphs with `**bold**` / `*italic*` / `~~strike~~` / `` `code` `` / `[links](https://…)`, bullet / numbered / nested / task lists, GFM tables (with column alignment), fenced code, block quotes, `---` rules, images `![alt](/abs/path.png)`, `[TOC]` on its own line, and ``. Math uses `$...$` inline and `$$...$$` on its own line for display. + +### Papers and equations + +Use heading levels, `[TOC]`, paragraph spacing/indents, page breaks, tables/captions, and page fields for papers. Agent-readable `.tex` content can be expressed with these blocks and Markdown; the tool does not compile an entire TeX document or execute its macros/files. Citation markers and bibliography entries can be authored as ordinary text; automatic citation databases, cross-reference numbering and footnotes are not supported. Do not claim journal-template compliance without checking its actual requirements. + +LaTeX math is written as native editable Word equations. Supported structures include fractions, roots, subscripts/superscripts, sums, integrals, Greek symbols, accents and rectangular matrices. Explicit input: `{"type":"paragraph","runs":[{"text":"E=mc^2","math":"display"}]}`. A display equation must be the only run in its paragraph or cell. Math text has no dollar delimiters in explicit runs; JSON backslashes must be escaped (`"\\frac{1}{2}"`). Code blocks retain literal TeX. + +Unsupported syntax/structures fail the whole batch with an actionable error; simplify that expression or explicitly show its source as code if that satisfies the user. No TeX/Office installation or external conversion is needed. `read` retains the original math source; `replace_blocks` edits equations in place. `replace_text` skips equations and never matches across them. Legacy readers retain the math fields and can show the stored source text. ## Operations @@ -35,9 +43,9 @@ Every block is an object with `type`. Text fields take either `text` (plain stri | `replace_blocks` | `index`, `count` (default 1), `blocks` or `markdown` | replaces `count` blocks with the new ones (any number) | | `delete_blocks` | `index`, `count` (default 1) | removes blocks | | `move_blocks` | `index`, `count` (default 1), `to` | moves blocks; `to` is the position in the list after they are removed | -| `replace_text` | `find`, `replace`, `match_case` (default true) | replaces inside every run (text split across differently formatted runs is not matched) | +| `replace_text` | `find`, `replace`, `match_case` (default true), `include_headers_footers` (default false) | replaces body text, including text split across formatted runs; replacement inherits the first matched run’s format. Matches never cross paragraphs, list items or table cells. `include_headers_footers: true` also updates header/footer text | | `set_page_setup` | `size` (`a4` `letter` `legal` `a3` `a5`), `orientation` (`portrait` `landscape`), `margins_cm` `{top,bottom,left,right}` | partial update | -| `set_styles` | `font`, `east_asia_font`, `font_size_pt`, `heading_font`, `heading_color`, `text_color`, `line_spacing`, `paragraph_space_after_pt`, `table_header_fill` | partial update of document-wide styles | +| `set_styles` | `lang` (BCP 47, optional), `font`, `east_asia_font`, `font_size_pt`, `heading_font`, `heading_color`, `text_color`, `line_spacing`, `paragraph_space_after_pt`, `table_header_fill` | partial update of document-wide styles | | `set_header_footer` | `header`, `footer`: a string, `{text, align}`, or `null` to remove. `{PAGE}` and `{NUMPAGES}` become page fields | | | `set_properties` | `title`, `subject`, `author`, `keywords`, `description`, `category` | document metadata | @@ -47,13 +55,13 @@ Prefer `replace_blocks` for content corrections instead of insert/delete/move se ## Settings at create -`settings` may carry `page`, `styles`, `header`, `footer`, `properties` (same fields as above). Use the style defaults unless document requirements call for specific fonts; `styles.east_asia_font` sets the East Asian font. Font availability and substitution depend on the viewing environment, which may differ from the generation environment. +`settings` may carry `page`, `styles`, `header`, `footer`, `properties` (same fields as above). CJK run languages follow their text unless overridden by run `lang` or `styles.lang`. Use the style defaults unless document requirements call for specific fonts; `styles.east_asia_font` sets the East Asian font. Font availability and substitution depend on the viewing environment, which may differ from the generation environment. ## Example ```json {"action": "create", "file_path": "/abs/report.docx", - "settings": {"styles": {"east_asia_font": "Microsoft YaHei"}}, + "settings": {"styles": {"font_size_pt": 11}}, "operations": [ {"op": "set_properties", "title": "Q3 Report"}, {"op": "insert_blocks", "markdown": "# Q3 Report\n\n[TOC]\n\n## Summary\n\nRevenue grew **23%**."}, diff --git a/src/crates/execution/office-documents/src/guides/verification.md b/src/crates/execution/office-documents/src/guides/verification.md new file mode 100644 index 0000000000..6c4228537e --- /dev/null +++ b/src/crates/execution/office-documents/src/guides/verification.md @@ -0,0 +1,7 @@ +## Completion and verification — all formats + +The engine writes Office files directly; it does not depend on an installed Office application. Finish edits, inspect warnings, then use `read` to verify changed content, indexes, formulas or chart references. A successful write plus this structural check is the default completion path. + +Visual inspection is a separate task when the user requests it or the deliverable requires it. Use a known available viewer on the machine that owns the file. Do not automatically probe, install or repair LibreOffice/soffice or other Office packages, search unrelated application directories, or build a substitute renderer just because `visual_rendered` is false. If no suitable viewer is available, deliver the file and state that visual inspection was not performed. + +Report evidence precisely: a structure read, geometry estimate, preview in one viewer, and verification in the intended Office application are different checks. Never claim Excel/WPS/Word/PowerPoint compatibility based only on another viewer or a custom drawing of the model. Keep the original file when previewing; saving in another application changes its editable-source fingerprint. diff --git a/src/crates/execution/office-documents/src/guides/xlsx.md b/src/crates/execution/office-documents/src/guides/xlsx.md index 3c14d9a133..ca687873a2 100644 --- a/src/crates/execution/office-documents/src/guides/xlsx.md +++ b/src/crates/execution/office-documents/src/guides/xlsx.md @@ -15,7 +15,9 @@ Rows are arrays of cell values: - `null` → leave that cell unchanged (skip) - object → `{"value": …, "formula": "…", "type": "text" | "number" | "boolean" | "date", "style": {…}}`. `type: "text"` keeps a leading `=` literal; `type: "date"` turns `"2024-03-31"` or `"2024-03-31 09:30"` into a real date with a date format. -Formula results are computed and cached for arithmetic, comparisons, `&`, cell and range references (including other sheets), and SUM, AVERAGE, MIN, MAX, COUNT, COUNTA, PRODUCT, ROUND, ROUNDUP, ROUNDDOWN, ABS, IF, IFERROR, AND, OR, NOT, CONCAT(ENATE), LEN, UPPER, LOWER, TRIM, so `read` and simple viewers show values. Other functions (VLOOKUP, SUMIF, FILTER, …) are stored without a cached value; Excel, WPS and LibreOffice compute every formula when the file opens, and the tool lists the uncached cells. Write numbers as numbers (not `"1,200"`) so formulas and number formats work. +Formula results are computed and cached for arithmetic, comparisons, `&`, cell and range references (including other sheets), and SUM, AVERAGE, MIN, MAX, COUNT, COUNTA, PRODUCT, ROUND, ROUNDUP, ROUNDDOWN, ABS, IF, IFERROR, AND, OR, NOT, CONCAT(ENATE), LEN, UPPER, LOWER, TRIM, so `read` and simple viewers show values. Other functions are stored without a cached value and listed in warnings. The target spreadsheet application must support and recalculate them; this is not a request to install or run an external converter. Write numbers as numbers (not `"1,200"`) so formulas and number formats work. + +A style-only cell object preserves its existing value/formula. Use `{"value": null}` or `clear_range` to clear content; a bare `null` skips it. ## Styles @@ -44,12 +46,25 @@ Formula results are computed and cached for arithmetic, comparisons, `&`, cell a | `freeze_panes` | `sheet`, `rows`, `columns` (0, 0 removes) | | | `set_autofilter` | `sheet`, `range` (`null` removes) | | | `set_sheet` | `sheet`, `tab_color` | | +| `add_chart` | `sheet`, `chart` | append a native editable chart | +| `replace_chart` | `sheet`, `index`, `chart` | replace the 0-based chart at `index` | +| `delete_chart` | `sheet`, `index` | remove a chart | | `set_properties` | `title`, `subject`, `author`, `keywords`, `description`, `category` | | Operations run in order and each sees the previous result: after `insert_rows`, later operations in the same batch address the shifted grid. A batch is atomic. Prefer `set_cells`/`format_range` for corrections instead of deleting/recreating sheets or rows. After failure, correct the named field/range; read the current sheet if uncertain. Do not repeat unchanged failed input or replay successful batches. +## Native charts + +`chart` takes `type` (`column`, `bar`, `line`, `pie`), `anchor` (top-left cell, e.g. `F2`), optional `title`, `width_px`/`height_px` (default 640/360), `legend` (default true), and `series`. Each series takes optional `name`/`color` and two references: `categories: {sheet?, range}` and `values: {sheet?, range}`. An omitted source sheet means the chart’s sheet. References must be equally sized single rows/columns, up to 32000 points; a pie chart takes one series. Values should be numeric, categories labels; blank/non-numeric values are gaps. Unknown formula results have no cached chart points until recalculation. + +```json +{"op":"add_chart","sheet":"Sales","chart":{"type":"column","anchor":"F2","title":"Revenue","series":[{"name":"Total","categories":{"range":"A2:A3"},"values":{"range":"D2:D3"}}]}} +``` + +`read` lists chart counts and returns configurations with the selected sheet. Range references and caches are rebuilt after edits; references follow sheet renames and row/column shifts. Deleting a source marks it `#REF!` and returns a warning; repair it with `replace_chart`. Charts remain editable in Office. Pivot charts, combination charts and conditional formatting are not currently supported; do not invent operations or claim those features were generated. + ## Example ```json diff --git a/src/crates/execution/office-documents/src/operation_contract.rs b/src/crates/execution/office-documents/src/operation_contract.rs index d967778311..69069b907e 100644 --- a/src/crates/execution/office-documents/src/operation_contract.rs +++ b/src/crates/execution/office-documents/src/operation_contract.rs @@ -19,11 +19,31 @@ pub fn operation_schema() -> Value { } } } + let chart_schema = json!({"type": "object", "description": "XLSX add_chart/replace_chart: native editable chart. Read the xlsx guide for range references.", "properties": { + "type": {"type": "string", "enum": ["column", "bar", "line", "pie"]}, + "anchor": {"type": "string", "description": "Top-left cell, e.g. F2."}, + "title": {"type": "string"}, + "width_px": {"type": "integer", "minimum": 1}, + "height_px": {"type": "integer", "minimum": 1}, + "legend": {"type": "boolean"}, + "series": {"type": "array", "minItems": 1, "items": { + "type": "object", "properties": { + "name": {"type": "string"}, + "color": {"type": "string"}, + "categories": {"type": "object", "properties": { + "sheet": {"type": "string"}, "range": {"type": "string", "description": "Single row/column, e.g. A2:A5."} + }, "required": ["range"], "additionalProperties": true}, + "values": {"type": "object", "properties": { + "sheet": {"type": "string"}, "range": {"type": "string", "description": "Numeric cells; same length as categories, e.g. B2:B5."} + }, "required": ["range"], "additionalProperties": true} + }, "required": ["categories", "values"], "additionalProperties": true + }} + }, "required": ["type", "anchor", "series"], "additionalProperties": true}); json!({ "type": "object", "properties": { "op": {"type": "string", "enum": names}, - "index": {"type": "integer", "minimum": 0, "description": "Current 0-based block/slide index. Required to replace/update/delete/move blocks or slides, and for duplicate_slide. Optional insertion position for insert_blocks, insert_slides, or add_sheet. Earlier operations in this batch shift indexes."}, + "index": {"type": "integer", "minimum": 0, "description": "Current 0-based block/slide/chart index. Required for replace/update/delete/move operations that use an index. Optional insertion position for insert_blocks, insert_slides, or add_sheet. Earlier operations in this batch shift indexes."}, "count": {"type": "integer", "minimum": 1, "description": "Number of items; omit for the operation's default."}, "to": {"type": "integer", "minimum": 0, "description": "Move destination after removing the source items; required for move operations."}, "slide": {"type": "object", "description": "replace_slide: complete replacement slide {title?, background?, notes?, elements:[...]}; both index and slide are required.", "properties": { @@ -52,9 +72,11 @@ pub fn operation_schema() -> Value { "column": {"type": "string", "description": "Excel column letters, e.g. A."}, "range": {"description": "Excel A1 range; set_autofilter also accepts null to clear it."}, "style": {"type": "object", "additionalProperties": true}, + "chart": chart_schema, + "include_headers_footers": {"type": "boolean", "description": "DOCX replace_text: also replace header/footer text (default false)."}, "font": {"type": "string"}, "east_asia_font": {"type": "string"}, - "lang": {"type": "string", "description": "PPTX set_theme: optional BCP 47 language, e.g. zh-CN or zh-TW. Omit for automatic text-based selection."} + "lang": {"type": "string", "description": "DOCX set_styles / PPTX set_theme: optional BCP 47 language, e.g. zh-CN or zh-TW. Omit for text-based selection."} }, "required": ["op"], "additionalProperties": true @@ -81,6 +103,9 @@ fn required_fields(op: &str) -> &'static [&'static str] { "format_range" => &["range", "style"], "set_column_widths" => &["widths"], "set_row_heights" => &["heights"], + "add_chart" => &["chart"], + "replace_chart" => &["index", "chart"], + "delete_chart" => &["index"], _ => &[], } } @@ -125,6 +150,14 @@ pub fn validate_operation_headers(format: OfficeFormat, operations: &[Value]) -> "`slide` must be a complete slide object, not an index or array", )); } + if matches!(name, "add_chart" | "replace_chart") { + let chart = &operation["chart"]; + for field in ["type", "anchor", "series"] { + if chart.get(field).is_none_or(Value::is_null) { + return Err(OfficeError::invalid(format!("{name} requires chart.{field}; pass a chart object {{type, anchor, series}}"))); + } + } + } if name == "update_slide" { if let Some(patches) = operation.get("update_elements") { let patches = patches.as_array().ok_or_else(|| { @@ -155,6 +188,8 @@ pub fn validate_operation_headers(format: OfficeFormat, operations: &[Value]) -> /// Recovery advice keyed to the actual failed operation, not a loop counter. pub fn operation_recovery_hint(op: &str) -> &'static str { match op { + "add_chart" | "replace_chart" => "Use chart:{type:\"column\"|\"bar\"|\"line\"|\"pie\",anchor:\"F2\",series:[{categories:{range:\"A2:A4\"},values:{range:\"B2:B4\"}}]}. Source ranges must be single rows/columns of equal length. Read the sheet to check data and existing chart indexes before retrying.", + "replace_text" => "Use {op:\"replace_text\",find:,replace:}. Matches may span formatted runs within one paragraph, list item or table cell. Set include_headers_footers:true only when those should also change.", "replace_slide" => "Use {op:\"replace_slide\",index:,slide:}. This replaces the slide in place; no insert/delete/move is needed.", "update_slide" => "Read the target slide with detail=full. Use {op:\"update_slide\",index:,update_elements:[{index:,...changed fields}]}; patches have no op. Or pass elements to replace the whole element list.", "move_slides" => "Indexes use the state after earlier operations in this batch. Inserting or deleting slides shifts subsequent indexes; recalculate selectors against that order. `to` is measured after removing the moved slides. To replace content, use replace_slide with index and slide instead.", diff --git a/src/crates/execution/office-documents/src/pptx/model.rs b/src/crates/execution/office-documents/src/pptx/model.rs index fb5f979063..2f8a63c92c 100644 --- a/src/crates/execution/office-documents/src/pptx/model.rs +++ b/src/crates/execution/office-documents/src/pptx/model.rs @@ -418,6 +418,11 @@ impl From for Paragraph { impl Paragraph { fn validate(&self, path: &str) -> OfficeResult<()> { validate_runs(&self.runs, path)?; + if self.runs.iter().any(|run| run.math.is_some()) { + return Err(OfficeError::invalid(format!( + "{path}: native math runs are currently supported only in DOCX" + ))); + } if let Some(value) = self.line_spacing_pct { if !(50.0..=300.0).contains(&value) { return Err(OfficeError::invalid(format!( @@ -644,6 +649,11 @@ impl Element { for (row_index, row) in e.rows.iter().enumerate() { for (cell_index, cell) in row.iter().enumerate() { cell.validate(&format!("{path}.rows[{row_index}][{cell_index}]"))?; + if cell.runs.iter().any(|run| run.math.is_some()) { + return Err(OfficeError::invalid(format!( + "{path}: native math runs are currently supported only in DOCX" + ))); + } } } for (name, color) in [ diff --git a/src/crates/execution/office-documents/src/pptx/render.rs b/src/crates/execution/office-documents/src/pptx/render.rs index df60d8098b..b7456a9fcf 100644 --- a/src/crates/execution/office-documents/src/pptx/render.rs +++ b/src/crates/execution/office-documents/src/pptx/render.rs @@ -746,18 +746,9 @@ fn notes_master_xml() -> String { /// the XML is valid UTF-8. Han-only text defaults to Chinese; explicit `lang` /// handles Traditional Chinese and Japanese/Korean text containing only Han. fn text_language<'a>(text: &str, explicit: Option<&'a str>) -> &'a str { - if let Some(lang) = explicit { - return lang; - } - if text.chars().any(|c| matches!(c as u32, 0x3040..=0x30FF | 0x31F0..=0x31FF | 0xFF66..=0xFF9D | 0x1B000..=0x1B16F)) { - "ja-JP" - } else if text.chars().any(|c| matches!(c as u32, 0x1100..=0x11FF | 0x3130..=0x318F | 0xA960..=0xA97F | 0xAC00..=0xD7FF | 0xFFA0..=0xFFDC)) { - "ko-KR" - } else if text.chars().any(|c| matches!(c as u32, 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF | 0x20000..=0x2FA1F | 0x30000..=0x323AF)) { - "zh-CN" - } else { - "en-US" - } + explicit + .or_else(|| crate::common::inferred_cjk_language(text)) + .unwrap_or("en-US") } fn notes_xml(notes: &str, default_lang: Option<&str>) -> String { diff --git a/src/crates/execution/office-documents/src/xlsx/chart_render.rs b/src/crates/execution/office-documents/src/xlsx/chart_render.rs new file mode 100644 index 0000000000..f764ad08b4 --- /dev/null +++ b/src/crates/execution/office-documents/src/xlsx/chart_render.rs @@ -0,0 +1,239 @@ +//! DrawingML parts and reference caches for native spreadsheet charts. + +use super::address::{column_letters, parse_cell, CellRef}; +use super::charts::{Chart, ChartKind, ChartRange}; +use super::eval::Computed; +use super::model::{Sheet, XlsxDocument}; +use crate::common::parse_color; +use crate::package::{ContentTypes, Parts, Relationships}; +use crate::xml::{esc, XML_HEADER}; +use crate::OfficeResult; +use serde_json::Value; +use std::collections::HashMap; + +const DRAWING: &str = "http://schemas.openxmlformats.org/drawingml/2006/main"; +const CHART: &str = "http://schemas.openxmlformats.org/drawingml/2006/chart"; +const REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; +const SHEET_DRAWING: &str = "http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing"; + +/// Returns the worksheet drawing relationship, if any. +pub(super) fn render( + book: &XlsxDocument, + sheet: &Sheet, + number: usize, + computed: &HashMap<(usize, CellRef), Computed>, + parts: &mut Parts, + types: &mut ContentTypes, +) -> OfficeResult> { + if sheet.charts.is_empty() { + return Ok(None); + } + let mut worksheet_rels = Relationships::default(); + let id = worksheet_rels.add( + &format!("{REL}/drawing"), + &format!("../drawings/drawing{number}.xml"), + ); + parts.add_xml( + format!("xl/worksheets/_rels/sheet{number}.xml.rels"), + worksheet_rels.xml(), + ); + let mut drawing_rels = Relationships::default(); + let mut xml = + format!("{XML_HEADER}"); + for (index, chart) in sheet.charts.iter().enumerate() { + let name = format!("chart{number}_{}.xml", index + 1); + let rel = drawing_rels.add(&format!("{REL}/chart"), &format!("../charts/{name}")); + let part = format!("xl/charts/{name}"); + types.part( + &part, + "application/vnd.openxmlformats-officedocument.drawingml.chart+xml", + ); + parts.add_xml(part, chart_xml(book, sheet, chart, computed)); + let anchor = parse_cell(&chart.anchor)?; + let (col, row) = (anchor.col - 1, anchor.row - 1); + let (cx, cy) = (chart.width_px as u64 * 9525, chart.height_px as u64 * 9525); + let id = index + 1; + xml.push_str(&format!( + "{col}0{row}0" + )); + } + xml.push_str(""); + let part = format!("xl/drawings/drawing{number}.xml"); + types.part( + &part, + "application/vnd.openxmlformats-officedocument.drawing+xml", + ); + parts.add_xml(part, xml); + parts.add_xml( + format!("xl/drawings/_rels/drawing{number}.xml.rels"), + drawing_rels.xml(), + ); + Ok(Some(id)) +} + +fn chart_xml( + book: &XlsxDocument, + owner: &Sheet, + chart: &Chart, + computed: &HashMap<(usize, CellRef), Computed>, +) -> String { + let mut xml = format!("{XML_HEADER}"); + if let Some(title) = &chart.title { + xml.push_str(&format!("{}", esc(title))); + } else { + xml.push_str(""); + } + xml.push_str(""); + let tag = match chart.kind { + ChartKind::Column | ChartKind::Bar => "barChart", + ChartKind::Line => "lineChart", + ChartKind::Pie => "pieChart", + }; + xml.push_str(&format!("")); + match chart.kind { + ChartKind::Column | ChartKind::Bar => xml.push_str(&format!( + "", + if chart.kind == ChartKind::Column { + "col" + } else { + "bar" + } + )), + ChartKind::Line => xml.push_str(""), + ChartKind::Pie => {} + } + xml.push_str(&format!( + "", + u8::from(chart.kind == ChartKind::Pie) + )); + for (index, series) in chart.series.iter().enumerate() { + xml.push_str(&format!( + "" + )); + if let Some(name) = &series.name { + xml.push_str(&format!("{}", esc(name))); + } + if let Some((color, _)) = series + .color + .as_deref() + .and_then(|color| parse_color(color).ok()) + { + let fill = format!(""); + let style = if chart.kind == ChartKind::Line { + format!("{fill}") + } else { + fill + }; + xml.push_str(&format!("{style}")); + } + xml.push_str(""); + xml.push_str(&reference_xml( + book, + &owner.name, + &series.categories, + computed, + false, + )); + xml.push_str(""); + xml.push_str(&reference_xml( + book, + &owner.name, + &series.values, + computed, + true, + )); + xml.push_str(""); + } + if chart.kind == ChartKind::Line { + xml.push_str(""); + } + if chart.kind != ChartKind::Pie { + xml.push_str(""); + } + xml.push_str(&format!("")); + if chart.kind != ChartKind::Pie { + let (category, value) = if chart.kind == ChartKind::Bar { + ("l", "b") + } else { + ("b", "l") + }; + xml.push_str(&format!("")); + } + xml.push_str(""); + if chart.legend { + xml.push_str(""); + } + xml.push_str( + "", + ); + xml +} + +fn reference_xml( + book: &XlsxDocument, + owner: &str, + source: &ChartRange, + computed: &HashMap<(usize, CellRef), Computed>, + numeric: bool, +) -> String { + let sheet_name = source.sheet.as_deref().unwrap_or(owner); + let sheet_index = book.sheet_index(sheet_name); + let range = source.vector().ok(); + let formula = match (&range, sheet_index) { + (Some(range), Some(_)) => format!( + "{}!${}${}:${}${}", + super::formula::quote_sheet_name(sheet_name), + column_letters(range.start.col), + range.start.row, + column_letters(range.end.col), + range.end.row + ), + _ => "#REF!".to_string(), + }; + let (tag, cache) = if numeric { + ("numRef", "numCache") + } else { + ("strRef", "strCache") + }; + let mut xml = format!("{}", esc(&formula)); + if numeric { + xml.push_str("General"); + } + let count = range.as_ref().map(|range| range.cell_count()).unwrap_or(0); + xml.push_str(&format!("")); + if let (Some(range), Some(index)) = (range, sheet_index) { + for offset in 0..count as u32 { + let address = if range.start.row == range.end.row { + CellRef::new(range.start.row, range.start.col + offset) + } else { + CellRef::new(range.start.row + offset, range.start.col) + }; + let Some(cell) = book.sheets[index].cells.get(&address) else { + continue; + }; + let value = if cell.formula.is_some() { + match computed.get(&(index, address)) { + Some(Computed::Number(n)) => Some(n.to_string()), + Some(Computed::Text(s)) if !numeric => Some(s.clone()), + Some(Computed::Bool(b)) if !numeric => Some(b.to_string()), + _ => None, + } + } else { + match &cell.value { + Value::Number(n) => Some(n.to_string()), + Value::String(s) if !numeric => Some(s.clone()), + Value::Bool(b) if !numeric => Some(b.to_string()), + _ => None, + } + }; + if let Some(value) = value { + xml.push_str(&format!( + "{}", + esc(&value) + )); + } + } + } + xml.push_str(&format!("")); + xml +} diff --git a/src/crates/execution/office-documents/src/xlsx/charts.rs b/src/crates/execution/office-documents/src/xlsx/charts.rs new file mode 100644 index 0000000000..c0707e950a --- /dev/null +++ b/src/crates/execution/office-documents/src/xlsx/charts.rs @@ -0,0 +1,152 @@ +//! Editable DrawingML charts backed by worksheet ranges. + +use super::address::{parse_cell, parse_range, CellRange}; +use super::model::XlsxDocument; +use crate::common::{parse_color, report_extra, report_list, Extra, UnknownFields}; +use crate::{OfficeError, OfficeResult}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ChartKind { + Column, + Bar, + Line, + Pie, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Chart { + #[serde(rename = "type")] + pub kind: ChartKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + /// Top-left cell, with a fixed pixel extent independent of column widths. + pub anchor: String, + #[serde(default = "default_width")] + pub width_px: u32, + #[serde(default = "default_height")] + pub height_px: u32, + #[serde(default = "default_legend")] + pub legend: bool, + pub series: Vec, + #[serde(flatten)] + pub extra: Extra, +} + +fn default_width() -> u32 { + 640 +} +fn default_height() -> u32 { + 360 +} +fn default_legend() -> bool { + true +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ChartSeries { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + pub categories: ChartRange, + pub values: ChartRange, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub color: Option, + #[serde(flatten)] + pub extra: Extra, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ChartRange { + /// Omitted input means the chart's worksheet; normalized before storage. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sheet: Option, + pub range: String, + #[serde(flatten)] + pub extra: Extra, +} + +impl UnknownFields for Chart { + fn unknown_fields(&self, path: &str, out: &mut Vec) { + report_extra(&self.extra, path, out); + report_list(&self.series, &format!("{path}.series"), out); + } +} +impl UnknownFields for ChartSeries { + fn unknown_fields(&self, path: &str, out: &mut Vec) { + report_extra(&self.extra, path, out); + self.categories + .unknown_fields(&format!("{path}.categories"), out); + self.values.unknown_fields(&format!("{path}.values"), out); + } +} +impl UnknownFields for ChartRange { + fn unknown_fields(&self, path: &str, out: &mut Vec) { + report_extra(&self.extra, path, out); + } +} + +impl ChartRange { + pub(crate) fn vector(&self) -> OfficeResult { + let range = parse_range(&self.range)?; + if range.start.row != range.end.row && range.start.col != range.end.col { + return Err(OfficeError::invalid( + "chart ranges must be a single row or column", + )); + } + // Excel's per-series point limit also bounds cache generation. + if range.cell_count() > 32_000 { + return Err(OfficeError::limit( + "a chart series is limited to 32000 points", + )); + } + Ok(range) + } + + fn normalize(&mut self, book: &XlsxDocument, owner: &str) -> OfficeResult { + let name = self.sheet.as_deref().unwrap_or(owner); + let index = book.sheet_index(name).ok_or_else(|| { + OfficeError::not_found(format!("chart source sheet `{name}` does not exist")) + })?; + self.sheet = Some(book.sheets[index].name.clone()); + let range = self.vector()?; + self.range = range.to_string(); + Ok(range.cell_count() as usize) + } +} + +impl Chart { + pub(crate) fn normalize(&mut self, book: &XlsxDocument, owner: &str) -> OfficeResult<()> { + let mut unknown = Vec::new(); + self.unknown_fields("chart", &mut unknown); + if !unknown.is_empty() { + return Err(OfficeError::invalid(format!( + "unknown field(s): {}", + unknown.join(", ") + ))); + } + self.anchor = parse_cell(&self.anchor)?.to_string(); + if self.width_px == 0 || self.height_px == 0 { + return Err(OfficeError::invalid( + "chart width_px and height_px must be positive", + )); + } + if self.series.is_empty() || self.series.len() > 255 { + return Err(OfficeError::invalid("a chart needs 1..=255 series")); + } + if self.kind == ChartKind::Pie && self.series.len() != 1 { + return Err(OfficeError::invalid("a pie chart needs exactly one series")); + } + for series in &mut self.series { + if series.categories.normalize(book, owner)? != series.values.normalize(book, owner)? { + return Err(OfficeError::invalid( + "chart categories and values must contain the same number of cells", + )); + } + if let Some(color) = &series.color { + parse_color(color)?; + } + } + Ok(()) + } +} diff --git a/src/crates/execution/office-documents/src/xlsx/mod.rs b/src/crates/execution/office-documents/src/xlsx/mod.rs index 8f46c3ec7a..5454fdd8e3 100644 --- a/src/crates/execution/office-documents/src/xlsx/mod.rs +++ b/src/crates/execution/office-documents/src/xlsx/mod.rs @@ -1,12 +1,15 @@ //! Workbooks (`.xlsx`). pub(crate) mod address; +mod chart_render; +pub(crate) mod charts; pub(crate) mod eval; pub(crate) mod formula; pub(crate) mod model; pub(crate) mod ops; pub(crate) mod render; +pub use charts::{Chart, ChartKind, ChartRange, ChartSeries}; pub use model::{Cell, CellStyle, Sheet, XlsxDocument}; use crate::common::preview; @@ -35,7 +38,9 @@ pub(crate) fn read(book: &XlsxDocument, request: &ReadRequest) -> OfficeResult parse_range(range)?, @@ -105,6 +110,7 @@ pub(crate) fn read(book: &XlsxDocument, request: &ReadRequest) -> OfficeResult last_col { @@ -142,6 +148,7 @@ fn list_sheets(book: &XlsxDocument, request: &ReadRequest) -> Value { "cells": sheet.cells.values().filter(|cell| !cell.value.is_null() || cell.formula.is_some()).count(), "formulas": sheet.cells.values().filter(|cell| cell.formula.is_some()).count(), "merges": sheet.merges.len(), + "charts": sheet.charts.len(), "freeze": sheet.freeze, "autofilter": sheet.autofilter, }) diff --git a/src/crates/execution/office-documents/src/xlsx/model.rs b/src/crates/execution/office-documents/src/xlsx/model.rs index 092af02887..f5aee838ff 100644 --- a/src/crates/execution/office-documents/src/xlsx/model.rs +++ b/src/crates/execution/office-documents/src/xlsx/model.rs @@ -26,6 +26,8 @@ pub struct XlsxDocument { #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct Sheet { pub name: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub charts: Vec, #[serde(default, with = "cell_map")] pub cells: BTreeMap, /// Column widths in characters, keyed by one-based column number. @@ -277,6 +279,19 @@ impl XlsxDocument { pub fn warnings(&self) -> Vec { let mut warnings = Vec::new(); for sheet in &self.sheets { + for (index, chart) in sheet.charts.iter().enumerate() { + for series in &chart.series { + for source in [&series.categories, &series.values] { + if source.range.contains("#REF!") + || self + .sheet_index(source.sheet.as_deref().unwrap_or(&sheet.name)) + .is_none() + { + warnings.push(format!("sheet `{}` chart {index}: source range is unavailable; replace_chart with valid references or delete_chart", sheet.name)); + } + } + } + } let mut missing: Vec = Vec::new(); for (cell, value) in &sheet.cells { let Some(formula) = &value.formula else { @@ -329,7 +344,7 @@ impl XlsxDocument { if !uncached.is_empty() { let sample: Vec<&str> = uncached.iter().take(5).map(String::as_str).collect(); warnings.push(format!( - "{} formula cell(s) ({}{}) use functions or references the local evaluator does not compute; they show values once Excel, WPS, or LibreOffice opens the file", + "{} formula cell(s) ({}{}) have no cached value because the local evaluator does not support them. The formulas are preserved for the target spreadsheet application to recalculate; verify function support there. This is not a file-generation failure or a request to install a converter", uncached.len(), sample.join(", "), if uncached.len() > sample.len() { ", …" } else { "" } diff --git a/src/crates/execution/office-documents/src/xlsx/ops.rs b/src/crates/execution/office-documents/src/xlsx/ops.rs index 1853012890..6a3a0efc4e 100644 --- a/src/crates/execution/office-documents/src/xlsx/ops.rs +++ b/src/crates/execution/office-documents/src/xlsx/ops.rs @@ -4,6 +4,7 @@ use super::address::{ column_letters, parse_cell, parse_column, parse_range, CellRange, CellRef, MAX_COLUMNS, MAX_ROWS, }; +use super::charts::Chart; use super::formula::{rename_sheet_in_formula, shift_formula, Axis, Shift}; use super::model::{validate_sheet_name, CellStyle, Freeze, Sheet, XlsxDocument}; use crate::common::{ @@ -37,6 +38,9 @@ pub(crate) const OPERATIONS: &[&str] = &[ "set_autofilter", "set_sheet", "set_properties", + "add_chart", + "replace_chart", + "delete_chart", ]; const MAX_SHEETS: usize = 255; @@ -176,6 +180,22 @@ op_struct!(SetSheet { #[serde(default)] tab_color: Option, }); +op_struct!(AddChart { + #[serde(default)] + sheet: Option, + chart: Chart, +}); +op_struct!(ReplaceChart { + #[serde(default)] + sheet: Option, + index: usize, + chart: Chart, +}); +op_struct!(DeleteChart { + #[serde(default)] + sheet: Option, + index: usize, +}); pub(crate) fn apply( book: &mut XlsxDocument, @@ -184,6 +204,35 @@ pub(crate) fn apply( ctx: &mut ApplyContext, ) -> OfficeResult<()> { match op { + "add_chart" | "replace_chart" => { + let (sheet, at, mut chart) = if op == "add_chart" { + let input: AddChart = parse_operation(value)?; + (input.sheet, None, input.chart) + } else { + let input: ReplaceChart = parse_operation(value)?; + (input.sheet, Some(input.index), input.chart) + }; + let owner = resolve_sheet(book, sheet.as_ref())?; + chart.normalize(book, &book.sheets[owner].name)?; + let charts = &mut book.sheets[owner].charts; + if let Some(index) = at { + crate::common::existing_span(index, Some(1), charts.len(), "chart")?; + charts[index] = chart; + } else { + charts.push(chart); + } + ctx.note(format!("{op} on `{}`", book.sheets[owner].name)); + Ok(()) + } + "delete_chart" => { + let input: DeleteChart = parse_operation(value)?; + let owner = resolve_sheet(book, input.sheet.as_ref())?; + let charts = &mut book.sheets[owner].charts; + crate::common::existing_span(input.index, Some(1), charts.len(), "chart")?; + charts.remove(input.index); + ctx.note(format!("deleted chart {}", input.index)); + Ok(()) + } "add_sheet" => { let input: AddSheet = parse_operation(value)?; validate_sheet_name(&input.name)?; @@ -216,6 +265,21 @@ pub(crate) fn apply( let input: DeleteSheet = parse_operation(value)?; let index = resolve_sheet(book, input.sheet.as_ref())?; let removed = book.sheets.remove(index); + for sheet in &mut book.sheets { + for chart in &mut sheet.charts { + for series in &mut chart.series { + for source in [&mut series.categories, &mut series.values] { + if source + .sheet + .as_deref() + .is_some_and(|name| name.eq_ignore_ascii_case(&removed.name)) + { + source.range = "#REF!".to_string(); + } + } + } + } + } ctx.note(format!("deleted sheet `{}`", removed.name)); Ok(()) } @@ -233,6 +297,19 @@ pub(crate) fn apply( } let old = std::mem::replace(&mut book.sheets[index].name, input.name.clone()); for sheet in &mut book.sheets { + for chart in &mut sheet.charts { + for series in &mut chart.series { + for source in [&mut series.categories, &mut series.values] { + if source + .sheet + .as_deref() + .is_some_and(|name| name.eq_ignore_ascii_case(&old)) + { + source.sheet = Some(input.name.clone()); + } + } + } + } for cell in sheet.cells.values_mut() { if let Some(formula) = &mut cell.formula { *formula = rename_sheet_in_formula(formula, &old, &input.name); @@ -655,8 +732,10 @@ fn write_rows(sheet: &mut Sheet, start: CellRef, rows: &[Vec]) -> OfficeR continue; }; let cell = sheet.cells.entry(address).or_default(); - cell.value = update.value; - cell.formula = update.formula; + if update.write_value { + cell.value = update.value; + cell.formula = update.formula; + } if let Some(style) = update.style { let mut merged = cell.style.take().unwrap_or_default(); merged.merge(&style); @@ -672,6 +751,7 @@ fn write_rows(sheet: &mut Sheet, start: CellRef, rows: &[Vec]) -> OfficeR } struct CellUpdate { + write_value: bool, value: Value, formula: Option, style: Option, @@ -680,6 +760,7 @@ struct CellUpdate { fn parse_cell_input(input: &Value, path: &str) -> OfficeResult> { let simple = |value: Value, formula: Option| { Ok(Some(CellUpdate { + write_value: true, value, formula, style: None, @@ -714,6 +795,16 @@ fn parse_cell_object(object: &Map, path: &str) -> OfficeResult None, Some(value) => { @@ -737,6 +828,7 @@ fn parse_cell_object(object: &Map, path: &str) -> OfficeResult, path: &str) -> OfficeResult anchor.row = new, + Axis::Column => anchor.col = new, + } + chart.anchor = anchor.to_string(); + } + for series in &mut chart.series { + for source in [&mut series.categories, &mut series.values] { + let source_sheet = source.sheet.as_deref().unwrap_or(&host); + source.range = shift_formula(&source.range, source_sheet, &shift); + } + } + } for cell in other.cells.values_mut() { if let Some(formula) = &mut cell.formula { *formula = shift_formula(formula, &host, &shift); diff --git a/src/crates/execution/office-documents/src/xlsx/render.rs b/src/crates/execution/office-documents/src/xlsx/render.rs index cca6c3355e..9118e937be 100644 --- a/src/crates/execution/office-documents/src/xlsx/render.rs +++ b/src/crates/execution/office-documents/src/xlsx/render.rs @@ -69,9 +69,18 @@ pub(crate) fn render( &part, "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml", ); + let drawing = + super::chart_render::render(book, sheet, number, &computed, &mut parts, &mut types)?; parts.add_xml( part, - worksheet_xml(sheet, index, &computed, &mut styles, &mut strings), + worksheet_xml( + sheet, + index, + &computed, + &mut styles, + &mut strings, + drawing.as_deref(), + ), ); } rels.add(REL_STYLES, "styles.xml"); @@ -130,6 +139,7 @@ fn worksheet_xml( computed: &HashMap<(usize, CellRef), Computed>, styles: &mut StyleRegistry, strings: &mut SharedStrings, + drawing: Option<&str>, ) -> String { let selected = sheet_index == 0; let mut xml = format!("{XML_HEADER}"); @@ -245,7 +255,11 @@ fn worksheet_xml( } xml.push_str(""); } - xml.push_str(""); + xml.push_str(""); + if let Some(id) = drawing { + xml.push_str(&format!("")); + } + xml.push_str(""); xml } diff --git a/src/crates/execution/office-documents/tests/end_to_end.rs b/src/crates/execution/office-documents/tests/end_to_end.rs index 5939c6dc16..1b344c17f3 100644 --- a/src/crates/execution/office-documents/tests/end_to_end.rs +++ b/src/crates/execution/office-documents/tests/end_to_end.rs @@ -743,3 +743,356 @@ fn unicode_text_is_preserved_in_word_and_excel_xml() { } } } + +#[test] +fn xlsx_native_charts_follow_data_edits_and_keep_editable_references() { + let mut book = OfficeDocument::new(OfficeFormat::Xlsx, None).unwrap(); + let images = ImageSources::new(); + book.apply(&ops(json!([ + {"op":"add_sheet","name":"数据'明细","rows":[["项目","数量","营收"],["中文",10,"=B2*2"],["日本語",20,"=B3*2"],["한국어",30,"=B4*2"]]}, + {"op":"add_sheet","name":"Charts 图表"} + ])), &images).unwrap(); + for (kind, anchor, title) in [ + ("column", "A1", "季度营收 · Column"), + ("bar", "K1", "条形图 · Bar"), + ("line", "A22", "趋势 · Line"), + ("pie", "K22", "占比 · Pie"), + ] { + book.apply(&[json!({"op":"add_chart","sheet":"Charts 图表","chart":{ + "type":kind,"title":title,"anchor":anchor,"width_px":560,"height_px":310, + "series":[{"name":"营收","categories":{"sheet":"数据'明细","range":"A2:A4"},"values":{"sheet":"数据'明细","range":"C2:C4"}}] + }})], &images).unwrap(); + } + let bytes = book.to_bytes().unwrap(); + keep_sample("native-charts.xlsx", &bytes); + let parts = assert_well_formed(&bytes); + for index in 1..=4 { + let chart = text_of(&parts, &format!("xl/charts/chart2_{index}.xml")); + assert!(chart.contains("")); + assert!(chart.contains("'数据''明细'!$C$2:$C$4")); + assert!(chart.contains("60")); + assert!(chart.contains("日本語")); + if index < 4 { + // Excel treats an omitted axis delete flag as hidden even though + // it still reads the series. Keep both axes explicitly visible. + assert_eq!(chart.matches("").count(), 2); + } + } + assert!(text_of(&parts, "xl/worksheets/sheet2.xml").contains("")); + assert!( + text_of(&parts, "xl/drawings/_rels/drawing2.xml.rels").contains("../charts/chart2_4.xml") + ); + + let mut reopened = OfficeDocument::open(&bytes, &OpenOptions::default()).unwrap(); + reopened + .apply( + &ops(json!([ + {"op":"rename_sheet","sheet":"数据'明细","name":"统计"}, + {"op":"insert_rows","sheet":"统计","row":2}, + {"op":"delete_rows","sheet":"统计","row":4}, + {"op":"insert_columns","sheet":"统计","column":"B"}, + {"op":"set_cells","sheet":"统计","start":"C3","rows":[[12]]} + ])), + &images, + ) + .unwrap(); + let view = reopened + .read(&ReadRequest { + sheet: Some("Charts 图表".into()), + detail: Detail::Full, + ..Default::default() + }) + .unwrap(); + assert_eq!( + view["charts"][0]["series"][0]["values"], + json!({"sheet":"统计","range":"D3:D4"}) + ); + let parts = assert_well_formed(&reopened.to_bytes().unwrap()); + let chart = text_of(&parts, "xl/charts/chart2_1.xml"); + assert!(chart.contains("$D$3:$D$4")); + assert!(chart.contains("24")); + assert!(!chart.contains("40")); + // Replacing preserves its position; delete indexes address the current list. + reopened.apply(&ops(json!([ + {"op":"replace_chart","sheet":"Charts 图表","index":0,"chart":{"type":"line","anchor":"A1","series":[{"categories":{"sheet":"统计","range":"A3:A4"},"values":{"sheet":"统计","range":"D3:D4"}}]}}, + {"op":"delete_chart","sheet":"Charts 图表","index":1} + ])), &images).unwrap(); + let before = reopened.to_bytes().unwrap(); + for chart in [ + json!({"type":"column","anchor":"A1","series":[]}), + json!({"type":"column","anchor":"A1","series":[{"categories":{"range":"A1:A3"},"values":{"range":"B1:B2"}}]}), + json!({"type":"column","anchor":"A1","series":[{"categories":{"range":"A1:A2"},"values":{"range":"B1:B2"},"colour":"red"}]}), + ] { + assert!(reopened + .apply( + &[ + json!({"op":"set_cells","sheet":"统计","rows":[["must roll back"]]}), + json!({"op":"add_chart","sheet":"统计","chart":chart}) + ], + &images + ) + .is_err()); + assert_eq!(reopened.to_bytes().unwrap(), before); + } + let report = reopened + .apply(&[json!({"op":"delete_sheet","sheet":"统计"})], &images) + .unwrap(); + assert!(report + .warnings + .iter() + .any(|warning| warning.contains("source range is unavailable"))); + let parts = assert_well_formed(&reopened.to_bytes().unwrap()); + assert!(text_of(&parts, "xl/charts/chart1_1.xml").contains("#REF!")); +} + +#[test] +fn xlsx_style_only_updates_preserve_literals_formulas_and_reject_invalid_types() { + let images = ImageSources::new(); + let mut book = OfficeDocument::new(OfficeFormat::Xlsx, None).unwrap(); + book.apply(&ops(json!([ + {"op":"add_sheet","name":"数据","rows":[[10,"=A1*2","保留"]]}, + {"op":"set_cells","rows":[[{"style":{"bold":true}},{"style":{"number_format":"0.00"}},null]]} + ])), &images).unwrap(); + let read = ReadRequest { + sheet: Some("数据".into()), + range: Some("A1:C1".into()), + detail: Detail::Full, + ..Default::default() + }; + let view = book.read(&read).unwrap(); + assert_eq!( + view["rows"][0]["cells"], + json!([10,{"formula":"=A1*2","value":20.0},"保留"]) + ); + let before = book.to_bytes().unwrap(); + for cell in [json!({"formula":42}), json!({"type":false,"value":10})] { + assert!(book + .apply(&[json!({"op":"set_cells","rows":[[cell]]})], &images) + .is_err()); + assert_eq!(book.to_bytes().unwrap(), before); + } + book.apply( + &[json!({"op":"set_cells","rows":[[{"value":null}]]})], + &images, + ) + .unwrap(); + assert!(book.read(&read).unwrap()["rows"][0]["cells"][0].is_null()); +} + +#[test] +fn docx_replacement_covers_formatted_text_without_crossing_content_boundaries() { + let images = ImageSources::new(); + let mut doc = OfficeDocument::new( + OfficeFormat::Docx, + Some(&json!({"styles":{"lang":"zh-CN"}})), + ) + .unwrap(); + doc.apply(&ops(json!([ + {"op":"set_header_footer","header":"旧项目 · Header","footer":"旧项目 · {PAGE}/{NUMPAGES}"}, + {"op":"insert_blocks","blocks":[ + {"type":"heading","level":1,"text":"中文报告 · Word"}, + {"type":"paragraph","runs":[{"text":"前缀 旧","bold":true},{"text":"项目 后缀","italic":true}]}, + {"type":"table","rows":[["指标","数值"],[{"runs":[{"text":"旧","bold":true},{"text":"项目"}]},42]],"caption":"旧项目 · 表格"}, + {"type":"code","text":"旧项目 = 42"}, + {"type":"list","items":["旧", "项目"]}, + {"type":"paragraph","text":"日本語の文章"}, + {"type":"paragraph","runs":[{"text":"한국어 문장","lang":"ko-KR"}]}, + {"type":"paragraph","text":"Emoji 🙂 · English 123"} + ]}, + {"op":"replace_text","find":"旧项目","replace":"新产品"} + ])), &images).unwrap(); + let view = doc + .read(&ReadRequest { + detail: Detail::Full, + ..Default::default() + }) + .unwrap(); + assert_eq!(view["blocks"][1]["runs"][0]["text"], "前缀 新产品"); + assert_eq!(view["blocks"][1]["runs"][1]["text"], " 后缀"); + assert_eq!(view["blocks"][1]["runs"][0]["bold"], true); + assert_eq!(view["blocks"][1]["runs"][1]["italic"], true); + assert_eq!(view["blocks"][3]["text"], "新产品 = 42"); + assert_eq!(view["blocks"][4]["items"][0]["runs"][0]["text"], "旧"); + let parts = assert_well_formed(&doc.to_bytes().unwrap()); + assert!(text_of(&parts, "word/header1.xml").contains("旧项目")); + assert!(text_of(&parts, "word/document.xml").contains("新产品 · 表格")); + doc.apply(&[json!({"op":"replace_text","find":"旧项目","replace":"新产品","include_headers_footers":true})], &images).unwrap(); + let bytes = doc.to_bytes().unwrap(); + keep_sample("word-edits.docx", &bytes); + let parts = assert_well_formed(&bytes); + assert!(text_of(&parts, "word/header1.xml").contains("新产品")); + assert!(text_of(&parts, "word/footer1.xml").contains("NUMPAGES")); + assert!(text_of(&parts, "word/styles.xml").contains("w:eastAsia=\"zh-CN\"")); + assert!(text_of(&parts, "word/document.xml").contains("w:eastAsia=\"ko-KR\"")); + let code_font = ""; + assert!(text_of(&parts, "word/document.xml").contains(code_font)); + let reopened = OfficeDocument::open(&bytes, &OpenOptions::default()).unwrap(); + assert_eq!( + reopened + .read(&ReadRequest { + detail: Detail::Full, + ..Default::default() + }) + .unwrap(), + doc.read(&ReadRequest { + detail: Detail::Full, + ..Default::default() + }) + .unwrap() + ); + for block in [ + json!({"type":"table","rows":[[{"text":"too wide","colspan":63},"extra"]]}), + json!({"type":"table","rows":[["bad size"]],"font_size_pt":-2}), + ] { + assert!(doc + .apply(&[json!({"op":"insert_blocks","blocks":[block]})], &images) + .is_err()); + assert_eq!(doc.to_bytes().unwrap(), bytes); + } +} + +#[test] +fn legacy_word_and_excel_models_keep_defaults_and_unknown_fields() { + use openbitfun_office_documents::{docx::DocxDocument, xlsx::XlsxDocument}; + // Hand-authored old shapes, not snapshots generated by the current writer. + let word = + json!({"blocks":[{"type":"paragraph","runs":[{"text":"旧文档"}]}],"future":{"value":1}}); + let doc: DocxDocument = serde_json::from_value(word).unwrap(); + assert!(doc.styles.lang.is_none()); + assert!(serde_json::to_value(&doc).unwrap()["blocks"][0]["runs"][0] + .get("math") + .is_none()); + let round_trip = serde_json::to_value(doc).unwrap(); + assert_eq!(round_trip["future"], json!({"value":1})); + assert!(round_trip["styles"].get("lang").is_none()); + let workbook = json!({"sheets":[{"name":"旧表","cells":{"A1":{"value":"中文"}},"future_sheet":{"value":2}}]}); + let book: XlsxDocument = serde_json::from_value(workbook).unwrap(); + assert!(book.sheets[0].charts.is_empty()); + let round_trip = serde_json::to_value(book).unwrap(); + assert_eq!(round_trip["sheets"][0]["cells"]["A1"]["value"], "中文"); + assert_eq!(round_trip["sheets"][0]["future_sheet"], json!({"value":2})); + assert!(round_trip["sheets"][0].get("charts").is_none()); +} + +#[test] +fn academic_word_preserves_structure_and_native_latex_equations_across_edits() { + let images = ImageSources::new(); + let mut doc = OfficeDocument::new( + OfficeFormat::Docx, + Some(&json!({ + "styles":{"font":"Times New Roman","font_size_pt":12,"line_spacing":1.5}, + "page":{"size":"a4","margins_cm":{"top":2.5,"bottom":2.5,"left":2.5,"right":2.5}} + })), + ) + .unwrap(); + let source = r#"# 论文排版与公式验证 / Academic Paper + +## 摘要 + +这是一份用于验证文档生成的测试论文,不包含真实研究结论。本文检查行内公式 $E=mc^2$ 与跨页结构。引用标记 [1] 按正文文本保留。 + +[TOC] + + + +## 1 方法 + +### 1.1 二次方程 + +独立公式应当生成可编辑的 Word 公式,而不是显示 TeX 源码: + +$$x = \frac{-b\pm\sqrt{b^2-4ac}}{2a}$$ + +### 1.2 求和与积分 + +$$S_n = \sum_{i=1}^{n} x_i^2$$ + +$$\int_0^1 x^2\,dx = \frac{1}{3}$$ + +### 1.3 矩阵与符号 + +$$A = \begin{pmatrix}a&b\\c&d\end{pmatrix}$$ + +行内变量 $\hat{x}$、$\alpha$ 与 $\beta$ 保持公式语义。 + +## 2 验证结果 + +| 检查项 | 预期 | +| --- | --- | +| 分式与根号 | 原生公式 | +| 目录与标题 | 可读结构 | +| 中文与 English | 原文保留 | + +表 1:测试场景。这里只验证格式,不代表数学证明。 + +## 参考文献 + +[1] OpenBitFun. 文档生成验证样例,2026。此条目为测试数据。 +"#; + doc.apply(&ops(json!([ + {"op":"set_header_footer","header":"论文验证 · Academic Paper","footer":"{PAGE} / {NUMPAGES}"}, + {"op":"insert_blocks","markdown":source} + ])), &images).unwrap(); + let bytes = doc.to_bytes().unwrap(); + keep_sample("academic-paper.docx", &bytes); + let parts = assert_well_formed(&bytes); + let xml = text_of(&parts, "word/document.xml"); + for native in [ + "", + "", + "", + "", + "", + "", + "TOC", + "Heading2", + "", + ] { + assert!(xml.contains(native), "missing {native}"); + } + assert!(!xml.contains("\\frac")); + assert!(!xml.contains("\\begin{pmatrix}")); + let mut reopened = OfficeDocument::open(&bytes, &OpenOptions::default()).unwrap(); + let full = ReadRequest { + detail: Detail::Full, + limit: Some(100), + ..Default::default() + }; + let view = reopened.read(&full).unwrap(); + assert!(view.to_string().contains("\\\\frac")); + let equation_index = view["blocks"] + .as_array() + .unwrap() + .iter() + .position(|block| { + block["runs"][0]["text"] + .as_str() + .is_some_and(|text| text.starts_with("S_n")) + }) + .unwrap(); + reopened.apply(&[json!({"op":"replace_blocks","index":equation_index,"blocks":[{"type":"paragraph","runs":[{"text":"S_n = \\sum_{i=1}^{n} i","math":"display"}]}]})], &images).unwrap(); + // Text replacement must not alter LaTeX source or match across a formula. + reopened + .apply( + &[json!({"op":"replace_text","find":"S_n","replace":"changed"})], + &images, + ) + .unwrap(); + assert!( + reopened.read(&full).unwrap()["blocks"][equation_index]["runs"][0]["text"] + .as_str() + .unwrap() + .starts_with("S_n") + ); + let before = reopened.to_bytes().unwrap(); + for markdown in [r"Bad $\unknownCommand{x}$", r"Bad $\frac{1}$"] { + assert!(reopened + .apply( + &[json!({"op":"insert_blocks","markdown":markdown})], + &images + ) + .is_err()); + assert_eq!(reopened.to_bytes().unwrap(), before); + } +} From 93dd9e6138696fad20d21d1594f37332bf330522 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Fri, 6 Nov 2026 16:04:11 +0800 Subject: [PATCH 2/2] fix(office): define portable font inheritance and recovery --- .../implementations/gen_office_file_tool.rs | 38 ++- .../execution/office-documents/src/common.rs | 7 + .../office-documents/src/docx/model.rs | 5 +- .../office-documents/src/docx/render.rs | 56 ++-- .../office-documents/src/font_contract.rs | 132 ++++++++ .../execution/office-documents/src/guide.rs | 6 + .../office-documents/src/guides/docx.md | 15 +- .../office-documents/src/guides/fonts.md | 29 ++ .../office-documents/src/guides/pptx.md | 16 +- .../src/guides/verification.md | 2 +- .../office-documents/src/guides/xlsx.md | 8 +- .../execution/office-documents/src/lib.rs | 3 + .../src/operation_contract.rs | 40 +-- .../office-documents/src/pptx/model.rs | 29 ++ .../office-documents/src/pptx/render.rs | 44 ++- .../office-documents/src/xlsx/ops.rs | 5 + .../office-documents/src/xlsx/render.rs | 2 +- .../office-documents/tests/end_to_end.rs | 285 ++++++++++++++++++ 18 files changed, 650 insertions(+), 72 deletions(-) create mode 100644 src/crates/execution/office-documents/src/font_contract.rs create mode 100644 src/crates/execution/office-documents/src/guides/fonts.md diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/gen_office_file_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/gen_office_file_tool.rs index 3d167a5008..42d6a878ba 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/gen_office_file_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/gen_office_file_tool.rs @@ -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}; @@ -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)) } @@ -265,7 +268,7 @@ impl GenOfficeFileTool { format.as_str(), document.revision() ); - text.push_str("\nGeneration complete. 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."); + 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."); } @@ -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. @@ -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." @@ -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" } }, @@ -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), )]); @@ -674,6 +678,7 @@ impl Tool for GenOfficeFileTool { "changes": report.changes, "warnings": report.warnings, "visual_rendered": false, + "fonts_verified": false, }), Some(text), )]) @@ -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}"); @@ -1035,7 +1043,7 @@ mod tests { ( "docx", json!([ - {"op":"insert_blocks","blocks":[{"type":"paragraph","runs":[{"text":"旧","bold":true},{"text":"项目"}]}]}, + {"op":"insert_blocks","blocks":[{"type":"paragraph","runs":[{"text":"旧","bold":true,"latin_font":"Arial"},{"text":"项目"}]}]}, {"op":"insert_blocks","markdown":"论文公式 $E=mc^2$"} ]), json!([ @@ -1047,10 +1055,12 @@ mod tests { "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"}}]}}), @@ -1061,7 +1071,7 @@ mod tests { {"op":"insert_slides","slides":[{"title":"旧项目","elements":[]}]} ]), json!([ - {"op":"replace_slide","index":0,"slide":{"title":"新产品","elements":[]}} + {"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}), ), @@ -1090,6 +1100,7 @@ mod tests { .. } => { assert_eq!(data["visual_rendered"], false); + assert_eq!(data["fonts_verified"], false); assert!(text.contains("Generation complete")); assert!(text.contains("Use read")); } @@ -1111,9 +1122,12 @@ mod tests { 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( diff --git a/src/crates/execution/office-documents/src/common.rs b/src/crates/execution/office-documents/src/common.rs index 6a8907971e..0860f74236 100644 --- a/src/crates/execution/office-documents/src/common.rs +++ b/src/crates/execution/office-documents/src/common.rs @@ -192,6 +192,13 @@ pub struct Run { pub size_pt: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub font: Option, + /// Script-specific overrides. `font` keeps its legacy all-script meaning. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub latin_font: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub east_asia_font: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub complex_script_font: Option, /// BCP 47 text language; omitted legacy runs keep automatic language selection. #[serde(default, skip_serializing_if = "Option::is_none")] pub lang: Option, diff --git a/src/crates/execution/office-documents/src/docx/model.rs b/src/crates/execution/office-documents/src/docx/model.rs index 9d630baf54..8f31d78010 100644 --- a/src/crates/execution/office-documents/src/docx/model.rs +++ b/src/crates/execution/office-documents/src/docx/model.rs @@ -148,12 +148,14 @@ pub(crate) fn cm_to_twips(cm: f64) -> u32 { pub struct DocxStyles { #[serde(default, skip_serializing_if = "Option::is_none")] pub lang: Option, - /// Latin body font; Word's default when absent. + /// Legacy Latin and complex-script body font. East Asian text is separate. #[serde(default, skip_serializing_if = "Option::is_none")] pub font: Option, /// Font for CJK text, such as "Microsoft YaHei" or "SimSun". #[serde(default, skip_serializing_if = "Option::is_none")] pub east_asia_font: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub complex_script_font: Option, #[serde(default = "default_body_size", alias = "size_pt")] pub font_size_pt: f64, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -196,6 +198,7 @@ impl Default for DocxStyles { lang: None, font: None, east_asia_font: None, + complex_script_font: None, font_size_pt: default_body_size(), heading_font: None, heading_color: default_heading_color(), diff --git a/src/crates/execution/office-documents/src/docx/render.rs b/src/crates/execution/office-documents/src/docx/render.rs index e3f70afacf..e36060b324 100644 --- a/src/crates/execution/office-documents/src/docx/render.rs +++ b/src/crates/execution/office-documents/src/docx/render.rs @@ -273,20 +273,31 @@ impl Writer<'_> { if link.is_some() { rpr.push_str(""); } - let font = run - .font + let code_font = (run.code == Some(true)).then_some("Consolas"); + let latin = run + .latin_font .as_deref() - .or_else(|| (run.code == Some(true)).then_some("Consolas")); - if let Some(font) = font { - let font = esc(font); - let east_asia = if run.font.is_some() { - format!(" w:eastAsia=\"{font}\"") - } else { - String::new() - }; - rpr.push_str(&format!( - "" - )); + .or(run.font.as_deref()) + .or(code_font); + let east_asia = run.east_asia_font.as_deref().or(run.font.as_deref()); + let complex = run + .complex_script_font + .as_deref() + .or(run.font.as_deref()) + .or(code_font); + if latin.is_some() || east_asia.is_some() || complex.is_some() { + rpr.push_str(""); } let bold = run.bold.or(defaults.and_then(|d| d.bold)); if bold == Some(true) { @@ -775,6 +786,17 @@ fn styles_xml(doc: &DocxDocument) -> String { .heading_font .as_deref() .unwrap_or(styles.font.as_deref().unwrap_or("Calibri"))); + let complex = esc(styles + .complex_script_font + .as_deref() + .or(styles.font.as_deref()) + .unwrap_or("Calibri")); + let heading_complex = esc(styles + .complex_script_font + .as_deref() + .or(styles.heading_font.as_deref()) + .or(styles.font.as_deref()) + .unwrap_or("Calibri")); let east_asia = styles .east_asia_font .as_deref() @@ -806,7 +828,7 @@ fn styles_xml(doc: &DocxDocument) -> String { "", ); out.push_str(&format!( - "{text_color}{language}" + "{text_color}{language}" )); out.push_str(""); out.push_str(""); @@ -818,13 +840,13 @@ fn styles_xml(doc: &DocxDocument) -> String { let size = (heading_sizes[level - 1].max(styles.font_size_pt) * 2.0).round() as i64; let italic = if level >= 5 { "" } else { "" }; out.push_str(&format!( - "{italic}", + "{italic}", spacing_before[level - 1], level - 1 )); } out.push_str(&format!( - "" + "" )); out.push_str(""); out.push_str(""); @@ -832,7 +854,7 @@ fn styles_xml(doc: &DocxDocument) -> String { out.push_str(""); out.push_str(""); out.push_str(&format!( - "" + "" )); for level in 1..=6u32 { out.push_str(&format!( diff --git a/src/crates/execution/office-documents/src/font_contract.rs b/src/crates/execution/office-documents/src/font_contract.rs new file mode 100644 index 0000000000..62e8d503f4 --- /dev/null +++ b/src/crates/execution/office-documents/src/font_contract.rs @@ -0,0 +1,132 @@ +//! Font input and schema facts. No host font inventory or viewer assumptions. + +use crate::{OfficeError, OfficeResult}; +use serde_json::{json, Value}; + +/// Validate only newly supplied input, never a persisted model. Legacy names +/// must remain readable even when they would not be accepted as a new choice. +pub fn validate_font_input(value: &Value, path: &str) -> OfficeResult<()> { + match value { + Value::Object(object) => { + for (key, value) in object { + let path = format!("{path}.{key}"); + if matches!( + key.as_str(), + "font" + | "font_name" + | "heading_font" + | "latin_font" + | "east_asia_font" + | "complex_script_font" + ) && !value.is_null() + { + let name = value.as_str().ok_or_else(|| { + OfficeError::invalid(format!( + "{path}: use one font family name as a string; omit it to inherit" + )) + })?; + if name.trim().is_empty() || name.chars().any(char::is_control) { + return Err(OfficeError::invalid(format!( + "{path}: font family names must be non-empty and contain no control characters; omit the field to inherit or use the guide's font reset operation" + ))); + } + } else { + validate_font_input(value, &path)?; + } + } + } + Value::Array(items) => { + for (index, item) in items.iter().enumerate() { + validate_font_input(item, &format!("{path}[{index}]"))?; + } + } + _ => {} + } + Ok(()) +} + +pub(crate) fn family_schema(scope: &str) -> Value { + json!({"type": ["string", "null"], "description": format!("{scope} One family name; omit to inherit. Not embedded or verified.")}) +} + +pub(crate) fn run_schema() -> Value { + json!({"type":"object", "properties": { + "text":{"type":"string", "description":"Ordinary Unicode, not XML/HTML-escaped text."}, + "font":family_schema("Legacy shorthand: overrides Latin, East Asian and complex-script fonts for this entire run."), + "latin_font":family_schema("Overrides only Latin text; leaves other scripts inherited."), + "east_asia_font":family_schema("Overrides only East Asian text."), + "complex_script_font":family_schema("Overrides only complex-script text."), + "lang":{"type":"string", "description":"BCP 47 language, not encoding or a font selection. Omit for text-based selection."} + }, "additionalProperties":true}) +} + +pub(crate) fn text_object_schema() -> Value { + let mut schema = run_schema(); + let properties = schema["properties"].as_object_mut().unwrap(); + properties.insert( + "font".into(), + family_schema( + "Legacy all-script default at this scope; more specific paragraph/run overrides win.", + ), + ); + properties.insert("size_pt".into(), json!({"type":"number"})); + properties.insert("color".into(), json!({"type":"string"})); + properties.insert("runs".into(), json!({"type":"array", "items":run_schema()})); + properties.insert("type".into(), json!({"type":"string"})); + properties.insert("index".into(), json!({"type":"integer", "minimum":0})); + schema["description"] = json!("PPTX element or paragraph defaults. Run overrides win; script-specific fields override font at the same scope. See guide for geometry and content fields."); + schema +} + +pub(crate) fn block_schema() -> Value { + json!({"type":"object", "properties":{ + "type":{"type":"string"}, "text":{"type":"string"}, + "runs":{"type":"array", "items":run_schema()} + }, "description":"Word block. Font overrides belong inside runs, not on the block. See guide for tables, lists and other block fields.", "additionalProperties":true}) +} + +pub(crate) fn element_schema() -> Value { + let mut schema = text_object_schema(); + for field in ["x", "y", "w", "h"] { + schema["properties"][field] = json!({"type":"number"}); + } + let mut paragraph = text_object_schema(); + // Paragraphs also accept plain strings; do not make an object-only schema. + paragraph.as_object_mut().unwrap().remove("type"); + schema["properties"]["paragraphs"] = json!({"type":"array", "items":paragraph}); + schema +} + +pub(crate) fn cell_style_schema() -> Value { + json!({"type":"object", "description":"XLSX cell formatting; omitted fields keep existing formatting. No language, encoding, or per-script font fields.", "properties":{ + "font_name":family_schema("One cell font. Null/omission preserves an existing override; format_range.reset_font clears it."), + "font":family_schema("Alias of font_name; do not supply both."), + "font_size":{"type":"number", "description":"Points."}, + "font_color":{"type":"string"}, "bold":{"type":"boolean"}, + "italic":{"type":"boolean"}, "wrap":{"type":"boolean"}, + "number_format":{"type":"string"} + }, "additionalProperties":true}) +} + +pub fn settings_schema() -> Value { + let fonts = json!({ + "font":family_schema("Default Latin font only. Does not force the East Asian font."), + "east_asia_font":family_schema("Default East Asian font; omit to keep viewer/theme fallback."), + "complex_script_font":family_schema("Default complex-script font; omit to inherit."), + "lang":{"type":"string", "description":"Optional BCP 47 language, not encoding. Does not verify font availability."} + }); + let mut styles = fonts.clone(); + styles["font"] = + family_schema("DOCX legacy Latin and complex-script default; East Asian font is separate."); + styles["heading_font"] = family_schema( + "DOCX Latin and complex-script heading font; complex_script_font takes precedence.", + ); + styles["font_size_pt"] = json!({"type":"number"}); + json!({"type":"object", "description":"Create defaults: DOCX page/styles/header/footer/properties; PPTX slide_size/theme/properties; XLSX properties only. Omit font choices unless the user/template requires them.", "properties":{ + "styles":{"type":"object", "description":"DOCX document styles.", "properties":styles, "additionalProperties":true}, + "theme":{"type":"object", "description":"PPTX theme. Element/run font overrides can supersede it.", "properties":fonts, "additionalProperties":true}, + "page":{"type":"object", "additionalProperties":true}, + "properties":{"type":"object", "additionalProperties":true}, + "slide_size":{"type":"string", "enum":["16:9","4:3"]} + }, "additionalProperties":true}) +} diff --git a/src/crates/execution/office-documents/src/guide.rs b/src/crates/execution/office-documents/src/guide.rs index a8eb297008..8787b4a656 100644 --- a/src/crates/execution/office-documents/src/guide.rs +++ b/src/crates/execution/office-documents/src/guide.rs @@ -3,16 +3,22 @@ use crate::OfficeFormat; const DOCX: &str = concat!( + include_str!("guides/fonts.md"), + "\n", include_str!("guides/docx.md"), "\n", include_str!("guides/verification.md") ); const XLSX: &str = concat!( + include_str!("guides/fonts.md"), + "\n", include_str!("guides/xlsx.md"), "\n", include_str!("guides/verification.md") ); const PPTX: &str = concat!( + include_str!("guides/fonts.md"), + "\n", include_str!("guides/pptx.md"), "\n", include_str!("guides/verification.md") diff --git a/src/crates/execution/office-documents/src/guides/docx.md b/src/crates/execution/office-documents/src/guides/docx.md index 56b2a5050e..86d0665741 100644 --- a/src/crates/execution/office-documents/src/guides/docx.md +++ b/src/crates/execution/office-documents/src/guides/docx.md @@ -2,7 +2,7 @@ A Word document is an ordered list of **blocks** (0-based indexes) plus document settings. Build it over several `edit` calls: write the skeleton first, then insert sections one or a few at a time. `read` pages over blocks with `offset`/`limit` and returns the heading outline, so you can find where to insert next. -Pass ordinary Unicode text; do not manually transcode or pre-escape XML. The tool writes UTF-8. Choose fonts installed in the intended viewer; fonts are not embedded. File generation does not require an Office installation, and `read` is a model inspection, not a rendered page preview. +File generation does not require an Office installation. `read` inspects the stored model, not rendered pages. ## Blocks @@ -21,7 +21,7 @@ Every block is an object with `type`. Text fields take either `text` (plain stri | `horizontal_rule` | — | | `toc` | `title`, `levels` (default 3). Lists headings; Word computes page numbers. Refresh the field after pagination if needed | -**Run** fields: `text`, `math` (`inline` or `display`, treats `text` as LaTeX math), `bold`, `italic`, `underline`, `strike`, `code`, `superscript`, `subscript`, `color` (`#RRGGBB`), `highlight` (`#RRGGBB`), `size_pt`, `font`, `lang` (optional BCP 47 language, e.g. `zh-CN`), `link` (http/https/mailto URL). +**Run** fields: `text`, `math` (`inline` or `display`, treats `text` as LaTeX math), `bold`, `italic`, `underline`, `strike`, `code`, `superscript`, `subscript`, `color` (`#RRGGBB`), `highlight` (`#RRGGBB`), `size_pt`, `font`, `latin_font`, `east_asia_font`, `complex_script_font`, `lang` (optional BCP 47 language, e.g. `zh-CN`), `link` (http/https/mailto URL). ### Markdown @@ -45,7 +45,7 @@ Unsupported syntax/structures fail the whole batch with an actionable error; sim | `move_blocks` | `index`, `count` (default 1), `to` | moves blocks; `to` is the position in the list after they are removed | | `replace_text` | `find`, `replace`, `match_case` (default true), `include_headers_footers` (default false) | replaces body text, including text split across formatted runs; replacement inherits the first matched run’s format. Matches never cross paragraphs, list items or table cells. `include_headers_footers: true` also updates header/footer text | | `set_page_setup` | `size` (`a4` `letter` `legal` `a3` `a5`), `orientation` (`portrait` `landscape`), `margins_cm` `{top,bottom,left,right}` | partial update | -| `set_styles` | `lang` (BCP 47, optional), `font`, `east_asia_font`, `font_size_pt`, `heading_font`, `heading_color`, `text_color`, `line_spacing`, `paragraph_space_after_pt`, `table_header_fill` | partial update of document-wide styles | +| `set_styles` | `lang` (BCP 47, optional), `font`, `east_asia_font`, `complex_script_font`, `font_size_pt`, `heading_font`, `heading_color`, `text_color`, `line_spacing`, `paragraph_space_after_pt`, `table_header_fill` | partial update of document-wide styles | | `set_header_footer` | `header`, `footer`: a string, `{text, align}`, or `null` to remove. `{PAGE}` and `{NUMPAGES}` become page fields | | | `set_properties` | `title`, `subject`, `author`, `keywords`, `description`, `category` | document metadata | @@ -57,6 +57,15 @@ Prefer `replace_blocks` for content corrections instead of insert/delete/move se `settings` may carry `page`, `styles`, `header`, `footer`, `properties` (same fields as above). CJK run languages follow their text unless overridden by run `lang` or `styles.lang`. Use the style defaults unless document requirements call for specific fonts; `styles.east_asia_font` sets the East Asian font. Font availability and substitution depend on the viewing environment, which may differ from the generation environment. +### Font scope and recovery + +- `styles.font` and `styles.heading_font` select Latin and complex-script fonts; `styles.complex_script_font` can override the latter; `styles.east_asia_font` selects the East Asian font. With no font overrides, the engine uses Calibri for Latin text and leaves East Asian selection to the viewer. +- `run.font` retains its all-script shorthand meaning. Run `latin_font`, `east_asia_font`, and `complex_script_font` override only their script and take precedence over `run.font`; unset scripts inherit document styles. Use `latin_font` for a Latin-only change in a mixed-language run. These fields work in paragraphs, headings, lists and table-cell runs. Equation fonts are selected by the native equation renderer. +- Remove global overrides with `{"op":"set_styles","font":null,"heading_font":null,"east_asia_font":null,"complex_script_font":null}`. This preserves text, sizes and other styles. Existing local run overrides still win; replace the affected block with the same content and those font fields omitted to restore inheritance. +- `lang` does not select a font. Run language overrides `styles.lang`; otherwise CJK language follows the text. Set `zh-TW`, `ja-JP`, etc. explicitly when Han-only text is ambiguous. + +Example of a required Latin-only override: `{"type":"paragraph","runs":[{"text":"Report 中文 日本語 한국어","latin_font":"Arial"}]}`. Omit `latin_font` too when no particular family is required. + ## Example ```json diff --git a/src/crates/execution/office-documents/src/guides/fonts.md b/src/crates/execution/office-documents/src/guides/fonts.md new file mode 100644 index 0000000000..87ffc7736b --- /dev/null +++ b/src/crates/execution/office-documents/src/guides/fonts.md @@ -0,0 +1,29 @@ +## Text and font contract — all formats + +Send ordinary Unicode strings. The engine writes UTF-8 XML and escapes markup; +do not transcode text, pre-escape XML/HTML, or replace non-Latin characters. +`lang` is a language hint, not an encoding or a guarantee of font coverage. + +Leave font fields absent unless the user or a supplied template requires a +particular family. A professional appearance can use the defaults with deliberate +sizes, weights and spacing. Do not infer a font requirement from the generation +host's OS or copy an unverified font choice from a different document format. + +When required, supply one family name recognized by the intended viewer: no CSS +fallback list, font-file path, surrounding quote characters, or guessed PostScript +name. Font names are preserved, not rewritten using a platform-specific alias +table. Fonts are not embedded, installed, or checked against a host font catalog. +The generating host and the viewer can be different machines. A locally installed +font or a successful preview in another app does not prove target-viewer support. + +Omission preserves inheritance when creating content; patch omission preserves +the existing value. Follow each format's reset operation to remove an override. +In Word/PPT, an explicit local all-script `font` can override the document's East +Asian font. Use script-specific fields there when only one writing system should +change. Excel has a single font per cell; use its format-specific reset operation. + +If displayed glyphs are wrong, first read the stored text. If Unicode is correct, +inspect and reset the relevant font overrides before selecting a verified family; +do not rewrite the text or change its encoding. `fonts_verified:false` means the +tool has not tested fonts in the intended viewer. Empty structural warnings do +not change that status. Report the actual verification scope when delivering. diff --git a/src/crates/execution/office-documents/src/guides/pptx.md b/src/crates/execution/office-documents/src/guides/pptx.md index 5f5d16d8c4..2a078a2124 100644 --- a/src/crates/execution/office-documents/src/guides/pptx.md +++ b/src/crates/execution/office-documents/src/guides/pptx.md @@ -11,9 +11,13 @@ A deck is a list of **slides** (0-based). Each slide is a fixed canvas — **128 ## Text and fonts -Pass ordinary Unicode text; do not manually transcode or pre-escape XML. The tool writes UTF-8. Set deck defaults at create with `settings.theme`, or later with `set_theme`: `{font, east_asia_font, text_color, lang}`. Use the theme defaults unless document requirements call for specific fonts. Fonts are not embedded; availability and substitution depend on the viewing environment, which may differ from the generation environment. +Set deck defaults at create with `settings.theme`, or later with `set_theme`: `{font, east_asia_font, complex_script_font, text_color, lang}`. Use the theme defaults unless document requirements call for specific fonts. Fonts are not embedded; availability and substitution depend on the viewing environment, which may differ from the generation environment. -`lang` is an optional BCP 47 override on the theme, text/shape element, paragraph, or run (most specific wins). Omit it for automatic selection: kana → `ja-JP`, Hangul → `ko-KR`, Han → `zh-CN`, otherwise `en-US`. Set the intended language explicitly when script alone is ambiguous, such as Traditional Chinese or Han-only Japanese/Korean. `font` on an element/run overrides its font; `theme.east_asia_font` supplies the theme's East Asian font. +`lang` is an optional BCP 47 override on the theme, text/shape element, paragraph, or run (most specific wins). Omit it for automatic selection: kana → `ja-JP`, Hangul → `ko-KR`, Han → `zh-CN`, otherwise `en-US`. Set the intended language explicitly when script alone is ambiguous, such as Traditional Chinese or Han-only Japanese/Korean. `theme.font` selects Latin text; `theme.east_asia_font` supplies the theme's East Asian font; `theme.complex_script_font` selects complex-script text. With neither set, Latin defaults to Calibri and the theme supplies language/script fallbacks. + +Font precedence is run → paragraph → element/table → theme. At each local scope, `latin_font`, `east_asia_font` and `complex_script_font` affect only their script; `font` is the legacy all-script shorthand. A run's `font` therefore outranks an element's `east_asia_font`. Prefer `latin_font` for Latin-only styling of mixed text, e.g. `{type:"text", x:40, y:40, w:1000, h:100, text:"Report 中文 日本語 한국어", latin_font:"Arial"}` when that family is required. + +To restore theme defaults, use `{"op":"set_theme","font":null,"east_asia_font":null,"complex_script_font":null}`. To remove an element override, set its font fields to `null` in `update_elements`. Explicit paragraph/run overrides remain; `read` the slide and replace its `paragraphs` with the same content and those font fields omitted. Keep rich formatting when doing so. Resetting a theme never silently deletes local formatting. ## Slide @@ -21,7 +25,7 @@ Pass ordinary Unicode text; do not manually transcode or pre-escape XML. The too ## Elements -Colors are `#RRGGBB` or `#RRGGBBAA` (alpha). Text-bearing elements accept `text` (string, `\n` = new paragraph) or `paragraphs`, plus element-level defaults `size_pt`, `bold`, `italic`, `color`, `font`, `lang`, `align`. Defaults apply only where a run/paragraph does not set its own value; changing element size/color/font does not override explicit values inside runs. +Colors are `#RRGGBB` or `#RRGGBBAA` (alpha). Text-bearing elements accept `text` (string, `\n` = new paragraph) or `paragraphs`, plus element-level defaults `size_pt`, `bold`, `italic`, `color`, `font`, `latin_font`, `east_asia_font`, `complex_script_font`, `lang`, `align`. Defaults apply only where a run/paragraph does not set its own value; changing element size/color/font does not override explicit values inside runs. **text** — `{type: "text", x, y, w, h, text | paragraphs, valign (top|middle|bottom, default top), rotation, …defaults}`. Zero inner padding; the box wraps at `w` and does not grow, so size it for its content. @@ -29,11 +33,11 @@ Colors are `#RRGGBB` or `#RRGGBBAA` (alpha). Text-bearing elements accept `text` **image** — `{type: "image", src, x, y, w, h, fit: cover|contain|stretch (default cover), alt, rotation}`. `src` is an absolute local path or a data URL (PNG/JPEG/GIF/BMP; download web images to a local file first). `cover` center-crops to fill the box, so give the box roughly the picture's aspect ratio. After embedding, `read` shows `asset`; keep it to reuse the picture. -**table** — `{type: "table", x, y, w, h, rows, header (default true), column_widths (px, scaled to w), font_size_pt (default 14), font, header_fill, header_color, fill, band_fill ("" disables banding), border_color, text_color}`. Cells are strings/numbers or `{text/runs, bold, color, fill, align}`. Rows share `h` equally. At most 60 rows × 30 columns. +**table** — `{type: "table", x, y, w, h, rows, header (default true), column_widths (px, scaled to w), font_size_pt (default 14), font, latin_font, east_asia_font, complex_script_font, header_fill, header_color, fill, band_fill ("" disables banding), border_color, text_color}`. Cells are strings/numbers or `{text/runs, bold, color, fill, align}`. Rows share `h` equally. At most 60 rows × 30 columns. -**Paragraph** — a string, or `{text | runs, align (left|center|right|justify), line_spacing_pct, space_before_pt, space_after_pt, bullet, numbered, level, size_pt, bold, italic, color, font, lang}`. +**Paragraph** — a string, or `{text | runs, align (left|center|right|justify), line_spacing_pct, space_before_pt, space_after_pt, bullet, numbered, level, size_pt, bold, italic, color, font, latin_font, east_asia_font, complex_script_font, lang}`. -**Run** — `{text, size_pt, bold, italic, underline, strike, superscript, subscript, color, highlight, font, lang, link}`. +**Run** — `{text, size_pt, bold, italic, underline, strike, superscript, subscript, color, highlight, font, latin_font, east_asia_font, complex_script_font, lang, link}`. GenOffice spec spellings are accepted as aliases: `sizePt`, `lineSpacingPct`, `spaceBeforePt`, `spaceAfterPt`, `widthPt`, `flipH`, `flipV`, `url`. diff --git a/src/crates/execution/office-documents/src/guides/verification.md b/src/crates/execution/office-documents/src/guides/verification.md index 6c4228537e..8806055fee 100644 --- a/src/crates/execution/office-documents/src/guides/verification.md +++ b/src/crates/execution/office-documents/src/guides/verification.md @@ -1,6 +1,6 @@ ## Completion and verification — all formats -The engine writes Office files directly; it does not depend on an installed Office application. Finish edits, inspect warnings, then use `read` to verify changed content, indexes, formulas or chart references. A successful write plus this structural check is the default completion path. +The engine writes Office files directly; it does not depend on an installed Office application. Finish edits, inspect warnings, then use `read` to verify changed content, indexes, formulas or chart references. A successful write plus this structural check is the default completion path. `fonts_verified:false` remains false after a structural read; no target-viewer font or glyph check has occurred. Visual inspection is a separate task when the user requests it or the deliverable requires it. Use a known available viewer on the machine that owns the file. Do not automatically probe, install or repair LibreOffice/soffice or other Office packages, search unrelated application directories, or build a substitute renderer just because `visual_rendered` is false. If no suitable viewer is available, deliver the file and state that visual inspection was not performed. diff --git a/src/crates/execution/office-documents/src/guides/xlsx.md b/src/crates/execution/office-documents/src/guides/xlsx.md index ca687873a2..6c34787146 100644 --- a/src/crates/execution/office-documents/src/guides/xlsx.md +++ b/src/crates/execution/office-documents/src/guides/xlsx.md @@ -4,7 +4,7 @@ A workbook is a list of **sheets**; each sheet holds sparse cells addressed A1-s Range reads also honor `offset`/`limit` (at most 500 rows per call); pass the same range and the returned `next_offset` to continue. `detail: "full"` preserves complete cell text; outline reads shorten long text. -Pass ordinary Unicode text; do not manually transcode or pre-escape XML. The tool writes UTF-8. Use `style.font_name` for a font available in the target viewer; fonts are not embedded. File generation does not require an Office installation, and `read` is not a rendered workbook preview. +File generation does not require an Office installation. `read` inspects cells and formulas, not rendered workbook pages. ## Cell values @@ -23,6 +23,10 @@ A style-only cell object preserves its existing value/formula. Use `{"value": nu `style` objects (in `format_range` or a cell object) take: `bold`, `italic`, `underline`, `strike`, `font_color`, `font_size`, `font_name`, `fill_color`, `number_format` (`"#,##0.00"`, `"0.0%"`, `"yyyy-mm-dd"`, `"¥#,##0"` …), `align` (`left` `center` `right` `justify` `general`), `valign` (`top` `center` `bottom`), `wrap`, `indent`, `border` (`thin` `medium` `thick` `dashed` `dotted` `double` `none`), `border_color`. Colors are `#RRGGBB`. Formatting merges into a cell's existing style. +A cell has one font name for all scripts: `style.font_name` (`font` is an accepted alias). There are no `lang`, `east_asia_font`, per-script font overrides or workbook `settings.styles/theme` in XLSX. Choose no font override unless a family is required; the engine defaults to Calibri and the viewer resolves missing glyphs. A style patch preserves the old font when `font_name` is absent or null. + +To remove only an explicit font name, use `{"op":"format_range","sheet":"Sales","range":"A1:D4","reset_font":true,"style":{}}`. Values, formulas, font size, weight, colors and number formats remain intact. A supplied `style.font_name` is applied after the reset. Do not clear all formatting or rewrite cell values just to repair fonts. + ## Operations `sheet` is a sheet name (or 0-based index); it may be omitted when the workbook has exactly one sheet. Rows and columns are 1-based in `row`, letters in `column`. @@ -39,7 +43,7 @@ A style-only cell object preserves its existing value/formula. Use `{"value": nu | `delete_rows` | `sheet`, `row`, `count` | references shift; references to deleted cells become `#REF!` | | `insert_columns` / `delete_columns` | `sheet`, `column`, `count` | same for columns | | `clear_range` | `sheet`, `range`, `what` (`all` `contents` `formats`) | | -| `format_range` | `sheet`, `range`, `style` | | +| `format_range` | `sheet`, `range`, `style`, `reset_font` (default false) | | | `set_column_widths` | `sheet`, `widths` `{ "A": 18, "B:D": 12 }` (characters; 0 hides) | | | `set_row_heights` | `sheet`, `heights` `{ "1": 28, "2:10": 18 }` (points; 0 hides) | | | `merge_cells` / `unmerge_cells` | `sheet`, `range` | | diff --git a/src/crates/execution/office-documents/src/lib.rs b/src/crates/execution/office-documents/src/lib.rs index ae0373a8a1..528588df5c 100644 --- a/src/crates/execution/office-documents/src/lib.rs +++ b/src/crates/execution/office-documents/src/lib.rs @@ -13,6 +13,7 @@ pub mod common; pub mod docx; mod error; +mod font_contract; mod guide; pub mod image; mod operation_contract; @@ -22,6 +23,7 @@ pub mod xlsx; mod xml; pub use error::{OfficeError, OfficeErrorKind, OfficeResult}; +pub use font_contract::{settings_schema, validate_font_input}; pub use guide::guide; pub use operation_contract::{ operation_recovery_hint, operation_schema, validate_operation_headers, @@ -486,6 +488,7 @@ where if !settings.is_object() { return Err(OfficeError::invalid("settings must be an object")); } + validate_font_input(settings, "settings")?; if settings.get(content_key).is_some() { return Err(OfficeError::invalid(format!( "settings cannot carry `{content_key}`; add content with operations" diff --git a/src/crates/execution/office-documents/src/operation_contract.rs b/src/crates/execution/office-documents/src/operation_contract.rs index 69069b907e..a2fa7ec05c 100644 --- a/src/crates/execution/office-documents/src/operation_contract.rs +++ b/src/crates/execution/office-documents/src/operation_contract.rs @@ -5,6 +5,9 @@ //! document owner; this preflight catches missing selectors/content before IO. use crate::common::{operation_name, unknown_operation}; +use crate::font_contract::{ + block_schema, cell_style_schema, element_schema, family_schema, validate_font_input, +}; use crate::{OfficeError, OfficeFormat, OfficeResult}; use serde_json::{json, Value}; @@ -39,6 +42,9 @@ pub fn operation_schema() -> Value { }, "required": ["categories", "values"], "additionalProperties": true }} }, "required": ["type", "anchor", "series"], "additionalProperties": true}); + let slide = json!({"type":"object", "description":"Complete slide {title?, background?, notes?, elements:[...]}. replace_slide requires both index and slide.", "properties":{"elements":{"type":"array", "items":element_schema()}}, "additionalProperties":true}); + let mut patch = element_schema(); + patch["required"] = json!(["index"]); json!({ "type": "object", "properties": { @@ -46,36 +52,29 @@ pub fn operation_schema() -> Value { "index": {"type": "integer", "minimum": 0, "description": "Current 0-based block/slide/chart index. Required for replace/update/delete/move operations that use an index. Optional insertion position for insert_blocks, insert_slides, or add_sheet. Earlier operations in this batch shift indexes."}, "count": {"type": "integer", "minimum": 1, "description": "Number of items; omit for the operation's default."}, "to": {"type": "integer", "minimum": 0, "description": "Move destination after removing the source items; required for move operations."}, - "slide": {"type": "object", "description": "replace_slide: complete replacement slide {title?, background?, notes?, elements:[...]}; both index and slide are required.", "properties": { - "elements": {"type": "array", "items": {"type": "object", "additionalProperties": true}} - }, "additionalProperties": true}, - "slides": {"type": "array", "minItems": 1, "description": "insert_slides: complete slide objects. Omit index to append.", "items": {"type": "object", "additionalProperties": true}}, - "elements": {"type": "array", "description": "update_slide: replace all elements while keeping slide metadata. Do not combine with update/remove/append_elements.", "items": {"type": "object", "additionalProperties": true}}, - "update_elements": {"type": "array", "description": "update_slide: patches {index, ...changed element fields}. No nested op, patch, or replace field.", "items": { - "type": "object", "properties": { - "index": {"type": "integer", "minimum": 0}, - "text": {"type": "string"}, - "x": {"type": "number"}, "y": {"type": "number"}, - "w": {"type": "number"}, "h": {"type": "number"}, - "size_pt": {"type": "number"}, "color": {"type": "string"} - }, "required": ["index"], "additionalProperties": true - }}, + "slide": slide, + "slides": {"type":"array", "minItems":1, "description":"insert_slides: complete slides. Omit index to append.", "items":slide}, + "elements": {"type":"array", "description":"update_slide: replaces all elements. Do not combine with update/remove/append_elements.", "items":element_schema()}, + "update_elements": {"type":"array", "description":"update_slide: patches {index, ...changed fields}; no nested op. Null removes a font override; omitted fields keep existing values.", "items":patch}, "remove_elements": {"type": "array", "items": {"type": "integer", "minimum": 0}}, - "append_elements": {"type": "array", "items": {"type": "object", "additionalProperties": true}}, + "append_elements": {"type": "array", "items": element_schema()}, "markdown": {"type": "string", "description": "Word insert_blocks/replace_blocks content; use this or blocks."}, - "blocks": {"type": "array", "items": {"type": "object", "additionalProperties": true}}, + "blocks": {"type": "array", "items": block_schema()}, "sheet": {"description": "Excel sheet name (preferred) or 0-based index; omit only for a one-sheet workbook."}, "name": {"type": "string", "description": "add_sheet/rename_sheet: sheet name."}, "start": {"type": "string", "description": "Excel starting cell, e.g. A1."}, - "rows": {"description": "Excel: 2-D array of cell values; for freeze_panes only, a non-negative row count."}, + "rows": {"description": "Excel: 2-D array of scalar values or cell objects; for freeze_panes only, a non-negative row count.", "items":{"type":"array", "items":{"properties":{"value":{}, "formula":{"type":"string"}, "style":cell_style_schema()}, "additionalProperties":true}}}, "row": {"type": "integer", "minimum": 1}, "column": {"type": "string", "description": "Excel column letters, e.g. A."}, "range": {"description": "Excel A1 range; set_autofilter also accepts null to clear it."}, - "style": {"type": "object", "additionalProperties": true}, + "style": cell_style_schema(), + "reset_font": {"type":"boolean", "description":"XLSX format_range only: clear the existing font name before applying style. Keeps values, formulas and all other formatting. Use style:{} for default-font recovery."}, "chart": chart_schema, "include_headers_footers": {"type": "boolean", "description": "DOCX replace_text: also replace header/footer text (default false)."}, - "font": {"type": "string"}, - "east_asia_font": {"type": "string"}, + "font": family_schema("DOCX: default Latin/complex-script font. PPTX: default Latin font. Null clears the override."), + "east_asia_font": family_schema("DOCX set_styles / PPTX set_theme: default East Asian font. Null restores fallback."), + "complex_script_font": family_schema("DOCX set_styles / PPTX set_theme: complex-script override. Null restores inheritance."), + "heading_font": family_schema("DOCX: Latin/complex-script heading font. Null restores inheritance."), "lang": {"type": "string", "description": "DOCX set_styles / PPTX set_theme: optional BCP 47 language, e.g. zh-CN or zh-TW. Omit for text-based selection."} }, "required": ["op"], @@ -116,6 +115,7 @@ pub fn validate_operation_headers(format: OfficeFormat, operations: &[Value]) -> for (index, operation) in operations.iter().enumerate() { let name = operation_name(operation).map_err(|err| err.at_operation(index, "?"))?; let check = || -> OfficeResult<()> { + validate_font_input(operation, "operation")?; if !format.operations().contains(&name) { return Err(unknown_operation(name, format.operations())); } diff --git a/src/crates/execution/office-documents/src/pptx/model.rs b/src/crates/execution/office-documents/src/pptx/model.rs index 2f8a63c92c..3adc20b458 100644 --- a/src/crates/execution/office-documents/src/pptx/model.rs +++ b/src/crates/execution/office-documents/src/pptx/model.rs @@ -66,6 +66,8 @@ pub struct DeckTheme { /// Font for CJK text. #[serde(default, skip_serializing_if = "Option::is_none")] pub east_asia_font: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub complex_script_font: Option, /// Default text language; runs/elements may override it. Omitted means auto. #[serde(default, skip_serializing_if = "Option::is_none")] pub lang: Option, @@ -134,6 +136,12 @@ pub struct TextDefaults { #[serde(default, skip_serializing_if = "Option::is_none")] pub font: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub latin_font: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub east_asia_font: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub complex_script_font: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub lang: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub align: Option, @@ -269,6 +277,12 @@ pub struct TableElement { pub font_size_pt: f64, #[serde(default, skip_serializing_if = "Option::is_none")] pub font: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub latin_font: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub east_asia_font: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub complex_script_font: Option, #[serde(default = "default_header_fill")] pub header_fill: String, #[serde(default = "default_header_color")] @@ -371,6 +385,12 @@ struct ParagraphFull { #[serde(default)] font: Option, #[serde(default)] + latin_font: Option, + #[serde(default)] + east_asia_font: Option, + #[serde(default)] + complex_script_font: Option, + #[serde(default)] lang: Option, #[serde(flatten)] extra: Extra, @@ -393,6 +413,15 @@ impl From for Paragraph { run.color = full.color.clone(); } if run.font.is_none() { + // A run's all-script override outranks paragraph defaults. + // Otherwise inherit each script before the paragraph shorthand. + run.latin_font = run.latin_font.take().or(full.latin_font.clone()); + run.east_asia_font = + run.east_asia_font.take().or(full.east_asia_font.clone()); + run.complex_script_font = run + .complex_script_font + .take() + .or(full.complex_script_font.clone()); run.font = full.font.clone(); } if run.lang.is_none() { diff --git a/src/crates/execution/office-documents/src/pptx/render.rs b/src/crates/execution/office-documents/src/pptx/render.rs index b7456a9fcf..c0aebb9122 100644 --- a/src/crates/execution/office-documents/src/pptx/render.rs +++ b/src/crates/execution/office-documents/src/pptx/render.rs @@ -458,6 +458,16 @@ impl SlideWriter<'_> { // empty English end run can reintroduce the wrong font fallback. text: paragraph.runs.iter().map(|run| run.text.as_str()).collect(), lang: paragraph.runs.last().and_then(|run| run.lang.clone()), + font: paragraph.runs.last().and_then(|run| run.font.clone()), + latin_font: paragraph.runs.last().and_then(|run| run.latin_font.clone()), + east_asia_font: paragraph + .runs + .last() + .and_then(|run| run.east_asia_font.clone()), + complex_script_font: paragraph + .runs + .last() + .and_then(|run| run.complex_script_font.clone()), size_pt: Some(size), ..Run::default() }; @@ -514,14 +524,26 @@ impl SlideWriter<'_> { "" )); } - let font = run - .font - .as_deref() - .or(defaults.font.as_deref()) - .or_else(|| (run.code == Some(true)).then_some("Consolas")); - if let Some(font) = font { - let font = esc(font); - children.push_str(&format!("")); + // Resolve by scope first, then by script. A local legacy `font` still + // overrides every script; a Latin-only override leaves CJK inheritance. + for (slot, local, inherited) in [ + ("latin", &run.latin_font, &defaults.latin_font), + ("ea", &run.east_asia_font, &defaults.east_asia_font), + ( + "cs", + &run.complex_script_font, + &defaults.complex_script_font, + ), + ] { + let font = local + .as_deref() + .or(run.font.as_deref()) + .or(inherited.as_deref()) + .or(defaults.font.as_deref()) + .or_else(|| (run.code == Some(true)).then_some("Consolas")); + if let Some(font) = font { + children.push_str(&format!("", esc(font))); + } } if let Some(link) = &run.link { let id = self.link(link); @@ -632,6 +654,9 @@ impl SlideWriter<'_> { .or_else(|| is_header.then(|| table.header_color.clone())) .or_else(|| table.text_color.clone()), font: table.font.clone(), + latin_font: table.latin_font.clone(), + east_asia_font: table.east_asia_font.clone(), + complex_script_font: table.complex_script_font.clone(), lang: None, align: cell.align, }; @@ -774,6 +799,7 @@ fn notes_xml(notes: &str, default_lang: Option<&str>) -> String { fn theme_xml(deck: &PptxDocument) -> String { let latin = esc(deck.theme.font.as_deref().unwrap_or("Calibri")); let east_asia = esc(deck.theme.east_asia_font.as_deref().unwrap_or("")); + let complex = esc(deck.theme.complex_script_font.as_deref().unwrap_or("")); let dark = deck .theme .text_color @@ -783,7 +809,7 @@ fn theme_xml(deck: &PptxDocument) -> String { .unwrap_or_else(|| "".to_string()); let font_set = |name: &str| { format!( - "" + "" ) }; let fill_variants = ""; diff --git a/src/crates/execution/office-documents/src/xlsx/ops.rs b/src/crates/execution/office-documents/src/xlsx/ops.rs index 6a3a0efc4e..c8c4e40d03 100644 --- a/src/crates/execution/office-documents/src/xlsx/ops.rs +++ b/src/crates/execution/office-documents/src/xlsx/ops.rs @@ -144,6 +144,8 @@ op_struct!(FormatRange { sheet: Option, range: String, style: CellStyle, + #[serde(default)] + reset_font: bool, }); op_struct!(SetColumnWidths { #[serde(default)] @@ -469,6 +471,9 @@ pub(crate) fn apply( for col in range.start.col..=range.end.col { let cell = sheet.cells.entry(CellRef::new(row, col)).or_default(); let mut style = cell.style.take().unwrap_or_default(); + if input.reset_font { + style.font_name = None; + } style.merge(&input.style); cell.style = (!style.is_default()).then_some(style); } diff --git a/src/crates/execution/office-documents/src/xlsx/render.rs b/src/crates/execution/office-documents/src/xlsx/render.rs index 9118e937be..9ea1521149 100644 --- a/src/crates/execution/office-documents/src/xlsx/render.rs +++ b/src/crates/execution/office-documents/src/xlsx/render.rs @@ -464,7 +464,7 @@ impl StyleRegistry { xml.push_str(&format!("")); } xml.push_str(&format!( - "", + "", esc(style.font_name.as_deref().unwrap_or("Calibri")) )); Self::intern(&mut self.fonts, xml) diff --git a/src/crates/execution/office-documents/tests/end_to_end.rs b/src/crates/execution/office-documents/tests/end_to_end.rs index 1b344c17f3..81ac77ec15 100644 --- a/src/crates/execution/office-documents/tests/end_to_end.rs +++ b/src/crates/execution/office-documents/tests/end_to_end.rs @@ -1096,3 +1096,288 @@ $$A = \begin{pmatrix}a&b\\c&d\end{pmatrix}$$ assert_eq!(reopened.to_bytes().unwrap(), before); } } + +const FONT_SAMPLE_TEXT: &[(&str, &str)] = &[ + ("en-US", "English: Report & "), + ("zh-CN", "简体中文:工程规模与社区增长"), + ("zh-TW", "繁體中文:工程規模與社群成長"), + ("ja-JP", "日本語:プロジェクトの成長"), + ("ko-KR", "한국어: 프로젝트 성장"), + ("ar", "العربية: تقرير المشروع"), +]; + +#[test] +fn docx_font_scopes_and_reset_preserve_multilingual_content() { + let mut doc = OfficeDocument::new( + OfficeFormat::Docx, + Some(&json!({"styles":{ + "font":"Legacy Latin", "east_asia_font":"Required CJK", "heading_font":"Heading Family", + "complex_script_font":"Required Complex", "font_size_pt":12 + }})), + ) + .unwrap(); + let mut blocks = vec![json!({"type":"heading","level":1,"text":"Font inheritance · 字体继承"})]; + blocks.extend(FONT_SAMPLE_TEXT.iter().map(|(lang, text)| { + json!({"type":"paragraph","runs":[ + {"text":text,"lang":lang,"latin_font":"Arial"} + ]}) + })); + blocks.push(json!({"type":"table","rows":[["Scope","中文"],["Run override",{"runs":[{"text":"Mixed 中文","font":"Legacy All","east_asia_font":"Required CJK"}]}]]})); + doc.apply( + &[json!({"op":"insert_blocks","blocks":blocks})], + &ImageSources::new(), + ) + .unwrap(); + let parts = assert_well_formed(&doc.to_bytes().unwrap()); + let xml = text_of(&parts, "word/document.xml"); + let dom = roxmltree::Document::parse(&xml).unwrap(); + let ns = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + for text in FONT_SAMPLE_TEXT.iter().map(|(_, text)| *text) { + let run = dom + .descendants() + .find(|n| n.tag_name().name() == "t" && n.text() == Some(text)) + .unwrap() + .parent() + .unwrap(); + let fonts = run + .descendants() + .find(|n| n.tag_name().name() == "rFonts") + .unwrap(); + assert_eq!(fonts.attribute((ns, "ascii")), Some("Arial")); + assert_eq!( + fonts.attribute((ns, "eastAsia")), + None, + "Latin-only choice must preserve CJK inheritance" + ); + assert_eq!(fonts.attribute((ns, "cs")), None); + } + assert!(xml.contains("w:ascii=\"Legacy All\" w:hAnsi=\"Legacy All\" w:eastAsia=\"Required CJK\" w:cs=\"Legacy All\"")); + let styles = text_of(&parts, "word/styles.xml"); + assert!(styles.contains("w:cs=\"Required Complex\"")); + doc.apply(&ops(json!([ + {"op":"set_styles","font":null,"heading_font":null,"east_asia_font":null,"complex_script_font":null}, + {"op":"replace_blocks","index":7,"blocks":[{"type":"table","rows":[["Scope","中文"],["Default font restored","内容保留"]]}]} + ])), &ImageSources::new()).unwrap(); + let bytes = doc.to_bytes().unwrap(); + let reopened = OfficeDocument::open(&bytes, &OpenOptions::default()).unwrap(); + let view = reopened + .read(&ReadRequest { + detail: Detail::Full, + ..Default::default() + }) + .unwrap(); + assert_eq!(view["settings"]["styles"]["font_size_pt"], 12.0); + for (_, text) in FONT_SAMPLE_TEXT { + assert!(view.to_string().contains(text)); + } + let parts = assert_well_formed(&bytes); + assert!(!text_of(&parts, "word/styles.xml").contains("w:eastAsia=")); + assert!(!text_of(&parts, "word/document.xml").contains("Required CJK")); + keep_sample("font-inheritance.docx", &reopened.to_bytes().unwrap()); +} + +#[test] +fn pptx_font_precedence_and_reset_preserve_theme_and_content() { + let images = ImageSources::new(); + let mut deck=OfficeDocument::new(OfficeFormat::Pptx,Some(&json!({"theme":{"font":"Theme Latin","east_asia_font":"Theme East","complex_script_font":"Theme Complex"}}))).unwrap(); + deck.apply(&[json!({"op":"insert_slides","slides":[{"elements":[ + {"type":"text","x":40,"y":40,"w":1100,"h":200,"font":"Element All","east_asia_font":"Element East","paragraphs":[ + {"font":"Paragraph All","east_asia_font":"Paragraph East","runs":[ + {"text":"first","font":"Run All","latin_font":"Run Latin"}, + {"text":"second","complex_script_font":"Run Complex"} + ]} + ]} + ]}]})],&images).unwrap(); + let parts = assert_well_formed(&deck.to_bytes().unwrap()); + let xml = text_of(&parts, "ppt/slides/slide1.xml"); + let dom = roxmltree::Document::parse(&xml).unwrap(); + for (text, expected) in [ + ("first", ["Run Latin", "Run All", "Run All"]), + ("second", ["Paragraph All", "Paragraph East", "Run Complex"]), + ] { + let run = dom + .descendants() + .find(|n| n.tag_name().name() == "t" && n.text() == Some(text)) + .unwrap() + .parent() + .unwrap(); + for (slot, font) in ["latin", "ea", "cs"].into_iter().zip(expected) { + assert_eq!( + run.descendants() + .find(|n| n.tag_name().name() == slot) + .unwrap() + .attribute("typeface"), + Some(font) + ); + } + } + let end = dom + .descendants() + .find(|n| n.tag_name().name() == "endParaRPr") + .unwrap(); + assert_eq!( + end.children() + .find(|n| n.tag_name().name() == "ea") + .unwrap() + .attribute("typeface"), + Some("Paragraph East") + ); + // Removal through patches restores inherited defaults; replacement retains all text. + deck.apply(&ops(json!([ + {"op":"set_theme","font":null,"east_asia_font":null,"complex_script_font":null}, + {"op":"update_slide","index":0,"update_elements":[{"index":0,"font":null,"east_asia_font":null,"latin_font":"Arial","paragraphs":[{"text":"Updated 中文","lang":"zh-CN"}]}]} + ])),&images).unwrap(); + let parts = assert_well_formed(&deck.to_bytes().unwrap()); + let xml = text_of(&parts, "ppt/slides/slide1.xml"); + assert!(!xml.contains("")); + assert!(xml.contains("Updated 中文")); + let elements:Vec<_>=FONT_SAMPLE_TEXT.iter().enumerate().map(|(i,(lang,text))|json!({ + "type":"text","x":64,"y":60+i*80,"w":1140,"h":62,"size_pt":26,"latin_font":"Arial","text":text,"lang":lang + })).collect(); + deck.apply(&[json!({"op":"update_slide","index":0,"elements":elements,"notes":"中文 notes: default East Asian fonts, Latin-only override."})],&images).unwrap(); + let bytes = deck.to_bytes().unwrap(); + let reopened = OfficeDocument::open(&bytes, &OpenOptions::default()).unwrap(); + for (_, text) in FONT_SAMPLE_TEXT { + assert!(reopened + .read(&ReadRequest { + detail: Detail::Full, + ..Default::default() + }) + .unwrap() + .to_string() + .contains(text)); + } + keep_sample("font-inheritance.pptx", &reopened.to_bytes().unwrap()); +} + +#[test] +fn xlsx_font_reset_preserves_values_formulas_and_other_styles() { + let images = ImageSources::new(); + let mut book = OfficeDocument::new(OfficeFormat::Xlsx, None).unwrap(); + let mut rows = vec![json!([ + "Language", + "Default / 默认", + "Reset font / 恢复字体", + "Amount", + "Formula" + ])]; + rows.extend( + FONT_SAMPLE_TEXT + .iter() + .enumerate() + .map(|(i, (lang, text))| json!([lang, text, text, i + 1, format!("=D{}*2", i + 2)])), + ); + book.apply(&ops(json!([ + {"op":"add_sheet","name":"Fonts 字体","rows":rows}, + {"op":"format_range","range":"C2:E7","style":{"font_name":"Unverified Family","bold":true,"font_color":"#1F4E79","number_format":"0.00"}}, + {"op":"format_range","range":"A1:E1","style":{"bold":true,"fill_color":"#D9E2F3"}}, + {"op":"set_column_widths","widths":{"A":12,"B:C":40,"D:E":14}}, + {"op":"set_row_heights","heights":{"1:7":30}} + ])),&images).unwrap(); + // Null kept its legacy no-op meaning; clearing uses an explicit operation flag. + book.apply( + &[json!({"op":"format_range","range":"C2:E7","style":{"font_name":null}})], + &images, + ) + .unwrap(); + assert!( + text_of(&entries(&book.to_bytes().unwrap()), "xl/styles.xml").contains("Unverified Family") + ); + book.apply( + &[json!({"op":"format_range","range":"C2:E7","reset_font":true,"style":{}})], + &images, + ) + .unwrap(); + let bytes = book.to_bytes().unwrap(); + let parts = assert_well_formed(&bytes); + let styles = text_of(&parts, "xl/styles.xml"); + assert!(!styles.contains("Unverified Family")); + let source: Value = serde_json::from_slice(&parts["openbitfun/genoffice.json"]).unwrap(); + let cells = &source["document"]["sheets"][0]["cells"]; + assert_eq!(cells["C2"]["value"], FONT_SAMPLE_TEXT[0].1); + assert_eq!(cells["D2"]["value"], 1); + assert_eq!(cells["E2"]["formula"], "D2*2"); + for name in ["C2", "D2", "E2"] { + assert_eq!(cells[name]["style"]["bold"], true); + assert_eq!(cells[name]["style"]["number_format"], "0.00"); + assert_eq!(cells[name]["style"]["font_color"], "#1F4E79"); + assert!(cells[name]["style"].get("font_name").is_none()); + } + let reopened = OfficeDocument::open(&bytes, &OpenOptions::default()).unwrap(); + keep_sample("font-inheritance.xlsx", &reopened.to_bytes().unwrap()); + // A custom font name does not imply a Swiss/sans-serif font classification. + book.apply( + &[json!({"op":"format_range","range":"B2","style":{"font_name":"Required Serif"}})], + &images, + ) + .unwrap(); + let styles = text_of(&entries(&book.to_bytes().unwrap()), "xl/styles.xml"); + let dom = roxmltree::Document::parse(&styles).unwrap(); + let font = dom + .descendants() + .find(|n| n.tag_name().name() == "name" && n.attribute("val") == Some("Required Serif")) + .unwrap() + .parent() + .unwrap(); + assert!(!font.children().any(|n| n.tag_name().name() == "family")); +} + +#[test] +fn font_input_errors_are_atomic_and_existing_v1_names_stay_readable() { + for (format, initial, bad) in [ + ( + OfficeFormat::Docx, + json!({"op":"insert_blocks","markdown":"中文 original"}), + json!({"op":"set_styles","east_asia_font":" "}), + ), + ( + OfficeFormat::Pptx, + json!({"op":"insert_slides","slides":[{"elements":[]}]}), + json!({"op":"set_theme","font":"bad\nname"}), + ), + ( + OfficeFormat::Xlsx, + json!({"op":"add_sheet","name":"Data","rows":[["中文 original"]]}), + json!({"op":"format_range","range":"A1","style":{"font_name":12}}), + ), + ] { + let mut doc = OfficeDocument::new(format, None).unwrap(); + doc.apply(&[initial], &ImageSources::new()).unwrap(); + let before = doc.to_bytes().unwrap(); + let error = doc.apply(&[bad], &ImageSources::new()).unwrap_err(); + assert_eq!(error.operation_index, Some(0)); + assert!(error.to_string().contains("font")); + assert_eq!(doc.to_bytes().unwrap(), before); + } + // Hand-authored old payload: no script-specific fields; an old empty name is + // preserved rather than making the record unreadable or resetting user data. + let legacy=br#"{"schema":"openbitfun.genoffice","version":1,"format":"docx","revision":4,"document":{"styles":{"font":"Legacy All","east_asia_font":""},"blocks":[{"type":"paragraph","runs":[{"text":"\u4e2d\u6587","font":"Legacy Run"}]}]}}"#; + let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new())); + zip.start_file( + "openbitfun/genoffice.json", + zip::write::SimpleFileOptions::default(), + ) + .unwrap(); + zip.write_all(legacy).unwrap(); + let bytes = zip.finish().unwrap().into_inner(); + let mut doc = OfficeDocument::open(&bytes, &OpenOptions::default()).unwrap(); + doc.apply( + &[json!({"op":"set_properties","title":"Unrelated edit"})], + &ImageSources::new(), + ) + .unwrap(); + let bytes = doc.to_bytes().unwrap(); + let parts = assert_well_formed(&bytes); + assert!(text_of(&parts,"word/document.xml").contains("w:ascii=\"Legacy Run\" w:hAnsi=\"Legacy Run\" w:eastAsia=\"Legacy Run\" w:cs=\"Legacy Run\"")); + let reopened = OfficeDocument::open(&bytes, &OpenOptions::default()).unwrap(); + let view = reopened + .read(&ReadRequest { + detail: Detail::Full, + ..Default::default() + }) + .unwrap(); + assert_eq!(view["settings"]["styles"]["east_asia_font"], ""); + assert_eq!(view["blocks"][0]["runs"][0]["font"], "Legacy Run"); + assert!(view["blocks"][0]["runs"][0].get("latin_font").is_none()); +}