diff --git a/README.md b/README.md
index 0070d275c..d0b809749 100644
--- a/README.md
+++ b/README.md
@@ -66,13 +66,19 @@ curl -fsSL https://officecli.ai/SKILL.md
That's it. The skill file teaches the agent how to install the binary and use all commands.
-> **Technical details:** OfficeCLI ships with a [SKILL.md](SKILL.md) (239 lines, ~8K tokens) that covers command syntax, architecture, and common pitfalls. After installation, your agent can immediately create, read, and modify any Office document.
+> **Technical details:** OfficeCLI ships with a [SKILL.md](SKILL.md) that covers command syntax, architecture, and common pitfalls. After installation, your agent can immediately create, read, and modify any Office document.
-## For Humans — Try It with AionUi
+## For Humans
-Want to experience the power of OfficeCLI without writing a single command? Install [**AionUi**](https://github.com/iOfficeAI/AionUi) — a desktop app that lets you create and edit Office documents through natural language, powered by OfficeCLI under the hood.
+**Option A — GUI:** Install [**AionUi**](https://github.com/iOfficeAI/AionUi) — a desktop app that lets you create and edit Office documents through natural language, powered by OfficeCLI under the hood. Just describe what you want, and AionUi handles the rest.
-Just describe what you want, and AionUi handles the rest.
+**Option B — CLI:** Download the binary for your platform from [GitHub Releases](https://github.com/iOfficeAI/OfficeCLI/releases), then run:
+
+```bash
+officecli install
+```
+
+This copies the binary to your PATH and installs the **officecli skill** into every AI coding agent it detects — Claude Code, Cursor, Windsurf, GitHub Copilot, and more. Your agent can immediately create, read, and edit Office documents on your behalf, no extra configuration needed.
## For Developers — See It Live in 30 Seconds
@@ -99,7 +105,7 @@ That's it. Every `add`, `set`, or `remove` command you run will refresh the prev
# Create a presentation and add content
officecli create deck.pptx
officecli add deck.pptx / --type slide --prop title="Q4 Report" --prop background=1A1A2E
-officecli add deck.pptx /slide[1] --type shape \
+officecli add deck.pptx '/slide[1]' --type shape \
--prop text="Revenue grew 25%" --prop x=2cm --prop y=5cm \
--prop font=Arial --prop size=24 --prop color=FFFFFF
@@ -112,7 +118,7 @@ officecli view deck.pptx outline
officecli view deck.pptx html
# Get structured JSON for any element
-officecli get deck.pptx /slide[1]/shape[1] --json
+officecli get deck.pptx '/slide[1]/shape[1]' --json
```
```json
@@ -264,7 +270,7 @@ Start simple, go deep only when needed.
| Layer | Purpose | Commands |
|-------|---------|----------|
| **L1: Read** | Semantic views of content | `view` (text, annotated, outline, stats, issues, html) |
-| **L2: DOM** | Structured element operations | `get`, `query`, `set`, `add`, `remove`, `move` |
+| **L2: DOM** | Structured element operations | `get`, `query`, `set`, `add`, `remove`, `move`, `swap` |
| **L3: Raw XML** | Direct XPath access — universal fallback | `raw`, `raw-set`, `add-part`, `validate` |
```bash
@@ -278,7 +284,7 @@ officecli add budget.xlsx / --type sheet --prop name="Q2 Report"
officecli move report.docx /body/p[5] --to /body --index 1
# L3 — raw XML when L2 isn't enough
-officecli raw deck.pptx /slide[1]
+officecli raw deck.pptx '/slide[1]'
officecli raw-set report.docx document \
--xpath "//w:p[1]" --action append \
--xml 'Injected text'
@@ -324,7 +330,7 @@ curl -fsSL https://officecli.ai/SKILL.md
curl -fsSL https://officecli.ai/SKILL.md -o ~/.claude/skills/officecli.md
```
-**Other agents:** Include the contents of `SKILL.md` (239 lines, ~8K tokens) in your agent's system prompt or tool description.
+**Other agents:** Include the contents of `SKILL.md` in your agent's system prompt or tool description.
@@ -456,7 +462,7 @@ OFFICECLI_SKIP_UPDATE=1 officecli ... # Skip check for one invocation (
| [`set`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-set) | Modify element properties |
| [`add`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-add) | Add element (or clone with `--from `) |
| [`remove`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-remove) | Remove an element |
-| [`move`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-move) | Move element (`--to --index N`) |
+| [`move`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-move) | Move element (`--to `, `--index N`, `--after `, `--before `) |
| [`swap`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-swap) | Swap two elements |
| [`validate`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-validate) | Validate against OpenXML schema |
| [`batch`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-batch) | Multiple operations in one open/save cycle (stdin, `--input`, or `--commands`; stops on first error, `--force` to continue) |
@@ -482,10 +488,10 @@ officecli create report.pptx
# 2. Add content
officecli add report.pptx / --type slide --prop title="Q4 Results"
-officecli add report.pptx /slide[1] --type shape \
+officecli add report.pptx '/slide[1]' --type shape \
--prop text="Revenue: $4.2M" --prop x=2cm --prop y=5cm --prop size=28
officecli add report.pptx / --type slide --prop title="Details"
-officecli add report.pptx /slide[2] --type shape \
+officecli add report.pptx '/slide[2]' --type shape \
--prop text="Growth driven by new markets" --prop x=2cm --prop y=5cm
# 3. Verify
@@ -495,7 +501,7 @@ officecli validate report.pptx
# 4. Fix any issues found
officecli view report.pptx issues --json
# Address issues based on output, e.g.:
-officecli set report.pptx /slide[1]/shape[1] --prop font=Arial
+officecli set report.pptx '/slide[1]/shape[1]' --prop font=Arial
```
### Template Merge
@@ -588,7 +594,7 @@ yaml-frontmatter:
ai-agent-compatible: true
mcp-server: true
skill-file: SKILL.md
- skill-file-lines: 239
+
install-command-unix: curl -fsSL https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.sh | bash
install-command-windows: irm https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.ps1 | iex
-->
@@ -606,7 +612,7 @@ keywords: office, cli, ai-agent, automation, docx, xlsx, pptx, openxml, document
ai-agent-compatible: true
mcp-server: true
skill-file: SKILL.md
-skill-file-lines: 239
+skill-file-lines: 403
alternatives: python-docx, openpyxl, python-pptx, libreoffice --headless
install-command-unix: curl -fsSL https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.sh | bash
install-command-windows: irm https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.ps1 | iex
diff --git a/README_ja.md b/README_ja.md
index 16b24de59..03c4546d8 100644
--- a/README_ja.md
+++ b/README_ja.md
@@ -66,7 +66,7 @@ curl -fsSL https://officecli.ai/SKILL.md
これだけです。スキルファイルがエージェントにバイナリのインストール方法と全コマンドの使い方を教えます。
-> **技術詳細:** OfficeCLI には [SKILL.md](SKILL.md)(239行、約8Kトークン)が付属し、コマンド構文、アーキテクチャ、よくある落とし穴をカバーしています。インストール後、エージェントはすぐに Office 文書の作成・読み取り・変更が可能です。
+> **技術詳細:** OfficeCLI には [SKILL.md](SKILL.md) が付属し、コマンド構文、アーキテクチャ、よくある落とし穴をカバーしています。インストール後、エージェントはすぐに Office 文書の作成・読み取り・変更が可能です。
## 一般ユーザー向け — AionUi をインストールして体験
@@ -99,7 +99,7 @@ officecli add deck.pptx / --type slide --prop title="Hello, World!"
# プレゼンテーションを作成してコンテンツを追加
officecli create deck.pptx
officecli add deck.pptx / --type slide --prop title="Q4 Report" --prop background=1A1A2E
-officecli add deck.pptx /slide[1] --type shape \
+officecli add deck.pptx '/slide[1]' --type shape \
--prop text="Revenue grew 25%" --prop x=2cm --prop y=5cm \
--prop font=Arial --prop size=24 --prop color=FFFFFF
@@ -112,7 +112,7 @@ officecli view deck.pptx outline
officecli view deck.pptx html
# 任意の要素の構造化 JSON を取得
-officecli get deck.pptx /slide[1]/shape[1] --json
+officecli get deck.pptx '/slide[1]/shape[1]' --json
```
```json
@@ -258,7 +258,7 @@ echo '[{"command":"set","path":"/slide[1]/shape[1]","props":{"text":"Hello"}},
| レイヤー | 用途 | コマンド |
|---------|------|---------|
| **L1:読み取り** | コンテンツのセマンティックビュー | `view`(text、annotated、outline、stats、issues、html) |
-| **L2:DOM** | 構造化された要素操作 | `get`、`query`、`set`、`add`、`remove`、`move` |
+| **L2:DOM** | 構造化された要素操作 | `get`、`query`、`set`、`add`、`remove`、`move`、`swap` |
| **L3:生 XML** | XPath による直接アクセス — 万能フォールバック | `raw`、`raw-set`、`add-part`、`validate` |
```bash
@@ -272,7 +272,7 @@ officecli add budget.xlsx / --type sheet --prop name="Q2 Report"
officecli move report.docx /body/p[5] --to /body --index 1
# L3 — L2 では足りない時に生 XML
-officecli raw deck.pptx /slide[1]
+officecli raw deck.pptx '/slide[1]'
officecli raw-set report.docx document \
--xpath "//w:p[1]" --action append \
--xml 'Injected text'
@@ -318,7 +318,7 @@ curl -fsSL https://officecli.ai/SKILL.md
curl -fsSL https://officecli.ai/SKILL.md -o ~/.claude/skills/officecli.md
```
-**その他のエージェント:** `SKILL.md`(239行、約8Kトークン)の内容をエージェントのシステムプロンプトまたはツール説明に含めてください。
+**その他のエージェント:** `SKILL.md` の内容をエージェントのシステムプロンプトまたはツール説明に含めてください。
@@ -452,7 +452,7 @@ OFFICECLI_SKIP_UPDATE=1 officecli ... # 単回のチェックをスキ
| [`set`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-set) | 要素のプロパティを変更 |
| [`add`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-add) | 要素を追加(または `--from ` でクローン) |
| [`remove`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-remove) | 要素を削除 |
-| [`move`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-move) | 要素を移動(`--to --index N`) |
+| [`move`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-move) | 要素を移動(`--to `、`--index N`、`--after `、`--before `) |
| [`swap`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-swap) | 2つの要素を交換 |
| [`validate`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-validate) | OpenXML スキーマ検証 |
| [`batch`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-batch) | 一度の open/save サイクルで複数操作を実行(stdin、`--input`、または `--commands`) |
@@ -478,10 +478,10 @@ officecli create report.pptx
# 2. コンテンツを追加
officecli add report.pptx / --type slide --prop title="Q4 Results"
-officecli add report.pptx /slide[1] --type shape \
+officecli add report.pptx '/slide[1]' --type shape \
--prop text="Revenue: $4.2M" --prop x=2cm --prop y=5cm --prop size=28
officecli add report.pptx / --type slide --prop title="Details"
-officecli add report.pptx /slide[2] --type shape \
+officecli add report.pptx '/slide[2]' --type shape \
--prop text="Growth driven by new markets" --prop x=2cm --prop y=5cm
# 3. 検証
@@ -491,7 +491,7 @@ officecli validate report.pptx
# 4. 問題の修正
officecli view report.pptx issues --json
# 出力に基づいて問題を修正:
-officecli set report.pptx /slide[1]/shape[1] --prop font=Arial
+officecli set report.pptx '/slide[1]/shape[1]' --prop font=Arial
```
### テンプレートマージ
diff --git a/README_ko.md b/README_ko.md
index 1da4b804e..9d5a1028f 100644
--- a/README_ko.md
+++ b/README_ko.md
@@ -66,7 +66,7 @@ curl -fsSL https://officecli.ai/SKILL.md
이게 전부입니다. 스킬 파일이 에이전트에게 바이너리 설치 방법과 모든 명령어 사용법을 알려줍니다.
-> **기술 세부사항:** OfficeCLI에는 [SKILL.md](SKILL.md)(239줄, 약 8K 토큰)가 포함되어 있으며, 명령어 구문, 아키텍처, 자주 발생하는 실수를 다룹니다. 설치 후 에이전트는 즉시 Office 문서를 생성, 읽기, 수정할 수 있습니다.
+> **기술 세부사항:** OfficeCLI에는 [SKILL.md](SKILL.md)가 포함되어 있으며, 명령어 구문, 아키텍처, 자주 발생하는 실수를 다룹니다. 설치 후 에이전트는 즉시 Office 문서를 생성, 읽기, 수정할 수 있습니다.
## 일반 사용자용 — AionUi를 설치하여 체험
@@ -99,7 +99,7 @@ officecli add deck.pptx / --type slide --prop title="Hello, World!"
# 프레젠테이션을 생성하고 콘텐츠 추가
officecli create deck.pptx
officecli add deck.pptx / --type slide --prop title="Q4 Report" --prop background=1A1A2E
-officecli add deck.pptx /slide[1] --type shape \
+officecli add deck.pptx '/slide[1]' --type shape \
--prop text="Revenue grew 25%" --prop x=2cm --prop y=5cm \
--prop font=Arial --prop size=24 --prop color=FFFFFF
@@ -112,7 +112,7 @@ officecli view deck.pptx outline
officecli view deck.pptx html
# 모든 요소의 구조화된 JSON 가져오기
-officecli get deck.pptx /slide[1]/shape[1] --json
+officecli get deck.pptx '/slide[1]/shape[1]' --json
```
```json
@@ -258,7 +258,7 @@ echo '[{"command":"set","path":"/slide[1]/shape[1]","props":{"text":"Hello"}},
| 레이어 | 용도 | 명령어 |
|--------|------|--------|
| **L1: 읽기** | 콘텐츠의 시맨틱 뷰 | `view` (text, annotated, outline, stats, issues, html) |
-| **L2: DOM** | 구조화된 요소 작업 | `get`, `query`, `set`, `add`, `remove`, `move` |
+| **L2: DOM** | 구조화된 요소 작업 | `get`, `query`, `set`, `add`, `remove`, `move`, `swap` |
| **L3: 원시 XML** | XPath 직접 접근 — 범용 폴백 | `raw`, `raw-set`, `add-part`, `validate` |
```bash
@@ -272,7 +272,7 @@ officecli add budget.xlsx / --type sheet --prop name="Q2 Report"
officecli move report.docx /body/p[5] --to /body --index 1
# L3 — L2로 부족할 때 원시 XML
-officecli raw deck.pptx /slide[1]
+officecli raw deck.pptx '/slide[1]'
officecli raw-set report.docx document \
--xpath "//w:p[1]" --action append \
--xml 'Injected text'
@@ -318,7 +318,7 @@ curl -fsSL https://officecli.ai/SKILL.md
curl -fsSL https://officecli.ai/SKILL.md -o ~/.claude/skills/officecli.md
```
-**기타 에이전트:** `SKILL.md`(239줄, 약 8K 토큰)의 내용을 에이전트의 시스템 프롬프트 또는 도구 설명에 포함하세요.
+**기타 에이전트:** `SKILL.md`의 내용을 에이전트의 시스템 프롬프트 또는 도구 설명에 포함하세요.
@@ -452,7 +452,7 @@ OFFICECLI_SKIP_UPDATE=1 officecli ... # 단일 실행 시 확인 건너
| [`set`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-set) | 요소 속성 수정 |
| [`add`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-add) | 요소 추가 (또는 `--from `로 복제) |
| [`remove`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-remove) | 요소 삭제 |
-| [`move`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-move) | 요소 이동 (`--to --index N`) |
+| [`move`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-move) | 요소 이동 (`--to `, `--index N`, `--after `, `--before `) |
| [`swap`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-swap) | 두 요소 교체 |
| [`validate`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-validate) | OpenXML 스키마 검증 |
| [`batch`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-batch) | 한 번의 open/save 사이클에서 여러 작업 실행 (stdin, `--input`, 또는 `--commands`) |
@@ -478,10 +478,10 @@ officecli create report.pptx
# 2. 콘텐츠 추가
officecli add report.pptx / --type slide --prop title="Q4 Results"
-officecli add report.pptx /slide[1] --type shape \
+officecli add report.pptx '/slide[1]' --type shape \
--prop text="Revenue: $4.2M" --prop x=2cm --prop y=5cm --prop size=28
officecli add report.pptx / --type slide --prop title="Details"
-officecli add report.pptx /slide[2] --type shape \
+officecli add report.pptx '/slide[2]' --type shape \
--prop text="Growth driven by new markets" --prop x=2cm --prop y=5cm
# 3. 검증
@@ -491,7 +491,7 @@ officecli validate report.pptx
# 4. 문제 수정
officecli view report.pptx issues --json
# 출력에 따라 문제 수정:
-officecli set report.pptx /slide[1]/shape[1] --prop font=Arial
+officecli set report.pptx '/slide[1]/shape[1]' --prop font=Arial
```
### 템플릿 병합
diff --git a/README_zh.md b/README_zh.md
index 32d073228..c80a4f190 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -66,7 +66,7 @@ curl -fsSL https://officecli.ai/SKILL.md
就这一步。技能文件会教智能体如何安装二进制文件并使用所有命令。
-> **技术细节:** OfficeCLI 附带 [SKILL.md](SKILL.md)(239 行,约 8K tokens),涵盖命令语法、架构设计和常见陷阱。安装后,您的智能体可以立即创建、读取和修改任何 Office 文档。
+> **技术细节:** OfficeCLI 附带 [SKILL.md](SKILL.md),涵盖命令语法、架构设计和常见陷阱。安装后,您的智能体可以立即创建、读取和修改任何 Office 文档。
## 普通用户 — 安装 AionUi 即可体验
@@ -99,7 +99,7 @@ officecli add deck.pptx / --type slide --prop title="Hello, World!"
# 创建演示文稿并添加内容
officecli create deck.pptx
officecli add deck.pptx / --type slide --prop title="Q4 Report" --prop background=1A1A2E
-officecli add deck.pptx /slide[1] --type shape \
+officecli add deck.pptx '/slide[1]' --type shape \
--prop text="Revenue grew 25%" --prop x=2cm --prop y=5cm \
--prop font=Arial --prop size=24 --prop color=FFFFFF
@@ -112,7 +112,7 @@ officecli view deck.pptx outline
officecli view deck.pptx html
# 获取任意元素的结构化 JSON
-officecli get deck.pptx /slide[1]/shape[1] --json
+officecli get deck.pptx '/slide[1]/shape[1]' --json
```
```json
@@ -258,7 +258,7 @@ echo '[{"command":"set","path":"/slide[1]/shape[1]","props":{"text":"Hello"}},
| 层 | 用途 | 命令 |
|----|------|------|
| **L1:读取** | 内容的语义视图 | `view`(text、annotated、outline、stats、issues、html) |
-| **L2:DOM** | 结构化元素操作 | `get`、`query`、`set`、`add`、`remove`、`move` |
+| **L2:DOM** | 结构化元素操作 | `get`、`query`、`set`、`add`、`remove`、`move`、`swap` |
| **L3:原始 XML** | XPath 直接访问 — 通用兜底 | `raw`、`raw-set`、`add-part`、`validate` |
```bash
@@ -272,7 +272,7 @@ officecli add budget.xlsx / --type sheet --prop name="Q2 Report"
officecli move report.docx /body/p[5] --to /body --index 1
# L3 — L2 不够时用原始 XML
-officecli raw deck.pptx /slide[1]
+officecli raw deck.pptx '/slide[1]'
officecli raw-set report.docx document \
--xpath "//w:p[1]" --action append \
--xml 'Injected text'
@@ -318,7 +318,7 @@ curl -fsSL https://officecli.ai/SKILL.md
curl -fsSL https://officecli.ai/SKILL.md -o ~/.claude/skills/officecli.md
```
-**其他智能体:** 将 `SKILL.md`(239 行,约 8K tokens)的内容添加到智能体的系统提示词或工具描述中。
+**其他智能体:** 将 `SKILL.md` 的内容添加到智能体的系统提示词或工具描述中。
@@ -452,7 +452,7 @@ OFFICECLI_SKIP_UPDATE=1 officecli ... # 单次调用跳过检查(CI
| [`set`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-set) | 修改元素属性 |
| [`add`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-add) | 添加元素(或通过 `--from ` 克隆) |
| [`remove`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-remove) | 删除元素 |
-| [`move`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-move) | 移动元素(`--to --index N`) |
+| [`move`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-move) | 移动元素(`--to `、`--index N`、`--after `、`--before `) |
| [`swap`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-swap) | 交换两个元素 |
| [`validate`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-validate) | OpenXML 模式校验 |
| [`batch`](https://github.com/iOfficeAI/OfficeCLI/wiki/command-batch) | 单次打开/保存周期内执行多条操作(JSON 通过标准输入或 `--input`) |
@@ -478,10 +478,10 @@ officecli create report.pptx
# 2. 添加内容
officecli add report.pptx / --type slide --prop title="Q4 Results"
-officecli add report.pptx /slide[1] --type shape \
+officecli add report.pptx '/slide[1]' --type shape \
--prop text="Revenue: $4.2M" --prop x=2cm --prop y=5cm --prop size=28
officecli add report.pptx / --type slide --prop title="Details"
-officecli add report.pptx /slide[2] --type shape \
+officecli add report.pptx '/slide[2]' --type shape \
--prop text="Growth driven by new markets" --prop x=2cm --prop y=5cm
# 3. 验证
@@ -491,7 +491,7 @@ officecli validate report.pptx
# 4. 修复发现的问题
officecli view report.pptx issues --json
# 根据输出修复问题,例如:
-officecli set report.pptx /slide[1]/shape[1] --prop font=Arial
+officecli set report.pptx '/slide[1]/shape[1]' --prop font=Arial
```
### 模板合并
@@ -584,7 +584,6 @@ yaml-frontmatter:
ai-agent-compatible: true
mcp-server: true
skill-file: SKILL.md
- skill-file-lines: 239
install-command-unix: curl -fsSL https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.sh | bash
install-command-windows: irm https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.ps1 | iex
-->
@@ -602,7 +601,6 @@ keywords: office, cli, ai-agent, automation, docx, xlsx, pptx, openxml, document
ai-agent-compatible: true
mcp-server: true
skill-file: SKILL.md
-skill-file-lines: 239
alternatives: python-docx, openpyxl, python-pptx, libreoffice --headless
install-command-unix: curl -fsSL https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.sh | bash
install-command-windows: irm https://raw.githubusercontent.com/iOfficeAI/OfficeCLI/main/install.ps1 | iex
diff --git a/SKILL.md b/SKILL.md
index 9c2aa6dc7..5be4a2317 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -65,8 +65,7 @@ officecli close report.docx # save and release
```bash
officecli create slides.pptx
officecli add slides.pptx / --type slide --prop title="Q4 Report" --prop background=1A1A2E
-officecli add slides.pptx /slide[1] --type shape --prop text="Revenue grew 25%" --prop x=2cm --prop y=5cm --prop font=Arial --prop size=24 --prop color=FFFFFF
-officecli set slides.pptx /slide[1] --prop transition=fade --prop advanceTime=3000
+officecli add slides.pptx '/slide[1]' --type shape --prop text="Revenue grew 25%" --prop x=2cm --prop y=5cm --prop font=Arial --prop size=24 --prop color=FFFFFF
```
**Word:**
@@ -80,9 +79,7 @@ officecli add report.docx /body --type paragraph --prop text="Revenue increased
```bash
officecli create data.xlsx
officecli set data.xlsx /Sheet1/A1 --prop value="Name" --prop bold=true
-officecli set data.xlsx /Sheet1/B1 --prop value="Score" --prop bold=true
officecli set data.xlsx /Sheet1/A2 --prop value="Alice"
-officecli set data.xlsx /Sheet1/B2 --prop value=95
```
---
@@ -106,6 +103,14 @@ officecli validate # Validate against OpenXML schema
| `issues` | Formatting/content/structure problems | `--type format\|content\|structure`, `--limit N` |
| `text` | Plain text extraction | `--start N --end N`, `--max-lines N` |
| `annotated` | Text with formatting annotations | |
+| `html` | Static HTML snapshot (.docx/.xlsx/.pptx) — writes to stdout | `--browser` (open in default browser), `--page N` (docx), `--start N --end N` (pptx slide range) |
+
+**`view html` vs `watch`** — both render the same HTML (shared `*.HtmlPreview.cs` renderer). Use `view html` for one-shot snapshots (CI artifacts, archival, diffing, piping to files); use `watch` when you need live refresh or browser-side click-to-select. `view html` needs no server/port.
+
+```bash
+officecli view report.docx html > snapshot.html # snapshot to file
+officecli view report.docx html --browser # open in default browser
+```
### get
@@ -119,6 +124,28 @@ officecli get data.xlsx '/Sheet1/B2' --json
Run `officecli docx get` / `officecli xlsx get` / `officecli pptx get` for all available paths.
+### Stable ID Addressing
+
+Elements with stable IDs return `@attr=value` paths instead of positional indices. These paths survive insert/delete operations — use them for multi-step workflows.
+
+**Returned path format (output):**
+```
+/slide[1]/shape[@id=550950021] # PPT shape (cNvPr.Id)
+/slide[1]/table[@id=1388430425]/tr[1]/tc[2] # PPT table
+/body/p[@paraId=1A2B3C4D] # Word paragraph
+/comments/comment[@commentId=1] # Word comment
+```
+Word footnote/endnote/sdt follow the same `@xxxId=` pattern; child elements inherit the parent's `@id=`. Run `officecli get` for the full list.
+
+**All formats accepted as input** — use returned paths directly for subsequent `set`/`remove`. PPT also accepts `@name=` (e.g. `shape[@name=Title 1]`); positional indices like `shape[2]` still work as fallback.
+```bash
+officecli set slides.pptx '/slide[1]/shape[@id=550950021]' --prop bold=true
+```
+
+Elements without stable IDs (slide, paragraph, run, tr/tc, row) use positional indices as fallback.
+
+**When to use stable IDs:** Prefer `@id=` / `@paraId=` paths in multi-step workflows where you add or remove elements between commands — positional indices shift, but stable IDs do not.
+
### query
CSS-like selectors: `[attr=value]`, `[attr!=value]`, `[attr~=text]`, `[attr>=value]`, `[attr<=value]`, `:contains("text")`, `:empty`, `:has(formula)`, `:no-alt`.
@@ -139,6 +166,103 @@ officecli validate slides.pptx # Must pass before delivery
---
+## Watch & Interactive Selection
+
+Live HTML preview that auto-refreshes on every file change. Browsers can click / shift-click / box-drag to select shapes; the CLI can read the current browser selection and act on it.
+
+```bash
+officecli watch [--port N] # Start preview server (default port 18080)
+officecli unwatch # Stop the preview server
+```
+
+Open the printed `http://localhost:N` URL in a browser. Click any shape to select (blue outline highlight); shift/cmd/ctrl+click to multi-select; drag from empty space to box-select (rubber-band).
+
+### `get selected` — read what the user clicked
+
+```bash
+officecli get selected [--json]
+```
+
+Returns the DocumentNodes for whatever is currently selected in the watching browser(s). Empty result if nothing selected. Exit code != 0 if no watch is running for this file.
+
+**Workflow** — agent acts on what the user visually selected:
+
+```bash
+# User clicks shapes in the browser, then asks "make these red"
+PATHS=$(officecli get deck.pptx selected --json | jq -r '.data.Results[].path')
+for p in $PATHS; do
+ officecli set deck.pptx "$p" --prop fill=FF0000
+done
+```
+
+### Key properties
+
+- **Selection survives file edits.** Paths use the stable `@id=` form (e.g. `/slide[1]/shape[@id=10000]`), so editing other shapes — or even the selected one — does not lose the selection.
+- **All connected browsers share one selection.** Opening the watch URL in two tabs gives a shared cursor; clicking in one updates highlights in the other. Last-write-wins.
+- **Same-file single-watch.** A given file can have only one watch process at a time; the second `watch ` errors.
+- **Group shapes select as a whole.** Clicking any shape inside a `` selects the group container, not the inner shape. The CLI sees `/slide[1]/group[@id=N]`. Drilling into individual children of a group is not supported in v1.
+- **PPT and top-level Word.** Selection / mark works on `.pptx` shapes, pictures, tables, charts, connectors, groups, and on `.docx` top-level paragraphs (`
`/``/`
`/`.empty`) and top-level `
`. Inherited layout/master decorations (footers, logos) and Word nested elements (table cells, run-level) are not addressable. **Excel `.xlsx` does not emit `data-path`** — `mark`/`selection` on xlsx will always resolve to `stale=true`. Excel support is a v2 candidate.
+
+## Marks — edit proposals waiting for review
+
+**Marks are edit proposals waiting for review.** Use `mark` when you (or the user) want to see, evaluate, and approve changes BEFORE they hit the file. Marks live in the watch process only — nothing is written to disk until a separate `set` pipeline applies them.
+
+**Decision tree — pick one:**
+
+- User doesn't need to confirm? → **`set`** directly (straight to disk). Marks are overkill for one-shot changes.
+- User wants to review before changes apply? → **`mark`** (propose → review → `set` → mark goes stale).
+- Just leaving a permanent annotation in the file? → **`add --type comment`** (Word native, persists in file).
+
+**Four-step lifecycle:**
+
+1. **Propose** — agent scans and creates marks with `find` + `tofix` + `note`.
+2. **Review** — human opens the watch URL, sees highlights, decides what to accept.
+3. **Apply** — a pipeline reads `get-marks --json` and runs real `set` commands for accepted items.
+4. **Stale** — after the underlying text changes, the mark's `find` no longer matches; `stale=true` signals "this proposal has been handled".
+
+```bash
+officecli mark [--prop find=...] [--prop color=...] [--prop note=...] [--prop tofix=...] [--prop regex=true] [--json]
+officecli unmark [--path
| --all] [--json]
+officecli get-marks [--json]
+```
+
+| Prop | Meaning |
+|------|---------|
+| `find` | Literal text to highlight (or regex when `regex=true`; raw form `find='r"[abc]"'` also accepted). 500ms match timeout. |
+| `color` | CSS color from whitelist: hex, `rgb(...)`, or one of 22 named colors. Invalid rejected. |
+| `note` | Free-form reviewer comment. |
+| `tofix` | Structured proposed replacement value (drives the apply pipeline). |
+| `regex` | `true` to switch `find` to regex. |
+
+**Path** must be `data-path` format from watch HTML: Word `/body/p[N]` or `/body/table[N]`; PPT `/slide[N]/shape[@id=ID]` (preferred) or `/slide[N]/shape[N]`. Excel is not supported in v1 (marks always resolve `stale=true`). Native query paths like `/body/p[@paraId=...]` will NOT resolve.
+
+**Worked example — propose → review → apply → stale:**
+
+```bash
+officecli watch report.docx &
+# 1. Propose
+officecli mark report.docx /body/p[3] --prop find="资钱" --prop tofix="资金" --prop color=red --prop note="术语错误"
+officecli mark report.docx /body/p[7] --prop find="teh" --prop tofix="the" --prop color=yellow
+
+# 2. Review — human eyeballs the browser highlights, optionally unmarks bad proposals
+# 3. Apply — pipeline reads accepted marks and runs real set commands
+# (`.marks // []` defends against the watch dying mid-pipeline; see note below)
+officecli get-marks report.docx --json \
+ | jq -r '(.marks // []) | .[] | select(.tofix != null) | [.path, .find, .tofix] | @tsv' \
+ | while IFS=$'\t' read -r path find tofix; do
+ officecli set report.docx "$path" --prop "find=$find" --prop "replace=$tofix"
+ done
+
+# 4. Verify — applied marks now report stale=true
+officecli get-marks report.docx --json | jq '(.marks // []) | .[] | {find, stale}'
+```
+
+> **Perf:** apply loops like the one above are exactly the case the **Performance: Resident Mode** section above warns about — for >3 mutations, wrap them in `batch` or `open`/`close`. A 20-shape `set` loop drops from ~67 s to under 1 s.
+
+All mark commands support `--json`. Server rejections produce a non-zero exit + error envelope. Even on error, `get-marks --json` always emits a `{version, marks, error?}` shape so the canonical apply pipeline above never crashes on `null`. Check the `error` field if you need to fail fast.
+
+---
+
## L2: DOM Operations
### set — modify properties
@@ -149,6 +273,8 @@ officecli set --prop key=value [--prop ...]
**Any XML attribute is settable** via element path (found via `get --depth N`) — even attributes not currently present.
+Without `find=`, `set` applies format to the entire element. To target specific text within a paragraph, use `find=` (see **find** section below).
+
Run `officecli set` for all settable elements. Run `officecli set ` for detail.
**Value formats:**
@@ -159,13 +285,75 @@ Run `officecli set` for all settable elements. Run `officecli
| Spacing | Unit-qualified | `12pt`, `0.5cm`, `1.5x`, `150%` |
| Dimensions | EMU or suffixed | `914400`, `2.54cm`, `1in`, `72pt`, `96px` |
+### find — format or replace matched text
+
+Use `find=` with `set` to target specific text within a paragraph (or broader scope) for formatting or replacement. The matched text is automatically split into its own run(s). Add `regex=true` for regex matching. Format props are separate `--prop` flags — do NOT nest them (e.g. `--prop bold=true`, not `--prop format=bold:true`).
+
+```bash
+# Format matched text (auto-splits runs) — combine any format props
+officecli set doc.docx '/body/p[1]' --prop find=weather --prop bold=true --prop color=red --prop highlight=yellow
+
+# Regex matching
+officecli set doc.docx '/body/p[1]' --prop 'find=\d+%' --prop regex=true --prop color=red
+
+# Replace text (use `/` for whole-document scope)
+officecli set doc.docx / --prop find=draft --prop replace=final
+
+# Replace + format
+officecli set doc.docx '/body/p[1]' --prop find=TODO --prop replace=DONE --prop bold=true
+
+# Replace in header
+officecli set doc.docx '/header[1]' --prop find=Draft --prop replace=Final
+```
+
+**PPT find works the same way** — same props, same behavior; just swap paths to `/slide[N]/shape[M]` (or `/slide[N]/table[M]`):
+
+```bash
+# Cross-slide replace
+officecli set slides.pptx / --prop find=draft --prop replace=final
+
+# Single-shape replace + format
+officecli set slides.pptx '/slide[1]/shape[1]' --prop find=TODO --prop replace=DONE --prop bold=true
+```
+
+Path controls search scope: `/` = all slides, `/slide[N]` = single slide, `/slide[N]/shape[M]` = single shape, `/slide[N]/table[M]` = table, `/slide[N]/notes` = notes pane.
+
+> **Known limitation:** Notes pane find+format writes correctly, but `get` returns plain text only — run-level formatting cannot be verified via CLI.
+
+**Behavior matrix:**
+
+| Props | Effect |
+|-------|--------|
+| `find` + format props | Split runs, apply format to matched text |
+| `find` + `replace` | Replace matched text |
+| `find` + `replace` + format props | Replace text and apply format to new text |
+
+- Add `regex=true` to enable regex matching: `--prop 'find=\d+%' --prop regex=true`
+ - Batch JSON: `{"props":{"find":"\\d+%","regex":"true","color":"FF0000"}}`
+- Path controls search scope: `/` = body only (excludes headers/footers), `/header[1]` = first header, `/footer[1]` = first footer, `/body/p[1]` = specific paragraph, etc.
+- If `find=` matches nothing, the command succeeds with no changes (no error)
+- `--json` output includes a `"matched": N` field indicating the number of matches found
+- Matching is **case-sensitive** by default. For case-insensitive, use regex: `--prop 'find=(?i)error' --prop regex=true`
+- `find:` / `find=` matches work across run boundaries — text split across multiple runs is still found
+
+**Excel limitations:** Excel only supports `find` + `replace` (text replacement). `find` + format props (formatting matched text without replacing) is not supported in Excel — use Word or PowerPoint for that. In Excel, `find` without `replace` is treated as an unsupported property.
+
### add — add elements or clone
```bash
-officecli add --type [--index N] [--prop ...]
-officecli add --from [--index N] # clone existing element
+officecli add --type [--prop ...]
+officecli add --type --after [--prop ...] # insert after anchor
+officecli add --type --before [--prop ...] # insert before anchor
+officecli add --type --index N [--prop ...] # insert at position (legacy)
+officecli add --from # clone existing element
```
+**Insert position** (`--after`, `--before`, `--index` are mutually exclusive):
+- `--after "p[@paraId=1A2B3C4D]"` — insert after the anchor element (short or full path)
+- `--before "/body/p[@paraId=5E6F7A8B]"` — insert before the anchor element
+- `--index N` — insert at 0-based position (legacy, prefer --after/--before)
+- No position flag — append to end (default)
+
**Element types (with aliases):**
| Format | Types |
@@ -174,18 +362,45 @@ officecli add --from [--index N] # clone existing elem
| **docx** | paragraph (para), run, table, row (tr), cell (td), image (picture/img), header, footer, section, bookmark, comment, footnote, endnote, formfield, sdt (contentcontrol), chart, equation (formula/math), field, hyperlink, style, toc, watermark, break (pagebreak/columnbreak) |
| **xlsx** | sheet, row, cell, chart, image (picture), comment, table (listobject), namedrange (definedname), pivottable (pivot), sparkline, validation (datavalidation), autofilter, shape, textbox, databar/colorscale/iconset/formulacf (conditional formatting), csv (tsv) |
-**Clone:** `officecli add / --from /slide[1]` — copies with all cross-part relationships.
+**Text-anchored insert** (`--after find:X` / `--before find:X`):
+
+The `--after` and `--before` flags accept a `find:` prefix to locate an insertion point by text match within a paragraph.
+
+```bash
+# Insert run after matched text (inline, within the same paragraph)
+officecli add doc.docx '/body/p[1]' --type run --after find:weather --prop text=" (sunny)"
+
+# Insert table after matched text (block — auto-splits the paragraph)
+officecli add doc.docx '/body/p[1]' --type table --after "find:First sentence." --prop rows=2 --prop cols=2
+
+# Insert before matched text
+officecli add doc.docx '/body/p[1]' --type run --before find:weather --prop text="["
+
+```
+
+- Inline types (run, picture, hyperlink...) insert within the paragraph
+- Block types (table, paragraph) auto-split the paragraph and insert between the two halves
+
+**PPT text-anchored insert** — same as Word, but PPT only supports **inline** types (`run`); block-type insertion is not supported.
+
+```bash
+officecli add slides.pptx '/slide[1]/shape[1]' --type run --after find:weather --prop text=" (sunny)"
+```
+
+**Clone:** `officecli add / --from '/slide[1]'` — copies with all cross-part relationships.
Run `officecli add` for all addable types and their properties.
### move, swap, remove
```bash
-officecli move [--to ] [--index N]
+officecli move [--to ] [--index N] [--after ] [--before ]
officecli swap
officecli remove '/body/p[4]'
```
+When using `--after` or `--before`, `--to` can be omitted — the target container is inferred from the anchor path.
+
### batch — multiple operations in one save cycle
Stops on first error by default. Use `--force` to continue past errors.
@@ -204,9 +419,9 @@ officecli batch data.xlsx --commands '[{"op":"set","path":"/Sheet1/A1","props":{
officecli batch data.xlsx --input updates.json --force --json
```
-Batch supports: `add`, `set`, `get`, `query`, `remove`, `move`, `view`, `raw`, `raw-set`, `validate`.
+Batch supports: `add`, `set`, `get`, `query`, `remove`, `move`, `swap`, `view`, `raw`, `raw-set`, `validate`.
-Batch fields: `command` (or `op`), `path`, `parent`, `type`, `from`, `to`, `index`, `props` (dict), `selector`, `mode`, `depth`, `part`, `xpath`, `action`, `xml`.
+Batch fields: `command` (or `op`), `path`, `parent`, `type`, `from`, `to`, `index`, `after`, `before`, `props` (dict), `selector`, `mode`, `depth`, `part`, `xpath`, `action`, `xml`.
JSON output is wrapped in an envelope: `{"results": [...], "summary": {"total", "executed", "succeeded", "failed", "skipped"}}`. On error, each failed result includes the original batch item for debugging. Large outputs automatically spill to a temp file.
@@ -234,10 +449,12 @@ Run `officecli raw` for available parts per format.
|---------|-----------------|
| `--name "foo"` | ❌ Use `--prop name="foo"` — all attributes go through `--prop` |
| `x=-3cm` | ❌ Negative coordinates not supported. Use `x=0cm` or `x=36cm` |
+| PPT `shape[1]` for content | ❌ `shape[1]` is typically the title placeholder. Use `shape[2]` or higher for content shapes |
| `/shape[myname]` | ❌ Name indexing not supported. Use numeric index: `/shape[3]` |
| Guessing property names | ❌ Run `officecli set ` to see exact names |
| Modifying an open file | ❌ Close the file in PowerPoint/WPS first |
| `\n` in shell strings | ❌ Use `\\n` for newlines in `--prop text="..."` |
+| `officecli set f.pptx /slide[1]` | ❌ Shell glob expands brackets. Always single-quote paths: `'/slide[1]'` |
---
@@ -245,15 +462,15 @@ Run `officecli raw` for available parts per format.
This skill covers the officecli CLI basics. For complex scenarios, load the dedicated skill for better results:
-| Scenario | Skill | Min Version | When to Use |
-|----------|-------|:-----------:|-------------|
-| **Word documents** | `officecli-docx` | v1.0.23 | Create, read, edit .docx — reports, letters, memos, proposals |
-| **Academic papers** | `officecli-academic-paper` | v1.0.24 | Research papers, white papers with TOC, equations, footnotes, bibliography |
-| **Presentations** | `officecli-pptx` | v1.0.23 | Create, read, edit .pptx — general slide decks |
-| **Pitch decks** | `officecli-pitch-deck` | v1.0.24 | Investor decks, product launches, sales decks with charts and stat callouts |
-| **Morph PPT** | `morph-ppt` | v1.0.24 | Morph-animated cinematic presentations |
-| **Excel** | `officecli-xlsx` | v1.0.23 | Create, read, edit .xlsx — financial models, trackers, formulas |
-| **Data dashboards** | `officecli-data-dashboard` | v1.0.24 | CSV/tabular data → Excel dashboards with KPI cards, charts, sparklines |
+| Scenario | Skill | When to Use |
+|----------|-------|-------------|
+| **Word documents** | `officecli-docx` | Create, read, edit .docx — reports, letters, memos, proposals |
+| **Academic papers** | `officecli-academic-paper` | Research papers, white papers with TOC, equations, footnotes, bibliography |
+| **Presentations** | `officecli-pptx` | Create, read, edit .pptx — general slide decks |
+| **Pitch decks** | `officecli-pitch-deck` | Investor decks, product launches, sales decks with charts and stat callouts |
+| **Morph PPT** | `morph-ppt` | Morph-animated cinematic presentations |
+| **Excel** | `officecli-xlsx` | Create, read, edit .xlsx — financial models, trackers, formulas |
+| **Data dashboards** | `officecli-data-dashboard` | CSV/tabular data → Excel dashboards with KPI cards, charts, sparklines |
> **How to load:** Ask your AI tool to enable the skill by name, or load the skill file from `skills//SKILL.md`.
diff --git a/skills/morph-ppt/SKILL.md b/skills/morph-ppt/SKILL.md
index 63f50b996..7d53a1473 100644
--- a/skills/morph-ppt/SKILL.md
+++ b/skills/morph-ppt/SKILL.md
@@ -516,6 +516,23 @@ Ask user for feedback, support quick adjustments.
---
+## Adjustments After Creation
+
+When the user requests changes after the deck is built:
+
+| Request | Command |
+|---------|---------|
+| Swap two slides | `officecli swap deck.pptx '/slide[2]' '/slide[4]'` |
+| Move a slide after another | `officecli move deck.pptx '/slide[5]' --after '/slide[2]'` |
+| Edit shape text | `officecli set deck.pptx '/slide[N]/shape[@name=!! ShapeName]' --prop text="..."` |
+| Change color / style | `officecli set deck.pptx '/slide[N]/shape[@name=!! ShapeName]' --prop fill=FF0000` |
+| Remove an element | `officecli remove deck.pptx '/slide[N]/shape[@name=!! ShapeName]'` |
+| Find & replace text | `officecli set deck.pptx / --prop find=OldText --prop replace=NewText` |
+
+> **Morph caution:** Morph transitions rely on matching `!!`-prefixed shape names across consecutive slides. After swapping or moving slides, verify that morph pairs (same `!!` name on adjacent slides) are still correctly aligned. Use `officecli get deck.pptx '/slide[N]' --depth 1` to check shape names.
+
+---
+
**First time?** Read "Understanding Morph" above, skim one style reference for inspiration, then generate. Always use `morph-helpers.py` workflow. You'll learn by doing.
**Trust yourself.** You have vision, design sense, and the ability to iterate. These tools enable you — your creativity makes it excellent.
diff --git a/skills/officecli-academic-paper/SKILL.md b/skills/officecli-academic-paper/SKILL.md
index 4a17c3eac..c6a9379c0 100644
--- a/skills/officecli-academic-paper/SKILL.md
+++ b/skills/officecli-academic-paper/SKILL.md
@@ -185,8 +185,24 @@ Follow [creating.md](creating.md) for the full step-by-step guide.
---
+## Adjustments After Creation
+
+When the user requests changes after the paper is built:
+
+| Request | Command |
+|---------|---------|
+| Move a paragraph after another | `officecli move paper.docx '/body/p[8]' --after '/body/p[2]'` |
+| Swap two paragraphs | `officecli swap paper.docx '/body/p[3]' '/body/p[7]'` |
+| Edit paragraph text | `officecli set paper.docx '/body/p[N]' --prop text="..."` |
+| Find & replace text | `officecli set paper.docx / --prop find=OldText --prop replace=NewText` |
+| Remove a paragraph | `officecli remove paper.docx '/body/p[N]'` |
+
+After any `swap` or `move`, paragraph indices shift — re-query with `officecli get paper.docx /body --depth 1` before further edits.
+
+---
+
## References
- [creating.md](creating.md) -- Complete academic paper creation guide
-- [docx SKILL.md](../docx/SKILL.md) -- General docx reading, editing, and QA reference
-- [docx creating.md](../docx/creating.md) -- General building blocks (paragraphs, tables, images, etc.)
+- [docx SKILL.md](../officecli-docx/SKILL.md) -- General docx reading, editing, and QA reference
+- [docx creating.md](../officecli-docx/creating.md) -- General building blocks (paragraphs, tables, images, etc.)
diff --git a/skills/officecli-data-dashboard/SKILL.md b/skills/officecli-data-dashboard/SKILL.md
index 316be6faa..a18cf0c1f 100644
--- a/skills/officecli-data-dashboard/SKILL.md
+++ b/skills/officecli-data-dashboard/SKILL.md
@@ -124,7 +124,21 @@ Read [creating.md](creating.md) and follow it step by step. It contains the comp
---
+## Adjustments After Creation
+
+When the user requests changes after the dashboard is built:
+
+| Request | Command |
+|---------|---------|
+| Swap two sheets | `officecli swap dashboard.xlsx '/Dashboard' '/Data'` |
+| Move a sheet after another | `officecli move dashboard.xlsx '/Summary' --after '/Dashboard'` |
+| Edit a cell value | `officecli set dashboard.xlsx '/Dashboard/A1' --prop value="..."` |
+| Find & replace text | `officecli set dashboard.xlsx / --prop find=OldText --prop replace=NewText` |
+| Update chart data | `officecli set dashboard.xlsx '/Dashboard/chart[N]' --prop data="A1:D10"` |
+
+---
+
## References
- [creating.md](creating.md) -- Complete dashboard creation guide (the main skill file)
-- [xlsx SKILL.md](../xlsx/SKILL.md) -- General xlsx reading, editing, and QA reference
+- [xlsx SKILL.md](../officecli-xlsx/SKILL.md) -- General xlsx reading, editing, and QA reference
diff --git a/skills/officecli-docx/SKILL.md b/skills/officecli-docx/SKILL.md
index b56ca320b..26b45f1cc 100644
--- a/skills/officecli-docx/SKILL.md
+++ b/skills/officecli-docx/SKILL.md
@@ -368,11 +368,11 @@ cat <<'EOF' | officecli batch doc.docx
EOF
```
-Batch supports: `add`, `set`, `get`, `query`, `remove`, `move`, `view`, `raw`, `raw-set`, `validate`.
+Batch supports: `add`, `set`, `get`, `query`, `remove`, `move`, `swap`, `view`, `raw`, `raw-set`, `validate`.
-Batch fields: `command`, `path`, `parent`, `type`, `from`, `to`, `index`, `props` (dict), `selector`, `mode`, `depth`, `part`, `xpath`, `action`, `xml`.
+Batch fields: `command`, `path`, `parent`, `type`, `from`, `to`, `index`, `after`, `before`, `props` (dict), `selector`, `mode`, `depth`, `part`, `xpath`, `action`, `xml`.
-`parent` = container to add into (for `add`). `path` = element to modify (for `set`, `get`, `remove`, `move`).
+`parent` = container to add into (for `add`). `path` = element to modify (for `set`, `get`, `remove`, `move`, `swap`).
---
# officecli: v1.0.23
diff --git a/skills/officecli-docx/creating.md b/skills/officecli-docx/creating.md
index eb73e36d5..a0fec3391 100644
--- a/skills/officecli-docx/creating.md
+++ b/skills/officecli-docx/creating.md
@@ -963,14 +963,15 @@ officecli set doc.docx "/body/p[10]" --prop style=BlockQuote
### Find/Replace
```bash
-# Find and replace across entire document
+# Find and replace in body
officecli set doc.docx / --prop find="2024" --prop replace="2025"
-# Scoped find/replace (body only, not headers/footers)
-officecli set doc.docx / --prop find="old text" --prop replace="new text" --prop scope=body
+# Find and replace in headers/footers only
+officecli set doc.docx '/header[1]' --prop find="Company Name" --prop replace="Acme Corp"
-# Replace in headers/footers only
-officecli set doc.docx / --prop find="Company Name" --prop replace="Acme Corp" --prop scope=headers
+# Find and replace everywhere (body + headers): call twice
+officecli set doc.docx / --prop find="old text" --prop replace="new text"
+officecli set doc.docx '/header[1]' --prop find="old text" --prop replace="new text"
```
**WARNING: Find/replace performs substring matching, not whole-word matching. Replacing "ACME" in "ACME Corporation" produces "New Name Corporation". After any find/replace, review with `view text` and run a second cleanup pass if needed.**
diff --git a/skills/officecli-docx/editing.md b/skills/officecli-docx/editing.md
index eb5600889..2c6c9e5a4 100644
--- a/skills/officecli-docx/editing.md
+++ b/skills/officecli-docx/editing.md
@@ -136,6 +136,9 @@ officecli add doc.docx /body --type section --prop type=nextPage --index 12
# Move paragraph to position
officecli move doc.docx "/body/p[8]" --index 2
+# Move paragraph after an anchor (target parent inferred automatically)
+officecli move doc.docx "/body/p[8]" --after "/body/p[2]"
+
# Swap two paragraphs
officecli swap doc.docx "/body/p[3]" "/body/p[7]"
```
@@ -233,17 +236,15 @@ officecli add doc.docx /body --type chart --prop chartType=column --prop categor
### Find/Replace
```bash
-# Global find/replace
+# Find/replace in body (default)
officecli set doc.docx / --prop find="2024" --prop replace="2025"
-# Scoped find/replace
-officecli set doc.docx / --prop find="Acme Inc" --prop replace="Acme Corporation" --prop scope=all
-
-# Body only (skip headers/footers)
-officecli set doc.docx / --prop find="old term" --prop replace="new term" --prop scope=body
+# Find/replace in headers/footers only
+officecli set doc.docx '/header[1]' --prop find="Company Name" --prop replace="Acme Corp"
-# Headers/footers only
-officecli set doc.docx / --prop find="Company Name" --prop replace="Acme Corp" --prop scope=headers
+# Find/replace everywhere (body + headers): call twice
+officecli set doc.docx / --prop find="Acme Inc" --prop replace="Acme Corporation"
+officecli set doc.docx '/header[1]' --prop find="Acme Inc" --prop replace="Acme Corporation"
```
**WARNING: Find/replace performs substring matching, not whole-word matching. Replacing "ACME" in "ACME Corporation" produces "New Name Corporation". After any find/replace, review with `view text` and run a second cleanup pass if needed.**
diff --git a/skills/officecli-financial-model/SKILL.md b/skills/officecli-financial-model/SKILL.md
index ee9d01777..b9e101ccb 100644
--- a/skills/officecli-financial-model/SKILL.md
+++ b/skills/officecli-financial-model/SKILL.md
@@ -171,6 +171,20 @@ Before delivering the `.xlsx` file, verify all items:
---
+## Adjustments After Creation
+
+When the user requests changes after the model is built:
+
+| Request | Command |
+|---------|---------|
+| Swap two sheets | `officecli swap model.xlsx '/Sheet1' '/Sheet2'` |
+| Move a sheet after another | `officecli move model.xlsx '/Scenarios' --after '/Assumptions'` |
+| Edit a cell value | `officecli set model.xlsx '/SheetName/A1' --prop value="..."` |
+| Find & replace text | `officecli set model.xlsx / --prop find=OldText --prop replace=NewText` |
+| Remove a row | `officecli remove model.xlsx '/SheetName/row[N]'` |
+
+---
+
## Full Guide
Read [creating.md](creating.md) and follow it step by step. It contains setup conventions, core financial statement patterns, advanced patterns (DCF, sensitivity, scenarios), chart recipes, QA checklist, and known issues with workarounds.
diff --git a/skills/officecli-pitch-deck/SKILL.md b/skills/officecli-pitch-deck/SKILL.md
index c8e32915d..4aadbdb89 100644
--- a/skills/officecli-pitch-deck/SKILL.md
+++ b/skills/officecli-pitch-deck/SKILL.md
@@ -268,6 +268,23 @@ See [creating.md](creating.md) Section H for the full list with workarounds. Key
---
+## Adjustments After Creation
+
+When the user requests changes after the deck is built:
+
+| Request | Command |
+|---------|---------|
+| Swap two slides | `officecli swap deck.pptx '/slide[2]' '/slide[4]'` |
+| Move a slide after another | `officecli move deck.pptx '/slide[5]' --after '/slide[2]'` |
+| Edit shape text | `officecli set deck.pptx '/slide[N]/shape[M]' --prop text="..."` |
+| Change color / style | `officecli set deck.pptx '/slide[N]/shape[M]' --prop fill=FF0000` |
+| Remove an element | `officecli remove deck.pptx '/slide[N]/shape[M]'` |
+| Find & replace text | `officecli set deck.pptx / --prop find=OldText --prop replace=NewText` |
+
+After any `swap` or `move`, re-query the affected slide with `officecli get deck.pptx '/slide[N]' --depth 1` — shape indices shift after reordering.
+
+---
+
## Help System
```bash
diff --git a/skills/officecli-pptx/SKILL.md b/skills/officecli-pptx/SKILL.md
index 55784e253..e87d578b4 100644
--- a/skills/officecli-pptx/SKILL.md
+++ b/skills/officecli-pptx/SKILL.md
@@ -651,13 +651,13 @@ cat <<'EOF' | officecli batch slides.pptx
EOF
```
-Batch supports: `add`, `set`, `get`, `query`, `remove`, `move`, `view`, `raw`, `raw-set`, `validate`.
+Batch supports: `add`, `set`, `get`, `query`, `remove`, `move`, `swap`, `view`, `raw`, `raw-set`, `validate`.
**Batch and resident mode are independent.** Each improves performance on its own. They can be combined, but batch alone (without `open`) already handles the file I/O in one cycle per batch call.
-Batch fields: `command`, `path`, `parent`, `type`, `from`, `to`, `index`, `props` (dict), `selector`, `mode`, `depth`, `part`, `xpath`, `action`, `xml`.
+Batch fields: `command`, `path`, `parent`, `type`, `from`, `to`, `index`, `after`, `before`, `props` (dict), `selector`, `mode`, `depth`, `part`, `xpath`, `action`, `xml`.
-`parent` = container to add into (for `add`, including clone via `from` field). `path` = element to modify (for `set`, `get`, `remove`, `move`).
+`parent` = container to add into (for `add`, including clone via `from` field). `path` = element to modify (for `set`, `get`, `remove`, `move`, `swap`).
---
diff --git a/skills/officecli-pptx/editing.md b/skills/officecli-pptx/editing.md
index ca751deea..f84d3537a 100644
--- a/skills/officecli-pptx/editing.md
+++ b/skills/officecli-pptx/editing.md
@@ -198,6 +198,12 @@ officecli remove template.pptx /slide[3]
```bash
# Move slide 5 to position index 1 (becomes second slide)
officecli move template.pptx /slide[5] --index 1
+
+# Move slide after another slide (anchor-based)
+officecli move template.pptx /slide[5] --after /slide[2]
+
+# Swap two slides
+officecli swap template.pptx /slide[2] /slide[4]
```
### Add New Slides
diff --git a/skills/officecli-xlsx/SKILL.md b/skills/officecli-xlsx/SKILL.md
index 5bbbfa737..c5e5723e8 100644
--- a/skills/officecli-xlsx/SKILL.md
+++ b/skills/officecli-xlsx/SKILL.md
@@ -407,11 +407,11 @@ cat <<'EOF' | officecli batch data.xlsx
EOF
```
-Batch supports: `add`, `set`, `get`, `query`, `remove`, `move`, `view`, `raw`, `raw-set`, `validate`.
+Batch supports: `add`, `set`, `get`, `query`, `remove`, `move`, `swap`, `view`, `raw`, `raw-set`, `validate`.
-Batch fields: `command`, `path`, `parent`, `type`, `from`, `to`, `index`, `props` (dict), `selector`, `mode`, `depth`, `part`, `xpath`, `action`, `xml`.
+Batch fields: `command`, `path`, `parent`, `type`, `from`, `to`, `index`, `after`, `before`, `props` (dict), `selector`, `mode`, `depth`, `part`, `xpath`, `action`, `xml`.
-`parent` = container to add into (for `add`). `path` = element to modify (for `set`, `get`, `remove`).
+`parent` = container to add into (for `add`). `path` = element to modify (for `set`, `get`, `remove`, `move`, `swap`).
Batch mode executes multiple operations in a single open/save cycle.
diff --git a/skills/officecli-xlsx/editing.md b/skills/officecli-xlsx/editing.md
index bb61e9a1e..f54b8b78d 100644
--- a/skills/officecli-xlsx/editing.md
+++ b/skills/officecli-xlsx/editing.md
@@ -114,7 +114,11 @@ officecli remove data.xlsx "/OldSheet"
### Reorder Sheets
```bash
+# Swap two sheets
officecli swap data.xlsx "/Sheet1" "/Sheet2"
+
+# Move sheet after another (anchor-based)
+officecli move data.xlsx "/Sheet3" --after "/Sheet1"
```
### Add/Remove Rows
diff --git a/src/officecli/BlankDocCreator.cs b/src/officecli/BlankDocCreator.cs
index ea33943a3..c5a12f9ae 100644
--- a/src/officecli/BlankDocCreator.cs
+++ b/src/officecli/BlankDocCreator.cs
@@ -51,8 +51,10 @@ private static void CreateWord(string path)
using var doc = WordprocessingDocument.Create(path, WordprocessingDocumentType.Document);
var mainPart = doc.AddMainDocumentPart();
- // Section with no docGrid snap
+ // Section with A4 page size, standard margins, and no docGrid snap
var sectPr = new SectionProperties(
+ new PageSize { Width = 11906, Height = 16838 },
+ new PageMargin { Top = 1440, Right = 1800U, Bottom = 1440, Left = 1800U },
new DocGrid { Type = DocGridValues.Default }
);
diff --git a/src/officecli/CommandBuilder.Add.cs b/src/officecli/CommandBuilder.Add.cs
index fb9ae03d1..f83beb876 100644
--- a/src/officecli/CommandBuilder.Add.cs
+++ b/src/officecli/CommandBuilder.Add.cs
@@ -15,6 +15,8 @@ private static Command BuildAddCommand(Option jsonOption)
var addTypeOpt = new Option("--type") { Description = "Element type to add (e.g. paragraph, run, table, sheet, row, cell, slide, shape)" };
var addFromOpt = new Option("--from") { Description = "Copy from an existing element path (e.g. /slide[1]/shape[2])" };
var addIndexOpt = new Option("--index") { Description = "Insert position (0-based). If omitted, appends to end" };
+ var addAfterOpt = new Option("--after") { Description = "Insert after the element at this path (e.g. p[@paraId=1A2B3C4D])" };
+ var addBeforeOpt = new Option("--before") { Description = "Insert before the element at this path" };
var addPropsOpt = new Option("--prop") { Description = "Property to set (key=value)", AllowMultipleArgumentsPerToken = true };
var forceOption = new Option("--force") { Description = "Force write even if document is protected" };
@@ -24,6 +26,8 @@ private static Command BuildAddCommand(Option jsonOption)
addCommand.Add(addTypeOpt);
addCommand.Add(addFromOpt);
addCommand.Add(addIndexOpt);
+ addCommand.Add(addAfterOpt);
+ addCommand.Add(addBeforeOpt);
addCommand.Add(addPropsOpt);
addCommand.Add(jsonOption);
addCommand.Add(forceOption);
@@ -35,8 +39,24 @@ private static Command BuildAddCommand(Option jsonOption)
var type = result.GetValue(addTypeOpt);
var from = result.GetValue(addFromOpt);
var index = result.GetValue(addIndexOpt);
+ var after = result.GetValue(addAfterOpt);
+ var before = result.GetValue(addBeforeOpt);
var props = result.GetValue(addPropsOpt);
var force = result.GetValue(forceOption);
+
+ // Validate mutual exclusivity of --index, --after, --before
+ var posCount = (index.HasValue ? 1 : 0) + (after != null ? 1 : 0) + (before != null ? 1 : 0);
+ if (posCount > 1)
+ throw new OfficeCli.Core.CliException("--index, --after, and --before are mutually exclusive. Use only one.")
+ {
+ Code = "invalid_argument",
+ Suggestion = "Use --index for positional insert, or --after/--before for anchor-based insert."
+ };
+
+ InsertPosition? position = index.HasValue ? InsertPosition.AtIndex(index.Value)
+ : after != null ? InsertPosition.AfterElement(after)
+ : before != null ? InsertPosition.BeforeElement(before)
+ : null;
bool hadWarnings = false;
// Check document protection for .docx files
@@ -87,12 +107,14 @@ private static Command BuildAddCommand(Option jsonOption)
req.Command = "add";
req.Args["parent"] = parentPath;
req.Args["from"] = from;
- if (index.HasValue) req.Args["index"] = index.Value.ToString();
+ if (position?.Index.HasValue == true) req.Args["index"] = position.Index.Value.ToString();
+ if (position?.After != null) req.Args["after"] = position.After;
+ if (position?.Before != null) req.Args["before"] = position.Before;
}, json) is {} rc) return rc != 0 ? rc : (hadWarnings ? 2 : 0);
using var handler = DocumentHandlerFactory.Open(file.FullName, editable: true);
var oldCount = (handler as OfficeCli.Handlers.PowerPointHandler)?.GetSlideCount() ?? 0;
- var resultPath = handler.CopyFrom(from, parentPath, index);
+ var resultPath = handler.CopyFrom(from, parentPath, position);
var message = $"Copied to {resultPath}";
if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeText(message));
else Console.WriteLine(message);
@@ -106,7 +128,9 @@ private static Command BuildAddCommand(Option jsonOption)
req.Command = "add";
req.Args["parent"] = parentPath;
req.Args["type"] = type!;
- if (index.HasValue) req.Args["index"] = index.Value.ToString();
+ if (position?.Index.HasValue == true) req.Args["index"] = position.Index.Value.ToString();
+ if (position?.After != null) req.Args["after"] = position.After;
+ if (position?.Before != null) req.Args["before"] = position.Before;
req.Props = ParsePropsArray(props);
}, json) is {} rc) return rc != 0 ? rc : (hadWarnings ? 2 : 0);
@@ -122,7 +146,7 @@ private static Command BuildAddCommand(Option jsonOption)
using var handler = DocumentHandlerFactory.Open(file.FullName, editable: true);
var oldCount = (handler as OfficeCli.Handlers.PowerPointHandler)?.GetSlideCount() ?? 0;
- var resultPath = handler.Add(parentPath, type!, index, properties);
+ var resultPath = handler.Add(parentPath, type!, position, properties);
var message = $"Added {type} at {resultPath}";
var spatialLine = GetPptSpatialLine(handler, resultPath);
var overlapNames = spatialLine != null ? CheckPositionOverlap(handler, resultPath) : new();
@@ -214,12 +238,16 @@ private static Command BuildMoveCommand(Option jsonOption)
var movePathArg = new Argument("path") { Description = "DOM path of the element to move" };
var moveToOpt = new Option("--to") { Description = "Target parent path. If omitted, reorders within the current parent" };
var moveIndexOpt = new Option("--index") { Description = "Insert position (0-based). If omitted, appends to end" };
+ var moveAfterOpt = new Option("--after") { Description = "Move after the element at this path" };
+ var moveBeforeOpt = new Option("--before") { Description = "Move before the element at this path" };
var moveCommand = new Command("move", "Move an element to a new position or parent");
moveCommand.Add(moveFileArg);
moveCommand.Add(movePathArg);
moveCommand.Add(moveToOpt);
moveCommand.Add(moveIndexOpt);
+ moveCommand.Add(moveAfterOpt);
+ moveCommand.Add(moveBeforeOpt);
moveCommand.Add(jsonOption);
moveCommand.SetAction(result => { var json = result.GetValue(jsonOption); return SafeRun(() =>
@@ -228,17 +256,35 @@ private static Command BuildMoveCommand(Option jsonOption)
var path = result.GetValue(movePathArg)!;
var to = result.GetValue(moveToOpt);
var index = result.GetValue(moveIndexOpt);
+ var after = result.GetValue(moveAfterOpt);
+ var before = result.GetValue(moveBeforeOpt);
+
+ // Validate mutual exclusivity of --index, --after, --before
+ var posCount = (index.HasValue ? 1 : 0) + (after != null ? 1 : 0) + (before != null ? 1 : 0);
+ if (posCount > 1)
+ throw new OfficeCli.Core.CliException("--index, --after, and --before are mutually exclusive. Use only one.")
+ {
+ Code = "invalid_argument",
+ Suggestion = "Use --index for positional insert, or --after/--before for anchor-based insert."
+ };
+
+ InsertPosition? position = index.HasValue ? InsertPosition.AtIndex(index.Value)
+ : after != null ? InsertPosition.AfterElement(after)
+ : before != null ? InsertPosition.BeforeElement(before)
+ : null;
if (TryResident(file.FullName, req =>
{
req.Command = "move";
req.Args["path"] = path;
if (to != null) req.Args["to"] = to;
- if (index.HasValue) req.Args["index"] = index.Value.ToString();
+ if (position?.Index.HasValue == true) req.Args["index"] = position.Index.Value.ToString();
+ if (position?.After != null) req.Args["after"] = position.After;
+ if (position?.Before != null) req.Args["before"] = position.Before;
}, json) is {} rc) return rc;
using var handler = DocumentHandlerFactory.Open(file.FullName, editable: true);
- var resultPath = handler.Move(path, to, index);
+ var resultPath = handler.Move(path, to, position);
var message = $"Moved to {resultPath}";
if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeText(message));
else Console.WriteLine(message);
@@ -248,4 +294,47 @@ private static Command BuildMoveCommand(Option jsonOption)
return moveCommand;
}
+
+ private static Command BuildSwapCommand(Option jsonOption)
+ {
+ var swapFileArg = new Argument("file") { Description = "Office document path" };
+ var swapPath1Arg = new Argument("path1") { Description = "DOM path of the first element" };
+ var swapPath2Arg = new Argument("path2") { Description = "DOM path of the second element" };
+
+ var swapCommand = new Command("swap", "Swap two elements in the document");
+ swapCommand.Add(swapFileArg);
+ swapCommand.Add(swapPath1Arg);
+ swapCommand.Add(swapPath2Arg);
+ swapCommand.Add(jsonOption);
+
+ swapCommand.SetAction(result => { var json = result.GetValue(jsonOption); return SafeRun(() =>
+ {
+ var file = result.GetValue(swapFileArg)!;
+ var path1 = result.GetValue(swapPath1Arg)!;
+ var path2 = result.GetValue(swapPath2Arg)!;
+
+ if (TryResident(file.FullName, req =>
+ {
+ req.Command = "swap";
+ req.Args["path"] = path1;
+ req.Args["to"] = path2;
+ }, json) is {} rc) return rc;
+
+ using var handler = DocumentHandlerFactory.Open(file.FullName, editable: true);
+ var (p1, p2) = handler switch
+ {
+ OfficeCli.Handlers.PowerPointHandler ppt => ppt.Swap(path1, path2),
+ OfficeCli.Handlers.WordHandler word => word.Swap(path1, path2),
+ OfficeCli.Handlers.ExcelHandler excel => excel.Swap(path1, path2),
+ _ => throw new InvalidOperationException("swap not supported for this document type")
+ };
+ var message = $"Swapped {p1} <-> {p2}";
+ if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeText(message));
+ else Console.WriteLine(message);
+ NotifyWatch(handler, file.FullName, path1);
+ return 0;
+ }, json); });
+
+ return swapCommand;
+ }
}
diff --git a/src/officecli/CommandBuilder.Batch.cs b/src/officecli/CommandBuilder.Batch.cs
index 9cf26d6bc..9509feb0f 100644
--- a/src/officecli/CommandBuilder.Batch.cs
+++ b/src/officecli/CommandBuilder.Batch.cs
@@ -76,6 +76,40 @@ private static Command BuildBatchCommand(Option jsonOption)
return 0;
}
+ // BUG-FUZZER-R6-03: batch must honour the same .docx document
+ // protection check that `set` enforces. Without this, a protected
+ // doc could be silently modified via
+ // officecli batch protected.docx --commands '[{"command":"set",...}]'
+ // even though the same set issued via the standalone `set` command
+ // would be rejected. We piggy-back on `--force` (which already
+ // means "ignore safety guards" for the continue-on-error path) so
+ // agents that need to override protection use the same flag they
+ // already know from `set --force`.
+ // CONSISTENCY(docx-protection): if you change the protection
+ // semantics, also update CommandBuilder.Set.cs at the matching
+ // CheckDocxProtection call site.
+ var force = !stopOnError;
+ if (!force && file.Extension.Equals(".docx", StringComparison.OrdinalIgnoreCase))
+ {
+ foreach (var batchItem in items)
+ {
+ // Only mutation commands need the protection gate. Read
+ // commands (get/query/view) are unaffected by document
+ // protection — protection blocks writes, not reads.
+ var cmdLower = (batchItem.Command ?? "").ToLowerInvariant();
+ if (cmdLower is not ("set" or "add" or "remove" or "raw-set"))
+ continue;
+ // Property-bag protection-changing op is its own escape
+ // hatch (mirrors set's isProtectionChange exemption).
+ if (batchItem.Props != null && batchItem.Props.Keys.Any(k =>
+ k.Equals("protection", StringComparison.OrdinalIgnoreCase)))
+ continue;
+ var path = batchItem.Path ?? "";
+ var rc = CheckDocxProtection(file.FullName, path, json);
+ if (rc != 0) return rc;
+ }
+ }
+
// If a resident process is running, forward each command to it
if (ResidentClient.TryConnect(file.FullName, out _))
{
diff --git a/src/officecli/CommandBuilder.GetQuery.cs b/src/officecli/CommandBuilder.GetQuery.cs
index 5d5c619f8..8e18d2b67 100644
--- a/src/officecli/CommandBuilder.GetQuery.cs
+++ b/src/officecli/CommandBuilder.GetQuery.cs
@@ -11,7 +11,7 @@ static partial class CommandBuilder
private static Command BuildGetCommand(Option jsonOption)
{
var getFileArg = new Argument("file") { Description = "Office document path (required even with open/close mode)" };
- var pathArg = new Argument("path") { Description = "DOM path (e.g. /body/p[1])" };
+ var pathArg = new Argument("path") { Description = "DOM path (e.g. /body/p[1]) or 'selected' to read the current watch selection" };
pathArg.DefaultValueFactory = _ => "/";
var depthOpt = new Option("--depth") { Description = "Depth of child nodes to include" };
depthOpt.DefaultValueFactory = _ => 1;
@@ -28,6 +28,13 @@ private static Command BuildGetCommand(Option jsonOption)
var path = result.GetValue(pathArg)!;
var depth = result.GetValue(depthOpt);
+ // Special pseudo-path "selected" — query the running watch process
+ // for the currently-selected element paths and resolve them to nodes.
+ if (string.Equals(path, "selected", StringComparison.OrdinalIgnoreCase))
+ {
+ return GetSelectedAction(file.FullName, depth, json);
+ }
+
if (TryResident(file.FullName, req =>
{
req.Command = "get";
@@ -49,6 +56,51 @@ private static Command BuildGetCommand(Option jsonOption)
return getCommand;
}
+ private static int GetSelectedAction(string filePath, int depth, bool json)
+ {
+ var paths = WatchNotifier.QuerySelection(filePath);
+ if (paths == null)
+ {
+ var msg = $"no watch running for {Path.GetFileName(filePath)}. Start one with: officecli watch \"{filePath}\"";
+ if (json)
+ Console.WriteLine(OutputFormatter.WrapEnvelopeError(msg));
+ else
+ Console.Error.WriteLine($"Error: {msg}");
+ return 1;
+ }
+
+ // Resolve each path to a DocumentNode. Skip paths that no longer exist
+ // (e.g. element removed since selection was made) — silently drop them.
+ var nodes = new List();
+ if (paths.Length > 0)
+ {
+ using var handler = DocumentHandlerFactory.Open(filePath);
+ foreach (var p in paths)
+ {
+ try
+ {
+ var n = handler.Get(p, depth);
+ if (n != null) nodes.Add(n);
+ }
+ catch
+ {
+ // path no longer resolves — drop
+ }
+ }
+ }
+
+ if (json)
+ {
+ Console.WriteLine(OutputFormatter.WrapEnvelope(
+ OutputFormatter.FormatNodes(nodes, OutputFormat.Json)));
+ }
+ else
+ {
+ Console.WriteLine(OutputFormatter.FormatNodes(nodes, OutputFormat.Text));
+ }
+ return 0;
+ }
+
private static Command BuildQueryCommand(Option jsonOption)
{
var queryFileArg = new Argument("file") { Description = "Office document path (required even with open/close mode)" };
diff --git a/src/officecli/CommandBuilder.Mark.cs b/src/officecli/CommandBuilder.Mark.cs
new file mode 100644
index 000000000..4d0ad7c90
--- /dev/null
+++ b/src/officecli/CommandBuilder.Mark.cs
@@ -0,0 +1,375 @@
+// Copyright 2025 OfficeCli (officecli.ai)
+// SPDX-License-Identifier: Apache-2.0
+
+using System.CommandLine;
+using OfficeCli.Core;
+
+namespace OfficeCli;
+
+static partial class CommandBuilder
+{
+ // ==================== mark ====================
+
+ // Canonical prop names accepted by `mark --prop`. Any other key triggers
+ // the unknown-prop warning. Lower-case for case-insensitive comparison
+ // (the prop dictionary itself is OrdinalIgnoreCase).
+ private static readonly HashSet KnownMarkProps = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "find", "color", "note", "tofix", "regex",
+ };
+
+ private static Command BuildMarkCommand(Option jsonOption)
+ {
+ var fileArg = new Argument("file") { Description = "Office document path (.pptx, .xlsx, .docx)" };
+ var pathArg = new Argument("path") { Description = "DOM path to the element to mark" };
+ var propsOpt = new Option("--prop")
+ {
+ Description = "Mark property: find=..., color=..., note=..., tofix=..., regex=true",
+ AllowMultipleArgumentsPerToken = true,
+ };
+
+ var cmd = new Command("mark",
+ "Attach an in-memory advisory mark to a document element via the running watch process. " +
+ "Marks are not written to the file. " +
+ "Path must be in data-path format (e.g. /body/p[1] for Word, /slide[1]/shape[@id=N] for PPT), as emitted by watch HTML preview. " +
+ "Use the 'selected' pseudo-path to mark every currently-selected element in one call (one mark per selected path). " +
+ "Inspect the rendered HTML for valid paths. Native handler query paths like /body/p[@paraId=...] will not resolve.");
+ cmd.Add(fileArg);
+ cmd.Add(pathArg);
+ cmd.Add(propsOpt);
+ cmd.Add(jsonOption);
+
+ cmd.SetAction(result => { var json = result.GetValue(jsonOption); return SafeRun(() =>
+ {
+ var file = result.GetValue(fileArg)!;
+ var path = result.GetValue(pathArg)!;
+ var rawProps = result.GetValue(propsOpt) ?? Array.Empty();
+
+ var props = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ string? deprecatedExpectValue = null;
+ foreach (var p in rawProps)
+ {
+ var eq = p.IndexOf('=');
+ if (eq <= 0) continue;
+ var key = p[..eq];
+ var val = p[(eq + 1)..];
+
+ // (a) Deprecated alias: `expect` was renamed to `tofix` in a052fb6.
+ // Route the value to `tofix` with a deprecation warning on stderr
+ // so old scripts/prompts continue to work instead of silently
+ // losing data. Explicit `--prop tofix=...` takes precedence.
+ if (string.Equals(key, "expect", StringComparison.OrdinalIgnoreCase))
+ {
+ deprecatedExpectValue = val;
+ continue;
+ }
+
+ // (c) Unknown prop — warn and ignore instead of dropping silently.
+ // This catches typos like --prop noet=... that previously produced
+ // a mark with missing fields and no diagnostic.
+ if (!KnownMarkProps.Contains(key))
+ {
+ Console.Error.WriteLine(
+ $"Warning: unknown property '{key}' for mark, ignored. " +
+ "Known: find, color, note, tofix, regex.");
+ continue;
+ }
+
+ props[key] = val;
+ }
+
+ if (deprecatedExpectValue != null)
+ {
+ if (props.ContainsKey("tofix"))
+ {
+ // Explicit `tofix` wins — the `expect` value is dropped.
+ // Warn the user the alias was shadowed so they don't wonder
+ // where their value went.
+ Console.Error.WriteLine(
+ "Warning: 'expect' has been renamed to 'tofix'. " +
+ "An explicit 'tofix' was also provided and takes precedence; " +
+ "the 'expect' value was ignored. Please update your scripts.");
+ }
+ else
+ {
+ props["tofix"] = deprecatedExpectValue;
+ Console.Error.WriteLine(
+ "Warning: 'expect' has been renamed to 'tofix'. " +
+ "The value has been applied to 'tofix'. Please update your scripts.");
+ }
+ }
+
+ // CONSISTENCY(find-regex): 复用 WordHandler.Set.cs:60-61 的 regex→raw-string 转换,
+ // 保持 mark 和 set 在 find/regex 词汇上完全一致(literal | r"..." | regex=true flag)。
+ // 要修改 find 解析协议,grep "CONSISTENCY(find-regex)" 找全所有调用点项目级一起改,
+ // 不要在 mark 单点改。见 CLAUDE.md Design Principles。
+ props.TryGetValue("find", out var findText);
+ findText ??= "";
+ if (props.TryGetValue("regex", out var regexFlag) && ParseHelpers.IsTruthySafe(regexFlag)
+ && !findText.StartsWith("r\"") && !findText.StartsWith("r'"))
+ {
+ findText = $"r\"{findText}\"";
+ }
+
+ // Build the common prop set once — reused for every target path
+ // when the user passes the `selected` pseudo-path.
+ var findVal = string.IsNullOrEmpty(findText) ? null : findText;
+ var colorVal = props.TryGetValue("color", out var c) ? c : null;
+ var noteVal = props.TryGetValue("note", out var n) ? n : null;
+ var tofixVal = props.TryGetValue("tofix", out var e) ? e : null;
+
+ // Resolve the target path(s). For the 'selected' pseudo-path, pull the
+ // current selection from the running watch process and mark each path
+ // individually with the same prop set. Rationale: a block of selected
+ // elements is conceptually N independent marks (one per element); a
+ // single mark with N paths would need new wire-format plumbing and
+ // make find/stale semantics ambiguous.
+ List targetPaths;
+ if (string.Equals(path, "selected", StringComparison.Ordinal))
+ {
+ var selection = WatchNotifier.QuerySelection(file.FullName);
+ if (selection == null)
+ {
+ var err = $"No watch process is running for {file.Name}. Start one with: officecli watch {file.Name}";
+ if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeError(err));
+ else Console.Error.WriteLine(err);
+ return 1;
+ }
+ if (selection.Length == 0)
+ {
+ var err = "No elements are currently selected. Click or drag-select in the watch browser first.";
+ if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeError(err));
+ else Console.Error.WriteLine(err);
+ return 1;
+ }
+ targetPaths = new List(selection);
+ }
+ else
+ {
+ targetPaths = new List { path };
+ }
+
+ var createdIds = new List();
+ var createdMarks = new List();
+ foreach (var targetPath in targetPaths)
+ {
+ var req = new MarkRequest
+ {
+ Path = targetPath,
+ Find = findVal,
+ Color = colorVal,
+ Note = noteVal,
+ Tofix = tofixVal,
+ };
+
+ string? id;
+ try
+ {
+ id = WatchNotifier.AddMark(file.FullName, req);
+ }
+ catch (MarkRejectedException rex)
+ {
+ // BUG-BT-001: server rejected the request (invalid color, invalid
+ // path, etc.). Surface the actual reason instead of silently
+ // returning success with an empty id.
+ var msg = targetPaths.Count > 1 ? $"{targetPath}: {rex.Message}" : rex.Message;
+ if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeError(msg));
+ else Console.Error.WriteLine(msg);
+ return 1;
+ }
+ if (id == null)
+ {
+ var err = $"No watch process is running for {file.Name}. Start one with: officecli watch {file.Name}";
+ if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeError(err));
+ else Console.Error.WriteLine(err);
+ return 1;
+ }
+ createdIds.Add(id);
+ }
+
+ if (json)
+ {
+ // Fetch the resolved marks (server has populated matched_text +
+ // stale by now) and return them so AI consumers don't need a
+ // follow-up get-marks round-trip.
+ var full = WatchNotifier.QueryMarksFull(file.FullName);
+ if (full != null)
+ {
+ var idSet = new HashSet(createdIds);
+ foreach (var m in full.Marks)
+ if (idSet.Contains(m.Id)) createdMarks.Add(m);
+ }
+ if (createdMarks.Count == targetPaths.Count)
+ {
+ if (targetPaths.Count == 1)
+ {
+ var payload = System.Text.Json.JsonSerializer.Serialize(
+ createdMarks[0], WatchMarkJsonOptions.WatchMarkInfo);
+ Console.WriteLine(payload);
+ }
+ else
+ {
+ // Array envelope mirrors MarksResponse shape (no version).
+ var payload = System.Text.Json.JsonSerializer.Serialize(
+ createdMarks.ToArray(), WatchMarkJsonOptions.WatchMarkArrayInfo);
+ Console.WriteLine(payload);
+ }
+ }
+ else
+ {
+ Console.WriteLine(OutputFormatter.WrapEnvelopeText(
+ $"Marked {targetPaths.Count} element(s) (ids={string.Join(",", createdIds)})"));
+ }
+ }
+ else
+ {
+ if (targetPaths.Count == 1)
+ Console.WriteLine($"Marked {targetPaths[0]} (id={createdIds[0]})");
+ else
+ Console.WriteLine($"Marked {targetPaths.Count} element(s) (ids={string.Join(",", createdIds)})");
+ }
+ return 0;
+ }, json); });
+
+ return cmd;
+ }
+
+ // ==================== unmark ====================
+
+ private static Command BuildUnmarkMarkCommand(Option jsonOption)
+ {
+ var fileArg = new Argument("file") { Description = "Office document path" };
+ var pathOpt = new Option("--path") { Description = "Element path to unmark" };
+ var allOpt = new Option("--all") { Description = "Remove all marks for this file" };
+
+ var cmd = new Command("unmark",
+ "Remove marks from the running watch process. Must specify either --path or --all. " +
+ "--path must be in data-path format (e.g. /body/p[1] for Word, /slide[1]/shape[@id=N] for PPT), matching the value used with mark. " +
+ "Native handler query paths like /body/p[@paraId=...] will not match.");
+ cmd.Add(fileArg);
+ cmd.Add(pathOpt);
+ cmd.Add(allOpt);
+ cmd.Add(jsonOption);
+
+ cmd.SetAction(result => { var json = result.GetValue(jsonOption); return SafeRun(() =>
+ {
+ var file = result.GetValue(fileArg)!;
+ var pathVal = result.GetValue(pathOpt);
+ var allVal = result.GetValue(allOpt);
+
+ // Require explicit choice — never silently default
+ if (allVal && !string.IsNullOrEmpty(pathVal))
+ {
+ var err = "Specify either --path or --all, not both.";
+ if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeError(err));
+ else Console.Error.WriteLine(err);
+ return 2;
+ }
+ if (!allVal && string.IsNullOrEmpty(pathVal))
+ {
+ var err = "Must specify either --path
or --all.";
+ if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeError(err));
+ else Console.Error.WriteLine(err);
+ return 2;
+ }
+
+ var req = new UnmarkRequest { Path = pathVal, All = allVal };
+ var removed = WatchNotifier.RemoveMarks(file.FullName, req);
+ if (removed == null)
+ {
+ var err = $"No watch process is running for {file.Name}.";
+ if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeError(err));
+ else Console.Error.WriteLine(err);
+ return 1;
+ }
+
+ var msg = $"Removed {removed} mark(s)";
+ if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeText(msg));
+ else Console.WriteLine(msg);
+ return 0;
+ }, json); });
+
+ return cmd;
+ }
+
+ // ==================== get-marks ====================
+
+ private static Command BuildGetMarksCommand(Option jsonOption)
+ {
+ var fileArg = new Argument("file") { Description = "Office document path" };
+
+ var cmd = new Command("get-marks",
+ "List all marks currently held by the running watch process. " +
+ "Paths in the output are in data-path format (e.g. /body/p[1] for Word, /slide[1]/shape[@id=N] for PPT), " +
+ "not native handler query paths.");
+ cmd.Add(fileArg);
+ cmd.Add(jsonOption);
+
+ cmd.SetAction(result => { var json = result.GetValue(jsonOption); return SafeRun(() =>
+ {
+ var file = result.GetValue(fileArg)!;
+ var full = WatchNotifier.QueryMarksFull(file.FullName);
+ if (full == null)
+ {
+ var err = $"No watch process is running for {file.Name}.";
+ // BUG-BT-R4-01: even on error the --json output must keep the
+ // {version, marks, error} shape so the SKILL.md jq pipeline
+ // (`.marks[] | ...`) doesn't crash with "Cannot iterate over
+ // null" when an agent runs the apply pipeline against a dead
+ // watch. Empty marks array is the natural "nothing to do" form;
+ // the error field carries the human-readable reason. Exit 1
+ // still signals failure to script-level checks.
+ if (json)
+ {
+ // JSON-escape the error message manually to avoid the
+ // reflection-based Serialize overload (IL2026 trim
+ // warning under AOT). The set of chars that actually need
+ // escaping in this context is small.
+ var escaped = err.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n").Replace("\r", "\\r").Replace("\t", "\\t");
+ var emptyEnvelope = $"{{\"version\":0,\"marks\":[],\"error\":\"{escaped}\"}}";
+ Console.WriteLine(emptyEnvelope);
+ }
+ else Console.Error.WriteLine(err);
+ return 1;
+ }
+
+ var marks = full.Marks;
+
+ if (json)
+ {
+ // Top-level object {version, marks} — no envelope wrapping, no
+ // double-encoded JSON-inside-JSON. AI consumers parse once.
+ var payload = System.Text.Json.JsonSerializer.Serialize(
+ full, WatchMarkJsonOptions.MarksResponseInfo);
+ Console.WriteLine(payload);
+ }
+ else
+ {
+ if (marks.Length == 0)
+ {
+ Console.WriteLine("(no marks)");
+ }
+ else
+ {
+ Console.WriteLine($"id path find matched color note");
+ Console.WriteLine($"-- ------------------------------------------------ -------------------- ------- ------- ----");
+ foreach (var m in marks)
+ {
+ var matchedStr = m.MatchedText.Length == 0
+ ? (m.Stale ? "(stale)" : "-")
+ : (m.MatchedText.Length == 1
+ ? Truncate(m.MatchedText[0], 6)
+ : $"[{string.Join(",", m.MatchedText.Take(2).Select(t => Truncate(t, 4)))}]({m.MatchedText.Length})");
+ Console.WriteLine($"{m.Id,-3} {Truncate(m.Path, 48),-48} {Truncate(m.Find ?? "-", 20),-20} {matchedStr,-7} {Truncate(m.Color ?? "-", 7),-7} {Truncate(m.Note ?? "-", 30)}");
+ }
+ }
+ }
+ return 0;
+ }, json); });
+
+ return cmd;
+ }
+
+ private static string Truncate(string s, int max)
+ => s.Length <= max ? s : s.Substring(0, max - 1) + "…";
+}
diff --git a/src/officecli/CommandBuilder.Raw.cs b/src/officecli/CommandBuilder.Raw.cs
index eb9957e99..9377ea4ea 100644
--- a/src/officecli/CommandBuilder.Raw.cs
+++ b/src/officecli/CommandBuilder.Raw.cs
@@ -11,7 +11,7 @@ static partial class CommandBuilder
private static Command BuildRawCommand(Option jsonOption)
{
var rawFileArg = new Argument("file") { Description = "Office document path (required even with open/close mode)" };
- var rawPathArg = new Argument("part") { Description = "Part path (e.g. /document, /styles, /header[0])" };
+ var rawPathArg = new Argument("part") { Description = "Part path (e.g. /document, /styles, /header[1])" };
rawPathArg.DefaultValueFactory = _ => "/document";
var rawStartOpt = new Option("--start") { Description = "Start row number (Excel sheets only)" };
diff --git a/src/officecli/CommandBuilder.Set.cs b/src/officecli/CommandBuilder.Set.cs
index 55f430794..ec28d8bee 100644
--- a/src/officecli/CommandBuilder.Set.cs
+++ b/src/officecli/CommandBuilder.Set.cs
@@ -29,6 +29,37 @@ private static Command BuildSetCommand(Option jsonOption)
var props = result.GetValue(propsOpt);
var force = result.GetValue(forceOption);
+ // BUG-BT-R5-01: support the `selected` pseudo-path (mark and get
+ // already do). Expand to the first selected path and recursively
+ // re-invoke set for any additional paths after the main set
+ // completes. CONSISTENCY(selected-pseudo): grep for the same
+ // pseudo-path handling in CommandBuilder.Mark.cs / GetQuery.cs.
+ List? extraSelectedPaths = null;
+ if (string.Equals(path, "selected", StringComparison.Ordinal))
+ {
+ var selection = WatchNotifier.QuerySelection(file.FullName);
+ if (selection == null)
+ {
+ var err = $"No watch process is running for {file.Name}. Start one with: officecli watch {file.Name}";
+ if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeError(err));
+ else Console.Error.WriteLine(err);
+ return 1;
+ }
+ if (selection.Length == 0)
+ {
+ var err = "No elements are currently selected. Click or drag-select in the watch browser first.";
+ if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeError(err));
+ else Console.Error.WriteLine(err);
+ return 1;
+ }
+ path = selection[0];
+ if (selection.Length > 1)
+ {
+ extraSelectedPaths = new List(selection.Length - 1);
+ for (int i = 1; i < selection.Length; i++) extraSelectedPaths.Add(selection[i]);
+ }
+ }
+
// Check document protection for .docx files
// Skip protection check if the user is changing the protection mode itself
var isProtectionChange = props?.Any(p => p.StartsWith("protection=", StringComparison.OrdinalIgnoreCase)) == true;
@@ -84,6 +115,17 @@ private static Command BuildSetCommand(Option jsonOption)
using var handler = DocumentHandlerFactory.Open(file.FullName, editable: true);
var unsupported = handler.Set(path, properties);
+ // Scope the unsupported-prop fuzzy-suggestion pool by handler type
+ // so e.g. Excel pivot errors don't suggest PPTX-only keys like
+ // 'rotation' for an unknown 'location' prop (R2-4).
+ string? suggestionScope = handler switch
+ {
+ OfficeCli.Handlers.ExcelHandler => "excel",
+ OfficeCli.Handlers.WordHandler => "word",
+ OfficeCli.Handlers.PowerPointHandler => "pptx",
+ _ => null,
+ };
+
// Auto-correct: attempt to fix unsupported properties with Levenshtein distance == 1
var autoCorrected = new List<(string Original, string Corrected, string Value)>();
var stillUnsupported = new List();
@@ -92,7 +134,7 @@ private static Command BuildSetCommand(Option jsonOption)
var rawKey = u.Contains(' ') ? u[..u.IndexOf(' ')] : u;
if (properties.TryGetValue(rawKey, out var val))
{
- var (suggestion, dist, isUnique) = SuggestPropertyWithDistance(rawKey);
+ var (suggestion, dist, isUnique) = SuggestPropertyWithDistance(rawKey, suggestionScope);
if (suggestion != null && dist == 1 && isUnique)
{
// Auto-correct: re-apply with corrected key
@@ -116,8 +158,22 @@ private static Command BuildSetCommand(Option jsonOption)
foreach (var ac in autoCorrected)
applied.Add(new KeyValuePair(ac.Corrected, ac.Value));
+ // Get find match count if applicable
+ int? findMatchCount = null;
+ if (properties.ContainsKey("find"))
+ {
+ findMatchCount = handler switch
+ {
+ OfficeCli.Handlers.WordHandler wh => wh.LastFindMatchCount,
+ OfficeCli.Handlers.PowerPointHandler ph => ph.LastFindMatchCount,
+ OfficeCli.Handlers.ExcelHandler eh => eh.LastFindMatchCount,
+ _ => null
+ };
+ }
+
var message = applied.Count > 0
? $"Updated {path}: {string.Join(", ", applied.Select(kv => $"{kv.Key}={kv.Value}"))}"
+ + (findMatchCount.HasValue ? $" ({findMatchCount.Value} matched)" : "")
: $"No properties applied to {path}";
// Check if position-related props were changed → show coordinates + overlap warning
@@ -144,7 +200,7 @@ private static Command BuildSetCommand(Option jsonOption)
}
foreach (var p in stillUnsupported)
{
- var suggestion = SuggestProperty(p);
+ var suggestion = SuggestPropertyScoped(p, suggestionScope);
allWarnings.Add(new OfficeCli.Core.CliWarning
{
Message = suggestion != null ? $"Unsupported property: {p} (did you mean: {suggestion}?)" : $"Unsupported property: {p}",
@@ -175,7 +231,7 @@ private static Command BuildSetCommand(Option jsonOption)
bool allFailed = applied.Count == 0 && (stillUnsupported.Count > 0 || unsupported.Count > 0);
Console.WriteLine(allFailed
? OutputFormatter.WrapEnvelopeError(outputMsg, allWarnings.Count > 0 ? allWarnings : null)
- : OutputFormatter.WrapEnvelopeText(outputMsg, allWarnings.Count > 0 ? allWarnings : null));
+ : OutputFormatter.WrapEnvelopeText(outputMsg, allWarnings.Count > 0 ? allWarnings : null, findMatchCount));
}
else
{
@@ -189,10 +245,34 @@ private static Command BuildSetCommand(Option jsonOption)
if (setOverflowPlain != null)
Console.Error.WriteLine($" WARNING: {setOverflowPlain}");
if (stillUnsupported.Count > 0)
- Console.Error.WriteLine(FormatUnsupported(stillUnsupported));
+ Console.Error.WriteLine(FormatUnsupported(stillUnsupported, suggestionScope));
}
NotifyWatch(handler, file.FullName, path);
+ // BUG-BT-R5-01: apply the same prop set to the remaining selected
+ // paths. Each call goes through handler.Set independently so each
+ // path gets its own auto-correct, find-count, and unsupported list,
+ // matching the per-path semantics that mark already uses for
+ // `mark selected`. We collect any non-zero return as an
+ // error escalation but keep going so partial application is at
+ // least observable.
+ if (extraSelectedPaths is not null && extraSelectedPaths.Count > 0)
+ {
+ var extraStillUnsupported = false;
+ foreach (var extraPath in extraSelectedPaths)
+ {
+ var extraResult = handler.Set(extraPath, properties);
+ if (extraResult.Count > 0)
+ {
+ extraStillUnsupported = true;
+ if (!json)
+ Console.Error.WriteLine($" {extraPath}: {FormatUnsupported(extraResult, suggestionScope)}");
+ }
+ NotifyWatch(handler, file.FullName, extraPath);
+ }
+ if (extraStillUnsupported && stillUnsupported.Count == 0) return 2;
+ }
+
if (stillUnsupported.Count > 0) return 2;
return 0;
}, json); });
diff --git a/src/officecli/CommandBuilder.Watch.cs b/src/officecli/CommandBuilder.Watch.cs
index 00cf3109c..9c1e007e3 100644
--- a/src/officecli/CommandBuilder.Watch.cs
+++ b/src/officecli/CommandBuilder.Watch.cs
@@ -44,6 +44,16 @@ private static Command BuildWatchCommand()
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
using var watch = new WatchServer(file.FullName, port, initialHtml: initialHtml);
+ // BUG-BT-R302: SIGTERM (pkill, kill) does NOT run `using` finally
+ // blocks, so the WatchServer.Dispose() pipe-socket cleanup never
+ // runs and stale CoreFxPipe_* files accumulate in $TMPDIR. Hook
+ // ProcessExit so a graceful SIGTERM still triggers Dispose. SIGKILL
+ // is unrecoverable by definition (kernel-level), so this only
+ // covers cooperative shutdown.
+ AppDomain.CurrentDomain.ProcessExit += (_, _) =>
+ {
+ try { watch.Dispose(); } catch { /* best effort */ }
+ };
watch.RunAsync(cts.Token).GetAwaiter().GetResult();
return 0;
}));
diff --git a/src/officecli/CommandBuilder.cs b/src/officecli/CommandBuilder.cs
index 0bb252642..0c2fbfa95 100644
--- a/src/officecli/CommandBuilder.cs
+++ b/src/officecli/CommandBuilder.cs
@@ -130,6 +130,9 @@ officecli pptx set shape.fill Specific property format and examples
// Register commands from partial files
rootCommand.Add(BuildWatchCommand());
rootCommand.Add(BuildUnwatchCommand());
+ rootCommand.Add(BuildMarkCommand(jsonOption));
+ rootCommand.Add(BuildUnmarkMarkCommand(jsonOption));
+ rootCommand.Add(BuildGetMarksCommand(jsonOption));
rootCommand.Add(BuildViewCommand(jsonOption));
rootCommand.Add(BuildGetCommand(jsonOption));
rootCommand.Add(BuildQueryCommand(jsonOption));
@@ -137,6 +140,7 @@ officecli pptx set shape.fill Specific property format and examples
rootCommand.Add(BuildAddCommand(jsonOption));
rootCommand.Add(BuildRemoveCommand(jsonOption));
rootCommand.Add(BuildMoveCommand(jsonOption));
+ rootCommand.Add(BuildSwapCommand(jsonOption));
rootCommand.Add(BuildRawCommand(jsonOption));
rootCommand.Add(BuildRawSetCommand(jsonOption));
rootCommand.Add(BuildAddPartCommand(jsonOption));
@@ -271,9 +275,32 @@ internal static string ExecuteBatchItem(OfficeCli.Core.IDocumentHandler handler,
var applied = props.Where(kv => !unsupported.Contains(kv.Key)).ToList();
var parts = new List();
if (applied.Count > 0)
- parts.Add($"Updated {path}: {string.Join(", ", applied.Select(kv => $"{kv.Key}={kv.Value}"))}");
+ {
+ var msg = $"Updated {path}: {string.Join(", ", applied.Select(kv => $"{kv.Key}={kv.Value}"))}";
+ if (props.ContainsKey("find"))
+ {
+ var matched = handler switch
+ {
+ OfficeCli.Handlers.WordHandler wh => wh.LastFindMatchCount,
+ OfficeCli.Handlers.PowerPointHandler ph => ph.LastFindMatchCount,
+ OfficeCli.Handlers.ExcelHandler eh => eh.LastFindMatchCount,
+ _ => 0
+ };
+ msg += $" ({matched} matched)";
+ }
+ parts.Add(msg);
+ }
if (unsupported.Count > 0)
- parts.Add(FormatUnsupported(unsupported));
+ {
+ string? batchScope = handler switch
+ {
+ OfficeCli.Handlers.ExcelHandler => "excel",
+ OfficeCli.Handlers.WordHandler => "word",
+ OfficeCli.Handlers.PowerPointHandler => "pptx",
+ _ => null,
+ };
+ parts.Add(FormatUnsupported(unsupported, batchScope));
+ }
return string.Join("\n", parts);
}
case "add":
@@ -283,15 +310,20 @@ internal static string ExecuteBatchItem(OfficeCli.Core.IDocumentHandler handler,
throw new ArgumentException("'add' command requires 'parent' field. Example: {\"command\": \"add\", \"parent\": \"/slide[1]\", \"type\": \"shape\", \"props\": {\"text\": \"Hello\"}}");
if (string.IsNullOrEmpty(item.Type) && string.IsNullOrEmpty(item.From))
throw new ArgumentException("'add' command requires 'type' or 'from' field. Example: {\"command\": \"add\", \"parent\": \"/\", \"type\": \"slide\"}");
+ InsertPosition? pos = null;
+ if (item.Index.HasValue) pos = InsertPosition.AtIndex(item.Index.Value);
+ else if (!string.IsNullOrEmpty(item.After)) pos = InsertPosition.AfterElement(item.After);
+ else if (!string.IsNullOrEmpty(item.Before)) pos = InsertPosition.BeforeElement(item.Before);
+
if (!string.IsNullOrEmpty(item.From))
{
- var resultPath = handler.CopyFrom(item.From, parentPath, item.Index);
+ var resultPath = handler.CopyFrom(item.From, parentPath, pos);
return $"Copied to {resultPath}";
}
else
{
var type = item.Type ?? "";
- var resultPath = handler.Add(parentPath, type, item.Index, props);
+ var resultPath = handler.Add(parentPath, type, pos, props);
return $"Added {type} at {resultPath}";
}
}
@@ -308,9 +340,26 @@ internal static string ExecuteBatchItem(OfficeCli.Core.IDocumentHandler handler,
case "move":
{
var path = item.Path ?? "/";
- var resultPath = handler.Move(path, item.To, item.Index);
+ InsertPosition? movePos = null;
+ if (item.Index.HasValue) movePos = InsertPosition.AtIndex(item.Index.Value);
+ else if (!string.IsNullOrEmpty(item.After)) movePos = InsertPosition.AfterElement(item.After);
+ else if (!string.IsNullOrEmpty(item.Before)) movePos = InsertPosition.BeforeElement(item.Before);
+ var resultPath = handler.Move(path, item.To, movePos);
return $"Moved to {resultPath}";
}
+ case "swap":
+ {
+ if (string.IsNullOrEmpty(item.Path) || string.IsNullOrEmpty(item.To))
+ throw new ArgumentException("'swap' command requires 'path' and 'to' fields. Example: {\"command\": \"swap\", \"path\": \"/slide[1]\", \"to\": \"/slide[2]\"}");
+ var (p1, p2) = handler switch
+ {
+ OfficeCli.Handlers.PowerPointHandler ppt => ppt.Swap(item.Path, item.To),
+ OfficeCli.Handlers.WordHandler word => word.Swap(item.Path, item.To),
+ OfficeCli.Handlers.ExcelHandler excel => excel.Swap(item.Path, item.To),
+ _ => throw new InvalidOperationException("swap not supported for this document type")
+ };
+ return $"Swapped {p1} <-> {p2}";
+ }
case "view":
{
var mode = item.Mode ?? "text";
@@ -370,7 +419,7 @@ internal static string ExecuteBatchItem(OfficeCli.Core.IDocumentHandler handler,
"Batch item missing required 'command' field. " +
"Valid commands: get, query, set, add, remove, move, view, raw, validate. " +
"Example: {\"command\": \"set\", \"path\": \"/Sheet1/A1\", \"props\": {\"value\": \"hello\"}}");
- throw new InvalidOperationException($"Unknown command: '{item.Command}'. Valid commands: get, query, set, add, remove, move, view, raw, validate.");
+ throw new InvalidOperationException($"Unknown command: '{item.Command}'. Valid commands: get, query, set, add, remove, move, swap, view, raw, validate.");
}
}
@@ -569,21 +618,60 @@ internal static List DetectUnmatchedKeyValues(System.CommandLine.ParseRe
}
}
}
+
+ // Pattern 3 (BUG-BT-R6): common typos for the `--prop` option name.
+ // `--props '{"k":"v"}'` is silently swallowed by System.CommandLine
+ // because `--props` (with trailing s) is not a known option, so the
+ // JSON value goes into UnmatchedTokens too. Catch the typo so the
+ // existing warning machinery emits a clear hint instead of letting
+ // the agent ship a shape with no text.
+ if (token is "--props" or "-props" or "--prop=" && i + 1 < tokens.Count)
+ {
+ var nextToken = tokens[i + 1];
+ if (!nextToken.StartsWith("--"))
+ {
+ result.Add($"--prop {nextToken}");
+ i++;
+ continue;
+ }
+ }
}
return result;
}
- internal static string FormatUnsupported(IEnumerable unsupported)
+ internal static string FormatUnsupported(IEnumerable unsupported, string? scope = null)
{
var parts = new List();
foreach (var prop in unsupported)
{
- var suggestion = SuggestProperty(prop);
+ var suggestion = SuggestPropertyScoped(prop, scope);
parts.Add(suggestion != null ? $"{prop} (did you mean: {suggestion}?)" : prop);
}
return $"UNSUPPORTED props: {string.Join(", ", parts)}. Use 'officecli help -set' to see available properties, or use raw-set for direct XML manipulation.";
}
+ ///
+ /// Property keys that belong to PPTX shape/text semantics and should not
+ /// be offered as suggestions when the caller is operating on an Excel
+ /// document (R2-4). Keep the list conservative — only keys whose presence
+ /// in an Excel error message would be clearly misleading.
+ ///
+ internal static readonly HashSet PptxOnlyProps = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "rotation", "opacity", "glow", "shadow",
+ "firstSliceAngle", "holeSize", "bubbleScale", "explosion",
+ "view3d", "varyColors",
+ };
+
+ ///
+ /// Property keys exclusive to Word document-level concerns that should
+ /// not bleed into Excel suggestions.
+ ///
+ internal static readonly HashSet WordOnlyProps = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "pageWidth", "pageHeight", "orientation",
+ };
+
internal static readonly string[] KnownProps = new[]
{
"text", "bold", "italic", "underline", "strike", "font", "size", "color",
@@ -628,10 +716,22 @@ internal static string FormatUnsupported(IEnumerable unsupported)
return best;
}
+ ///
+ /// Scoped variant: filters the suggestion pool against a target document
+ /// format ("excel", "word", "pptx", or null for unscoped) to avoid
+ /// cross-format leakage such as suggesting PPTX 'rotation' for an
+ /// Excel pivot property (R2-4).
+ ///
+ internal static string? SuggestPropertyScoped(string input, string? scope)
+ {
+ var (best, _, _) = SuggestPropertyWithDistance(input, scope);
+ return best;
+ }
+
///
/// Returns (bestMatch, distance, isUnique) where isUnique means no other candidate shares the same distance.
///
- internal static (string? Best, int Distance, bool IsUnique) SuggestPropertyWithDistance(string input)
+ internal static (string? Best, int Distance, bool IsUnique) SuggestPropertyWithDistance(string input, string? scope = null)
{
// Strip help text suffix if present (e.g. "key (valid props: ...)")
var rawInput = input.Contains(' ') ? input[..input.IndexOf(' ')] : input;
@@ -640,8 +740,24 @@ internal static (string? Best, int Distance, bool IsUnique) SuggestPropertyWithD
int bestDist = int.MaxValue;
int bestCount = 0; // how many props share the best distance
+ HashSet? exclude = null;
+ switch (scope?.ToLowerInvariant())
+ {
+ case "excel":
+ exclude = new HashSet(PptxOnlyProps, StringComparer.OrdinalIgnoreCase);
+ foreach (var w in WordOnlyProps) exclude.Add(w);
+ break;
+ case "word":
+ exclude = PptxOnlyProps;
+ break;
+ case "pptx":
+ exclude = WordOnlyProps;
+ break;
+ }
+
foreach (var prop in KnownProps)
{
+ if (exclude != null && exclude.Contains(prop)) continue;
var dist = LevenshteinDistance(lower, prop.ToLowerInvariant());
if (dist > 0 && dist <= Math.Max(2, rawInput.Length / 3))
{
@@ -836,7 +952,10 @@ private static void NotifyWatch(IDocumentHandler handler, string filePath, strin
var html = ppt.RenderSlideHtml(slideNum);
if (html != null)
{
- WatchNotifier.NotifyIfWatching(filePath, new WatchMessage { Action = "replace", Slide = slideNum, Html = html, FullHtml = ppt.ViewAsHtml() });
+ // Slide-scoped replace: the watch server patches its cached _currentHtml in
+ // place via PatchSlideInHtml; bundling a full ViewAsHtml() here is redundant
+ // (and ResidentServer.NotifyWatchSlideChanged already omits it).
+ WatchNotifier.NotifyIfWatching(filePath, new WatchMessage { Action = "replace", Slide = slideNum, Html = html });
return;
}
}
diff --git a/src/officecli/Core/AttributeFilter.cs b/src/officecli/Core/AttributeFilter.cs
index b1ba2e991..89ff29a8b 100644
--- a/src/officecli/Core/AttributeFilter.cs
+++ b/src/officecli/Core/AttributeFilter.cs
@@ -293,6 +293,17 @@ private static (bool HasKey, string Value) ResolveValue(DocumentNode node, strin
return (!string.IsNullOrEmpty(node.Type), node.Type ?? "");
}
+ // BUG-BT-R6-01: "style" falls back to node.Style if not in Format.
+ // Word/PPT handlers populate the top-level DocumentNode.Style property
+ // (serialized as the top-level "style" key in JSON output) but do NOT
+ // duplicate it into Format. Without this fallback, query selectors
+ // like `paragraph[style=Normal]` returned 0 results even though every
+ // paragraph in the document literally had style="Normal".
+ if (string.Equals(key, "style", StringComparison.OrdinalIgnoreCase))
+ {
+ return (!string.IsNullOrEmpty(node.Style), node.Style ?? "");
+ }
+
return (false, "");
}
diff --git a/src/officecli/Core/BatchTypes.cs b/src/officecli/Core/BatchTypes.cs
index 2cd49c4bb..3d3c8b533 100644
--- a/src/officecli/Core/BatchTypes.cs
+++ b/src/officecli/Core/BatchTypes.cs
@@ -72,6 +72,8 @@ internal class BatchItemConverter : JsonConverter
case "type": item.Type = reader.GetString(); break;
case "from": item.From = reader.GetString(); break;
case "index": item.Index = reader.TokenType == JsonTokenType.Null ? null : reader.GetInt32(); break;
+ case "after": item.After = reader.GetString(); break;
+ case "before": item.Before = reader.GetString(); break;
case "to": item.To = reader.GetString(); break;
case "props": item.Props = PropsConverter.Read(ref reader, typeof(Dictionary), options); break;
case "selector": item.Selector = reader.GetString(); break;
@@ -120,6 +122,8 @@ public class BatchItem
public string? Type { get; set; }
public string? From { get; set; }
public int? Index { get; set; }
+ public string? After { get; set; }
+ public string? Before { get; set; }
public string? To { get; set; }
public Dictionary? Props { get; set; }
public string? Selector { get; set; }
@@ -133,7 +137,7 @@ public class BatchItem
internal static readonly HashSet KnownFields = new(StringComparer.OrdinalIgnoreCase)
{
- "command", "op", "path", "parent", "type", "from", "index", "to",
+ "command", "op", "path", "parent", "type", "from", "index", "after", "before", "to",
"props", "selector", "text", "mode", "depth", "part", "xpath", "action", "xml"
};
@@ -146,6 +150,8 @@ public ResidentRequest ToResidentRequest()
if (Type != null) req.Args["type"] = Type;
if (From != null) req.Args["from"] = From;
if (Index.HasValue) req.Args["index"] = Index.Value.ToString();
+ if (After != null) req.Args["after"] = After;
+ if (Before != null) req.Args["before"] = Before;
if (To != null) req.Args["to"] = To;
if (Selector != null) req.Args["selector"] = Selector;
if (Text != null) req.Args["text"] = Text;
diff --git a/src/officecli/Core/CellPropHints.cs b/src/officecli/Core/CellPropHints.cs
new file mode 100644
index 000000000..68dc3de69
--- /dev/null
+++ b/src/officecli/Core/CellPropHints.cs
@@ -0,0 +1,41 @@
+// Copyright 2025 OfficeCli (officecli.ai)
+// SPDX-License-Identifier: Apache-2.0
+
+namespace OfficeCli.Core;
+
+///
+/// Precise error hints for Excel cell properties that are genuinely ambiguous
+/// when carried over from PPT/Word habits.
+///
+/// Excel cells use a layered namespace (font.*, border.*, alignment.*, fill).
+/// Most common PPT/Word flat keys — `size`, `font`, `halign`, `valign`, `wrap` —
+/// are already accepted as aliases by ExcelStyleManager because they have a
+/// single unambiguous meaning in cell context.
+///
+/// This class lists the keys that cannot be safely aliased because they mean
+/// two different things. For those we refuse silent mapping and return a
+/// precise hint telling the user to pick one explicitly.
+///
+public static class CellPropHints
+{
+ private static readonly Dictionary AmbiguousKeys = new(StringComparer.OrdinalIgnoreCase)
+ {
+ // `color` in PPT/Word run context means text color, but in Excel cells
+ // the user might intuitively expect background color. Force them to
+ // pick: `font.color` (text) or `fill` (background).
+ ["color"] = "ambiguous in cell context — use 'font.color' for text color or 'fill' for background color",
+ };
+
+ ///
+ /// If the given key is a known ambiguous cell prop, returns a human-readable
+ /// hint telling the user to pick an unambiguous alternative. Returns null
+ /// otherwise.
+ ///
+ public static string? TryGetHint(string key)
+ {
+ if (!AmbiguousKeys.TryGetValue(key, out var hint))
+ return null;
+
+ return $"{key} ({hint})";
+ }
+}
diff --git a/src/officecli/Core/ChartBuilder.cs b/src/officecli/Core/ChartBuilder.cs
index 303eba4e6..f8e6fe261 100644
--- a/src/officecli/Core/ChartBuilder.cs
+++ b/src/officecli/Core/ChartBuilder.cs
@@ -85,11 +85,11 @@ internal static C.ChartSpace BuildChartSpace(
categories, seriesData, catAxisId, valAxisId, colors);
break;
case "pie":
- chartElement = BuildPieChart(categories, seriesData);
+ chartElement = BuildPieChart(categories, seriesData, colors);
needsAxes = false;
break;
case "doughnut":
- chartElement = BuildDoughnutChart(categories, seriesData);
+ chartElement = BuildDoughnutChart(categories, seriesData, colors);
needsAxes = false;
break;
case "scatter":
@@ -441,26 +441,50 @@ internal static C.AreaChart BuildAreaChart(
}
internal static C.PieChart BuildPieChart(
- string[]? categories, List<(string name, double[] values)> seriesData)
+ string[]? categories, List<(string name, double[] values)> seriesData,
+ string[]? colors = null)
{
var pieChart = new C.PieChart(new C.VaryColors { Val = true });
if (seriesData.Count > 0)
- pieChart.AppendChild(BuildPieSeries(0, seriesData[0].name,
- categories, seriesData[0].values));
+ {
+ var series = BuildPieSeries(0, seriesData[0].name,
+ categories, seriesData[0].values);
+ ApplyDataPointColors(series, seriesData[0].values.Length, colors);
+ pieChart.AppendChild(series);
+ }
return pieChart;
}
internal static C.DoughnutChart BuildDoughnutChart(
- string[]? categories, List<(string name, double[] values)> seriesData)
+ string[]? categories, List<(string name, double[] values)> seriesData,
+ string[]? colors = null)
{
var chart = new C.DoughnutChart(new C.VaryColors { Val = true });
if (seriesData.Count > 0)
- chart.AppendChild(BuildPieSeries(0, seriesData[0].name,
- categories, seriesData[0].values));
+ {
+ var series = BuildPieSeries(0, seriesData[0].name,
+ categories, seriesData[0].values);
+ ApplyDataPointColors(series, seriesData[0].values.Length, colors);
+ chart.AppendChild(series);
+ }
chart.AppendChild(new C.HoleSize { Val = 50 });
return chart;
}
+ ///
+ /// For pie/doughnut charts, apply per-data-point colors via c:dPt elements.
+ /// Each slice gets its own DataPoint with Index and ChartShapeProperties containing a solid fill.
+ ///
+ private static void ApplyDataPointColors(C.PieChartSeries series, int pointCount, string[]? colors)
+ {
+ if (colors == null || colors.Length == 0) return;
+ var count = Math.Min(pointCount, colors.Length);
+ for (int i = 0; i < count; i++)
+ {
+ ApplyDataPointColor(series, i, colors[i]);
+ }
+ }
+
internal static C.ScatterChart BuildScatterChart(
string[]? categories, List<(string name, double[] values)> seriesData,
uint catAxisId, uint valAxisId)
diff --git a/src/officecli/Core/ChartSetter.cs b/src/officecli/Core/ChartSetter.cs
index b42baa407..a4d158006 100644
--- a/src/officecli/Core/ChartSetter.cs
+++ b/src/officecli/Core/ChartSetter.cs
@@ -276,6 +276,45 @@ static int PropOrder(string k)
var plotArea2 = chart.GetFirstChild();
if (plotArea2 == null) { unsupported.Add(key); break; }
var colorList = value.Split(',').Select(c => c.Trim()).ToArray();
+
+ // Pie and doughnut charts use VaryColors with dPt elements per data point.
+ // Color per-series is meaningless (only 1 series); color each data point instead.
+ var isPieOrDoughnut = plotArea2.GetFirstChild() != null
+ || plotArea2.GetFirstChild() != null;
+ if (isPieOrDoughnut)
+ {
+ var ser = plotArea2.Descendants()
+ .FirstOrDefault(e => e.LocalName == "ser");
+ if (ser != null)
+ {
+ // Remove existing dPt elements then re-add with new colors
+ var existing = ser.Elements().ToList();
+ foreach (var dp in existing) dp.Remove();
+
+ for (int ci = 0; ci < colorList.Length; ci++)
+ {
+ var dPt = new C.DataPoint();
+ dPt.AppendChild(new C.Index { Val = (uint)ci });
+ dPt.AppendChild(new C.InvertIfNegative { Val = false });
+ var spPr = new C.ChartShapeProperties();
+ var solidFill = new Drawing.SolidFill();
+ solidFill.AppendChild(BuildChartColorElement(colorList[ci]));
+ spPr.AppendChild(solidFill);
+ dPt.AppendChild(spPr);
+
+ // Insert dPt before cat/val data — after Order/SerText/spPr header elements
+ var insertBefore = ser.Elements().FirstOrDefault()
+ ?? (OpenXmlElement?)ser.Elements().FirstOrDefault()
+ ?? ser.Elements().FirstOrDefault();
+ if (insertBefore != null)
+ ser.InsertBefore(dPt, insertBefore);
+ else
+ ser.AppendChild(dPt);
+ }
+ }
+ break;
+ }
+
var allSer = plotArea2.Descendants()
.Where(e => e.LocalName == "ser").ToList();
for (int ci = 0; ci < Math.Min(colorList.Length, allSer.Count); ci++)
diff --git a/src/officecli/Core/ChartSvgRenderer.cs b/src/officecli/Core/ChartSvgRenderer.cs
index 22947e5d6..4de47e6a4 100644
--- a/src/officecli/Core/ChartSvgRenderer.cs
+++ b/src/officecli/Core/ChartSvgRenderer.cs
@@ -13,12 +13,41 @@ namespace OfficeCli.Core;
///
internal class ChartSvgRenderer
{
- // Default chart colors matching Office theme accent colors
- public static readonly string[] DefaultColors = [
+ // Fallback chart colors — used only when no theme is available
+ public static readonly string[] FallbackColors = [
"#4472C4", "#ED7D31", "#A5A5A5", "#FFC000", "#5B9BD5", "#70AD47",
"#264478", "#9E480E", "#636363", "#997300", "#255E91", "#43682B"
];
+ ///
+ /// Theme-derived accent colors for chart series. Set from document theme accent1-6.
+ /// Falls back to FallbackColors if not set.
+ ///
+ public string[]? ThemeAccentColors { get; set; }
+
+ /// Get effective default colors: theme accents (with shade/tint variants) or fallback.
+ public string[] DefaultColors => ThemeAccentColors ?? FallbackColors;
+
+ /// Build theme accent color array from theme color map (accent1-6 + shade variants).
+ public static string[] BuildThemeAccentColors(Dictionary themeColors)
+ {
+ var accents = new List();
+ for (int i = 1; i <= 6; i++)
+ {
+ if (themeColors.TryGetValue($"accent{i}", out var hex))
+ accents.Add($"#{hex}");
+ else
+ accents.Add(FallbackColors[(i - 1) % FallbackColors.Length]);
+ }
+ // Generate shade variants for cycling (darker versions of accent1-6)
+ foreach (var accent in accents.ToList())
+ {
+ var raw = accent.TrimStart('#');
+ accents.Add(ColorMath.ApplyTransforms(raw, shade: 50000)); // 50% shade
+ }
+ return accents.ToArray();
+ }
+
// Chart styling — configurable per chart instance
public string ValueColor { get; set; } = "#D0D8E0";
public string CatColor { get; set; } = "#C8D0D8";
@@ -28,6 +57,7 @@ internal class ChartSvgRenderer
public string AxisLineColor { get; set; } = "#555";
public int ValFontPx { get; set; } = 9;
public int CatFontPx { get; set; } = 9;
+ public int DataLabelFontPx { get; set; } = 8;
public int AxisTickCount { get; set; } = 4;
public static string HtmlEncode(string text) =>
@@ -39,7 +69,7 @@ public void RenderBarChartSvg(StringBuilder sb, List<(string name, double[] valu
bool horizontal, bool stacked = false, bool percentStacked = false,
double? ooxmlMax = null, double? ooxmlMin = null, double? ooxmlMajorUnit = null,
int? ooxmlGapWidth = null, int valFontSize = 9, int catFontSize = 9,
- bool showDataLabels = false)
+ bool showDataLabels = false, string? valNumFmt = null, string? plotFillColor = null)
{
var allValues = series.SelectMany(s => s.values).ToArray();
if (allValues.Length == 0) return;
@@ -77,9 +107,16 @@ public void RenderBarChartSvg(StringBuilder sb, List<(string name, double[] valu
if (horizontal)
{
- var hLabelMargin = 50;
+ // Estimate label width from longest category name (approx 0.5 × fontSize per char)
+ var maxLabelLen = categories.Length > 0 ? categories.Max(c => c.Length) : 0;
+ var hLabelMargin = (int)(maxLabelLen * catFontSize * 0.5) + 4;
var plotOx = ox + hLabelMargin;
var plotPw = pw - hLabelMargin;
+
+ // Plot area background starts at the Y-axis (plotOx), labels are outside
+ if (plotFillColor != null)
+ sb.AppendLine($" ");
+
var groupH = (double)ph / Math.Max(catCount, 1);
var gapPct = (ooxmlGapWidth ?? 150) / 100.0;
double barH, gap;
@@ -129,7 +166,7 @@ public void RenderBarChartSvg(StringBuilder sb, List<(string name, double[] valu
for (int t = 0; t <= nTicks; t++)
{
var val = tickStep * t;
- var label = percentStacked ? $"{(int)val}%" : (val % 1 == 0 ? $"{(int)val}" : $"{val:0.#}");
+ var label = percentStacked ? $"{(int)val}%" : FormatAxisValue(val, valNumFmt);
var tx = plotOx + (double)plotPw * t / nTicks;
sb.AppendLine($" {label}");
}
@@ -174,7 +211,7 @@ public void RenderBarChartSvg(StringBuilder sb, List<(string name, double[] valu
if (showDataLabels)
{
var vlabel = rawVal % 1 == 0 ? $"{(int)rawVal}" : $"{rawVal:0.#}";
- sb.AppendLine($" {vlabel}");
+ sb.AppendLine($" {vlabel}");
}
}
}
@@ -188,7 +225,7 @@ public void RenderBarChartSvg(StringBuilder sb, List<(string name, double[] valu
for (int t = 0; t <= nTicks; t++)
{
var val = tickStep * t;
- var label = percentStacked ? $"{(int)val}%" : (val % 1 == 0 ? $"{(int)val}" : $"{val:0.#}");
+ var label = percentStacked ? $"{(int)val}%" : FormatAxisValue(val, valNumFmt);
var ty = oy + ph - (double)ph * t / nTicks;
sb.AppendLine($" {label}");
}
@@ -234,7 +271,7 @@ public void RenderLineChartSvg(StringBuilder sb, List<(string name, double[] val
{
var val = series[s].values[p];
var vlabel = val % 1 == 0 ? $"{(int)val}" : $"{val:0.#}";
- sb.AppendLine($" {vlabel}");
+ sb.AppendLine($" {vlabel}");
}
}
}
@@ -255,7 +292,8 @@ public void RenderLineChartSvg(StringBuilder sb, List<(string name, double[] val
}
public void RenderPieChartSvg(StringBuilder sb, List<(string name, double[] values)> series,
- string[] categories, List colors, int svgW, int svgH, double holeRatio = 0.0, bool showDataLabels = false)
+ string[] categories, List colors, int svgW, int svgH, double holeRatio = 0.0, bool showDataLabels = false,
+ bool showVal = false, bool showPercent = false)
{
var values = series.FirstOrDefault().values ?? [];
if (values.Length == 0) return;
@@ -305,9 +343,17 @@ public void RenderPieChartSvg(StringBuilder sb, List<(string name, double[] valu
var lx = cx + labelR * Math.Cos(midAngle);
var ly = cy + labelR * Math.Sin(midAngle);
var pct = values[i] / total * 100;
- var label = pct >= 5 ? $"{pct:0}%" : "";
+ string label;
+ if (showVal && !showPercent)
+ label = pct >= 5 ? $"{values[i]:0.##}" : "";
+ else if (showPercent && !showVal)
+ label = pct >= 5 ? $"{pct:0}%" : "";
+ else if (showVal && showPercent)
+ label = pct >= 5 ? $"{values[i]:0.##} ({pct:0}%)" : "";
+ else
+ label = pct >= 5 ? $"{pct:0}%" : ""; // default to percent for pie
if (!string.IsNullOrEmpty(label))
- sb.AppendLine($" {label}");
+ sb.AppendLine($" {label}");
labelAngle += sliceAngle;
}
}
@@ -429,12 +475,12 @@ public void RenderRadarChartSvg(StringBuilder sb, List<(string name, double[] va
var angle = -Math.PI / 2 + 2 * Math.PI * c / catCount;
gridPoints.Add($"{cx + rr * Math.Cos(angle):0.#},{cy + rr * Math.Sin(angle):0.#}");
}
- sb.AppendLine($" ");
+ sb.AppendLine($" ");
}
for (int c = 0; c < catCount; c++)
{
var angle = -Math.PI / 2 + 2 * Math.PI * c / catCount;
- sb.AppendLine($" ");
+ sb.AppendLine($" ");
}
for (int s = 0; s < series.Count; s++)
{
@@ -711,14 +757,57 @@ public void RenderComboChartSvg(StringBuilder sb, PlotArea plotArea,
}
}
- private static string FormatAxisValue(double val)
+ private static string FormatAxisValue(double val, string? numFmt = null)
{
+ if (!string.IsNullOrEmpty(numFmt) && numFmt != "General")
+ return ApplyNumFmt(val, numFmt);
if (val == 0) return "0";
if (Math.Abs(val) >= 1_000_000) return $"{val / 1_000_000:0.#}M";
if (Math.Abs(val) >= 1_000) return $"{val / 1_000:0.#}K";
return val % 1 == 0 ? $"{(long)val}" : $"{val:0.#}";
}
+ /// Apply an OOXML number format code to a value for axis display.
+ private static string ApplyNumFmt(double val, string fmt)
+ {
+ var prefix = "";
+ var suffix = "";
+ var f = fmt;
+
+ // Extract literal prefix (e.g. "$")
+ if (f.Length > 0 && !char.IsDigit(f[0]) && f[0] != '#' && f[0] != '0' && f[0] != '.')
+ {
+ prefix = f[0].ToString();
+ f = f[1..];
+ }
+ // Extract literal suffix (e.g. "%")
+ if (f.Length > 0 && f[^1] == '%')
+ {
+ suffix = "%";
+ f = f[..^1];
+ val *= 100;
+ }
+
+ // Determine decimal places from format
+ var decIdx = f.IndexOf('.');
+ int decimals = decIdx >= 0 ? f[(decIdx + 1)..].Count(c => c is '0' or '#') : 0;
+
+ // Check if thousands separator is used (#,##0 pattern)
+ bool useThousands = f.Contains(",##") || f.Contains("#,#");
+
+ string formatted;
+ if (useThousands)
+ formatted = decimals > 0
+ ? val.ToString($"N{decimals}")
+ : ((long)val).ToString("N0");
+ else
+ formatted = decimals > 0
+ ? val.ToString($"F{decimals}")
+ : (val % 1 == 0 ? $"{(long)val}" : $"{val:0.#}");
+
+ return prefix + formatted + suffix;
+ }
+
public void RenderStockChartSvg(StringBuilder sb, PlotArea plotArea,
List<(string name, double[] values)> series, string[] categories, List colors,
int ox, int oy, int pw, int ph)
@@ -730,7 +819,7 @@ public void RenderStockChartSvg(StringBuilder sb, PlotArea plotArea,
var range = maxVal - minVal;
var catCount = Math.Max(categories.Length, series.Max(s => s.values.Length));
- var upColor = "#2ECC71"; var downColor = "#E74C3C";
+ var upColor = "#FFFFFF"; var downColor = "#000000"; // OOXML spec defaults
var stockChart = plotArea.GetFirstChild();
if (stockChart != null)
{
@@ -811,6 +900,8 @@ public class ChartInfo
public string? Title { get; set; }
public string TitleFontSize { get; set; } = "10pt";
public bool ShowDataLabels { get; set; }
+ public bool ShowDataLabelVal { get; set; }
+ public bool ShowDataLabelPercent { get; set; }
public double HoleRatio { get; set; }
public bool IsStacked { get; set; }
public bool IsPercent { get; set; }
@@ -820,13 +911,25 @@ public class ChartInfo
public double? MajorUnit { get; set; }
public int? GapWidth { get; set; }
public string? ValAxisTitle { get; set; }
+ public int ValAxisTitleFontPx { get; set; } = 9;
+ public bool ValAxisTitleBold { get; set; }
public string? CatAxisTitle { get; set; }
+ public int CatAxisTitleFontPx { get; set; } = 9;
+ public bool CatAxisTitleBold { get; set; }
public string? PlotFillColor { get; set; }
public string? ChartFillColor { get; set; }
public bool HasLegend { get; set; }
public string LegendFontSize { get; set; } = "8pt";
+ public string? LegendFontColor { get; set; }
public int ValFontPx { get; set; } = 9;
+ public string? ValFontColor { get; set; }
public int CatFontPx { get; set; } = 9;
+ public string? CatFontColor { get; set; }
+ public string? ValNumFmt { get; set; }
+ public string? TitleFontColor { get; set; }
+ public string? GridlineColor { get; set; }
+ public string? AxisLineColor { get; set; }
+ public int DataLabelFontPx { get; set; } = 8;
}
/// Extract all chart metadata from OOXML PlotArea and Chart elements.
@@ -866,9 +969,10 @@ e.LocalName is "barChart" or "bar3DChart" or "lineChart" or "line3DChart"
.Select(r => r.GetFirstChild()?.Text)
.Where(t => t != null);
info.Title = string.Join("", titleRuns);
- var titleFontSize = titleEl.Descendants().FirstOrDefault()?.FontSize;
- if (titleFontSize?.HasValue == true)
- info.TitleFontSize = $"{titleFontSize.Value / 100.0:0.##}pt";
+ var titleRPr = titleEl.Descendants().FirstOrDefault();
+ if (titleRPr?.FontSize?.HasValue == true)
+ info.TitleFontSize = $"{titleRPr.FontSize.Value / 100.0:0.##}pt";
+ info.TitleFontColor = ExtractFontColor(titleRPr);
}
// Data labels
@@ -876,9 +980,11 @@ e.LocalName is "barChart" or "bar3DChart" or "lineChart" or "line3DChart"
?? plotArea.Descendants().FirstOrDefault(e => e.LocalName == "dLbls");
if (dLbls != null)
{
- info.ShowDataLabels = dLbls.Elements().Any(e =>
- (e.LocalName is "showVal" or "showPercent" or "showCatName")
- && e.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value == "1");
+ bool IsOn(string name) => dLbls.Elements().Any(e =>
+ e.LocalName == name && e.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value == "1");
+ info.ShowDataLabelVal = IsOn("showVal");
+ info.ShowDataLabelPercent = IsOn("showPercent");
+ info.ShowDataLabels = info.ShowDataLabelVal || info.ShowDataLabelPercent || IsOn("showCatName");
}
// Doughnut hole size
@@ -886,7 +992,7 @@ e.LocalName is "barChart" or "bar3DChart" or "lineChart" or "line3DChart"
{
var holeSizeEl = chartTypeEl?.Elements().FirstOrDefault(e => e.LocalName == "holeSize");
var holeSizeVal = holeSizeEl?.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value;
- info.HoleRatio = (holeSizeVal != null && int.TryParse(holeSizeVal, out var hs) ? hs : 50) / 100.0;
+ info.HoleRatio = (holeSizeVal != null && int.TryParse(holeSizeVal, out var hs) ? hs : 10) / 100.0; // OOXML spec default: 10%
}
// Axis info
@@ -895,8 +1001,13 @@ e.LocalName is "barChart" or "bar3DChart" or "lineChart" or "line3DChart"
if (valAxis != null)
{
- info.ValAxisTitle = valAxis.Elements().FirstOrDefault(e => e.LocalName == "title")
- ?.Descendants().FirstOrDefault()?.Text;
+ var valTitleEl = valAxis.Elements().FirstOrDefault(e => e.LocalName == "title");
+ info.ValAxisTitle = valTitleEl?.Descendants().FirstOrDefault()?.Text;
+ var valTitleRPr = valTitleEl?.Descendants().FirstOrDefault();
+ if (valTitleRPr?.FontSize?.HasValue == true)
+ info.ValAxisTitleFontPx = (int)(valTitleRPr.FontSize.Value / 100.0);
+ if (valTitleRPr?.Bold?.Value == true)
+ info.ValAxisTitleBold = true;
var scaling = valAxis.Elements().FirstOrDefault(e => e.LocalName == "scaling");
if (scaling != null)
{
@@ -911,17 +1022,52 @@ e.LocalName is "barChart" or "bar3DChart" or "lineChart" or "line3DChart"
if (majorUnit != null && double.TryParse(majorUnit.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value, out var mu))
info.MajorUnit = mu;
- var valFontSize = valAxis.Descendants().FirstOrDefault()?.FontSize;
- if (valFontSize?.HasValue == true)
- info.ValFontPx = (int)(valFontSize.Value / 100.0 * 96 / 72);
+ // Use txPr > defRPr for tick label font (not title's RunProperties)
+ var valTxPr = valAxis.Elements().FirstOrDefault(e => e.LocalName == "txPr");
+ var valDefRPr = valTxPr?.Descendants().FirstOrDefault();
+ if (valDefRPr?.FontSize?.HasValue == true)
+ info.ValFontPx = (int)(valDefRPr.FontSize.Value / 100.0);
+ info.ValFontColor = ExtractFontColor(valDefRPr);
+
+ // Gridline color
+ var majorGridlines = valAxis.Elements().FirstOrDefault(e => e.LocalName == "majorGridlines");
+ var gridSpPr = majorGridlines?.Elements().FirstOrDefault(e => e.LocalName == "spPr");
+ info.GridlineColor = ExtractLineColor(gridSpPr);
+
+ // Axis line color
+ var valSpPr = valAxis.Elements().FirstOrDefault(e => e.LocalName == "spPr");
+ info.AxisLineColor = ExtractLineColor(valSpPr);
+
+ // Value axis number format (e.g. "$#,##0")
+ var numFmtEl = valAxis.Elements().FirstOrDefault(e => e.LocalName == "numFmt");
+ var fmtCode = numFmtEl?.GetAttributes().FirstOrDefault(a => a.LocalName == "formatCode").Value;
+ if (!string.IsNullOrEmpty(fmtCode) && fmtCode != "General")
+ info.ValNumFmt = fmtCode;
}
if (catAxis != null)
{
- info.CatAxisTitle = catAxis.Elements().FirstOrDefault(e => e.LocalName == "title")
- ?.Descendants().FirstOrDefault()?.Text;
- var catFontSize = catAxis.Descendants().FirstOrDefault()?.FontSize;
- if (catFontSize?.HasValue == true)
- info.CatFontPx = (int)(catFontSize.Value / 100.0 * 96 / 72);
+ var catTitleEl = catAxis.Elements().FirstOrDefault(e => e.LocalName == "title");
+ info.CatAxisTitle = catTitleEl?.Descendants().FirstOrDefault()?.Text;
+ var catTitleRPr = catTitleEl?.Descendants().FirstOrDefault();
+ if (catTitleRPr?.FontSize?.HasValue == true)
+ info.CatAxisTitleFontPx = (int)(catTitleRPr.FontSize.Value / 100.0);
+ if (catTitleRPr?.Bold?.Value == true)
+ info.CatAxisTitleBold = true;
+ // Use txPr > defRPr for tick label font (not title's RunProperties)
+ var catTxPr = catAxis.Elements().FirstOrDefault(e => e.LocalName == "txPr");
+ var catDefRPr = catTxPr?.Descendants().FirstOrDefault();
+ if (catDefRPr?.FontSize?.HasValue == true)
+ info.CatFontPx = (int)(catDefRPr.FontSize.Value / 100.0);
+ info.CatFontColor = ExtractFontColor(catDefRPr);
+ }
+
+ // Data label font size
+ if (dLbls != null)
+ {
+ var dLblDefRPr = dLbls.Descendants().FirstOrDefault();
+ var dLblFontSize = dLblDefRPr?.FontSize ?? dLbls.Descendants().FirstOrDefault()?.FontSize;
+ if (dLblFontSize?.HasValue == true)
+ info.DataLabelFontPx = (int)(dLblFontSize.Value / 100.0);
}
// Gap width
@@ -945,9 +1091,12 @@ e.LocalName is "barChart" or "bar3DChart" or "lineChart" or "line3DChart"
var deleteEl = legendEl.Elements().FirstOrDefault(e => e.LocalName == "delete");
var delVal = deleteEl?.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value;
info.HasLegend = delVal != "1";
- var legendFontSize = legendEl.Descendants().FirstOrDefault()?.FontSize;
- if (legendFontSize?.HasValue == true)
- info.LegendFontSize = $"{legendFontSize.Value / 100.0:0.##}pt";
+ var legendRPr = legendEl.Descendants().FirstOrDefault()
+ ?? (OpenXmlElement?)legendEl.Descendants().FirstOrDefault();
+ var legendFontSize = legendRPr?.GetAttributes().FirstOrDefault(a => a.LocalName == "sz").Value;
+ if (legendFontSize != null && int.TryParse(legendFontSize, out var lfs))
+ info.LegendFontSize = $"{lfs / 100.0:0.##}pt";
+ info.LegendFontColor = ExtractFontColor(legendRPr);
}
else
{
@@ -978,7 +1127,7 @@ private static List ExtractColors(List serElements, List
return idxEl.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value == i.ToString();
});
var rgb = ExtractFillColor(dPt?.Elements().FirstOrDefault(e => e.LocalName == "spPr"));
- colors.Add(rgb != null ? $"#{rgb}" : DefaultColors[i % DefaultColors.Length]);
+ colors.Add(rgb != null ? $"#{rgb}" : FallbackColors[i % FallbackColors.Length]);
}
}
else
@@ -1000,7 +1149,7 @@ private static List ExtractColors(List serElements, List
// Fallback to solidFill
rgb ??= ExtractFillColor(spPr);
}
- colors.Add(rgb != null ? $"#{rgb}" : DefaultColors[i % DefaultColors.Length]);
+ colors.Add(rgb != null ? $"#{rgb}" : FallbackColors[i % FallbackColors.Length]);
}
}
return colors;
@@ -1015,30 +1164,63 @@ private static List ExtractColors(List serElements, List
return srgb?.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value;
}
+ /// Extract font color from RunProperties or DefaultRunProperties (solidFill > srgbClr).
+ private static string? ExtractFontColor(OpenXmlElement? rPr)
+ {
+ if (rPr == null) return null;
+ var solidFill = rPr.Elements().FirstOrDefault(e => e.LocalName == "solidFill");
+ var srgb = solidFill?.Elements().FirstOrDefault(e => e.LocalName == "srgbClr");
+ var val = srgb?.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value;
+ return val != null ? $"#{val}" : null;
+ }
+
+ /// Extract line/outline color from spPr (ln > solidFill > srgbClr).
+ private static string? ExtractLineColor(OpenXmlElement? spPr)
+ {
+ if (spPr == null) return null;
+ var ln = spPr.Elements().FirstOrDefault(e => e.LocalName == "ln");
+ if (ln == null) return null;
+ var solidFill = ln.Elements().FirstOrDefault(e => e.LocalName == "solidFill");
+ var srgb = solidFill?.Elements().FirstOrDefault(e => e.LocalName == "srgbClr");
+ var val = srgb?.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value;
+ return val != null ? $"#{val}" : null;
+ }
+
/// Render the chart SVG content (inside an already-opened svg tag) based on ChartInfo.
public void RenderChartSvgContent(StringBuilder sb, ChartInfo info, int svgW, int svgH,
int marginLeft = 45, int marginTop = 10, int marginRight = 15, int marginBottom = 30)
{
- // Sync instance font sizes from ChartInfo
+ // Sync instance font sizes and colors from ChartInfo
ValFontPx = info.ValFontPx;
CatFontPx = info.CatFontPx;
+ if (info.ValFontColor != null) AxisColor = info.ValFontColor;
+ if (info.CatFontColor != null) CatColor = info.CatFontColor;
+ if (info.GridlineColor != null) GridColor = info.GridlineColor;
+ if (info.AxisLineColor != null) AxisLineColor = info.AxisLineColor;
+ DataLabelFontPx = info.DataLabelFontPx;
+
+ // Increase right margin for long axis labels (e.g. "$1,000,000")
+ if (!string.IsNullOrEmpty(info.ValNumFmt) && marginRight < 30)
+ marginRight = 30;
var plotW = svgW - marginLeft - marginRight;
var plotH = svgH - marginTop - marginBottom;
if (plotW < 10 || plotH < 10) return;
- // Plot area background
- if (info.PlotFillColor != null)
- sb.AppendLine($" ");
-
var chartType = info.ChartType;
+ // Plot area background — for horizontal bar charts, defer to RenderBarChartSvg (labels are outside plot)
+ var isHorizBarType = chartType.Contains("bar") && !chartType.Contains("column");
+ if (info.PlotFillColor != null && !isHorizBarType)
+ sb.AppendLine($" ");
+
if (chartType.Contains("pie") || chartType.Contains("doughnut"))
{
if (info.Is3D)
RenderPie3DSvg(sb, info.Series, info.Categories, info.Colors, svgW, svgH);
else
- RenderPieChartSvg(sb, info.Series, info.Categories, info.Colors, svgW, svgH, info.HoleRatio, info.ShowDataLabels);
+ RenderPieChartSvg(sb, info.Series, info.Categories, info.Colors, svgW, svgH, info.HoleRatio, info.ShowDataLabels,
+ info.ShowDataLabelVal, info.ShowDataLabelPercent);
}
else if (chartType.Contains("area"))
{
@@ -1072,27 +1254,39 @@ public void RenderChartSvgContent(StringBuilder sb, ChartInfo info, int svgW, in
{
// Column/bar variants
var isHorizontal = chartType.Contains("bar") && !chartType.Contains("column");
+ // Horizontal bars have their own hLabelMargin inside, so reduce outer marginLeft
+ var barMarginLeft = isHorizontal ? 5 : marginLeft;
+ var barPlotW = isHorizontal ? svgW - barMarginLeft - marginRight : plotW;
if (info.Is3D && !info.IsStacked)
- RenderBar3DSvg(sb, info.Series, info.Categories, info.Colors, marginLeft, marginTop, plotW, plotH, isHorizontal);
+ RenderBar3DSvg(sb, info.Series, info.Categories, info.Colors, barMarginLeft, marginTop, barPlotW, plotH, isHorizontal);
else
- RenderBarChartSvg(sb, info.Series, info.Categories, info.Colors, marginLeft, marginTop, plotW, plotH,
+ RenderBarChartSvg(sb, info.Series, info.Categories, info.Colors, barMarginLeft, marginTop, barPlotW, plotH,
isHorizontal, info.IsStacked, info.IsPercent, info.AxisMax, info.AxisMin, info.MajorUnit,
- info.GapWidth, ValFontPx, CatFontPx, info.ShowDataLabels);
- }
-
- // Axis titles inside SVG
- if (!string.IsNullOrEmpty(info.ValAxisTitle))
- sb.AppendLine($" {HtmlEncode(info.ValAxisTitle)}");
- if (!string.IsNullOrEmpty(info.CatAxisTitle))
- sb.AppendLine($" {HtmlEncode(info.CatAxisTitle)}");
+ info.GapWidth, ValFontPx, CatFontPx, info.ShowDataLabels, info.ValNumFmt,
+ isHorizontal ? info.PlotFillColor : null);
+ }
+
+ // Axis titles inside SVG — for horizontal bar charts, value axis is on bottom and category axis is on left
+ var isHorizBar = chartType.Contains("bar") && !chartType.Contains("column");
+ var bottomTitle = isHorizBar ? info.ValAxisTitle : info.CatAxisTitle;
+ var bottomTitleFont = isHorizBar ? info.ValAxisTitleFontPx : info.CatAxisTitleFontPx;
+ var bottomTitleBold = isHorizBar ? info.ValAxisTitleBold : info.CatAxisTitleBold;
+ var leftTitle = isHorizBar ? info.CatAxisTitle : info.ValAxisTitle;
+ var leftTitleFont = isHorizBar ? info.CatAxisTitleFontPx : info.ValAxisTitleFontPx;
+ var leftTitleBold = isHorizBar ? info.CatAxisTitleBold : info.ValAxisTitleBold;
+ if (!string.IsNullOrEmpty(leftTitle))
+ sb.AppendLine($" {HtmlEncode(leftTitle)}");
+ if (!string.IsNullOrEmpty(bottomTitle))
+ sb.AppendLine($" {HtmlEncode(bottomTitle)}");
}
/// Render chart legend HTML (outside the svg tag).
public void RenderLegendHtml(StringBuilder sb, ChartInfo info, string fontColor = "#555")
{
if (!info.HasLegend) return;
+ var legendColor = info.LegendFontColor ?? fontColor;
var isPieType = info.ChartType.Contains("pie") || info.ChartType.Contains("doughnut");
- sb.Append($"
");
+ sb.Append($"
");
if (isPieType && info.Categories.Length > 0)
{
for (int i = 0; i < info.Categories.Length; i++)
@@ -1141,7 +1335,9 @@ private void RenderBar3DSvg(StringBuilder sb, List<(string name, double[] values
if (horizontal)
{
- var hLabelMargin = 50;
+ // Estimate label width from longest category name (approx 0.5 × fontSize per char)
+ var maxLabelLen = categories.Length > 0 ? categories.Max(c => c.Length) : 0;
+ var hLabelMargin = (int)(maxLabelLen * CatFontPx * 0.5) + 4;
var plotOx = ox + hLabelMargin;
var plotPw = pw - hLabelMargin;
var groupH = (double)ph / Math.Max(catCount, 1);
@@ -1171,7 +1367,7 @@ private void RenderBar3DSvg(StringBuilder sb, List<(string name, double[] values
sb.AppendLine($" ");
sb.AppendLine($" ");
var vlabel = val % 1 == 0 ? $"{(int)val}" : $"{val:0.#}";
- sb.AppendLine($" {vlabel}");
+ sb.AppendLine($" {vlabel}");
}
}
for (int c = 0; c < catCount; c++)
@@ -1219,7 +1415,7 @@ private void RenderBar3DSvg(StringBuilder sb, List<(string name, double[] values
sb.AppendLine($" ");
sb.AppendLine($" ");
var vlabel = val % 1 == 0 ? $"{(int)val}" : $"{val:0.#}";
- sb.AppendLine($" {vlabel}");
+ sb.AppendLine($" {vlabel}");
}
}
for (int c = 0; c < catCount; c++)
diff --git a/src/officecli/Core/ExcelStyleManager.cs b/src/officecli/Core/ExcelStyleManager.cs
index f2696f074..9f7f3095b 100644
--- a/src/officecli/Core/ExcelStyleManager.cs
+++ b/src/officecli/Core/ExcelStyleManager.cs
@@ -105,8 +105,8 @@ public uint ApplyStyle(Cell cell, Dictionary styleProps)
// Map "font" shorthand to font.name
if (styleProps.TryGetValue("font", out var fontShorthand))
fontProps["name"] = fontShorthand;
- // Map shorthand keys (bold, italic, strike, underline, superscript, subscript, strikethrough) to font.* equivalents
- foreach (var shortKey in new[] { "bold", "italic", "strike", "underline", "superscript", "subscript", "strikethrough" })
+ // Map shorthand keys (bold, italic, strike, underline, superscript, subscript, strikethrough, size) to font.* equivalents
+ foreach (var shortKey in new[] { "bold", "italic", "strike", "underline", "superscript", "subscript", "strikethrough", "size" })
{
if (styleProps.TryGetValue(shortKey, out var shortVal))
fontProps[shortKey == "strikethrough" ? "strike" : shortKey] = shortVal;
@@ -240,7 +240,7 @@ public static bool IsStyleKey(string key)
var lower = key.ToLowerInvariant();
return lower is "numfmt" or "fill" or "bgcolor" or "font" or "border"
or "bold" or "italic" or "strike" or "strikethrough" or "underline"
- or "superscript" or "subscript"
+ or "superscript" or "subscript" or "size"
or "wrap" or "wraptext" or "numberformat" or "format" or "halign" or "valign"
or "rotation" or "indent" or "shrinktofit"
or "locked" or "formulahidden"
diff --git a/src/officecli/Core/FormulaEvaluator.Functions.cs b/src/officecli/Core/FormulaEvaluator.Functions.cs
index 14af4cbc9..d80ace0c2 100644
--- a/src/officecli/Core/FormulaEvaluator.Functions.cs
+++ b/src/officecli/Core/FormulaEvaluator.Functions.cs
@@ -24,7 +24,8 @@ internal partial class FormulaEvaluator
"SUMPRODUCT" => EvalSumProduct(args),
"AVERAGE" => nums() is { Length: > 0 } a ? FR(a.Average()) : null,
"COUNT" => FR(nums().Length),
- "COUNTA" => FR(args.Sum(a => a is FormulaResult r && !r.IsError && r.AsString() != "" ? 1 : a is double[] arr ? arr.Length : 0)),
+ "COUNTA" => FR(args.Sum(a => a is RangeData rd ? rd.ToFlatResults().Count(c => c != null && !c.IsError && c.AsString() != "")
+ : a is FormulaResult r && !r.IsError && r.AsString() != "" ? 1 : a is double[] arr ? arr.Length : 0)),
"COUNTBLANK" => FR(0),
"MIN" => nums() is { Length: > 0 } mn ? FR(mn.Min()) : FR(0),
"MAX" => nums() is { Length: > 0 } mx ? FR(mx.Max()) : FR(0),
@@ -491,11 +492,15 @@ internal partial class FormulaEvaluator
// Helper: extract double[] from RangeData or double[]
private static double[]? AsDoubles(object? a) => a is RangeData rd ? rd.ToDoubleArray() : a is double[] arr ? arr : null;
+ // Helper: extract FormulaResult?[] from RangeData (preserves string values for criteria matching)
+ private static FormulaResult?[]? AsResults(object? a) => a is RangeData rd ? rd.ToFlatResults() : null;
+
private FormulaResult? EvalSumIf(List