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 args) { if (args.Count < 2) return null; - var range = AsDoubles(args[0]); var criteria = args[1] is FormulaResult c ? c.AsString() : ""; - var sumRange = args.Count > 2 ? AsDoubles(args[2]) ?? range : range; if (range == null || sumRange == null) return null; + var range = AsResults(args[0]); var criteria = args[1] is FormulaResult c ? c.AsString() : ""; + var sumRange = args.Count > 2 ? AsDoubles(args[2]) : AsDoubles(args[0]); + if (range == null || sumRange == null) return null; double sum = 0; for (int i = 0; i < range.Length && i < sumRange.Length; i++) if (MatchesCriteria(range[i], criteria)) sum += sumRange[i]; return FR(sum); } @@ -509,7 +514,7 @@ internal partial class FormulaEvaluator { var match = true; for (int c = 1; c + 1 < args.Count; c += 2) - { var cr = AsDoubles(args[c]); var crit = args[c + 1] is FormulaResult cv ? cv.AsString() : ""; + { var cr = AsResults(args[c]); var crit = args[c + 1] is FormulaResult cv ? cv.AsString() : ""; if (cr == null || i >= cr.Length || !MatchesCriteria(cr[i], crit)) { match = false; break; } } if (match) sum += sumRange[i]; } @@ -519,20 +524,20 @@ internal partial class FormulaEvaluator private FormulaResult? EvalCountIf(List args) { if (args.Count < 2) return null; - var range = AsDoubles(args[0]); var criteria = args[1] is FormulaResult c ? c.AsString() : ""; + var range = AsResults(args[0]); var criteria = args[1] is FormulaResult c ? c.AsString() : ""; return range != null ? FR(range.Count(v => MatchesCriteria(v, criteria))) : null; } private FormulaResult? EvalCountIfs(List args) { if (args.Count < 2) return null; - var first = AsDoubles(args[0]); if (first == null) return null; + var first = AsResults(args[0]); if (first == null) return null; int count = 0; for (int i = 0; i < first.Length; i++) { var match = true; for (int c = 0; c + 1 < args.Count; c += 2) - { var cr = AsDoubles(args[c]); var crit = args[c + 1] is FormulaResult cv ? cv.AsString() : ""; + { var cr = AsResults(args[c]); var crit = args[c + 1] is FormulaResult cv ? cv.AsString() : ""; if (cr == null || i >= cr.Length || !MatchesCriteria(cr[i], crit)) { match = false; break; } } if (match) count++; } @@ -542,8 +547,9 @@ internal partial class FormulaEvaluator private FormulaResult? EvalAverageIf(List args) { if (args.Count < 2) return null; - var range = AsDoubles(args[0]); var criteria = args[1] is FormulaResult c ? c.AsString() : ""; - var avgRange = args.Count > 2 ? AsDoubles(args[2]) ?? range : range; if (range == null || avgRange == null) return null; + var range = AsResults(args[0]); var criteria = args[1] is FormulaResult c ? c.AsString() : ""; + var avgRange = args.Count > 2 ? AsDoubles(args[2]) : AsDoubles(args[0]); + if (range == null || avgRange == null) return null; var vals = new List(); for (int i = 0; i < range.Length && i < avgRange.Length; i++) if (MatchesCriteria(range[i], criteria)) vals.Add(avgRange[i]); return vals.Count > 0 ? FR(vals.Average()) : FormulaResult.Error("#DIV/0!"); @@ -558,7 +564,7 @@ internal partial class FormulaEvaluator { var match = true; for (int c = 1; c + 1 < args.Count; c += 2) - { var cr = AsDoubles(args[c]); var crit = args[c + 1] is FormulaResult cv ? cv.AsString() : ""; + { var cr = AsResults(args[c]); var crit = args[c + 1] is FormulaResult cv ? cv.AsString() : ""; if (cr == null || i >= cr.Length || !MatchesCriteria(cr[i], crit)) { match = false; break; } } if (match) vals.Add(avgRange[i]); } @@ -574,7 +580,7 @@ internal partial class FormulaEvaluator { var match = true; for (int c = 1; c + 1 < args.Count; c += 2) - { var cr = AsDoubles(args[c]); var crit = args[c + 1] is FormulaResult cv ? cv.AsString() : ""; + { var cr = AsResults(args[c]); var crit = args[c + 1] is FormulaResult cv ? cv.AsString() : ""; if (cr == null || i >= cr.Length || !MatchesCriteria(cr[i], crit)) { match = false; break; } } if (match) vals.Add(valRange[i]); } diff --git a/src/officecli/Core/FormulaParser.cs b/src/officecli/Core/FormulaParser.cs index baa53ccf1..8ac382d4d 100644 --- a/src/officecli/Core/FormulaParser.cs +++ b/src/officecli/Core/FormulaParser.cs @@ -167,21 +167,37 @@ private static string ToLatexByName(OpenXmlElement element) var text = tElem?.InnerText ?? ""; // Check for math style in run properties (mathbf, mathrm, etc.) var rPr = element.ChildElements.FirstOrDefault(e => e.LocalName == "rPr"); + // Check for w:rPr with w:color (used by \color{}) + var wRPr = element.ChildElements.FirstOrDefault(e => + e is DocumentFormat.OpenXml.Wordprocessing.RunProperties); + string? colorHex = null; + if (wRPr != null) + { + var colorEl = wRPr.ChildElements.FirstOrDefault(e => e.LocalName == "color"); + colorHex = colorEl?.GetAttribute("val", "http://schemas.openxmlformats.org/wordprocessingml/2006/main").Value; + } + string result; if (rPr != null) { var sty = rPr.ChildElements.FirstOrDefault(e => e.LocalName == "sty"); var styVal = sty?.GetAttribute("val", "http://schemas.openxmlformats.org/officeDocument/2006/math").Value; var hasNor = rPr.ChildElements.Any(e => e.LocalName == "nor"); if (hasNor) - return $"\\text{{{EscapeLatex(text)}}}"; - if (styVal == "b") - return $"\\mathbf{{{EscapeLatex(text)}}}"; - if (styVal == "bi") - return $"\\boldsymbol{{{EscapeLatex(text)}}}"; - if (styVal == "p") - return $"\\mathrm{{{EscapeLatex(text)}}}"; + result = $"\\text{{{EscapeLatex(text)}}}"; + else if (styVal == "b") + result = $"\\mathbf{{{EscapeLatex(text)}}}"; + else if (styVal == "bi") + result = $"\\boldsymbol{{{EscapeLatex(text)}}}"; + else if (styVal == "p") + result = $"\\mathrm{{{EscapeLatex(text)}}}"; + else + result = EscapeLatex(text); } - return EscapeLatex(text); + else + result = EscapeLatex(text); + if (colorHex != null) + result = $"\\textcolor{{#{colorHex}}}{{{result}}}"; + return result; } case "sSub": @@ -329,8 +345,40 @@ private static string ToLatexByName(OpenXmlElement element) var matrixRows = element.ChildElements.Where(e => e.LocalName == "mr").ToList(); var rowStrings = matrixRows.Select(mr => string.Join(" & ", mr.ChildElements.Where(e => e.LocalName == "e").Select(ArgToLatex))); - // Detect delimiter wrapping from parent - return string.Join(" \\\\ ", rowStrings); + var content = string.Join(" \\\\ ", rowStrings); + // Standalone matrix (not inside a delimiter) needs environment wrapper + if (element.Parent?.LocalName != "e" || element.Parent?.Parent?.LocalName != "d") + return $"\\begin{{matrix}}{content}\\end{{matrix}}"; + return content; + } + + case "borderBox": + { + var baseText = ArgToLatex(element.ChildElements.FirstOrDefault(e => e.LocalName == "e")); + var bbPr = element.ChildElements.FirstOrDefault(e => e.LocalName == "borderBoxPr"); + var hasStrikeTLBR = bbPr?.ChildElements.Any(e => e.LocalName == "strikeTLBR") ?? false; + var hasStrikeBLTR = bbPr?.ChildElements.Any(e => e.LocalName == "strikeBLTR") ?? false; + var hasStrikeH = bbPr?.ChildElements.Any(e => e.LocalName == "strikeH") ?? false; + if (hasStrikeTLBR && hasStrikeBLTR) + return $"\\cancel{{{baseText}}}"; // xcancel → KaTeX uses \cancel for visual + if (hasStrikeTLBR || hasStrikeBLTR || hasStrikeH) + return $"\\cancel{{{baseText}}}"; + return $"\\boxed{{{baseText}}}"; + } + + case "groupChr": + { + var baseText = ArgToLatex(element.ChildElements.FirstOrDefault(e => e.LocalName == "e")); + var gcPr = element.ChildElements.FirstOrDefault(e => e.LocalName == "groupChrPr"); + var chrEl = gcPr?.ChildElements.FirstOrDefault(e => e.LocalName == "chr"); + var chr = chrEl?.GetAttribute("val", "http://schemas.openxmlformats.org/officeDocument/2006/math").Value; + var posEl = gcPr?.ChildElements.FirstOrDefault(e => e.LocalName == "pos"); + var pos = posEl?.GetAttribute("val", "http://schemas.openxmlformats.org/officeDocument/2006/math").Value; + if (chr == "\u23DF" || pos == "bot") // ⏟ + return $"\\underbrace{{{baseText}}}"; + if (chr == "\u23DE" || pos == "top") // ⏞ + return $"\\overbrace{{{baseText}}}"; + return baseText; } default: @@ -834,9 +882,43 @@ private static OpenXmlElement ParseCommand(string cmd, List tokens, ref i if (pos < tokens.Count) pos++; // skip } } - if (envName is "matrix" or "pmatrix" or "bmatrix" or "Bmatrix" or "vmatrix" or "cases") + if (envName is "matrix" or "pmatrix" or "bmatrix" or "Bmatrix" or "vmatrix" or "cases" + or "array") { - return ParseMatrix(envName, tokens, ref pos); + // For array, skip optional column spec like {cc} + if (envName == "array" && pos < tokens.Count && tokens[pos].Type == TokenType.LBrace) + { + pos++; // skip { + while (pos < tokens.Count && tokens[pos].Type != TokenType.RBrace) pos++; + if (pos < tokens.Count) pos++; // skip } + } + var matrixResult = ParseMatrix(envName, tokens, ref pos); + // array should render without implicit delimiters + if (envName == "array" && matrixResult is M.Delimiter arrDelim) + { + var innerMatrix = arrDelim.GetFirstChild()?.GetFirstChild(); + if (innerMatrix != null) + return innerMatrix.CloneNode(true); + } + return matrixResult; + } + if (envName is "align" or "align*" or "aligned" or "gathered" or "eqnarray" + or "eqnarray*" or "split") + { + // Multi-line equation environments mapped via matrix parser (m:m) + // These use \\ for row breaks and & for alignment points + var matrixEl = ParseMatrix(envName, tokens, ref pos); + // ParseMatrix wraps some environments in a delimiter + // For align/gathered, we want the raw m:m (matrix) without delimiters + if (matrixEl is M.Delimiter delim) + { + // Extract the matrix from inside the delimiter + var innerBase = delim.GetFirstChild(); + var innerMatrix = innerBase?.GetFirstChild(); + if (innerMatrix != null) + return innerMatrix.CloneNode(true); + } + return matrixEl; } // Unknown environment, render as text @@ -857,7 +939,23 @@ private static OpenXmlElement ParseCommand(string cmd, List tokens, ref i { // Get opening delimiter character from next token var openChar = "("; - if (pos < tokens.Count && tokens[pos].Type == TokenType.Text) + if (pos < tokens.Count && tokens[pos].Type == TokenType.Command) + { + // Handle \left\langle, \left\lfloor, \left\lceil, \left\lvert, \left\| + var delimCmd = tokens[pos].Value; + var mapped = delimCmd switch + { + "langle" => "\u27E8", + "lceil" => "\u2308", + "lfloor" => "\u230A", + "lvert" => "|", + "lVert" => "\u2016", + "|" => "\u2016", + _ => null + }; + if (mapped != null) { openChar = mapped; pos++; } + } + else if (pos < tokens.Count && tokens[pos].Type == TokenType.Text) { openChar = tokens[pos].Value[..1]; if (tokens[pos].Value.Length > 1) @@ -873,14 +971,30 @@ private static OpenXmlElement ParseCommand(string cmd, List tokens, ref i // Parse content until \right var content = new List(); - var closeChar = openChar switch { "(" => ")", "[" => "]", "{" => "}", "|" => "|", _ => ")" }; + var closeChar = openChar switch { "(" => ")", "[" => "]", "{" => "}", "|" => "|", "\u27E8" => "\u27E9", "\u2308" => "\u2309", "\u230A" => "\u230B", "\u2016" => "\u2016", _ => ")" }; while (pos < tokens.Count) { if (tokens[pos].Type == TokenType.Command && tokens[pos].Value == "right") { pos++; // Get closing delimiter character — capture the actual delimiter - if (pos < tokens.Count && tokens[pos].Type == TokenType.Text) + if (pos < tokens.Count && tokens[pos].Type == TokenType.Command) + { + // Handle \right\rangle, \right\rfloor, \right\rceil, etc. + var rDelimCmd = tokens[pos].Value; + var rMapped = rDelimCmd switch + { + "rangle" => "\u27E9", + "rceil" => "\u2309", + "rfloor" => "\u230B", + "rvert" => "|", + "rVert" => "\u2016", + "|" => "\u2016", + _ => null + }; + if (rMapped != null) { closeChar = rMapped; pos++; } + } + else if (pos < tokens.Count && tokens[pos].Type == TokenType.Text) { closeChar = tokens[pos].Value[..1]; if (tokens[pos].Value.Length > 1) @@ -1150,10 +1264,156 @@ private static OpenXmlElement ParseCommand(string cmd, List tokens, ref i case "xcancel": case "cancelto": { - // Feynman slash notation: \cancel{D} → D followed by combining long solidus overlay (U+0338) + // Cancel/strikethrough: use m:borderBox with strike properties + // \cancelto{value}{expr} takes two args — we discard the target value + if (cmd is "cancelto") + ParseBracedArg(tokens, ref pos); // skip target value var cancelArg = ParseBracedArg(tokens, ref pos); - var cancelText = ExtractText(cancelArg); - return MakeMathRun(cancelText + "\u0338"); + var bbPr = new M.BorderBoxProperties( + new M.HideTop { Val = M.BooleanValues.True }, + new M.HideBottom { Val = M.BooleanValues.True }, + new M.HideLeft { Val = M.BooleanValues.True }, + new M.HideRight { Val = M.BooleanValues.True } + ); + if (cmd is "cancel" or "cancelto") + bbPr.AppendChild(new M.StrikeTopLeftToBottomRight { Val = M.BooleanValues.True }); + else if (cmd is "bcancel") + bbPr.AppendChild(new M.StrikeBottomLeftToTopRight { Val = M.BooleanValues.True }); + else // xcancel — both diagonals + { + bbPr.AppendChild(new M.StrikeTopLeftToBottomRight { Val = M.BooleanValues.True }); + bbPr.AppendChild(new M.StrikeBottomLeftToTopRight { Val = M.BooleanValues.True }); + } + return new M.BorderBox(bbPr, new M.Base(ExtractChildren(cancelArg))); + } + case "boxed": + { + // \boxed{expr} → m:borderBox (all four sides) + var arg = ParseBracedArg(tokens, ref pos); + return new M.BorderBox( + new M.BorderBoxProperties(), + new M.Base(ExtractChildren(arg)) + ); + } + case "underbrace": + { + // \underbrace{expr}_{label} → m:groupChr with ⏟ below + var arg = ParseBracedArg(tokens, ref pos); + var groupChr = new M.GroupChar( + new M.GroupCharProperties( + new M.AccentChar { Val = "\u23DF" }, + new M.Position { Val = M.VerticalJustificationValues.Bottom } + ), + new M.Base(ExtractChildren(arg)) + ); + // Check for subscript label + if (pos < tokens.Count && tokens[pos].Type == TokenType.Sub) + { + pos++; + var label = ParseSingleArg(tokens, ref pos); + return new M.LimitLower( + new M.LimitLowerProperties(), + new M.Base(groupChr), + new M.Limit(ExtractChildren(label)) + ); + } + return groupChr; + } + case "overbrace": + { + // \overbrace{expr}^{label} → m:groupChr with ⏞ above + var arg = ParseBracedArg(tokens, ref pos); + var groupChr = new M.GroupChar( + new M.GroupCharProperties( + new M.AccentChar { Val = "\u23DE" }, + new M.Position { Val = M.VerticalJustificationValues.Top } + ), + new M.Base(ExtractChildren(arg)) + ); + // Check for superscript label + if (pos < tokens.Count && tokens[pos].Type == TokenType.Sup) + { + pos++; + var label = ParseSingleArg(tokens, ref pos); + return new M.LimitUpper( + new M.LimitUpperProperties(), + new M.Base(groupChr), + new M.Limit(ExtractChildren(label)) + ); + } + return groupChr; + } + case "color": + case "textcolor": + { + // \color{red}{expr} / \textcolor{red}{expr} → preserve math structure, apply color to all runs + var colorArg = ParseBracedArg(tokens, ref pos); + var colorName = ExtractText(colorArg); + var contentArg = ParseBracedArg(tokens, ref pos); + var colorHex = NamedColorToHex(colorName); + ApplyColorToRuns(contentArg, colorHex); + return contentArg; + } + case "pmod": + { + // \pmod{n} → (mod n) with upright "mod" + var arg = ParseBracedArg(tokens, ref pos); + var modRun = new M.Run( + new M.RunProperties(new M.NormalText()), + new M.Text("mod") { Space = SpaceProcessingModeValues.Preserve } + ); + var spaceRun = MakeMathRun("\u2003"); + var baseChildren = new List { modRun, spaceRun }; + baseChildren.AddRange(ExtractChildren(arg)); + var delimiter = new M.Delimiter( + new M.DelimiterProperties(), + new M.Base(baseChildren) + ); + return delimiter; + } + case "bmod": + { + // \bmod → upright "mod" (binary operator form) + return new M.Run( + new M.RunProperties(new M.NormalText()), + new M.Text("\u2003mod\u2003") { Space = SpaceProcessingModeValues.Preserve } + ); + } + case "arcsin" or "arccos" or "arctan" or "arccot" or "arcsec" or "arccsc": + { + // Arc-trig functions: render upright like \sin, \cos, etc. + var funcRun = new M.Run( + new M.RunProperties(new M.NormalText()), + new M.Text(cmd) { Space = SpaceProcessingModeValues.Preserve } + ); + return funcRun; + } + case "operatorname": + { + // \operatorname{name} → upright function name with limit support + var arg = ParseBracedArg(tokens, ref pos); + var opText = ExtractText(arg); + OpenXmlElement result = new M.Run( + new M.RunProperties(new M.NormalText()), + new M.Text(opText) { Space = SpaceProcessingModeValues.Preserve } + ); + // Parse sub/superscript limits (like \lim) + OpenXmlElement? subArg = null, supArg = null; + for (var i = 0; i < 2 && pos < tokens.Count; i++) + { + if (tokens[pos].Type == TokenType.Sub && subArg == null) + { pos++; subArg = ParseSingleArg(tokens, ref pos); } + else if (tokens[pos].Type == TokenType.Sup && supArg == null) + { pos++; supArg = ParseSingleArg(tokens, ref pos); } + else break; + } + if (subArg != null) + result = new M.LimitLower(new M.LimitLowerProperties(), + new M.Base(result), new M.Limit(ExtractChildren(subArg))); + if (supArg != null) + result = new M.LimitUpper(new M.LimitUpperProperties(), + new M.Base(result), new M.Limit(ExtractChildren(supArg))); + return result; } default: @@ -1328,6 +1588,23 @@ private static OpenXmlElement WrapInOfficeMath(List elements) return math; } + private static void ApplyColorToRuns(OpenXmlElement element, string colorHex) + { + if (element is M.Run run) + { + var rPr = run.GetFirstChild(); + if (rPr == null) + { + rPr = new DocumentFormat.OpenXml.Wordprocessing.RunProperties(); + run.InsertAt(rPr, 0); + } + rPr.Color = new DocumentFormat.OpenXml.Wordprocessing.Color { Val = colorHex }; + return; + } + foreach (var child in element.ChildElements) + ApplyColorToRuns(child, colorHex); + } + private static OpenXmlElement[] ExtractChildren(OpenXmlElement element) { if (element is M.OfficeMath math) @@ -1335,6 +1612,40 @@ private static OpenXmlElement[] ExtractChildren(OpenXmlElement element) return new[] { element.CloneNode(true) }; } + private static string NamedColorToHex(string color) + { + // Strip # prefix if present, return 6-digit hex + color = color.Trim().TrimStart('#'); + if (color.Length == 6 && color.All(c => "0123456789ABCDEFabcdef".Contains(c))) + return color.ToUpperInvariant(); + return color.ToLowerInvariant() switch + { + "red" => "FF0000", + "blue" => "0000FF", + "green" => "008000", + "black" => "000000", + "white" => "FFFFFF", + "orange" => "FF8C00", + "purple" => "800080", + "brown" => "8B4513", + "gray" or "grey" => "808080", + "cyan" => "00FFFF", + "magenta" => "FF00FF", + "yellow" => "FFD700", + "darkred" => "8B0000", + "darkblue" => "00008B", + "darkgreen" => "006400", + "lightblue" => "ADD8E6", + "lightgreen" => "90EE90", + "pink" => "FFC0CB", + "teal" => "008080", + "navy" => "000080", + "maroon" => "800000", + "olive" => "808000", + _ => "000000" + }; + } + private static string ExtractText(OpenXmlElement element) { if (element is M.Run run) @@ -1430,9 +1741,32 @@ private static string EscapeLatex(string text) "ldots" => "…", "vdots" => "⋮", "ddots" => "⋱", + // Delimiters (when used standalone, not with \left/\right) + "langle" => "\u27E8", // ⟨ mathematical left angle bracket + "rangle" => "\u27E9", // ⟩ mathematical right angle bracket + "lceil" => "\u2308", // ⌈ left ceiling + "rceil" => "\u2309", // ⌉ right ceiling + "lfloor" => "\u230A", // ⌊ left floor + "rfloor" => "\u230B", // ⌋ right floor + "lvert" => "|", + "rvert" => "|", + "lVert" => "\u2016", // ‖ double vertical line + "rVert" => "\u2016", + "vert" => "|", + "Vert" => "\u2016", + // Set notation + "emptyset" => "∅", + "varnothing" => "∅", + "setminus" => "∖", + "complement" => "∁", + "cap" => "∩", + "cup" => "∪", // Spacing "quad" => "\u2003", // em space "qquad" => "\u2003\u2003", // double em space + "," => "\u2009", // thin space + ";" => "\u2005", // medium mathematical space + "!" => "", // negative thin space (approximate with nothing) // Greek lowercase "alpha" => "α", "beta" => "β", diff --git a/src/officecli/Core/IDocumentHandler.cs b/src/officecli/Core/IDocumentHandler.cs index 5ebef829a..02afc3bbd 100644 --- a/src/officecli/Core/IDocumentHandler.cs +++ b/src/officecli/Core/IDocumentHandler.cs @@ -3,6 +3,42 @@ namespace OfficeCli.Core; +/// +/// Represents where to insert an element: by index, after an anchor, or before an anchor. +/// At most one field is set. All null = append to end. +/// +public class InsertPosition +{ + public int? Index { get; init; } + public string? After { get; init; } + public string? Before { get; init; } + + public static InsertPosition AtIndex(int idx) => new() { Index = idx }; + public static InsertPosition AfterElement(string path) => new() { After = path }; + public static InsertPosition BeforeElement(string path) => new() { Before = path }; + + /// + /// Resolve After/Before anchor to a 0-based index among children. + /// If this is already an Index or null, returns Index as-is. + /// anchorFinder: given the anchor path, returns the 0-based index of that element among siblings, or throws. + /// childCount: total number of children of the relevant type. + /// + public int? Resolve(Func anchorFinder, int childCount) + { + if (Index.HasValue) return Index; + if (After != null) + { + var anchorIdx = anchorFinder(After); + return anchorIdx + 1 >= childCount ? null : anchorIdx + 1; // null = append + } + if (Before != null) + { + return anchorFinder(Before); + } + return null; // append + } +} + /// /// Common interface for all document types (Word/Excel/PowerPoint). /// Each handler implements the three-layer architecture: @@ -31,13 +67,13 @@ public interface IDocumentHandler : IDisposable /// Returns list of prop names that were not applied (unsupported for this element type). /// List Set(string path, Dictionary properties); - string Add(string parentPath, string type, int? index, Dictionary properties); + string Add(string parentPath, string type, InsertPosition? position, Dictionary properties); /// /// Remove element at path. Returns an optional warning message (e.g. formula cells affected by shift). /// string? Remove(string path); - string Move(string sourcePath, string? targetParentPath, int? index); - string CopyFrom(string sourcePath, string targetParentPath, int? index); + string Move(string sourcePath, string? targetParentPath, InsertPosition? position); + string CopyFrom(string sourcePath, string targetParentPath, InsertPosition? position); // === Raw Layer === string Raw(string partPath, int? startRow = null, int? endRow = null, HashSet? cols = null); diff --git a/src/officecli/Core/Installer.cs b/src/officecli/Core/Installer.cs index 84a0b9ae3..e54142188 100644 --- a/src/officecli/Core/Installer.cs +++ b/src/officecli/Core/Installer.cs @@ -1,6 +1,8 @@ // Copyright 2025 OfficeCli (officecli.ai) // SPDX-License-Identifier: Apache-2.0 +using System.Diagnostics; + namespace OfficeCli.Core; /// @@ -62,24 +64,30 @@ private static void InstallMcpFallback(HashSet skilledTools, string targ } } - private static void InstallBinary() + internal static bool InstallBinary(bool quiet = false) { var src = Environment.ProcessPath; if (string.IsNullOrEmpty(src)) - return; + return false; - // Already at target location — skip + // Already at target location — record version and skip the copy if (string.Equals(Path.GetFullPath(src), Path.GetFullPath(TargetPath), StringComparison.Ordinal)) - return; + { + RecordInstalledVersion(); + return false; + } // Skip if not a self-contained published binary (e.g. running via dotnet run) // Self-contained single-file binaries are typically >5MB; framework-dependent builds are <1MB var srcInfo = new FileInfo(src); if (srcInfo.Length < 5 * 1024 * 1024) { - Console.WriteLine($"Skipping binary install: not a published self-contained binary."); - Console.WriteLine($" Run: dotnet publish -c Release -r --self-contained -p:PublishSingleFile=true"); - return; + if (!quiet) + { + Console.WriteLine($"Skipping binary install: not a published self-contained binary."); + Console.WriteLine($" Run: dotnet publish -c Release -r --self-contained -p:PublishSingleFile=true"); + } + return false; } Directory.CreateDirectory(BinDir); @@ -98,9 +106,125 @@ private static void InstallBinary() catch { /* best effort */ } } - Console.WriteLine($"Installed binary to {TargetPath}"); + RecordInstalledVersion(); + + if (quiet) + Console.Error.WriteLine($"note: officecli self-installed to {TargetPath}"); + else + Console.WriteLine($"Installed binary to {TargetPath}"); + + EnsurePath(quiet); + return true; + } + + private static void RecordInstalledVersion() + { + try + { + var current = UpdateChecker.GetCurrentVersionPublic(); + if (string.IsNullOrEmpty(current)) return; + var config = UpdateChecker.LoadConfig(); + if (config.InstalledBinaryVersion == current) return; + config.InstalledBinaryVersion = current; + UpdateChecker.SaveConfig(config); + } + catch { /* best effort */ } + } + + /// + /// Auto-install hook called on every officecli invocation. + /// - Target missing → full install (binary + skills + MCP fallback). + /// - Target older than current → binary-only upgrade. + /// - Otherwise → no-op (cheap path: one File.Exists + one config read). + /// Never throws, never blocks the main command. + /// + internal static void MaybeAutoInstall(string[] args) + { + try + { + // Opt-out + if (Environment.GetEnvironmentVariable("OFFICECLI_NO_AUTO_INSTALL") == "1") + return; - EnsurePath(); + // Only trigger on bare `officecli` invocation (exploratory / discovery call). + // Real work commands (view, set, add, create, ...) are left alone to keep + // zero side-effects and zero overhead on the hot path. + if (args.Length != 0) + return; + + var src = Environment.ProcessPath; + if (string.IsNullOrEmpty(src)) return; + + // Already running from target — nothing to do (RecordInstalledVersion is handled by explicit `install`) + if (string.Equals(Path.GetFullPath(src), Path.GetFullPath(TargetPath), StringComparison.Ordinal)) + return; + + // Dev-build filter: framework-dependent / dotnet run binaries are <5MB + FileInfo srcInfo; + try { srcInfo = new FileInfo(src); } + catch { return; } + if (srcInfo.Length < 5 * 1024 * 1024) return; + + var currentVer = UpdateChecker.GetCurrentVersionPublic(); + if (string.IsNullOrEmpty(currentVer)) return; + + if (!File.Exists(TargetPath)) + { + // Fresh install — full Run() (binary + skills + MCP fallback) + Console.Error.WriteLine($"note: officecli not installed yet, running first-time install..."); + Run([]); + return; + } + + // Upgrade case — compare current vs config-recorded version + var config = UpdateChecker.LoadConfig(); + var installedVer = config.InstalledBinaryVersion; + if (string.IsNullOrEmpty(installedVer)) + { + // Config field missing (older install) — fall back to subprocess once. + installedVer = ReadVersionFromBinary(TargetPath); + if (!string.IsNullOrEmpty(installedVer)) + { + config.InstalledBinaryVersion = installedVer; + try { UpdateChecker.SaveConfig(config); } catch { } + } + } + + if (string.IsNullOrEmpty(installedVer)) return; + if (!UpdateChecker.IsNewerPublic(currentVer, installedVer)) return; + + // Strict upgrade — binary only, leave skills/MCP alone + InstallBinary(quiet: true); + } + catch { /* never block the user's command */ } + } + + private static string? ReadVersionFromBinary(string path) + { + try + { + var psi = new ProcessStartInfo + { + FileName = path, + Arguments = "--version", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + using var proc = Process.Start(psi); + if (proc == null) return null; + if (!proc.WaitForExit(2000)) + { + try { proc.Kill(); } catch { } + return null; + } + var output = (proc.StandardOutput.ReadToEnd() + " " + proc.StandardError.ReadToEnd()).Trim(); + // Match first x.y.z token + var match = System.Text.RegularExpressions.Regex.Match(output, @"\d+\.\d+\.\d+"); + return match.Success ? match.Value : null; + } + catch { return null; } } private static bool IsInPath() @@ -113,7 +237,7 @@ private static bool IsInPath() }); } - private static void EnsurePath() + private static void EnsurePath(bool quiet = false) { if (IsInPath()) return; @@ -126,7 +250,8 @@ private static void EnsurePath() if (OperatingSystem.IsWindows()) { // Windows: just advise, don't auto-modify registry - Console.WriteLine($" Add {BinDir} to your system PATH."); + if (!quiet) + Console.WriteLine($" Add {BinDir} to your system PATH."); return; } diff --git a/src/officecli/Core/McpServer.cs b/src/officecli/Core/McpServer.cs index f421a422e..1f0706328 100644 --- a/src/officecli/Core/McpServer.cs +++ b/src/officecli/Core/McpServer.cs @@ -220,9 +220,15 @@ string[] ArgStringArray(string key) var parent = Arg("parent"); var type = Arg("type"); var index = ArgIntOpt("index"); + var after = Arg("after"); if (string.IsNullOrEmpty(after)) after = null; + var before = Arg("before"); if (string.IsNullOrEmpty(before)) before = null; + var position = index.HasValue ? InsertPosition.AtIndex(index.Value) + : after != null ? InsertPosition.AfterElement(after) + : before != null ? InsertPosition.BeforeElement(before) + : null; var props = ParseProps(ArgStringArray("props")); using var handler = DocumentHandlerFactory.Open(file, editable: true); - var resultPath = handler.Add(parent, type, index, props); + var resultPath = handler.Add(parent, type, position, props); return $"Added {type} at {resultPath}"; } case "remove": @@ -239,8 +245,14 @@ string[] ArgStringArray(string key) var path = Arg("path"); var to = Arg("to"); if (string.IsNullOrEmpty(to)) to = null; var index = ArgIntOpt("index"); + var mvAfter = Arg("after"); if (string.IsNullOrEmpty(mvAfter)) mvAfter = null; + var mvBefore = Arg("before"); if (string.IsNullOrEmpty(mvBefore)) mvBefore = null; + var mvPosition = index.HasValue ? InsertPosition.AtIndex(index.Value) + : mvAfter != null ? InsertPosition.AfterElement(mvAfter) + : mvBefore != null ? InsertPosition.BeforeElement(mvBefore) + : null; using var handler = DocumentHandlerFactory.Open(file, editable: true); - var resultPath = handler.Move(path, to, index); + var resultPath = handler.Move(path, to, mvPosition); return $"Moved to {resultPath}"; } case "validate": diff --git a/src/officecli/Core/OutputFormatter.cs b/src/officecli/Core/OutputFormatter.cs index ee0b79ebf..3695cfcc3 100644 --- a/src/officecli/Core/OutputFormatter.cs +++ b/src/officecli/Core/OutputFormatter.cs @@ -134,7 +134,7 @@ public static string WrapEnvelope(string dataJson, List? warnings = /// /// Wraps a plain text result (like "Updated ..." or "Added ...") into an envelope. /// - public static string WrapEnvelopeText(string message, List? warnings = null) + public static string WrapEnvelopeText(string message, List? warnings = null, int? matched = null) { var envelope = new JsonObject { @@ -142,6 +142,27 @@ public static string WrapEnvelopeText(string message, List? warnings ["message"] = message }; + if (matched.HasValue) + envelope["matched"] = matched.Value; + + if (warnings is { Count: > 0 }) + envelope["warnings"] = JsonSerializer.SerializeToNode(warnings, AppJsonContext.Default.ListCliWarning); + + return envelope.ToJsonString(JsonOptions); + } + + public static string WrapEnvelopeWithData(string message, DocumentNode data, List? warnings = null, int? matched = null) + { + var envelope = new JsonObject + { + ["success"] = true, + ["message"] = message, + ["data"] = JsonSerializer.SerializeToNode(data, AppJsonContext.Default.DocumentNode) + }; + + if (matched.HasValue) + envelope["matched"] = matched.Value; + if (warnings is { Count: > 0 }) envelope["warnings"] = JsonSerializer.SerializeToNode(warnings, AppJsonContext.Default.ListCliWarning); diff --git a/src/officecli/Core/ParseHelpers.cs b/src/officecli/Core/ParseHelpers.cs index 4106ea915..6142d7839 100644 --- a/src/officecli/Core/ParseHelpers.cs +++ b/src/officecli/Core/ParseHelpers.cs @@ -126,6 +126,17 @@ public static bool IsTruthy(string? value) }; } + /// + /// Returns true if the value is a recognized truthy string. + /// Returns false for anything else (null, empty, falsy, or unrecognized values). + /// Unlike , never throws. + /// + public static bool IsTruthySafe(string? value) + { + if (value == null) return false; + return value.ToLowerInvariant() is "true" or "1" or "yes" or "on"; + } + /// /// Returns true if the value is a recognized boolean string (truthy or falsy). /// Returns false for null, empty, or non-boolean values (no exception thrown). diff --git a/src/officecli/Core/PivotTableHelper.cs b/src/officecli/Core/PivotTableHelper.cs index 5dd7db10b..3b7adb27a 100644 --- a/src/officecli/Core/PivotTableHelper.cs +++ b/src/officecli/Core/PivotTableHelper.cs @@ -1,6 +1,7 @@ // Copyright 2025 OfficeCli (officecli.ai) // SPDX-License-Identifier: Apache-2.0 +using System.Text; using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Spreadsheet; @@ -13,6 +14,538 @@ namespace OfficeCli.Core; /// internal static class PivotTableHelper { + // ==================== XML text sanitization (R2-2) ==================== + // + // XML 1.0 only permits a narrow set of character code points in element + // content: Tab (U+0009), LF (U+000A), CR (U+000D), and anything in + // [U+0020..U+D7FF] ∪ [U+E000..U+FFFD] ∪ [U+10000..U+10FFFF]. Everything + // else — including the NUL byte — causes XmlWriter to throw + // ArgumentException at save time, which tore down PivotCacheDefinition.Save + // whenever a source cell contained a stray U+0000 (see FuzzPivotRound2Tests + // Add_Pivot_NulCharInRowValue_ShouldNotThrow). + // + // Sanitization is applied ONLY to strings that get embedded in the pivot + // cache (sharedItems and fieldGroup ). The + // original cell values in the source sheet are untouched — we just want + // the cache write to succeed. Unpaired surrogates are also stripped so we + // don't turn one invalid form into another. + internal static string SanitizeXmlText(string? s) + { + if (string.IsNullOrEmpty(s)) return s ?? string.Empty; + System.Text.StringBuilder? sb = null; + for (int i = 0; i < s.Length; i++) + { + char c = s[i]; + bool ok; + if (c == '\t' || c == '\n' || c == '\r') ok = true; + else if (c < 0x20) ok = false; + else if (c == 0xFFFE || c == 0xFFFF) ok = false; + else if (char.IsHighSurrogate(c)) + { + if (i + 1 < s.Length && char.IsLowSurrogate(s[i + 1])) + { + if (sb != null) { sb.Append(c); sb.Append(s[i + 1]); } + i++; + continue; + } + ok = false; + } + else if (char.IsLowSurrogate(c)) ok = false; // unpaired trailing surrogate + else ok = true; + + if (ok) + { + sb?.Append(c); + } + else + { + if (sb == null) + { + sb = new System.Text.StringBuilder(s.Length); + sb.Append(s, 0, i); + } + // Drop the invalid code unit entirely. + } + } + return sb?.ToString() ?? s; + } + + // ==================== Pivot property key canonicalization ==================== + // + // R12-2 / R12-3: pivot property keys arrive from three sources + // (CLI --prop, batch JSON, programmatic Dictionary) with varying case + // and legacy singular/plural spellings. Normalize them all through one + // helper so every downstream lookup site sees the same canonical key. + // + // Canonical keys (matches the Get readback and the ParseFieldList sites): + // source, src, name, position, pos, rows, cols, filters, values, + // aggregate, showdataas, topn, style, sort, grandtotals, + // rowgrandtotals, colgrandtotals + // + // Aliases that normalize TO a canonical key: + // row, rowfield, rowfields → rows + // col, column, columns, colfield, + // colfields, columnfield, columnfields → cols + // filter, filterfield, filterfields → filters + // value, valuefield, valuefields → values + // columngrandtotals → colgrandtotals + // + // CONSISTENCY(compatibility-aliases): matches CLAUDE.md rule that Add/Set + // may accept legacy aliases so old scripts (e.g. Round 3's rowFields key) + // keep round-tripping. Get continues to emit only the canonical form. + private static readonly Dictionary _pivotKeyAliases = + new(StringComparer.OrdinalIgnoreCase) + { + // rows aliases + ["row"] = "rows", + ["rowfield"] = "rows", + ["rowfields"] = "rows", + // cols aliases + ["col"] = "cols", + ["column"] = "cols", + ["columns"] = "cols", + ["colfield"] = "cols", + ["colfields"] = "cols", + ["columnfield"] = "cols", + ["columnfields"] = "cols", + // filters aliases + ["filter"] = "filters", + ["filterfield"] = "filters", + ["filterfields"] = "filters", + // values aliases + ["value"] = "values", + ["valuefield"] = "values", + ["valuefields"] = "values", + // grand totals + ["columngrandtotals"] = "colgrandtotals", + // col/column spelling aliases: the + // OOXML attribute names use "column" but we prefer "col" as + // the canonical CLI key to match the existing `cols=` axis + // key. Add-path warning suppression relies on this rewrite. + ["showcolumnstripes"] = "showcolstripes", + ["showcolumnheaders"] = "showcolheaders", + }; + + /// + /// Map a pivot property key to its canonical form. Returns the lower-cased + /// key if no alias applies. Used by both CreatePivotTable (Add) and + /// SetPivotTableProperties (Set) so every downstream `properties["rows"]` + /// lookup binds to user input written as `row` / `rowFields` / `ROWS`. + /// + internal static string NormalizePivotPropKey(string key) + { + if (string.IsNullOrEmpty(key)) return key; + var lower = key.ToLowerInvariant(); + return _pivotKeyAliases.TryGetValue(lower, out var canonical) ? canonical : lower; + } + + /// + /// Validate a user-supplied pivot table name and return the trimmed value. + /// Throws ArgumentException for empty, whitespace-only, control-character, + /// or over-255-character names. Does NOT check workbook-level uniqueness + /// (that is the caller's responsibility). + /// R16-2: extracted from CreatePivotTable so SetPivotTableProperties can + /// reuse the same validation — previously Set accepted empty/whitespace + /// names without any check. + /// + internal static string ValidatePivotName(string name) + { + // Empty string is rejected — a blank name is always an error. + if (string.IsNullOrEmpty(name)) + throw new ArgumentException("pivot name must not be empty"); + var trimmed = name.Trim(); + // Whitespace-only names are rejected — R8-4. + if (trimmed.Length == 0) + throw new ArgumentException("pivot name must not be whitespace-only"); + // ASCII control characters are rejected — R8-5. + foreach (var ch in trimmed) + { + if (ch < 0x20 || ch == 0x7F) + throw new ArgumentException("pivot name contains invalid control characters"); + } + // 255-character limit — R11-4. + if (trimmed.Length > 255) + throw new ArgumentException("pivot name exceeds 255-character limit"); + return trimmed; + } + + /// + /// Canonical key set recognized by the pivot Add / Set pipeline. Any + /// property whose NORMALIZED key is not in this set is reported as + /// UNSUPPORTED (Add: stderr warning; Set: returned unsupported list). + /// Must stay in sync with the switch in SetPivotTableProperties and + /// every properties lookup in CreatePivotTable. + /// + private static readonly HashSet _knownPivotKeys = + new(StringComparer.OrdinalIgnoreCase) + { + "source", "src", "name", "position", "pos", "style", + "rows", "cols", "filters", "values", + "aggregate", "showdataas", "topn", + "sort", + "grandtotals", "rowgrandtotals", "colgrandtotals", + // bool toggles (see ApplyPivotStyleInfoProps). + // Canonical keys only; col/column aliases are handled by the switch + // in SetPivotTableProperties and the helper's case labels. + "showrowstripes", "showcolstripes", + "showrowheaders", "showcolheaders", + "showlastcolumn", + }; + + /// + /// Return the subset of the caller's pivot-property keys that are not + /// known to the pipeline after alias normalization. Used by Add to + /// emit an UNSUPPORTED stderr warning (R12-1) and shared by Set to + /// merge into its existing unsupported return list. Keys are echoed + /// in their ORIGINAL spelling (Unicode, case) so the user sees exactly + /// what they typed — matches the 'unsupported echoes caller key' rule + /// followed by the Set default case. + /// + internal static List CollectUnknownPivotKeys(Dictionary properties) + { + var unknown = new List(); + if (properties == null) return unknown; + foreach (var key in properties.Keys) + { + if (string.IsNullOrEmpty(key)) continue; + var canonical = NormalizePivotPropKey(key); + if (!_knownPivotKeys.Contains(canonical)) + unknown.Add(key); + } + return unknown; + } + + /// + /// Emit an UNSUPPORTED props warning to stderr for the Add pivot path. + /// Set already surfaces unknown keys through its return list; Add has + /// no such channel, so we write directly. Format mirrors + /// CommandBuilder.FormatUnsupported so JSON envelope parsing (see + /// OutputFormatter.cs line 273) picks up the same prefix. + /// + private static void WarnUnknownPivotProperties(List unknownKeys) + { + if (unknownKeys == null || unknownKeys.Count == 0) return; + Console.Error.WriteLine( + $"UNSUPPORTED props: {string.Join(", ", unknownKeys)}. " + + "Use 'officecli help excel-set' to see available pivot properties."); + } + + /// + /// Normalize a user-supplied pivot properties dict into a new dict whose + /// alias keys are rewritten to their canonical form. Keys that are + /// already canonical and keys that don't match any known alias are + /// preserved VERBATIM so the downstream unsupported-list reports the + /// original spelling (matches the CLI contract that Set return values + /// echo the caller's key). Collisions between an alias and an already- + /// present canonical key are resolved first-seen-wins. + /// + internal static Dictionary NormalizePivotProperties( + Dictionary properties) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (properties == null) return result; + foreach (var (rawKey, value) in properties) + { + // Only rewrite keys that the alias table knows about; everything + // else (canonical keys, typos, non-ASCII) passes through with + // the original spelling so error messages can echo it. + var lower = rawKey?.ToLowerInvariant() ?? string.Empty; + var outKey = _pivotKeyAliases.TryGetValue(lower, out var canonical) + ? canonical + : rawKey!; + if (!result.ContainsKey(outKey)) + result[outKey] = value; + } + return result; + } + + // ==================== Axis sort options ==================== + // + // Axis labels on every level are sorted through a single comparer that + // CreatePivotTable / SetPivotTableProperties publishes into _axisSortMode + // for the duration of the operation. Every sort site below reads + // ActiveAxisComparer / ActiveAxisDescending rather than hard-coding + // StringComparer.Ordinal. + // + // Why ThreadStatic instead of a parameter: the sort opts have to reach + // ~15 deeply-nested call sites (cache builders, pivotField items writers, + // per-level index maps, 5 specialized renderers). Threading a parameter + // through all of them would balloon 15+ signatures with pass-through + // boilerplate. The CLI is single-threaded per pivot operation, so + // ThreadStatic is safe and dramatically less invasive. + // + // Supported modes: + // "asc" — StringComparer.Ordinal ascending (DEFAULT, preserves + // byte-level regression baselines) + // "desc" — StringComparer.Ordinal descending + // "locale" — zh-CN culture ascending (pinyin). Hard-coded to + // zh-CN rather than StringComparer.CurrentCulture: + // on non-Chinese process locales (e.g. en-US on CI or + // most developer machines) CurrentCulture silently + // degrades to Ordinal for CJK strings, making locale + // indistinguishable from asc. Pinyin is the primary + // use case this mode exists for; honoring it regardless + // of process locale is worth the lost generality. + // "locale-desc" — zh-CN culture descending + [ThreadStatic] private static string? _axisSortMode; + + private static readonly IComparer ZhCnComparer = + StringComparer.Create(System.Globalization.CultureInfo.GetCultureInfo("zh-CN"), ignoreCase: false); + + private static IComparer ActiveAxisComparer => _axisSortMode switch + { + "locale" or "locale-desc" => ZhCnComparer, + _ => StringComparer.Ordinal + }; + + private static bool ActiveAxisDescending => _axisSortMode switch + { + "desc" or "locale-desc" => true, + _ => false + }; + + /// + /// Set axis sort mode from the pivot properties and return a token that + /// restores the previous value on Dispose. Usage: + /// using (PushAxisSortMode(properties)) { ... build pivot ... } + /// + private static readonly HashSet _validSortModes = new(StringComparer.OrdinalIgnoreCase) + { + "asc", "desc", "locale", "locale-desc" + }; + + private static IDisposable PushAxisSortMode(Dictionary properties) + { + var prev = _axisSortMode; + if (properties.TryGetValue("sort", out var mode) && !string.IsNullOrWhiteSpace(mode)) + { + var normalized = mode.Trim().ToLowerInvariant(); + // CONSISTENCY(strict-enums): unknown sort tokens are rejected + // up front. Empty / whitespace fall through to the default + // (no-op) so users can clear the sort by passing an empty + // value without seeing an error. + if (!_validSortModes.Contains(normalized)) + throw new ArgumentException( + $"invalid sort: '{mode}'. Valid: asc, desc, locale, locale-desc"); + _axisSortMode = normalized; + } + return new SortModeScope(prev); + } + + private sealed class SortModeScope : IDisposable + { + private readonly string? _prev; + public SortModeScope(string? prev) { _prev = prev; } + public void Dispose() { _axisSortMode = _prev; } + } + + // ==================== Grand totals options ==================== + // + // CONSISTENCY(thread-static-pivot-opts): reuses the same ThreadStatic + // pattern as _axisSortMode above. Grand totals need to reach the same + // ~15 nested sites (item builders, geometry, all 6 renderers, definition + // builder), and threading parameters would explode signature churn. + // + // OOXML semantics (ECMA-376 § 18.10.1.73 on pivotTableDefinition): + // rowGrandTotals — "Show grand totals for rows" = per-row grand totals + // = RIGHTMOST grand total COLUMN (a total for each row) + // colGrandTotals — "Show grand totals for columns" = per-col grand totals + // = BOTTOM grand total ROW (a total for each column) + // + // Both default to true. We only write the attribute when the user + // explicitly opts out (matches how real Excel + LibreOffice serialize). + [ThreadStatic] private static bool? _rowGrandTotals; + [ThreadStatic] private static bool? _colGrandTotals; + + private static bool ActiveRowGrandTotals => _rowGrandTotals ?? true; + private static bool ActiveColGrandTotals => _colGrandTotals ?? true; + + /// + /// Parse grand-totals properties into the thread-static scope. Supports: + /// grandTotals=both|none|rows|cols|on|off|true|false + /// rowGrandTotals=true|false (overrides grandTotals for the row-grand axis) + /// colGrandTotals=true|false (overrides grandTotals for the col-grand axis) + /// Returns a scope that restores the previous values on Dispose. + /// + private static IDisposable PushGrandTotalsOptions(Dictionary properties) + { + var prevRow = _rowGrandTotals; + var prevCol = _colGrandTotals; + + // Master 'grandTotals' key (friendly). 'rows' means only per-row grand + // totals (right column); 'cols' means only per-col grand totals (bottom). + if (properties.TryGetValue("grandTotals", out var gt) + || properties.TryGetValue("grandtotals", out gt)) + { + switch ((gt ?? "").Trim().ToLowerInvariant()) + { + case "both": case "on": case "true": case "1": case "yes": + _rowGrandTotals = true; _colGrandTotals = true; break; + case "none": case "off": case "false": case "0": case "no": + _rowGrandTotals = false; _colGrandTotals = false; break; + case "rows": case "row": + _rowGrandTotals = true; _colGrandTotals = false; break; + case "cols": case "col": case "columns": + _rowGrandTotals = false; _colGrandTotals = true; break; + } + } + + // Fine-grained bool keys (OOXML-level), parsed AFTER the master key + // so they override it when both are supplied. + if (TryParseBoolProp(properties, "rowGrandTotals", out var rgt)) + _rowGrandTotals = rgt; + if (TryParseBoolProp(properties, "colGrandTotals", out var cgt) + || TryParseBoolProp(properties, "columnGrandTotals", out cgt)) + _colGrandTotals = cgt; + + return new GrandTotalsScope(prevRow, prevCol); + } + + private static bool TryParseBoolProp(Dictionary properties, string key, out bool value) + { + value = false; + if (!properties.TryGetValue(key, out var raw) + && !properties.TryGetValue(key.ToLowerInvariant(), out raw)) + return false; + switch ((raw ?? "").Trim().ToLowerInvariant()) + { + case "true": case "1": case "yes": case "on": value = true; return true; + case "false": case "0": case "no": case "off": value = false; return true; + default: return false; + } + } + + private sealed class GrandTotalsScope : IDisposable + { + private readonly bool? _prevRow; + private readonly bool? _prevCol; + public GrandTotalsScope(bool? prevRow, bool? prevCol) { _prevRow = prevRow; _prevCol = prevCol; } + public void Dispose() { _rowGrandTotals = _prevRow; _colGrandTotals = _prevCol; } + } + + /// + /// Apply axis ordering (ascending/descending) to an OrderBy clause using + /// the currently-active sort mode. All axis sort sites use this helper. + /// + private static IOrderedEnumerable OrderByAxis(this IEnumerable source, Func keySelector) + { + return ActiveAxisDescending + ? source.OrderByDescending(keySelector, ActiveAxisComparer) + : source.OrderBy(keySelector, ActiveAxisComparer); + } + + // ==================== Top-N filter ==================== + // + // Applies a Top-N filter to the source data BEFORE the cache / renderer + // see it. Semantics (V1): + // * Ranks values of the OUTERMOST row field by the FIRST value field's + // aggregate (using that value field's func: sum/avg/count/...). + // * Keeps the top N keys by that aggregate (descending — "top = largest"). + // * Drops source rows whose outer-row-field value is not in the kept set. + // + // Why filter source rows instead of emitting / OOXML: + // the renderer writes pivot cells directly into sheetData as a static + // snapshot. There is no Excel-side recompute step for an OOXML-level + // filter to honour, so filtering the source is what keeps cache, + // rendered cells, and grand totals in lock-step. + // + // Interaction with `sort`: independent. `topN` picks the set by VALUE + // (largest aggregates), `sort` arranges the kept set by LABEL + // (asc/desc/locale). Both compose cleanly. + // + // Known limitations (tracked for v2 expansion): + // * Outermost row field only — col-axis and inner-level Top-N are not + // supported. + // * Always "top" (largest). "bottom" / worst-N is not supported. + // * Ranks by the FIRST value field when multiple values exist. + // * Set operation does NOT re-apply Top-N (cache is already built at + // that point). Users must remove + re-add the pivot to re-filter. + // + // No-op cases (silently skipped — mirrors how `sort` handles degenerate + // inputs): + // * topN <= 0 + // * rows empty (nothing to rank on) + // * values empty (nothing to rank by) + // * topN >= distinct outer keys (keeps everything) + private static void ApplyTopNFilter( + List columnData, + List rowFields, + List<(int idx, string func, string showAs, string name)> valueFields, + int topN) + { + if (topN <= 0 || rowFields.Count == 0 || valueFields.Count == 0 || columnData.Count == 0) + return; + + var outerFieldIdx = rowFields[0]; + var valueFieldIdx = valueFields[0].idx; + var valueFunc = valueFields[0].func; + if (outerFieldIdx < 0 || outerFieldIdx >= columnData.Count) return; + if (valueFieldIdx < 0 || valueFieldIdx >= columnData.Count) return; + + var outerCol = columnData[outerFieldIdx]; + var valueCol = columnData[valueFieldIdx]; + var rowCount = outerCol.Length; + if (rowCount == 0) return; + + // Aggregate per outer-key using the first value field's function. + var buckets = new Dictionary>(StringComparer.Ordinal); + for (int r = 0; r < rowCount; r++) + { + var key = outerCol[r]; + if (string.IsNullOrEmpty(key)) continue; + if (r >= valueCol.Length) continue; + if (!double.TryParse(valueCol[r], System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out var v)) + continue; + if (!buckets.TryGetValue(key, out var list)) + { + list = new List(); + buckets[key] = list; + } + list.Add(v); + } + + if (buckets.Count <= topN) return; // keeps everything — no-op + + // Rank keys by aggregate descending; stable tie-break by ordinal label + // so the kept set is deterministic across runs. + var kept = buckets + .Select(kv => (key: kv.Key, agg: ReducePivotValues(kv.Value, valueFunc))) + .OrderByDescending(t => t.agg) + .ThenBy(t => t.key, StringComparer.Ordinal) + .Take(topN) + .Select(t => t.key) + .ToHashSet(StringComparer.Ordinal); + + // Build keep-mask over source rows. + var keep = new bool[rowCount]; + int keepCount = 0; + for (int r = 0; r < rowCount; r++) + { + var k = outerCol[r]; + if (!string.IsNullOrEmpty(k) && kept.Contains(k)) + { + keep[r] = true; + keepCount++; + } + } + + if (keepCount == rowCount) return; // nothing to drop + + // Apply mask to every column in place. + for (int c = 0; c < columnData.Count; c++) + { + var src = columnData[c]; + var dst = new string[keepCount]; + int w = 0; + for (int r = 0; r < rowCount && r < src.Length; r++) + { + if (keep[r]) dst[w++] = src[r]; + } + columnData[c] = dst; + } + } + /// /// Create a pivot table on the target worksheet. /// @@ -33,10 +566,61 @@ internal static int CreatePivotTable( string position, Dictionary properties) { + // R12-1: detect unknown pivot property keys (including non-ASCII + // like '源'/'行名') BEFORE normalization so the warning echoes the + // original spelling. Previously these keys were silently dropped + // and users saw an empty pivot with no diagnostic. + WarnUnknownPivotProperties(CollectUnknownPivotKeys(properties)); + + // R12-2 / R12-3: normalize alias keys (row→rows, rowFields→rows, + // columngrandtotals→colgrandtotals, etc.) so every downstream + // lookup below reads from the canonical dict. `row=Cat` then + // binds to the same code path as `rows=Cat`. + properties = NormalizePivotProperties(properties); + + // Publish the axis sort mode (asc/desc/locale/locale-desc) so every + // sort site below — cache builder, pivotField items writer, per-level + // index maps, specialized renderers — reads the same comparer. + using var _sortScope = PushAxisSortMode(properties); + // CONSISTENCY(thread-static-pivot-opts): same pattern — grand totals + // options reach item builders, geometry, and every renderer via + // ActiveRowGrandTotals/ActiveColGrandTotals. + using var _gtScope = PushGrandTotalsOptions(properties); + // 1. Read source data to build cache - var (headers, columnData) = ReadSourceData(sourceSheet, sourceRef); + var (headers, columnData, columnStyleIds) = ReadSourceData(sourceSheet, sourceRef); if (headers.Length == 0) throw new ArgumentException("Source range has no data"); + // CONSISTENCY(empty-pivot-source): a header row with zero data rows + // (e.g. A1:D1) silently produces an empty pivot whose cache has no + // records — Excel opens it but renders nothing. Reject it with the + // same family of ArgumentException as the no-headers case so callers + // get a single, predictable error path. Bt#8 / fuzzer baseline. + if (columnData.Count == 0 || columnData[0].Length == 0) + throw new ArgumentException("Source range has no data rows"); + + // 1b. Date auto-grouping preprocessing. Scans rows/cols/filters props + // for `fieldName:grouping` syntax (e.g. `rows='日期:month,城市'`) and + // creates a new virtual column per grouped field containing the + // bucketed labels. The raw field spec is rewritten to reference the + // new virtual column so ParseFieldList below sees a clean name. + // + // Supported groupings: + // :year → "2024" + // :quarter → "2024-Q1" + // :month → "2024-01" + // :day → "2024-01-05" + // + // Compose multiple groupings for hierarchical date layouts: + // `rows='日期:year,日期:quarter'` → 2-level year-then-quarter. + // + // Returns a list of DateGroupSpec describing each derived field so + // BuildCacheDefinition can emit the native + + + // XML that Excel requires to accept the pivot as a + // real date-grouped table (without it, Excel detects a "fieldGroup + // shape mismatch" and refuses to render the inner hierarchy levels). + List dateGroups; + (headers, columnData, dateGroups) = ApplyDateGrouping(headers, columnData, properties); // 2. Parse field assignments from properties var rowFields = ParseFieldList(properties, "rows", headers); @@ -44,6 +628,37 @@ internal static int CreatePivotTable( var filterFields = ParseFieldList(properties, "filters", headers); var valueFields = ParseValueFields(properties, "values", headers); + // CONSISTENCY(aggregate-override / showdataas): parity with Set — + // the sibling `aggregate=` / `showdataas=` properties are positional + // comma-lists applied to the parsed value-field list so users can + // write `values=Sales showdataas=percent_of_row` and have it take + // effect at Add time, not only when re-specified via Set. R8-1. + { + string[]? aggOverrideAdd = null; + string[]? showOverrideAdd = null; + if (properties.TryGetValue("aggregate", out var aggSpecAdd) && !string.IsNullOrEmpty(aggSpecAdd)) + aggOverrideAdd = aggSpecAdd.Split(',').Select(s => s.Trim().ToLowerInvariant()).ToArray(); + if (properties.TryGetValue("showdataas", out var showSpecAdd) && !string.IsNullOrEmpty(showSpecAdd)) + showOverrideAdd = showSpecAdd.Split(',').Select(s => s.Trim().ToLowerInvariant()).ToArray(); + if (aggOverrideAdd != null || showOverrideAdd != null) + { + for (int i = 0; i < valueFields.Count; i++) + { + var (idx, func, showAs, name) = valueFields[i]; + if (aggOverrideAdd != null && i < aggOverrideAdd.Length && !string.IsNullOrEmpty(aggOverrideAdd[i])) + func = aggOverrideAdd[i]; + if (showOverrideAdd != null && i < showOverrideAdd.Length && !string.IsNullOrEmpty(showOverrideAdd[i])) + { + // Validate via ParseShowDataAs — throws on unknown/unsupported tokens, + // matching the Set path and CONSISTENCY(strict-enums). + ParseShowDataAs(showOverrideAdd[i]); + showAs = showOverrideAdd[i]; + } + valueFields[i] = (idx, func, showAs, name); + } + } + } + // Auto-assign: if no values specified, use the first numeric column if (valueFields.Count == 0) { @@ -52,12 +667,24 @@ internal static int CreatePivotTable( if (!rowFields.Contains(i) && !colFields.Contains(i) && !filterFields.Contains(i) && columnData[i].All(v => double.TryParse(v, System.Globalization.CultureInfo.InvariantCulture, out _))) { - valueFields.Add((i, "sum", $"Sum of {headers[i]}")); + valueFields.Add((i, "sum", "normal", $"Sum of {headers[i]}")); break; } } } + // 2b. Apply Top-N filter to the source rows (ranked by the first value + // field's aggregate on the outermost row field). Runs BEFORE cache + // build so the cache, rendered cells, and grand totals all reflect + // the filtered subset. See ApplyTopNFilter for semantics & limits. + if ((properties.TryGetValue("topN", out var topNStr) + || properties.TryGetValue("topn", out topNStr)) + && int.TryParse(topNStr, System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, out var topN)) + { + ApplyTopNFilter(columnData, rowFields, valueFields, topN); + } + // 3. Generate unique cache ID uint cacheId = 0; var workbook = workbookPart.Workbook @@ -66,15 +693,68 @@ internal static int CreatePivotTable( if (pivotCaches != null) cacheId = pivotCaches.Elements().Select(pc => pc.CacheId?.Value ?? 0u).DefaultIfEmpty(0u).Max() + 1; + // 3b. Collect all existing pivot names in the workbook so we can + // reject duplicates (user-supplied) or auto-increment past collisions + // (default name). Excel auto-renames on open to avoid the clash, but + // the file as written with a duplicate is confusing and breaks any + // downstream consumer keying pivots by name. R6-1. + var existingPivotNames = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var wsp in workbookPart.WorksheetParts) + { + foreach (var ptp in wsp.PivotTableParts) + { + var existingName = ptp.PivotTableDefinition?.Name?.Value; + if (!string.IsNullOrEmpty(existingName)) + existingPivotNames.Add(existingName); + } + } + // 4. Create PivotTableCacheDefinitionPart at workbook level var cachePart = workbookPart.AddNewPart(); var cacheRelId = workbookPart.GetIdOfPart(cachePart); - // Build cache definition - var cacheDef = BuildCacheDefinition(sourceSheetName, sourceRef, headers, columnData); + // Build cache definition + per-field shared-item index maps. The maps are + // needed to write pivotCacheRecords below: each non-numeric field value is + // referenced as where N is the value's position in sharedItems. + // + // Axis fields (row/col/filter) ALWAYS go through the string/indexed + // path even if their values parse as numeric. Otherwise the pivotField + // items list (which AppendFieldItems builds by index) and the cache + // records (which would emit ) disagree on what "index 0" + // means, and Excel refuses to render the row/col hierarchy. Date + // grouping's "year" bucket (values like "2024"/"2025") was the + // triggering case — the fix is to mark axis fields here. + var axisFieldSet = new HashSet(); + foreach (var r in rowFields) axisFieldSet.Add(r); + foreach (var c in colFields) axisFieldSet.Add(c); + foreach (var f in filterFields) axisFieldSet.Add(f); + var (cacheDef, fieldNumeric, fieldValueIndex) = + BuildCacheDefinition(sourceSheetName, sourceRef, headers, columnData, axisFieldSet, dateGroups); cachePart.PivotCacheDefinition = cacheDef; cachePart.PivotCacheDefinition.Save(); + // 4b. Create PivotTableCacheRecordsPart and write one record per source row. + // Without records, Excel rejects the file with "PivotTable report is invalid" + // because saveData defaults to true. Writing real records also makes the file + // self-contained for non-refreshing consumers (POI, third-party parsers). + var recordsPart = cachePart.AddNewPart(); + // Derived date-group fields (databaseField="0") must be excluded from + // pivotCacheRecords — Excel computes them from the base field's + // definition on the fly. Pass their indices so the + // record writer skips them. + var derivedFieldSet = dateGroups.Count > 0 + ? new HashSet(dateGroups.Select(g => g.DerivedFieldIdx)) + : null; + recordsPart.PivotCacheRecords = BuildCacheRecords(columnData, fieldNumeric, fieldValueIndex, derivedFieldSet); + recordsPart.PivotCacheRecords.Save(); + + // The pivotCacheDefinition element MUST carry an r:id attribute pointing to the + // records part — Excel uses it to find records, not the package _rels alone. + // LibreOffice writes this in xepivotxml.cxx:280 (FSNS(XML_r, XML_id)). Without + // this attribute the file looks structurally complete but Excel rejects it. + cacheDef.Id = cachePart.GetIdOfPart(recordsPart); + cachePart.PivotCacheDefinition.Save(); + // Register in workbook's PivotCaches if (pivotCaches == null) { @@ -89,305 +769,4343 @@ internal static int CreatePivotTable( // Link pivot table to cache definition pivotPart.AddPart(cachePart); - var pivotName = properties.GetValueOrDefault("name", $"PivotTable{cacheId + 1}"); + string pivotName; + if (properties.TryGetValue("name", out var explicitName) && !string.IsNullOrEmpty(explicitName)) + { + // R8-4 / R8-5 / R11-4 / R16-2: delegate all name validation to + // ValidatePivotName so Add and Set share identical rules. + explicitName = ValidatePivotName(explicitName); + // R6-1: user-supplied name must be unique within the workbook. + // Throw ArgumentException rather than silently allowing the + // collision (Excel would auto-rename on open, but the on-disk + // file would still carry two pivots with the same name). + if (existingPivotNames.Contains(explicitName)) + throw new ArgumentException($"Pivot name '{explicitName}' already exists in workbook"); + pivotName = explicitName; + } + else + { + // R6-1: auto-generated default names must also avoid collisions + // (two pivots on different sheets otherwise both pick + // PivotTable{cacheId+1} with the same cacheId path). + pivotName = $"PivotTable{cacheId + 1}"; + int bump = 1; + while (existingPivotNames.Contains(pivotName)) + { + bump++; + pivotName = $"PivotTable{cacheId + bump}"; + } + } var style = properties.GetValueOrDefault("style", "PivotStyleLight16"); + // Resolve per-column numFmtId from the source StyleIndex so we can stamp + // it onto DataField elements below. Excel uses DataField.NumberFormatId + // as the PRIMARY display driver for pivot values — the cell-level + // StyleIndex alone is not enough; without this, Excel renders pivot + // values as plain General-format numbers even though the rendered cells + // carry the correct style. + var columnNumFmtIds = ResolveColumnNumFmtIds(workbookPart, columnStyleIds); + var pivotDef = BuildPivotTableDefinition( pivotName, cacheId, position, headers, columnData, - rowFields, colFields, filterFields, valueFields, style); + rowFields, colFields, filterFields, valueFields, style, columnNumFmtIds, dateGroups); + // Overlay user-supplied bool attributes + // (showRowStripes, showColStripes, showRowHeaders, showColHeaders, + // showLastColumn) onto the style info element BuildPivotTableDefinition + // just created with defaults. Shared helper with the Set path so + // Add and Set accept the same vocabulary / validation. + ApplyPivotStyleInfoProps(EnsurePivotTableStyle(pivotDef), properties); pivotPart.PivotTableDefinition = pivotDef; pivotPart.PivotTableDefinition.Save(); + // 6. RENDER the pivot output into the target sheet's . + // + // This is the critical step that distinguishes a "valid pivot file Excel + // accepts" from a "pivot file Excel actually displays". Excel does NOT + // recompute pivots from cache on open — it reads the rendered cells + // directly from sheetData, exactly like any other range. We verified this + // by inspecting an Excel-authored sample (excel_authored.xlsx → sheet2.xml): + // every aggregated cell is a literal 200 element. + // + // Without this step the pivot opens as an empty drop-down skeleton — the + // structure is valid but there is nothing to display. POI / Open XML SDK + // suffer from exactly the same limitation; this is the lift that turns + // officecli into a real pivot writer rather than a definition-only one. + // + // For unsupported configurations (multiple row/col fields, multiple data + // fields, page filters), the renderer falls back to writing nothing, which + // gives Excel an empty sheetData and the same skeleton-only behavior. + // Those configs are tracked as a v2 expansion. + RenderPivotIntoSheet( + targetSheet, position, headers, columnData, + rowFields, colFields, valueFields, filterFields, columnStyleIds); + // Return 1-based index return targetSheet.PivotTableParts.ToList().IndexOf(pivotPart) + 1; } - // ==================== Source Data Reader ==================== + // ==================== Axis Tree (general N-level row/col abstraction) ==================== + // + // For N≥3 row or col fields the existing specialized renderers (1×1, 2×1, + // 1×2, 2×2 with K data variants) cannot be extended without an N² explosion + // in case count. The AxisTree abstraction below replaces them with a single + // recursive tree representation: + // + // - The root has one child per unique value of the FIRST (outermost) field + // - Each level-L node has one child per unique value of the (L+1)-th field + // that appears in the source data PAIRED WITH the parent's path + // - Leaves are at depth N (i.e. path length = N field values) + // + // Example for rows=[地区, 城市, 区]: + // root + // ├── 华东 + // │ ├── 上海 + // │ │ ├── 浦东 + // │ │ └── 徐汇 + // │ └── 杭州 + // │ └── 西湖 + // └── 华北 + // └── 北京 + // ├── 朝阳 + // └── 海淀 + // + // Walk order produces (in display sequence): outer subtotals at internal + // nodes + leaf rows at leaves + grand total at the very end. For 2D pivots + // both row and col axes use independent AxisTrees and the renderer walks + // them in lockstep. + // + // This abstraction is currently used ONLY for N≥3 cases via the dispatch in + // RenderPivotIntoSheet. The 8 existing N≤2 cases continue to use their + // specialized renderers (regression-tested via test-samples/pivot_baselines). - private static (string[] headers, List columnData) ReadSourceData( - WorksheetPart sourceSheet, string sourceRef) + /// + /// One node in the axis tree. Represents either an internal node (subtotal + /// row/col) or a leaf node (specific data row/col). Children are sorted in + /// ordinal display order to keep rowItems/colItems indices consistent with + /// the corresponding pivotField items list. + /// + private sealed class AxisNode { - var ws = sourceSheet.Worksheet ?? throw new InvalidOperationException("Worksheet missing"); - var sheetData = ws.GetFirstChild(); - if (sheetData == null) return (Array.Empty(), new List()); - - // Parse range "A1:D100" - var parts = sourceRef.Replace("$", "").Split(':'); - if (parts.Length != 2) throw new ArgumentException($"Invalid source range: {sourceRef}"); + /// The label for this node (e.g. "华东"). Empty string for the root. + public string Label { get; } + /// 0 = root, 1 = outermost field, 2 = next inner, ..., N = leaf level. + public int Depth { get; } + /// Path from root: [outerVal, ..., this.Label]. Length == Depth. + public string[] Path { get; } + /// Child nodes in ordinal display order. Empty for leaves. + public List Children { get; } = new(); - var (startCol, startRow) = ParseCellRef(parts[0]); - var (endCol, endRow) = ParseCellRef(parts[1]); + public AxisNode(string label, int depth, string[] path) + { + Label = label; + Depth = depth; + Path = path; + } - var startColIdx = ColToIndex(startCol); - var endColIdx = ColToIndex(endCol); - var colCount = endColIdx - startColIdx + 1; + public bool IsLeaf => Children.Count == 0; + } - // Read all rows in range - var rows = new List(); - var sst = sourceSheet.OpenXmlPackage is SpreadsheetDocument doc - ? doc.WorkbookPart?.GetPartsOfType().FirstOrDefault() - : null; + /// + /// Build an AxisTree from columnData given the field indices for an axis. + /// Only paths that actually appear in the source data are included — Excel + /// does not enumerate empty cartesian intersections at any level. + /// + private static AxisNode BuildAxisTree(List fieldIndices, List columnData) + { + var root = new AxisNode(string.Empty, 0, Array.Empty()); + if (fieldIndices.Count == 0 || columnData.Count == 0) + return root; - foreach (var row in sheetData.Elements()) + var rowCount = columnData[fieldIndices[0]].Length; + // For each source row, walk down the tree, creating child nodes as needed. + for (int r = 0; r < rowCount; r++) { - var rowIdx = (int)(row.RowIndex?.Value ?? 0); - if (rowIdx < startRow || rowIdx > endRow) continue; + var current = root; + var validPath = true; + var path = new string[fieldIndices.Count]; - var values = new string[colCount]; - foreach (var cell in row.Elements()) + for (int level = 0; level < fieldIndices.Count; level++) { - var cellRef = cell.CellReference?.Value ?? ""; - var (cn, _) = ParseCellRef(cellRef); - var ci = ColToIndex(cn) - startColIdx; - if (ci < 0 || ci >= colCount) continue; + var fieldIdx = fieldIndices[level]; + if (fieldIdx < 0 || fieldIdx >= columnData.Count) { validPath = false; break; } + var values = columnData[fieldIdx]; + if (r >= values.Length) { validPath = false; break; } + var v = values[r]; + if (string.IsNullOrEmpty(v)) { validPath = false; break; } + path[level] = v; - values[ci] = GetCellText(cell, sst); + // Find or create child for this value at this level. + var child = current.Children.FirstOrDefault(c => c.Label == v); + if (child == null) + { + var childPath = new string[level + 1]; + Array.Copy(path, childPath, level + 1); + child = new AxisNode(v, level + 1, childPath); + current.Children.Add(child); + } + current = child; } - rows.Add(values); - } - if (rows.Count == 0) return (Array.Empty(), new List()); - - // First row = headers (ensure no nulls) - var headers = rows[0].Select(h => h ?? "").ToArray(); - // Remaining rows = data, transposed to column-major for cache - var columnDataList = new List(); - for (int c = 0; c < colCount; c++) - { - var colVals = new string[rows.Count - 1]; - for (int r = 1; r < rows.Count; r++) - colVals[r - 1] = rows[r][c] ?? ""; - columnDataList.Add(colVals); + // Drop the row entirely if any field had an empty value — matches the + // "skip rows with missing values" semantics of the specialized renderers. + _ = validPath; } - return (headers, columnDataList); + // Sort children at every level using the same StringComparer.Ordinal that + // BuildOuterInnerGroups and AppendFieldItems use, so the rowItems indices + // line up with the pivotField items list. + SortAxisTreeRecursive(root); + return root; } - private static string GetCellText(Cell cell, SharedStringTablePart? sst) + private static void SortAxisTreeRecursive(AxisNode node) { - // Handle InlineString cells (t="inlineStr") — used by openpyxl and some other tools - if (cell.DataType?.Value == CellValues.InlineString) - return cell.InlineString?.InnerText ?? ""; + var cmp = ActiveAxisComparer; + var sign = ActiveAxisDescending ? -1 : 1; + node.Children.Sort((a, b) => sign * cmp.Compare(a.Label, b.Label)); + foreach (var c in node.Children) SortAxisTreeRecursive(c); + } - var value = cell.CellValue?.Text ?? ""; - if (cell.DataType?.Value == CellValues.SharedString && sst?.SharedStringTable != null) + /// + /// Walk the tree in display order, yielding each node alongside whether it's + /// a subtotal (internal) or a leaf, plus its absolute display row/col index + /// (relative to the start of the data area). + /// + /// Display order for row axis is "pre-order": for each internal node, emit + /// the subtotal row first, then recurse into children. The order matches + /// what BuildMultiRowItems already produces for N=2 and what Excel writes + /// for N≥3 in compact mode. + /// + /// For col axis it's the same plus an additional subtotal column AFTER the + /// children of each internal node — Excel writes the col subtotal column + /// to the right of the inner cols, not to the left like the row subtotal. + /// + private static IEnumerable<(AxisNode node, bool isLeaf, bool isSubtotal)> WalkAxisTree( + AxisNode root, bool isCol) + { + // Skip the synthetic root, walk its children in order. + foreach (var child in root.Children) + foreach (var entry in WalkAxisTreeRecursive(child, isCol)) + yield return entry; + } + + private static IEnumerable<(AxisNode node, bool isLeaf, bool isSubtotal)> WalkAxisTreeRecursive( + AxisNode node, bool isCol) + { + if (node.IsLeaf) { - if (int.TryParse(value, out int idx)) - { - var item = sst.SharedStringTable.Elements().ElementAtOrDefault(idx); - return item?.InnerText ?? value; - } + yield return (node, true, false); + yield break; } - return value; - } - // ==================== Cache Definition Builder ==================== + // Row axis convention: outer subtotal row appears BEFORE the children. + // Col axis convention: outer subtotal col appears AFTER the children + // (matches multi_col_authored.xlsx ground truth). + if (!isCol) + yield return (node, false, true); - private static PivotCacheDefinition BuildCacheDefinition( - string sourceSheetName, string sourceRef, - string[] headers, List columnData) - { - var recordCount = columnData.Count > 0 ? columnData[0].Length : 0; + foreach (var child in node.Children) + foreach (var entry in WalkAxisTreeRecursive(child, isCol)) + yield return entry; - var cacheDef = new PivotCacheDefinition - { - CreatedVersion = 3, - MinRefreshableVersion = 3, - RefreshedVersion = 3, - RecordCount = (uint)recordCount - }; + if (isCol) + yield return (node, false, true); + } - // CacheSource -> WorksheetSource - var cacheSource = new CacheSource { Type = SourceValues.Worksheet }; - cacheSource.AppendChild(new WorksheetSource + /// Count all internal nodes (subtotal positions) in a tree. + private static int CountSubtotalNodes(AxisNode root) + { + int count = 0; + void Recurse(AxisNode n) { - Reference = sourceRef, - Sheet = sourceSheetName - }); - cacheDef.AppendChild(cacheSource); + if (!n.IsLeaf && n.Depth > 0) count++; + foreach (var c in n.Children) Recurse(c); + } + Recurse(root); + return count; + } - // CacheFields - var cacheFields = new CacheFields { Count = (uint)headers.Length }; - for (int i = 0; i < headers.Length; i++) + /// Count all leaf nodes in a tree. + private static int CountLeafNodes(AxisNode root) + { + int count = 0; + void Recurse(AxisNode n) { - var fieldName = string.IsNullOrEmpty(headers[i]) ? $"Column{i + 1}" : headers[i]; - var values = i < columnData.Count ? columnData[i] : Array.Empty(); - cacheFields.AppendChild(BuildCacheField(fieldName, values)); + if (n.IsLeaf && n.Depth > 0) count++; + else foreach (var c in n.Children) Recurse(c); } - cacheDef.AppendChild(cacheFields); - - return cacheDef; + Recurse(root); + return count; } - private static CacheField BuildCacheField(string name, string[] values) + // ==================== Geometry & Cache Readback Helpers ==================== + + /// Computed pivot table extent — anchor + bounding range + key offsets. + private readonly struct PivotGeometry { - var field = new CacheField { Name = name, NumberFormatId = 0u }; - var uniqueValues = values.Distinct().OrderBy(v => v).ToList(); - var allNumeric = values.Length > 0 && values.All(v => - string.IsNullOrEmpty(v) || double.TryParse(v, System.Globalization.CultureInfo.InvariantCulture, out _)); + public PivotGeometry(int anchorCol, int anchorRow, int width, int height, int rowLabelCols, string rangeRef) + { + AnchorCol = anchorCol; + AnchorRow = anchorRow; + Width = width; + Height = height; + RowLabelCols = rowLabelCols; + RangeRef = rangeRef; + } + public int AnchorCol { get; } + public int AnchorRow { get; } + public int Width { get; } + public int Height { get; } + public int RowLabelCols { get; } + public string RangeRef { get; } + } + + /// + /// Compute the bounding range and row-label column count for a pivot at the + /// given anchor with the given field assignments. Used by both initial creation + /// (BuildPivotTableDefinition) and post-Set rebuild (RebuildFieldAreas) so the + /// two paths agree on layout. + /// + /// Layout assumes the standard compact/outline mode with: + /// width = max(1, rowFieldCount) // row labels + /// + max(1, colUnique) * max(1, valueCount) // data cells + /// + (colFieldCount > 0 ? 1 : 0) // grand total column + /// height = (colFieldCount > 0 ? 2 : 1) // header rows + /// + max(1, rowUnique) // data rows + /// + 1 // grand total row + /// Page filter rows are excluded from the range per ECMA-376. + /// + private static PivotGeometry ComputePivotGeometry( + string position, List columnData, + List rowFieldIndices, List colFieldIndices, + List<(int idx, string func, string showAs, string name)> valueFields) + { + int dataFieldCount = Math.Max(1, valueFields.Count); + int rowLabelCols = 1; // Compact mode - var sharedItems = new SharedItems { Count = (uint)uniqueValues.Count }; + int valueCols, totalCols, dataRowCount, headerRows; - if (allNumeric && values.Any(v => !string.IsNullOrEmpty(v))) + // N≥3 on either axis: use AxisTree for both width and height counts. + // N≤2: keep the existing specialized formulas (regression-tested). + if (rowFieldIndices.Count >= 3 || colFieldIndices.Count >= 3) { - // Numeric field — set metadata but don't enumerate all values - var nums = values.Where(v => !string.IsNullOrEmpty(v)) - .Select(v => double.Parse(v, System.Globalization.CultureInfo.InvariantCulture)).ToArray(); - sharedItems.ContainsSemiMixedTypes = false; - sharedItems.ContainsString = false; - sharedItems.ContainsNumber = true; - sharedItems.MinValue = nums.Min(); - sharedItems.MaxValue = nums.Max(); - sharedItems.Count = 0; + var rowTree = BuildAxisTree(rowFieldIndices, columnData); + var colTree = BuildAxisTree(colFieldIndices, columnData); + + // Display row count = subtotal positions + leaf positions + // (the grand total row is added separately below). + int rowSubtotals = CountSubtotalNodes(rowTree); + int rowLeaves = CountLeafNodes(rowTree); + dataRowCount = rowSubtotals + rowLeaves; + + int colSubtotals = CountSubtotalNodes(colTree); + int colLeaves = CountLeafNodes(colTree); + // Per col position: K cells. Plus K grand totals. + valueCols = (colSubtotals + colLeaves) * dataFieldCount; + totalCols = dataFieldCount; + + // Header rows: 1 caption + N_col field-label rows + (K>1 ? 1 : 0). + headerRows = 1 + Math.Max(1, colFieldIndices.Count) + (dataFieldCount > 1 ? 1 : 0); + } + else if (colFieldIndices.Count >= 2) + { + var groups = BuildOuterInnerGroups( + colFieldIndices[0], colFieldIndices[1], columnData); + valueCols = groups.Sum(g => (g.inners.Count + 1) * dataFieldCount); + totalCols = dataFieldCount; + + if (rowFieldIndices.Count >= 2) + { + var rowGroups = BuildOuterInnerGroups( + rowFieldIndices[0], rowFieldIndices[1], columnData); + dataRowCount = rowGroups.Sum(g => 1 + g.inners.Count); + } + else + { + dataRowCount = Math.Max(1, ProductOfUniqueValues(rowFieldIndices, columnData)); + } + headerRows = dataFieldCount > 1 ? 4 : 3; + } + else + { + int colUnique = ProductOfUniqueValues(colFieldIndices, columnData); + valueCols = Math.Max(1, colUnique) * dataFieldCount; + totalCols = colFieldIndices.Count > 0 ? dataFieldCount : 0; + + if (rowFieldIndices.Count >= 2) + { + var rowGroups = BuildOuterInnerGroups( + rowFieldIndices[0], rowFieldIndices[1], columnData); + dataRowCount = rowGroups.Sum(g => 1 + g.inners.Count); + } + else + { + dataRowCount = Math.Max(1, ProductOfUniqueValues(rowFieldIndices, columnData)); + } + + if (colFieldIndices.Count > 0) + headerRows = dataFieldCount > 1 ? 3 : 2; + else + // No col fields: renderer always writes 2 header rows (caption + col-label), + // plus an extra data-field name row when there are multiple value fields. + headerRows = dataFieldCount > 1 ? 3 : 2; + } + + // Grand-totals toggles: + // rowGrandTotals=false → no rightmost grand-total COLUMN → drop totalCols + // colGrandTotals=false → no bottom grand-total ROW → drop the +1 in height + if (!ActiveRowGrandTotals) totalCols = 0; + int grandRowHeight = ActiveColGrandTotals ? 1 : 0; + + int width = rowLabelCols + valueCols + totalCols; + int height = headerRows + dataRowCount + grandRowHeight; + + var (anchorCol, anchorRow) = ParseCellRef(position); + var anchorColIdx = ColToIndex(anchorCol); + var endColIdx = anchorColIdx + width - 1; + var endRow = anchorRow + height - 1; + var rangeRef = $"{position}:{IndexToCol(endColIdx)}{endRow}"; + + return new PivotGeometry(anchorColIdx, anchorRow, width, height, rowLabelCols, rangeRef); + } + + /// + /// Reconstruct the per-field columnData from the cache definition + records. + /// Used by RebuildFieldAreas after Set: the source sheet may not be readily + /// reachable, but the cache holds the original values (string fields via + /// sharedItems index, numeric fields directly in <n v=...>). This makes + /// the rebuild self-contained on the cache part alone. + /// + private static (string[] headers, List columnData) ReadColumnDataFromCache( + PivotCacheDefinition cacheDef, PivotCacheRecords? records) + { + var cacheFields = cacheDef.GetFirstChild(); + if (cacheFields == null) return (Array.Empty(), new List()); + + var fieldList = cacheFields.Elements().ToList(); + var headers = fieldList.Select(cf => cf.Name?.Value ?? "").ToArray(); + var fieldCount = fieldList.Count; + + // Pre-resolve each field's sharedItems string lookup table (index → text). + // Numeric fields without enumerated items leave the table empty; their + // values come straight from in the records below. + var perFieldStrings = new List>(fieldCount); + for (int f = 0; f < fieldCount; f++) + { + var items = fieldList[f].GetFirstChild(); + var list = new List(); + if (items != null) + { + foreach (var child in items.ChildElements) + { + list.Add(child switch + { + StringItem s => s.Val?.Value ?? string.Empty, + NumberItem n => n.Val?.Value.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty, + DateTimeItem d => d.Val?.Value.ToString("yyyy-MM-dd") ?? string.Empty, + BooleanItem b => b.Val?.Value == true ? "true" : "false", + _ => string.Empty + }); + } + } + perFieldStrings.Add(list); + } + + var recordList = records?.Elements().ToList() ?? new List(); + var columnData = new List(fieldCount); + for (int f = 0; f < fieldCount; f++) + columnData.Add(new string[recordList.Count]); + + for (int r = 0; r < recordList.Count; r++) + { + var record = recordList[r]; + var children = record.ChildElements.ToList(); + for (int f = 0; f < fieldCount && f < children.Count; f++) + { + columnData[f][r] = children[f] switch + { + FieldItem fi when fi.Val?.Value is uint idx + && idx < perFieldStrings[f].Count + => perFieldStrings[f][(int)idx], + NumberItem n => n.Val?.Value.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty, + StringItem s => s.Val?.Value ?? string.Empty, + DateTimeItem d => d.Val?.Value.ToString("yyyy-MM-dd") ?? string.Empty, + BooleanItem b => b.Val?.Value == true ? "true" : "false", + _ => string.Empty + }; + } + } + + return (headers, columnData); + } + + /// + /// Remove every cell in sheetData that falls inside the given pivot range. + /// Called before re-rendering so stale cells from the previous pivot layout + /// (e.g. row totals from a wider configuration) do not leak through. + /// Also called by ExcelHandler.Remove to clean up rendered cells when a pivot is deleted. + /// + internal static void ClearPivotRangeCells(SheetData sheetData, string rangeRef) + { + var parts = rangeRef.Split(':'); + if (parts.Length != 2) return; + var (startCol, startRow) = ParseCellRef(parts[0]); + var (endCol, endRow) = ParseCellRef(parts[1]); + var startColIdx = ColToIndex(startCol); + var endColIdx = ColToIndex(endCol); + + var rowsToRemove = new List(); + foreach (var row in sheetData.Elements()) + { + var rIdx = (int)(row.RowIndex?.Value ?? 0); + if (rIdx < startRow || rIdx > endRow) continue; + + var cellsToRemove = row.Elements() + .Where(c => + { + var cref = c.CellReference?.Value ?? ""; + var (cc, _) = ParseCellRef(cref); + var ci = ColToIndex(cc); + return ci >= startColIdx && ci <= endColIdx; + }) + .ToList(); + foreach (var c in cellsToRemove) c.Remove(); + + // If the row is now empty AND was entirely inside the pivot, drop it + // entirely so we don't leave stray elements behind. + if (!row.Elements().Any()) + rowsToRemove.Add(row); + } + foreach (var r in rowsToRemove) r.Remove(); + } + + // ==================== Pivot Output Renderer ==================== + + /// + /// Compute the pivot's aggregation matrix from columnData and write the + /// rendered cells into targetSheet's SheetData. Mirrors what real Excel writes + /// on save: literal cells with computed values, NOT a definition that Excel + /// recomputes on open. + /// + /// Supported (v1): exactly 1 row field × 1 col field × 1 data field, with + /// aggregator in {sum, count, average, min, max}, plus row/column/grand totals. + /// Other configurations leave sheetData empty and emit a stderr warning so + /// the file still validates and opens, just without rendered data. + /// + /// Layout (verified against Excel-authored sample): + /// Row 0: [data caption] [col field caption] + /// Row 1: [row field caption] [col label 1] [col label 2] ... [总计] + /// Row 2: [row label 1] [v] [v] [row total 1] + /// ... + /// Row N: [总计] [col total 1] [col total 2] ... [grand total] + /// + private static void RenderPivotIntoSheet( + WorksheetPart targetSheet, string position, + string[] headers, List columnData, + List rowFieldIndices, List colFieldIndices, + List<(int idx, string func, string showAs, string name)> valueFields, + List? filterFieldIndices = null, + uint?[]? columnStyleIds = null) + { + // Per-data-field style index: pivot value cells for data field d inherit + // the source column's StyleIndex (number format). A null entry means the + // source cell had no explicit style → pivot cell stays General. + int dataFieldCount = Math.Max(1, valueFields.Count); + var valueStyleIds = new uint?[dataFieldCount]; + if (columnStyleIds != null) + { + for (int d = 0; d < valueFields.Count; d++) + { + var srcIdx = valueFields[d].idx; + if (srcIdx >= 0 && srcIdx < columnStyleIds.Length) + valueStyleIds[d] = columnStyleIds[srcIdx]; + } + } + + // v3 limits: dispatch based on field-count combinations. + // 1 row × 1 col × K data → single-row K-data renderer below + // 2 row × 1 col × 1 data → multi-row renderer (RenderMultiRowPivot) + // 1 row × 2 col × 1 data → multi-col renderer (RenderMultiColPivot) + // Other combinations fall back to empty skeleton with a warning. + // N≥3 row or col fields → general tree-based renderer (handles arbitrary depth). + // N≤2 cases continue to use the specialized renderers below for byte-level + // backward compatibility (regression-tested via test-samples/pivot_baselines). + if (rowFieldIndices.Count >= 3 || colFieldIndices.Count >= 3) + { + // CONSISTENCY(no-values-noop): RenderGeneralPivot dereferences + // valueFields[0] for the data column anchor and crashes when the + // user has moved every field to an axis (no values left). Skip + // rendering — the pivotDef + cache survive so a subsequent Set + // re-adds values cleanly. + if (valueFields.Count == 0) + { + Console.Error.WriteLine( + "WARNING: pivot has no value fields; skipping cell render. " + + "Add a value field to materialize the table."); + return; + } + RenderGeneralPivot(targetSheet, position, headers, columnData, + rowFieldIndices, colFieldIndices, valueFields, filterFieldIndices, valueStyleIds); + return; + } + + if (rowFieldIndices.Count == 2 && colFieldIndices.Count == 2 && valueFields.Count >= 1) + { + RenderMatrixPivot(targetSheet, position, headers, columnData, + rowFieldIndices, colFieldIndices, valueFields, filterFieldIndices, valueStyleIds); + return; + } + if (rowFieldIndices.Count == 2 && colFieldIndices.Count == 1 && valueFields.Count >= 1) + { + RenderMultiRowPivot(targetSheet, position, headers, columnData, + rowFieldIndices, colFieldIndices, valueFields, filterFieldIndices, valueStyleIds); + return; + } + if (rowFieldIndices.Count == 1 && colFieldIndices.Count == 2 && valueFields.Count >= 1) + { + RenderMultiColPivot(targetSheet, position, headers, columnData, + rowFieldIndices, colFieldIndices, valueFields, filterFieldIndices, valueStyleIds); + return; + } + + // Accept 1×1×K AND 1×0×K (rows-only). The 1×0 layout collapses the + // column axis to a single synthetic bucket so the same matrix code + // below produces one data column ("Total " / value name) plus + // the rightmost grand-total column. + bool rowsOnly = rowFieldIndices.Count == 1 && colFieldIndices.Count == 0 && valueFields.Count >= 1; + if (!rowsOnly && (rowFieldIndices.Count != 1 || colFieldIndices.Count != 1 || valueFields.Count < 1)) + { + Console.Error.WriteLine( + "WARNING: pivot rendering currently supports 1×0×K, 1×1×K, 2×1×1, or 1×2×1 field combinations. " + + "The file will open but the pivot will appear empty. " + + "Use Excel's Refresh button to populate it manually."); + return; + } + + var rowFieldIdx = rowFieldIndices[0]; + var colFieldIdx = rowsOnly ? -1 : colFieldIndices[0]; + var rowFieldName = headers[rowFieldIdx]; + // CONSISTENCY(rows-only-pivot): no col field → use empty caption so + // the layout collapses cleanly. The K-column header path uses the + // value field name as the only visible column label. + var colFieldName = rowsOnly ? "" : headers[colFieldIdx]; + int K = valueFields.Count; + + var rowValues = columnData[rowFieldIdx]; + // Synthetic single-bucket col axis for rows-only: every source row + // collapses into one column so Reduce/Aggregate machinery below stays + // structurally identical to the 1×1×K path. + var colValues = rowsOnly ? new string[rowValues.Length] : columnData[colFieldIdx]; + if (rowsOnly) + { + for (int i = 0; i < colValues.Length; i++) colValues[i] = "__total__"; + } + + // Unique row/col labels in cache order (alphabetical ordinal). + var uniqueRows = rowValues.Where(v => !string.IsNullOrEmpty(v)).Distinct() + .OrderByAxis(v => v).ToList(); + var uniqueCols = colValues.Where(v => !string.IsNullOrEmpty(v)).Distinct() + .OrderByAxis(v => v).ToList(); + + // Bucket source values per (rowLabel, colLabel, dataFieldIdx) so each data + // field is aggregated independently. The aggregator function differs per + // data field (sum/count/avg/...) so each bucket carries its own reducer. + // Two data fields on the same source column are common (e.g. sum + count + // of 金额) and produce two independent buckets keyed by their dataFieldIdx + // in valueFields. + var perBucket = new Dictionary<(string r, string c, int d), List>(); + var perDataField = new List>(); + for (int d = 0; d < K; d++) perDataField.Add(new List()); + + for (int i = 0; i < rowValues.Length; i++) + { + var rv = rowValues.Length > i ? rowValues[i] : null; + var cv = colValues.Length > i ? colValues[i] : null; + if (string.IsNullOrEmpty(rv) || string.IsNullOrEmpty(cv)) continue; + + for (int d = 0; d < K; d++) + { + var dataIdx = valueFields[d].idx; + var dataValues = columnData[dataIdx]; + if (i >= dataValues.Length) continue; + if (!double.TryParse(dataValues[i], System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var num)) continue; + + var key = (rv, cv, d); + if (!perBucket.TryGetValue(key, out var list)) + { + list = new List(); + perBucket[key] = list; + } + list.Add(num); + perDataField[d].Add(num); + } + } + + double Reduce(IEnumerable values, string func) => ReducePivotValues(values, func); + + // Compute the K-deep cell matrix + row/col/grand totals per data field. + // matrix[r, c, d] = reduce(values for row r, col c, data field d) + // rowTotals[r, d], colTotals[c, d], grandTotals[d] follow the same shape. + var matrix = new double?[uniqueRows.Count, uniqueCols.Count, K]; + var rowTotals = new double[uniqueRows.Count, K]; + var colTotals = new double[uniqueCols.Count, K]; + var grandTotals = new double[K]; + for (int d = 0; d < K; d++) + { + var func = valueFields[d].func; + for (int r = 0; r < uniqueRows.Count; r++) + { + var rowAll = new List(); + for (int c = 0; c < uniqueCols.Count; c++) + { + if (perBucket.TryGetValue((uniqueRows[r], uniqueCols[c], d), out var bucket) && bucket.Count > 0) + { + matrix[r, c, d] = Reduce(bucket, func); + rowAll.AddRange(bucket); + } + } + rowTotals[r, d] = Reduce(rowAll, func); + } + for (int c = 0; c < uniqueCols.Count; c++) + { + var colAll = new List(); + for (int r = 0; r < uniqueRows.Count; r++) + { + if (perBucket.TryGetValue((uniqueRows[r], uniqueCols[c], d), out var bucket)) + colAll.AddRange(bucket); + } + colTotals[c, d] = Reduce(colAll, func); + } + grandTotals[d] = Reduce(perDataField[d], func); + } + + // showDataAs post-processing: transform raw aggregates into ratio / + // running-total forms before they hit sheetData. Done per data field + // so sum + percent_of_total can coexist in the same pivot. Cell values + // for a data field are normalized against the corresponding total, + // matching Excel's Show Values As semantics. See ParseShowDataAs for + // the supported mode strings. + // + // Row/col/grand totals are transformed alongside the matrix so the + // rendered totals stay consistent with the transformed data cells + // (e.g. under percent_of_total, the grand total becomes 1.0). + for (int d = 0; d < K; d++) + { + var mode = valueFields[d].showAs; + ApplyShowDataAs1x1(mode, matrix, rowTotals, colTotals, grandTotals, uniqueRows.Count, uniqueCols.Count, d); + } + + // ===== Write cells ===== + // For K=1, layout is 2 header rows: caption + col labels. + // For K>1, layout is 3 header rows: caption + col labels + per-data-field + // names repeated under each col label group. This matches the Excel sample + // multi_data_authored.xlsx exactly. + var (anchorCol, anchorRow) = ParseCellRef(position); + var anchorColIdx = ColToIndex(anchorCol); + var totalColLabel = "总计"; + + var ws = targetSheet.Worksheet + ?? throw new InvalidOperationException("Target worksheet has no Worksheet element"); + var sheetData = ws.GetFirstChild(); + if (sheetData == null) + { + sheetData = new SheetData(); + ws.AppendChild(sheetData); + } + + // ----- Row 0 (caption row) ----- + // Single data field: data field name in row-label col, col field name in first data col. + // Multi data field: empty in row-label col, col field name (or "Values" placeholder) in first data col. + var captionRow = new Row { RowIndex = (uint)anchorRow }; + if (K == 1) + captionRow.AppendChild(MakeStringCell(anchorColIdx, anchorRow, valueFields[0].name)); + captionRow.AppendChild(MakeStringCell(anchorColIdx + 1, anchorRow, colFieldName)); + sheetData.AppendChild(captionRow); + + // ----- Row 1 (col label row) ----- + // K=1: row field caption + col labels + grand total label + // K>1: empty row-label cell + col labels at first col of each K-group + grand total labels + var colLabelRowIdx = anchorRow + 1; + var colLabelRow = new Row { RowIndex = (uint)colLabelRowIdx }; + if (K == 1) + { + colLabelRow.AppendChild(MakeStringCell(anchorColIdx, colLabelRowIdx, rowFieldName)); + for (int c = 0; c < uniqueCols.Count; c++) + { + // Rows-only: the synthetic "__total__" bucket is invisible; show + // the value field name as the single data column header. + var label = rowsOnly ? valueFields[0].name : uniqueCols[c]; + colLabelRow.AppendChild(MakeStringCell(anchorColIdx + 1 + c, colLabelRowIdx, label)); + } + // CONSISTENCY(grand-totals): rowGrandTotals=false drops the rightmost + // 总计 column entirely — header label, per-row totals, and the grand + // total row's rightmost cells all gated on ActiveRowGrandTotals. + // For rows-only the only data column already IS the value's grand + // total, so we suppress the duplicate trailing 总计 column. + if (ActiveRowGrandTotals && !rowsOnly) + colLabelRow.AppendChild(MakeStringCell(anchorColIdx + 1 + uniqueCols.Count, colLabelRowIdx, totalColLabel)); + } + else + { + // First col of each K-group gets the col label; the K-1 cells after are + // visually spanned in Excel's renderer but we leave them empty in + // sheetData (Excel handles the visual span via colItems metadata). + for (int c = 0; c < uniqueCols.Count; c++) + { + int colStart = anchorColIdx + 1 + c * K; + colLabelRow.AppendChild(MakeStringCell(colStart, colLabelRowIdx, uniqueCols[c])); + } + // Grand total area: K cells, one per data field, labeled "Total " + if (ActiveRowGrandTotals) + { + int totalStart = anchorColIdx + 1 + uniqueCols.Count * K; + for (int d = 0; d < K; d++) + colLabelRow.AppendChild(MakeStringCell(totalStart + d, colLabelRowIdx, "Total " + valueFields[d].name)); + } + } + sheetData.AppendChild(colLabelRow); + + // ----- Row 2 (data field name row, only when K>1) ----- + int firstDataRow; + if (K > 1) + { + var dfNameRowIdx = anchorRow + 2; + var dfNameRow = new Row { RowIndex = (uint)dfNameRowIdx }; + // row label column gets the row field name + dfNameRow.AppendChild(MakeStringCell(anchorColIdx, dfNameRowIdx, rowFieldName)); + // Repeat data field names under each col label group + for (int c = 0; c < uniqueCols.Count; c++) + { + for (int d = 0; d < K; d++) + { + int colIdx = anchorColIdx + 1 + c * K + d; + dfNameRow.AppendChild(MakeStringCell(colIdx, dfNameRowIdx, valueFields[d].name)); + } + } + // No data field names under the grand total cols — row 1 already + // labeled them with "Total " so they are self-describing. + sheetData.AppendChild(dfNameRow); + firstDataRow = anchorRow + 3; + } + else + { + firstDataRow = anchorRow + 2; + } + + // ----- Data rows ----- + for (int r = 0; r < uniqueRows.Count; r++) + { + var rowIdx = firstDataRow + r; + var dataRow = new Row { RowIndex = (uint)rowIdx }; + dataRow.AppendChild(MakeStringCell(anchorColIdx, rowIdx, uniqueRows[r])); + for (int c = 0; c < uniqueCols.Count; c++) + { + for (int d = 0; d < K; d++) + { + int colIdx = anchorColIdx + 1 + c * K + d; + var v = matrix[r, c, d]; + if (v.HasValue) + dataRow.AppendChild(MakeNumericCell(colIdx, rowIdx, v.Value, valueStyleIds[d])); + } + } + // Row totals — K cells (one per data field). + // CONSISTENCY(grand-totals): gated on ActiveRowGrandTotals so the + // rightmost 总计 column disappears entirely when grandTotals=none|cols. + // Rows-only: the K data cells already ARE the row totals (single + // synthetic col bucket), so the trailing duplicate is omitted. + if (ActiveRowGrandTotals && !rowsOnly) + { + int rowTotalStart = anchorColIdx + 1 + uniqueCols.Count * K; + for (int d = 0; d < K; d++) + dataRow.AppendChild(MakeNumericCell(rowTotalStart + d, rowIdx, rowTotals[r, d], valueStyleIds[d])); + } + sheetData.AppendChild(dataRow); + } + + // ----- Grand total row ----- + // CONSISTENCY(grand-totals): the entire bottom 总计 row is omitted + // when ActiveColGrandTotals is false (grandTotals=none|rows). The + // rightmost cells inside the row are independently gated on + // ActiveRowGrandTotals so grandTotals=cols still renders the bottom + // row but without the trailing K row-grand cells. + if (ActiveColGrandTotals) + { + var grandRowIdx = firstDataRow + uniqueRows.Count; + var grandRow = new Row { RowIndex = (uint)grandRowIdx }; + grandRow.AppendChild(MakeStringCell(anchorColIdx, grandRowIdx, totalColLabel)); + for (int c = 0; c < uniqueCols.Count; c++) + { + for (int d = 0; d < K; d++) + { + int colIdx = anchorColIdx + 1 + c * K + d; + grandRow.AppendChild(MakeNumericCell(colIdx, grandRowIdx, colTotals[c, d], valueStyleIds[d])); + } + } + if (ActiveRowGrandTotals && !rowsOnly) + { + int grandTotalStart = anchorColIdx + 1 + uniqueCols.Count * K; + for (int d = 0; d < K; d++) + grandRow.AppendChild(MakeNumericCell(grandTotalStart + d, grandRowIdx, grandTotals[d], valueStyleIds[d])); + } + sheetData.AppendChild(grandRow); + } + + // Page filter cells: rendered ABOVE the table at rows + // (anchorRow - filterCount - 1) ... (anchorRow - 2). One row per filter + // field, with field name in the row-label column and "(All)" in the + // adjacent data column. Row (anchorRow - 1) is left empty as a visual gap. + // + // Page filters are NOT inside per ECMA-376; they are + // separate visual cells whose presence is signalled by the rowPageCount / + // colPageCount attributes on pivotTableDefinition (already set in + // BuildPivotTableDefinition). Excel pairs the filter cells with the pivot + // by their position above the location range. + // + // If there isn't enough room above (e.g. user anchored at F1), we skip the + // visible cells but the pivot definition still tags them as page fields, + // so the dropdowns appear in Excel's pivot UI even without the cell labels. + if (filterFieldIndices != null && filterFieldIndices.Count > 0) + { + var requiredHeadroom = filterFieldIndices.Count + 1; // filter rows + 1 gap + if (anchorRow > requiredHeadroom) + { + var firstFilterRow = anchorRow - requiredHeadroom; + for (int fi = 0; fi < filterFieldIndices.Count; fi++) + { + var fIdx = filterFieldIndices[fi]; + if (fIdx < 0 || fIdx >= headers.Length) continue; + var rowIdx = firstFilterRow + fi; + var filterRow = new Row { RowIndex = (uint)rowIdx }; + filterRow.AppendChild(MakeStringCell(anchorColIdx, rowIdx, headers[fIdx])); + filterRow.AppendChild(MakeStringCell(anchorColIdx + 1, rowIdx, "(All)")); + // Insert in row order: existing rows in sheetData start at + // anchorRow, so prepend the filter rows to the front. + sheetData.InsertAt(filterRow, fi); + } + } + else + { + Console.Error.WriteLine( + $"WARNING: pivot at {position} has {filterFieldIndices.Count} page filter(s) " + + $"but only {anchorRow - 1} row(s) of headroom above. " + + "Filter cells will not be visible in the host sheet, but the filter dropdowns " + + "will still appear in Excel's pivot UI. Move the pivot to a lower anchor row " + + $"(at least row {requiredHeadroom + 1}) to render the filter cells."); + } + } + + ws.Save(); + } + + /// + /// Render a 2-row-field pivot. Compact-mode layout (verified against + /// multi_row_authored.xlsx with rows=地区,城市): + /// + /// A B C D + /// 3 [data caption] [col field caption] + /// 4 Row Labels 咖啡 奶茶 Grand Total + /// 5 华东 200 260 460 <- outer subtotal + /// 6 上海 200 150 350 + /// 7 杭州 110 110 + /// 8 华北 215 85 300 <- outer subtotal + /// ... + /// N Grand Total 595 345 940 + /// + /// Both outer and inner labels live in column A (compact mode collapses the + /// row-label area into a single column, with Excel auto-indenting inners + /// visually). Each outer value gets its own subtotal row showing the + /// aggregate across all its existing inners; only (outer, inner) pairs that + /// actually appear in the source data are rendered (Excel does not enumerate + /// empty cartesian cells). + /// + /// Multi data fields (K>1) are not yet supported in this code path — would + /// need to extend col multiplication and add the third "data field name" + /// header row. v4 expansion. Tracked. + /// + private static void RenderMultiRowPivot( + WorksheetPart targetSheet, string position, + string[] headers, List columnData, + List rowFieldIndices, List colFieldIndices, + List<(int idx, string func, string showAs, string name)> valueFields, + List? filterFieldIndices, + uint?[] valueStyleIds) + { + var outerFieldIdx = rowFieldIndices[0]; + var innerFieldIdx = rowFieldIndices[1]; + var colFieldIdx = colFieldIndices[0]; + int K = valueFields.Count; + + var outerVals = columnData[outerFieldIdx]; + var innerVals = columnData[innerFieldIdx]; + var colVals = columnData[colFieldIdx]; + var colFieldName = headers[colFieldIdx]; + + // Build the same (outer → [inners]) groups used by BuildMultiRowItems so + // the rendered cells match the rowItems indices position-for-position. + var groups = BuildOuterInnerGroups(outerFieldIdx, innerFieldIdx, columnData); + var uniqueCols = colVals.Where(v => !string.IsNullOrEmpty(v)).Distinct() + .OrderByAxis(v => v).ToList(); + + // Aggregate per (outer, inner, col, dataFieldIdx). For K=1 the d + // dimension is degenerate but the same data structure works uniformly. + var leafBucket = new Dictionary<(string o, string i, string c, int d), List>(); + var perDataField = new List>(); + for (int d = 0; d < K; d++) perDataField.Add(new List()); + + for (int i = 0; i < outerVals.Length; i++) + { + var ov = outerVals.Length > i ? outerVals[i] : null; + var iv = innerVals.Length > i ? innerVals[i] : null; + var cv = colVals.Length > i ? colVals[i] : null; + if (string.IsNullOrEmpty(ov) || string.IsNullOrEmpty(iv) || string.IsNullOrEmpty(cv)) continue; + + for (int d = 0; d < K; d++) + { + var dataIdx = valueFields[d].idx; + var dataValues = columnData[dataIdx]; + if (i >= dataValues.Length) continue; + if (!double.TryParse(dataValues[i], System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var num)) continue; + + var key = (ov, iv, cv, d); + if (!leafBucket.TryGetValue(key, out var list)) + { + list = new List(); + leafBucket[key] = list; + } + list.Add(num); + perDataField[d].Add(num); + } + } + + double Reduce(IEnumerable values, string func) => ReducePivotValues(values, func); + + // The closures below compute the cell values per (row pos, col pos, d) + // by reducing raw value lists. Each closure takes a data field index d + // so each data field aggregates with its own function (sum/count/avg/...). + double LeafCell(string outer, string inner, string col, int d) + => leafBucket.TryGetValue((outer, inner, col, d), out var b) && b.Count > 0 + ? Reduce(b, valueFields[d].func) : double.NaN; + + double OuterSubtotalForCol(string outer, string col, int d) + { + var all = new List(); + foreach (var (o, inners) in groups) + if (o == outer) + foreach (var inner in inners) + if (leafBucket.TryGetValue((outer, inner, col, d), out var b)) + all.AddRange(b); + return Reduce(all, valueFields[d].func); + } + + double LeafRowTotal(string outer, string inner, int d) + { + var all = new List(); + foreach (var col in uniqueCols) + if (leafBucket.TryGetValue((outer, inner, col, d), out var b)) + all.AddRange(b); + return Reduce(all, valueFields[d].func); + } + + double OuterRowTotal(string outer, int d) + { + var all = new List(); + foreach (var (o, inners) in groups) + if (o == outer) + foreach (var inner in inners) + foreach (var col in uniqueCols) + if (leafBucket.TryGetValue((outer, inner, col, d), out var b)) + all.AddRange(b); + return Reduce(all, valueFields[d].func); + } + + double ColTotal(string col, int d) + { + var all = new List(); + foreach (var (outer, inners) in groups) + foreach (var inner in inners) + if (leafBucket.TryGetValue((outer, inner, col, d), out var b)) + all.AddRange(b); + return Reduce(all, valueFields[d].func); + } + + // ===== Write cells ===== + var (anchorCol, anchorRow) = ParseCellRef(position); + var anchorColIdx = ColToIndex(anchorCol); + var totalLabel = "总计"; + + var ws = targetSheet.Worksheet + ?? throw new InvalidOperationException("Target worksheet has no Worksheet element"); + var sheetData = ws.GetFirstChild(); + if (sheetData == null) + { + sheetData = new SheetData(); + ws.AppendChild(sheetData); + } + + // Helper: column index of leaf cell for col label c, data field d. + int LeafColIdx(int c, int d) => anchorColIdx + 1 + c * K + d; + // Helper: column index of grand-total cell for data field d. + int GrandTotalColIdx(int d) => anchorColIdx + 1 + uniqueCols.Count * K + d; + + // ----- Row 0 (caption row) ----- + // K=1: data field name + col field name + // K>1: empty + col field name (data caption is implicit per col group) + var captionRow = new Row { RowIndex = (uint)anchorRow }; + if (K == 1) + captionRow.AppendChild(MakeStringCell(anchorColIdx, anchorRow, valueFields[0].name)); + captionRow.AppendChild(MakeStringCell(anchorColIdx + 1, anchorRow, colFieldName)); + sheetData.AppendChild(captionRow); + + // ----- Row 1 (col label row) ----- + // K=1: row field name + col labels + 总计 + // K>1: empty + col labels at first col of each K-group + "Total " cells + var colLabelRowIdx = anchorRow + 1; + var colLabelRow = new Row { RowIndex = (uint)colLabelRowIdx }; + if (K == 1) + { + colLabelRow.AppendChild(MakeStringCell(anchorColIdx, colLabelRowIdx, headers[outerFieldIdx])); + for (int c = 0; c < uniqueCols.Count; c++) + colLabelRow.AppendChild(MakeStringCell(anchorColIdx + 1 + c, colLabelRowIdx, uniqueCols[c])); + colLabelRow.AppendChild(MakeStringCell(anchorColIdx + 1 + uniqueCols.Count, colLabelRowIdx, totalLabel)); } else { - // String field — enumerate shared items - foreach (var v in uniqueValues) - sharedItems.AppendChild(new StringItem { Val = v }); + for (int c = 0; c < uniqueCols.Count; c++) + colLabelRow.AppendChild(MakeStringCell(LeafColIdx(c, 0), colLabelRowIdx, uniqueCols[c])); + for (int d = 0; d < K; d++) + colLabelRow.AppendChild(MakeStringCell(GrandTotalColIdx(d), colLabelRowIdx, "Total " + valueFields[d].name)); + } + sheetData.AppendChild(colLabelRow); + + // ----- Row 2 (data field name row, only when K>1) ----- + int firstDataRow; + if (K > 1) + { + var dfNameRowIdx = anchorRow + 2; + var dfNameRow = new Row { RowIndex = (uint)dfNameRowIdx }; + dfNameRow.AppendChild(MakeStringCell(anchorColIdx, dfNameRowIdx, headers[outerFieldIdx])); + for (int c = 0; c < uniqueCols.Count; c++) + for (int d = 0; d < K; d++) + dfNameRow.AppendChild(MakeStringCell(LeafColIdx(c, d), dfNameRowIdx, valueFields[d].name)); + sheetData.AppendChild(dfNameRow); + firstDataRow = anchorRow + 3; + } + else + { + firstDataRow = anchorRow + 2; + } + + // ----- Data rows ----- + int currentRow = firstDataRow; + foreach (var (outer, inners) in groups) + { + // Outer subtotal row: K cells per col + K cells in grand total area. + var subRow = new Row { RowIndex = (uint)currentRow }; + subRow.AppendChild(MakeStringCell(anchorColIdx, currentRow, outer)); + for (int c = 0; c < uniqueCols.Count; c++) + { + bool any = HasAnyValueInOuterCol(outer, uniqueCols[c], groups, leafBucket, K); + for (int d = 0; d < K; d++) + { + var v = OuterSubtotalForCol(outer, uniqueCols[c], d); + if (any || v != 0) + subRow.AppendChild(MakeNumericCell(LeafColIdx(c, d), currentRow, v, valueStyleIds[d])); + } + } + for (int d = 0; d < K; d++) + subRow.AppendChild(MakeNumericCell(GrandTotalColIdx(d), currentRow, OuterRowTotal(outer, d), valueStyleIds[d])); + sheetData.AppendChild(subRow); + currentRow++; + + // Leaf rows for each existing (outer, inner) combo. + foreach (var inner in inners) + { + var leafRow = new Row { RowIndex = (uint)currentRow }; + leafRow.AppendChild(MakeStringCell(anchorColIdx, currentRow, inner)); + for (int c = 0; c < uniqueCols.Count; c++) + { + for (int d = 0; d < K; d++) + { + var v = LeafCell(outer, inner, uniqueCols[c], d); + if (!double.IsNaN(v)) + leafRow.AppendChild(MakeNumericCell(LeafColIdx(c, d), currentRow, v, valueStyleIds[d])); + } + } + for (int d = 0; d < K; d++) + leafRow.AppendChild(MakeNumericCell(GrandTotalColIdx(d), currentRow, LeafRowTotal(outer, inner, d), valueStyleIds[d])); + sheetData.AppendChild(leafRow); + currentRow++; + } + } + + // Grand total row. + var grandRow = new Row { RowIndex = (uint)currentRow }; + grandRow.AppendChild(MakeStringCell(anchorColIdx, currentRow, totalLabel)); + for (int c = 0; c < uniqueCols.Count; c++) + for (int d = 0; d < K; d++) + grandRow.AppendChild(MakeNumericCell(LeafColIdx(c, d), currentRow, ColTotal(uniqueCols[c], d), valueStyleIds[d])); + for (int d = 0; d < K; d++) + grandRow.AppendChild(MakeNumericCell(GrandTotalColIdx(d), currentRow, + Reduce(perDataField[d], valueFields[d].func), valueStyleIds[d])); + sheetData.AppendChild(grandRow); + + // Page filter cells reuse the single-row path's logic — same shape, same + // layout above the table. RenderPivotIntoSheet handles them; we don't + // duplicate the code, but if the user really needs filters with 2 row + // fields, they should still get rendered. v4 candidate to factor out. + // (Currently filters on multi-row pivots will write the page filter + // markers in the pivot definition but no visible filter cells above + // the table. Same warning is emitted.) + if (filterFieldIndices != null && filterFieldIndices.Count > 0) + { + var requiredHeadroom = filterFieldIndices.Count + 1; + if (anchorRow > requiredHeadroom) + { + var firstFilterRow = anchorRow - requiredHeadroom; + for (int fi = 0; fi < filterFieldIndices.Count; fi++) + { + var fIdx = filterFieldIndices[fi]; + if (fIdx < 0 || fIdx >= headers.Length) continue; + var rowIdx = firstFilterRow + fi; + var filterRow = new Row { RowIndex = (uint)rowIdx }; + filterRow.AppendChild(MakeStringCell(anchorColIdx, rowIdx, headers[fIdx])); + filterRow.AppendChild(MakeStringCell(anchorColIdx + 1, rowIdx, "(All)")); + sheetData.InsertAt(filterRow, fi); + } + } + } + + ws.Save(); + } + + /// + /// Render a 1-row × 2-col pivot with hierarchical column subtotals. Compact + /// mode layout (verified against multi_col_authored.xlsx, cols=产品,包装): + /// + /// A B C D E F G H + /// 3 [data cap] [col field caption] + /// 4 咖啡 奶茶 + /// 5 Row Labels 罐装 袋装 咖啡 Total 罐装 袋装 奶茶 Tot. Grand Total + /// 6 华东 200 200 150 150 350 + /// 7 华北 120 80 200 85 85 285 + /// ... + /// N Grand Tot. 320 80 400 195 150 345 745 + /// + /// Each outer col value gets its own subtotal column, then a final grand + /// total column. Only (outer, inner) col combinations that exist in the + /// data are rendered (matching Excel's behavior). Three header rows total + /// (caption, outer col labels, inner col labels) — same as the multi-data + /// case, so firstDataRow=3. + /// + /// Limitation: K=1 data field only. Multi-col + multi-data is a v4 + /// expansion; the col layout would multiply by K just like the single-col + /// multi-data path does. + /// + private static void RenderMultiColPivot( + WorksheetPart targetSheet, string position, + string[] headers, List columnData, + List rowFieldIndices, List colFieldIndices, + List<(int idx, string func, string showAs, string name)> valueFields, + List? filterFieldIndices, + uint?[] valueStyleIds) + { + var rowFieldIdx = rowFieldIndices[0]; + var outerColIdx = colFieldIndices[0]; + var innerColIdx = colFieldIndices[1]; + int K = valueFields.Count; + + var rowVals = columnData[rowFieldIdx]; + var outerColVals = columnData[outerColIdx]; + var innerColVals = columnData[innerColIdx]; + + var colGroups = BuildOuterInnerGroups(outerColIdx, innerColIdx, columnData); + var uniqueRows = rowVals.Where(v => !string.IsNullOrEmpty(v)).Distinct() + .OrderByAxis(v => v).ToList(); + + // Aggregate per (row, outerCol, innerCol, dataFieldIdx). For K=1 the d + // dimension is degenerate but the same data structure works uniformly. + var leafBucket = new Dictionary<(string r, string oc, string ic, int d), List>(); + var perDataField = new List>(); + for (int d = 0; d < K; d++) perDataField.Add(new List()); + + for (int i = 0; i < rowVals.Length; i++) + { + var rv = rowVals.Length > i ? rowVals[i] : null; + var ocv = outerColVals.Length > i ? outerColVals[i] : null; + var icv = innerColVals.Length > i ? innerColVals[i] : null; + if (string.IsNullOrEmpty(rv) || string.IsNullOrEmpty(ocv) || string.IsNullOrEmpty(icv)) continue; + + for (int d = 0; d < K; d++) + { + var dataIdx = valueFields[d].idx; + var dataValues = columnData[dataIdx]; + if (i >= dataValues.Length) continue; + if (!double.TryParse(dataValues[i], System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var num)) continue; + + var key = (rv, ocv, icv, d); + if (!leafBucket.TryGetValue(key, out var list)) + { + list = new List(); + leafBucket[key] = list; + } + list.Add(num); + perDataField[d].Add(num); + } + } + + double Reduce(IEnumerable values, string func) => ReducePivotValues(values, func); + + // Per-(row, outerCol, innerCol, d) reductions over raw values. + double LeafCell(string row, string outerCol, string innerCol, int d) + => leafBucket.TryGetValue((row, outerCol, innerCol, d), out var b) && b.Count > 0 + ? Reduce(b, valueFields[d].func) : double.NaN; + + double OuterColSubtotalForRow(string row, string outerCol, int d) + { + var all = new List(); + foreach (var (oc, inners) in colGroups) + if (oc == outerCol) + foreach (var inner in inners) + if (leafBucket.TryGetValue((row, outerCol, inner, d), out var b)) + all.AddRange(b); + return Reduce(all, valueFields[d].func); + } + + double RowGrandTotal(string row, int d) + { + var all = new List(); + foreach (var (oc, inners) in colGroups) + foreach (var inner in inners) + if (leafBucket.TryGetValue((row, oc, inner, d), out var b)) + all.AddRange(b); + return Reduce(all, valueFields[d].func); + } + + double LeafColTotal(string outerCol, string innerCol, int d) + { + var all = new List(); + foreach (var row in uniqueRows) + if (leafBucket.TryGetValue((row, outerCol, innerCol, d), out var b)) + all.AddRange(b); + return Reduce(all, valueFields[d].func); + } + + double OuterColTotal(string outerCol, int d) + { + var all = new List(); + foreach (var (oc, inners) in colGroups) + if (oc == outerCol) + foreach (var inner in inners) + foreach (var row in uniqueRows) + if (leafBucket.TryGetValue((row, outerCol, inner, d), out var b)) + all.AddRange(b); + return Reduce(all, valueFields[d].func); + } + + // ===== Write cells ===== + var (anchorCol, anchorRow) = ParseCellRef(position); + var anchorColIdx = ColToIndex(anchorCol); + var totalLabel = "总计"; + + var ws = targetSheet.Worksheet + ?? throw new InvalidOperationException("Target worksheet has no Worksheet element"); + var sheetData = ws.GetFirstChild(); + if (sheetData == null) + { + sheetData = new SheetData(); + ws.AppendChild(sheetData); + } + + // Pre-compute absolute column indices. K data fields multiply the leaf + // and subtotal positions by K. Layout (left to right): + // row label + // For each outer: + // For each inner: K cells (data fields) + // subtotal: K cells (per-data subtotal) + // grand total: K cells (per-data grand) + var leafColPositions = new Dictionary<(string outer, string inner, int d), int>(); + var subtotalColPositions = new Dictionary<(string outer, int d), int>(); + var grandTotalColPositions = new int[K]; + int currentCol = anchorColIdx + 1; + foreach (var (outer, inners) in colGroups) + { + foreach (var inner in inners) + { + for (int d = 0; d < K; d++) + { + leafColPositions[(outer, inner, d)] = currentCol; + currentCol++; + } + } + for (int d = 0; d < K; d++) + { + subtotalColPositions[(outer, d)] = currentCol; + currentCol++; + } + } + for (int d = 0; d < K; d++) + { + grandTotalColPositions[d] = currentCol; + currentCol++; + } + + // ----- Header rows ----- + // K=1 → 3 header rows (caption, outer col labels, inner col labels) + // K>1 → 4 header rows (caption, outer col labels + subtotal/grand-total + // labels in same row, inner col labels, data field names) + if (K == 1) + { + // Row 0 (caption): data field name + col field name. + var captionRow = new Row { RowIndex = (uint)anchorRow }; + captionRow.AppendChild(MakeStringCell(anchorColIdx, anchorRow, valueFields[0].name)); + captionRow.AppendChild(MakeStringCell(anchorColIdx + 1, anchorRow, headers[outerColIdx])); + sheetData.AppendChild(captionRow); + + // Row 1 (outer col header): outer col label at first leaf col of each group. + var outerHeaderRowIdx = anchorRow + 1; + var outerHeaderRow = new Row { RowIndex = (uint)outerHeaderRowIdx }; + foreach (var (outer, inners) in colGroups) + { + int firstLeafCol = leafColPositions[(outer, inners[0], 0)]; + outerHeaderRow.AppendChild(MakeStringCell(firstLeafCol, outerHeaderRowIdx, outer)); + } + sheetData.AppendChild(outerHeaderRow); + + // Row 2 (inner col header): row field caption + inner col labels + + // " Total" at subtotal cols + "总计" at grand. + var innerHeaderRowIdx = anchorRow + 2; + var innerHeaderRow = new Row { RowIndex = (uint)innerHeaderRowIdx }; + innerHeaderRow.AppendChild(MakeStringCell(anchorColIdx, innerHeaderRowIdx, headers[rowFieldIdx])); + foreach (var (outer, inners) in colGroups) + { + foreach (var inner in inners) + innerHeaderRow.AppendChild(MakeStringCell(leafColPositions[(outer, inner, 0)], innerHeaderRowIdx, inner)); + innerHeaderRow.AppendChild(MakeStringCell(subtotalColPositions[(outer, 0)], innerHeaderRowIdx, outer + " Total")); + } + innerHeaderRow.AppendChild(MakeStringCell(grandTotalColPositions[0], innerHeaderRowIdx, totalLabel)); + sheetData.AppendChild(innerHeaderRow); + } + else + { + // Row 0 (caption): only the col field caption (no data caption when K>1). + var captionRow = new Row { RowIndex = (uint)anchorRow }; + captionRow.AppendChild(MakeStringCell(anchorColIdx + 1, anchorRow, headers[outerColIdx])); + sheetData.AppendChild(captionRow); + + // Row 1 (outer col header): outer label at first leaf col of group + + // per-subtotal labels " " + grand total labels + // "Total ". This is verified against multi_col_K_authored.xlsx + // where the subtotal labels live in row 4 (the outer header row) NOT + // in the inner-label or data-field rows below. + var outerHeaderRowIdx = anchorRow + 1; + var outerHeaderRow = new Row { RowIndex = (uint)outerHeaderRowIdx }; + foreach (var (outer, inners) in colGroups) + { + int firstLeafCol = leafColPositions[(outer, inners[0], 0)]; + outerHeaderRow.AppendChild(MakeStringCell(firstLeafCol, outerHeaderRowIdx, outer)); + for (int d = 0; d < K; d++) + outerHeaderRow.AppendChild(MakeStringCell(subtotalColPositions[(outer, d)], + outerHeaderRowIdx, $"{outer} {valueFields[d].name}")); + } + for (int d = 0; d < K; d++) + outerHeaderRow.AppendChild(MakeStringCell(grandTotalColPositions[d], + outerHeaderRowIdx, $"Total {valueFields[d].name}")); + sheetData.AppendChild(outerHeaderRow); + + // Row 2 (inner col header): inner label at the first data col of each + // (outer, inner) sub-group. Subtotal/grand-total cols are EMPTY in this + // row (their labels live one row above). + var innerHeaderRowIdx = anchorRow + 2; + var innerHeaderRow = new Row { RowIndex = (uint)innerHeaderRowIdx }; + foreach (var (outer, inners) in colGroups) + { + foreach (var inner in inners) + innerHeaderRow.AppendChild(MakeStringCell(leafColPositions[(outer, inner, 0)], + innerHeaderRowIdx, inner)); + } + sheetData.AppendChild(innerHeaderRow); + + // Row 3 (data field name row): row field caption + data field name at + // every leaf col. Subtotal/grand-total cols stay empty (already labeled + // in the outer header row above). + var dfNameRowIdx = anchorRow + 3; + var dfNameRow = new Row { RowIndex = (uint)dfNameRowIdx }; + dfNameRow.AppendChild(MakeStringCell(anchorColIdx, dfNameRowIdx, headers[rowFieldIdx])); + foreach (var (outer, inners) in colGroups) + { + foreach (var inner in inners) + for (int d = 0; d < K; d++) + dfNameRow.AppendChild(MakeStringCell(leafColPositions[(outer, inner, d)], + dfNameRowIdx, valueFields[d].name)); + } + sheetData.AppendChild(dfNameRow); + } + + // ----- Data rows ----- + int firstDataRow = anchorRow + (K == 1 ? 3 : 4); + for (int r = 0; r < uniqueRows.Count; r++) + { + var rowIdx = firstDataRow + r; + var dataRow = new Row { RowIndex = (uint)rowIdx }; + dataRow.AppendChild(MakeStringCell(anchorColIdx, rowIdx, uniqueRows[r])); + + foreach (var (outer, inners) in colGroups) + { + foreach (var inner in inners) + { + for (int d = 0; d < K; d++) + { + var v = LeafCell(uniqueRows[r], outer, inner, d); + if (!double.IsNaN(v)) + dataRow.AppendChild(MakeNumericCell(leafColPositions[(outer, inner, d)], rowIdx, v, valueStyleIds[d])); + } + } + // Outer col subtotal cells (K per outer). + bool any = HasAnyValueInRowOuter(uniqueRows[r], outer, colGroups, leafBucket, K); + for (int d = 0; d < K; d++) + { + var sub = OuterColSubtotalForRow(uniqueRows[r], outer, d); + if (sub != 0 || any) + dataRow.AppendChild(MakeNumericCell(subtotalColPositions[(outer, d)], rowIdx, sub, valueStyleIds[d])); + } + } + + for (int d = 0; d < K; d++) + dataRow.AppendChild(MakeNumericCell(grandTotalColPositions[d], rowIdx, RowGrandTotal(uniqueRows[r], d), valueStyleIds[d])); + sheetData.AppendChild(dataRow); + } + + // Grand total row. + int grandRowIdx = firstDataRow + uniqueRows.Count; + var grandRow = new Row { RowIndex = (uint)grandRowIdx }; + grandRow.AppendChild(MakeStringCell(anchorColIdx, grandRowIdx, totalLabel)); + foreach (var (outer, inners) in colGroups) + { + foreach (var inner in inners) + for (int d = 0; d < K; d++) + grandRow.AppendChild(MakeNumericCell(leafColPositions[(outer, inner, d)], grandRowIdx, + LeafColTotal(outer, inner, d), valueStyleIds[d])); + for (int d = 0; d < K; d++) + grandRow.AppendChild(MakeNumericCell(subtotalColPositions[(outer, d)], grandRowIdx, OuterColTotal(outer, d), valueStyleIds[d])); + } + for (int d = 0; d < K; d++) + grandRow.AppendChild(MakeNumericCell(grandTotalColPositions[d], grandRowIdx, + Reduce(perDataField[d], valueFields[d].func), valueStyleIds[d])); + sheetData.AppendChild(grandRow); + + // Page filter cells (same logic as the single-row renderer). + if (filterFieldIndices != null && filterFieldIndices.Count > 0) + { + var requiredHeadroom = filterFieldIndices.Count + 1; + if (anchorRow > requiredHeadroom) + { + var firstFilterRow = anchorRow - requiredHeadroom; + for (int fi = 0; fi < filterFieldIndices.Count; fi++) + { + var fIdx = filterFieldIndices[fi]; + if (fIdx < 0 || fIdx >= headers.Length) continue; + var rowIdx = firstFilterRow + fi; + var filterRow = new Row { RowIndex = (uint)rowIdx }; + filterRow.AppendChild(MakeStringCell(anchorColIdx, rowIdx, headers[fIdx])); + filterRow.AppendChild(MakeStringCell(anchorColIdx + 1, rowIdx, "(All)")); + sheetData.InsertAt(filterRow, fi); + } + } + } + + ws.Save(); + } + + /// + /// Render a 2-row × 2-col × 1-data matrix pivot. The cross product of + /// hierarchical rows (multi-row layout) with hierarchical columns + /// (multi-col layout). Verified against matrix_authored.xlsx. + /// + /// Layout (rows=地区,城市 cols=产品,包装 values=金额:sum): + /// Row 0 (caption): [data caption] [col field caption] + /// Row 1 (outer col hdr): 咖啡 奶茶 + /// Row 2 (inner col hdr): [row field nm] 罐装 袋装 咖啡 Total 罐装 袋装 奶茶 Total Grand Total + /// Row 3 onwards: + /// For each row outer in display order: + /// Outer subtotal row: [outer] + /// For each (existing) inner: + /// Leaf row: [inner] + /// Last row: [总计]
    + /// + /// Cell value semantics (all reduce raw value lists, never pre-aggregated): + /// - (outer row sub, leaf col): sum over (rOuter, *, cOuter, cInner) + /// - (outer row sub, col sub): sum over (rOuter, *, cOuter, *) + /// - (outer row sub, grand col): sum over (rOuter, *, *, *) + /// - (leaf row, leaf col): sum over (rOuter, rInner, cOuter, cInner) + /// - (leaf row, col sub): sum over (rOuter, rInner, cOuter, *) + /// - (leaf row, grand col): sum over (rOuter, rInner, *, *) + /// - (grand row, leaf col): sum over (*, *, cOuter, cInner) + /// - (grand row, col sub): sum over (*, *, cOuter, *) + /// - (grand row, grand col): sum over (*, *, *, *) + /// + /// K=1 only. 2×2×K (matrix + multi-data) is rare and tracked as v5. + /// + private static void RenderMatrixPivot( + WorksheetPart targetSheet, string position, + string[] headers, List columnData, + List rowFieldIndices, List colFieldIndices, + List<(int idx, string func, string showAs, string name)> valueFields, + List? filterFieldIndices, + uint?[] valueStyleIds) + { + var rowOuterIdx = rowFieldIndices[0]; + var rowInnerIdx = rowFieldIndices[1]; + var colOuterIdx = colFieldIndices[0]; + var colInnerIdx = colFieldIndices[1]; + int K = valueFields.Count; + + var rowOuterVals = columnData[rowOuterIdx]; + var rowInnerVals = columnData[rowInnerIdx]; + var colOuterVals = columnData[colOuterIdx]; + var colInnerVals = columnData[colInnerIdx]; + + var rowGroups = BuildOuterInnerGroups(rowOuterIdx, rowInnerIdx, columnData); + var colGroups = BuildOuterInnerGroups(colOuterIdx, colInnerIdx, columnData); + + // Aggregate per (rowOuter, rowInner, colOuter, colInner, dataFieldIdx). + // 5-tuple bucket — combines the 4-tuple matrix bucket with K data fields. + var bucket = new Dictionary<(string ro, string ri, string co, string ci, int d), List>(); + var perDataField = new List>(); + for (int d = 0; d < K; d++) perDataField.Add(new List()); + + for (int i = 0; i < rowOuterVals.Length; i++) + { + var ro = rowOuterVals.Length > i ? rowOuterVals[i] : null; + var ri = rowInnerVals.Length > i ? rowInnerVals[i] : null; + var co = colOuterVals.Length > i ? colOuterVals[i] : null; + var ci = colInnerVals.Length > i ? colInnerVals[i] : null; + if (string.IsNullOrEmpty(ro) || string.IsNullOrEmpty(ri) + || string.IsNullOrEmpty(co) || string.IsNullOrEmpty(ci)) continue; + + for (int d = 0; d < K; d++) + { + var dataIdx = valueFields[d].idx; + var dataValues = columnData[dataIdx]; + if (i >= dataValues.Length) continue; + if (!double.TryParse(dataValues[i], System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var num)) continue; + + var key = (ro, ri, co, ci, d); + if (!bucket.TryGetValue(key, out var list)) + { + list = new List(); + bucket[key] = list; + } + list.Add(num); + perDataField[d].Add(num); + } + } + + double Reduce(IEnumerable values, string func) => ReducePivotValues(values, func); + + // The 9 cell-value closures from the K=1 path now each take a data + // field index d so the right aggregator is applied per cell. + double LeafCell(string ro, string ri, string co, string ci, int d) + => bucket.TryGetValue((ro, ri, co, ci, d), out var b) && b.Count > 0 + ? Reduce(b, valueFields[d].func) : double.NaN; + + double LeafRowColSub(string ro, string ri, string co, int d) + { + var all = new List(); + foreach (var (oc, inners) in colGroups) + if (oc == co) + foreach (var inner in inners) + if (bucket.TryGetValue((ro, ri, co, inner, d), out var b)) + all.AddRange(b); + return Reduce(all, valueFields[d].func); + } + + double LeafRowGrandTotal(string ro, string ri, int d) + { + var all = new List(); + foreach (var (oc, inners) in colGroups) + foreach (var inner in inners) + if (bucket.TryGetValue((ro, ri, oc, inner, d), out var b)) + all.AddRange(b); + return Reduce(all, valueFields[d].func); + } + + double OuterRowLeafCell(string ro, string co, string ci, int d) + { + var all = new List(); + foreach (var (g, inners) in rowGroups) + if (g == ro) + foreach (var inner in inners) + if (bucket.TryGetValue((ro, inner, co, ci, d), out var b)) + all.AddRange(b); + return Reduce(all, valueFields[d].func); + } + + double OuterRowColSub(string ro, string co, int d) + { + var all = new List(); + foreach (var (g, rinners) in rowGroups) + if (g == ro) + foreach (var rinner in rinners) + foreach (var (oc, cinners) in colGroups) + if (oc == co) + foreach (var cinner in cinners) + if (bucket.TryGetValue((ro, rinner, co, cinner, d), out var b)) + all.AddRange(b); + return Reduce(all, valueFields[d].func); + } + + double OuterRowGrandTotal(string ro, int d) + { + var all = new List(); + foreach (var (g, rinners) in rowGroups) + if (g == ro) + foreach (var rinner in rinners) + foreach (var (oc, cinners) in colGroups) + foreach (var cinner in cinners) + if (bucket.TryGetValue((ro, rinner, oc, cinner, d), out var b)) + all.AddRange(b); + return Reduce(all, valueFields[d].func); + } + + double GrandRowLeafCol(string co, string ci, int d) + { + var all = new List(); + foreach (var (g, rinners) in rowGroups) + foreach (var rinner in rinners) + if (bucket.TryGetValue((g, rinner, co, ci, d), out var b)) + all.AddRange(b); + return Reduce(all, valueFields[d].func); + } + + double GrandRowColSub(string co, int d) + { + var all = new List(); + foreach (var (g, rinners) in rowGroups) + foreach (var rinner in rinners) + foreach (var (oc, cinners) in colGroups) + if (oc == co) + foreach (var cinner in cinners) + if (bucket.TryGetValue((g, rinner, co, cinner, d), out var b)) + all.AddRange(b); + return Reduce(all, valueFields[d].func); + } + + // ===== Write cells ===== + var (anchorCol, anchorRow) = ParseCellRef(position); + var anchorColIdx = ColToIndex(anchorCol); + var totalLabel = "总计"; + + var ws = targetSheet.Worksheet + ?? throw new InvalidOperationException("Target worksheet has no Worksheet element"); + var sheetData = ws.GetFirstChild(); + if (sheetData == null) + { + sheetData = new SheetData(); + ws.AppendChild(sheetData); + } + + // Pre-compute K-aware col positions: each (outer, inner) leaf gets K + // cells, each outer subtotal gets K cells, K final grand total cells. + var leafColPositions = new Dictionary<(string outer, string inner, int d), int>(); + var subtotalColPositions = new Dictionary<(string outer, int d), int>(); + var grandTotalColPositions = new int[K]; + int currentCol = anchorColIdx + 1; + foreach (var (outer, inners) in colGroups) + { + foreach (var inner in inners) + { + for (int d = 0; d < K; d++) + { + leafColPositions[(outer, inner, d)] = currentCol; + currentCol++; + } + } + for (int d = 0; d < K; d++) + { + subtotalColPositions[(outer, d)] = currentCol; + currentCol++; + } + } + for (int d = 0; d < K; d++) + { + grandTotalColPositions[d] = currentCol; + currentCol++; + } + + // ----- Header rows ----- + // K=1 → 3 header rows (caption + outer col + inner col) + // K>1 → 4 header rows (caption + outer col + inner col + data field name) + if (K == 1) + { + // Row 0: data caption + col field caption. + var captionRow = new Row { RowIndex = (uint)anchorRow }; + captionRow.AppendChild(MakeStringCell(anchorColIdx, anchorRow, valueFields[0].name)); + captionRow.AppendChild(MakeStringCell(anchorColIdx + 1, anchorRow, headers[colOuterIdx])); + sheetData.AppendChild(captionRow); + + // Row 1: outer col labels at first leaf col of each group. + var outerHdrRowIdx = anchorRow + 1; + var outerHdrRow = new Row { RowIndex = (uint)outerHdrRowIdx }; + foreach (var (outer, inners) in colGroups) + { + int firstLeafCol = leafColPositions[(outer, inners[0], 0)]; + outerHdrRow.AppendChild(MakeStringCell(firstLeafCol, outerHdrRowIdx, outer)); + } + sheetData.AppendChild(outerHdrRow); + + // Row 2: row outer field name + inner col labels + " Total" + 总计. + var innerHdrRowIdx = anchorRow + 2; + var innerHdrRow = new Row { RowIndex = (uint)innerHdrRowIdx }; + innerHdrRow.AppendChild(MakeStringCell(anchorColIdx, innerHdrRowIdx, headers[rowOuterIdx])); + foreach (var (outer, inners) in colGroups) + { + foreach (var inner in inners) + innerHdrRow.AppendChild(MakeStringCell(leafColPositions[(outer, inner, 0)], + innerHdrRowIdx, inner)); + innerHdrRow.AppendChild(MakeStringCell(subtotalColPositions[(outer, 0)], innerHdrRowIdx, outer + " Total")); + } + innerHdrRow.AppendChild(MakeStringCell(grandTotalColPositions[0], innerHdrRowIdx, totalLabel)); + sheetData.AppendChild(innerHdrRow); + } + else + { + // Row 0 (caption): only the col field caption (no data caption when K>1). + var captionRow = new Row { RowIndex = (uint)anchorRow }; + captionRow.AppendChild(MakeStringCell(anchorColIdx + 1, anchorRow, headers[colOuterIdx])); + sheetData.AppendChild(captionRow); + + // Row 1 (outer col): outer label at first leaf col + per-subtotal labels + // " " + "Total " at grand total cols. + var outerHdrRowIdx = anchorRow + 1; + var outerHdrRow = new Row { RowIndex = (uint)outerHdrRowIdx }; + foreach (var (outer, inners) in colGroups) + { + int firstLeafCol = leafColPositions[(outer, inners[0], 0)]; + outerHdrRow.AppendChild(MakeStringCell(firstLeafCol, outerHdrRowIdx, outer)); + for (int d = 0; d < K; d++) + outerHdrRow.AppendChild(MakeStringCell(subtotalColPositions[(outer, d)], + outerHdrRowIdx, $"{outer} {valueFields[d].name}")); + } + for (int d = 0; d < K; d++) + outerHdrRow.AppendChild(MakeStringCell(grandTotalColPositions[d], + outerHdrRowIdx, $"Total {valueFields[d].name}")); + sheetData.AppendChild(outerHdrRow); + + // Row 2 (inner col): inner label at the first data col of each (outer, inner) sub-group. + var innerHdrRowIdx = anchorRow + 2; + var innerHdrRow = new Row { RowIndex = (uint)innerHdrRowIdx }; + foreach (var (outer, inners) in colGroups) + { + foreach (var inner in inners) + innerHdrRow.AppendChild(MakeStringCell(leafColPositions[(outer, inner, 0)], + innerHdrRowIdx, inner)); + } + sheetData.AppendChild(innerHdrRow); + + // Row 3 (data field name): row outer field name + data field name at every leaf col. + var dfNameRowIdx = anchorRow + 3; + var dfNameRow = new Row { RowIndex = (uint)dfNameRowIdx }; + dfNameRow.AppendChild(MakeStringCell(anchorColIdx, dfNameRowIdx, headers[rowOuterIdx])); + foreach (var (outer, inners) in colGroups) + { + foreach (var inner in inners) + for (int d = 0; d < K; d++) + dfNameRow.AppendChild(MakeStringCell(leafColPositions[(outer, inner, d)], + dfNameRowIdx, valueFields[d].name)); + } + sheetData.AppendChild(dfNameRow); + } + + // ----- Data rows: alternate (outer subtotal row + leaf rows) per row group ----- + int firstDataRow = anchorRow + (K == 1 ? 3 : 4); + int currentRowIdx = firstDataRow; + foreach (var (rowOuter, rowInners) in rowGroups) + { + // Outer subtotal row. + var outerSubRow = new Row { RowIndex = (uint)currentRowIdx }; + outerSubRow.AppendChild(MakeStringCell(anchorColIdx, currentRowIdx, rowOuter)); + foreach (var (colOuter, colInners) in colGroups) + { + foreach (var colInner in colInners) + { + bool any = HasAnyValueInOuterRowCol(rowOuter, colOuter, colInner, rowGroups, bucket, K); + for (int d = 0; d < K; d++) + { + var v = OuterRowLeafCell(rowOuter, colOuter, colInner, d); + if (v != 0 || any) + outerSubRow.AppendChild(MakeNumericCell(leafColPositions[(colOuter, colInner, d)], currentRowIdx, v, valueStyleIds[d])); + } + } + bool anyOuter = HasAnyValueInOuterRowOuterCol(rowOuter, colOuter, rowGroups, colGroups, bucket, K); + for (int d = 0; d < K; d++) + { + var sub = OuterRowColSub(rowOuter, colOuter, d); + if (sub != 0 || anyOuter) + outerSubRow.AppendChild(MakeNumericCell(subtotalColPositions[(colOuter, d)], currentRowIdx, sub, valueStyleIds[d])); + } + } + for (int d = 0; d < K; d++) + outerSubRow.AppendChild(MakeNumericCell(grandTotalColPositions[d], currentRowIdx, OuterRowGrandTotal(rowOuter, d), valueStyleIds[d])); + sheetData.AppendChild(outerSubRow); + currentRowIdx++; + + // Leaf rows for each existing inner of this row outer. + foreach (var rowInner in rowInners) + { + var leafRow = new Row { RowIndex = (uint)currentRowIdx }; + leafRow.AppendChild(MakeStringCell(anchorColIdx, currentRowIdx, rowInner)); + foreach (var (colOuter, colInners) in colGroups) + { + foreach (var colInner in colInners) + { + for (int d = 0; d < K; d++) + { + var v = LeafCell(rowOuter, rowInner, colOuter, colInner, d); + if (!double.IsNaN(v)) + leafRow.AppendChild(MakeNumericCell(leafColPositions[(colOuter, colInner, d)], currentRowIdx, v, valueStyleIds[d])); + } + } + bool any = HasAnyValueInLeafRowCol(rowOuter, rowInner, colOuter, colGroups, bucket, K); + for (int d = 0; d < K; d++) + { + var sub = LeafRowColSub(rowOuter, rowInner, colOuter, d); + if (sub != 0 || any) + leafRow.AppendChild(MakeNumericCell(subtotalColPositions[(colOuter, d)], currentRowIdx, sub, valueStyleIds[d])); + } + } + for (int d = 0; d < K; d++) + leafRow.AppendChild(MakeNumericCell(grandTotalColPositions[d], currentRowIdx, LeafRowGrandTotal(rowOuter, rowInner, d), valueStyleIds[d])); + sheetData.AppendChild(leafRow); + currentRowIdx++; + } + } + + // Grand total row. + var grandRow = new Row { RowIndex = (uint)currentRowIdx }; + grandRow.AppendChild(MakeStringCell(anchorColIdx, currentRowIdx, totalLabel)); + foreach (var (colOuter, colInners) in colGroups) + { + foreach (var colInner in colInners) + for (int d = 0; d < K; d++) + grandRow.AppendChild(MakeNumericCell(leafColPositions[(colOuter, colInner, d)], currentRowIdx, + GrandRowLeafCol(colOuter, colInner, d), valueStyleIds[d])); + for (int d = 0; d < K; d++) + grandRow.AppendChild(MakeNumericCell(subtotalColPositions[(colOuter, d)], currentRowIdx, GrandRowColSub(colOuter, d), valueStyleIds[d])); + } + for (int d = 0; d < K; d++) + grandRow.AppendChild(MakeNumericCell(grandTotalColPositions[d], currentRowIdx, + Reduce(perDataField[d], valueFields[d].func), valueStyleIds[d])); + sheetData.AppendChild(grandRow); + + // Page filter cells (same logic as the other renderers). + if (filterFieldIndices != null && filterFieldIndices.Count > 0) + { + var requiredHeadroom = filterFieldIndices.Count + 1; + if (anchorRow > requiredHeadroom) + { + var firstFilterRow = anchorRow - requiredHeadroom; + for (int fi = 0; fi < filterFieldIndices.Count; fi++) + { + var fIdx = filterFieldIndices[fi]; + if (fIdx < 0 || fIdx >= headers.Length) continue; + var rowIdx = firstFilterRow + fi; + var filterRow = new Row { RowIndex = (uint)rowIdx }; + filterRow.AppendChild(MakeStringCell(anchorColIdx, rowIdx, headers[fIdx])); + filterRow.AppendChild(MakeStringCell(anchorColIdx + 1, rowIdx, "(All)")); + sheetData.InsertAt(filterRow, fi); + } + } + } + + ws.Save(); + } + + // ==================== General Tree-Based Renderer (N≥3 axis fields) ==================== + + /// + /// Render a pivot with arbitrary depth on either axis using AxisTree + /// abstraction. Currently engaged for N_row≥3 OR N_col≥3 (the cases that + /// the specialized RenderMultiRow/Col/Matrix renderers do not handle). + /// + /// Layout strategy: + /// - Compact mode: row labels collapse into a single column (col A) + /// regardless of N_row. firstDataCol = 1. + /// - Each internal row tree node emits an outer-subtotal row before its + /// children. Each leaf tree node emits a leaf row. + /// - Each internal col tree node emits an outer-subtotal col AFTER its + /// children (matching multi-col convention). Each leaf node emits a + /// leaf data col. + /// - K data fields multiply the col area by K (K cells per leaf, K cells + /// per col subtotal, K final grand totals). + /// - Header rows: 1 caption + N_col rows (one per col field level) + + /// optional 1 data field name row (when K>1) = 1 + N_col + (K>1?1:0) + /// + /// Cell value semantics: for each (row pos, col pos, dataField d), reduce + /// raw values from rows whose row-field tuple matches BOTH the row path + /// prefix AND the col path prefix. Subtotal positions widen the prefix + /// match (e.g. an outer-row subtotal at depth 1 in a depth-3 row tree + /// matches all source rows whose first-field value equals the path[0]). + /// + private static void RenderGeneralPivot( + WorksheetPart targetSheet, string position, + string[] headers, List columnData, + List rowFieldIndices, List colFieldIndices, + List<(int idx, string func, string showAs, string name)> valueFields, + List? filterFieldIndices, + uint?[] valueStyleIds) + { + int K = Math.Max(1, valueFields.Count); + var rowTree = BuildAxisTree(rowFieldIndices, columnData); + var colTree = BuildAxisTree(colFieldIndices, columnData); + + // Walk both trees in display order. Each entry is the absolute display + // position relative to the start of the data area. + var rowPositions = WalkAxisTree(rowTree, isCol: false).ToList(); + var colPositions = WalkAxisTree(colTree, isCol: true).ToList(); + + // Build per-source-row tuples once so cell value lookups are O(rows × K) + // instead of O(rows × cells × N). + int srcRowCount = columnData.Count > 0 ? columnData[0].Length : 0; + var rowFieldVals = new string[srcRowCount][]; + var colFieldVals = new string[srcRowCount][]; + for (int r = 0; r < srcRowCount; r++) + { + rowFieldVals[r] = new string[rowFieldIndices.Count]; + colFieldVals[r] = new string[colFieldIndices.Count]; + for (int l = 0; l < rowFieldIndices.Count; l++) + { + var fi = rowFieldIndices[l]; + rowFieldVals[r][l] = (fi >= 0 && fi < columnData.Count && r < columnData[fi].Length) + ? columnData[fi][r] : null!; + } + for (int l = 0; l < colFieldIndices.Count; l++) + { + var fi = colFieldIndices[l]; + colFieldVals[r][l] = (fi >= 0 && fi < columnData.Count && r < columnData[fi].Length) + ? columnData[fi][r] : null!; + } + } + + // Numeric value cache per data field. Pre-parse so we don't double_parse + // every cell access. NaN encodes "not a number / skip". + var dataNums = new double[K][]; + for (int d = 0; d < K; d++) + { + var dataIdx = valueFields[d].idx; + var values = (dataIdx >= 0 && dataIdx < columnData.Count) ? columnData[dataIdx] : Array.Empty(); + dataNums[d] = new double[srcRowCount]; + for (int r = 0; r < srcRowCount; r++) + { + if (r >= values.Length || string.IsNullOrEmpty(values[r]) + || !double.TryParse(values[r], System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var n)) + dataNums[d][r] = double.NaN; + else + dataNums[d][r] = n; + } + } + + double Reduce(IEnumerable values, string func) => ReducePivotValues(values, func); + + // Compute the value at (rowNode, colNode, dataFieldIdx). + // Subtotal nodes have shorter Path arrays than leaves; the prefix match + // automatically widens the set of source rows that contribute. + double ComputeCell(AxisNode rowNode, AxisNode colNode, int d) + { + var rPath = rowNode.Path; + var cPath = colNode.Path; + var collected = new List(); + for (int r = 0; r < srcRowCount; r++) + { + bool match = true; + for (int l = 0; l < rPath.Length && match; l++) + if (rowFieldVals[r][l] != rPath[l]) match = false; + for (int l = 0; l < cPath.Length && match; l++) + if (colFieldVals[r][l] != cPath[l]) match = false; + if (!match) continue; + + // Skip rows where ANY row-axis or col-axis field is empty (mirrors + // the specialized renderers' validity gate). + for (int l = 0; l < rowFieldIndices.Count && match; l++) + if (string.IsNullOrEmpty(rowFieldVals[r][l])) match = false; + for (int l = 0; l < colFieldIndices.Count && match; l++) + if (string.IsNullOrEmpty(colFieldVals[r][l])) match = false; + if (!match) continue; + + var v = dataNums[d][r]; + if (!double.IsNaN(v)) collected.Add(v); + } + return Reduce(collected, valueFields[d].func); + } + + bool HasAnyValue(AxisNode rowNode, AxisNode colNode) + { + var rPath = rowNode.Path; + var cPath = colNode.Path; + for (int r = 0; r < srcRowCount; r++) + { + bool match = true; + for (int l = 0; l < rPath.Length && match; l++) + if (rowFieldVals[r][l] != rPath[l]) match = false; + for (int l = 0; l < cPath.Length && match; l++) + if (colFieldVals[r][l] != cPath[l]) match = false; + if (!match) continue; + for (int d = 0; d < K; d++) + if (!double.IsNaN(dataNums[d][r])) return true; + } + return false; + } + + // ===== Write cells ===== + var (anchorCol, anchorRow) = ParseCellRef(position); + var anchorColIdx = ColToIndex(anchorCol); + var totalLabel = "总计"; + + var ws = targetSheet.Worksheet + ?? throw new InvalidOperationException("Target worksheet has no Worksheet element"); + var sheetData = ws.GetFirstChild(); + if (sheetData == null) + { + sheetData = new SheetData(); + ws.AppendChild(sheetData); + } + + // Pre-compute absolute col indices for every col position × data field. + // colPositions does not include the grand total column — that's tracked + // separately so the writer doesn't accidentally include it inside the + // per-outer subtotal block. + int colCells = colPositions.Count * K; + int firstDataCol = anchorColIdx + 1; + var colIdxByPosition = new int[colPositions.Count, K]; + for (int p = 0; p < colPositions.Count; p++) + for (int d = 0; d < K; d++) + colIdxByPosition[p, d] = firstDataCol + p * K + d; + int grandTotalColStart = firstDataCol + colCells; + + // Header rows. Layout depends on (N_col, K): + // - 1 caption row (row 0) + // - N_col header rows (one per col field level, top→bottom = outer→inner) + // - Optionally 1 data-field-name row when K>1 + int headerRows = 1 + Math.Max(1, colFieldIndices.Count) + (K > 1 ? 1 : 0); + + // Row 0 (caption): col field caption (the outermost col field name) at + // first data col position. For K=1 the row-label col also gets the + // single data field name. + var captionRow = new Row { RowIndex = (uint)anchorRow }; + if (K == 1) + captionRow.AppendChild(MakeStringCell(anchorColIdx, anchorRow, valueFields[0].name)); + if (colFieldIndices.Count > 0) + captionRow.AppendChild(MakeStringCell(firstDataCol, anchorRow, + headers[colFieldIndices[0]])); + sheetData.AppendChild(captionRow); + + // Rows 1..N_col (col field header rows). For each level L (1..N_col), the + // L-th col field's labels are written at the first leaf col of every node + // at depth L in the col tree. Subtotal cols at level L get their label + // here too (for the outermost level when K>1, we put the subtotal labels + // in the outermost header row, matching the multi-col K>1 ground truth). + for (int level = 1; level <= colFieldIndices.Count; level++) + { + int headerRowIdx = anchorRow + level; + var headerRow = new Row { RowIndex = (uint)headerRowIdx }; + // Row label column header on the LAST col-field row carries the + // outermost row field name (when K=1) or stays empty (when K>1 + // because the data-field-name row below carries it). + if (level == colFieldIndices.Count && K == 1 && rowFieldIndices.Count > 0) + headerRow.AppendChild(MakeStringCell(anchorColIdx, headerRowIdx, headers[rowFieldIndices[0]])); + + for (int p = 0; p < colPositions.Count; p++) + { + var (node, isLeaf, isSubtotal) = colPositions[p]; + // Internal-node label appears at THIS row only when level matches + // the node's depth, AND it appears at the FIRST data col of its + // descendants (i.e. the position of the first leaf in its subtree). + if (isSubtotal) + { + // For each internal node N at depth L, the subtotal label + // pattern depends on which row we're on: + // - At header row L (matching the node's depth): emit the + // parent-style label "" at the first + // leaf col of N's subtree. + // - At the LAST col-field header row (level == N_col): emit + // the " Total" at THIS subtotal col position. + if (level == node.Depth) + { + // Subtotal cols don't carry inner labels; the label here + // is the node's own label, written at THIS subtotal col. + // Match the multi-col single-data convention: " Total". + if (K == 1) + headerRow.AppendChild(MakeStringCell(colIdxByPosition[p, 0], headerRowIdx, + node.Label + " Total")); + else + { + // Multi-data: emit per-data-field labels. + for (int d = 0; d < K; d++) + headerRow.AppendChild(MakeStringCell(colIdxByPosition[p, d], headerRowIdx, + $"{node.Label} {valueFields[d].name}")); + } + } + continue; + } + + // Leaf node: emit the label corresponding to THIS header level. + // Only at the level where the node's path-element matches (depth). + if (level <= node.Path.Length) + { + // Write at the FIRST leaf of any contiguous group sharing the + // same prefix at this level. Approximation: write at every + // leaf, but Excel deduplicates visually via colItems metadata. + // Simpler implementation: just write the label at this leaf + // for the level matching its current depth in the tree. + if (level == node.Path.Length) + { + // Innermost level for this leaf: emit at first data col. + headerRow.AppendChild(MakeStringCell(colIdxByPosition[p, 0], headerRowIdx, node.Label)); + } + else + { + // Outer ancestor levels: emit the ancestor label only at + // the first leaf of the ancestor's subtree (positions + // sharing path[level-1] = ancestor's label, AND this is + // the first such position). + // Find the previous position; if its path[level-1] differs + // OR there is no previous, this is the start of a new group. + bool isFirst = (p == 0); + if (!isFirst) + { + var (prevNode, _, prevIsSub) = colPositions[p - 1]; + // Skip subtotal cols when checking "previous leaf in group" + // — subtotals belong to a different ancestor than their + // following leaves. + if (prevIsSub) isFirst = true; + else + { + var prev = prevNode; + if (level - 1 >= prev.Path.Length || level - 1 >= node.Path.Length + || prev.Path[level - 1] != node.Path[level - 1]) + isFirst = true; + } + } + if (isFirst && level - 1 < node.Path.Length) + headerRow.AppendChild(MakeStringCell(colIdxByPosition[p, 0], headerRowIdx, + node.Path[level - 1])); + } + } + } + + // Grand total column header label appears at the LAST col header row + // (or in the K>1 case it's spread across all data field columns). + if (level == colFieldIndices.Count) + { + if (K == 1) + headerRow.AppendChild(MakeStringCell(grandTotalColStart, headerRowIdx, totalLabel)); + else + for (int d = 0; d < K; d++) + headerRow.AppendChild(MakeStringCell(grandTotalColStart + d, headerRowIdx, + $"Total {valueFields[d].name}")); + } + sheetData.AppendChild(headerRow); + } + + // Optional data field name row (K>1). + if (K > 1) + { + int dfRowIdx = anchorRow + headerRows - 1; + var dfRow = new Row { RowIndex = (uint)dfRowIdx }; + if (rowFieldIndices.Count > 0) + dfRow.AppendChild(MakeStringCell(anchorColIdx, dfRowIdx, headers[rowFieldIndices[0]])); + for (int p = 0; p < colPositions.Count; p++) + { + var (_, isLeaf, isSubtotal) = colPositions[p]; + if (isSubtotal) continue; // Subtotal cols already labelled in their header row above. + for (int d = 0; d < K; d++) + dfRow.AppendChild(MakeStringCell(colIdxByPosition[p, d], dfRowIdx, valueFields[d].name)); + } + sheetData.AppendChild(dfRow); + } + + // Data + grand total rows. + int firstDataRowIdx = anchorRow + headerRows; + for (int rp = 0; rp < rowPositions.Count; rp++) + { + var (rowNode, rIsLeaf, rIsSubtotal) = rowPositions[rp]; + int rowIdx = firstDataRowIdx + rp; + var row = new Row { RowIndex = (uint)rowIdx }; + row.AppendChild(MakeStringCell(anchorColIdx, rowIdx, rowNode.Label)); + + for (int cp = 0; cp < colPositions.Count; cp++) + { + var (colNode, cIsLeaf, cIsSubtotal) = colPositions[cp]; + bool any = HasAnyValue(rowNode, colNode); + for (int d = 0; d < K; d++) + { + var v = ComputeCell(rowNode, colNode, d); + // Skip 0-value cells when there are no underlying values to + // mirror Excel's behavior of leaving sparse intersections blank. + if (any || v != 0) + row.AppendChild(MakeNumericCell(colIdxByPosition[cp, d], rowIdx, v, valueStyleIds[d])); + } + } + + // Grand total cells (per data field) — the row's value across all cols. + var grandRowNode = new AxisNode(string.Empty, 0, Array.Empty()); + for (int d = 0; d < K; d++) + row.AppendChild(MakeNumericCell(grandTotalColStart + d, rowIdx, + ComputeCell(rowNode, grandRowNode, d), valueStyleIds[d])); + sheetData.AppendChild(row); + } + + // Final grand total row. + int grandRowIdx = firstDataRowIdx + rowPositions.Count; + var grandRow = new Row { RowIndex = (uint)grandRowIdx }; + grandRow.AppendChild(MakeStringCell(anchorColIdx, grandRowIdx, totalLabel)); + var grandRowNodeFinal = new AxisNode(string.Empty, 0, Array.Empty()); + for (int cp = 0; cp < colPositions.Count; cp++) + { + var (colNode, _, _) = colPositions[cp]; + for (int d = 0; d < K; d++) + { + var v = ComputeCell(grandRowNodeFinal, colNode, d); + grandRow.AppendChild(MakeNumericCell(colIdxByPosition[cp, d], grandRowIdx, v, valueStyleIds[d])); + } + } + for (int d = 0; d < K; d++) + grandRow.AppendChild(MakeNumericCell(grandTotalColStart + d, grandRowIdx, + ComputeCell(grandRowNodeFinal, grandRowNodeFinal, d), valueStyleIds[d])); + sheetData.AppendChild(grandRow); + + // Page filter cells (same logic as the other renderers). + if (filterFieldIndices != null && filterFieldIndices.Count > 0) + { + var requiredHeadroom = filterFieldIndices.Count + 1; + if (anchorRow > requiredHeadroom) + { + var firstFilterRow = anchorRow - requiredHeadroom; + for (int fi = 0; fi < filterFieldIndices.Count; fi++) + { + var fIdx = filterFieldIndices[fi]; + if (fIdx < 0 || fIdx >= headers.Length) continue; + var rowIdx = firstFilterRow + fi; + var filterRow = new Row { RowIndex = (uint)rowIdx }; + filterRow.AppendChild(MakeStringCell(anchorColIdx, rowIdx, headers[fIdx])); + filterRow.AppendChild(MakeStringCell(anchorColIdx + 1, rowIdx, "(All)")); + sheetData.InsertAt(filterRow, fi); + } + } + } + + ws.Save(); + } + + /// + /// Helper for RenderMatrixPivot: true if (rowOuter, *, colOuter, colInner) + /// has any non-empty leaf bucket across any data field. + /// + private static bool HasAnyValueInOuterRowCol(string rowOuter, string colOuter, string colInner, + List<(string outer, List inners)> rowGroups, + Dictionary<(string ro, string ri, string co, string ci, int d), List> bucket, + int dataFieldCount) + { + foreach (var (g, inners) in rowGroups) + { + if (g != rowOuter) continue; + foreach (var inner in inners) + for (int d = 0; d < dataFieldCount; d++) + if (bucket.TryGetValue((rowOuter, inner, colOuter, colInner, d), out var b) && b.Count > 0) + return true; + } + return false; + } + + /// + /// Helper for RenderMatrixPivot: true if (rowOuter, *, colOuter, *) has any + /// non-empty bucket across any data field. + /// + private static bool HasAnyValueInOuterRowOuterCol(string rowOuter, string colOuter, + List<(string outer, List inners)> rowGroups, + List<(string outer, List inners)> colGroups, + Dictionary<(string ro, string ri, string co, string ci, int d), List> bucket, + int dataFieldCount) + { + foreach (var (g, rinners) in rowGroups) + { + if (g != rowOuter) continue; + foreach (var rinner in rinners) + foreach (var (oc, cinners) in colGroups) + if (oc == colOuter) + foreach (var cinner in cinners) + for (int d = 0; d < dataFieldCount; d++) + if (bucket.TryGetValue((rowOuter, rinner, colOuter, cinner, d), out var b) && b.Count > 0) + return true; + } + return false; + } + + /// + /// Helper for RenderMatrixPivot: true if (rowOuter, rowInner, colOuter, *) + /// has any non-empty bucket across any data field. + /// + private static bool HasAnyValueInLeafRowCol(string rowOuter, string rowInner, string colOuter, + List<(string outer, List inners)> colGroups, + Dictionary<(string ro, string ri, string co, string ci, int d), List> bucket, + int dataFieldCount) + { + foreach (var (oc, cinners) in colGroups) + { + if (oc != colOuter) continue; + foreach (var cinner in cinners) + for (int d = 0; d < dataFieldCount; d++) + if (bucket.TryGetValue((rowOuter, rowInner, colOuter, cinner, d), out var b) && b.Count > 0) + return true; + } + return false; + } + + /// + /// Helper for RenderMultiColPivot: like HasAnyValueInOuterCol but flipped + /// (checks if a (row, outerCol) pair has any non-empty leaf bucket across + /// the outer's inners and any data field). Used to decide whether to + /// write a 0-valued subtotal cell or skip it entirely on a sparse row. + /// + private static bool HasAnyValueInRowOuter(string row, string outerCol, + List<(string outer, List inners)> colGroups, + Dictionary<(string r, string oc, string ic, int d), List> leafBucket, + int dataFieldCount) + { + foreach (var (oc, inners) in colGroups) + { + if (oc != outerCol) continue; + foreach (var inner in inners) + for (int d = 0; d < dataFieldCount; d++) + if (leafBucket.TryGetValue((row, outerCol, inner, d), out var b) && b.Count > 0) + return true; + } + return false; + } + + /// + /// Helper for the multi-row renderer: returns true if the (outer, col) + /// pair has at least one non-empty leaf bucket across any of the K data + /// fields. Used to decide whether to write a 0-valued subtotal cell or + /// skip it entirely (Excel writes nothing rather than a literal 0 for + /// genuinely empty (outer, col) intersections). + /// + private static bool HasAnyValueInOuterCol(string outer, string col, + List<(string outer, List inners)> groups, + Dictionary<(string o, string i, string c, int d), List> leafBucket, + int dataFieldCount) + { + foreach (var (o, inners) in groups) + { + if (o != outer) continue; + foreach (var inner in inners) + for (int d = 0; d < dataFieldCount; d++) + if (leafBucket.TryGetValue((outer, inner, col, d), out var b) && b.Count > 0) + return true; + } + return false; + } + + /// + /// Build an inline-string cell. We use inline strings (t="inlineStr" + <is>) + /// rather than the SharedStringTable because the renderer is self-contained + /// and adding entries to the SST would require coordinating with whatever + /// other handler code touches the workbook's strings — out of scope for v1. + /// + private static Cell MakeStringCell(int colIdx, int rowIdx, string text) + { + return new Cell + { + CellReference = $"{IndexToCol(colIdx)}{rowIdx}", + DataType = CellValues.InlineString, + InlineString = new InlineString(new Text(text ?? string.Empty)) + }; + } + + /// + /// Numeric cell with the value serialized using invariant culture. + /// When is provided, the cell carries that + /// styles.xml cellXfs index — used to inherit the source column's number + /// format (currency, percentage, custom format) onto pivot value cells so + /// the pivot displays "¥1,234.50" rather than the raw "1234.5". + /// + private static Cell MakeNumericCell(int colIdx, int rowIdx, double value, uint? styleIndex = null) + { + var cell = new Cell + { + CellReference = $"{IndexToCol(colIdx)}{rowIdx}", + CellValue = new CellValue(value.ToString("R", System.Globalization.CultureInfo.InvariantCulture)) + }; + if (styleIndex.HasValue) + cell.StyleIndex = styleIndex.Value; + return cell; + } + + // ==================== Date Grouping Preprocessing ==================== + + /// + /// Metadata describing one date-grouped derived field. Used by the cache + /// builder to emit native Excel <fieldGroup> XML that makes + /// Excel recognize the derived field as a proper date bucket (required + /// for the rendered layout to appear — without this, Excel detects a + /// "fieldGroup shape mismatch" and falls back to grand-total only). + /// + private sealed class DateGroupSpec + { + /// Index of the original date field in the final columnData list. + public int BaseFieldIdx { get; set; } + /// Index of this derived field in the final columnData list. + public int DerivedFieldIdx { get; set; } + /// Grouping kind: "year" / "quarter" / "month" / "day". + public string Grouping { get; set; } = ""; + /// Minimum date observed across the source column. + public DateTime? MinDate { get; set; } + /// Maximum date observed across the source column. + public DateTime? MaxDate { get; set; } + } + + /// + /// Scans rows/cols/filters properties for fieldName:grouping syntax + /// and creates a new virtual column per unique (field, grouping) pair. The + /// original property strings are rewritten in-place so downstream + /// ParseFieldList sees clean names. + /// + /// Example: input properties + /// rows = "日期:year,日期:quarter" + /// cols = "产品" + /// With source columns [日期, 产品, 金额], returns: + /// headers = [日期, 产品, 金额, 日期 (Year), 日期 (Quarter)] + /// columnData = [orig days, products, amounts, year labels, quarter labels] + /// dateGroups = [ {Base=0, Derived=3, Grouping=year}, {Base=0, Derived=4, Grouping=quarter} ] + /// And mutates properties to: + /// rows = "日期 (Year),日期 (Quarter)" + /// + /// Multiple field specs referencing the same (field, grouping) pair share + /// the single virtual column. Rows that don't parse as dates pass through + /// unchanged so columns with a few stray non-date rows don't break. + /// + private static (string[] headers, List columnData, List dateGroups) ApplyDateGrouping( + string[] headers, List columnData, Dictionary properties) + { + // Track virtual columns keyed by (srcIdx, grouping). Value = new + // column's header name, used to rewrite property references. + var virtualColumns = new Dictionary<(int srcIdx, string grouping), string>(); + + bool RewriteFieldListProp(string propKey) + { + if (!properties.TryGetValue(propKey, out var raw) || string.IsNullOrEmpty(raw)) + return false; + + var parts = raw.Split(','); + var outParts = new List(parts.Length); + bool changed = false; + + foreach (var p in parts) + { + var spec = p.Trim(); + if (spec.Length == 0) continue; + + // Grouping suffix is allowed only if the prefix matches an + // existing header. Otherwise the ':' might be part of the + // field name (unlikely in practice but allowed by the parser) + // and we must not mangle it. + var colonIdx = spec.LastIndexOf(':'); + if (colonIdx <= 0 || colonIdx == spec.Length - 1) + { + outParts.Add(spec); + continue; + } + + var fieldName = spec.Substring(0, colonIdx).Trim(); + var grouping = spec.Substring(colonIdx + 1).Trim().ToLowerInvariant(); + if (grouping != "year" && grouping != "quarter" + && grouping != "month" && grouping != "day") + { + outParts.Add(spec); + continue; + } + + // Locate the source field. + int srcIdx = -1; + for (int i = 0; i < headers.Length; i++) + { + if (headers[i] != null && headers[i].Equals(fieldName, StringComparison.OrdinalIgnoreCase)) + { + srcIdx = i; + break; + } + } + if (srcIdx < 0) + { + outParts.Add(spec); + continue; + } + + if (!virtualColumns.TryGetValue((srcIdx, grouping), out var virtName)) + { + virtName = $"{fieldName} ({CapitalizeFirst(grouping)})"; + virtualColumns[(srcIdx, grouping)] = virtName; + } + outParts.Add(virtName); + changed = true; + } + + if (changed) + properties[propKey] = string.Join(",", outParts); + return changed; + } + + bool any = false; + any |= RewriteFieldListProp("rows"); + any |= RewriteFieldListProp("cols"); + any |= RewriteFieldListProp("columns"); + any |= RewriteFieldListProp("filters"); + + var dateGroups = new List(); + + if (!any || virtualColumns.Count == 0) + return (headers, columnData, dateGroups); + + // Materialize each virtual column AND record a DateGroupSpec so the + // cache builder can emit XML. Output ordering follows + // the insertion order of virtualColumns (first reference in props). + // Also walk the source date column once to find min/max for the + // rangePr startDate/endDate attributes Excel requires. + var newHeaders = new List(headers); + foreach (var ((srcIdx, grouping), virtName) in virtualColumns) + { + var src = columnData[srcIdx]; + var derived = new string[src.Length]; + DateTime? min = null, max = null; + for (int r = 0; r < src.Length; r++) + { + derived[r] = BucketDateValue(src[r], grouping); + if (TryParseSourceDate(src[r], out var dt)) + { + if (!min.HasValue || dt < min.Value) min = dt; + if (!max.HasValue || dt > max.Value) max = dt; + } + } + newHeaders.Add(virtName); + columnData.Add(derived); + dateGroups.Add(new DateGroupSpec + { + BaseFieldIdx = srcIdx, + DerivedFieldIdx = columnData.Count - 1, + Grouping = grouping, + MinDate = min, + MaxDate = max, + }); + } + + return (newHeaders.ToArray(), columnData, dateGroups); + } + + /// + /// Parse a cell value as a DateTime, handling both string form + /// ("2024-01-05") and Excel's OLE serial number form ("45296"). Used by + /// ApplyDateGrouping to find the min/max needed for fieldGroup rangePr. + /// + private static bool TryParseSourceDate(string raw, out DateTime dt) + { + dt = default; + if (string.IsNullOrEmpty(raw)) return false; + if (DateTime.TryParse(raw, System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.AssumeLocal, out dt)) + return true; + if (double.TryParse(raw, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var serial)) + { + try { dt = DateTime.FromOADate(serial); return true; } + catch { return false; } + } + return false; + } + + /// + /// Transform a raw cell value into a date bucket label for the given + /// grouping. Accepts either a formatted date string ("2024-01-05") or + /// Excel's serial number form ("45296"). Unparseable values pass through + /// unchanged. + /// + private static string BucketDateValue(string raw, string grouping) + { + if (string.IsNullOrEmpty(raw)) return raw ?? string.Empty; + + DateTime dt; + if (!DateTime.TryParse(raw, System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.AssumeLocal, out dt)) + { + if (double.TryParse(raw, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var serial)) + { + try { dt = DateTime.FromOADate(serial); } + catch { return raw; } + } + else + { + return raw; + } + } + + // Bucket labels must match the canonical names emitted by + // ComputeDateGroupBuckets (Qtr1..Qtr4 / Jan..Dec / 1..31) so the + // cache's groupItems and the renderer's columnData agree on bucket + // identity. Cross-year disambiguation for quarter/month/day is + // handled by the year field (if present as a sibling row/col). + return grouping switch + { + "year" => dt.Year.ToString("D4", System.Globalization.CultureInfo.InvariantCulture), + "quarter" => $"Qtr{(dt.Month - 1) / 3 + 1}", + "month" => MonthShortName(dt.Month), + "day" => dt.Day.ToString(System.Globalization.CultureInfo.InvariantCulture), + _ => raw, + }; + } + + private static string MonthShortName(int month) + => month switch + { + 1 => "Jan", 2 => "Feb", 3 => "Mar", 4 => "Apr", + 5 => "May", 6 => "Jun", 7 => "Jul", 8 => "Aug", + 9 => "Sep", 10 => "Oct", 11 => "Nov", 12 => "Dec", + _ => month.ToString(System.Globalization.CultureInfo.InvariantCulture), + }; + + private static string CapitalizeFirst(string s) + => string.IsNullOrEmpty(s) ? s : char.ToUpperInvariant(s[0]) + s.Substring(1); + + // ==================== Source Data Reader ==================== + + private static (string[] headers, List columnData, uint?[] columnStyleIds) ReadSourceData( + WorksheetPart sourceSheet, string sourceRef) + { + var ws = sourceSheet.Worksheet ?? throw new InvalidOperationException("Worksheet missing"); + var sheetData = ws.GetFirstChild(); + if (sheetData == null) return (Array.Empty(), new List(), Array.Empty()); + + // Parse range "A1:D100" + var parts = sourceRef.Replace("$", "").Split(':'); + if (parts.Length != 2) throw new ArgumentException($"Invalid source range: {sourceRef}"); + + var (startCol, startRow) = ParseCellRef(parts[0]); + var (endCol, endRow) = ParseCellRef(parts[1]); + + var startColIdx = ColToIndex(startCol); + var endColIdx = ColToIndex(endCol); + // R6-3: reject columns beyond Excel's hard max (XFD = 16384). Previously + // XFE / XFZ / ZZZZ silently parsed into oversized indices, produced a + // giant colCount, and either crashed deep in the renderer or wrote an + // invalid source range into the cache. + const int ExcelMaxColumn = 16384; // XFD + if (startColIdx > ExcelMaxColumn) + throw new ArgumentException($"Column {startCol} out of range (max: XFD)"); + if (endColIdx > ExcelMaxColumn) + throw new ArgumentException($"Column {endCol} out of range (max: XFD)"); + var colCount = endColIdx - startColIdx + 1; + + // Read all rows in range. We also capture the StyleIndex of the first + // non-empty data cell per column (skipping the header row) so pivot + // value cells can inherit the source column's number format. This + // mirrors how Excel's pivot engine picks the column format: it looks + // at the data-area formatting, not the header. + var rows = new List(); + var columnStyleIds = new uint?[colCount]; + var sst = sourceSheet.OpenXmlPackage is SpreadsheetDocument doc + ? doc.WorkbookPart?.GetPartsOfType().FirstOrDefault() + : null; + + foreach (var row in sheetData.Elements()) + { + var rowIdx = (int)(row.RowIndex?.Value ?? 0); + if (rowIdx < startRow || rowIdx > endRow) continue; + + var values = new string[colCount]; + foreach (var cell in row.Elements()) + { + var cellRef = cell.CellReference?.Value ?? ""; + var (cn, _) = ParseCellRef(cellRef); + var ci = ColToIndex(cn) - startColIdx; + if (ci < 0 || ci >= colCount) continue; + + values[ci] = GetCellText(cell, sst); + + // Capture style from first non-header data cell per column. + // rowIdx > startRow skips the header row; we keep the first + // one we encounter and ignore subsequent rows. + if (rowIdx > startRow && columnStyleIds[ci] == null && cell.StyleIndex?.Value is uint sIdx && sIdx != 0) + columnStyleIds[ci] = sIdx; + } + rows.Add(values); + } + + if (rows.Count == 0) return (Array.Empty(), new List(), Array.Empty()); + + // First row = headers (ensure no nulls) + var headers = rows[0].Select(h => h ?? "").ToArray(); + // Remaining rows = data, transposed to column-major for cache + var columnDataList = new List(); + for (int c = 0; c < colCount; c++) + { + var colVals = new string[rows.Count - 1]; + for (int r = 1; r < rows.Count; r++) + colVals[r - 1] = rows[r][c] ?? ""; + columnDataList.Add(colVals); + } + + return (headers, columnDataList, columnStyleIds); + } + + private static string GetCellText(Cell cell, SharedStringTablePart? sst) + { + // Handle InlineString cells (t="inlineStr") — used by openpyxl and some other tools + if (cell.DataType?.Value == CellValues.InlineString) + return cell.InlineString?.InnerText ?? ""; + + var value = cell.CellValue?.Text ?? ""; + if (cell.DataType?.Value == CellValues.SharedString && sst?.SharedStringTable != null) + { + if (int.TryParse(value, out int idx)) + { + var item = sst.SharedStringTable.Elements().ElementAtOrDefault(idx); + return item?.InnerText ?? value; + } + } + return value; + } + + // ==================== Cache Definition Builder ==================== + + private static (PivotCacheDefinition def, bool[] fieldNumeric, Dictionary[] fieldValueIndex) + BuildCacheDefinition( + string sourceSheetName, string sourceRef, + string[] headers, List columnData, + HashSet? axisFieldIndices = null, + List? dateGroups = null) + { + var recordCount = columnData.Count > 0 ? columnData[0].Length : 0; + + // refreshOnLoad=1 tells Excel to re-render the pivot from the cache when the + // file is opened. We need this because officecli (a pure DOM library) does NOT + // have a pivot computation engine — we cannot materialize the rendered cells + // into sheetData ourselves. Real Excel/LibreOffice DO write rendered cells on + // save (verified against pivot5.xlsx and pivot_dark1.xlsx fixtures), so opening + // their files shows data immediately. Without refreshOnLoad, our pivot-only + // sheet would render empty even though the cache and definition are valid. + // + // Trade-off: Excel may prompt for trust before refreshing, and consumers that + // do not implement refresh (POI, third-party parsers) will still see an empty + // sheet. The proper long-term fix is a built-in render engine; this flag is + // the lowest-cost workaround until that lands. + var cacheDef = new PivotCacheDefinition + { + CreatedVersion = 3, + MinRefreshableVersion = 3, + RefreshedVersion = 3, + RecordCount = (uint)recordCount, + RefreshOnLoad = true + }; + + // CacheSource -> WorksheetSource + var cacheSource = new CacheSource { Type = SourceValues.Worksheet }; + cacheSource.AppendChild(new WorksheetSource + { + Reference = sourceRef, + Sheet = sourceSheetName + }); + cacheDef.AppendChild(cacheSource); + + // CacheFields — also build per-field metadata used to write records: + // - fieldNumeric[i]: true if field i is numeric (records emit ) + // - fieldValueIndex[i]: value→sharedItems index map for non-numeric fields + // (records emit referencing this index) + // + // Date group handling: + // - Base date field gets standard enumerated items PLUS a pointer to the FIRST derived field (Excel's convention). + // - Each derived field writes a synthetic cacheField with + // databaseField="0", a containing + // and a + // list of string labels — including LEADING/TRAILING + // sentinels ("endDate") that Excel requires. + // - Derived fields emit NO entries in pivotCacheRecords (databaseField=0). + // BuildCacheRecords in the caller must skip them, which we signal by + // setting fieldNumeric[derivedIdx] = false AND leaving fieldValueIndex + // entries pointing into the enumerated shared items of the synthetic + // field. See BuildCacheRecords for the skip logic. + var fieldNumeric = new bool[headers.Length]; + var fieldValueIndex = new Dictionary[headers.Length]; + + // Build quick lookups from the date group specs. + var derivedByIdx = new Dictionary(); + var baseFields = new HashSet(); + if (dateGroups != null) + { + foreach (var g in dateGroups) + { + derivedByIdx[g.DerivedFieldIdx] = g; + baseFields.Add(g.BaseFieldIdx); + } + } + + var cacheFields = new CacheFields { Count = (uint)headers.Length }; + for (int i = 0; i < headers.Length; i++) + { + var fieldName = string.IsNullOrEmpty(headers[i]) ? $"Column{i + 1}" : headers[i]; + var values = i < columnData.Count ? columnData[i] : Array.Empty(); + + if (derivedByIdx.TryGetValue(i, out var spec)) + { + // Derived date group field — synthesized, no records entries. + cacheFields.AppendChild(BuildDateGroupDerivedCacheField(fieldName, spec, + out fieldValueIndex[i])); + fieldNumeric[i] = false; // records should skip this field + continue; + } + + if (baseFields.Contains(i)) + { + // Base date field — enumerate date items (not a plain numeric + // column) and add a pointing at the first + // derived field for this base. Records for this field emit + // referencing the enumerated date items. + int parIdx = derivedByIdx + .Where(kv => kv.Value.BaseFieldIdx == i) + .Min(kv => kv.Key); + cacheFields.AppendChild(BuildDateGroupBaseCacheField(fieldName, values, parIdx, + out fieldValueIndex[i])); + fieldNumeric[i] = false; + continue; + } + + // Axis fields (row/col/filter) go through the string/indexed path + // even when their values parse as numeric, so pivotField items + // indices and cache record references stay in sync. + bool forceStringIndexed = axisFieldIndices?.Contains(i) == true; + cacheFields.AppendChild(BuildCacheField( + fieldName, values, out fieldNumeric[i], out fieldValueIndex[i], forceStringIndexed)); + } + cacheDef.AppendChild(cacheFields); + + return (cacheDef, fieldNumeric, fieldValueIndex); + } + + private static CacheField BuildCacheField( + string name, string[] values, out bool isNumeric, out Dictionary valueIndex, + bool forceStringIndexed = false) + { + var field = new CacheField { Name = name, NumberFormatId = 0u }; + bool valuesAreNumeric = values.Length > 0 && values.All(v => + string.IsNullOrEmpty(v) || double.TryParse(v, System.Globalization.CultureInfo.InvariantCulture, out _)); + // When forceStringIndexed is true (axis fields), report isNumeric=false + // so downstream record-writing code uses the valueIndex map to emit + // references instead of direct values. The + // local 'valuesAreNumeric' still determines which sharedItems branch + // we take below. + isNumeric = valuesAreNumeric && !forceStringIndexed; + valueIndex = new Dictionary(StringComparer.Ordinal); + + var sharedItems = new SharedItems(); + + // MIXED strategy — verified against Microsoft's own pivot5.xlsx (in + // OPEN-XML-SDK test fixtures, authored by real Excel): + // + // • Numeric fields: emit ONLY containsNumber/minValue/maxValue metadata, + // no enumerated items, no count attribute. Records reference values + // directly via . + // • String fields: enumerate every unique value as with + // count attribute. Records reference them by index via . + // + // I previously experimented with LibreOffice's uniform strategy (always + // enumerate, always index-reference), but Microsoft's actual format is + // the mixed one — and matching the real Excel format is the safest bet + // for round-trip compatibility. The uniform strategy is technically valid + // OOXML but introduces an asymmetry that Excel handles less reliably + // (numeric data fields with item enumeration have failed to render in + // testing, even though the file passes schema validation). + if (isNumeric && values.Any(v => !string.IsNullOrEmpty(v))) + { + var nums = values.Where(v => !string.IsNullOrEmpty(v)) + .Select(v => double.Parse(v, System.Globalization.CultureInfo.InvariantCulture)).ToArray(); + sharedItems.ContainsSemiMixedTypes = false; + sharedItems.ContainsString = false; + sharedItems.ContainsNumber = true; + sharedItems.MinValue = nums.Min(); + sharedItems.MaxValue = nums.Max(); + // No items enumerated, no count — records emit directly. + } + else + { + var uniqueValues = values + .Where(v => !string.IsNullOrEmpty(v)) + .Distinct() + .OrderByAxis(v => v) + .ToList(); + sharedItems.Count = (uint)uniqueValues.Count; + for (int i = 0; i < uniqueValues.Count; i++) + { + var v = uniqueValues[i]; + // R2-2: strip XML-illegal chars (e.g. U+0000) before writing. + sharedItems.AppendChild(new StringItem { Val = SanitizeXmlText(v) }); + if (!valueIndex.ContainsKey(v)) + valueIndex[v] = i; + } + } + + field.AppendChild(sharedItems); + return field; + } + + // ==================== Date Group Cache Field Builders ==================== + + /// + /// Build the base date cacheField for a date-grouped column. Enumerates + /// every parsed source date as a <d v="..."/> shared item and + /// appends a <fieldGroup par="N"/> pointing at the first + /// derived field for this base (Excel convention: even when there are + /// multiple derived fields — year + quarter + month — only the lowest + /// par index is written on the base). + /// + /// Verified against Excel-authored /tmp/date_authored.xlsx: the base + /// field has containsDate="1", enumerated ISO-format dates, no + /// containsString/containsNumber attributes. + /// + private static CacheField BuildDateGroupBaseCacheField( + string name, string[] values, int parDerivedIdx, + out Dictionary valueIndex) + { + var field = new CacheField { Name = name, NumberFormatId = 164u }; + valueIndex = new Dictionary(StringComparer.Ordinal); + + // Collect unique parsed dates in source order. Excel enumerates them + // in the order they first appear in the data, which keeps the cache + // record indices stable and human-readable. + var uniqueDates = new List(); + var dateToIdx = new Dictionary(); + DateTime? min = null, max = null; + for (int r = 0; r < values.Length; r++) + { + if (!TryParseSourceDate(values[r], out var dt)) continue; + if (!dateToIdx.ContainsKey(dt)) + { + dateToIdx[dt] = uniqueDates.Count; + uniqueDates.Add(dt); + } + if (!min.HasValue || dt < min.Value) min = dt; + if (!max.HasValue || dt > max.Value) max = dt; + } + + var sharedItems = new SharedItems + { + ContainsSemiMixedTypes = false, + ContainsNonDate = false, + ContainsDate = true, + ContainsString = false, + Count = (uint)uniqueDates.Count + }; + if (min.HasValue) sharedItems.MinDate = min.Value; + if (max.HasValue) sharedItems.MaxDate = max.Value; + + foreach (var dt in uniqueDates) + { + sharedItems.AppendChild(new DateTimeItem { Val = dt }); + } + + // Populate the value→index map so BuildCacheRecords can resolve each + // source row's date value to the correct sharedItems index. The map + // keys are the ORIGINAL raw cell values (not the normalized dates), + // since that's what the record writer will look up. + for (int r = 0; r < values.Length; r++) + { + var raw = values[r]; + if (string.IsNullOrEmpty(raw)) continue; + if (valueIndex.ContainsKey(raw)) continue; + if (TryParseSourceDate(raw, out var dt) && dateToIdx.TryGetValue(dt, out var idx)) + valueIndex[raw] = idx; + } + + field.AppendChild(sharedItems); + + // — the "par" attribute points at the FIRST + // derived field for this base. Verified against /tmp/date_authored.xlsx + // where the base had par=3 pointing at the Quarters field at idx 3. + field.AppendChild(new FieldGroup { ParentId = (uint)parDerivedIdx }); + return field; + } + + /// + /// Build a derived date-group cacheField (Year / Quarter / Month / Day) + /// with databaseField="0" and a synthetic <fieldGroup base=> + /// <rangePr groupBy="..."/> <groupItems>...</groupItems> + /// </fieldGroup> structure. + /// + /// The groupItems list follows Excel's sentinel convention: a leading + /// <startDate and trailing >endDate sentinel bracket + /// the real buckets. Excel uses sentinel indices (0 and last) internally + /// to mark "out of range" values, but for our purposes only the middle + /// real buckets matter. The renderer writes bucket labels directly into + /// sheetData so the sentinel placeholder semantics are moot. + /// + /// The valueIndex map lets BuildCacheRecords resolve each source row's + /// bucketed LABEL value back into a groupItems index ≥ 1 (skipping the + /// leading sentinel). Derived fields do NOT emit records entries because + /// databaseField="0", but we still populate the map defensively. + /// + private static CacheField BuildDateGroupDerivedCacheField( + string name, DateGroupSpec spec, out Dictionary valueIndex) + { + valueIndex = new Dictionary(StringComparer.Ordinal); + + var field = new CacheField + { + Name = name, + NumberFormatId = 0u, + DatabaseField = false // Derived — not backed by a record column + }; + + // Compute bucket labels for the grouping. The order and count must + // match Excel's convention because rowItems/colItems reference these + // indices. Year buckets are per-year observed in the data; quarter + // labels use the Qtr1..Qtr4 short form Excel writes natively. + List buckets = ComputeDateGroupBuckets(spec); + + // Wrap the buckets with Excel's sentinel items: + // idx 0: "endDate" + var startSentinel = spec.MinDate.HasValue + ? "<" + spec.MinDate.Value.ToString("yyyy.MM.dd", System.Globalization.CultureInfo.InvariantCulture) + : "" + (spec.MaxDate.Value < DateTime.MaxValue.Date + ? spec.MaxDate.Value.AddDays(1) + : spec.MaxDate.Value) + .ToString("yyyy.MM.dd", System.Globalization.CultureInfo.InvariantCulture) + : ">end"; + + var allItems = new List(buckets.Count + 2); + allItems.Add(startSentinel); + allItems.AddRange(buckets); + allItems.Add(endSentinel); + + // Populate valueIndex so raw bucket labels (the ones our renderer + // wrote into columnData) resolve to the correct groupItems index. + for (int i = 0; i < buckets.Count; i++) + { + valueIndex[buckets[i]] = i + 1; // +1 for leading sentinel + } + + var fieldGroup = new FieldGroup { Base = (uint)spec.BaseFieldIdx }; + + var rangePr = new RangeProperties + { + GroupBy = spec.Grouping switch + { + "year" => GroupByValues.Years, + "quarter" => GroupByValues.Quarters, + "month" => GroupByValues.Months, + "day" => GroupByValues.Days, + _ => GroupByValues.Days, + }, + }; + if (spec.MinDate.HasValue) rangePr.StartDate = spec.MinDate.Value; + // CONSISTENCY(date-boundary-clamp): same AddDays(1) guard as endSentinel above. + if (spec.MaxDate.HasValue) rangePr.EndDate = spec.MaxDate.Value < DateTime.MaxValue.Date + ? spec.MaxDate.Value.AddDays(1) + : spec.MaxDate.Value; + fieldGroup.AppendChild(rangePr); + + var groupItems = new GroupItems { Count = (uint)allItems.Count }; + foreach (var label in allItems) + // R2-2: defensive sanitize — date labels are code-generated so + // they shouldn't contain control chars, but keep parity with the + // sharedItems writer in case a format spec ever changes. + groupItems.AppendChild(new StringItem { Val = SanitizeXmlText(label) }); + fieldGroup.AppendChild(groupItems); + + field.AppendChild(fieldGroup); + return field; + } + + /// + /// Compute the ordered list of bucket labels for a given date group spec. + /// These labels are FIXED across years (matching Excel's native + /// behavior): quarter → Qtr1..Qtr4, month → Jan..Dec, day → 1..31. + /// Year is the exception: it returns the actual observed years. + /// + /// Excel treats quarter/month/day as CATEGORICAL fields — the same + /// "Qtr1" bucket applies to all years in the data. Different years of + /// the same quarter disambiguate in the rendered pivot via the + /// rowItems/colItems (year_idx, quarter_idx) tuple, not via label + /// text. Verified against /tmp/date_authored.xlsx where quarters + /// enumerated exactly 4 buckets regardless of year range. + /// + /// This is critical: if we emit non-standard labels like "2024-Q1" + /// (which we initially did), Excel's pivot engine crashes when + /// parsing month grouping because it expects Jan..Dec format. The + /// buckets below are the canonical names Excel writes natively. + /// + private static List ComputeDateGroupBuckets(DateGroupSpec spec) + { + var result = new List(); + switch (spec.Grouping) + { + case "year": + // Years ARE actual — observed years in the data. + if (!spec.MinDate.HasValue || !spec.MaxDate.HasValue) return result; + for (int y = spec.MinDate.Value.Year; y <= spec.MaxDate.Value.Year; y++) + result.Add(y.ToString("D4", System.Globalization.CultureInfo.InvariantCulture)); + break; + + case "quarter": + // Fixed set regardless of year range. + result.AddRange(new[] { "Qtr1", "Qtr2", "Qtr3", "Qtr4" }); + break; + + case "month": + // Fixed set. Excel uses 3-letter English month abbreviations + // (Jan..Dec) in its native format — verified against Excel's + // quarter-grouping output which emits "Qtr1..Qtr4". We follow + // the same short-form convention for months. + result.AddRange(new[] + { + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" + }); + break; + + case "day": + // Fixed set — day-of-month 1..31. + for (int d = 1; d <= 31; d++) + result.Add(d.ToString(System.Globalization.CultureInfo.InvariantCulture)); + break; + } + return result; + } + + // ==================== Cache Records Builder ==================== + + /// + /// Build pivotCacheRecords using the MIXED strategy verified against Microsoft's + /// own pivot5.xlsx test fixture: + /// + /// + /// + /// + /// + /// + /// + /// + /// String fields use indexed references () into the per-field + /// sharedItems list; numeric fields use NumberItem () directly, + /// because their cacheField only carries min/max metadata, not enumerated items. + /// + private static PivotCacheRecords BuildCacheRecords( + List columnData, bool[] fieldNumeric, Dictionary[] fieldValueIndex, + HashSet? skipFieldIndices = null) + { + var recordCount = columnData.Count > 0 ? columnData[0].Length : 0; + var fieldCount = columnData.Count; + var records = new PivotCacheRecords { Count = (uint)recordCount }; + + for (int r = 0; r < recordCount; r++) + { + var record = new PivotCacheRecord(); + for (int f = 0; f < fieldCount; f++) + { + // Derived date-group fields carry databaseField="0" and therefore + // don't contribute entries to pivotCacheRecords — they're computed + // on-the-fly by Excel from the base date field's + // / definition. Skip them here so the record + // column count matches the non-derived fields. + if (skipFieldIndices?.Contains(f) == true) continue; + + var v = columnData[f][r]; + if (string.IsNullOrEmpty(v)) + { + record.AppendChild(new MissingItem()); + } + else if (fieldNumeric[f]) + { + record.AppendChild(new NumberItem + { + Val = double.Parse(v, System.Globalization.CultureInfo.InvariantCulture) + }); + } + else if (fieldValueIndex[f].TryGetValue(v, out var idx)) + { + // FieldItem = in OpenXml SDK, references sharedItems[N]. + record.AppendChild(new FieldItem { Val = (uint)idx }); + } + else + { + // Defensive: value missing from the per-field index map. Should + // not occur since the map is built from the same columnData; + // emit rather than a dangling reference. + record.AppendChild(new MissingItem()); + } + } + records.AppendChild(record); + } + + return records; + } + + // ==================== Pivot Table Definition Builder ==================== + + /// + /// Resolve each source column's StyleIndex into the numFmtId that Excel + /// actually needs on DataField. Returns null entries for columns whose + /// source cell had no explicit style (→ General) so the caller can leave + /// DataField.NumberFormatId unset. + /// + private static uint?[] ResolveColumnNumFmtIds(WorkbookPart workbookPart, uint?[] columnStyleIds) + { + var result = new uint?[columnStyleIds.Length]; + var stylesPart = workbookPart.WorkbookStylesPart; + var cellXfs = stylesPart?.Stylesheet?.CellFormats?.Elements().ToList(); + if (cellXfs == null) return result; + for (int i = 0; i < columnStyleIds.Length; i++) + { + var sIdx = columnStyleIds[i]; + if (!sIdx.HasValue) continue; + if (sIdx.Value >= cellXfs.Count) continue; + var xf = cellXfs[(int)sIdx.Value]; + var numFmtId = xf.NumberFormatId?.Value; + // numFmtId == 0 is General → no-op, skip so DataField stays plain + if (numFmtId.HasValue && numFmtId.Value != 0) + result[i] = numFmtId.Value; + } + return result; + } + + // ==================== Pivot style info helpers ==================== + // + // PivotTableStyle carries both the style NAME and five bool layout + // toggles (showRowStripes, showColStripes, showRowHeaders, + // showColHeaders, showLastColumn). CONSISTENCY(canonical-format-key): + // every toggle is a first-class Set key with a canonical lowercase + // form matching ReadPivotTableProperties output. The helper below is + // the single ensure-or-create site so Add and Set never diverge on + // defaults, and style-name changes preserve existing toggles. + + /// + /// Return the pivot's existing <pivotTableStyleInfo> element, creating + /// one with the project-standard defaults if absent. Callers then + /// mutate individual attributes in place. Defaults match the hard- + /// coded values previously duplicated in CreatePivotTable and the + /// Set 'style' case (row/col headers on, stripes off, last column on). + /// + private static PivotTableStyle EnsurePivotTableStyle(PivotTableDefinition pivotDef) + { + if (pivotDef.PivotTableStyle == null) + { + pivotDef.PivotTableStyle = new PivotTableStyle + { + ShowRowHeaders = true, + ShowColumnHeaders = true, + ShowRowStripes = false, + ShowColumnStripes = false, + ShowLastColumn = true + }; + } + return pivotDef.PivotTableStyle; + } + + /// + /// Strict bool parser for pivot style toggles. Accepts true/false/1/0/ + /// yes/no/on/off (case-insensitive) and throws ArgumentException on + /// anything else. CONSISTENCY(strict-enums): matches the sort-mode and + /// showdataas reject-unknown behavior introduced in the recent pivot + /// validation sweep — silent fallbacks mask typos. + /// + private static bool ParsePivotStyleBool(string key, string value) + { + switch ((value ?? "").Trim().ToLowerInvariant()) + { + case "true": case "1": case "yes": case "on": return true; + case "false": case "0": case "no": case "off": return false; + default: + throw new ArgumentException( + $"invalid {key}: '{value}'. Valid: true, false"); + } + } + + /// + /// Apply the five <pivotTableStyleInfo> bool attributes from the + /// caller's properties dict onto an existing PivotTableStyle element. + /// Only keys actually present in the dict are applied, so Set + /// operations can change one toggle without clobbering the others. + /// Accepts both canonical (showColStripes) and OOXML-verbatim + /// (showColumnStripes) spellings for the "col/column" siblings, + /// matching the existing alias policy. + /// + private static void ApplyPivotStyleInfoProps( + PivotTableStyle styleInfo, + Dictionary properties) + { + foreach (var (rawKey, value) in properties) + { + switch (rawKey.ToLowerInvariant()) + { + case "showrowstripes": + styleInfo.ShowRowStripes = ParsePivotStyleBool(rawKey, value); + break; + case "showcolstripes": + case "showcolumnstripes": + styleInfo.ShowColumnStripes = ParsePivotStyleBool(rawKey, value); + break; + case "showrowheaders": + styleInfo.ShowRowHeaders = ParsePivotStyleBool(rawKey, value); + break; + case "showcolheaders": + case "showcolumnheaders": + styleInfo.ShowColumnHeaders = ParsePivotStyleBool(rawKey, value); + break; + case "showlastcolumn": + styleInfo.ShowLastColumn = ParsePivotStyleBool(rawKey, value); + break; + } + } + } + + private static PivotTableDefinition BuildPivotTableDefinition( + string name, uint cacheId, string position, + string[] headers, List columnData, + List rowFieldIndices, List colFieldIndices, + List filterFieldIndices, List<(int idx, string func, string showAs, string name)> valueFields, + string styleName, + uint?[]? columnNumFmtIds = null, + List? dateGroups = null) + { + var pivotDef = new PivotTableDefinition + { + Name = name, + CacheId = cacheId, + DataCaption = "Values", + CreatedVersion = 3, + MinRefreshableVersion = 3, + UpdatedVersion = 3, + ApplyNumberFormats = false, + ApplyBorderFormats = false, + ApplyFontFormats = false, + ApplyPatternFormats = false, + ApplyAlignmentFormats = false, + ApplyWidthHeightFormats = true, + UseAutoFormatting = true, + ItemPrintTitles = true, + MultipleFieldFilters = false, + Indent = 0u, + // outline + outlineData are emitted by both Microsoft Excel (pivot5.xlsx) + // and LibreOffice (pivot_dark1.xlsx). They select the "outline" layout — + // the default presentation where row labels stack into one column. Without + // these, Excel falls back to a layout that's not fully wired through and + // refuses to render the data area. + Outline = true, + OutlineData = true, + // Caption attributes — when present, Excel uses these strings instead + // of its locale-default "Row Labels" / "Column Labels" / "Grand Total". + // Without these the rendered cells we wrote into sheetData ("地区", + // "产品", "总计") get visually overlaid by Excel's English defaults + // because the pivot's caption layer takes precedence over cell content + // when the corresponding caption attribute is empty/missing. + RowHeaderCaption = rowFieldIndices.Count > 0 ? headers[rowFieldIndices[0]] : "Rows", + ColumnHeaderCaption = colFieldIndices.Count > 0 ? headers[colFieldIndices[0]] : "Columns", + GrandTotalCaption = "总计" + }; + + // Grand totals toggles. Both attributes default to true in ECMA-376 — + // only emit when the user opted out, matching real Excel + LibreOffice + // serialization behavior. + if (!ActiveRowGrandTotals) pivotDef.RowGrandTotals = false; + if (!ActiveColGrandTotals) pivotDef.ColumnGrandTotals = false; + + // Use typed property setters to ensure correct schema order + + // Compute the pivot's geometry (range + offsets) via shared helper, so the + // initial CreatePivotTable path and the post-Set RebuildFieldAreas path + // produce identical results. + var geom = ComputePivotGeometry( + position, columnData, rowFieldIndices, colFieldIndices, valueFields); + pivotDef.Location = new Location + { + Reference = geom.RangeRef, + FirstHeaderRow = 1u, + FirstDataRow = (colFieldIndices.Count >= 2 && valueFields.Count > 1) ? 4u + : ((valueFields.Count > 1 || colFieldIndices.Count >= 2) ? 3u : 2u), + FirstDataColumn = (uint)geom.RowLabelCols + }; + + // Page filters: presence is signalled by the element + the + // pivotField axis="axisPage" marker, both written further down. ECMA-376 + // also defines optional rowPageCount / colPageCount attributes here, but + // OpenXml SDK 3.3.0 does not model them and rejects them as unknown + // during schema validation. Excel recognizes the filter without them + // (verified empirically and in pivot_dark1.xlsx, which has filters but + // no page count attributes). Tracked as a v2 polish item if any consumer + // turns out to require them. + + // Derived date-group fields need their pivotField items count to + // match the FIXED bucket count (month=12, quarter=4, day=31, year= + // observed years), not just the values present in the source data. + // Excel validates the cache groupItems count against the pivotField + // items count and crashes if they mismatch (verified with 'months' + // grouping — Excel for Mac hit a hard crash during parser on + // item-count mismatch). + var derivedFieldByIdx = new Dictionary(); + if (dateGroups != null) + foreach (var g in dateGroups) derivedFieldByIdx[g.DerivedFieldIdx] = g; + + // PivotFields — one per source column + var pivotFields = new PivotFields { Count = (uint)headers.Length }; + for (int i = 0; i < headers.Length; i++) + { + var pf = new PivotField { ShowAll = false }; + var values = i < columnData.Count ? columnData[i] : Array.Empty(); + var isNumeric = values.Length > 0 && values.All(v => + string.IsNullOrEmpty(v) || double.TryParse(v, System.Globalization.CultureInfo.InvariantCulture, out _)); + + // Axis fields (row/col/filter) MUST enumerate regardless of + // whether the values look numeric. The "skip items for numeric + // fields" optimization is only valid for data/value fields, whose + // values are referenced directly via in cache records. + // Row/col/filter fields are referenced by INDEX through the + // pivotField items list, so omitting the list leaves rowItems / + // colItems entries dangling. Failure mode verified against a + // date-grouped pivot where year bucket values "2024"/"2025" parse + // as numeric but render as labels — Excel showed only the grand + // total row instead of the year hierarchy. + // R6-2: a field can be on an axis AND a data field at the same + // time (e.g. rows=Region values=Region:count). The axis flag and + // the DataField flag are independent, so check each of them + // separately instead of if/else-if which silently dropped the + // DataField marker. + bool isDerivedDateGroup = derivedFieldByIdx.ContainsKey(i); + bool onAxis = false; + if (rowFieldIndices.Contains(i)) + { + pf.Axis = PivotTableAxisValues.AxisRow; + onAxis = true; + } + else if (colFieldIndices.Contains(i)) + { + pf.Axis = PivotTableAxisValues.AxisColumn; + onAxis = true; + } + else if (filterFieldIndices.Contains(i)) + { + pf.Axis = PivotTableAxisValues.AxisPage; + onAxis = true; + } + if (onAxis) + { + if (isDerivedDateGroup) + AppendFixedBucketItems(pf, derivedFieldByIdx[i]); + else + AppendFieldItems(pf, values); + } + if (valueFields.Any(vf => vf.idx == i)) + { + pf.DataField = true; + } + + _ = isNumeric; // kept for readability; consumed only by data fields above + + pivotFields.AppendChild(pf); + } + pivotDef.PivotFields = pivotFields; + + // RowFields — the synthetic sentinel for multiple data + // fields belongs to whichever axis (rows or columns) actually displays + // the data field labels. The default is dataOnRows=false, so multi-data + // labels go in COLUMNS — meaning the sentinel appears in colFields, NOT + // rowFields. Only add the sentinel here when there are no col fields and + // therefore data must flow in the row dimension. + if (rowFieldIndices.Count > 0) + { + // Note: the synthetic sentinel for multi-data labels + // belongs only on the column axis (default dataOnRows=false). The + // ColumnFields branch below unconditionally adds it when there are + // 2+ data fields, so we must NOT also add it here. + var rf = new RowFields(); + foreach (var idx in rowFieldIndices) + rf.AppendChild(new Field { Index = idx }); + rf.Count = (uint)rf.Elements().Count(); + pivotDef.RowFields = rf; + } + + // RowItems — describes the row-label layout. Without this, Excel renders only the + // pivot's drop-down chrome but no actual data cells (the layout we observed earlier). + // Pattern verified against LibreOffice's pivot_dark1.xlsx test fixture: + // + // <-- index 0 (shorthand: omit v attribute) + // <-- index 1 + // ... + // <-- grand total row + // + // The values index into the corresponding pivotField's list, + // which we already populate via AppendFieldItems in BuildPivotTableDefinition above. + // Single row field only: multi-row-field cartesian-product layout is a v2 concern. + if (rowFieldIndices.Count > 0) + pivotDef.RowItems = (RowItems)BuildAxisItems(rowFieldIndices, columnData, isRow: true, dataFieldCount: 1); + + // ColumnFields — when there are 2+ data fields, append the synthetic + // sentinel that tells Excel "data field labels go in + // the column dimension here". Verified against multi_data_authored.xlsx: + // a 1-row × 1-col × 2-data pivot writes + // . Without this sentinel + // Excel still opens the file but renders the K data fields stacked + // incorrectly. RebuildFieldAreas already handles this; the initial + // build path was missing the sentinel. + if (colFieldIndices.Count > 0 || valueFields.Count > 1) + { + var cf = new ColumnFields(); + foreach (var idx in colFieldIndices) + cf.AppendChild(new Field { Index = idx }); + if (valueFields.Count > 1) + cf.AppendChild(new Field { Index = -2 }); + cf.Count = (uint)cf.Elements().Count(); + pivotDef.ColumnFields = cf; + } + + // ColumnItems — same shape as RowItems but for the column-label layout. + // Even when there are NO column fields, ECMA-376 requires a with one + // empty placeholder; LibreOffice's writeRowColumnItems empty-case branch + // (xepivotxml.cxx:1008-1014) writes exactly that. + pivotDef.ColumnItems = (ColumnItems)BuildAxisItems( + colFieldIndices, columnData, isRow: false, dataFieldCount: valueFields.Count); + + // PageFields (filters) + if (filterFieldIndices.Count > 0) + { + var pf = new PageFields { Count = (uint)filterFieldIndices.Count }; + foreach (var idx in filterFieldIndices) + pf.AppendChild(new PageField { Field = idx, Hierarchy = -1 }); + pivotDef.PageFields = pf; + } + + // DataFields + if (valueFields.Count > 0) + { + var df = new DataFields { Count = (uint)valueFields.Count }; + foreach (var (idx, func, showAs, displayName) in valueFields) + { + // BaseField/BaseItem: Excel ignores these when ShowDataAs is normal, + // but LibreOffice and Excel both emit them unconditionally on every + // dataField (verified against pivot_dark1.xlsx and other LO fixtures). + // Following the verified pattern rather than my earlier "omit them" + // theory — being closer to what real producers write reduces the risk + // of triggering picky consumers. + var dataField = new DataField + { + Name = displayName, + Field = (uint)idx, + Subtotal = ParseSubtotal(func), + BaseField = 0, + BaseItem = 0u + }; + var sda = ParseShowDataAs(showAs); + if (sda.HasValue) dataField.ShowDataAs = sda.Value; + // Inherit the source column's numFmtId so Excel displays + // pivot values using the same format as the source (currency, + // percent, etc.). DataField.NumberFormatId is the primary + // display driver — cell-level StyleIndex alone is ignored by + // Excel for pivot values. + if (columnNumFmtIds != null && idx >= 0 && idx < columnNumFmtIds.Length + && columnNumFmtIds[idx] is uint nfid) + { + dataField.NumberFormatId = nfid; + } + // showDataAs=percent_* always renders as a fraction in [0,1], + // regardless of source column format. Override to built-in + // numFmtId 10 ("0.00%") so Excel displays "43.08%" instead of + // the bare "0.43" the source format would produce. + if (IsPercentShowAs(showAs)) + { + dataField.NumberFormatId = 10u; + } + df.AppendChild(dataField); + } + pivotDef.DataFields = df; + } + + // Style: create with project-standard defaults via the shared + // EnsurePivotTableStyle helper so Set and Add never diverge on + // defaults. The caller (CreatePivotTable) overlays any user- + // supplied style-info toggles via ApplyPivotStyleInfoProps before + // the definition is saved. + var styleInfo = EnsurePivotTableStyle(pivotDef); + styleInfo.Name = styleName; + + return pivotDef; + } + + /// + /// Build the <rowItems> or <colItems> layout block. Excel uses this to + /// know how to expand row/column labels in the rendered pivot. + /// + /// Single data field (K=1): + /// + /// <-- index 0 (shorthand: omit v) + /// + /// ... + /// + /// + /// + /// Multi-data field on the column axis (K>1, only used for ColumnItems): + /// + /// <-- col label 0, data field 0 + /// <-- col label 0, data field 1 (r=1 = repeat prev x) + /// <-- col label 1, data field 0 + /// <-- col label 1, data field 1 + /// ... + /// <-- grand total, data field 0 + /// <-- grand total, data field 1 + /// + /// Verified against multi_data_authored.xlsx (a 1×1×2 pivot from real Excel). + /// + /// Empty axis: single <i/> placeholder (LibreOffice writeRowColumnItems + /// empty-case branch in xepivotxml.cxx:1008-1014). + /// + /// Limitation: still only single-axis-field cases are correct. Multi-row-field + /// cartesian-product layouts need a deeper expansion tracked as v2. + /// + private static OpenXmlElement BuildAxisItems( + List fieldIndices, List columnData, bool isRow, int dataFieldCount = 1) + { + OpenXmlCompositeElement container = isRow + ? new RowItems() + : new ColumnItems(); + + // Empty axis: write a single empty . LibreOffice does this unconditionally + // when there's nothing to render — Excel needs the placeholder. When there are + // multiple data fields on the column axis but no col field, we still need + // K entries (one per data field) instead of just one — handled below. + if (fieldIndices.Count == 0) + { + if (!isRow && dataFieldCount > 1) + { + // Data-only column axis: K entries, each marked with i="d". + for (int d = 0; d < dataFieldCount; d++) + { + var item = new RowItem(); + if (d > 0) item.Index = (uint)d; + item.AppendChild(new MemberPropertyIndex()); + container.AppendChild(item); + } + SetAxisCount(container, dataFieldCount); + } + else + { + container.AppendChild(new RowItem()); + SetAxisCount(container, 1); + } + return container; + } + + // N≥3 axis: route to tree-based items writer that uses LCP encoding + // (longest common prefix) to compress arbitrary-depth path encoding. + // Falls back to specialized N=2 path below for byte-level backward + // compat with the regression baseline. + if (fieldIndices.Count >= 3) + { + return BuildTreeAxisItems(fieldIndices, columnData, isRow, dataFieldCount); + } + + // Multi-col case (N>=2 col fields, only used for ColumnItems). + // + // Pattern (verified against multi_col_authored.xlsx with cols=产品,包装): + // For each outer col value O: + // <- O + first inner (2 x children) + // For each subsequent inner I (sorted): + // <- repeat outer, just give inner + // <- O subtotal column + // <- final grand total column + // + // Compared to BuildMultiRowItems: col subtotals use t="default" (not the + // bare- form rows use), and the leaf entries have 2 x children for + // the first inner of each group instead of just 1. + if (!isRow && fieldIndices.Count >= 2) + { + return BuildMultiColItems(fieldIndices, columnData, dataFieldCount); + } + + // Multi-row case (N>=2 row fields, only used for RowItems). + // + // Pattern (verified against multi_row_authored.xlsx with 2 row fields, + // where the user manually built a pivot with rows=地区,城市): + // For each outer value O in display order: + // <- outer subtotal row (1 x child) + // For each inner value I that exists in (O, *): + // <- leaf row (r=1 = repeat outer) + // <- final grand total + // + // The "1 x child only" form is treated by Excel as the outer-level + // subtotal row (it shows aggregate across all this outer's inners). Leaf + // rows use r='1' to mean "the first 1 member is inherited from the + // previous row" (the outer index), so the leaf only needs its own inner + // index as a single x child. + // + // This implementation supports exactly N=2 row fields. N>=3 would need a + // recursive expansion at every non-leaf level — tracked as v4. + if (isRow && fieldIndices.Count >= 2) + { + return BuildMultiRowItems(fieldIndices, columnData); + } + + // Single field: one per unique value, then a grand-total entry. + // Multi-field is not yet supported — fall back to the first field's values + // so the file is at least openable; rendering will be incomplete. + var fieldIdx = fieldIndices[0]; + if (fieldIdx < 0 || fieldIdx >= columnData.Count) + { + container.AppendChild(new RowItem()); + SetAxisCount(container, 1); + return container; + } + + var uniqueCount = columnData[fieldIdx] + .Where(v => !string.IsNullOrEmpty(v)) + .Distinct() + .Count(); + + // Multi-data on column axis: each col label gets K entries, then K grand totals. + // The first entry per col label has TWO children (col index + data field 0); + // subsequent entries use r="1" to repeat the col index and bump i to the data + // field number. + if (!isRow && dataFieldCount > 1) + { + for (int i = 0; i < uniqueCount; i++) + { + // Entry for data field 0: + var first = new RowItem(); + if (i == 0) + first.AppendChild(new MemberPropertyIndex()); + else + first.AppendChild(new MemberPropertyIndex { Val = i }); + first.AppendChild(new MemberPropertyIndex()); + container.AppendChild(first); + + // Entries for data fields 1..K-1: + for (int d = 1; d < dataFieldCount; d++) + { + var rep = new RowItem + { + RepeatedItemCount = 1u, + Index = (uint)d + }; + if (d == 0) + rep.AppendChild(new MemberPropertyIndex()); + else + rep.AppendChild(new MemberPropertyIndex { Val = d }); + container.AppendChild(rep); + } + } + + // Grand totals: K entries marked t="grand", with i=d for d>0. + for (int d = 0; d < dataFieldCount; d++) + { + var gt = new RowItem { ItemType = ItemValues.Grand }; + if (d > 0) gt.Index = (uint)d; + gt.AppendChild(new MemberPropertyIndex()); + container.AppendChild(gt); + } + + SetAxisCount(container, uniqueCount * dataFieldCount + dataFieldCount); + return container; + } + + // Single-data layout (original path): K data rows + 1 grand total. + for (int i = 0; i < uniqueCount; i++) + { + var item = new RowItem(); + if (i == 0) + item.AppendChild(new MemberPropertyIndex()); + else + item.AppendChild(new MemberPropertyIndex { Val = i }); + container.AppendChild(item); + } + + // Grand total entry — always present in the default layout. + var grandTotal = new RowItem { ItemType = ItemValues.Grand }; + grandTotal.AppendChild(new MemberPropertyIndex()); + container.AppendChild(grandTotal); + + SetAxisCount(container, uniqueCount + 1); + return container; + } + + /// + /// Compute the (outer → ordered list of inners) groupings for a 2-row-field + /// pivot. Only (outer, inner) combinations that actually appear in the + /// source data are included — Excel does not enumerate empty cartesian + /// cells in compact mode. Output is sorted by ordinal: outer keys first, + /// then each outer's inner list. Used by both BuildMultiRowItems (XML + /// rowItems generation) and the renderer (cell layout). + /// + private static List<(string outer, List inners)> BuildOuterInnerGroups( + int outerFieldIdx, int innerFieldIdx, List columnData) + { + var outerVals = columnData[outerFieldIdx]; + var innerVals = columnData[innerFieldIdx]; + var n = outerVals.Length; + + var seen = new HashSet<(string, string)>(); + var combos = new List<(string outer, string inner)>(); + for (int i = 0; i < n; i++) + { + var ov = outerVals[i]; + var iv = innerVals[i]; + if (string.IsNullOrEmpty(ov) || string.IsNullOrEmpty(iv)) continue; + if (seen.Add((ov, iv))) + combos.Add((ov, iv)); } - field.AppendChild(sharedItems); - return field; + // Sort using the active axis comparer so display order matches the + // pivotField items list (which sorts via the same comparer). This + // keeps rowItems indices in sync with rendered cell labels. + return combos + .GroupBy(c => c.outer, StringComparer.Ordinal) // equality, not ordering + .OrderByAxis(g => g.Key) + .Select(g => (g.Key, g.Select(c => c.inner) + .OrderByAxis(v => v).ToList())) + .ToList(); } - // ==================== Pivot Table Definition Builder ==================== - - private static PivotTableDefinition BuildPivotTableDefinition( - string name, uint cacheId, string position, - string[] headers, List columnData, - List rowFieldIndices, List colFieldIndices, - List filterFieldIndices, List<(int idx, string func, string name)> valueFields, - string styleName) + /// + /// Build the <rowItems> element for a 2-row-field pivot. Emits one + /// outer-subtotal row per unique outer value plus one leaf row per + /// (outer, inner) combination that exists in the data, then the grand + /// total. See BuildOuterInnerGroups for the grouping logic. + /// + private static OpenXmlElement BuildMultiRowItems( + List fieldIndices, List columnData) { - var pivotDef = new PivotTableDefinition + var container = new RowItems(); + if (fieldIndices.Count < 2 || fieldIndices[0] >= columnData.Count || fieldIndices[1] >= columnData.Count) { - Name = name, - CacheId = cacheId, - DataCaption = "Values", - CreatedVersion = 3, - MinRefreshableVersion = 3, - UpdatedVersion = 3, - ApplyNumberFormats = false, - ApplyBorderFormats = false, - ApplyFontFormats = false, - ApplyPatternFormats = false, - ApplyAlignmentFormats = false, - ApplyWidthHeightFormats = true, - UseAutoFormatting = true, - ItemPrintTitles = true, - MultipleFieldFilters = false, - Indent = 0u - }; + container.AppendChild(new RowItem()); + container.Count = 1u; + return container; + } - // Use typed property setters to ensure correct schema order + var outerIdx = fieldIndices[0]; + var innerIdx = fieldIndices[1]; + var groups = BuildOuterInnerGroups(outerIdx, innerIdx, columnData); - // Location - pivotDef.Location = new Location - { - Reference = position, - FirstHeaderRow = 1u, - FirstDataRow = 1u, - FirstDataColumn = (uint)rowFieldIndices.Count - }; + // Pre-compute the value→pivotField-items-index map for both row fields. + // The pivotField items list is built with StringComparer.Ordinal in + // AppendFieldItems below, so we mirror the same ordering here to keep + // the indices consistent. + var outerOrder = columnData[outerIdx] + .Where(v => !string.IsNullOrEmpty(v)) + .Distinct() + .OrderByAxis(v => v) + .Select((v, i) => (v, i)) + .ToDictionary(t => t.v, t => t.i, StringComparer.Ordinal); + var innerOrder = columnData[innerIdx] + .Where(v => !string.IsNullOrEmpty(v)) + .Distinct() + .OrderByAxis(v => v) + .Select((v, i) => (v, i)) + .ToDictionary(t => t.v, t => t.i, StringComparer.Ordinal); - // PivotFields — one per source column - var pivotFields = new PivotFields { Count = (uint)headers.Length }; - for (int i = 0; i < headers.Length; i++) + int count = 0; + foreach (var (outer, inners) in groups) { - var pf = new PivotField { ShowAll = false }; - var values = i < columnData.Count ? columnData[i] : Array.Empty(); - var isNumeric = values.Length > 0 && values.All(v => - string.IsNullOrEmpty(v) || double.TryParse(v, System.Globalization.CultureInfo.InvariantCulture, out _)); + // Outer subtotal row: + // ECMA-376 §18.10.1.44: r=0 requires fieldCount (2) children. + // Pad with the inner field's default item index. + var outerEntry = new RowItem(); + var outerPivIdx = outerOrder[outer]; + if (outerPivIdx == 0) + outerEntry.AppendChild(new MemberPropertyIndex()); + else + outerEntry.AppendChild(new MemberPropertyIndex { Val = outerPivIdx }); + var innerDefaultIdx = innerOrder.Count; + if (innerDefaultIdx == 0) + outerEntry.AppendChild(new MemberPropertyIndex()); + else + outerEntry.AppendChild(new MemberPropertyIndex { Val = innerDefaultIdx }); + container.AppendChild(outerEntry); + count++; - if (rowFieldIndices.Contains(i)) - { - pf.Axis = PivotTableAxisValues.AxisRow; - if (!isNumeric) AppendFieldItems(pf, values); - } - else if (colFieldIndices.Contains(i)) + // Leaf rows for each inner of this outer: + foreach (var inner in inners) { - pf.Axis = PivotTableAxisValues.AxisColumn; - if (!isNumeric) AppendFieldItems(pf, values); + var leafEntry = new RowItem { RepeatedItemCount = 1u }; + var innerPivIdx = innerOrder[inner]; + if (innerPivIdx == 0) + leafEntry.AppendChild(new MemberPropertyIndex()); + else + leafEntry.AppendChild(new MemberPropertyIndex { Val = innerPivIdx }); + container.AppendChild(leafEntry); + count++; } - else if (filterFieldIndices.Contains(i)) + } + + // Grand total row. + var grand = new RowItem { ItemType = ItemValues.Grand }; + grand.AppendChild(new MemberPropertyIndex()); + container.AppendChild(grand); + count++; + + container.Count = (uint)count; + return container; + } + + /// + /// Build the <colItems> element for a 2-col-field pivot, supporting K + /// data fields. Mirrors BuildMultiRowItems but uses the col-subtotal + /// pattern (t="default") instead of the bare-i form rows use, and the + /// first leaf of each outer group emits 2 x children (outer + inner). + /// + /// For K>1 (multi-col + multi-data, e.g. 1×2×2), each leaf and each + /// subtotal/grand-total entry is multiplied by K, with the additional + /// data field entries using r='2' (repeat outer + inner) and i='d' to + /// flag the data field index. Verified against multi_col_K_authored.xlsx. + /// + private static OpenXmlElement BuildMultiColItems( + List fieldIndices, List columnData, int dataFieldCount) + { + var container = new ColumnItems(); + if (fieldIndices.Count < 2 || fieldIndices[0] >= columnData.Count || fieldIndices[1] >= columnData.Count) + { + container.AppendChild(new RowItem()); + container.Count = 1u; + return container; + } + + var outerIdx = fieldIndices[0]; + var innerIdx = fieldIndices[1]; + var groups = BuildOuterInnerGroups(outerIdx, innerIdx, columnData); + + // Value → pivotField-items-index map (alphabetical ordinal sort). + var outerOrder = columnData[outerIdx] + .Where(v => !string.IsNullOrEmpty(v)) + .Distinct() + .OrderByAxis(v => v) + .Select((v, i) => (v, i)) + .ToDictionary(t => t.v, t => t.i, StringComparer.Ordinal); + var innerOrder = columnData[innerIdx] + .Where(v => !string.IsNullOrEmpty(v)) + .Distinct() + .OrderByAxis(v => v) + .Select((v, i) => (v, i)) + .ToDictionary(t => t.v, t => t.i, StringComparer.Ordinal); + + int K = Math.Max(1, dataFieldCount); + int count = 0; + foreach (var (outer, inners) in groups) + { + var outerPivIdx = outerOrder[outer]; + + for (int idx = 0; idx < inners.Count; idx++) { - pf.Axis = PivotTableAxisValues.AxisPage; - if (!isNumeric) AppendFieldItems(pf, values); + var inner = inners[idx]; + var innerPivIdx = innerOrder[inner]; + + // First leaf of (this outer, this inner): K entries (one per data field). + // The very first entry has the full path; subsequent K-1 use r=2 (repeat + // outer + inner) to compress the encoding. + for (int d = 0; d < K; d++) + { + if (d == 0) + { + // First data field: full path. + // For new outer (idx==0): 2 or 3 x children (outer + inner + maybe d). + // With K==1: just outer + inner = 2 x children. + // With K>1: outer + inner + first data = 3 x children. + // For new inner (idx>0) with new outer leaf area: r=1 (repeat outer) + // With K==1: r=1, then inner = 1 x child total. + // With K>1: r=1, then inner + first data = 2 x children. + if (idx == 0) + { + // First leaf of new outer: write everything fresh. + var first = new RowItem(); + if (outerPivIdx == 0) first.AppendChild(new MemberPropertyIndex()); + else first.AppendChild(new MemberPropertyIndex { Val = outerPivIdx }); + if (innerPivIdx == 0) first.AppendChild(new MemberPropertyIndex()); + else first.AppendChild(new MemberPropertyIndex { Val = innerPivIdx }); + if (K > 1) + { + // First data field index = 0 → bare + first.AppendChild(new MemberPropertyIndex()); + } + container.AppendChild(first); + } + else + { + // Inner shift within same outer: r=1 keeps outer. + var rep = new RowItem { RepeatedItemCount = 1u }; + if (innerPivIdx == 0) rep.AppendChild(new MemberPropertyIndex()); + else rep.AppendChild(new MemberPropertyIndex { Val = innerPivIdx }); + if (K > 1) rep.AppendChild(new MemberPropertyIndex()); + container.AppendChild(rep); + } + } + else + { + // Additional data field for the same (outer, inner): r=2 keeps + // outer + inner, i=d marks the data field, x v=d gives the index. + var rep = new RowItem { RepeatedItemCount = 2u, Index = (uint)d }; + if (d == 0) rep.AppendChild(new MemberPropertyIndex()); + else rep.AppendChild(new MemberPropertyIndex { Val = d }); + container.AppendChild(rep); + } + count++; + } } - else if (valueFields.Any(vf => vf.idx == i)) + + // Outer subtotal columns: K entries with t="default", x v=outer, i=d for d>0. + for (int d = 0; d < K; d++) { - pf.DataField = true; + var sub = new RowItem { ItemType = ItemValues.Default }; + if (d > 0) sub.Index = (uint)d; + if (outerPivIdx == 0) sub.AppendChild(new MemberPropertyIndex()); + else sub.AppendChild(new MemberPropertyIndex { Val = outerPivIdx }); + container.AppendChild(sub); + count++; } - - pivotFields.AppendChild(pf); } - pivotDef.PivotFields = pivotFields; - // RowFields - if (rowFieldIndices.Count > 0) + // Grand total columns: K entries with t="grand", x=0, i=d for d>0. + for (int d = 0; d < K; d++) { - var rf = new RowFields { Count = (uint)rowFieldIndices.Count }; - foreach (var idx in rowFieldIndices) - rf.AppendChild(new Field { Index = idx }); - if (valueFields.Count > 1) - rf.AppendChild(new Field { Index = -2 }); - pivotDef.RowFields = rf; + var grand = new RowItem { ItemType = ItemValues.Grand }; + if (d > 0) grand.Index = (uint)d; + grand.AppendChild(new MemberPropertyIndex()); + container.AppendChild(grand); + count++; } - // ColumnFields - if (colFieldIndices.Count > 0) + container.Count = (uint)count; + return container; + } + + /// + /// Generic axis-items writer for N≥3 row or col fields. Walks the AxisTree + /// in display order and emits RowItem entries with longest-common-prefix + /// (LCP) compression for the <i r="K"> repeat attribute. + /// + /// Pattern (verified by extending the N=2 patterns recursively): + /// - Each entry has 1 logical "path" of length = entry depth (subtotals + /// have shorter paths than leaves). + /// - r = LCP(this.path, prev.path). x children = path elements after the LCP. + /// - For N=2 cases this naturally collapses to the existing + /// BuildMultiRowItems / BuildMultiColItems output (verified by hand). + /// - Row axis: subtotals are bare <i> entries. They sit BEFORE their + /// children in walk order. + /// - Col axis: subtotals are <i t="default"> entries that always emit + /// r=0 + 1 x child for the path's last (and only) element. They sit + /// AFTER their children in walk order. This matches the empirical + /// observation that Excel "resets" the inheritance chain at every + /// col-axis subtotal. + /// - Grand total: <i t="grand"> with bare <x/>, always r=0. + /// + /// For K>1 on the column axis, each logical entry (leaf, subtotal, grand) + /// is multiplied by K, mirroring the BuildMultiColItems pattern: + /// - Leaf d=0: LCP-compressed path + 1 extra <x/> for data field 0. + /// - Leaf d∈[1,K): r=path.Length, i=d, 1 <x v=d/>. (The whole + /// non-data path is inherited from d=0; i=d flags this as "same + /// cell position, different data field".) + /// - Subtotal d=0: as in K=1 (r=0 + 1 x child for path[last]). + /// - Subtotal d∈[1,K): same x child, add i=d attribute. + /// - Grand d=0: bare <x/>. Grand d∈[1,K): bare <x/> + i=d. + /// Row axis is never K-multiplied regardless of K — verified against + /// 2x1x1 vs 2x1xK baselines where rowItems.count is identical. + /// + private static OpenXmlElement BuildTreeAxisItems( + List fieldIndices, List columnData, bool isRow, int dataFieldCount) + { + var container = isRow + ? (OpenXmlCompositeElement)new RowItems() + : new ColumnItems(); + + var tree = BuildAxisTree(fieldIndices, columnData); + + // Pre-compute per-level value→index maps so the emitted + // references match the corresponding pivotField items list (which + // we sort with StringComparer.Ordinal in AppendFieldItems). + var perLevelOrder = new Dictionary[fieldIndices.Count]; + for (int level = 0; level < fieldIndices.Count; level++) { - var cf = new ColumnFields { Count = (uint)colFieldIndices.Count }; - foreach (var idx in colFieldIndices) - cf.AppendChild(new Field { Index = idx }); - pivotDef.ColumnFields = cf; + var fi = fieldIndices[level]; + if (fi < 0 || fi >= columnData.Count) { perLevelOrder[level] = new Dictionary(); continue; } + perLevelOrder[level] = columnData[fi] + .Where(v => !string.IsNullOrEmpty(v)) + .Distinct() + .OrderByAxis(v => v) + .Select((v, i) => (v, i)) + .ToDictionary(t => t.v, t => t.i, StringComparer.Ordinal); } - // PageFields (filters) - if (filterFieldIndices.Count > 0) + // Collect entries by walking the tree in display order. Each entry is a + // (path, type) pair where type ∈ {leaf, subtotal, grand}. + var entries = new List<(string[] path, string kind)>(); // kind: "leaf" | "subtotal" | "grand" + void Walk(AxisNode node) { - var pf = new PageFields { Count = (uint)filterFieldIndices.Count }; - foreach (var idx in filterFieldIndices) - pf.AppendChild(new PageField { Field = idx, Hierarchy = -1 }); - pivotDef.PageFields = pf; + if (node.IsLeaf) + { + entries.Add((node.Path, "leaf")); + return; + } + // Skip the synthetic root (Depth=0). + if (!isRow && node.Depth > 0) + { + // Col axis: children before subtotal. + foreach (var c in node.Children) Walk(c); + entries.Add((node.Path, "subtotal")); + } + else if (isRow && node.Depth > 0) + { + // Row axis: subtotal before children. + entries.Add((node.Path, "subtotal")); + foreach (var c in node.Children) Walk(c); + } + else + { + // Synthetic root, just recurse. + foreach (var c in node.Children) Walk(c); + } } + Walk(tree); + entries.Add((Array.Empty(), "grand")); - // DataFields - if (valueFields.Count > 0) + // K>1 multiplies col-axis entries by K (one per data field). Row axis + // stays 1 entry per logical row regardless of K. + int K = Math.Max(1, dataFieldCount); + bool kMultiply = !isRow && K > 1; + + // Emit entries with LCP compression. Col-axis subtotals are special-cased + // to always emit r=0 + 1 x child for the outer index (Excel's empirical + // convention — col subtotals "reset" the inheritance chain). + string[] prevPath = Array.Empty(); + int emittedCount = 0; + foreach (var (path, kind) in entries) { - var df = new DataFields { Count = (uint)valueFields.Count }; - foreach (var (idx, func, displayName) in valueFields) + if (kind == "grand") { - df.AppendChild(new DataField + // K entries on col axis, 1 entry on row axis. Each is a bare + // (v=0), with i=d on d∈[1,K) for col axis. + int grandCount = kMultiply ? K : 1; + for (int d = 0; d < grandCount; d++) { - Name = displayName, - Field = (uint)idx, - Subtotal = ParseSubtotal(func), - BaseField = 0, - BaseItem = 0u - }); + var gt = new RowItem { ItemType = ItemValues.Grand }; + if (d > 0) gt.Index = (uint)d; + gt.AppendChild(new MemberPropertyIndex()); + container.AppendChild(gt); + emittedCount++; + } + prevPath = path; + continue; } - pivotDef.DataFields = df; + + if (kind == "subtotal" && !isRow) + { + // Col-axis subtotal: always r=0 + 1 x child for the deepest + // index in the path (the immediate-parent value). Verified + // against multi_col_authored.xlsx. For K>1, emit K of these + // with i=d attribute on d∈[1,K). + int lastLevel = path.Length - 1; + int lastIdx = perLevelOrder[lastLevel].TryGetValue(path[lastLevel], out var li) ? li : 0; + for (int d = 0; d < K; d++) + { + var sub = new RowItem { ItemType = ItemValues.Default }; + if (d > 0) sub.Index = (uint)d; + if (lastIdx == 0) sub.AppendChild(new MemberPropertyIndex()); + else sub.AppendChild(new MemberPropertyIndex { Val = lastIdx }); + container.AppendChild(sub); + emittedCount++; + } + // Reset prev so the next entry doesn't try to inherit through + // the subtotal's truncated path. The next leaf in a new outer + // group will write a fresh path from r=0. + prevPath = path; + continue; + } + + // Leaf entries (both row and col) and row subtotals use LCP encoding. + var item = new RowItem(); + int lcp = 0; + while (lcp < path.Length && lcp < prevPath.Length && path[lcp] == prevPath[lcp]) lcp++; + if (lcp > 0) item.RepeatedItemCount = (uint)lcp; + for (int i = lcp; i < path.Length; i++) + { + int idx = perLevelOrder[i].TryGetValue(path[i], out var pi) ? pi : 0; + if (idx == 0) item.AppendChild(new MemberPropertyIndex()); + else item.AppendChild(new MemberPropertyIndex { Val = idx }); + } + // ECMA-376 §18.10.1.44: each must have exactly + // (fieldCount - r) children. Subtotal entries have + // path.Length < fieldIndices.Count, so pad with the "default" + // item index for each remaining deeper field. The default item + // is appended after all value items by AppendFieldItems, so its + // 0-based index equals the unique value count for that level. + // Leaf entries already satisfy the requirement (path.Length == + // fieldIndices.Count), so the loop is a no-op for them. + for (int i = path.Length; i < fieldIndices.Count; i++) + { + int defaultIdx = perLevelOrder[i].Count; + if (defaultIdx == 0) item.AppendChild(new MemberPropertyIndex()); + else item.AppendChild(new MemberPropertyIndex { Val = defaultIdx }); + } + // For col-axis leaves with K>1, append one extra for the + // first data field (index 0 = bare ). The K-1 subsequent + // entries below handle the remaining data fields. + if (kMultiply && kind == "leaf") + { + item.AppendChild(new MemberPropertyIndex()); + } + // Defensive: an entry with no x children (e.g. an empty path with + // no LCP slack) would be malformed. Always ensure at least one. + if (!item.Elements().Any()) + item.AppendChild(new MemberPropertyIndex()); + + container.AppendChild(item); + emittedCount++; + + // K>1 col-axis leaf: emit K-1 more entries that inherit the full + // path (r=path.Length) and carry i=d to mark the data field. + if (kMultiply && kind == "leaf") + { + for (int d = 1; d < K; d++) + { + var rep = new RowItem + { + RepeatedItemCount = (uint)path.Length, + Index = (uint)d + }; + rep.AppendChild(new MemberPropertyIndex { Val = d }); + container.AppendChild(rep); + emittedCount++; + } + } + + prevPath = path; } - // Style - pivotDef.PivotTableStyle = new PivotTableStyle - { - Name = styleName, - ShowRowHeaders = true, - ShowColumnHeaders = true, - ShowRowStripes = false, - ShowColumnStripes = false, - ShowLastColumn = true - }; + SetAxisCount(container, emittedCount); + return container; + } - return pivotDef; + /// Set the count attribute on RowItems / ColumnItems uniformly. + private static void SetAxisCount(OpenXmlCompositeElement container, int count) + { + if (container is RowItems ri) ri.Count = (uint)count; + else if (container is ColumnItems ci) ci.Count = (uint)count; } private static void AppendFieldItems(PivotField pf, string[] values) { - var unique = values.Where(v => !string.IsNullOrEmpty(v)).Distinct().OrderBy(v => v).ToList(); + var unique = values.Where(v => !string.IsNullOrEmpty(v)).Distinct().OrderByAxis(v => v).ToList(); var items = new Items { Count = (uint)(unique.Count + 1) }; for (int i = 0; i < unique.Count; i++) items.AppendChild(new Item { Index = (uint)i }); @@ -395,97 +5113,482 @@ private static void AppendFieldItems(PivotField pf, string[] values) pf.AppendChild(items); } + /// + /// Append pivot field for a derived date-group field. The item + /// count MUST match the cache's groupItems count — Excel validates the + /// two and crashes (hard parser abort on macOS) when they mismatch. + /// + /// cache groupItems = N buckets + 2 sentinels + /// pivotField items = N + 2 sentinels + 1 grand-total (default) + /// + /// Item indices run 0..N+1 referencing groupItems directly (including + /// the sentinels), then the final entry is the + /// grand total row/col. Verified against /tmp/date_authored.xlsx. + /// + private static void AppendFixedBucketItems(PivotField pf, DateGroupSpec spec) + { + var buckets = ComputeDateGroupBuckets(spec); + int totalGroupItems = buckets.Count + 2; // + leading/trailing sentinels + var items = new Items { Count = (uint)(totalGroupItems + 1) }; + for (int i = 0; i < totalGroupItems; i++) + items.AppendChild(new Item { Index = (uint)i }); + items.AppendChild(new Item { ItemType = ItemValues.Default }); + pf.AppendChild(items); + } + // ==================== Readback ==================== - internal static void ReadPivotTableProperties(PivotTableDefinition pivotDef, DocumentNode node) + internal static void ReadPivotTableProperties(PivotTableDefinition pivotDef, DocumentNode node, PivotTablePart? pivotPart = null) { if (pivotDef.Name?.HasValue == true) node.Format["name"] = pivotDef.Name.Value; if (pivotDef.CacheId?.HasValue == true) node.Format["cacheId"] = pivotDef.CacheId.Value; - var location = pivotDef.GetFirstChild(); - if (location?.Reference?.HasValue == true) node.Format["location"] = location.Reference.Value; + var location = pivotDef.GetFirstChild(); + if (location?.Reference?.HasValue == true) node.Format["location"] = location.Reference.Value; + + // R15-3: Round-trip the source range so `Get`'s output is symmetric + // with the `source=Sheet1!A1:C3` input form accepted by Add/Set. + // Pull from the cache definition's WorksheetSource (Sheet + Reference); + // emit the "Sheet!Ref" form, or just "Ref" when the sheet attribute + // is absent (same-sheet fallback used by BuildCacheDefinition). + if (pivotPart != null) + { + var cachePartForSrc = pivotPart.GetPartsOfType().FirstOrDefault(); + var wsSrc = cachePartForSrc?.PivotCacheDefinition?.CacheSource?.WorksheetSource; + if (wsSrc?.Reference?.HasValue == true) + { + var refVal = wsSrc.Reference.Value; + var sheetVal = wsSrc.Sheet?.Value; + node.Format["source"] = string.IsNullOrEmpty(sheetVal) + ? refVal! + : $"{sheetVal}!{refVal}"; + } + } + + // Count fields + var pivotFields = pivotDef.GetFirstChild(); + if (pivotFields != null) + node.Format["fieldCount"] = pivotFields.Elements().Count(); + + // R3-1: resolve field indices to cacheField names for rowFields / + // colFields / filters readback. dataField{N} already emits names, so + // consistency requires the same here. Fall back to numeric index only + // when the cache can't be loaded (defensive, should not happen for + // well-formed files). + string[]? fieldNames = null; + if (pivotPart != null) + { + var cachePart = pivotPart.GetPartsOfType().FirstOrDefault(); + var cacheFields = cachePart?.PivotCacheDefinition?.GetFirstChild(); + if (cacheFields != null) + fieldNames = cacheFields.Elements().Select(cf => cf.Name?.Value ?? "").ToArray(); + } + string ResolveFieldName(uint idx) + { + if (fieldNames != null && idx < fieldNames.Length && !string.IsNullOrEmpty(fieldNames[idx])) + return fieldNames[idx]; + return idx.ToString(); + } + + // Row fields + var rowFields = pivotDef.RowFields; + if (rowFields != null) + { + var names = rowFields.Elements().Where(f => f.Index?.Value >= 0).Select(f => ResolveFieldName((uint)f.Index!.Value)).ToList(); + if (names.Count > 0) + // R4-1: canonical key matches input ('rows=' on Add/Set). + // Legacy 'rowFields' output key removed in favor of single + // canonical key per CLAUDE.md "Canonical DocumentNode.Format Rules". + node.Format["rows"] = string.Join(",", names); + } + + // Column fields + var colFields = pivotDef.ColumnFields; + if (colFields != null) + { + var names = colFields.Elements().Where(f => f.Index?.Value >= 0).Select(f => ResolveFieldName((uint)f.Index!.Value)).ToList(); + if (names.Count > 0) + // R4-1: canonical key matches input ('cols=' on Add/Set). + node.Format["cols"] = string.Join(",", names); + } + + // Page/filter fields + var pageFields = pivotDef.PageFields; + if (pageFields != null) + { + var names = pageFields.Elements().Select(f => f.Field?.Value ?? -1).Where(v => v >= 0).Select(v => ResolveFieldName((uint)v)).ToList(); + if (names.Count > 0) + // R2-3: canonical key matches input ('filters=' on Add/Set). + // Legacy 'filterFields' output key removed in favor of single + // canonical key per CLAUDE.md "Canonical DocumentNode.Format Rules". + node.Format["filters"] = string.Join(",", names); + } + + // Data fields (use typed property for reliable access) + var dataFields = pivotDef.DataFields; + if (dataFields != null) + { + var dfList = dataFields.Elements().ToList(); + node.Format["dataFieldCount"] = dfList.Count; + for (int i = 0; i < dfList.Count; i++) + { + var df = dfList[i]; + var dfName = df.Name?.Value ?? ""; + var dfFunc = df.Subtotal?.InnerText ?? "sum"; + var dfField = df.Field?.Value ?? 0; + node.Format[$"dataField{i + 1}"] = $"{dfName}:{dfFunc}:{dfField}"; + // CONSISTENCY(canonical-format-key): showDataAs round-trips + // through its own structured Format key rather than being + // packed into the dataField{N} colon string. Existing + // dataField{N} schema (name:func:fieldIdx) stays untouched. + // 'normal' is the absent/default value, omitted from output. + if (df.ShowDataAs != null && df.ShowDataAs.Value != ShowDataAsValues.Normal) + { + node.Format[$"dataField{i + 1}.showAs"] = ShowDataAsToCanonicalToken(df.ShowDataAs.Value); + } + } + } + // NOTE: sort=asc|desc round-trip is not implemented because the + // current pivot writer applies sort positionally during render but + // does not persist it as a per-PivotField AutoSort element. Adding + // a Format key here without a corresponding XML write site would + // produce a round-trip mismatch. See CONSISTENCY(pivot-sort-store) + // — v2 candidate: write/read AutoSort + AutoSortScope on PivotField. + + // Style + var styleInfo = pivotDef.PivotTableStyle; + if (styleInfo?.Name?.HasValue == true) + node.Format["style"] = styleInfo.Name.Value; + // bool toggles. Emit as "true"/"false" strings + // for symmetry with the Set input form (accepts true/false/1/0/on/off + // via ParsePivotStyleBool; Get emits the canonical true/false pair + // so a round-trip Get → Set is a no-op). Defaults (row/col headers + // on, stripes off, last column on) are surfaced explicitly rather + // than being elided, so consumers reading the dict never have to + // know which value is the OOXML default. + if (styleInfo != null) + { + node.Format["showRowHeaders"] = (styleInfo.ShowRowHeaders?.Value ?? true) ? "true" : "false"; + node.Format["showColHeaders"] = (styleInfo.ShowColumnHeaders?.Value ?? true) ? "true" : "false"; + node.Format["showRowStripes"] = (styleInfo.ShowRowStripes?.Value ?? false) ? "true" : "false"; + node.Format["showColStripes"] = (styleInfo.ShowColumnStripes?.Value ?? false) ? "true" : "false"; + node.Format["showLastColumn"] = (styleInfo.ShowLastColumn?.Value ?? true) ? "true" : "false"; + } + + // R11-3: Grand totals readback. Both attributes default to true in + // OOXML, so emit "true" when absent (default) and reflect explicit + // false. Canonical key matches Add/Set input ('rowGrandTotals' / + // 'colGrandTotals') per CLAUDE.md canonical Format rules. + node.Format["rowGrandTotals"] = (pivotDef.RowGrandTotals?.Value ?? true) ? "true" : "false"; + node.Format["colGrandTotals"] = (pivotDef.ColumnGrandTotals?.Value ?? true) ? "true" : "false"; + } + + /// + /// R10-1: refresh a pivot's cache definition + records from a new source + /// range spec ("Sheet1!A1:C4" or "A1:C4" — same sheet as the existing + /// CacheSource). Replaces CacheFields, updates WorksheetSource.Reference + /// (and Sheet if changed), rewrites the PivotTableCacheRecordsPart, and + /// resizes pivotDef.PivotFields to match the new column count. Existing + /// PivotField Axis/DataField assignments are reset because indices may no + /// longer line up — RebuildFieldAreas reapplies them after this returns. + /// + private static void RefreshPivotCacheFromSource(PivotTablePart pivotPart, string newSourceSpec, + Dictionary? pendingFieldAreaProps = null) + { + if (string.IsNullOrWhiteSpace(newSourceSpec)) + throw new ArgumentException("source must not be empty"); + newSourceSpec = newSourceSpec.Trim(); + if (newSourceSpec.StartsWith("[")) + throw new ArgumentException( + "External workbook references are not supported in pivot source. " + + "Use a local sheet name (e.g. Sheet1!A1:D10)"); + + var cachePart = pivotPart.GetPartsOfType().FirstOrDefault() + ?? throw new InvalidOperationException("Pivot table has no cache definition part"); + var cacheDef = cachePart.PivotCacheDefinition + ?? throw new InvalidOperationException("Pivot cache definition is missing"); + var existingWsSource = cacheDef.CacheSource?.WorksheetSource + ?? throw new InvalidOperationException("Pivot cache source is not a worksheet source"); + + // Parse the new source spec. + string newSheetName; + string newRef; + if (newSourceSpec.Contains('!')) + { + var parts = newSourceSpec.Split('!', 2); + newSheetName = parts[0].Trim().Trim('\'', '"').Trim(); + newRef = parts[1].Trim(); + } + else + { + newSheetName = existingWsSource.Sheet?.Value ?? ""; + newRef = newSourceSpec; + } + + // Locate the source worksheet via the workbook part. + var workbookPart = pivotPart.GetParentParts().OfType().FirstOrDefault() + ?.GetParentParts().OfType().FirstOrDefault() + ?? throw new InvalidOperationException("Workbook part not reachable from pivot table part"); + var sheetEntry = workbookPart.Workbook?.Sheets?.Elements() + .FirstOrDefault(s => s.Name?.Value == newSheetName) + ?? throw new ArgumentException($"Source sheet not found: {newSheetName}"); + if (sheetEntry.Id?.Value is not string srcRelId) + throw new InvalidOperationException("Source sheet has no relationship id"); + var sourceWsPart = workbookPart.GetPartById(srcRelId) as WorksheetPart + ?? throw new InvalidOperationException("Source sheet relationship does not resolve to a WorksheetPart"); + + // Re-read source data from the new range. + var (headers, columnData, _) = ReadSourceData(sourceWsPart, newRef); + if (headers.Length == 0) + throw new ArgumentException("Source range has no data"); + if (columnData.Count == 0 || columnData[0].Length == 0) + throw new ArgumentException("Source range has no data rows"); + + // R15-2: Before mutating any cache/pivot state, validate that existing + // row/col/value/filter field references still fit inside the new + // (possibly narrower) header list. A silent drop or index clamp here + // would leave the DataFields pointing past the rendered columnData, + // crashing RenderPivotIntoSheet with ArgumentOutOfRangeException. + // Prefer strict error over data loss: user must explicitly restate the + // affected axes in the same Set call if they intended to drop them. + var newFieldCount = headers.Length; + var existingPivotDef = pivotPart.PivotTableDefinition; + if (existingPivotDef != null) + { + // Axes that the same Set call is explicitly overwriting are + // excluded from validation — their new values will be parsed + // against the fresh headers by RebuildFieldAreas. + bool rowsOverwritten = pendingFieldAreaProps?.ContainsKey("rows") == true; + bool colsOverwritten = pendingFieldAreaProps?.ContainsKey("cols") == true; + bool valuesOverwritten = pendingFieldAreaProps?.ContainsKey("values") == true; + bool filtersOverwritten = pendingFieldAreaProps?.ContainsKey("filters") == true; + + void ValidateIndex(int idx, string axis, string fieldRef) + { + if (idx >= newFieldCount) + throw new ArgumentException( + $"{axis} field '{fieldRef}' (index {idx}) is out of range " + + $"after source narrowing to {newFieldCount} column(s). " + + $"Restate {axis}= in the same Set call to drop or reassign it."); + } + if (!valuesOverwritten && existingPivotDef.DataFields != null) + { + foreach (var df in existingPivotDef.DataFields.Elements()) + { + var fi = (int)(df.Field?.Value ?? 0); + ValidateIndex(fi, "value", df.Name?.Value ?? fi.ToString()); + } + } + if (!rowsOverwritten && existingPivotDef.RowFields != null) + { + foreach (var f in existingPivotDef.RowFields.Elements()) + { + var fi = f.Index?.Value ?? -1; + if (fi >= 0) ValidateIndex(fi, "row", fi.ToString()); + } + } + if (!colsOverwritten && existingPivotDef.ColumnFields != null) + { + foreach (var f in existingPivotDef.ColumnFields.Elements()) + { + var fi = f.Index?.Value ?? -1; + // -2 sentinel is the values pseudo-field; it is not a cache index. + if (fi >= 0) ValidateIndex(fi, "col", fi.ToString()); + } + } + if (!filtersOverwritten && existingPivotDef.PageFields != null) + { + foreach (var f in existingPivotDef.PageFields.Elements()) + { + var fi = f.Field?.Value ?? -1; + if (fi >= 0) ValidateIndex(fi, "filter", fi.ToString()); + } + } + } + + // Build a fresh cache definition (just to harvest its CacheFields, + // fieldNumeric, and fieldValueIndex). We do NOT swap the part — only + // its child elements — so the workbook-level registration + // and the relationship id from PivotTablePart → PivotCacheDefinitionPart + // stay intact. + var (freshDef, fieldNumeric, fieldValueIndex) = + BuildCacheDefinition(newSheetName, newRef, headers, columnData, axisFieldIndices: null, dateGroups: null); + + // Replace WorksheetSource attributes in place. + existingWsSource.Reference = newRef; + existingWsSource.Sheet = newSheetName; - // Count fields - var pivotFields = pivotDef.GetFirstChild(); - if (pivotFields != null) - node.Format["fieldCount"] = pivotFields.Elements().Count(); + // Replace the CacheFields child wholesale. + var oldCacheFields = cacheDef.GetFirstChild(); + var freshCacheFields = freshDef.GetFirstChild() + ?? throw new InvalidOperationException("Fresh cache definition missing CacheFields"); + freshCacheFields.Remove(); + if (oldCacheFields != null) + cacheDef.ReplaceChild(freshCacheFields, oldCacheFields); + else + cacheDef.AppendChild(freshCacheFields); - // Row fields - var rowFields = pivotDef.RowFields; - if (rowFields != null) + // Update the record count attribute on the cache definition. + var newRecordCount = (uint)columnData[0].Length; + cacheDef.RecordCount = newRecordCount; + + // Rebuild the PivotTableCacheRecordsPart in place. Drop the old part + // (if any) and add a fresh one so the records align with the new + // CacheFields layout. + var oldRecordsPart = cachePart.GetPartsOfType().FirstOrDefault(); + if (oldRecordsPart != null) + cachePart.DeletePart(oldRecordsPart); + var newRecordsPart = cachePart.AddNewPart(); + newRecordsPart.PivotCacheRecords = BuildCacheRecords(columnData, fieldNumeric, fieldValueIndex, skipFieldIndices: null); + newRecordsPart.PivotCacheRecords.Save(); + cacheDef.Id = cachePart.GetIdOfPart(newRecordsPart); + cacheDef.Save(); + + // Resize pivotDef.PivotFields to match the new header count. Reset + // axis/dataField on every retained PivotField — RebuildFieldAreas + // (called immediately after this in SetPivotTableProperties) reads + // the new headers and reapplies axis assignments. + var pivotDef = pivotPart.PivotTableDefinition + ?? throw new InvalidOperationException("Pivot table definition is missing"); + var pivotFields = pivotDef.PivotFields; + if (pivotFields == null) { - var indices = rowFields.Elements().Where(f => f.Index?.Value >= 0).Select(f => f.Index!.Value).ToList(); - if (indices.Count > 0) - node.Format["rowFields"] = string.Join(",", indices); + pivotFields = new PivotFields(); + pivotDef.PivotFields = pivotFields; } - - // Column fields - var colFields = pivotDef.ColumnFields; - if (colFields != null) + var existingPfList = pivotFields.Elements().ToList(); + // Drop trailing PivotFields beyond the new column count. + while (existingPfList.Count > headers.Length) { - var indices = colFields.Elements().Where(f => f.Index?.Value >= 0).Select(f => f.Index!.Value).ToList(); - if (indices.Count > 0) - node.Format["colFields"] = string.Join(",", indices); + existingPfList[existingPfList.Count - 1].Remove(); + existingPfList.RemoveAt(existingPfList.Count - 1); } - - // Page/filter fields - var pageFields = pivotDef.PageFields; - if (pageFields != null) + // Append fresh PivotFields for any newly-added columns. + while (existingPfList.Count < headers.Length) { - var indices = pageFields.Elements().Select(f => f.Field?.Value ?? -1).Where(v => v >= 0).ToList(); - if (indices.Count > 0) - node.Format["filterFields"] = string.Join(",", indices); + var pf = new PivotField { ShowAll = false }; + pivotFields.AppendChild(pf); + existingPfList.Add(pf); } - - // Data fields (use typed property for reliable access) - var dataFields = pivotDef.DataFields; - if (dataFields != null) + // Items contents on retained PivotFields are stale (they were + // generated from the old shared-items list). RebuildFieldAreas will + // re-generate them from the fresh CacheFields, but it only resets + // when the field is on an axis. Wipe them now so leftover entries + // from non-axis fields cannot be read by Excel. + foreach (var pf in existingPfList) { - var dfList = dataFields.Elements().ToList(); - node.Format["dataFieldCount"] = dfList.Count; - for (int i = 0; i < dfList.Count; i++) - { - var df = dfList[i]; - var dfName = df.Name?.Value ?? ""; - var dfFunc = df.Subtotal?.InnerText ?? "sum"; - var dfField = df.Field?.Value ?? 0; - node.Format[$"dataField{i + 1}"] = $"{dfName}:{dfFunc}:{dfField}"; - } + pf.RemoveAllChildren(); } + pivotFields.Count = (uint)headers.Length; - // Style - var styleInfo = pivotDef.PivotTableStyle; - if (styleInfo?.Name?.HasValue == true) - node.Format["style"] = styleInfo.Name.Value; + // RowFields / ColumnFields / PageFields / DataFields are preserved + // here so RebuildFieldAreas can read the current assignments and + // carry over any axes the caller did not explicitly re-specify in + // this Set call. RebuildFieldAreas resets PivotField.Axis/DataField + // and rewrites the area lists from scratch. + pivotDef.Save(); } internal static List SetPivotTableProperties(PivotTablePart pivotPart, Dictionary properties) { + // R12-2 / R12-3: normalize alias keys (row→rows, rowFields→rows, + // columngrandtotals→colgrandtotals) so Set accepts the same aliases + // as Add and the switch below binds to canonical keys. + properties = NormalizePivotProperties(properties); + + // Publish sort mode for this Set operation so the re-rendered items / + // renderers use the requested order. Sort only affects the rendered + // layout — sharedItems order in the cache is fixed at Create time. + using var _sortScope = PushAxisSortMode(properties); + // CONSISTENCY(thread-static-pivot-opts): grand totals options ride + // through the same ambient scope as sort. + using var _gtScope = PushGrandTotalsOptions(properties); + var unsupported = new List(); var pivotDef = pivotPart.PivotTableDefinition; if (pivotDef == null) { unsupported.AddRange(properties.Keys); return unsupported; } + // Seed the thread-static grand-totals scope from the CURRENT definition + // when the caller did not explicitly pass the keys. This keeps prior + // toggles sticky across unrelated Set operations (e.g. `set rows=...` + // must not silently re-enable grand totals that were turned off earlier). + if (!_rowGrandTotals.HasValue && pivotDef.RowGrandTotals?.Value == false) + _rowGrandTotals = false; + if (!_colGrandTotals.HasValue && pivotDef.ColumnGrandTotals?.Value == false) + _colGrandTotals = false; + // Collect field-area properties separately — they require a coordinated rebuild var fieldAreaProps = new Dictionary(); + // R15-2: Pre-scan for field-area keys so RefreshPivotCacheFromSource + // can skip validation of axes the same Set call is about to overwrite. + var pendingAreaKeys = new Dictionary(); + foreach (var (k, v) in properties) + { + var lk = k.ToLowerInvariant(); + if (lk == "rows" || lk == "cols" || lk == "columns" || lk == "values" || lk == "filters") + pendingAreaKeys[lk == "columns" ? "cols" : lk] = v; + } + foreach (var (key, value) in properties) { switch (key.ToLowerInvariant()) { case "name": - pivotDef.Name = value; + // R16-2: validate via shared helper so Set rejects + // empty / whitespace / control-char names just like Add. + // CONSISTENCY(pivot-name-validation): same rules, same + // error messages for both Add and Set paths. + pivotDef.Name = ValidatePivotName(value); + break; + case "source": + case "src": + // R10-1: refreshing the pivot's source range MUST also + // refresh the cache definition's CacheFields and the + // CacheRecords part. Otherwise RebuildFieldAreas reads + // headers from the stale cache and rejects fields that + // exist in the new range. Run the refresh BEFORE the + // field-area rebuild so any newly-added columns from the + // new range are visible to header validation. + RefreshPivotCacheFromSource(pivotPart, value, pendingAreaKeys); + // Force RebuildFieldAreas to run even if the caller did + // not pass any rows/cols/values keys, so the existing + // PivotField axis assignments get re-rendered against + // the new (possibly resized) header list. + if (!fieldAreaProps.ContainsKey("rows") && !fieldAreaProps.ContainsKey("cols") + && !fieldAreaProps.ContainsKey("values") && !fieldAreaProps.ContainsKey("filters") + && !fieldAreaProps.ContainsKey("__sort_only__")) + { + fieldAreaProps["__sort_only__"] = ""; + } break; case "style": { - pivotDef.PivotTableStyle = new PivotTableStyle - { - Name = value, - ShowRowHeaders = true, - ShowColumnHeaders = true, - ShowRowStripes = false, - ShowColumnStripes = false, - ShowLastColumn = true - }; + // Preserve existing style-info bool toggles so a bare + // `style=PivotStyleMedium9` does not clobber a previously- + // set showRowStripes=true. EnsurePivotTableStyle creates + // the element with defaults if absent; only the Name is + // overwritten here. + var styleInfo = EnsurePivotTableStyle(pivotDef); + styleInfo.Name = value; + break; + } + case "showrowstripes": + case "showcolstripes": + case "showcolumnstripes": + case "showrowheaders": + case "showcolheaders": + case "showcolumnheaders": + case "showlastcolumn": + { + // Individual bool toggles. Route + // through the shared ApplyPivotStyleInfoProps helper so + // Add and Set share the exact same validation + alias + // rules (col/column siblings) and neither path can + // diverge on which OOXML attribute a key maps to. + ApplyPivotStyleInfoProps( + EnsurePivotTableStyle(pivotDef), + new Dictionary { [key] = value }); break; } case "rows": @@ -494,9 +5597,93 @@ internal static List SetPivotTableProperties(PivotTablePart pivotPart, D case "filters": fieldAreaProps[key.ToLowerInvariant() == "columns" ? "cols" : key.ToLowerInvariant()] = value; break; + case "aggregate": + case "showdataas": + // CONSISTENCY(aggregate-override / showdataas): these two + // sibling keys mutate per-value-field semantics. They piggy- + // back on the same RebuildFieldAreas pass that 'values' uses, + // so we hand them through verbatim and let the rebuild path + // (which always re-parses the value field list, even when + // 'values' was not in this Set call) pick them up. + fieldAreaProps[key.ToLowerInvariant()] = value; + break; + case "sort": + // Already consumed by PushAxisSortMode at the top of this + // method; re-rendering below reads _axisSortMode directly. + // Trigger a re-render even if no field areas changed so + // the layout reflects the new sort. + if (!fieldAreaProps.ContainsKey("rows") && !fieldAreaProps.ContainsKey("cols") + && !fieldAreaProps.ContainsKey("values") && !fieldAreaProps.ContainsKey("filters")) + { + // Seed an empty entry so RebuildFieldAreas runs with + // current field assignments and re-renders with the + // new sort. + fieldAreaProps["__sort_only__"] = value; + } + break; + case "grandtotals": + case "rowgrandtotals": + case "colgrandtotals": + case "columngrandtotals": + // Already consumed by PushGrandTotalsOptions at the top of + // this method. Trigger a re-render so geometry / items / + // cells all reflect the new toggle. Mirrors "sort". + if (!fieldAreaProps.ContainsKey("rows") && !fieldAreaProps.ContainsKey("cols") + && !fieldAreaProps.ContainsKey("values") && !fieldAreaProps.ContainsKey("filters") + && !fieldAreaProps.ContainsKey("__sort_only__")) + { + fieldAreaProps["__sort_only__"] = value; + } + break; default: + { + // R15-4: accept `dataField{N}.showAs=` as the + // write-side counterpart of the Get readback key. N is + // 1-indexed over the current DataFields list; map to + // the positional `showdataas` list so RebuildFieldAreas + // can apply the transform through its existing showAs + // override path. Consistency with the Get readback + // symmetry rule: users copy a key from Get and Set it + // back without learning a second vocabulary. + var lkDf = key.ToLowerInvariant(); + if (lkDf.StartsWith("datafield") && lkDf.EndsWith(".showas")) + { + var idxStr = lkDf.Substring("datafield".Length, + lkDf.Length - "datafield".Length - ".showas".Length); + if (int.TryParse(idxStr, out var oneBasedIdx) && oneBasedIdx >= 1) + { + var existingDf = pivotDef.DataFields?.Elements().ToList(); + var dfCount = existingDf?.Count ?? 0; + if (oneBasedIdx > dfCount) + throw new ArgumentException( + $"dataField{oneBasedIdx}.showAs: index out of range " + + $"(1..{dfCount} data field(s) defined)"); + + // Build / extend the positional showdataas list + // so slot oneBasedIdx-1 carries the new token, + // leaving earlier slots empty (RebuildFieldAreas + // treats empty slot as "keep current"). + fieldAreaProps.TryGetValue("showdataas", out var existingShow); + var slots = existingShow?.Split(',').Select(s => s.Trim()).ToList() + ?? new List(); + while (slots.Count < oneBasedIdx) slots.Add(""); + slots[oneBasedIdx - 1] = value; + fieldAreaProps["showdataas"] = string.Join(",", slots); + + // Force RebuildFieldAreas to run even without + // any rows/cols/values/filters in this call. + if (!fieldAreaProps.ContainsKey("rows") && !fieldAreaProps.ContainsKey("cols") + && !fieldAreaProps.ContainsKey("values") && !fieldAreaProps.ContainsKey("filters") + && !fieldAreaProps.ContainsKey("__sort_only__")) + { + fieldAreaProps["__sort_only__"] = ""; + } + break; + } + } unsupported.Add(key); break; + } } } @@ -543,10 +5730,91 @@ private static void RebuildFieldAreas(PivotTablePart pivotPart, PivotTableDefini var filterFieldIndices = changes.ContainsKey("filters") ? ParseFieldListWithWarning(changes, "filters", headers) : currentFilters; + + // CONSISTENCY(field-area-dedup): a field cannot be in two axes at + // once. When a Set call moves a field into one axis, it must drop + // out of any other axis it currently sits on. Without this dedup, + // `set rows=X` can leave X in both currentCols and the new rows + // list, which Excel renders as a corrupt pivotTableDefinition. + // Precedence: the most-recently-set axis wins; areas not touched + // in this Set call shed any field that was just claimed elsewhere. var valueFields = changes.ContainsKey("values") ? ParseValueFieldsWithWarning(changes, "values", headers) : currentValues; + if (changes.ContainsKey("rows")) + { + colFieldIndices = colFieldIndices.Where(i => !rowFieldIndices.Contains(i)).ToList(); + filterFieldIndices = filterFieldIndices.Where(i => !rowFieldIndices.Contains(i)).ToList(); + // R15-1 parity: claimed row field also drops from values axis. + valueFields = valueFields.Where(vf => !rowFieldIndices.Contains(vf.idx)).ToList(); + } + if (changes.ContainsKey("cols")) + { + rowFieldIndices = rowFieldIndices.Where(i => !colFieldIndices.Contains(i)).ToList(); + filterFieldIndices = filterFieldIndices.Where(i => !colFieldIndices.Contains(i)).ToList(); + valueFields = valueFields.Where(vf => !colFieldIndices.Contains(vf.idx)).ToList(); + } + if (changes.ContainsKey("filters")) + { + rowFieldIndices = rowFieldIndices.Where(i => !filterFieldIndices.Contains(i)).ToList(); + colFieldIndices = colFieldIndices.Where(i => !filterFieldIndices.Contains(i)).ToList(); + // R15-1: without this, `set filters=Sales` leaves Sales in both + // DataFields and PageFields, producing a corrupt pivot with + // duplicate assignment on the same cacheField. + valueFields = valueFields.Where(vf => !filterFieldIndices.Contains(vf.idx)).ToList(); + } + if (changes.ContainsKey("values")) + { + var valueIdxSet = valueFields.Select(vf => vf.idx).ToHashSet(); + rowFieldIndices = rowFieldIndices.Where(i => !valueIdxSet.Contains(i)).ToList(); + colFieldIndices = colFieldIndices.Where(i => !valueIdxSet.Contains(i)).ToList(); + filterFieldIndices = filterFieldIndices.Where(i => !valueIdxSet.Contains(i)).ToList(); + } + + // CONSISTENCY(aggregate-override / showdataas in Set): when only the + // sibling keys were passed (values list unchanged), apply them to + // the existing value-field list positionally so users can mutate + // func / showAs without restating the whole values spec. + if (!changes.ContainsKey("values")) + { + string[]? aggOverride = null; + string[]? showOverride = null; + if (changes.TryGetValue("aggregate", out var aggSpec) && !string.IsNullOrEmpty(aggSpec)) + aggOverride = aggSpec.Split(',').Select(s => s.Trim().ToLowerInvariant()).ToArray(); + if (changes.TryGetValue("showdataas", out var showSpec) && !string.IsNullOrEmpty(showSpec)) + showOverride = showSpec.Split(',').Select(s => s.Trim().ToLowerInvariant()).ToArray(); + if (aggOverride != null || showOverride != null) + { + for (int i = 0; i < valueFields.Count; i++) + { + var (idx, func, showAs, name) = valueFields[i]; + var funcChanged = false; + if (aggOverride != null && i < aggOverride.Length && !string.IsNullOrEmpty(aggOverride[i])) + { + if (!string.Equals(func, aggOverride[i], StringComparison.OrdinalIgnoreCase)) + funcChanged = true; + func = aggOverride[i]; + } + if (showOverride != null && i < showOverride.Length && !string.IsNullOrEmpty(showOverride[i])) + showAs = showOverride[i]; + // R15-5: when aggregate changes, regenerate the display + // name so the DataField header shows "Count of Sales" + // instead of the stale "Sum of Sales". Only rewrite when + // the current name still matches the canonical + // " of " shape — future explicit + // user-provided names would then survive untouched. + if (funcChanged && idx >= 0 && idx < headers.Length) + { + var sourceHeader = headers[idx]; + if (LooksLikeAutoDataFieldName(name, sourceHeader)) + name = $"{AggregateDisplayName(func)} of {sourceHeader}"; + } + valueFields[i] = (idx, func, showAs, name); + } + } + } + // Layer 1: Reset all PivotField axis/dataField, then re-assign var pivotFields = pivotDef.PivotFields; if (pivotFields == null) return; @@ -588,15 +5856,13 @@ private static void RebuildFieldAreas(PivotTablePart pivotPart, PivotTableDefini // RowFields if (rowFieldIndices.Count > 0) { + // The -2 sentinel belongs to the column axis only (dataOnRows=false + // is the default and we never flip it). ColumnFields below adds it + // unconditionally for valueFields.Count > 1, so do not duplicate + // it on the row axis. var rf = new RowFields { Count = (uint)rowFieldIndices.Count }; foreach (var idx in rowFieldIndices) rf.AppendChild(new Field { Index = idx }); - // -2 sentinel for multiple value fields displayed in rows - if (valueFields.Count > 1 && colFieldIndices.Count == 0) - { - rf.AppendChild(new Field { Index = -2 }); - rf.Count = (uint)rf.Elements().Count(); - } pivotDef.RowFields = rf; } else @@ -634,20 +5900,67 @@ private static void RebuildFieldAreas(PivotTablePart pivotPart, PivotTableDefini pivotDef.PageFields = null; } + // Re-read the source sheet's column styles so both (a) the DataField's + // NumberFormatId (Excel's primary pivot-value display driver) and + // (b) the value-cell StyleIndex stay in sync with the source column's + // currency/percent/custom format across Set operations. + uint?[]? sourceColumnStyleIds = null; + uint?[]? sourceColumnNumFmtIds = null; + var wbPart = pivotPart.GetParentParts().OfType().FirstOrDefault() + ?.GetParentParts().OfType().FirstOrDefault(); + var wsSource = cachePart.PivotCacheDefinition.CacheSource?.WorksheetSource; + if (wbPart != null && wsSource?.Sheet?.Value is string srcSheetName + && wsSource.Reference?.Value is string srcRef) + { + var sheetRef = wbPart.Workbook?.Sheets?.Elements() + .FirstOrDefault(s => s.Name?.Value == srcSheetName); + if (sheetRef?.Id?.Value is string relId + && wbPart.GetPartById(relId) is WorksheetPart srcWsPart) + { + try + { + var (_, _, ids) = ReadSourceData(srcWsPart, srcRef); + sourceColumnStyleIds = ids; + sourceColumnNumFmtIds = ResolveColumnNumFmtIds(wbPart, ids); + } + catch { /* best-effort: Set still succeeds with General format */ } + } + } + // DataFields if (valueFields.Count > 0) { var df = new DataFields { Count = (uint)valueFields.Count }; - foreach (var (idx, func, displayName) in valueFields) + foreach (var (idx, func, showAs, displayName) in valueFields) { - df.AppendChild(new DataField + // BaseField/BaseItem: Excel ignores these when ShowDataAs is normal, + // but LibreOffice and Excel both emit them unconditionally on every + // dataField (verified against pivot_dark1.xlsx and other LO fixtures). + // Following the verified pattern rather than my earlier "omit them" + // theory — being closer to what real producers write reduces the risk + // of triggering picky consumers. + var dataField = new DataField { Name = displayName, Field = (uint)idx, Subtotal = ParseSubtotal(func), BaseField = 0, BaseItem = 0u - }); + }; + var sda = ParseShowDataAs(showAs); + if (sda.HasValue) dataField.ShowDataAs = sda.Value; + if (sourceColumnNumFmtIds != null && idx >= 0 && idx < sourceColumnNumFmtIds.Length + && sourceColumnNumFmtIds[idx] is uint nfid) + { + dataField.NumberFormatId = nfid; + } + // CONSISTENCY(percent-numfmt): mirror Add path — percent_* showAs + // overrides any inherited numFmtId so values render as percentages. + if (IsPercentShowAs(showAs)) + { + dataField.NumberFormatId = 10u; + } + df.AppendChild(dataField); } pivotDef.DataFields = df; } @@ -656,10 +5969,82 @@ private static void RebuildFieldAreas(PivotTablePart pivotPart, PivotTableDefini pivotDef.DataFields = null; } - // Update Location.FirstDataColumn - var location = pivotDef.Location; - if (location != null) - location.FirstDataColumn = (uint)rowFieldIndices.Count; + // Update Location with the full new geometry — range, offsets, FirstDataCol — + // not just FirstDataColumn. The previous incremental approach left a stale + // range covering the old layout, which made Excel render only the original + // bounds even when fields were added or removed. + var oldLocation = pivotDef.Location; + var oldRangeRef = oldLocation?.Reference?.Value; + var anchorRefForGeometry = oldRangeRef?.Split(':')[0] + ?? oldLocation?.Reference?.Value + ?? "A1"; + + // Reconstruct columnData from the cache so the geometry helper and the + // renderer below can compute new extents without re-reading the source sheet. + var (cacheHeaders, cacheColumnData) = ReadColumnDataFromCache( + cachePart.PivotCacheDefinition, + cachePart.GetPartsOfType().FirstOrDefault()?.PivotCacheRecords); + + var newGeom = ComputePivotGeometry( + anchorRefForGeometry, cacheColumnData, rowFieldIndices, colFieldIndices, valueFields); + + pivotDef.Location = new Location + { + Reference = newGeom.RangeRef, + FirstHeaderRow = 1u, + FirstDataRow = 2u, + FirstDataColumn = (uint)newGeom.RowLabelCols + }; + + // Sync grand-totals attributes. Only touch when the caller explicitly + // set them in this Set call (_*.HasValue); otherwise leave whatever + // the definition already carried so repeated Sets don't clobber an + // earlier toggle. + if (_rowGrandTotals.HasValue) + pivotDef.RowGrandTotals = _rowGrandTotals.Value ? null : (BooleanValue)false; + if (_colGrandTotals.HasValue) + pivotDef.ColumnGrandTotals = _colGrandTotals.Value ? null : (BooleanValue)false; + + // Rebuild RowItems / ColumnItems for the new field assignments. The previous + // configuration's row/col layout no longer matches; without these the rendered + // skeleton would still describe the old shape. + if (rowFieldIndices.Count > 0) + pivotDef.RowItems = (RowItems)BuildAxisItems(rowFieldIndices, cacheColumnData, isRow: true, dataFieldCount: 1); + else + pivotDef.RowItems = null; + pivotDef.ColumnItems = (ColumnItems)BuildAxisItems( + colFieldIndices, cacheColumnData, isRow: false, dataFieldCount: valueFields.Count); + + // Refresh caption attributes — they pin to the row/col field's header name, + // so reassigning fields means the visible caption changes too. + pivotDef.RowHeaderCaption = rowFieldIndices.Count > 0 ? cacheHeaders[rowFieldIndices[0]] : "Rows"; + pivotDef.ColumnHeaderCaption = colFieldIndices.Count > 0 ? cacheHeaders[colFieldIndices[0]] : "Columns"; + + // Re-render the materialized cells. Find the host worksheet via the pivot + // part's parent — pivotPart is owned by exactly one WorksheetPart so this + // is unambiguous in v1 (no shared pivot tables). + var hostSheet = pivotPart.GetParentParts().OfType().FirstOrDefault(); + if (hostSheet != null) + { + var ws = hostSheet.Worksheet; + var sheetData = ws?.GetFirstChild(); + if (ws != null && sheetData != null) + { + // Clear the OLD rendered cells before drawing the new layout. The + // new geometry might be smaller (fewer cols → stale right-hand cells) + // OR larger (more rows → safe overwrite), so we always wipe the union + // of old and new bounds. Old range first, then new range — the new + // render writes into the cleared area immediately after. + if (!string.IsNullOrEmpty(oldRangeRef)) + ClearPivotRangeCells(sheetData, oldRangeRef); + ClearPivotRangeCells(sheetData, newGeom.RangeRef); + + RenderPivotIntoSheet( + hostSheet, anchorRefForGeometry, cacheHeaders, cacheColumnData, + rowFieldIndices, colFieldIndices, valueFields, filterFieldIndices, + sourceColumnStyleIds); + } + } } private static List ReadCurrentFieldIndices(IEnumerable? elements, Func getIndex) @@ -668,12 +6053,13 @@ private static List ReadCurrentFieldIndices(IEnumerable? elements, Fu return elements.Select(getIndex).Where(i => i >= 0).ToList(); } - private static List<(int idx, string func, string name)> ReadCurrentDataFields(DataFields? dataFields) + private static List<(int idx, string func, string showAs, string name)> ReadCurrentDataFields(DataFields? dataFields) { - if (dataFields == null) return new List<(int, string, string)>(); + if (dataFields == null) return new List<(int, string, string, string)>(); return dataFields.Elements().Select(df => ( idx: (int)(df.Field?.Value ?? 0), func: df.Subtotal?.InnerText ?? "sum", + showAs: df.ShowDataAs?.InnerText ?? "normal", name: df.Name?.Value ?? "" )).ToList(); } @@ -713,7 +6099,7 @@ private static List ParseFieldListWithWarning(Dictionary pr return result; } - private static List<(int idx, string func, string name)> ParseValueFieldsWithWarning( + private static List<(int idx, string func, string showAs, string name)> ParseValueFieldsWithWarning( Dictionary props, string key, string[] headers) { var result = ParseValueFields(props, key, headers); @@ -725,70 +6111,617 @@ private static List ParseFieldListWithWarning(Dictionary pr return result; } + // R4-2: Unicode field names may reach us in different normalization forms + // (e.g. source header in NFD "e\u0301" vs user input in NFC "\u00E9"). An + // ordinal compare would fail on semantically equivalent strings and report + // the field as missing. Normalize both sides to NFC before lookup so + // composed and decomposed spellings bind to the same header. We only + // normalize for matching — stored header text is left unchanged. + private static bool FieldNameMatches(string? header, string candidate) + { + if (header == null) return false; + // Trim surrounding whitespace on both sides so header cells with + // incidental leading/trailing spaces (a common paste-from-Excel + // artefact) still resolve against clean user input. NFC normalisation + // from Round 4 R4-2 is preserved. CONSISTENCY(pivot-field-matching). + return header.Trim().Normalize(NormalizationForm.FormC) + .Equals(candidate.Trim().Normalize(NormalizationForm.FormC), StringComparison.OrdinalIgnoreCase); + } + private static List ParseFieldList(Dictionary props, string key, string[] headers) { if (!props.TryGetValue(key, out var value) || string.IsNullOrEmpty(value)) return new List(); - return value.Split(',').Select(f => + var result = new List(); + foreach (var f in value.Split(',')) { var name = f.Trim(); - // Try as column index first - if (int.TryParse(name, out var idx)) return idx; - // Try as header name + if (string.IsNullOrEmpty(name)) continue; + + // CONSISTENCY(field-name-validation): a numeric token is treated + // as a column index (out-of-range still silently dropped — that + // is the legacy contract used by tests with index hints). A + // non-numeric token MUST resolve to an existing header, else we + // throw with the available header list so users can fix typos + // immediately instead of seeing an empty / wrong pivot. + if (int.TryParse(name, out var idx)) + { + if (idx >= 0 && idx < headers.Length) result.Add(idx); + continue; + } + int found = -1; for (int i = 0; i < headers.Length; i++) - if (headers[i] != null && headers[i].Equals(name, StringComparison.OrdinalIgnoreCase)) return i; - return -1; - }).Where(i => i >= 0 && i < headers.Length).ToList(); + if (FieldNameMatches(headers[i], name)) { found = i; break; } + // CONSISTENCY(date-grouping-passthrough): unrecognized grouping + // suffixes (e.g. "Date:hours") survive ApplyDateGrouping as + // literals. Strip the suffix and re-resolve so the bare field + // name still binds — matches the existing best-effort fuzz + // contract that says invalid grouping must not crash. + if (found < 0) + { + var colon = name.IndexOf(':'); + if (colon > 0) + { + var bare = name.Substring(0, colon); + for (int i = 0; i < headers.Length; i++) + if (FieldNameMatches(headers[i], bare)) { found = i; break; } + } + } + if (found < 0) + { + var available = string.Join(", ", headers.Where(h => !string.IsNullOrEmpty(h))); + throw new ArgumentException($"field '{name}' not found in source headers: {available}"); + } + result.Add(found); + } + return result; } - private static List<(int idx, string func, string name)> ParseValueFields( + private static List<(int idx, string func, string showAs, string name)> ParseValueFields( Dictionary props, string key, string[] headers) { if (!props.TryGetValue(key, out var value) || string.IsNullOrEmpty(value)) - return new List<(int, string, string)>(); + return new List<(int, string, string, string)>(); + + // CONSISTENCY(aggregate-override): the optional sibling 'aggregate' + // property is a comma-list aligned positionally with 'values'. It + // overrides the per-field func parsed from the colon-suffix syntax. + // This lets users write `values=Sales,Sales aggregate=sum,count` + // instead of `values=Sales:sum,Sales:count` — both forms are + // equivalent. Per-spec colon syntax still wins for any slot the + // aggregate list does not cover (shorter list ⇒ remaining slots + // keep their parsed func). + string[]? aggregateOverrides = null; + if (props.TryGetValue("aggregate", out var aggSpec) && !string.IsNullOrEmpty(aggSpec)) + aggregateOverrides = aggSpec.Split(',').Select(s => s.Trim().ToLowerInvariant()).ToArray(); - var result = new List<(int idx, string func, string name)>(); - foreach (var spec in value.Split(',')) + var result = new List<(int idx, string func, string showAs, string name)>(); + var specs = value.Split(','); + for (int specIndex = 0; specIndex < specs.Length; specIndex++) { - // Format: "FieldName:func" or "FieldName" (default sum) + var spec = specs[specIndex]; + // Format: "FieldName" | "FieldName:func" | "FieldName:func:showAs" + // default func = sum + // default showAs = normal + // showAs accepts: normal | percent_of_total | percent_of_row | + // percent_of_col | running_total | (+ camelCase aliases) + // R11-2: Parse right-to-left so field names containing literal + // colons (e.g. "A:B:sum" → field "A:B", func "sum") work without + // requiring users to escape. Strategy: + // 1. Split into all colon segments. + // 2. Peek the rightmost segment: if it's a known showAs token, + // consume it as showAs, then peek again for func. + // 3. Otherwise, if the rightmost segment is a known aggregate + // function, consume it as func. + // 4. Anything not consumed (joined back with ':') is the field + // name, preserving any embedded colons. + // The 1-segment case ("Sales") and 2-segment case ("Sales:sum") and + // 3-segment case ("Sales:sum:percent_of_total") all keep working + // because trailing tokens are still recognized — only the field + // name parsing changes. var parts = spec.Trim().Split(':'); - var fieldName = parts[0].Trim(); - var func = parts.Length > 1 ? parts[1].Trim().ToLowerInvariant() : "sum"; + string fieldName; + string func = "sum"; + string showAs = "normal"; + if (parts.Length == 1) + { + fieldName = parts[0].Trim(); + } + else + { + int consumed = 0; + var last = parts[parts.Length - 1].Trim().ToLowerInvariant(); + if (parts.Length >= 2 && IsKnownShowAsToken(last)) + { + showAs = last; + consumed = 1; + if (parts.Length - consumed >= 2) + { + var prev = parts[parts.Length - 1 - consumed].Trim().ToLowerInvariant(); + if (IsKnownAggregateToken(prev)) + { + func = prev; + consumed = 2; + } + } + } + else if (IsKnownAggregateToken(last)) + { + func = last; + consumed = 1; + } + else + { + // Unknown trailing token: fall back to legacy left-to-right + // semantics so existing error messages (invalid showDataAs / + // unknown aggregate) still surface from ParseShowDataAs / + // ParseSubtotal downstream. + fieldName = parts[0].Trim(); + func = parts.Length > 1 ? parts[1].Trim().ToLowerInvariant() : "sum"; + showAs = parts.Length > 2 ? parts[2].Trim().ToLowerInvariant() : "normal"; + goto afterParse; + } + var nameParts = parts.Take(parts.Length - consumed).ToList(); + // Drop trailing empty segments — the legacy "Sales::percent_of_total" + // form (empty func slot, default "sum") leaves a "" between the + // field name and the consumed showAs token. Right-to-left parsing + // would otherwise concatenate "Sales:" as the field name and fail + // header lookup. The empty func will be defaulted to "sum" below. + while (nameParts.Count > 1 && string.IsNullOrEmpty(nameParts[nameParts.Count - 1])) + nameParts.RemoveAt(nameParts.Count - 1); + fieldName = string.Join(":", nameParts).Trim(); + // Edge: "sum" alone with no field name (e.g. spec was ":sum") + // → fall through to the same "field not found" error path. + } + afterParse:; + + // CONSISTENCY(pivot-roundtrip / R9-2): Get readback emits dataField{N} + // as "{displayName}:{func}:{fieldIdx}" where displayName has the form + // "Sum of Sales" and the third slot is a numeric cacheField index + // (NOT a showAs token). Accept this shape so the output of Get can + // be fed straight back into Set values=... without translation. + // Disambiguation: only switch into round-trip mode when parts[0] + // starts with a known English aggregate display prefix + // ("Sum of ", "Count of ", ...). Otherwise the third slot stays + // a showAs token, preserving the existing "Sales:sum:42" → invalid + // showDataAs throw contract. + var displayPrefixes = new[] + { + "Sum of ", "Count of ", "Average of ", "Max of ", "Min of ", + "Product of ", "Count Numbers of ", "StdDev of ", "StdDevp of ", + "Var of ", "Varp of ", "Std Dev of ", "Std Dev p of " + }; + bool isGetReadbackShape = false; + foreach (var p in displayPrefixes) + { + if (fieldName.StartsWith(p, StringComparison.OrdinalIgnoreCase)) + { + fieldName = fieldName.Substring(p.Length).Trim(); + isGetReadbackShape = true; + break; + } + } + int? roundTripFieldIdx = null; + if (isGetReadbackShape && parts.Length > 2 && int.TryParse(parts[2].Trim(), out var rtIdx)) + { + // Get readback packs cacheField index in slot 3; reset showAs + // to canonical default (the sibling dataField{N}.showAs key + // carries showDataAs round-trip). + roundTripFieldIdx = rtIdx; + showAs = "normal"; + } + + // Empty func slot ("Sales:" or "Sales::percent_of_total") is a + // common user mistake from optional-segment trailing colons. Treat + // as the documented default ("sum") rather than crashing on + // func[0] below. This keeps the showAs slot positionally addressable. + if (string.IsNullOrEmpty(func)) func = "sum"; + + // CONSISTENCY(aggregate-override): if aggregate= was passed + // and has an entry at this position, it wins over the colon form. + if (aggregateOverrides != null && specIndex < aggregateOverrides.Length + && !string.IsNullOrEmpty(aggregateOverrides[specIndex])) + func = aggregateOverrides[specIndex]; int fieldIdx = -1; - if (int.TryParse(fieldName, out var idx)) fieldIdx = idx; + // CONSISTENCY(pivot-roundtrip / R9-2): when the Get readback shape + // gave us an explicit numeric cacheField index, prefer it over the + // (possibly stripped) display name. This makes Set values=GetOutput + // robust even if the source headers were renamed between Get and + // Set, and removes any ambiguity from the prefix-strip heuristic. + if (roundTripFieldIdx.HasValue) + { + if (roundTripFieldIdx.Value < 0 || roundTripFieldIdx.Value >= headers.Length) + throw new ArgumentException( + $"field index {roundTripFieldIdx.Value} out of range (0..{headers.Length - 1})"); + fieldIdx = roundTripFieldIdx.Value; + } + else if (int.TryParse(fieldName, out var idx)) + { + // CONSISTENCY(strict-enums / R8-6): a numeric token is a + // column index. Out-of-range indices used to silently drop + // the value-field, producing an empty pivot with no error. + // Reject up front with the available-index range so users + // catch the typo immediately (mirrors the throw used for + // unknown field names). + if (idx < 0 || idx >= headers.Length) + throw new ArgumentException( + $"field index {idx} out of range (0..{headers.Length - 1})"); + fieldIdx = idx; + } else { for (int i = 0; i < headers.Length; i++) - if (headers[i] != null && headers[i].Equals(fieldName, StringComparison.OrdinalIgnoreCase)) { fieldIdx = i; break; } + if (FieldNameMatches(headers[i], fieldName)) { fieldIdx = i; break; } + // CONSISTENCY(field-name-validation): non-numeric token must + // resolve. Same throw shape as ParseFieldList. + if (fieldIdx < 0) + { + var available = string.Join(", ", headers.Where(h => !string.IsNullOrEmpty(h))); + throw new ArgumentException($"field '{fieldName}' not found in source headers: {available}"); + } } if (fieldIdx >= 0 && fieldIdx < headers.Length) { var displayName = $"{char.ToUpper(func[0])}{func[1..]} of {headers[fieldIdx]}"; - result.Add((fieldIdx, func, displayName)); + result.Add((fieldIdx, func, showAs, displayName)); } } return result; } + /// + /// Map a user-facing showAs string to the OOXML ShowDataAsValues enum. + /// Returns null for "normal" (no-op; DataField element omits the attribute). + /// Accepts both snake_case and camelCase forms so users don't get punished + /// by the convention split between CLI params (snake) and XML schema (camel). + /// + /// + /// Inverse of ParseShowDataAs: map a stored OOXML ShowDataAsValues enum + /// back to the canonical snake_case token used in CLI input/output. + /// Used by ReadPivotTableProperties to surface dataField{N}.showAs in + /// Get readback. Defaults to "normal" for unmapped enum values so the + /// caller can suppress them via the Normal short-circuit. + /// + private static string ShowDataAsToCanonicalToken(ShowDataAsValues v) + { + if (v == ShowDataAsValues.Normal) return "normal"; + if (v == ShowDataAsValues.PercentOfTotal) return "percent_of_total"; + if (v == ShowDataAsValues.PercentOfRaw) return "percent_of_row"; + if (v == ShowDataAsValues.PercentOfColumn) return "percent_of_col"; + if (v == ShowDataAsValues.RunTotal) return "running_total"; + if (v == ShowDataAsValues.Difference) return "difference"; + if (v == ShowDataAsValues.PercentageDifference) return "percent_diff"; + if (v == ShowDataAsValues.Index) return "index"; + return v.ToString().ToLowerInvariant(); + } + + /// + /// True if the showAs token is any of the percent_* family + /// (percent_of_total / _row / _col + camelCase / "percent" aliases). + /// Used to force DataField.NumberFormatId to built-in 10 ("0.00%") so + /// computed fractions display as percentages instead of bare decimals. + /// + private static bool IsPercentShowAs(string showAs) + { + return showAs.ToLowerInvariant() switch + { + "percent_of_total" or "percentoftotal" or "percent" => true, + "percent_of_row" or "percentofrow" => true, + "percent_of_col" or "percent_of_column" or "percentofcol" or "percentofcolumn" => true, + _ => false, + }; + } + + private static ShowDataAsValues? ParseShowDataAs(string showAs) + { + return showAs.ToLowerInvariant() switch + { + "" or "normal" => null, + "percent_of_total" or "percentoftotal" or "percent" => ShowDataAsValues.PercentOfTotal, + "percent_of_row" or "percentofrow" => ShowDataAsValues.PercentOfRaw, + "percent_of_col" or "percent_of_column" or "percentofcol" or "percentofcolumn" => ShowDataAsValues.PercentOfColumn, + "running_total" or "runningtotal" or "runtotal" => ShowDataAsValues.RunTotal, + // CONSISTENCY(strict-enums): difference / percent_diff / index are + // accepted by the OOXML ShowDataAsValues enum, but ApplyShowDataAs1x1 + // has no matrix transformation for them, so rendered cells would + // silently equal the raw aggregate. Reject up front until a proper + // renderer exists, mirroring the invalid-sort / invalid-aggregate + // policy from Round 1. + "difference" or "diff" or "percent_diff" or "percentdiff" or "index" => + throw new ArgumentException( + $"showDataAs '{showAs}' is not yet supported by the renderer " + + "(would silently return raw aggregate). Supported: normal, " + + "percent_of_total, percent_of_row, percent_of_col, running_total."), + // CONSISTENCY(strict-enums): unknown showAs tokens are rejected + // up front so users see typos at Add/Set time, not on render. + _ => throw new ArgumentException( + $"invalid showDataAs: '{showAs}'. Valid: normal, percent_of_total, percent_of_row, " + + "percent_of_col, running_total"), + }; + } + + // R11-2: Right-to-left value-spec parser support. Token recognizers + // mirror the cases ParseSubtotal / ParseShowDataAs accept (lowercase + // canonical only — we lowercase the token before calling). Keep these + // in sync if new aggregates / showAs tokens are added downstream. + private static bool IsKnownAggregateToken(string token) => token switch + { + "sum" or "count" or "countnums" or "countnum" or "average" or "avg" or + "max" or "min" or "product" or "stddev" or "std" or "stddevp" or "stdp" or + "var" or "variance" or "varp" => true, + _ => false, + }; + + private static bool IsKnownShowAsToken(string token) => token switch + { + "normal" or + "percent_of_total" or "percentoftotal" or "percent" or + "percent_of_row" or "percentofrow" or + "percent_of_col" or "percent_of_column" or "percentofcol" or "percentofcolumn" or + "running_total" or "runningtotal" or "runtotal" => true, + _ => false, + }; + + /// + /// R15-5: canonical English display prefix for the auto-generated + /// DataField name ("Sum of Sales", "Count of Sales", ...). Matches the + /// displayPrefixes table used by the values-spec round-trip parser. + /// + private static string AggregateDisplayName(string func) => func.ToLowerInvariant() switch + { + "sum" => "Sum", + "count" => "Count", + "countnums" or "countnum" => "Count Numbers", + "average" or "avg" => "Average", + "max" => "Max", + "min" => "Min", + "product" => "Product", + "stddev" or "std" => "StdDev", + "stddevp" or "stdp" => "StdDevp", + "var" or "variance" => "Var", + "varp" => "Varp", + _ => "Sum", + }; + + /// + /// R15-5: true when the current DataField name still matches the auto- + /// generated " of " form, so a Set aggregate + /// call is safe to rewrite it. Any name that does not end in " of + /// " is treated as user-provided and left alone. + /// + private static bool LooksLikeAutoDataFieldName(string name, string sourceHeader) + { + if (string.IsNullOrEmpty(name)) return true; + var suffix = " of " + sourceHeader; + if (!name.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) return false; + var prefix = name.Substring(0, name.Length - suffix.Length); + return prefix is "Sum" or "Count" or "Count Numbers" or "Average" or "Max" + or "Min" or "Product" or "StdDev" or "StdDevp" or "Var" or "Varp" + or "Std Dev" or "Std Dev p"; + } + private static DataConsolidateFunctionValues ParseSubtotal(string func) { return func.ToLowerInvariant() switch { "sum" => DataConsolidateFunctionValues.Sum, "count" => DataConsolidateFunctionValues.Count, + "countnums" or "countnum" => DataConsolidateFunctionValues.CountNumbers, "average" or "avg" => DataConsolidateFunctionValues.Average, "max" => DataConsolidateFunctionValues.Maximum, "min" => DataConsolidateFunctionValues.Minimum, "product" => DataConsolidateFunctionValues.Product, - "stddev" => DataConsolidateFunctionValues.StandardDeviation, - "var" => DataConsolidateFunctionValues.Variance, - _ => DataConsolidateFunctionValues.Sum + "stddev" or "std" => DataConsolidateFunctionValues.StandardDeviation, + "stddevp" or "stdp" => DataConsolidateFunctionValues.StandardDeviationP, + "var" or "variance" => DataConsolidateFunctionValues.Variance, + "varp" => DataConsolidateFunctionValues.VarianceP, + // CONSISTENCY(strict-enums): mirror ParseShowDataAs / ParseFieldList — + // unknown tokens throw at Add/Set time so typos surface immediately + // instead of silently falling back to sum and producing the wrong + // numbers on render (Bug #3). + _ => throw new ArgumentException( + $"invalid aggregate: '{func}'. Valid: sum, count, countNums, average/avg, " + + "max, min, product, stdDev/std, stdDevp/stdp, var/variance, varP"), }; } + /// + /// Aggregate a bag of numeric values using the given subtotal function. + /// Matches LibreOffice's ScDPAggData semantics (sc/source/core/data/dptabres.cxx): + /// sum / product / min / max / count : trivial + /// countNums : count of numeric entries (identical to count here because + /// the caller only places parsed numerics into the bag) + /// average : arithmetic mean + /// stdDev : sample std-dev (sqrt(Σ(x-μ)²/(n-1))), requires n≥2 + /// stdDevp : population std-dev (sqrt(Σ(x-μ)²/n)), requires n≥1 + /// var : sample variance (Σ(x-μ)²/(n-1)), requires n≥2 + /// varp : population variance (Σ(x-μ)²/n), requires n≥1 + /// Returns 0 for empty input and for stdDev/var when n<2, matching the + /// existing 0-on-empty convention that the rest of the renderer assumes. + /// + private static double ReducePivotValues(IEnumerable values, string func) + { + var arr = values as double[] ?? values.ToArray(); + if (arr.Length == 0) return 0; + switch (func.ToLowerInvariant()) + { + case "sum": return arr.Sum(); + case "count": return arr.Length; + case "countnums": + case "countnum": return arr.Length; + case "average": + case "avg": return arr.Average(); + case "min": return arr.Min(); + case "max": return arr.Max(); + case "product": + double p = 1; + foreach (var v in arr) p *= v; + return p; + case "stddev": + case "std": + { + if (arr.Length < 2) return 0; + var mean = arr.Average(); + var sq = arr.Sum(x => (x - mean) * (x - mean)); + return Math.Sqrt(sq / (arr.Length - 1)); + } + case "stddevp": + case "stdp": + { + var mean = arr.Average(); + var sq = arr.Sum(x => (x - mean) * (x - mean)); + return Math.Sqrt(sq / arr.Length); + } + case "var": + case "variance": + { + if (arr.Length < 2) return 0; + var mean = arr.Average(); + var sq = arr.Sum(x => (x - mean) * (x - mean)); + return sq / (arr.Length - 1); + } + case "varp": + { + var mean = arr.Average(); + var sq = arr.Sum(x => (x - mean) * (x - mean)); + return sq / arr.Length; + } + default: return arr.Sum(); + } + } + + /// + /// Apply a showDataAs transform to a 1×1×K pivot matrix for data field d. + /// Used by RenderPivotIntoSheet (the 1 row × 1 col × K data inline + /// renderer). Other renderers share the same normalization by value + /// type but not by matrix layout, so each renderer post-processes its + /// own buckets after aggregation. + /// + /// Supported modes: + /// normal — no-op + /// percent_of_total — divide everything by grandTotals[d] + /// percent_of_row — divide each (r,c) by rowTotals[r] (the whole row shares the divisor) + /// percent_of_col — divide each (r,c) by colTotals[c] + /// running_total — in-row cumulative sum across cols, left→right; + /// rowTotals/grandTotals unchanged (cumulative ends at row total) + /// Unknown modes are silently treated as "normal" so new modes added to + /// ParseShowDataAs don't explode old renderers. + /// + private static void ApplyShowDataAs1x1( + string mode, double?[,,] matrix, double[,] rowTotals, double[,] colTotals, + double[] grandTotals, int rowCount, int colCount, int d) + { + switch (mode.ToLowerInvariant()) + { + case "" or "normal": + return; + + case "percent_of_total" or "percentoftotal" or "percent": + { + var gt = grandTotals[d]; + if (gt == 0) return; + for (int r = 0; r < rowCount; r++) + { + for (int c = 0; c < colCount; c++) + { + if (matrix[r, c, d].HasValue) + matrix[r, c, d] = matrix[r, c, d]!.Value / gt; + } + rowTotals[r, d] = rowTotals[r, d] / gt; + } + for (int c = 0; c < colCount; c++) + colTotals[c, d] = colTotals[c, d] / gt; + grandTotals[d] = 1.0; + return; + } + + case "percent_of_row" or "percentofrow": + { + for (int r = 0; r < rowCount; r++) + { + var rt = rowTotals[r, d]; + if (rt == 0) continue; + for (int c = 0; c < colCount; c++) + { + if (matrix[r, c, d].HasValue) + matrix[r, c, d] = matrix[r, c, d]!.Value / rt; + } + rowTotals[r, d] = 1.0; + } + // Col totals and grand lose their direct interpretation under + // "percent of row" (they're sums of ratios across heterogeneous + // row bases). Excel renders them as the sum of the per-row + // ratios across the column, which equals colSum / grandTotal + // only if all rows share the same total. Mirror that here: + // recompute as "percent of total" for the col and grand cells + // so the displayed numbers sum to 100% across each row but + // col totals reflect "this col's share of the grand total". + var grand = grandTotals[d]; + if (grand != 0) + { + for (int c = 0; c < colCount; c++) + colTotals[c, d] = colTotals[c, d] / grand; + grandTotals[d] = 1.0; + } + return; + } + + case "percent_of_col" or "percent_of_column" or "percentofcol" or "percentofcolumn": + { + for (int c = 0; c < colCount; c++) + { + var ct = colTotals[c, d]; + if (ct == 0) continue; + for (int r = 0; r < rowCount; r++) + { + if (matrix[r, c, d].HasValue) + matrix[r, c, d] = matrix[r, c, d]!.Value / ct; + } + colTotals[c, d] = 1.0; + } + var grand = grandTotals[d]; + if (grand != 0) + { + for (int r = 0; r < rowCount; r++) + rowTotals[r, d] = rowTotals[r, d] / grand; + grandTotals[d] = 1.0; + } + return; + } + + case "running_total" or "runningtotal" or "runtotal": + { + // In-row cumulative sum across cols, left→right. Cells with + // null values count as 0 in the running sum but remain null + // in the output so Excel shows blank instead of the previous + // cumulative value (matches Excel's "(blank)" behavior). + for (int r = 0; r < rowCount; r++) + { + double running = 0; + for (int c = 0; c < colCount; c++) + { + if (matrix[r, c, d].HasValue) + { + running += matrix[r, c, d]!.Value; + matrix[r, c, d] = running; + } + } + } + // Row / col / grand totals are left as-is: running total's + // final-column value already equals the row total, and col / + // grand totals don't have a natural running interpretation + // across rows in Excel's semantics. + return; + } + + default: + return; + } + } + private static (string col, int row) ParseCellRef(string cellRef) { int i = 0; @@ -805,4 +6738,36 @@ private static int ColToIndex(string col) result = result * 26 + (c - 'A' + 1); return result; } + + private static string IndexToCol(int index) + { + // Inverse of ColToIndex (1-based: A=1, Z=26, AA=27, ...) + var sb = new System.Text.StringBuilder(); + while (index > 0) + { + int rem = (index - 1) % 26; + sb.Insert(0, (char)('A' + rem)); + index = (index - 1) / 26; + } + return sb.ToString(); + } + + /// + /// Multiply the cardinality (distinct non-empty values) of each field in the + /// given index list. Used to size the pivot table's rendered area for the + /// Location.ref range. Returns 1 when the list is empty (so layout math stays + /// safe in pivots that have only column fields, only row fields, etc.). + /// + private static int ProductOfUniqueValues(List fieldIndices, List columnData) + { + if (fieldIndices.Count == 0) return 1; + int product = 1; + foreach (var idx in fieldIndices) + { + if (idx < 0 || idx >= columnData.Count) continue; + var unique = columnData[idx].Where(v => !string.IsNullOrEmpty(v)).Distinct().Count(); + product *= Math.Max(1, unique); + } + return product; + } } diff --git a/src/officecli/Core/ResidentClient.cs b/src/officecli/Core/ResidentClient.cs index 5471cd4e4..8406a1855 100644 --- a/src/officecli/Core/ResidentClient.cs +++ b/src/officecli/Core/ResidentClient.cs @@ -20,15 +20,12 @@ public static bool TryConnect(string filePath, out string pipeName) using var client = new NamedPipeClientStream(".", pipeName + "-ping", PipeDirection.InOut); client.Connect(100); // 100ms timeout - using var reader = new StreamReader(client, Encoding.UTF8, leaveOpen: true); - using var writer = new StreamWriter(client, Encoding.UTF8, leaveOpen: true) { AutoFlush = true }; - // Ping to verify it's the right file var pingRequest = new ResidentRequest { Command = "__ping__" }; var json = System.Text.Json.JsonSerializer.Serialize(pingRequest, ResidentJsonContext.Default.ResidentRequest); - writer.WriteLine(json); + PipeWriteLine(client, json); - var responseLine = reader.ReadLine(); + var responseLine = PipeReadLine(client); if (responseLine == null) return false; var response = System.Text.Json.JsonSerializer.Deserialize(responseLine, ResidentJsonContext.Default.ResidentResponse); @@ -60,13 +57,10 @@ public static bool TryConnect(string filePath, out string pipeName) using var client = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut); client.Connect(1000); // 1s timeout (was 200ms — too short under load) - using var reader = new StreamReader(client, Encoding.UTF8, leaveOpen: true); - using var writer = new StreamWriter(client, Encoding.UTF8, leaveOpen: true) { AutoFlush = true }; - var json = System.Text.Json.JsonSerializer.Serialize(request, ResidentJsonContext.Default.ResidentRequest); - writer.WriteLine(json); + PipeWriteLine(client, json); - var responseLine = reader.ReadLine(); + var responseLine = PipeReadLine(client); if (responseLine == null) continue; var response = System.Text.Json.JsonSerializer.Deserialize(responseLine, ResidentJsonContext.Default.ResidentResponse); @@ -93,14 +87,11 @@ public static bool SendClose(string filePath) using var client = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut); client.Connect(200); - using var reader = new StreamReader(client, Encoding.UTF8, leaveOpen: true); - using var writer = new StreamWriter(client, Encoding.UTF8, leaveOpen: true) { AutoFlush = true }; - var request = new ResidentRequest { Command = "__close__" }; var json = System.Text.Json.JsonSerializer.Serialize(request, ResidentJsonContext.Default.ResidentRequest); - writer.WriteLine(json); + PipeWriteLine(client, json); - var responseLine = reader.ReadLine(); + var responseLine = PipeReadLine(client); if (responseLine == null) return false; var response = System.Text.Json.JsonSerializer.Deserialize(responseLine, ResidentJsonContext.Default.ResidentResponse); @@ -111,4 +102,53 @@ public static bool SendClose(string filePath) return false; } } + + // ==================== Pipe I/O helpers ==================== + // + // On Windows, StreamReader/StreamWriter deadlock on named pipes under .NET 11 + // preview — the managed stream wrapper's internal buffering stalls reads even + // when bytes are available on the wire. Raw byte I/O avoids the issue. + // + // On Linux/macOS, StreamReader/StreamWriter work fine and are faster (buffered + // reads), so we keep using them. + + private const int MaxLineLength = 1_048_576; // 1 MB safety limit + + private static void PipeWriteLine(Stream pipe, string line) + { + if (!OperatingSystem.IsWindows()) + { + using var writer = new StreamWriter(pipe, Encoding.UTF8, leaveOpen: true) { AutoFlush = true }; + writer.WriteLine(line); + return; + } + var bytes = Encoding.UTF8.GetBytes(line + "\n"); + pipe.Write(bytes, 0, bytes.Length); + pipe.Flush(); + } + + private static string? PipeReadLine(Stream pipe) + { + if (!OperatingSystem.IsWindows()) + { + using var reader = new StreamReader(pipe, Encoding.UTF8, leaveOpen: true); + return reader.ReadLine(); + } + var buffer = new byte[1]; + var lineBytes = new List(256); + while (true) + { + var bytesRead = pipe.Read(buffer, 0, 1); + if (bytesRead == 0) return lineBytes.Count > 0 ? Encoding.UTF8.GetString(lineBytes.ToArray()) : null; + if (buffer[0] == (byte)'\n') + { + if (lineBytes.Count > 0 && lineBytes[^1] == (byte)'\r') + lineBytes.RemoveAt(lineBytes.Count - 1); + return Encoding.UTF8.GetString(lineBytes.ToArray()); + } + if (lineBytes.Count >= MaxLineLength) + return null; + lineBytes.Add(buffer[0]); + } + } } diff --git a/src/officecli/Core/ResidentServer.cs b/src/officecli/Core/ResidentServer.cs index ff86c04b6..e4f8682f0 100644 --- a/src/officecli/Core/ResidentServer.cs +++ b/src/officecli/Core/ResidentServer.cs @@ -110,54 +110,85 @@ private async Task RunIdleWatchdogAsync(CancellationToken token) private async Task RunPingResponderAsync(CancellationToken token) { var pingPipeName = _pipeName + "-ping"; - while (!token.IsCancellationRequested) + + // BUG-FUZZER-R6-B-01: pre-create the next server instance BEFORE the + // current one is disposed, so there is no window where TryConnect can + // return false even though the resident is alive. Without this, a + // second `officecli open` racing into the dispose-and-recreate gap + // would think no resident exists and spawn a duplicate process. + // Both instances live concurrently via MaxAllowedServerInstances; the + // OS routes the next client to whichever server is in + // WaitForConnectionAsync first. + NamedPipeServerStream NewServer() => new(pingPipeName, PipeDirection.InOut, + NamedPipeServerStream.MaxAllowedServerInstances, + PipeTransmissionMode.Byte, PipeOptions.Asynchronous); + + var current = NewServer(); + try { - var server = new NamedPipeServerStream(pingPipeName, PipeDirection.InOut, - NamedPipeServerStream.MaxAllowedServerInstances, - PipeTransmissionMode.Byte, PipeOptions.Asynchronous); - try + while (!token.IsCancellationRequested) { - await server.WaitForConnectionAsync(token); - using var reader = new StreamReader(server, Encoding.UTF8, leaveOpen: true); - using var writer = new StreamWriter(server, Encoding.UTF8, leaveOpen: true) { AutoFlush = true }; - - var requestLine = await reader.ReadLineAsync(token); - if (requestLine != null) + try { - var request = System.Text.Json.JsonSerializer.Deserialize(requestLine, ResidentJsonContext.Default.ResidentRequest); - if (request?.Command == "__ping__") - { - var response = MakeResponse(0, _filePath, ""); - await writer.WriteLineAsync(response.AsMemory(), token); - } - else if (request?.Command == "__close__") + await current.WaitForConnectionAsync(token); + + // Hand over the just-accepted server to the request + // handler and immediately stand up the replacement so the + // pipe is never unlistened. The OS holds the new server + // ready while this request is being processed. + var accepted = current; + current = NewServer(); + + // Use raw byte I/O instead of StreamReader/StreamWriter. + // StreamReader.ReadLineAsync(CancellationToken) can deadlock on + // Windows named pipes under .NET 11 preview — the cancellation-aware + // overload uses a different code path that never completes the read. + try { - var response = MakeResponse(0, "Closing resident.", ""); - await writer.WriteLineAsync(response.AsMemory(), token); - _cts.Cancel(); - // Kick the main pipe listener out of WaitForConnectionAsync - try + var requestLine = await ReadLineFromPipeAsync(accepted, token); + if (requestLine != null) { - using var kick = new NamedPipeClientStream(".", _pipeName, PipeDirection.InOut); - kick.Connect(500); + var request = System.Text.Json.JsonSerializer.Deserialize(requestLine, ResidentJsonContext.Default.ResidentRequest); + if (request?.Command == "__ping__") + { + var response = MakeResponse(0, _filePath, ""); + await WriteLineToPipeAsync(accepted, response, token); + } + else if (request?.Command == "__close__") + { + var response = MakeResponse(0, "Closing resident.", ""); + await WriteLineToPipeAsync(accepted, response, token); + _cts.Cancel(); + // Kick the main pipe listener out of WaitForConnectionAsync + try + { + using var kick = new NamedPipeClientStream(".", _pipeName, PipeDirection.InOut); + kick.Connect(500); + } + catch { } + return; + } } - catch { } - break; + } + finally + { + await accepted.DisposeAsync(); } } + catch (OperationCanceledException) + { + break; + } + catch + { + // Ignore individual request errors; the next iteration's + // current server is already standing by. + } } - catch (OperationCanceledException) - { - break; - } - catch - { - // Ignore ping errors - } - finally - { - await server.DisposeAsync(); - } + } + finally + { + try { await current.DisposeAsync(); } catch { } } } @@ -190,14 +221,11 @@ private async Task HandleClientWithLockAsync(NamedPipeServerStream server, Cance private async Task HandleClientAsync(NamedPipeServerStream server, CancellationToken token) { - using var reader = new StreamReader(server, Encoding.UTF8, leaveOpen: true); - using var writer = new StreamWriter(server, Encoding.UTF8, leaveOpen: true) { AutoFlush = true }; - - var requestLine = await reader.ReadLineAsync(token); + var requestLine = await ReadLineFromPipeAsync(server, token); if (requestLine == null) return; var response = ProcessRequest(requestLine); - await writer.WriteLineAsync(response.AsMemory(), token); + await WriteLineToPipeAsync(server, response, token); } private string ProcessRequest(string requestLine) @@ -338,8 +366,13 @@ private void ExecuteCommand(ResidentRequest request) ExecuteValidate(); break; default: - Console.Error.WriteLine($"Unknown command: {request.Command}"); - break; + // BUG-FUZZER-R6-A-06/07: previously this branch only wrote to + // stderr and fell through, leaving the response with + // ExitCode=0. Callers (and especially the AI agent piping the + // CLI) had no way to detect that a typo / case-mangled verb + // was actually rejected. Throw so ProcessRequest's exception + // handler maps this to a proper non-zero ExitCode response. + throw new InvalidOperationException($"Unknown command: {request.Command}"); } } @@ -574,18 +607,18 @@ private void ExecuteAdd(ResidentRequest req) { var parentPath = req.GetArg("parent", "/body"); var from = req.GetArgOrNull("from"); - var index = req.GetIntArg("index"); + var position = BuildInsertPosition(req); if (!string.IsNullOrEmpty(from)) { - var resultPath = _handler.CopyFrom(from, parentPath, index); + var resultPath = _handler.CopyFrom(from, parentPath, position); Console.WriteLine($"Copied to {resultPath}"); } else { var type = req.GetArg("type", ""); var properties = req.GetProps(); - var resultPath = _handler.Add(parentPath, type, index, properties); + var resultPath = _handler.Add(parentPath, type, position, properties); Console.WriteLine($"Added {type} at {resultPath}"); } } @@ -601,11 +634,21 @@ private void ExecuteMove(ResidentRequest req) { var path = req.GetArg("path", "/"); var to = req.GetArgOrNull("to"); - var index = req.GetIntArg("index"); - var resultPath = _handler.Move(path, to, index); + var resultPath = _handler.Move(path, to, BuildInsertPosition(req)); Console.WriteLine($"Moved to {resultPath}"); } + private static InsertPosition? BuildInsertPosition(ResidentRequest req) + { + var index = req.GetIntArg("index"); + var after = req.GetArgOrNull("after"); + var before = req.GetArgOrNull("before"); + if (index.HasValue) return InsertPosition.AtIndex(index.Value); + if (after != null) return InsertPosition.AfterElement(after); + if (before != null) return InsertPosition.BeforeElement(before); + return null; + } + private void ExecuteRaw(ResidentRequest req) { var partPath = req.GetArg("part", "/document"); @@ -686,6 +729,52 @@ private static string MakeResponse(int exitCode, string stdout, string stderr) return System.Text.Json.JsonSerializer.Serialize(response, ResidentJsonContext.Default.ResidentResponse); } + // ==================== Pipe I/O helpers ==================== + // + // On Windows, StreamReader/StreamWriter deadlock on named pipes under .NET 11 + // preview. Raw byte I/O avoids the issue. + // On Linux/macOS, StreamReader/StreamWriter work fine and are faster. + + private const int MaxLineLength = 1_048_576; // 1 MB safety limit + + private static async Task ReadLineFromPipeAsync(Stream pipe, CancellationToken token) + { + if (!OperatingSystem.IsWindows()) + { + using var reader = new StreamReader(pipe, Encoding.UTF8, leaveOpen: true); + return await reader.ReadLineAsync(token); + } + var buffer = new byte[1]; + var lineBytes = new List(256); + while (true) + { + var bytesRead = await pipe.ReadAsync(buffer.AsMemory(0, 1), token); + if (bytesRead == 0) return lineBytes.Count > 0 ? Encoding.UTF8.GetString(lineBytes.ToArray()) : null; + if (buffer[0] == (byte)'\n') + { + if (lineBytes.Count > 0 && lineBytes[^1] == (byte)'\r') + lineBytes.RemoveAt(lineBytes.Count - 1); + return Encoding.UTF8.GetString(lineBytes.ToArray()); + } + if (lineBytes.Count >= MaxLineLength) + return null; + lineBytes.Add(buffer[0]); + } + } + + private static async Task WriteLineToPipeAsync(Stream pipe, string line, CancellationToken token) + { + if (!OperatingSystem.IsWindows()) + { + using var writer = new StreamWriter(pipe, Encoding.UTF8, leaveOpen: true) { AutoFlush = true }; + await writer.WriteLineAsync(line.AsMemory(), token); + return; + } + var bytes = Encoding.UTF8.GetBytes(line + "\n"); + await pipe.WriteAsync(bytes, token); + await pipe.FlushAsync(token); + } + public void Dispose() { if (!_disposed) diff --git a/src/officecli/Core/UpdateChecker.cs b/src/officecli/Core/UpdateChecker.cs index 8bfe27807..d3a69d636 100644 --- a/src/officecli/Core/UpdateChecker.cs +++ b/src/officecli/Core/UpdateChecker.cs @@ -121,20 +121,24 @@ internal static void RunRefresh() downloadClient.Timeout = TimeSpan.FromMinutes(5); var downloadUrl = $"{resolvedBase}/releases/latest/download/{assetName}"; - var tempPath = exePath + ".update"; + var finalPath = exePath + ".update"; + // Stage download to .partial so a crashed/killed download never leaves + // a truncated PE at the canonical .update path that ApplyPendingUpdate would apply. + var partialPath = exePath + ".update.partial"; + try { File.Delete(partialPath); } catch { } using (var stream = downloadClient.GetStreamAsync(downloadUrl).GetAwaiter().GetResult()) - using (var fileStream = File.Create(tempPath)) + using (var fileStream = File.Create(partialPath)) { stream.CopyTo(fileStream); } // Verify downloaded binary can start if (!OperatingSystem.IsWindows()) - Process.Start("chmod", $"+x \"{tempPath}\"")?.WaitForExit(3000); + Process.Start("chmod", $"+x \"{partialPath}\"")?.WaitForExit(3000); var verify = Process.Start(new ProcessStartInfo { - FileName = tempPath, + FileName = partialPath, Arguments = "--version", UseShellExecute = false, RedirectStandardOutput = true, @@ -144,14 +148,26 @@ internal static void RunRefresh() }); if (verify == null) { - try { File.Delete(tempPath); } catch { } + try { File.Delete(partialPath); } catch { } return; } var exited = verify.WaitForExit(5000); if (!exited || verify.ExitCode != 0) { if (!exited) try { verify.Kill(); } catch { } - try { File.Delete(tempPath); } catch { } + try { File.Delete(partialPath); } catch { } + return; + } + + // Atomically promote .partial -> .update only after verification. + try { File.Delete(finalPath); } catch { } + try + { + File.Move(partialPath, finalPath, overwrite: true); + } + catch + { + try { File.Delete(partialPath); } catch { } return; } @@ -164,15 +180,15 @@ internal static void RunRefresh() // Unix: replace in-place (safe even while running) var oldPath = exePath + ".old"; try { File.Delete(oldPath); } catch { } - File.Move(exePath, oldPath); + File.Move(exePath, oldPath, overwrite: true); try { - File.Move(tempPath, exePath); + File.Move(finalPath, exePath, overwrite: true); } catch { // Rollback: restore original if new file failed to move - try { File.Move(oldPath, exePath); } catch { } + try { File.Move(oldPath, exePath, overwrite: true); } catch { } return; } try { File.Delete(oldPath); } catch { } @@ -195,31 +211,41 @@ internal static void RunRefresh() /// Apply a pending update (.update file) from a previous background check. /// private static void ApplyPendingUpdate() + { + var exePath = Environment.ProcessPath ?? Process.GetCurrentProcess().MainModule?.FileName; + if (exePath == null) return; + TryApplyPendingUpdate(exePath); + } + + /// + /// Test seam: applies a pending {exePath}.update by swapping it into place. + /// Note: only the canonical .update file is applied — a stale + /// .update.partial from an interrupted download is intentionally ignored. + /// + internal static bool TryApplyPendingUpdate(string exePath) { try { - var exePath = Environment.ProcessPath ?? Process.GetCurrentProcess().MainModule?.FileName; - if (exePath == null) return; - var updatePath = exePath + ".update"; - if (!File.Exists(updatePath)) return; + if (!File.Exists(updatePath)) return false; var oldPath = exePath + ".old"; try { File.Delete(oldPath); } catch { } - File.Move(exePath, oldPath); + File.Move(exePath, oldPath, overwrite: true); try { - File.Move(updatePath, exePath); + File.Move(updatePath, exePath, overwrite: true); } catch { // Rollback: restore original - try { File.Move(oldPath, exePath); } catch { } - return; + try { File.Move(oldPath, exePath, overwrite: true); } catch { } + return false; } try { File.Delete(oldPath); } catch { } + return true; } - catch { } + catch { return false; } } private static string? GetAssetName() @@ -350,11 +376,16 @@ internal static AppConfig LoadConfig() catch { return new AppConfig(); } } - private static void SaveConfig(AppConfig config) + internal static void SaveConfig(AppConfig config) { + Directory.CreateDirectory(ConfigDir); var json = JsonSerializer.Serialize(config, AppConfigContext.Default.AppConfig); File.WriteAllText(ConfigPath, json); } + + internal static string? GetCurrentVersionPublic() => GetCurrentVersion(); + + internal static bool IsNewerPublic(string latest, string current) => IsNewer(latest, current); } internal class AppConfig @@ -363,6 +394,7 @@ internal class AppConfig public string? LatestVersion { get; set; } public bool AutoUpdate { get; set; } = true; public bool Log { get; set; } + public string? InstalledBinaryVersion { get; set; } } [JsonSerializable(typeof(AppConfig))] diff --git a/src/officecli/Core/WatchMark.cs b/src/officecli/Core/WatchMark.cs new file mode 100644 index 000000000..a3972a05f --- /dev/null +++ b/src/officecli/Core/WatchMark.cs @@ -0,0 +1,187 @@ +// Copyright 2025 OfficeCli (officecli.ai) +// SPDX-License-Identifier: Apache-2.0 +// +// CONSISTENCY(watch-isolation): 本文件不引用 OfficeCli.Handlers,不打开文件,不写盘。 +// 见 CLAUDE.md "Watch Server Rules"。要放宽这条红线, +// grep "CONSISTENCY(watch-isolation)" 找全 watch 子系统所有文件项目级一起评审。 + +using System.Text.Json.Serialization; + +namespace OfficeCli.Core; + +/// +/// In-memory mark stored on the WatchServer. Marks are advisory annotations +/// (find/expect/note/color) attached to a document path. They live only in +/// the watch process — never persisted to disk, never written into the +/// underlying OOXML file. The watch server stores them; browsers re-locate +/// the find target in the live DOM after each refresh. +/// +/// Find supports two forms (matching Set's vocabulary verbatim): +/// • literal: find = "hello" +/// • regex: find = r"[abc]" OR find = "[abc]" with regex=true flag +/// The flag is normalized into the r"..." form on insert (see WatchServer). +/// +/// Tofix is a free-form display label rendered in the mark tooltip alongside +/// the find pattern. It does NOT participate in matching or staleness — when +/// a mark goes stale (find no longer hits), tofix is the human hint for +/// "what should be done about it". +/// +public class WatchMark +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("path")] + public string Path { get; set; } = ""; + + [JsonPropertyName("find")] + public string? Find { get; set; } + + [JsonPropertyName("color")] + public string? Color { get; set; } + + [JsonPropertyName("note")] + public string? Note { get; set; } + + [JsonPropertyName("tofix")] + public string? Tofix { get; set; } + + /// + /// Always an array. For literal find: 0 entries (no match → stale) + /// or 1 entry (the literal text). For regex find: 0..N entries. + /// Server stores whatever the client reports back; default = empty. + /// + [JsonPropertyName("matched_text")] + public string[] MatchedText { get; set; } = Array.Empty(); + + [JsonPropertyName("stale")] + public bool Stale { get; set; } + + [JsonPropertyName("created_at")] + public DateTime CreatedAt { get; set; } +} + +/// Request payload for the "mark" pipe command. +public class MarkRequest +{ + [JsonPropertyName("path")] + public string Path { get; set; } = ""; + + [JsonPropertyName("find")] + public string? Find { get; set; } + + [JsonPropertyName("color")] + public string? Color { get; set; } + + [JsonPropertyName("note")] + public string? Note { get; set; } + + [JsonPropertyName("tofix")] + public string? Tofix { get; set; } +} + +/// Request payload for the "unmark" pipe command. +public class UnmarkRequest +{ + [JsonPropertyName("path")] + public string? Path { get; set; } + + [JsonPropertyName("all")] + public bool All { get; set; } +} + +/// +/// Response payload for "mark". On success, is the assigned +/// mark id. On server-side rejection (invalid color, invalid path, malformed +/// request), carries the reason and Id is empty. +/// BUG-BT-001: callers MUST check Error first — an empty Id is not the same +/// as a null pipe response. +/// +public class MarkResponse +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("error")] + public string? Error { get; set; } +} + +/// Response payload for "unmark" — returns the removed count or error. +public class UnmarkResponse +{ + [JsonPropertyName("removed")] + public int Removed { get; set; } + + [JsonPropertyName("error")] + public string? Error { get; set; } +} + +/// +/// Thrown by / RemoveMarks when the +/// running watch process accepts the pipe call but rejects the request +/// (invalid color, invalid path, etc.). Distinct from "no watch running" +/// (which returns null) so the CLI can surface the actual error message +/// instead of silently treating an empty id as success. +/// +public sealed class MarkRejectedException : Exception +{ + public MarkRejectedException(string message) : base(message) { } +} + +/// +/// Response payload for "get-marks" — carries the current marks list plus +/// a monotonic version counter so clients can CAS on top of the SSE +/// broadcast stream without missing updates. +/// +public class MarksResponse +{ + [JsonPropertyName("version")] + public int Version { get; set; } + + [JsonPropertyName("marks")] + public WatchMark[] Marks { get; set; } = Array.Empty(); +} + +[JsonSerializable(typeof(WatchMark))] +[JsonSerializable(typeof(WatchMark[]))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(MarkRequest))] +[JsonSerializable(typeof(UnmarkRequest))] +[JsonSerializable(typeof(MarkResponse))] +[JsonSerializable(typeof(UnmarkResponse))] +[JsonSerializable(typeof(MarksResponse))] +internal partial class WatchMarkJsonContext : JsonSerializerContext { } + +/// +/// Shared JSON serializer options for the watch subsystem. Uses +/// UnsafeRelaxedJsonEscaping so CJK / non-ASCII payloads round-trip as +/// literal characters (资钱) instead of \uXXXX escapes — A complained +/// these were unreadable during manual debugging. +/// +/// "Unsafe" in the encoder name refers to HTML/attribute contexts: the +/// server emits these bytes inside SSE `data:` lines and a named pipe +/// where they are consumed as raw JSON, not embedded in HTML. +/// +/// AOT-friendly pattern: we build Relaxed once by cloning the source-gen +/// context's baked-in Options and overriding only the encoder, then cache +/// typed +/// instances that production code uses directly. The typed overloads +/// satisfy the trimmer without IL2026 warnings. +/// +internal static class WatchMarkJsonOptions +{ + public static readonly System.Text.Json.JsonSerializerOptions Relaxed = + new(WatchMarkJsonContext.Default.Options) + { + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + + public static readonly System.Text.Json.Serialization.Metadata.JsonTypeInfo WatchMarkInfo = + (System.Text.Json.Serialization.Metadata.JsonTypeInfo)Relaxed.GetTypeInfo(typeof(WatchMark)); + + public static readonly System.Text.Json.Serialization.Metadata.JsonTypeInfo WatchMarkArrayInfo = + (System.Text.Json.Serialization.Metadata.JsonTypeInfo)Relaxed.GetTypeInfo(typeof(WatchMark[])); + + public static readonly System.Text.Json.Serialization.Metadata.JsonTypeInfo MarksResponseInfo = + (System.Text.Json.Serialization.Metadata.JsonTypeInfo)Relaxed.GetTypeInfo(typeof(MarksResponse)); +} diff --git a/src/officecli/Core/WatchNotifier.cs b/src/officecli/Core/WatchNotifier.cs index 4f5f7af2d..eadafe47c 100644 --- a/src/officecli/Core/WatchNotifier.cs +++ b/src/officecli/Core/WatchNotifier.cs @@ -1,5 +1,9 @@ // Copyright 2025 OfficeCli (officecli.ai) // SPDX-License-Identifier: Apache-2.0 +// +// CONSISTENCY(watch-isolation): 本文件不引用 OfficeCli.Handlers,不打开文件,不写盘。 +// 见 CLAUDE.md "Watch Server Rules"。要放宽这条红线, +// grep "CONSISTENCY(watch-isolation)" 找全 watch 子系统所有文件项目级一起评审。 using System.IO.Pipes; using System.Text; @@ -48,6 +52,178 @@ public static void NotifyIfWatching(string filePath, WatchMessage message) } } + /// + /// Query the running watch process for the current selection. + /// Returns: + /// null → no watch running for this file (or pipe failure) + /// [] → watch is running but nothing is selected + /// [...] → list of currently-selected element paths + /// + public static string[]? QuerySelection(string filePath) + { + try + { + string[]? result = null; + RunWithTimeout(() => + { + var pipeName = WatchServer.GetWatchPipeName(filePath); + using var client = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut); + client.Connect(200); + + var noBom = new UTF8Encoding(false); + using var writer = new StreamWriter(client, noBom, leaveOpen: true) { AutoFlush = true }; + writer.WriteLine("get-selection"); + writer.Flush(); + + using var reader = new StreamReader(client, noBom, detectEncodingFromByteOrderMarks: false, leaveOpen: true); + var json = reader.ReadLine(); + if (json == null) { result = Array.Empty(); return; } + result = JsonSerializer.Deserialize(json, WatchSelectionJsonContext.Default.StringArray) + ?? Array.Empty(); + }, PipeTimeout); + return result; + } + catch + { + return null; // no watch running, or timed out + } + } + + // ==================== Marks ==================== + + /// + /// Add a mark to the running watch process. Returns the assigned id, or + /// null if no watch is running. Throws if the request payload is rejected. + /// + /// The find string should be passed as-is. The CLI must wrap with r"..." + /// when regex=true (mirroring WordHandler.Set's vocabulary). + /// + public static string? AddMark(string filePath, MarkRequest request) + { + // BUG-BT-001: distinguish "no watch running" from "watch rejected the + // request". Pipe failures → return null so CLI prints "start watch first". + // Server-side reject (Error field) → throw MarkRejectedException so CLI + // surfaces the real error instead of silently treating empty id as success. + string? result = null; + string? error = null; + try + { + RunWithTimeout(() => + { + var pipeName = WatchServer.GetWatchPipeName(filePath); + using var client = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut); + client.Connect(200); + + var noBom = new UTF8Encoding(false); + using var writer = new StreamWriter(client, noBom, leaveOpen: true) { AutoFlush = true }; + var payload = JsonSerializer.Serialize(request, WatchMarkJsonContext.Default.MarkRequest); + writer.WriteLine("mark " + payload); + writer.Flush(); + + using var reader = new StreamReader(client, noBom, detectEncodingFromByteOrderMarks: false, leaveOpen: true); + var responseLine = reader.ReadLine(); + if (string.IsNullOrEmpty(responseLine)) { result = null; return; } + var resp = JsonSerializer.Deserialize(responseLine, WatchMarkJsonContext.Default.MarkResponse); + // BUG-FUZZER-R3-M01: use IsNullOrWhiteSpace for symmetry with the + // server-side path/color validation. A whitespace-only error string + // would otherwise spuriously throw MarkRejectedException. + if (!string.IsNullOrWhiteSpace(resp?.Error)) { error = resp!.Error; return; } + result = string.IsNullOrEmpty(resp?.Id) ? null : resp.Id; + }, PipeTimeout); + } + catch + { + return null; // no watch running, or pipe failure + } + if (error != null) throw new MarkRejectedException(error); + return result; + } + + /// + /// Remove marks from the running watch process. Returns count removed, + /// or null if no watch is running. + /// + public static int? RemoveMarks(string filePath, UnmarkRequest request) + { + try + { + int? result = null; + RunWithTimeout(() => + { + var pipeName = WatchServer.GetWatchPipeName(filePath); + using var client = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut); + client.Connect(200); + + var noBom = new UTF8Encoding(false); + using var writer = new StreamWriter(client, noBom, leaveOpen: true) { AutoFlush = true }; + var payload = JsonSerializer.Serialize(request, WatchMarkJsonContext.Default.UnmarkRequest); + writer.WriteLine("unmark " + payload); + writer.Flush(); + + using var reader = new StreamReader(client, noBom, detectEncodingFromByteOrderMarks: false, leaveOpen: true); + var responseLine = reader.ReadLine(); + if (string.IsNullOrEmpty(responseLine)) { result = 0; return; } + var resp = JsonSerializer.Deserialize(responseLine, WatchMarkJsonContext.Default.UnmarkResponse); + result = resp?.Removed ?? 0; + }, PipeTimeout); + return result; + } + catch + { + return null; // no watch running + } + } + + /// + /// Query all marks currently held by the watch process. Returns null if + /// no watch is running, an empty array if the watch is running but no + /// marks have been added, or the full list of marks otherwise. + /// + /// Thin wrapper over for callers that only + /// care about the array. Use QueryMarksFull if you need the version. + /// + public static WatchMark[]? QueryMarks(string filePath) + { + var full = QueryMarksFull(filePath); + return full?.Marks; + } + + /// + /// Query marks + monotonic version. Returns null if no watch is running. + /// The version field lets callers CAS-style detect whether marks changed + /// between two reads; the CLI's get-marks --json output surfaces this + /// directly so AI consumers can cache without re-parsing. + /// + public static MarksResponse? QueryMarksFull(string filePath) + { + try + { + MarksResponse? result = null; + RunWithTimeout(() => + { + var pipeName = WatchServer.GetWatchPipeName(filePath); + using var client = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut); + client.Connect(200); + + var noBom = new UTF8Encoding(false); + using var writer = new StreamWriter(client, noBom, leaveOpen: true) { AutoFlush = true }; + writer.WriteLine("get-marks"); + writer.Flush(); + + using var reader = new StreamReader(client, noBom, detectEncodingFromByteOrderMarks: false, leaveOpen: true); + var json = reader.ReadLine(); + if (json == null) { result = new MarksResponse(); return; } + result = JsonSerializer.Deserialize(json, WatchMarkJsonContext.Default.MarksResponse) + ?? new MarksResponse(); + }, PipeTimeout); + return result; + } + catch + { + return null; // no watch running + } + } + /// /// Send a close command to a running watch process. /// Returns true if the watch was successfully closed. @@ -169,3 +345,34 @@ public class WordPatch [System.Text.Json.Serialization.JsonSerializable(typeof(WatchMessage))] [System.Text.Json.Serialization.JsonSerializable(typeof(WordPatch))] internal partial class WatchMessageJsonContext : System.Text.Json.Serialization.JsonSerializerContext { } + +/// +/// Request body for POST /api/selection — list of currently selected element paths. +/// +public class SelectionRequest +{ + [System.Text.Json.Serialization.JsonPropertyName("paths")] + public List? Paths { get; set; } +} + +[System.Text.Json.Serialization.JsonSerializable(typeof(SelectionRequest))] +[System.Text.Json.Serialization.JsonSerializable(typeof(string[]))] +internal partial class WatchSelectionJsonContext : System.Text.Json.Serialization.JsonSerializerContext { } + +/// +/// Selection-side mirror of : same +/// UnsafeRelaxedJsonEscaping relaxation. Selection paths are usually ASCII +/// today but future path schemes may carry CJK or symbols (e.g. path +/// predicates referencing element text), so keep the two sides in sync. +/// +internal static class WatchSelectionJsonOptions +{ + public static readonly System.Text.Json.JsonSerializerOptions Relaxed = + new(WatchSelectionJsonContext.Default.Options) + { + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + + public static readonly System.Text.Json.Serialization.Metadata.JsonTypeInfo StringArrayInfo = + (System.Text.Json.Serialization.Metadata.JsonTypeInfo)Relaxed.GetTypeInfo(typeof(string[])); +} diff --git a/src/officecli/Core/WatchServer.cs b/src/officecli/Core/WatchServer.cs index e54e5595c..4ad71e711 100644 --- a/src/officecli/Core/WatchServer.cs +++ b/src/officecli/Core/WatchServer.cs @@ -1,5 +1,9 @@ // Copyright 2025 OfficeCli (officecli.ai) // SPDX-License-Identifier: Apache-2.0 +// +// CONSISTENCY(watch-isolation): 本文件不引用 OfficeCli.Handlers,不打开文件,不写盘。 +// 见 CLAUDE.md "Watch Server Rules"。要放宽这条红线, +// grep "CONSISTENCY(watch-isolation)" 找全 watch 子系统所有文件项目级一起评审。 using System.Net; using System.Net.Sockets; @@ -28,6 +32,30 @@ public class WatchServer : IDisposable private DateTime _lastActivityTime = DateTime.UtcNow; private readonly TimeSpan _idleTimeout; + // Current selection — paths of elements selected in any connected browser. + // Single shared list (last-write-wins): all browsers viewing the same file see + // the same selection. CLI reads this via the named pipe "get-selection" command. + // + // CONSISTENCY(path-stability): selection 和 mark 共享同一套裸位置寻址契约, + // 没有指纹/漂移检测。要升级到稳定 ID,grep "CONSISTENCY(path-stability)" + // 找全所有 deferred 站点项目级一起改。见 CLAUDE.md "Design Principles"。 + private List _currentSelection = new(); + private readonly object _selectionLock = new(); + + // Current marks — advisory annotations attached to document paths. Live in + // memory only. Server never opens the document and never inspects DOM — + // marks are pure metadata; the browser computes match positions client-side. + // + // CONSISTENCY(path-stability): 元素删除/位置漂移的处理刻意和 selection 一致 —— + // 裸位置寻址,无指纹,无漂移检测。stale 仅在 path 解析失败或 find 不命中时由 + // 客户端报告设置。见 CLAUDE.md "Design Principles" + "Watch Server Rules"。 + // 要修复成稳定 ID 路径,grep "CONSISTENCY(path-stability)" 找全所有 deferred 站点 + // (selection / mark / 未来其它 path 消费者)项目级一起改,不要在 mark 单点改。 + private readonly List _currentMarks = new(); + private readonly object _marksLock = new(); + private int _marksVersion = 0; + private int _nextMarkId = 1; + private const string WaitingHtml = """ Watching... bodies + // BEFORE per-tag stripping. _tagStripRx only removes tags, so without + // this step inner JS/CSS text leaks into find matching. + var noScript = _scriptBodyRx.Replace(htmlFragment, ""); + var noStyle = _styleBodyRx.Replace(noScript, ""); + var stripped = _tagStripRx.Replace(noStyle, ""); + var decoded = System.Net.WebUtility.HtmlDecode(stripped); + try { return decoded.Normalize(System.Text.NormalizationForm.FormC); } + catch { return decoded; } + } + + /// + /// Resolve a mark against the current HTML snapshot: populate + /// MatchedText and Stale based on whether the path still resolves + /// and whether find still matches. + /// + /// Pure function: returns a new WatchMark, does not mutate the input. + /// The caller is responsible for locking _marksLock if it's writing back + /// into _currentMarks. + /// + internal static WatchMark ResolveMark(WatchMark mark, string currentHtml) + { + var resolved = new WatchMark + { + Id = mark.Id, + Path = mark.Path, + Find = mark.Find, + Color = mark.Color, + Note = mark.Note, + Tofix = mark.Tofix, + CreatedAt = mark.CreatedAt, + // Defaults get overwritten below. + MatchedText = Array.Empty(), + Stale = false, + }; + + if (string.IsNullOrEmpty(currentHtml)) + { + // No snapshot yet (watch just started, first refresh not arrived) — + // treat as "not resolvable yet" but don't flag stale: the CLI may + // be adding marks before the first render. Stale stays false. + return resolved; + } + + var fragment = FindDataPathInHtml(currentHtml, mark.Path); + if (fragment == null) + { + resolved.Stale = true; + return resolved; + } + + if (string.IsNullOrEmpty(mark.Find)) + { + // Whole-element mark — no text matching needed. + return resolved; + } + + var text = ExtractTextContent(fragment); + var find = mark.Find; + + // CONSISTENCY(find-regex): r"..." / r'...' raw-string prefix detection + // matches WordHandler.Set.cs:60-61 and CommandBuilder.Mark.cs. Keep in + // sync. grep "CONSISTENCY(find-regex)" for every project-wide site. + bool isRegex = find.Length >= 3 + && find[0] == 'r' + && (find[1] == '"' || find[1] == '\'') + && find[^1] == find[1]; + + if (isRegex) + { + var pattern = find.Substring(2, find.Length - 3); + try + { + // BUG-TESTER-001: bound the match with MarkRegexMatchTimeout so a + // catastrophic backtracker cannot freeze the reconcile loop. + var matches = System.Text.RegularExpressions.Regex.Matches( + text, pattern, + System.Text.RegularExpressions.RegexOptions.None, + MarkRegexMatchTimeout); + if (matches.Count == 0) + { + resolved.Stale = true; + return resolved; + } + var list = new string[matches.Count]; + for (int i = 0; i < matches.Count; i++) list[i] = matches[i].Value; + resolved.MatchedText = list; + return resolved; + } + catch (System.Text.RegularExpressions.RegexMatchTimeoutException) + { + // Pattern took too long against this input → treat as stale with + // empty matches. Future reconciles will retry against fresh HTML. + resolved.Stale = true; + resolved.MatchedText = Array.Empty(); + return resolved; + } + catch + { + // Bad regex → treat as no match, stale. + resolved.Stale = true; + return resolved; + } + } + else + { + var needle = find; + try { needle = needle.Normalize(System.Text.NormalizationForm.FormC); } catch { } + if (text.IndexOf(needle, StringComparison.Ordinal) < 0) + { + resolved.Stale = true; + return resolved; + } + resolved.MatchedText = new[] { needle }; + return resolved; + } + } + + /// + /// Re-run ResolveMark on every mark in the current list. Called when the + /// cached HTML snapshot changes (document reload / full refresh). Updates + /// each mark's MatchedText and Stale in place and bumps _marksVersion so + /// clients that missed the change can detect it. + /// + private void ReconcileAllMarks() + { + WatchMark[] snapshot; + lock (_marksLock) + { + if (_currentMarks.Count == 0) return; + for (int i = 0; i < _currentMarks.Count; i++) + { + _currentMarks[i] = ResolveMark(_currentMarks[i], _currentHtml); + } + _marksVersion++; + snapshot = _currentMarks.ToArray(); + } + BroadcastMarkUpdate(snapshot); + } + /// Replace a single slide fragment in the full HTML by data-slide number. private static string PatchSlideInHtml(string html, int slideNum, string newFragment) { @@ -827,7 +1826,7 @@ private async Task HandleClientAsync(TcpClient client, CancellationToken token) try { var stream = client.GetStream(); - var requestLine = await ReadHttpRequestAsync(stream, token); + var (requestLine, headers, bodyPrefix) = await ReadHttpRequestHeaderAsync(stream, token); if (requestLine.Contains("GET /events")) { @@ -839,19 +1838,56 @@ private async Task HandleClientAsync(TcpClient client, CancellationToken token) { client.Close(); } + return; } - else + + if (requestLine.StartsWith("POST /api/selection", StringComparison.Ordinal)) + { + await HandlePostSelectionAsync(stream, headers, bodyPrefix, token); + client.Close(); + return; + } + + // BUG-TESTER-R503: GET/PUT/etc on /api/selection must return 405, + // not fall through to the HTML preview. Without this, an API + // client that uses the wrong verb gets back a 200 HTML page and + // never realizes the request was malformed. + if (requestLine.Contains(" /api/selection")) + { + var msg = Encoding.UTF8.GetBytes("Method Not Allowed: /api/selection only accepts POST"); + var hdr = Encoding.UTF8.GetBytes( + $"HTTP/1.1 405 Method Not Allowed\r\nAllow: POST\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {msg.Length}\r\nConnection: close\r\n\r\n"); + await stream.WriteAsync(hdr, token); + await stream.WriteAsync(msg, token); + client.Close(); + return; + } + + // BUG-TESTER-R504: any other /api/... path is unknown and must + // return 404. Without this, an agent that mistypes /api/marks + // (we don't have a marks HTTP endpoint, only the pipe verb) gets + // the HTML preview page back and silently misroutes. + if (requestLine.Contains(" /api/")) { - var html = string.IsNullOrEmpty(_currentHtml) - ? InjectSseScript(WaitingHtml) - : InjectSseScript(_currentHtml); - var body = Encoding.UTF8.GetBytes(html); - var header = Encoding.UTF8.GetBytes( - $"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {body.Length}\r\nConnection: close\r\n\r\n"); - await stream.WriteAsync(header, token); - await stream.WriteAsync(body, token); + var msg = Encoding.UTF8.GetBytes("Not Found"); + var hdr = Encoding.UTF8.GetBytes( + $"HTTP/1.1 404 Not Found\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {msg.Length}\r\nConnection: close\r\n\r\n"); + await stream.WriteAsync(hdr, token); + await stream.WriteAsync(msg, token); client.Close(); + return; } + + // Default: serve current HTML (GET / and everything else) + var html = string.IsNullOrEmpty(_currentHtml) + ? InjectSseScript(WaitingHtml) + : InjectSseScript(_currentHtml); + var bodyBytes = Encoding.UTF8.GetBytes(html); + var header = Encoding.UTF8.GetBytes( + $"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {bodyBytes.Length}\r\nConnection: close\r\n\r\n"); + await stream.WriteAsync(header, token); + await stream.WriteAsync(bodyBytes, token); + client.Close(); } catch { @@ -859,13 +1895,192 @@ private async Task HandleClientAsync(TcpClient client, CancellationToken token) } } - private static async Task ReadHttpRequestAsync(NetworkStream stream, CancellationToken token) + /// + /// Read the HTTP request line and headers, plus any body bytes that arrived in the + /// same TCP read. Returns (requestLine, headers, bodyPrefix). Caller is responsible + /// for reading the rest of the body using Content-Length if needed. + /// + private static async Task<(string requestLine, Dictionary headers, string bodyPrefix)> + ReadHttpRequestHeaderAsync(NetworkStream stream, CancellationToken token) { - var buffer = new byte[4096]; - var read = await stream.ReadAsync(buffer, token); - var request = Encoding.UTF8.GetString(buffer, 0, read); - var idx = request.IndexOf('\r'); - return idx >= 0 ? request[..idx] : request; + var buffer = new byte[8192]; + var sb = new StringBuilder(); + int headerEnd = -1; + while (headerEnd < 0) + { + var n = await stream.ReadAsync(buffer.AsMemory(), token); + if (n == 0) break; + sb.Append(Encoding.UTF8.GetString(buffer, 0, n)); + headerEnd = sb.ToString().IndexOf("\r\n\r\n", StringComparison.Ordinal); + if (sb.Length > 32 * 1024) break; // safety cap + } + + var raw = sb.ToString(); + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (headerEnd < 0) + { + // No header terminator — treat the whole thing as a single line + var firstLine = raw; + var crlf = raw.IndexOf("\r\n", StringComparison.Ordinal); + if (crlf >= 0) firstLine = raw[..crlf]; + return (firstLine, headers, ""); + } + + var headerSection = raw[..headerEnd]; + var bodyPrefix = raw[(headerEnd + 4)..]; + var lines = headerSection.Split("\r\n"); + var requestLine = lines.Length > 0 ? lines[0] : ""; + for (int i = 1; i < lines.Length; i++) + { + var colon = lines[i].IndexOf(':'); + if (colon > 0) + headers[lines[i][..colon].Trim()] = lines[i][(colon + 1)..].Trim(); + } + return (requestLine, headers, bodyPrefix); + } + + // Maximum size of a POST /api/selection request body. 64 KB is plenty for tens + // of thousands of selected paths and bounds memory + read time per request. + private const int MaxSelectionBodyBytes = 64 * 1024; + // Hard limit on how long we'll wait for the rest of a POST body to arrive. + // Prevents slow-loris style stalls (Content-Length advertised, body never sent). + private static readonly TimeSpan PostBodyReadTimeout = TimeSpan.FromSeconds(3); + + private async Task HandlePostSelectionAsync(NetworkStream stream, Dictionary headers, string bodyPrefix, CancellationToken token) + { + int statusCode = 204; + string statusText = "No Content"; + string body = bodyPrefix; + + try + { + // Reject runaway Content-Length up front (covers FUZZER-001 slow-loris). + int contentLength = -1; + if (headers.TryGetValue("Content-Length", out var clStr) && int.TryParse(clStr, out var parsedCl)) + { + if (parsedCl < 0 || parsedCl > MaxSelectionBodyBytes) + throw new InvalidDataException("body too large"); + contentLength = parsedCl; + } + + // If the bodyPrefix already exceeds Content-Length, trim it. Without this, + // an attacker could smuggle extra bytes by sending a long body in the same + // TCP segment as the headers (FUZZER-002). + var prefixBytes = Encoding.UTF8.GetByteCount(body); + if (contentLength >= 0 && prefixBytes > contentLength) + { + var prefBytes = Encoding.UTF8.GetBytes(body); + body = Encoding.UTF8.GetString(prefBytes, 0, contentLength); + prefixBytes = contentLength; + } + + // Read any missing tail bytes, bounded by both size and time. + if (contentLength > prefixBytes) + { + using var readCts = CancellationTokenSource.CreateLinkedTokenSource(token); + readCts.CancelAfter(PostBodyReadTimeout); + var sb = new StringBuilder(body, contentLength); + int have = prefixBytes; + var buf = new byte[8192]; + try + { + while (have < contentLength) + { + var toRead = Math.Min(buf.Length, contentLength - have); + var n = await stream.ReadAsync(buf.AsMemory(0, toRead), readCts.Token); + if (n == 0) break; + sb.Append(Encoding.UTF8.GetString(buf, 0, n)); + have += n; + if (have > MaxSelectionBodyBytes) + throw new InvalidDataException("body too large"); + } + } + catch (OperationCanceledException) when (!token.IsCancellationRequested) + { + throw new InvalidDataException("body read timed out"); + } + body = sb.ToString(); + } + + // Expected JSON: {"paths": ["/slide[1]/shape[2]", ...]} + var req = JsonSerializer.Deserialize(body, WatchSelectionJsonContext.Default.SelectionRequest); + var rawSelection = req?.Paths ?? new List(); + // BUG-TESTER-R501/R502 + BUG-FUZZER-R5-04: bring selection path + // hardening up to parity with mark (Round 2/3 fixes). Each path is + // Trim()-normalized; whitespace-only and paths not starting with + // '/' are dropped; paths containing control characters (CR/LF/NUL + // /etc) are dropped because they would corrupt the in-memory + // representation and the SSE/pipe readback even though + // AppendJsonString escapes them on the wire. + // CONSISTENCY(path-stability): mirror of HandleMarkAdd's input + // validation. If you change the path acceptance rules, change + // both at once. grep CONSISTENCY(path-stability). + var newSelection = new List(rawSelection.Count); + foreach (var raw in rawSelection) + { + if (string.IsNullOrEmpty(raw)) continue; + var trimmed = raw.Trim(); + if (string.IsNullOrWhiteSpace(trimmed)) continue; + if (!trimmed.StartsWith("/")) continue; + var hasControl = false; + for (int i = 0; i < trimmed.Length; i++) + { + if (char.IsControl(trimmed[i])) { hasControl = true; break; } + } + if (hasControl) continue; + newSelection.Add(trimmed); + } + + lock (_selectionLock) { _currentSelection = newSelection; } + _lastActivityTime = DateTime.UtcNow; + + // Broadcast to all SSE clients so other browsers can highlight in sync + BroadcastSelectionUpdate(newSelection); + } + catch + { + statusCode = 400; + statusText = "Bad Request"; + } + + var resp = Encoding.UTF8.GetBytes( + $"HTTP/1.1 {statusCode} {statusText}\r\nContent-Length: 0\r\nConnection: close\r\nAccess-Control-Allow-Origin: *\r\n\r\n"); + await stream.WriteAsync(resp, token); + } + + private void BroadcastSelectionUpdate(List paths) + { + var sb = new StringBuilder(); + sb.Append("{\"action\":\"selection-update\",\"paths\":["); + for (int i = 0; i < paths.Count; i++) + { + if (i > 0) sb.Append(','); + AppendJsonString(sb, paths[i]); + } + sb.Append("]}"); + BroadcastSse(sb.ToString()); + } + + /// + /// Wrap a WatchMark[] snapshot in a "mark-update" SSE envelope. Called + /// after every mark add/remove, and during initial SSE client handshake. + /// The version field is a monotonically-increasing counter that clients + /// can use for CAS-style update detection. + /// + /// Uses the Relaxed encoder so CJK find/note/tofix bytes flow through + /// as literal characters instead of \uXXXX escapes. + /// + private static string BuildMarkUpdateJson(WatchMark[] marks, int version) + { + var marksJson = JsonSerializer.Serialize(marks, WatchMarkJsonOptions.WatchMarkArrayInfo); + return $"{{\"action\":\"mark-update\",\"version\":{version},\"marks\":{marksJson}}}"; + } + + private void BroadcastMarkUpdate(WatchMark[] marks) + { + int version; + lock (_marksLock) { version = _marksVersion; } + BroadcastSse(BuildMarkUpdateJson(marks, version)); } private async Task HandleSseAsync(NetworkStream stream, CancellationToken token) @@ -875,6 +2090,47 @@ private async Task HandleSseAsync(NetworkStream stream, CancellationToken token) await stream.WriteAsync(header, token); _lastActivityTime = DateTime.UtcNow; + + // Send the current selection immediately so the new client can highlight + // any elements that are already selected by other browsers viewing the same + // file. CRITICAL: this write must happen BEFORE adding the stream to + // _sseClients. Otherwise BroadcastSse (running on another thread under + // _sseLock) could write to the same stream at the same time we are writing + // the initial event here, and NetworkStream is not safe for concurrent writes + // — interleaved bytes would corrupt SSE framing. + try + { + string[] snapshot; + lock (_selectionLock) { snapshot = _currentSelection.ToArray(); } + var sb = new StringBuilder(); + sb.Append("{\"action\":\"selection-update\",\"paths\":["); + for (int i = 0; i < snapshot.Length; i++) + { + if (i > 0) sb.Append(','); + AppendJsonString(sb, snapshot[i]); + } + sb.Append("]}"); + var initEvt = Encoding.UTF8.GetBytes($"event: update\ndata: {sb}\n\n"); + await stream.WriteAsync(initEvt, token); + + // Also dump the current marks snapshot so a freshly connected browser + // immediately sees any marks the CLI has already added. Mirrors the + // selection init dump pattern above. + WatchMark[] markSnapshot; + int markVersion; + lock (_marksLock) + { + markSnapshot = _currentMarks.ToArray(); + markVersion = _marksVersion; + } + var markJson = BuildMarkUpdateJson(markSnapshot, markVersion); + var markInitEvt = Encoding.UTF8.GetBytes($"event: update\ndata: {markJson}\n\n"); + await stream.WriteAsync(markInitEvt, token); + } + catch { } + + // Now safe to register: any subsequent BroadcastSse will serialize against + // future writes via _sseLock. lock (_sseLock) { _sseClients.Add(stream); } try @@ -918,6 +2174,22 @@ public void Dispose() } catch { } + // BUG-BT-003: on Unix, .NET implements named pipes as Unix domain + // sockets at $TMPDIR/CoreFxPipe_. The runtime does NOT delete + // these on Dispose, so they accumulate in /var/folders across many + // watch start/stop cycles (fuzzer found 302 stale files). Clean up + // explicitly on Unix; Windows pipes are kernel objects and need no + // file cleanup. + if (!OperatingSystem.IsWindows()) + { + try + { + var sockPath = Path.Combine(Path.GetTempPath(), "CoreFxPipe_" + _pipeName); + if (File.Exists(sockPath)) File.Delete(sockPath); + } + catch { /* best-effort cleanup */ } + } + _cts.Dispose(); } } diff --git a/src/officecli/Handlers/Excel/ExcelHandler.Add.cs b/src/officecli/Handlers/Excel/ExcelHandler.Add.cs index 016351342..547cf5c0d 100644 --- a/src/officecli/Handlers/Excel/ExcelHandler.Add.cs +++ b/src/officecli/Handlers/Excel/ExcelHandler.Add.cs @@ -16,8 +16,9 @@ namespace OfficeCli.Handlers; public partial class ExcelHandler { - public string Add(string parentPath, string type, int? index, Dictionary properties) + public string Add(string parentPath, string type, InsertPosition? position, Dictionary properties) { + var index = position?.Index; // Normalize to case-insensitive lookup so camelCase keys (e.g. minColor) match lowercase lookups if (properties != null && properties.Comparer != StringComparer.OrdinalIgnoreCase) properties = new Dictionary(properties, StringComparer.OrdinalIgnoreCase); @@ -142,8 +143,13 @@ public string Add(string parentPath, string type, int? index, Dictionary(CellValues.String); } if (properties.TryGetValue("formula", out var formula)) @@ -257,7 +263,13 @@ public string Add(string parentPath, string type, int? index, Dictionary new EnumValue(CellValues.String), "number" or "num" => null, "boolean" or "bool" => new EnumValue(CellValues.Boolean), - _ => throw new ArgumentException($"Invalid cell 'type' value '{cellType}'. Valid types: string, number, boolean, richtext.") + // CONSISTENCY(cell-type-parity): Bug #4 — Add must accept + // the same type tokens as Set (ExcelHandler.Set.cs line 1105). + // Dates are stored as numeric OADate, so DataType stays null; + // the date-shaped cell value serialization and default + // numberformat are applied right after this switch. + "date" => null, + _ => throw new ArgumentException($"Invalid cell 'type' value '{cellType}'. Valid types: string, number, boolean, date, richtext.") }; // Convert boolean string values to OOXML-compliant 1/0 if (cellType.Equals("boolean", StringComparison.OrdinalIgnoreCase) || cellType.Equals("bool", StringComparison.OrdinalIgnoreCase)) @@ -268,6 +280,31 @@ public string Add(string parentPath, string type, int? index, Dictionary Add(parentPath, "iconset", index, properties), - "colorscale" => Add(parentPath, "colorscale", index, properties), - "formula" => Add(parentPath, "formulacf", index, properties), - "topn" or "top10" => Add(parentPath, "topn", index, properties), - "aboveaverage" => Add(parentPath, "aboveaverage", index, properties), - "uniquevalues" => Add(parentPath, "uniquevalues", index, properties), - "duplicatevalues" => Add(parentPath, "duplicatevalues", index, properties), - "containstext" => Add(parentPath, "containstext", index, properties), - "dateoccurring" or "timeperiod" => Add(parentPath, "dateoccurring", index, properties), - _ => Add(parentPath, "conditionalformatting", index, properties) + "iconset" => Add(parentPath, "iconset", position, properties), + "colorscale" => Add(parentPath, "colorscale", position, properties), + "formula" => Add(parentPath, "formulacf", position, properties), + "topn" or "top10" => Add(parentPath, "topn", position, properties), + "aboveaverage" => Add(parentPath, "aboveaverage", position, properties), + "uniquevalues" => Add(parentPath, "uniquevalues", position, properties), + "duplicatevalues" => Add(parentPath, "duplicatevalues", position, properties), + "containstext" => Add(parentPath, "containstext", position, properties), + "dateoccurring" or "timeperiod" => Add(parentPath, "dateoccurring", position, properties), + _ => Add(parentPath, "conditionalformatting", position, properties) }; } @@ -597,15 +634,15 @@ public string Add(string parentPath, string type, int? index, Dictionary() ?? throw new InvalidOperationException("Workbook has no sheets element"); @@ -2108,13 +2166,49 @@ public string Move(string sourcePath, string? targetParentPath, int? index) string.Equals(s.Name?.Value, sheetName, StringComparison.OrdinalIgnoreCase)) ?? throw new ArgumentException($"Sheet not found: {sheetName}"); - var targetIndex = index ?? throw new ArgumentException("--index is required when moving a sheet"); + // Resolve after/before anchor BEFORE removing sheetEl. + static string ExtractAnchorSheetName(string raw) => + (raw.StartsWith("/") ? raw[1..] : raw).Split('/', 2)[0]; + + Sheet? afterAnchor = null, beforeAnchor = null; + if (position?.After != null) + { + var anchorName = ExtractAnchorSheetName(position.After); + afterAnchor = sheets.Elements().FirstOrDefault(s => + string.Equals(s.Name?.Value, anchorName, StringComparison.OrdinalIgnoreCase)) + ?? throw new ArgumentException($"After anchor not found: {position.After}"); + } + else if (position?.Before != null) + { + var anchorName = ExtractAnchorSheetName(position.Before); + beforeAnchor = sheets.Elements().FirstOrDefault(s => + string.Equals(s.Name?.Value, anchorName, StringComparison.OrdinalIgnoreCase)) + ?? throw new ArgumentException($"Before anchor not found: {position.Before}"); + } + else if (index == null) + { + throw new ArgumentException("One of --index, --after, or --before is required when moving a sheet"); + } + sheetEl.Remove(); - var sheetList = sheets.Elements().ToList(); - if (targetIndex >= 0 && targetIndex < sheetList.Count) - sheetList[targetIndex].InsertBeforeSelf(sheetEl); + + if (afterAnchor != null) + { + afterAnchor.InsertAfterSelf(sheetEl); + } + else if (beforeAnchor != null) + { + beforeAnchor.InsertBeforeSelf(sheetEl); + } else - sheets.AppendChild(sheetEl); + { + var targetIndex = index!.Value; + var sheetList = sheets.Elements().ToList(); + if (targetIndex >= 0 && targetIndex < sheetList.Count) + sheetList[targetIndex].InsertBeforeSelf(sheetEl); + else + sheets.AppendChild(sheetEl); + } workbook.Save(); return $"/{sheetName}"; } @@ -2233,8 +2327,9 @@ public string Move(string sourcePath, string? targetParentPath, int? index) return ($"/{sheetName}/row[{rowIndex2}]", $"/{sheetName}/row[{rowIndex1}]"); } - public string CopyFrom(string sourcePath, string targetParentPath, int? index) + public string CopyFrom(string sourcePath, string targetParentPath, InsertPosition? position) { + var index = position?.Index; var segments = sourcePath.TrimStart('/').Split('/', 2); var sheetName = segments[0]; var worksheet = FindWorksheet(sheetName) diff --git a/src/officecli/Handlers/Excel/ExcelHandler.Helpers.cs b/src/officecli/Handlers/Excel/ExcelHandler.Helpers.cs index 16249a28f..0a72b27b4 100644 --- a/src/officecli/Handlers/Excel/ExcelHandler.Helpers.cs +++ b/src/officecli/Handlers/Excel/ExcelHandler.Helpers.cs @@ -324,7 +324,7 @@ private ArgumentException SheetNotFoundException(string sheetName) $"Use DOM path \"/{available.FirstOrDefault() ?? "SheetName"}/A1\" or Excel notation \"{available.FirstOrDefault() ?? "SheetName"}!A1\"."); } - private string GetCellDisplayValue(Cell cell) + private string GetCellDisplayValue(Cell cell, Core.FormulaEvaluator? evaluator = null) { if (cell.DataType?.Value == CellValues.InlineString) { @@ -344,9 +344,17 @@ private string GetCellDisplayValue(Cell cell) } // Formula cells: if there's a cached value, return it. - // If not, show the formula expression so view text doesn't show blank. + // If not, try to evaluate; last resort: show the formula expression. if (string.IsNullOrEmpty(value) && cell.CellFormula?.Text != null) + { + if (evaluator != null) + { + var evalResult = evaluator.TryEvaluateFull(cell.CellFormula.Text); + if (evalResult != null && !evalResult.IsError) + return evalResult.ToCellValueText(); + } return "=" + cell.CellFormula.Text; + } return value; } @@ -354,8 +362,18 @@ private string GetCellDisplayValue(Cell cell) private List GetSheetChildNodes(string sheetName, SheetData sheetData, int depth, WorksheetPart? worksheetPart = null) { var children = new List(); + var eval = depth > 0 && worksheetPart != null ? new Core.FormulaEvaluator(sheetData, _doc.WorkbookPart) : null; + // R6-5: dedupe by RowIndex. When a sheet contains both source data + // rows and pivot-rendered rows (possible when a pivot is placed on + // its own source sheet), the renderer appends additional nodes + // that can collide with existing RowIndex values. Children should + // expose each logical row once. + var seenRowIndices = new HashSet(); foreach (var row in sheetData.Elements()) { + var ridx = row.RowIndex?.Value ?? 0; + if (ridx != 0 && !seenRowIndices.Add(ridx)) + continue; var rowIdx = row.RowIndex?.Value ?? 0; var rowNode = new DocumentNode { @@ -372,7 +390,7 @@ private List GetSheetChildNodes(string sheetName, SheetData sheetD { foreach (var cell in row.Elements()) { - rowNode.Children.Add(CellToNode(sheetName, cell, worksheetPart)); + rowNode.Children.Add(CellToNode(sheetName, cell, worksheetPart, eval)); } } @@ -397,13 +415,31 @@ private List GetSheetChildNodes(string sheetName, SheetData sheetD } } + // R16-1: expose pivottable children so Get /Sheet1 lists them. + // CONSISTENCY(sheet-children): same pattern as chart children above. + if (worksheetPart != null) + { + var pivotParts = worksheetPart.PivotTableParts.ToList(); + for (int i = 0; i < pivotParts.Count; i++) + { + var ptNode = new DocumentNode + { + Path = $"/{sheetName}/pivottable[{i + 1}]", + Type = "pivottable" + }; + var pivotDef = pivotParts[i].PivotTableDefinition; + if (pivotDef != null) + Core.PivotTableHelper.ReadPivotTableProperties(pivotDef, ptNode, pivotParts[i]); + children.Add(ptNode); + } + } + return children; } - private DocumentNode CellToNode(string sheetName, Cell cell, WorksheetPart? part = null) + private DocumentNode CellToNode(string sheetName, Cell cell, WorksheetPart? part = null, Core.FormulaEvaluator? evaluator = null) { var cellRef = cell.CellReference?.Value ?? "?"; - var value = GetCellDisplayValue(cell); var formula = cell.CellFormula?.Text; string type; if (cell.DataType?.HasValue != true) @@ -423,10 +459,15 @@ private DocumentNode CellToNode(string sheetName, Cell cell, WorksheetPart? part else type = "Number"; - // When a formula cell has no cached value, display the formula as text - var displayText = value; - if (string.IsNullOrEmpty(displayText) && formula != null) - displayText = "=" + formula; + // Lazy-create evaluator if not provided and needed + if (evaluator == null && formula != null && string.IsNullOrEmpty(cell.CellValue?.Text) && part != null) + { + var sheetData = GetSheet(part).GetFirstChild(); + if (sheetData != null) + evaluator = new Core.FormulaEvaluator(sheetData, _doc.WorkbookPart); + } + + var displayText = GetCellDisplayValue(cell, evaluator); var node = new DocumentNode { @@ -440,12 +481,12 @@ private DocumentNode CellToNode(string sheetName, Cell cell, WorksheetPart? part if (formula != null) { node.Format["formula"] = formula; - // Expose cached value separately so callers know whether the formula has been evaluated + // cachedValue: prefer XML cached value, then evaluated value var rawCached = cell.CellValue?.Text; if (!string.IsNullOrEmpty(rawCached)) node.Format["cachedValue"] = rawCached; - else - node.Format["uncalculated"] = true; + else if (displayText != null && !displayText.StartsWith("=")) + node.Format["cachedValue"] = displayText; } // Array formula readback — keys match Set input if (cell.CellFormula?.FormulaType?.Value == CellFormulaValues.Array) @@ -454,7 +495,7 @@ private DocumentNode CellToNode(string sheetName, Cell cell, WorksheetPart? part if (cell.CellFormula.Reference?.Value != null) node.Format["arrayref"] = cell.CellFormula.Reference.Value; } - if (string.IsNullOrEmpty(value) && formula == null) node.Format["empty"] = true; + if (string.IsNullOrEmpty(displayText) && formula == null) node.Format["empty"] = true; // Hyperlink readback if (part != null) @@ -798,13 +839,14 @@ private DocumentNode GetCellRange(string sheetName, SheetData sheetData, string // Enumerate every position in the range in row-major order, // materializing empty stubs for positions that have no cell element. + var eval = new Core.FormulaEvaluator(sheetData, _doc.WorkbookPart); for (int r = startRow; r <= endRow; r++) { for (int c = startColIdx; c <= endColIdx; c++) { var cellRef = $"{IndexToColumnName(c)}{r}"; if (existingCells.TryGetValue(cellRef, out var existingCell)) - node.Children.Add(CellToNode(sheetName, existingCell, part)); + node.Children.Add(CellToNode(sheetName, existingCell, part, eval)); else node.Children.Add(new DocumentNode { diff --git a/src/officecli/Handlers/Excel/ExcelHandler.HtmlPreview.Charts.cs b/src/officecli/Handlers/Excel/ExcelHandler.HtmlPreview.Charts.cs index ab22cb660..66262e65d 100644 --- a/src/officecli/Handlers/Excel/ExcelHandler.HtmlPreview.Charts.cs +++ b/src/officecli/Handlers/Excel/ExcelHandler.HtmlPreview.Charts.cs @@ -21,64 +21,85 @@ public partial class ExcelHandler /// private void RenderSheetCharts(StringBuilder sb, WorksheetPart worksheetPart) { + var charts = CollectSheetCharts(worksheetPart); + foreach (var (_, _, _, _, html) in charts) + sb.Append(html); + } + + /// + /// Pre-render all charts and return them with their anchor row/col positions. + /// Charts with overlapping row ranges are grouped into flex rows. + /// + private List<(int fromRow, int toRow, int fromCol, int toCol, string html)> CollectSheetCharts(WorksheetPart worksheetPart) + { + var result = new List<(int fromRow, int toRow, int fromCol, int toCol, string html)>(); var drawingsPart = worksheetPart.DrawingsPart; - if (drawingsPart?.WorksheetDrawing == null) return; + if (drawingsPart?.WorksheetDrawing == null) return result; - // Find all graphic frames that contain chart references var chartFrames = drawingsPart.WorksheetDrawing .Descendants() .Where(gf => gf.Descendants().Any()) .ToList(); - if (chartFrames.Count == 0) return; + if (chartFrames.Count == 0) return result; - // Read anchor positions and group charts into rows (overlapping row ranges = same row) var chartAnchors = chartFrames.Select(gf => { var anchor = gf.Parent as XDR.TwoCellAnchor; - int fromRow = 0, toRow = 0, fromCol = 0; + int fromRow = 0, toRow = 0, fromCol = 0, toCol = 0; if (anchor?.FromMarker != null && anchor?.ToMarker != null) { int.TryParse(anchor.FromMarker.RowId?.Text, out fromRow); int.TryParse(anchor.ToMarker.RowId?.Text, out toRow); int.TryParse(anchor.FromMarker.ColumnId?.Text, out fromCol); + int.TryParse(anchor.ToMarker.ColumnId?.Text, out toCol); } - return (gf, fromRow, toRow, fromCol); + return (gf, fromRow, toRow, fromCol, toCol); }).OrderBy(x => x.fromRow).ThenBy(x => x.fromCol).ToList(); // Group into rows: charts whose row ranges overlap go in the same flex row - var rows = new List>(); + var groups = new List<(int fromRow, int toRow, int minFromCol, int maxToCol, List frames)>(); int currentRowEnd = -1; - List? currentRow = null; - foreach (var (gf, fromRow, toRow, _) in chartAnchors) + List? currentGroup = null; + int currentMinFromCol = 0, currentMaxToCol = 0; + foreach (var (gf, fromRow, toRow, fromCol, toCol) in chartAnchors) { - if (currentRow == null || fromRow >= currentRowEnd) + if (currentGroup == null || fromRow >= currentRowEnd) { - currentRow = new List(); - rows.Add(currentRow); + currentGroup = new List(); + currentMinFromCol = fromCol; + currentMaxToCol = toCol; currentRowEnd = toRow; + groups.Add((fromRow, toRow, fromCol, toCol, currentGroup)); } else { currentRowEnd = Math.Max(currentRowEnd, toRow); + currentMinFromCol = Math.Min(currentMinFromCol, fromCol); + currentMaxToCol = Math.Max(currentMaxToCol, toCol); + groups[^1] = (groups[^1].fromRow, currentRowEnd, currentMinFromCol, currentMaxToCol, currentGroup); } - currentRow.Add(gf); + currentGroup.Add(gf); } - foreach (var row in rows) + foreach (var (fromRow, toRow, minFromCol, maxToCol, frames) in groups) { - if (row.Count > 1) + var chartSb = new StringBuilder(); + if (frames.Count > 1) { - sb.AppendLine("
    "); - foreach (var gf in row) - RenderExcelChart(sb, gf, drawingsPart, worksheetPart); - sb.AppendLine("
    "); + chartSb.AppendLine("
    "); + foreach (var gf in frames) + RenderExcelChart(chartSb, gf, drawingsPart, worksheetPart); + chartSb.AppendLine("
    "); } else { - RenderExcelChart(sb, row[0], drawingsPart, worksheetPart); + RenderExcelChart(chartSb, frames[0], drawingsPart, worksheetPart); } + result.Add((fromRow, toRow, minFromCol, maxToCol, chartSb.ToString())); } + + return result; } private void RenderExcelChart(StringBuilder sb, XDR.GraphicFrame gf, @@ -145,21 +166,23 @@ private void RenderExcelChart(StringBuilder sb, XDR.GraphicFrame gf, if (info.Series.Count == 0) return; // Ensure colors match series count (ExtractChartInfo may have extracted for a different count) while (info.Colors.Count < info.Series.Count) - info.Colors.Add(ChartSvgRenderer.DefaultColors[info.Colors.Count % ChartSvgRenderer.DefaultColors.Length]); + info.Colors.Add(ChartSvgRenderer.FallbackColors[info.Colors.Count % ChartSvgRenderer.FallbackColors.Length]); if (info.Colors.Count > info.Series.Count && !info.ChartType.Contains("pie") && !info.ChartType.Contains("doughnut")) info.Colors = info.Colors.Take(info.Series.Count).ToList(); - // 4. Estimate chart dimensions from TwoCellAnchor - var (widthPt, heightPt) = EstimateChartSize(gf); + // 4. Estimate chart dimensions from TwoCellAnchor using actual column widths + var colWidths = GetColumnWidths(GetSheet(worksheetPart)); + var (widthPt, heightPt) = EstimateChartSize(gf, colWidths); - // 5. Create renderer with Excel-appropriate colors (light background) + // 5. Create renderer — colors from OOXML with Excel-appropriate fallbacks var renderer = new ChartSvgRenderer { - ValueColor = "#333", - CatColor = "#555", - AxisColor = "#666", - GridColor = "#ddd", - AxisLineColor = "#999", + ThemeAccentColors = ChartSvgRenderer.BuildThemeAccentColors(GetExcelThemeColors()), + ValueColor = info.ValFontColor ?? "#333", + CatColor = info.CatFontColor ?? "#555", + AxisColor = info.ValFontColor ?? "#666", + GridColor = info.GridlineColor ?? "#ddd", + AxisLineColor = info.AxisLineColor ?? "#999", ValFontPx = info.ValFontPx, CatFontPx = info.CatFontPx }; @@ -167,16 +190,25 @@ private void RenderExcelChart(StringBuilder sb, XDR.GraphicFrame gf, // 6. Build SVG var svgW = Math.Max(widthPt, 225); var svgH = Math.Max(heightPt, 150); - var titleH = string.IsNullOrEmpty(info.Title) ? 0 : 30; - var legendH = info.HasLegend ? 30 : 0; + // Title/legend height from actual font sizes + var titleFontPt = 10.0; + if (!string.IsNullOrEmpty(info.TitleFontSize) && double.TryParse(info.TitleFontSize.Replace("pt", ""), out var tfp)) + titleFontPt = tfp; + var titleH = string.IsNullOrEmpty(info.Title) ? 0 : (int)(titleFontPt * 1.6 + 8); + var legendFontPt = 8.0; + if (!string.IsNullOrEmpty(info.LegendFontSize) && double.TryParse(info.LegendFontSize.Replace("pt", ""), out var lfp)) + legendFontPt = lfp; + var legendH = info.HasLegend ? (int)(legendFontPt * 1.6 + 12) : 0; var chartSvgH = svgH - titleH - legendH; if (chartSvgH < 80) return; var bgStyle = info.ChartFillColor != null ? $"background:#{info.ChartFillColor};" : ""; - sb.AppendLine($"
    "); + // Use estimated width as max-width, but allow stretching to fill parent (e.g. colspan td) + sb.AppendLine($"
    "); + var titleColor = info.TitleFontColor ?? "#333"; if (!string.IsNullOrEmpty(info.Title)) - sb.AppendLine($"
    {HtmlEncode(info.Title)}
    "); + sb.AppendLine($"
    {HtmlEncode(info.Title)}
    "); sb.AppendLine($" "); @@ -184,15 +216,17 @@ private void RenderExcelChart(StringBuilder sb, XDR.GraphicFrame gf, sb.AppendLine(" "); - renderer.RenderLegendHtml(sb, info, "#555"); + var legendColor = info.LegendFontColor ?? "#555"; + renderer.RenderLegendHtml(sb, info, legendColor); sb.AppendLine("
    "); } /// - /// Estimate chart pixel size from the TwoCellAnchor parent. + /// Estimate chart size from the TwoCellAnchor parent, using actual column widths when available. /// - private static (int widthPt, int heightPt) EstimateChartSize(XDR.GraphicFrame gf) + private static (int widthPt, int heightPt) EstimateChartSize(XDR.GraphicFrame gf, + Dictionary? colWidths = null) { var anchor = gf.Parent as XDR.TwoCellAnchor; if (anchor == null) return (450, 263); @@ -211,8 +245,13 @@ private static (int widthPt, int heightPt) EstimateChartSize(XDR.GraphicFrame gf var fromRowOff = long.TryParse(from.RowOffset?.Text, out var fro) ? fro : 0; var toRowOff = long.TryParse(to.RowOffset?.Text, out var tro) ? tro : 0; - // Default column width ~48pt, default row height ~15pt; offsets in EMU (1pt = 12700 EMU) - double totalWidth = (toCol - fromCol) * 48.0 + (toColOff - fromColOff) / 12700.0; + // Sum actual column widths; fall back to 48pt for columns without explicit width + double totalWidth = 0; + for (int c = fromCol + 1; c <= toCol; c++) + totalWidth += (colWidths != null && colWidths.TryGetValue(c, out var w)) ? w : 48.0; + totalWidth += (toColOff - fromColOff) / 12700.0; + + // Default row height ~15pt; offsets in EMU (1pt = 12700 EMU) double totalHeight = (toRow - fromRow) * 15.0 + (toRowOff - fromRowOff) / 12700.0; return ((int)Math.Max(totalWidth, 225), (int)Math.Max(totalHeight, 150)); diff --git a/src/officecli/Handlers/Excel/ExcelHandler.HtmlPreview.cs b/src/officecli/Handlers/Excel/ExcelHandler.HtmlPreview.cs index dada5976a..cc7680157 100644 --- a/src/officecli/Handlers/Excel/ExcelHandler.HtmlPreview.cs +++ b/src/officecli/Handlers/Excel/ExcelHandler.HtmlPreview.cs @@ -5,11 +5,77 @@ using System.Text.RegularExpressions; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Spreadsheet; - namespace OfficeCli.Handlers; public partial class ExcelHandler { + // Theme color map (lazy-initialized from theme1.xml) + private Dictionary? _excelThemeColors; + // Indexed color palette (default 64 + custom overrides from styles.xml) + private string[]? _resolvedIndexedColors; + + private Dictionary GetExcelThemeColors() + { + if (_excelThemeColors != null) return _excelThemeColors; + var colorScheme = _doc.WorkbookPart?.ThemePart?.Theme?.ThemeElements?.ColorScheme; + _excelThemeColors = Core.ThemeColorResolver.BuildColorMap(colorScheme); + return _excelThemeColors; + } + + /// + /// Excel theme color index mapping: + /// 0=lt1, 1=dk1, 2=lt2, 3=dk2, 4=accent1, 5=accent2, 6=accent3, 7=accent4, 8=accent5, 9=accent6 + /// + private static readonly string[] ThemeIndexToName = + ["lt1", "dk1", "lt2", "dk2", "accent1", "accent2", "accent3", "accent4", "accent5", "accent6"]; + + private string? ResolveThemeColor(uint themeIndex, double? tintValue = null) + { + if (themeIndex >= (uint)ThemeIndexToName.Length) return null; + var themeColors = GetExcelThemeColors(); + if (!themeColors.TryGetValue(ThemeIndexToName[themeIndex], out var hex)) return null; + + if (tintValue.HasValue && Math.Abs(tintValue.Value) > 0.001) + { + // Excel tint: positive = tint toward white, negative = shade toward black + // Convert to OOXML 0-100000 range + var t = tintValue.Value; + if (t > 0) + return Core.ColorMath.ApplyTransforms(hex, tint: (int)((1 - t) * 100000)); + else + return Core.ColorMath.ApplyTransforms(hex, shade: (int)((1 + t) * 100000)); + } + + return $"#{hex}"; + } + + private string[] GetResolvedIndexedColors() + { + if (_resolvedIndexedColors != null) return _resolvedIndexedColors; + + // Start with default palette + _resolvedIndexedColors = (string[])DefaultIndexedColors.Clone(); + + // Check for custom overrides in styles.xml + var stylesheet = _doc.WorkbookPart?.WorkbookStylesPart?.Stylesheet; + var colors = stylesheet?.GetFirstChild(); + var indexedColors = colors?.GetFirstChild(); + if (indexedColors != null) + { + int idx = 0; + foreach (var rgbColor in indexedColors.Elements()) + { + if (idx < _resolvedIndexedColors.Length && rgbColor.Rgb?.Value != null) + { + var raw = rgbColor.Rgb.Value; + _resolvedIndexedColors[idx] = FormatColorForCss(raw); + } + idx++; + } + } + return _resolvedIndexedColors; + } + /// /// Generate a self-contained HTML file that previews all sheets as spreadsheet tables. /// Supports cell formatting (font, fill, borders, alignment), merged cells, @@ -48,8 +114,8 @@ public string ViewAsHtml() var isRtl = sheetView?.RightToLeft?.Value == true; var dirAttr = isRtl ? " dir=\"rtl\"" : ""; sb.AppendLine($"
    "); - RenderSheetTable(sb, sheetName, worksheetPart, stylesheet); - RenderSheetCharts(sb, worksheetPart); + var charts = CollectSheetCharts(worksheetPart); + RenderSheetTable(sb, sheetName, worksheetPart, stylesheet, charts); sb.AppendLine("
    "); } sb.AppendLine("
    "); @@ -100,7 +166,8 @@ public int GetSheetIndex(string sheetName) // ==================== Sheet Rendering ==================== - private void RenderSheetTable(StringBuilder sb, string sheetName, WorksheetPart worksheetPart, Stylesheet? stylesheet) + private void RenderSheetTable(StringBuilder sb, string sheetName, WorksheetPart worksheetPart, Stylesheet? stylesheet, + List<(int fromRow, int toRow, int fromCol, int toCol, string html)>? charts = null) { var ws = GetSheet(worksheetPart); var sheetData = ws.GetFirstChild(); @@ -112,12 +179,33 @@ private void RenderSheetTable(StringBuilder sb, string sheetName, WorksheetPart return; } + // Read default dimensions from sheetFormatPr + var sheetFmtPr = ws.GetFirstChild(); + // Excel column width → pixels: chars * 7.0017 (POI's DEFAULT_CHARACTER_WIDTH for Calibri 11) + // pt = px * 0.75 + var defaultColWidthPt = sheetFmtPr?.DefaultColumnWidth?.Value != null + ? sheetFmtPr.DefaultColumnWidth.Value * 7.0017 * 0.75 : 8.43 * 7.0017 * 0.75; + var defaultRowHeightPt = sheetFmtPr?.DefaultRowHeight?.Value ?? 15.0; + + // Read default font size from stylesheet + var defaultFontPt = 11.0; + if (stylesheet?.Fonts != null && stylesheet.Fonts.Elements().Any()) + { + var defFont = stylesheet.Fonts.Elements().First(); + defaultFontPt = defFont.FontSize?.Val?.Value ?? 11.0; + } + // Create formula evaluator for this sheet to compute uncached formula values var evaluator = new Core.FormulaEvaluator(sheetData, _doc.WorkbookPart); // Collect merge info var mergeMap = BuildMergeMap(ws); + // Build conditional formatting CSS overrides + var cfMap = BuildConditionalFormatMap(ws, stylesheet, sheetData, _doc.WorkbookPart); + var dataBarMap = BuildDataBarMap(ws, sheetData); + var iconSetMap = BuildIconSetMap(ws, sheetData); + // Collect column widths var colWidths = GetColumnWidths(ws); @@ -133,38 +221,58 @@ private void RenderSheetTable(StringBuilder sb, string sheetName, WorksheetPart for (int fc = 1; fc <= frozenCols; fc++) { frozenLeftOffsets[fc] = cumLeft; - cumLeft += colWidths.TryGetValue(fc, out var w) ? w : 48.0; + cumLeft += colWidths.TryGetValue(fc, out var w) ? w : defaultColWidthPt; } } - // Determine grid dimensions + // Determine grid dimensions — only count cells with actual content (value or formula), + // not styled-but-empty cells. Mirrors LibreOffice's GetPrintArea / TrimDataArea behavior. var rows = sheetData.Elements().ToList(); int maxCol = 0; int maxRow = 0; foreach (var row in rows) { var rowIdx = (int)(row.RowIndex?.Value ?? 0); - if (rowIdx > maxRow) maxRow = rowIdx; + bool rowHasContent = false; foreach (var cell in row.Elements()) { var cellRef = cell.CellReference?.Value; - if (cellRef != null) - { - var (colName, _) = ParseCellReference(cellRef); - var colIdx = ColumnNameToIndex(colName); - if (colIdx > maxCol) maxCol = colIdx; - } + if (cellRef == null) continue; + // Skip empty cells (no value, no formula) — they bloat maxCol with styled blanks + var hasValue = cell.CellValue != null && !string.IsNullOrEmpty(cell.CellValue.Text); + var hasFormula = cell.CellFormula != null; + if (!hasValue && !hasFormula) continue; + var (colName, _) = ParseCellReference(cellRef); + var colIdx = ColumnNameToIndex(colName); + if (colIdx > maxCol) maxCol = colIdx; + rowHasContent = true; } + if (rowHasContent && rowIdx > maxRow) maxRow = rowIdx; } // Empty sheet (SheetData exists but no rows/cells) if (maxRow == 0 || maxCol == 0) { - if (worksheetPart.DrawingsPart?.WorksheetDrawing == null) - sb.AppendLine("
    Empty sheet
    "); + if (charts == null || charts.Count == 0) + { + if (worksheetPart.DrawingsPart?.WorksheetDrawing == null) + sb.AppendLine("
    Empty sheet
    "); + return; + } + // Charts exist but no cell data — just render charts + foreach (var (_, _, _, _, html) in charts) + sb.Append(html); return; } + // Extend maxRow/maxCol to include chart anchor ranges + if (charts != null) + foreach (var (_, toRow, fromCol, toCol, _) in charts) + { + if (toCol > maxCol) maxCol = toCol; + if (toRow > maxRow) maxRow = toRow; + } + // Limit rendering to reasonable size var actualRow = maxRow; var actualCol = maxCol; @@ -201,6 +309,41 @@ private void RenderSheetTable(StringBuilder sb, string sheetName, WorksheetPart hiddenRows.Add(rowIdx); } + // Compute cumulative top offsets for frozen rows (for sticky positioning) + // Includes thead height (~24pt for column headers) + var frozenTopOffsets = new Dictionary(); + if (frozenRows > 0) + { + double cumTop = 24; // approximate thead (column header) height + for (int fr = 1; fr <= frozenRows; fr++) + { + frozenTopOffsets[fr] = cumTop; + if (rowHeights.TryGetValue(fr, out var rh)) + cumTop += rh; + else + { + // Estimate row height from max font size in the row's cells + double maxFontPt = defaultFontPt; + foreach (var cell in cellMap.Where(kv => kv.Key.row == fr).Select(kv => kv.Value)) + { + var si = cell.StyleIndex?.Value ?? 0; + if (stylesheet?.CellFormats != null && si < (uint)stylesheet.CellFormats.Elements().Count()) + { + var xf = stylesheet.CellFormats.Elements().ElementAt((int)si); + var fontId = xf.FontId?.Value ?? 0; + if (stylesheet.Fonts != null && fontId < (uint)stylesheet.Fonts.Elements().Count()) + { + var font = stylesheet.Fonts.Elements().ElementAt((int)fontId); + var sz = font.FontSize?.Val?.Value ?? defaultFontPt; + if (sz > maxFontPt) maxFontPt = sz; + } + } + } + cumTop += maxFontPt * 1.4 + 4; // font height + padding + } + } + } + // Collect hidden columns var hiddenCols = new HashSet(); foreach (var (colIdx, widthPx) in colWidths) @@ -208,20 +351,34 @@ private void RenderSheetTable(StringBuilder sb, string sheetName, WorksheetPart if (widthPx <= 0) hiddenCols.Add(colIdx); } + // Build chart lookup: fromRow → chart info for inline insertion + var chartAtRow = new Dictionary(); + if (charts != null) + foreach (var (fromRow, toRow, fromCol, toCol, html) in charts) + chartAtRow[fromRow] = (toRow, fromCol, toCol, html); + + // Compute total table width so the table sizes to its content (not the wrapper). + // Without an explicit width, table-layout:fixed inside a flex wrapper shrinks columns + // proportionally to fit the viewport, ignoring declared col widths. + double totalTableWidthPt = 30; // row-header-col width + for (int c = 1; c <= maxCol; c++) + { + if (hiddenCols.Contains(c)) continue; + totalTableWidthPt += colWidths.TryGetValue(c, out var cw) ? cw : defaultColWidthPt; + } + // Start table sb.AppendLine("
    "); - sb.AppendLine("
    "); + sb.AppendLine($"
    "); sb.AppendLine($""); - // Colgroup for column widths + header column + // Colgroup for column widths + header column (skip hidden columns to match td count) sb.Append(""); for (int c = 1; c <= maxCol; c++) { - var width = colWidths.TryGetValue(c, out var w) ? w : 48.0; // default ~8.43 chars ≈ 48pt - if (width <= 0) - sb.Append(""); - else - sb.Append($""); + if (hiddenCols.Contains(c)) continue; // skip hidden cols — tds are also skipped + var width = colWidths.TryGetValue(c, out var w) ? w : defaultColWidthPt; + sb.Append($""); } sb.AppendLine(""); @@ -253,17 +410,92 @@ private void RenderSheetTable(StringBuilder sb, string sheetName, WorksheetPart } sb.AppendLine(""); + // chartAtRow and sideCharts already built above + + // Visible column count for chart colspan + var visibleColCount = Enumerable.Range(1, maxCol).Count(c => !hiddenCols.Contains(c)); + // Data rows sb.AppendLine(""); for (int r = 1; r <= maxRow; r++) { + // Insert chart at its anchor row position + if (chartAtRow.TryGetValue(r, out var chartEntry)) + { + // Chart fromCol is 0-based; columns in table are 1-based + var chartFromCol1 = chartEntry.fromCol + 1; // convert to 1-based + var chartToCol1 = chartEntry.toCol; // toCol is exclusive in anchor + // Count visible columns before and within chart range + var colsBefore = Enumerable.Range(1, Math.Min(chartFromCol1 - 1, maxCol)) + .Count(c => !hiddenCols.Contains(c)); + var chartColSpan = Enumerable.Range(chartFromCol1, Math.Min(chartToCol1, maxCol) - chartFromCol1 + 1) + .Count(c => !hiddenCols.Contains(c)); + var rowSpan = chartEntry.toRow - r; + + sb.Append(""); + sb.Append($""); + // Empty cells before the chart + for (int c = 1; c < chartFromCol1 && c <= maxCol; c++) + { + if (hiddenCols.Contains(c)) continue; + var cellRef = $"{IndexToColumnName(c)}{r}"; + var cell = cellMap.TryGetValue((r, c), out var mc) ? mc : null; + var style = GetCellStyleCss(cell, stylesheet, frozenRows, frozenCols, r, c, frozenLeftOffsets, frozenTopOffsets, cfMap, dataBarMap, iconSetMap); + var value = cell != null ? GetFormattedCellValue(cell, stylesheet, evaluator) : ""; + sb.Append($"{BuildCellContent(cellRef, value, dataBarMap, iconSetMap)}"); + } + // Chart cell spanning multiple rows and columns + if (chartColSpan > 0) + sb.Append($""); + // Empty cells after the chart + for (int c = chartToCol1 + 1; c <= maxCol; c++) + { + if (hiddenCols.Contains(c)) continue; + sb.Append(""); + } + sb.AppendLine(""); + continue; + } + // Skip rows that are within a chart's rowspan (but still render non-chart columns) + if (charts != null && charts.Any(ch => r > ch.fromRow && r < ch.toRow)) + { + sb.Append(""); + sb.Append($""); + var activeChart = charts.First(ch => r > ch.fromRow && r < ch.toRow); + var acFromCol1 = activeChart.fromCol + 1; + var acToCol1 = activeChart.toCol; + for (int c = 1; c <= maxCol; c++) + { + if (hiddenCols.Contains(c)) continue; + if (c >= acFromCol1 && c <= acToCol1) continue; // spanned by chart rowspan + var cellRef = $"{IndexToColumnName(c)}{r}"; + var cell = cellMap.TryGetValue((r, c), out var mc) ? mc : null; + var style = GetCellStyleCss(cell, stylesheet, frozenRows, frozenCols, r, c, frozenLeftOffsets, frozenTopOffsets, cfMap, dataBarMap, iconSetMap); + var value = cell != null ? GetFormattedCellValue(cell, stylesheet, evaluator) : ""; + sb.Append($"{BuildCellContent(cellRef, value, dataBarMap, iconSetMap)}"); + } + sb.AppendLine(""); + continue; + } + if (hiddenRows.Contains(r)) { sb.AppendLine(""); continue; } - var rowH = rowHeights.TryGetValue(r, out var rh) ? $" style=\"height:{rh:0.##}pt\"" : ""; - sb.Append($""); + bool isRowFrozen = frozenRows > 0 && r <= frozenRows; + var rowStyles = new List(); + if (rowHeights.TryGetValue(r, out var rh)) rowStyles.Add($"height:{rh:0.##}pt"); + if (isRowFrozen) rowStyles.Add("background:#fff"); + var rowStyle = rowStyles.Count > 0 ? $" style=\"{string.Join(";", rowStyles)}\"" : ""; + var frozenAttr = isRowFrozen ? " data-frozen=\"1\"" : ""; + sb.Append($""); // Row header - var rowHeaderSticky = frozenCols > 0 ? " style=\"position:sticky;left:0;z-index:2\"" : ""; - sb.Append($""); + string rowHeaderStyle; + if (isRowFrozen) + rowHeaderStyle = " style=\"position:sticky;top:0;left:0;z-index:3\""; + else if (frozenCols > 0) + rowHeaderStyle = " style=\"position:sticky;left:0;z-index:2\""; + else + rowHeaderStyle = ""; + sb.Append($""); for (int c = 1; c <= maxCol; c++) { @@ -275,7 +507,7 @@ private void RenderSheetTable(StringBuilder sb, string sheetName, WorksheetPart if (!mergeInfo.IsAnchor) continue; // skip non-anchor cells var cell = cellMap.TryGetValue((r, c), out var mc) ? mc : null; - var style = GetCellStyleCss(cell, stylesheet, frozenRows, frozenCols, r, c, frozenLeftOffsets); + var style = GetCellStyleCss(cell, stylesheet, frozenRows, frozenCols, r, c, frozenLeftOffsets, frozenTopOffsets, cfMap, dataBarMap, iconSetMap); var value = cell != null ? GetFormattedCellValue(cell, stylesheet, evaluator) : ""; // Adjust colspan to exclude hidden columns within the merge range var adjColSpan = mergeInfo.ColSpan; @@ -288,14 +520,14 @@ private void RenderSheetTable(StringBuilder sb, string sheetName, WorksheetPart if (adjColSpan > 1) spanAttrs += $" colspan=\"{adjColSpan}\""; if (mergeInfo.RowSpan > 1) spanAttrs += $" rowspan=\"{mergeInfo.RowSpan}\""; - sb.Append($"{CellHtml(value)}"); + sb.Append($"{BuildCellContent(cellRef, value, dataBarMap, iconSetMap)}"); } else { var cell = cellMap.TryGetValue((r, c), out var nc) ? nc : null; - var style = GetCellStyleCss(cell, stylesheet, frozenRows, frozenCols, r, c, frozenLeftOffsets); + var style = GetCellStyleCss(cell, stylesheet, frozenRows, frozenCols, r, c, frozenLeftOffsets, frozenTopOffsets, cfMap, dataBarMap, iconSetMap); var value = cell != null ? GetFormattedCellValue(cell, stylesheet, evaluator) : ""; - sb.Append($"{CellHtml(value)}"); + sb.Append($"{BuildCellContent(cellRef, value, dataBarMap, iconSetMap)}"); } } sb.AppendLine(""); @@ -305,7 +537,7 @@ private void RenderSheetTable(StringBuilder sb, string sheetName, WorksheetPart // Truncation warning if (truncated) sb.AppendLine($"
    Showing {maxRow} of {actualRow} rows, {maxCol} of {actualCol} columns
    "); - sb.AppendLine(""); + sb.AppendLine(""); // close table-wrapper } // ==================== Merge Map ==================== @@ -362,7 +594,8 @@ private static Dictionary GetColumnWidths(Worksheet ws) var min = (int)(col.Min?.Value ?? 1u); var max = (int)(col.Max?.Value ?? (uint)min); // Hidden columns get width 0 - var widthPt = col.Hidden?.Value == true ? 0 : (col.Width.Value == 0 ? 0 : col.Width.Value * 5.625 + 3.75); + // Excel column width → pixels: chars * 7.0017; pt = px * 0.75 (POI XSSFSheet.getColumnWidthInPixels) + var widthPt = col.Hidden?.Value == true ? 0 : (col.Width.Value == 0 ? 0 : col.Width.Value * 7.0017 * 0.75); for (int c = min; c <= max; c++) result[c] = widthPt; } @@ -388,9 +621,390 @@ private static (int frozenRows, int frozenCols) GetFrozenPanes(Worksheet ws) return (frozenRows, frozenCols); } + // ==================== Conditional Formatting ==================== + + /// + /// Evaluate conditional formatting rules and return CSS overrides per cell. + /// + private Dictionary BuildConditionalFormatMap( + Worksheet ws, Stylesheet? stylesheet, SheetData sheetData, WorkbookPart? workbookPart) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (stylesheet == null) return result; + + var dxfs = stylesheet.DifferentialFormats?.Elements().ToArray(); + if (dxfs == null || dxfs.Length == 0) return result; + + var cfElements = ws.Elements().ToList(); + if (cfElements.Count == 0) return result; + + var evaluator = new Core.FormulaEvaluator(sheetData, workbookPart); + + foreach (var cf in cfElements) + { + var sqref = cf.SequenceOfReferences?.Items?.ToList(); + if (sqref == null || sqref.Count == 0) continue; + + foreach (var rule in cf.Elements()) + { + var dxfId = rule.FormatId?.Value; + if (dxfId == null || dxfId >= dxfs.Length) continue; + var dxf = dxfs[(int)dxfId]; + + // Extract CSS from dxf + var cssParts = new List(); + var fill = dxf.Fill?.PatternFill; + if (fill != null) + { + var bgColor = fill.BackgroundColor?.Rgb?.Value ?? fill.ForegroundColor?.Rgb?.Value; + if (bgColor != null) + { + if (bgColor.Length > 6) bgColor = bgColor[^6..]; + cssParts.Add($"background:#{bgColor}"); + } + } + var font = dxf.Font; + if (font != null) + { + var fontColor = font.Color?.Rgb?.Value; + if (fontColor != null) + { + if (fontColor.Length > 6) fontColor = fontColor[^6..]; + cssParts.Add($"color:#{fontColor}"); + } + } + if (cssParts.Count == 0) continue; + var cssOverride = string.Join(";", cssParts); + + // Expand sqref and evaluate each cell + foreach (var rangeStr in sqref) + { + var cells = ExpandSqref(rangeStr.Value ?? ""); + foreach (var (cellRef, row, col) in cells) + { + if (result.ContainsKey(cellRef)) continue; // first matching rule wins + + bool matches = EvaluateCfRule(rule, cellRef, row, col, sheetData, evaluator); + if (matches) + result[cellRef] = cssOverride; + } + } + } + } + return result; + } + + /// + /// Build data bar info per cell: returns HTML for the bar overlay. + /// + private Dictionary BuildDataBarMap(Worksheet ws, SheetData sheetData) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var cf in ws.Elements()) + { + foreach (var rule in cf.Elements()) + { + var dataBar = rule.GetFirstChild(); + if (dataBar == null) continue; + + var sqref = cf.SequenceOfReferences?.Items?.ToList(); + if (sqref == null || sqref.Count == 0) continue; + + // Get bar color + var barColorEl = dataBar.GetFirstChild(); + var barColor = barColorEl?.Rgb?.Value ?? "FF4472C4"; + if (barColor.Length > 6) barColor = barColor[^6..]; + + // Collect all cell values in range + var cells = new List<(string cellRef, double value)>(); + foreach (var rangeStr in sqref) + { + foreach (var (cellRef, row, col) in ExpandSqref(rangeStr.Value ?? "")) + { + var cell = sheetData.Descendants() + .FirstOrDefault(c => string.Equals(c.CellReference?.Value, cellRef, StringComparison.OrdinalIgnoreCase)); + if (cell?.CellValue != null && double.TryParse(cell.CellValue.Text, + System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var v)) + cells.Add((cellRef, v)); + } + } + if (cells.Count == 0) continue; + + // Determine min/max from cfvo elements or from data + var cfvos = dataBar.Elements().ToList(); + double minVal, maxVal; + if (cfvos.Count >= 2 && cfvos[0].Type?.Value == ConditionalFormatValueObjectValues.Number + && double.TryParse(cfvos[0].Val?.Value, System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out var explicitMin)) + minVal = explicitMin; + else + minVal = 0; // Excel default: bars start from 0 + + if (cfvos.Count >= 2 && cfvos[1].Type?.Value == ConditionalFormatValueObjectValues.Number + && double.TryParse(cfvos[1].Val?.Value, System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out var explicitMax)) + maxVal = explicitMax; + else + maxVal = cells.Max(c => c.value); + + if (maxVal <= minVal) maxVal = minVal + 1; + + // Read bar length bounds (Excel defaults: min=10%, max=90%) + var minLength = dataBar.MinLength?.Value ?? 10U; + var maxLength = dataBar.MaxLength?.Value ?? 90U; + var showValue = dataBar.ShowValue?.Value ?? true; + + foreach (var (cellRef, value) in cells) + { + var rawPct = (value - minVal) / (maxVal - minVal) * 100; + // Scale to minLength..maxLength range + var pct = Math.Max(0, Math.Min(100, minLength + rawPct / 100 * (maxLength - minLength))); + // Store bar HTML + showValue flag (prefixed with "0|" or "1|") + result[cellRef] = $"{(showValue ? "1" : "0")}|
    "; + } + } + } + return result; + } + + /// + /// Build icon set info per cell: returns HTML for the icon. + /// + private Dictionary BuildIconSetMap(Worksheet ws, SheetData sheetData) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var cf in ws.Elements()) + { + foreach (var rule in cf.Elements()) + { + var iconSet = rule.GetFirstChild(); + if (iconSet == null) continue; + + var sqref = cf.SequenceOfReferences?.Items?.ToList(); + if (sqref == null || sqref.Count == 0) continue; + + var iconSetName = iconSet.IconSetValue?.Value ?? IconSetValues.ThreeTrafficLights1; + var showValue = iconSet.ShowValue?.Value ?? true; + var reverse = iconSet.Reverse?.Value ?? false; + + // Collect all cell values in range + var cells = new List<(string cellRef, double value)>(); + foreach (var rangeStr in sqref) + { + foreach (var (cellRef, row, col) in ExpandSqref(rangeStr.Value ?? "")) + { + var cell = sheetData.Descendants() + .FirstOrDefault(c => string.Equals(c.CellReference?.Value, cellRef, StringComparison.OrdinalIgnoreCase)); + if (cell?.CellValue != null && double.TryParse(cell.CellValue.Text, + System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var v)) + cells.Add((cellRef, v)); + } + } + if (cells.Count == 0) continue; + + // Parse cfvo thresholds + var cfvos = iconSet.Elements().ToList(); + var allValues = cells.Select(c => c.value).OrderBy(v => v).ToList(); + double minVal = allValues.First(), maxVal = allValues.Last(); + var range = maxVal - minVal; + if (range == 0) range = 1; + + // Resolve thresholds (skip first cfvo which is the base) + var thresholds = new List(); + for (int i = 1; i < cfvos.Count; i++) + { + var cfvo = cfvos[i]; + var type = cfvo.Type?.Value ?? ConditionalFormatValueObjectValues.Percent; + double.TryParse(cfvo.Val?.Value, System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out var tv); + if (type == ConditionalFormatValueObjectValues.Number) + thresholds.Add(tv); + else if (type == ConditionalFormatValueObjectValues.Percent) + thresholds.Add(minVal + range * tv / 100); + else if (type == ConditionalFormatValueObjectValues.Percentile) + { + var idx = (int)Math.Round(tv / 100.0 * (allValues.Count - 1)); + thresholds.Add(allValues[Math.Clamp(idx, 0, allValues.Count - 1)]); + } + else + thresholds.Add(minVal + range * tv / 100); + } + + foreach (var (cellRef, value) in cells) + { + // Determine which bucket the value falls into + int bucket = 0; + for (int i = 0; i < thresholds.Count; i++) + { + if (value >= thresholds[i]) bucket = i + 1; + } + if (reverse) bucket = cfvos.Count - 1 - bucket; + var icon = GetIconHtml(iconSetName, bucket, cfvos.Count); + // Prefix with showValue flag: "0|" = hide value, "1|" = show value + result[cellRef] = $"{(showValue ? "1" : "0")}|{icon}"; + } + } + } + return result; + } + + private static string GetIconHtml(IconSetValues iconSetName, int bucket, int totalBuckets) + { + // Traffic lights: red=0, yellow=1, green=2 + if (iconSetName == IconSetValues.ThreeTrafficLights1 || iconSetName == IconSetValues.ThreeTrafficLights2) + { + var color = bucket switch { 0 => "#C00000", 1 => "#FFC000", _ => "#00B050" }; + return $""; + } + // Arrows + if (iconSetName == IconSetValues.ThreeArrows || iconSetName == IconSetValues.ThreeArrowsGray) + { + return bucket switch + { + 0 => "", + 1 => "", + _ => "", + }; + } + // 4-icon traffic lights + if (iconSetName == IconSetValues.FourTrafficLights) + { + var color = bucket switch { 0 => "#C00000", 1 => "#FFC000", 2 => "#92D050", _ => "#00B050" }; + return $""; + } + // Default: colored circles + if (totalBuckets <= 3) + { + var color = bucket switch { 0 => "#C00000", 1 => "#FFC000", _ => "#00B050" }; + return $""; + } + else + { + var pct = totalBuckets > 1 ? (double)bucket / (totalBuckets - 1) : 1; + var r = (int)(0xC0 * (1 - pct)); + var g = (int)(0xB0 * pct); + var color = $"#{r:X2}{g:X2}00"; + return $""; + } + } + + /// Evaluate whether a conditional formatting rule matches a specific cell. + private bool EvaluateCfRule(ConditionalFormattingRule rule, string cellRef, int row, int col, + SheetData sheetData, Core.FormulaEvaluator evaluator) + { + var ruleType = rule.Type?.Value; + + // Get cell value for comparison + double? cellValue = null; + var cell = sheetData.Descendants() + .FirstOrDefault(c => string.Equals(c.CellReference?.Value, cellRef, StringComparison.OrdinalIgnoreCase)); + if (cell != null) + { + if (double.TryParse(cell.CellValue?.Text, System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out var v)) + cellValue = v; + } + + if (ruleType == ConditionalFormatValues.Expression) + { + // Formula-based rule: evaluate with cell reference adjustment + var formula = rule.Elements().FirstOrDefault()?.Text; + if (string.IsNullOrEmpty(formula)) return false; + + // Adjust formula references relative to the first cell in sqref + // The formula is written for the top-left cell; adjust for current cell + var adjusted = AdjustCfFormula(formula, row, col, rule); + var result = evaluator.TryEvaluateFull(adjusted); + return result?.BoolValue == true || (result?.NumericValue != null && result.NumericValue != 0); + } + + if (ruleType == ConditionalFormatValues.CellIs && cellValue.HasValue) + { + var op = rule.Operator?.Value; + var f1 = rule.Elements().FirstOrDefault()?.Text; + var f2 = rule.Elements().Skip(1).FirstOrDefault()?.Text; + double? v1 = f1 != null ? evaluator.TryEvaluate(f1) ?? (double.TryParse(f1, out var p1) ? p1 : null) : null; + double? v2 = f2 != null ? evaluator.TryEvaluate(f2) ?? (double.TryParse(f2, out var p2) ? p2 : null) : null; + if (v1 == null) return false; + if (op == ConditionalFormattingOperatorValues.GreaterThan) return cellValue > v1; + if (op == ConditionalFormattingOperatorValues.LessThan) return cellValue < v1; + if (op == ConditionalFormattingOperatorValues.GreaterThanOrEqual) return cellValue >= v1; + if (op == ConditionalFormattingOperatorValues.LessThanOrEqual) return cellValue <= v1; + if (op == ConditionalFormattingOperatorValues.Equal) return cellValue == v1; + if (op == ConditionalFormattingOperatorValues.NotEqual) return cellValue != v1; + if (op == ConditionalFormattingOperatorValues.Between) return v2.HasValue && cellValue >= v1 && cellValue <= v2; + if (op == ConditionalFormattingOperatorValues.NotBetween) return v2.HasValue && (cellValue < v1 || cellValue > v2); + return false; + } + + return false; + } + + /// Adjust a CF formula's cell references from the anchor cell to the target cell. + private string AdjustCfFormula(string formula, int targetRow, int targetCol, ConditionalFormattingRule rule) + { + // Find the anchor cell from the parent ConditionalFormatting sqref + var cf = rule.Parent as ConditionalFormatting; + var sqref = cf?.SequenceOfReferences?.Items?.FirstOrDefault()?.Value; + if (string.IsNullOrEmpty(sqref)) return formula; + + // Extract anchor from sqref (e.g. "E7:E21" → anchor is E7) + var anchorRef = sqref.Contains(':') ? sqref.Split(':')[0] : sqref; + var (anchorColName, anchorRow) = ParseCellReference(anchorRef); + var anchorCol = ColumnNameToIndex(anchorColName); + + var rowDelta = targetRow - anchorRow; + var colDelta = targetCol - anchorCol; + if (rowDelta == 0 && colDelta == 0) return formula; + + // Replace cell references in formula, adjusting by delta + return Regex.Replace(formula, @"(\$?)([A-Z]+)(\$?)(\d+)", m => + { + var colAbsolute = m.Groups[1].Value == "$"; + var rowAbsolute = m.Groups[3].Value == "$"; + var refCol = ColumnNameToIndex(m.Groups[2].Value); + var refRow = int.Parse(m.Groups[4].Value); + + var newCol = colAbsolute ? refCol : refCol + colDelta; + var newRow = rowAbsolute ? refRow : refRow + rowDelta; + if (newCol < 1) newCol = 1; + if (newRow < 1) newRow = 1; + return $"{(colAbsolute ? "$" : "")}{IndexToColumnName(newCol)}{(rowAbsolute ? "$" : "")}{newRow}"; + }); + } + + /// Expand a sqref string like "E7:E21" into individual cell references. + private List<(string cellRef, int row, int col)> ExpandSqref(string sqref) + { + var result = new List<(string, int, int)>(); + foreach (var part in sqref.Split(' ')) + { + if (part.Contains(':')) + { + var sides = part.Split(':'); + var (startColName, startRow) = ParseCellReference(sides[0]); + var (endColName, endRow) = ParseCellReference(sides[1]); + var startCol = ColumnNameToIndex(startColName); + var endCol = ColumnNameToIndex(endColName); + for (int r = startRow; r <= endRow; r++) + for (int c = startCol; c <= endCol; c++) + result.Add(($"{IndexToColumnName(c)}{r}", r, c)); + } + else + { + var (colName, row) = ParseCellReference(part); + result.Add((part, row, ColumnNameToIndex(colName))); + } + } + return result; + } + // ==================== Cell Style to CSS ==================== - private string GetCellStyleCss(Cell? cell, Stylesheet? stylesheet, int frozenRows, int frozenCols, int row, int col, Dictionary? frozenLeftOffsets = null) + private string GetCellStyleCss(Cell? cell, Stylesheet? stylesheet, int frozenRows, int frozenCols, int row, int col, + Dictionary? frozenLeftOffsets = null, Dictionary? frozenTopOffsets = null, + Dictionary? cfMap = null, Dictionary? dataBarMap = null, + Dictionary? iconSetMap = null) { var styles = new List(); @@ -399,6 +1013,7 @@ private string GetCellStyleCss(Cell? cell, Stylesheet? stylesheet, int frozenRow bool isFrozenCol = frozenCols > 0 && col <= frozenCols; // z-index layering: corner-cell=4, col-header=3, frozen-row+col=2, frozen-col=1 var frozenLeft = frozenLeftOffsets?.TryGetValue(col, out var fl) == true ? fl : 0; + var frozenTop = frozenTopOffsets?.TryGetValue(row, out var ft) == true ? ft : 0; if (isFrozenRow && isFrozenCol) styles.Add($"position:sticky;top:0;left:{frozenLeft:0.##}pt;z-index:2"); else if (isFrozenRow) @@ -407,7 +1022,13 @@ private string GetCellStyleCss(Cell? cell, Stylesheet? stylesheet, int frozenRow styles.Add($"position:sticky;left:{frozenLeft:0.##}pt;z-index:1"); if (cell == null || stylesheet == null) + { + // Frozen rows need opaque background so scrolling content doesn't show through + // Use actual cell fill if available; fallback to white for cells with no explicit fill + if (isFrozenRow && !styles.Any(s => s.StartsWith("background"))) + styles.Add("background:#fff"); return styles.Count > 0 ? $" style=\"{string.Join(";", styles)}\"" : ""; + } var styleIndex = cell.StyleIndex?.Value ?? 0; @@ -419,14 +1040,38 @@ private string GetCellStyleCss(Cell? cell, Stylesheet? stylesheet, int frozenRow BuildFontCss(xf, stylesheet, styles); BuildFillCss(xf, stylesheet, styles); BuildBorderCss(xf, stylesheet, styles); - BuildAlignmentCss(xf, styles); + BuildAlignmentCss(xf, styles, cell); } } + // Conditional formatting overrides (background, color) + var cfCellRef = $"{IndexToColumnName(col)}{row}"; + if (cfMap != null && cfMap.TryGetValue(cfCellRef, out var cfCss)) + { + // CF overrides existing background/color — remove conflicting base styles + foreach (var cfPart in cfCss.Split(';')) + { + var prop = cfPart.Split(':')[0].Trim(); + styles.RemoveAll(s => s.StartsWith(prop + ":")); + } + styles.Add(cfCss); + } + + // Data bar or icon set: add position:relative so inner elements can be absolutely positioned + if ((dataBarMap != null && dataBarMap.ContainsKey(cfCellRef)) || + (iconSetMap != null && iconSetMap.ContainsKey(cfCellRef))) + { + styles.Add("position:relative"); + } + + // Frozen rows need opaque background so scrolling content doesn't show through + if (isFrozenRow && !styles.Any(s => s.StartsWith("background:"))) + styles.Add("background:#fff"); + return styles.Count > 0 ? $" style=\"{string.Join(";", styles)}\"" : ""; } - private static void BuildFontCss(CellFormat xf, Stylesheet stylesheet, List styles) + private void BuildFontCss(CellFormat xf, Stylesheet stylesheet, List styles) { var fontId = xf.FontId?.Value ?? 0; var fonts = stylesheet.Fonts; @@ -463,7 +1108,7 @@ private static void BuildFontCss(CellFormat xf, Stylesheet stylesheet, List styles) + private void BuildFillCss(CellFormat xf, Stylesheet stylesheet, List styles) { var fillId = xf.FillId?.Value ?? 0; if (fillId <= 1) return; // 0=none, 1=gray125 pattern (default) @@ -502,7 +1147,7 @@ private static void BuildFillCss(CellFormat xf, Stylesheet stylesheet, List styles) + private void BuildBorderCss(CellFormat xf, Stylesheet stylesheet, List styles) { var borderId = xf.BorderId?.Value ?? 0; if (borderId == 0) return; @@ -518,7 +1163,7 @@ private static void BuildBorderCss(CellFormat xf, Stylesheet stylesheet, List styles) + private void AddBorderSideCss(BorderPropertiesType? bp, string side, List styles) { if (bp?.Style?.Value == null || bp.Style.Value == BorderStyleValues.None) return; @@ -539,14 +1184,14 @@ private static void AddBorderSideCss(BorderPropertiesType? bp, string side, List styles.Add($"border-{side}:{width} {cssStyle} {color}"); } - private static void BuildAlignmentCss(CellFormat xf, List styles) + private void BuildAlignmentCss(CellFormat xf, List styles, Cell? cell = null) { var alignment = xf.Alignment; - if (alignment == null) return; + bool hasExplicitHAlign = alignment?.Horizontal?.HasValue == true; - if (alignment.Horizontal?.HasValue == true) + if (hasExplicitHAlign) { - var h = alignment.Horizontal.InnerText; + var h = alignment!.Horizontal!.InnerText; var cssAlign = h switch { "center" => "center", @@ -554,11 +1199,24 @@ private static void BuildAlignmentCss(CellFormat xf, List styles) "left" => "left", "justify" => "justify", "fill" => "left", + "general" => (string?)null, // fall through to auto-detect _ => null }; - if (cssAlign != null) styles.Add($"text-align:{cssAlign}"); + if (cssAlign != null) { styles.Add($"text-align:{cssAlign}"); hasExplicitHAlign = true; } + else hasExplicitHAlign = false; + } + + // Excel default: numbers right-aligned, text left-aligned (General alignment) + if (!hasExplicitHAlign && cell != null) + { + var dt = cell.DataType?.Value; + bool isText = dt == CellValues.SharedString || dt == CellValues.InlineString || dt == CellValues.String; + if (!isText && cell.CellValue != null) + styles.Add("text-align:right"); } + if (alignment == null) return; + if (alignment.Vertical?.HasValue == true) { var v = alignment.Vertical.InnerText; @@ -593,7 +1251,13 @@ private static void BuildAlignmentCss(CellFormat xf, List styles) } if (alignment.Indent?.HasValue == true && alignment.Indent.Value > 0) - styles.Add($"padding-left:{alignment.Indent.Value * 6}pt"); + { + // 1 indent level ≈ width of "0" in default font ≈ fontSize × 0.6 + var defFontSz = _doc.WorkbookPart?.WorkbookStylesPart?.Stylesheet + ?.Fonts?.Elements().FirstOrDefault()?.FontSize?.Val?.Value ?? 11.0; + var indentPt = alignment.Indent.Value * defFontSz * 0.6; + styles.Add($"padding-left:{indentPt:0.#}pt"); + } // Reading order: 1=LTR, 2=RTL (for mixed-direction content) if (alignment.ReadingOrder?.HasValue == true) @@ -606,7 +1270,7 @@ private static void BuildAlignmentCss(CellFormat xf, List styles) // ==================== Color Resolution ==================== - private static string? ResolveFontColor(Font font) + private string? ResolveFontColor(Font font) { if (font.Color?.Rgb?.Value != null) { @@ -615,20 +1279,14 @@ private static void BuildAlignmentCss(CellFormat xf, List styles) } if (font.Color?.Theme?.Value != null) { - // Theme 0=lt1 (usually white bg), 1=dk1 (usually black text) - // For HTML preview, map common theme colors - return font.Color.Theme.Value switch - { - 0 => "#FFFFFF", - 1 => "#000000", - _ => null // skip unresolved theme colors — will use default - }; + var tint = font.Color.Tint?.Value; + return ResolveThemeColor(font.Color.Theme.Value, tint); } return null; } - // Standard Excel indexed color palette (first 64 colors) - private static readonly string[] IndexedColors = [ + // Standard Excel indexed color palette (first 64 colors) — can be overridden by styles.xml + private static readonly string[] DefaultIndexedColors = [ "#000000","#FFFFFF","#FF0000","#00FF00","#0000FF","#FFFF00","#FF00FF","#00FFFF", "#000000","#FFFFFF","#FF0000","#00FF00","#0000FF","#FFFF00","#FF00FF","#00FFFF", "#800000","#008000","#000080","#808000","#800080","#008080","#C0C0C0","#808080", @@ -639,34 +1297,23 @@ private static void BuildAlignmentCss(CellFormat xf, List styles) "#003366","#339966","#003300","#333300","#993300","#993366","#333399","#333333" ]; - private static string? ResolveColorRgb(ColorType? color) + private string? ResolveColorRgb(ColorType? color) { if (color?.Rgb?.Value != null) return FormatColorForCss(color.Rgb.Value); if (color?.Indexed?.Value != null) { var idx = (int)color.Indexed.Value; - if (idx >= 0 && idx < IndexedColors.Length) - return IndexedColors[idx]; + var palette = GetResolvedIndexedColors(); + if (idx >= 0 && idx < palette.Length) + return palette[idx]; if (idx == 64) return null; // system foreground (context dependent) if (idx == 65) return null; // system background } if (color?.Theme?.Value != null) { - return color.Theme.Value switch - { - 0 => "#FFFFFF", // lt1 - 1 => "#000000", // dk1 - 2 => "#E7E6E6", // lt2 - 3 => "#44546A", // dk2 - 4 => "#4472C4", // accent1 - 5 => "#ED7D31", // accent2 - 6 => "#A5A5A5", // accent3 - 7 => "#FFC000", // accent4 - 8 => "#5B9BD5", // accent5 - 9 => "#70AD47", // accent6 - _ => null - }; + var tint = color.Tint?.Value; + return ResolveThemeColor(color.Theme.Value, tint); } return null; } @@ -864,6 +1511,9 @@ private static string ApplyNumberFormat(double value, string fmtCode) { prefix += "-"; cleanFmt = cleanFmt[1..]; } var formatted = ApplyNumberFormatCore(value, cleanFmt.Trim()); + // For single-section formats with currency prefix, negative sign goes before the prefix + if (value < 0 && prefix.Length > 0 && formatted.StartsWith('-')) + return "-" + prefix + formatted[1..] + suffix; return prefix + formatted + suffix; } @@ -981,7 +1631,19 @@ private static int CountDecimalPlaces(string fmtCode) // ==================== CSS ==================== - private static string GenerateExcelCss() => """ + private string GenerateExcelCss() + { + // Read default font from workbook styles (font index 0) + var defFontName = "Calibri"; + var defFontSize = "11"; + var stylesheet = _doc.WorkbookPart?.WorkbookStylesPart?.Stylesheet; + if (stylesheet?.Fonts != null && stylesheet.Fonts.Elements().Any()) + { + var f0 = stylesheet.Fonts.Elements().First(); + if (f0.FontName?.Val?.Value != null) defFontName = f0.FontName.Val.Value; + if (f0.FontSize?.Val?.Value != null) defFontSize = f0.FontSize.Val.Value.ToString("0.##"); + } + return $$""" * { margin: 0; padding: 0; box-sizing: border-box; } html, body { height: 100%; } body { @@ -1048,8 +1710,8 @@ private static string GenerateExcelCss() => """ } table { border-collapse: collapse; - font-size: 11px; - font-family: 'Calibri', 'Segoe UI', sans-serif; + font-size: {{defFontSize}}px; + font-family: '{{defFontName}}', 'Segoe UI', sans-serif; table-layout: fixed; } .row-header-col { width: 30pt; } @@ -1076,9 +1738,16 @@ private static string GenerateExcelCss() => """ z-index: 2; background: #f8f8f8; min-width: 40px; + /* Drop right border so the data cell's own (often darker) left border shows through. + Otherwise, with border-collapse, the row-header's light grey right border can win + the collapse contest and erase the merged-cell left border (rowspan cells especially). */ + border-right: none; } td { - border: 1px solid #e0e0e0; + /* No default border. POI/libra approach: only render borders explicitly defined in OOXML. + A default 1px light-grey border interferes with border-collapse — it competes with + adjacent cells' dark borders and can erase explicit dividers (e.g. row 9→10 in col C + where row 9 has no bottom border but row 10 has a dark top border). */ padding: 2px 4px; white-space: nowrap; overflow: hidden; @@ -1126,6 +1795,7 @@ @media print { td { max-width: none !important; white-space: normal !important; overflow: visible !important; } } """; + } // ==================== JavaScript ==================== @@ -1139,6 +1809,20 @@ function switchSheet(idx) { }); window.scrollTo(0, 0); } + // Fix frozen row sticky top values using actual rendered heights + document.querySelectorAll('.table-wrapper table').forEach(function(table) { + var thead = table.querySelector('thead'); + if (!thead) return; + var theadH = thead.offsetHeight; + var cumTop = theadH; + var frozen = table.querySelectorAll('tr[data-frozen]'); + frozen.forEach(function(tr) { + tr.querySelectorAll('th, td').forEach(function(cell) { + if (cell.style.position === 'sticky') cell.style.top = cumTop + 'px'; + }); + cumTop += tr.offsetHeight; + }); + }); """; // ==================== Utility ==================== @@ -1160,6 +1844,40 @@ private static string CellHtml(string text) return encoded.Contains('\n') ? encoded.Replace("\n", "
    ") : encoded; } + private static string BuildCellContent(string cellRef, string value, + Dictionary dataBarMap, Dictionary iconSetMap) + { + var hasBar = dataBarMap.TryGetValue(cellRef, out var barEntry); + var hasIcon = iconSetMap.TryGetValue(cellRef, out var iconEntry); + if (!hasBar && !hasIcon) return CellHtml(value); + + // Parse "showValue|html" format + var barShowValue = true; + var barHtml = ""; + if (hasBar && barEntry != null) + { + var sep = barEntry.IndexOf('|'); + barShowValue = sep < 0 || barEntry[0] != '0'; + barHtml = sep >= 0 ? barEntry[(sep + 1)..] : barEntry; + } + var iconShowValue = true; + var iconHtml = ""; + if (hasIcon && iconEntry != null) + { + var sep = iconEntry.IndexOf('|'); + iconShowValue = sep < 0 || iconEntry[0] != '0'; + iconHtml = sep >= 0 ? iconEntry[(sep + 1)..] : iconEntry; + } + var showValue = barShowValue && iconShowValue; + + var sb = new StringBuilder(); + if (hasBar) sb.Append(barHtml); + if (hasIcon) sb.Append($"{iconHtml}"); + if (showValue) + sb.Append($"{CellHtml(value)}"); + return sb.ToString(); + } + private static string CssSanitize(string value) { // Strip characters that could break CSS context diff --git a/src/officecli/Handlers/Excel/ExcelHandler.Query.cs b/src/officecli/Handlers/Excel/ExcelHandler.Query.cs index fda50984f..b3047b39a 100644 --- a/src/officecli/Handlers/Excel/ExcelHandler.Query.cs +++ b/src/officecli/Handlers/Excel/ExcelHandler.Query.cs @@ -40,7 +40,13 @@ public DocumentNode Get(string path, int depth = 1) { var sheetNode = new DocumentNode { Path = $"/{name}", Type = "sheet", Preview = name }; var sheetData = GetSheet(part).GetFirstChild(); - var rowCount = sheetData?.Elements().Count() ?? 0; + // R6-5: dedupe by RowIndex so a pivot placed on its own source + // sheet doesn't double-count row children. + var rowCount = sheetData?.Elements() + .Select(r => r.RowIndex?.Value ?? 0u) + .Where(i => i != 0) + .Distinct() + .Count() ?? 0; var chartCount = part.DrawingsPart != null ? CountExcelCharts(part.DrawingsPart) : 0; sheetNode.ChildCount = rowCount + chartCount; @@ -129,7 +135,7 @@ public DocumentNode Get(string path, int depth = 1) Path = path, Type = "sheet", Preview = sheetNameFromPath, - ChildCount = data.Elements().Count() + (worksheet.DrawingsPart != null ? CountExcelCharts(worksheet.DrawingsPart) : 0) + ChildCount = data.Elements().Select(r => r.RowIndex?.Value ?? 0u).Where(i => i != 0).Distinct().Count() + (worksheet.DrawingsPart != null ? CountExcelCharts(worksheet.DrawingsPart) : 0) }; // Include freeze pane info @@ -313,6 +319,7 @@ public DocumentNode Get(string path, int depth = 1) // Include cells in this column as children (non-empty rows only) if (depth > 0) { + var eval = new Core.FormulaEvaluator(data, _doc.WorkbookPart); foreach (var row in data.Elements().OrderBy(r => r.RowIndex?.Value ?? 0)) { var cell = row.Elements().FirstOrDefault(c => @@ -322,7 +329,7 @@ public DocumentNode Get(string path, int depth = 1) return cn.Equals(colName, StringComparison.OrdinalIgnoreCase); }); if (cell != null) - colNode.Children.Add(CellToNode(sheetNameFromPath, cell, worksheet)); + colNode.Children.Add(CellToNode(sheetNameFromPath, cell, worksheet, eval)); } colNode.ChildCount = colNode.Children.Count; } @@ -347,8 +354,11 @@ public DocumentNode Get(string path, int depth = 1) rowNode.Format["outlineLevel"] = (int)row.OutlineLevel.Value; if (row.Collapsed?.Value == true) rowNode.Format["collapsed"] = true; if (depth > 0) + { + var eval = new Core.FormulaEvaluator(data, _doc.WorkbookPart); foreach (var c in row.Elements()) - rowNode.Children.Add(CellToNode(sheetNameFromPath, c, worksheet)); + rowNode.Children.Add(CellToNode(sheetNameFromPath, c, worksheet, eval)); + } return rowNode; } @@ -473,6 +483,10 @@ public DocumentNode Get(string path, int depth = 1) if (rule.TimePeriod?.HasValue == true) cfNode.Format["period"] = rule.TimePeriod.InnerText; if (rule.FormatId?.Value != null) cfNode.Format["dxfId"] = rule.FormatId.Value; } + + // Resolve dxfId to actual fill/font colors from the stylesheet + if (rule.FormatId?.Value != null) + PopulateCfNodeFromDxf(cfNode, (int)rule.FormatId.Value); } return cfNode; } @@ -547,7 +561,7 @@ public DocumentNode Get(string path, int depth = 1) var pivotPart = pivotParts[ptIdx - 1]; var ptNode = new DocumentNode { Path = path, Type = "pivottable" }; if (pivotPart.PivotTableDefinition != null) - PivotTableHelper.ReadPivotTableProperties(pivotPart.PivotTableDefinition, ptNode); + PivotTableHelper.ReadPivotTableProperties(pivotPart.PivotTableDefinition, ptNode, pivotPart); return ptNode; } @@ -848,7 +862,7 @@ public List Query(string selector) var node = new DocumentNode { Path = $"/{sheetName}/pivottable[{i + 1}]", Type = "pivottable" }; var pivotDef = pivotParts[i].PivotTableDefinition; if (pivotDef != null) - PivotTableHelper.ReadPivotTableProperties(pivotDef, node); + PivotTableHelper.ReadPivotTableProperties(pivotDef, node, pivotParts[i]); if (parsed.ValueContains != null) { @@ -985,7 +999,7 @@ public List Query(string selector) if (part != null) { node.Format["contentType"] = part.ContentType; - node.Format["size"] = part.GetStream().Length; + node.Format["fileSize"] = part.GetStream().Length; } } results.Add(node); @@ -1037,13 +1051,14 @@ public List Query(string selector) var sheetData = GetSheet(worksheetPart).GetFirstChild(); if (sheetData == null) continue; + var eval = new Core.FormulaEvaluator(sheetData, _doc.WorkbookPart); foreach (var row in sheetData.Elements()) { foreach (var cell in row.Elements()) { if (MatchesCellSelector(cell, sheetName, parsed)) { - var node = CellToNode(sheetName, cell, worksheetPart); + var node = CellToNode(sheetName, cell, worksheetPart, eval); if (MatchesFormatAttributes(node, parsed)) results.Add(node); } @@ -1053,4 +1068,52 @@ public List Query(string selector) return results; } + + // ==================== CF DXF resolution ==================== + + /// + /// Resolves a conditional formatting rule's dxfId to fill and font colors + /// from the workbook stylesheet, and populates the DocumentNode accordingly. + /// + private void PopulateCfNodeFromDxf(DocumentNode cfNode, int dxfId) + { + var stylesheet = _doc.WorkbookPart?.WorkbookStylesPart?.Stylesheet; + if (stylesheet == null) return; + + var dxfs = stylesheet.GetFirstChild(); + if (dxfs == null) return; + + var dxfList = dxfs.Elements().ToList(); + if (dxfId < 0 || dxfId >= dxfList.Count) return; + + var dxf = dxfList[dxfId]; + + // Resolve fill color + var fill = dxf.GetFirstChild(); + if (fill != null) + { + var patternFill = fill.GetFirstChild(); + if (patternFill != null) + { + var bgColor = patternFill.GetFirstChild(); + if (bgColor?.Rgb?.Value != null) + cfNode.Format["fill"] = ParseHelpers.FormatHexColor(bgColor.Rgb.Value); + else + { + var fgColor = patternFill.GetFirstChild(); + if (fgColor?.Rgb?.Value != null) + cfNode.Format["fill"] = ParseHelpers.FormatHexColor(fgColor.Rgb.Value); + } + } + } + + // Resolve font color + var font = dxf.GetFirstChild(); + if (font != null) + { + var fontColor = font.GetFirstChild(); + if (fontColor?.Rgb?.Value != null) + cfNode.Format["font.color"] = ParseHelpers.FormatHexColor(fontColor.Rgb.Value); + } + } } diff --git a/src/officecli/Handlers/Excel/ExcelHandler.Remove.cs b/src/officecli/Handlers/Excel/ExcelHandler.Remove.cs index f72653d77..7f1ac0d10 100644 --- a/src/officecli/Handlers/Excel/ExcelHandler.Remove.cs +++ b/src/officecli/Handlers/Excel/ExcelHandler.Remove.cs @@ -68,11 +68,41 @@ public partial class ExcelHandler if (sheetCount <= 1) throw new InvalidOperationException($"Cannot remove the last sheet. A workbook must contain at least one sheet."); + // R10-2: capture pivot cache definitions referenced by this + // sheet's pivot table parts BEFORE deleting the worksheet part, + // so we can prune any caches that become orphaned by the + // removal. Without this the workbook still carries pivotCaches + // entries + cache parts whose owning pivot is gone, which + // corrupts the file (Content_Types + workbook.xml.rels keep + // references to unreachable parts). Mirrors the cleanup done + // by the pivottable[N] branch below — both routes share the + // same orphan prune helper. var relId = sheet.Id?.Value; + var sheetWsPart = relId != null + ? workbookPart.GetPartById(relId) as WorksheetPart + : null; + var cachePartsTouched = sheetWsPart != null + ? sheetWsPart.PivotTableParts + .Select(pp => pp.PivotTableCacheDefinitionPart) + .Where(cp => cp != null) + .Cast() + .Distinct() + .ToList() + : new List(); + sheet.Remove(); if (relId != null) workbookPart.DeletePart(workbookPart.GetPartById(relId)); + // Prune orphan pivot caches now that the sheet (and its pivot + // table parts) are gone. PrunePivotCacheIfOrphan walks every + // remaining worksheet's pivot tables to confirm the cache is no + // longer referenced, then drops the workbook-level pivotCache + // entry and the cache part itself (which cascades to records, + // _rels, and Content_Types). + foreach (var cp in cachePartsTouched) + PrunePivotCacheIfOrphan(workbookPart, cp); + // Clean up named ranges referencing the deleted sheet var workbook = GetWorkbook(); var definedNames = workbook.GetFirstChild(); @@ -408,6 +438,50 @@ public partial class ExcelHandler return null; } + // pivottable[N] — remove pivot table (and its cache if no other pivot references it) + var pivotRemoveMatch = Regex.Match(cellRef, @"^pivottable\[(\d+)\]$", RegexOptions.IgnoreCase); + if (pivotRemoveMatch.Success) + { + var ptIdx = int.Parse(pivotRemoveMatch.Groups[1].Value); + var pivotParts = worksheet.PivotTableParts.ToList(); + if (ptIdx < 1 || ptIdx > pivotParts.Count) + throw new ArgumentException($"PivotTable index {ptIdx} out of range (1..{pivotParts.Count})"); + var pivotPart = pivotParts[ptIdx - 1]; + + // Capture the cache-definition part (if any) so we can clean up + // workbook-level PivotCache registration after removing the pivot. + var cachePart = pivotPart.PivotTableCacheDefinitionPart; + + // Capture pivot location before deleting the part so we can erase + // the rendered cell data from sheetData. Without this, add→remove + // cycles leave orphaned rows in sheetData (duplicate row indices, + // unbounded XML growth). CONSISTENCY(pivot-remove-cleanup) + var pivotLocationRef = pivotPart.PivotTableDefinition + ?.GetFirstChild() + ?.Reference?.Value; + + // Remove the pivot table part itself. + worksheet.DeletePart(pivotPart); + + // Erase the pivot's rendered cells from sheetData. + if (!string.IsNullOrEmpty(pivotLocationRef)) + { + var pivotSd = GetSheet(worksheet).GetFirstChild(); + if (pivotSd != null) + OfficeCli.Core.PivotTableHelper.ClearPivotRangeCells(pivotSd, pivotLocationRef); + } + + // If no other pivot table references this cache, drop the cache + // definition (and its records) plus the workbook-level PivotCache + // registration. Otherwise leave it alone — shared caches are valid. + // Shared with the sheet-remove path above via PrunePivotCacheIfOrphan. + if (cachePart != null) + PrunePivotCacheIfOrphan(_doc.WorkbookPart!, cachePart); + + SaveWorksheet(worksheet); + return null; + } + // autofilter — remove AutoFilter from worksheet if (cellRef.Equals("autofilter", StringComparison.OrdinalIgnoreCase)) { @@ -1121,4 +1195,44 @@ private static string ShiftColLettersInText(string text, string sheetName, int d }, RegexOptions.IgnoreCase); } + + /// + /// R10-2 / R2-1 shared helper. Drops a PivotTableCacheDefinitionPart and + /// its workbook-level <pivotCache> entry IF no remaining pivot + /// table part references it. Used by both the sheet-remove and the + /// pivottable[N]-remove code paths so the orphan-cleanup logic stays + /// in one place. + /// + private static void PrunePivotCacheIfOrphan(WorkbookPart workbookPart, PivotTableCacheDefinitionPart cachePart) + { + bool stillReferenced = workbookPart.WorksheetParts + .SelectMany(ws => ws.PivotTableParts) + .Any(pp => pp.PivotTableCacheDefinitionPart == cachePart); + if (stillReferenced) return; + + // Locate and remove the entry in workbook.xml by + // matching the relationship id from WorkbookPart → cachePart. + string? cacheRelId = null; + try { cacheRelId = workbookPart.GetIdOfPart(cachePart); } catch { } + + var wb = workbookPart.Workbook; + if (wb != null) + { + var pivotCaches = wb.GetFirstChild(); + if (pivotCaches != null && cacheRelId != null) + { + var pcEntry = pivotCaches.Elements() + .FirstOrDefault(pc => pc.Id?.Value == cacheRelId); + pcEntry?.Remove(); + if (!pivotCaches.HasChildren) + pivotCaches.Remove(); + } + try { workbookPart.DeletePart(cachePart); } catch { } + wb.Save(); + } + else + { + try { workbookPart.DeletePart(cachePart); } catch { } + } + } } diff --git a/src/officecli/Handlers/Excel/ExcelHandler.Set.cs b/src/officecli/Handlers/Excel/ExcelHandler.Set.cs index c7b982ad4..aa32b1311 100644 --- a/src/officecli/Handlers/Excel/ExcelHandler.Set.cs +++ b/src/officecli/Handlers/Excel/ExcelHandler.Set.cs @@ -41,6 +41,12 @@ public List Set(string path, Dictionary properties) path = NormalizeExcelPath(path); path = ResolveSheetIndexInPath(path); + // Excel only supports find+replace — reject find without replace early (before path dispatch) + if (properties.ContainsKey("find") && !properties.ContainsKey("replace")) + throw new ArgumentException("Excel only supports 'find' with 'replace'. Use 'find' + 'replace' for text replacement. find+format (without replace) is not supported in Excel."); + if (properties.ContainsKey("regex") && properties.ContainsKey("find")) + throw new ArgumentException("Excel find+replace does not support regex. Remove 'regex' property."); + // Handle root path "/" — document properties if (path == "/") { @@ -48,6 +54,7 @@ public List Set(string path, Dictionary properties) if (properties.TryGetValue("find", out var findText) && properties.TryGetValue("replace", out var replaceText)) { var count = FindAndReplace(findText, replaceText, null); + LastFindMatchCount = count; var remaining = new Dictionary(properties, StringComparer.OrdinalIgnoreCase); remaining.Remove("find"); remaining.Remove("replace"); @@ -1161,10 +1168,20 @@ private List ApplyCellProperties(Cell cell, string cellRef, WorksheetPar break; } default: - if (!GenericXmlQuery.SetGenericAttribute(cell, key, value)) + // Check for known flat-key misuse first, even before generic + // attribute fallback — otherwise user typos like `size=14` + // would be silently written as unknown XML attributes. + var cellHint = CellPropHints.TryGetHint(key); + if (cellHint != null) + { + unsupported.Add(cellHint); + } + else if (!GenericXmlQuery.SetGenericAttribute(cell, key, value)) + { unsupported.Add(unsupported.Count == 0 ? $"{key} (valid cell props: value, formula, arrayformula, type, clear, link, bold, italic, strike, underline, superscript, subscript, font.color, font.size, font.name, fill, border.all, alignment.horizontal, numfmt, locked, formulahidden)" : key); + } break; } } @@ -1189,6 +1206,7 @@ private List SetSheetLevel(WorksheetPart worksheet, string sheetName, Di if (properties.TryGetValue("find", out var findText) && properties.TryGetValue("replace", out var replaceText)) { var count = FindAndReplace(findText, replaceText, worksheet); + LastFindMatchCount = count; var remaining = new Dictionary(properties, StringComparer.OrdinalIgnoreCase); remaining.Remove("find"); remaining.Remove("replace"); @@ -1249,6 +1267,23 @@ static bool NeedsQuoting(string n) => } GetSheet(wsPart).Save(); } + + // Update any pivot cache definitions whose WorksheetSource + // references the old sheet name. Without this the pivot + // cache's stale sheet ref breaks Excel refresh. + // CONSISTENCY(sheet-rename-refs) + var workbookPart = _doc.WorkbookPart!; + foreach (var cacheDefPart in workbookPart.GetPartsOfType()) + { + var wsSource = cacheDefPart.PivotCacheDefinition?.CacheSource?.WorksheetSource; + if (wsSource?.Sheet?.Value != null && + wsSource.Sheet.Value.Equals(oldName, StringComparison.OrdinalIgnoreCase)) + { + wsSource.Sheet = value; + cacheDefPart.PivotCacheDefinition!.Save(); + } + } + workbook.Save(); } break; diff --git a/src/officecli/Handlers/Excel/ExcelHandler.View.cs b/src/officecli/Handlers/Excel/ExcelHandler.View.cs index 3de39c924..0872e38eb 100644 --- a/src/officecli/Handlers/Excel/ExcelHandler.View.cs +++ b/src/officecli/Handlers/Excel/ExcelHandler.View.cs @@ -27,6 +27,7 @@ public string ViewAsText(int? startLine = null, int? endLine = null, int? maxLin if (sheetData == null) continue; int totalRows = sheetData.Elements().Count(); + var evaluator = new Core.FormulaEvaluator(sheetData, _doc.WorkbookPart); int lineNum = 0; foreach (var row in sheetData.Elements()) { @@ -44,7 +45,7 @@ public string ViewAsText(int? startLine = null, int? endLine = null, int? maxLin var cellElements = row.Elements(); if (cols != null) cellElements = cellElements.Where(c => cols.Contains(ParseCellReference(c.CellReference?.Value ?? "A1").Column)); - var cells = cellElements.Select(c => GetCellDisplayValue(c)).ToArray(); + var cells = cellElements.Select(c => GetCellDisplayValue(c, evaluator)).ToArray(); var rowRef = row.RowIndex?.Value ?? (uint)lineNum; sb.AppendLine($"[/{sheetName}/row[{rowRef}]] {string.Join("\t", cells)}"); emitted++; @@ -145,7 +146,16 @@ public string ViewAsOutline() } var formulaInfo = formulaCount > 0 ? $", {formulaCount} formula(s)" : ""; - sb.AppendLine($"\u251c\u2500\u2500 \"{name}\" ({rowCount} rows \u00d7 {colCount} cols{formulaInfo})"); + + // Pivot tables are stored as pivotTableDefinition XML; their rendered cells + // are NOT materialized into sheetData (Excel/Calc re-render from pivotCacheRecords + // at display time). Without this hint, a pivot-only sheet looks like "0 rows × 0 cols" + // and users think it's empty. Surface the pivot count explicitly — same strategy POI + // takes via XSSFSheet.getPivotTables(). See also: query pivottable. + int pivotCount = worksheetPart.PivotTableParts.Count(); + var pivotInfo = pivotCount > 0 ? $", {pivotCount} pivot table(s)" : ""; + + sb.AppendLine($"\u251c\u2500\u2500 \"{name}\" ({rowCount} rows \u00d7 {colCount} cols{formulaInfo}{pivotInfo})"); } return sb.ToString().TrimEnd(); diff --git a/src/officecli/Handlers/ExcelHandler.cs b/src/officecli/Handlers/ExcelHandler.cs index 892d0e2c7..14a783970 100644 --- a/src/officecli/Handlers/ExcelHandler.cs +++ b/src/officecli/Handlers/ExcelHandler.cs @@ -15,6 +15,7 @@ public partial class ExcelHandler : IDocumentHandler private readonly SpreadsheetDocument _doc; private readonly string _filePath; private readonly HashSet _initialSheetNames; + public int LastFindMatchCount { get; internal set; } public ExcelHandler(string filePath, bool editable) { diff --git a/src/officecli/Handlers/PowerPointHandler.cs b/src/officecli/Handlers/PowerPointHandler.cs index e6c8426c9..b3402ee72 100644 --- a/src/officecli/Handlers/PowerPointHandler.cs +++ b/src/officecli/Handlers/PowerPointHandler.cs @@ -16,11 +16,16 @@ public partial class PowerPointHandler : IDocumentHandler { private readonly PresentationDocument _doc; private readonly string _filePath; + private HashSet _usedShapeIds = new(); + private uint _nextShapeId = 10000; + public int LastFindMatchCount { get; internal set; } public PowerPointHandler(string filePath, bool editable) { _filePath = filePath; _doc = PresentationDocument.Open(filePath, editable); + if (editable) + InitShapeIdCounter(); } /// @@ -604,6 +609,31 @@ public void RawSet(string partPath, string xpath, string action, string? xml) public List Validate() => RawXmlHelper.ValidateDocument(_doc); + /// + /// Execute a JSON batch of operations on this document. + /// Returns one BatchResult per item, with Success=true or Success=false+Error. + /// + public List Batch(string json) + { + var items = System.Text.Json.JsonSerializer.Deserialize(json, Core.BatchJsonContext.Default.ListBatchItem) + ?? throw new ArgumentException("Invalid batch JSON"); + var results = new List(); + for (var i = 0; i < items.Count; i++) + { + var item = items[i]; + try + { + var output = CommandBuilder.ExecuteBatchItem(this, item, json: false); + results.Add(new Core.BatchResult { Index = i, Success = true, Output = output }); + } + catch (Exception ex) + { + results.Add(new Core.BatchResult { Index = i, Success = false, Error = ex.Message, Item = item }); + } + } + return results; + } + public void Dispose() => _doc.Dispose(); // ==================== Private Helpers ==================== diff --git a/src/officecli/Handlers/Pptx/PowerPointHandler.Add.Media.cs b/src/officecli/Handlers/Pptx/PowerPointHandler.Add.Media.cs index 9b0a63571..8043af29a 100644 --- a/src/officecli/Handlers/Pptx/PowerPointHandler.Add.Media.cs +++ b/src/officecli/Handlers/Pptx/PowerPointHandler.Add.Media.cs @@ -59,8 +59,8 @@ private string AddPicture(string parentPath, int? index, Dictionary().Count() + imgShapeTree.Elements().Count() + 2); - var imgName = properties.GetValueOrDefault("name", $"Picture {imgShapeId}"); + var imgShapeId = GenerateUniqueShapeId(imgShapeTree); + var imgName = properties.GetValueOrDefault("name", $"Picture {imgShapeTree.Elements().Count() + 1}"); var altText = properties.GetValueOrDefault("alt", Path.GetFileName(imgPath)); // Build Picture element following Open-XML-SDK conventions @@ -89,11 +89,10 @@ private string AddPicture(string parentPath, int? index, Dictionary().Count(); - return $"/slide[{imgSlideIdx}]/picture[{picCount}]"; + return $"/slide[{imgSlideIdx}]/{BuildElementPathSegment("picture", picture, imgShapeTree.Elements().Count())}"; } @@ -130,8 +129,8 @@ private string AddChart(string parentPath, int? index, Dictionary().Count(gf => gf.Descendants().Any() || IsExtendedChartFrame(gf)) + 1}"); // Extended chart types (cx:chart) — funnel, treemap, sunburst, boxWhisker, histogram if (ChartExBuilder.IsExtendedChartType(chartType)) @@ -144,13 +143,13 @@ private string AddChart(string parentPath, int? index, Dictionary() .Count(gf => gf.Descendants().Any() || IsExtendedChartFrame(gf)); - return $"/slide[{chartSlideIdx}]/chart[{totalCharts}]"; + return $"/slide[{chartSlideIdx}]/{BuildElementPathSegment("chart", chartGfEx, totalCharts)}"; } // Build chart content BEFORE adding part (invalid type throws, must not leave empty part) @@ -168,12 +167,12 @@ private string AddChart(string parentPath, int? index, Dictionary() .Count(gf => gf.Descendants().Any()); - return $"/slide[{chartSlideIdx}]/chart[{chartCount}]"; + return $"/slide[{chartSlideIdx}]/{BuildElementPathSegment("chart", chartGf, chartCount)}"; } @@ -261,7 +260,7 @@ private string AddMedia(string parentPath, int? index, Dictionary().Count() + 1}"); // Position: x1,y1 → x2,y2 or x,y,width,height long cxnX = (properties.TryGetValue("x", out var cx1) || properties.TryGetValue("left", out cx1)) ? ParseEmu(cx1) : 2000000; @@ -124,11 +124,10 @@ private string AddConnector(string parentPath, int? index, Dictionary().Count(); - return $"/slide[{cxnSlideIdx}]/connector[{cxnCount}]"; + return $"/slide[{cxnSlideIdx}]/{BuildElementPathSegment("connector", connector, cxnShapeTree.Elements().Count())}"; } /// @@ -183,8 +182,8 @@ private string AddGroup(string parentPath, int? index, Dictionary().Count() + 1}"); // Parse shape paths to group: shapes="1,2,3" (shape indices) if (!properties.TryGetValue("shapes", out var shapesStr)) @@ -264,7 +263,7 @@ private string AddGroup(string parentPath, int? index, Dictionary().Count(); @@ -375,8 +374,8 @@ private string AddZoom(string parentPath, int? index, Dictionary var transitionDur = properties.GetValueOrDefault("transitiondur", "1000"); // Generate shape IDs - var zmShapeId = (uint)(zmShapeTree.ChildElements.Count + 2); - var zmName = properties.GetValueOrDefault("name", $"Slide Zoom {zmShapeId}"); + var zmShapeId = GenerateUniqueShapeId(zmShapeTree); + var zmName = properties.GetValueOrDefault("name", $"Slide Zoom {GetZoomElements(zmShapeTree).Count + 1}"); var zmGuid = Guid.NewGuid().ToString("B").ToUpperInvariant(); var zmCreationId = Guid.NewGuid().ToString("B").ToUpperInvariant(); @@ -580,7 +579,7 @@ private string AddZoom(string parentPath, int? index, Dictionary acElement.AppendChild(choiceElement); acElement.AppendChild(fallbackElement); - zmShapeTree.AppendChild(acElement); + InsertAtPosition(zmShapeTree, acElement, index); GetSlide(zmSlidePart).Save(); var zmCount = zmShapeTree.ChildElements diff --git a/src/officecli/Handlers/Pptx/PowerPointHandler.Add.Model3D.cs b/src/officecli/Handlers/Pptx/PowerPointHandler.Add.Model3D.cs index b986e6c06..bbfefc03e 100644 --- a/src/officecli/Handlers/Pptx/PowerPointHandler.Add.Model3D.cs +++ b/src/officecli/Handlers/Pptx/PowerPointHandler.Add.Model3D.cs @@ -72,8 +72,8 @@ private string AddModel3D(string parentPath, int? index, Dictionary e.Descendants().FirstOrDefault()?.Id?.Value ?? 0) - .DefaultIfEmpty(1U) - .Max(); - var shapeId = maxExistingId + 1; - var shapeName = properties.GetValueOrDefault("name", $"TextBox {shapeId}"); + var shapeId = GenerateUniqueShapeId(shapeTree); + var shapeName = properties.GetValueOrDefault("name", $"TextBox {shapeTree.Elements().Count() + 1}"); // Auto-add !! prefix if the slide (or the next slide) has a morph transition if (!shapeName.StartsWith("!!") && !shapeName.StartsWith("TextBox ") && !shapeName.StartsWith("Content ") && shapeName != "") @@ -238,8 +233,7 @@ private string AddShape(string parentPath, int? index, Dictionary().Count(); - return $"/slide[{slideIdx}]/shape[{shapeCount}]"; + return $"/slide[{slideIdx}]/{BuildElementPathSegment("shape", newShape, shapeTree.Elements().Count())}"; } diff --git a/src/officecli/Handlers/Pptx/PowerPointHandler.Add.Table.cs b/src/officecli/Handlers/Pptx/PowerPointHandler.Add.Table.cs index 2f8c52704..31fde943a 100644 --- a/src/officecli/Handlers/Pptx/PowerPointHandler.Add.Table.cs +++ b/src/officecli/Handlers/Pptx/PowerPointHandler.Add.Table.cs @@ -86,12 +86,12 @@ private string AddTable(string parentPath, int? index, Dictionary().Count(gf => gf.Descendants().Any()) + 1}") }, new NonVisualGraphicFrameDrawingProperties(), new ApplicationNonVisualDrawingProperties() ); @@ -118,6 +118,14 @@ private string AddTable(string parentPath, int? index, Dictionary() .Count(gf => gf.Descendants().Any()); - return $"/slide[{tblSlideIdx}]/table[{tblCount}]"; + return $"/slide[{tblSlideIdx}]/{BuildElementPathSegment("table", graphicFrame, tblCount)}"; } diff --git a/src/officecli/Handlers/Pptx/PowerPointHandler.Add.Text.cs b/src/officecli/Handlers/Pptx/PowerPointHandler.Add.Text.cs index e34f12819..5738af1b1 100644 --- a/src/officecli/Handlers/Pptx/PowerPointHandler.Add.Text.cs +++ b/src/officecli/Handlers/Pptx/PowerPointHandler.Add.Text.cs @@ -32,11 +32,8 @@ private string AddEquation(string parentPath, int? index, Dictionary e.Descendants().FirstOrDefault()?.Id?.Value ?? 0) - .DefaultIfEmpty(1U) - .Max() + 1; - var eqShapeName = properties.GetValueOrDefault("name", $"Equation {eqShapeId}"); + var eqShapeId = GenerateUniqueShapeId(eqShapeTree); + var eqShapeName = properties.GetValueOrDefault("name", $"Equation {eqShapeTree.Elements().Count() + 1}"); // Parse formula to OMML var mathContent = FormulaParser.Parse(eqFormula); @@ -101,7 +98,7 @@ private string AddEquation(string parentPath, int? index, Dictionary().Count(); - return $"/slide[{eqSlideIdx}]/shape[{eqShapeCount}]"; + return $"/slide[{eqSlideIdx}]/{BuildElementPathSegment("shape", eqShape, eqShapeTree.Elements().Count())}"; } @@ -225,7 +221,7 @@ private string AddParagraph(string parentPath, int? index, Dictionary().Count(); GetSlide(paraSlidePart).Save(); - return $"/slide[{paraSlideIdx}]/shape[{paraShapeIdx}]/paragraph[{paraCount}]"; + return $"/slide[{paraSlideIdx}]/{BuildElementPathSegment("shape", paraShape, paraShapeIdx)}/paragraph[{paraCount}]"; } @@ -320,16 +316,33 @@ private string AddRun(string parentPath, int? index, Dictionary newRun.RunProperties = rProps; newRun.Text = new Drawing.Text { Text = runText.Replace("\\n", "\n") }; - // Append run to paragraph (before EndParagraphRunProperties if present) - var endParaRun = targetPara.GetFirstChild(); - if (endParaRun != null) - targetPara.InsertBefore(newRun, endParaRun); + // Insert run at specified index, or append + if (index.HasValue) + { + var existingRuns = targetPara.Elements().ToList(); + if (index.Value >= 0 && index.Value < existingRuns.Count) + existingRuns[index.Value].InsertBeforeSelf(newRun); + else + { + var endParaRun2 = targetPara.GetFirstChild(); + if (endParaRun2 != null) + targetPara.InsertBefore(newRun, endParaRun2); + else + targetPara.Append(newRun); + } + } else - targetPara.Append(newRun); + { + var endParaRun = targetPara.GetFirstChild(); + if (endParaRun != null) + targetPara.InsertBefore(newRun, endParaRun); + else + targetPara.Append(newRun); + } var runCount = targetPara.Elements().Count(); GetSlide(runSlidePart).Save(); - return $"/slide[{runSlideIdx}]/shape[{runShapeIdx}]/paragraph[{targetParaIdx}]/run[{runCount}]"; + return $"/slide[{runSlideIdx}]/{BuildElementPathSegment("shape", runShape, runShapeIdx)}/paragraph[{targetParaIdx}]/run[{runCount}]"; } diff --git a/src/officecli/Handlers/Pptx/PowerPointHandler.Add.cs b/src/officecli/Handlers/Pptx/PowerPointHandler.Add.cs index f5bd9afac..cc685b4fb 100644 --- a/src/officecli/Handlers/Pptx/PowerPointHandler.Add.cs +++ b/src/officecli/Handlers/Pptx/PowerPointHandler.Add.cs @@ -14,9 +14,22 @@ namespace OfficeCli.Handlers; public partial class PowerPointHandler { - public string Add(string parentPath, string type, int? index, Dictionary properties) + public string Add(string parentPath, string type, InsertPosition? position, Dictionary properties) { parentPath = NormalizeCellPath(parentPath); + parentPath = ResolveIdPath(parentPath); + + // Resolve --after/--before to index (handles find: prefix) + var index = ResolveAnchorPosition(parentPath, position); + + // Handle find: prefix — text-based anchoring in PPT paragraphs + if (index == FindAnchorIndex && position != null) + { + var anchorValue = (position.After ?? position.Before)!; + var findValue = anchorValue["find:".Length..]; + var isAfter = position.After != null; + return AddPptAtFindPosition(parentPath, type, findValue, isAfter, properties); + } return type.ToLowerInvariant() switch { diff --git a/src/officecli/Handlers/Pptx/PowerPointHandler.Animations.cs b/src/officecli/Handlers/Pptx/PowerPointHandler.Animations.cs index db66066c8..540a4bf06 100644 --- a/src/officecli/Handlers/Pptx/PowerPointHandler.Animations.cs +++ b/src/officecli/Handlers/Pptx/PowerPointHandler.Animations.cs @@ -505,8 +505,7 @@ private static void ApplyShapeAnimation(SlidePart slidePart, Shape shape, string bldLst.AppendChild(new BuildParagraph { ShapeId = shapeIdStr, - GroupId = new UInt32Value((uint)grpId), - Build = ParagraphBuildValues.AllAtOnce + GroupId = new UInt32Value((uint)grpId) }); } } diff --git a/src/officecli/Handlers/Pptx/PowerPointHandler.Background.cs b/src/officecli/Handlers/Pptx/PowerPointHandler.Background.cs index c2b29e36f..f3d967ed1 100644 --- a/src/officecli/Handlers/Pptx/PowerPointHandler.Background.cs +++ b/src/officecli/Handlers/Pptx/PowerPointHandler.Background.cs @@ -260,10 +260,13 @@ internal static Drawing.GradientFill BuildGradientFill(string value) } else { - // For linear: last segment is angle if it's a short integer + // For linear: last segment is angle if it's a short integer (with optional "deg" suffix) + var lastPart = colorParts.Last(); + var angleCandidate = lastPart.EndsWith("deg", StringComparison.OrdinalIgnoreCase) + ? lastPart[..^3] : lastPart; if (colorParts.Count >= 2 && - int.TryParse(colorParts.Last(), out var angleDeg) && - colorParts.Last().Length <= 3) + int.TryParse(angleCandidate, out var angleDeg) && + angleCandidate.Length <= 3) { angle = angleDeg * 60000; colorParts.RemoveAt(colorParts.Count - 1); diff --git a/src/officecli/Handlers/Pptx/PowerPointHandler.Chart.cs b/src/officecli/Handlers/Pptx/PowerPointHandler.Chart.cs index e77b3af3a..8ea8aec30 100644 --- a/src/officecli/Handlers/Pptx/PowerPointHandler.Chart.cs +++ b/src/officecli/Handlers/Pptx/PowerPointHandler.Chart.cs @@ -119,14 +119,17 @@ private static DocumentNode ChartToNode(GraphicFrame gf, SlidePart slidePart, in { var name = gf.NonVisualGraphicFrameProperties?.NonVisualDrawingProperties?.Name?.Value ?? "Chart"; + var chartPathSeg = BuildElementPathSegment("chart", gf, chartIdx); var node = new DocumentNode { - Path = $"/slide[{slideNum}]/chart[{chartIdx}]", + Path = $"/slide[{slideNum}]/{chartPathSeg}", Type = "chart", Preview = name }; node.Format["name"] = name; + var chartId = GetCNvPrId(gf); + if (chartId.HasValue) node.Format["id"] = chartId.Value; // Position (PPTX-specific: from GraphicFrame transform) var offset = gf.Transform?.Offset; diff --git a/src/officecli/Handlers/Pptx/PowerPointHandler.Fill.cs b/src/officecli/Handlers/Pptx/PowerPointHandler.Fill.cs index 8fb1305c6..a93e958b8 100644 --- a/src/officecli/Handlers/Pptx/PowerPointHandler.Fill.cs +++ b/src/officecli/Handlers/Pptx/PowerPointHandler.Fill.cs @@ -265,7 +265,9 @@ private static void ApplyListStyle(Drawing.ParagraphProperties pProps, string va break; case "none" or "false": pProps.AppendChild(new Drawing.NoBullet()); - break; + pProps.LeftMargin = null; + pProps.Indent = null; + return; default: if (value.Length <= 2) pProps.AppendChild(new Drawing.CharacterBullet { Char = value }); @@ -273,6 +275,12 @@ private static void ApplyListStyle(Drawing.ParagraphProperties pProps, string va throw new ArgumentException($"Invalid list style: {value}. Use: bullet, numbered, alpha, roman, none, or a single character"); break; } + + // Apply default hanging indent for bullet/numbered lists (matches PowerPoint defaults) + if (pProps.LeftMargin == null) + pProps.LeftMargin = 457200; // 0.5 inch + if (pProps.Indent == null) + pProps.Indent = -457200; // hanging indent } private static Drawing.ShapeTypeValues ParsePresetShape(string name) => diff --git a/src/officecli/Handlers/Pptx/PowerPointHandler.Helpers.cs b/src/officecli/Handlers/Pptx/PowerPointHandler.Helpers.cs index a8dbf1b1c..d673b7be8 100644 --- a/src/officecli/Handlers/Pptx/PowerPointHandler.Helpers.cs +++ b/src/officecli/Handlers/Pptx/PowerPointHandler.Helpers.cs @@ -4,6 +4,7 @@ using System.Text; using System.Text.RegularExpressions; using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Presentation; using OfficeCli.Core; using Drawing = DocumentFormat.OpenXml.Drawing; @@ -29,6 +30,233 @@ private static string NormalizeCellPath(string path) return Regex.Replace(path, @"cell\[(\d+),\s*(\d+)\]", m => $"tr[{m.Groups[1].Value}]/tc[{m.Groups[2].Value}]"); } + /// + /// Resolve InsertPosition (After/Before anchor path) to a 0-based int? index for PPT. + /// Anchor path can be full (/slide[1]/shape[@id=X]) or short (shape[@id=X]). + /// + /// Sentinel value for find: anchor resolution. + private const int FindAnchorIndex = -99999; + + private int? ResolveAnchorPosition(string parentPath, InsertPosition? position) + { + if (position == null) return null; + if (position.Index.HasValue) return position.Index; + + var anchorPath = position.After ?? position.Before!; + + // Catch bare attribute selector without element wrapper, e.g. @id=XXX instead of shape[@id=XXX] + if (Regex.IsMatch(anchorPath, @"^@(\w+)=(.+)$")) + throw new ArgumentException($"Invalid anchor path \"{anchorPath}\". Did you mean: shape[{anchorPath}]?"); + + // Handle find: prefix — text-based anchoring + if (anchorPath.StartsWith("find:", StringComparison.OrdinalIgnoreCase)) + return FindAnchorIndex; + + // Normalize: if short form, prepend parentPath + if (!anchorPath.StartsWith("/")) + anchorPath = parentPath.TrimEnd('/') + "/" + anchorPath; + + // Resolve @id=/@name= in the anchor path + anchorPath = ResolveIdPath(anchorPath); + + // For slide-level anchors (/slide[N]) + var slideMatch = Regex.Match(anchorPath, @"^/slide\[(\d+)\]$"); + if (slideMatch.Success) + { + var slideIdx = int.Parse(slideMatch.Groups[1].Value) - 1; // 0-based + var slideCount = GetSlideParts().Count(); + if (slideIdx < 0 || slideIdx >= slideCount) + throw new ArgumentException($"Anchor slide not found: {anchorPath} (total slides: {slideCount})"); + if (position.After != null) + return slideIdx + 1 >= slideCount ? null : slideIdx + 1; + else + return slideIdx; + } + + // For element-level anchors (/slide[N]/shape[M], /slide[N]/table[M], etc.) + var elemMatch = Regex.Match(anchorPath, @"^/slide\[(\d+)\]/(\w+)\[(\d+)\]$"); + if (elemMatch.Success) + { + var slideIdx = int.Parse(elemMatch.Groups[1].Value); + var elemIdx = int.Parse(elemMatch.Groups[3].Value) - 1; // 0-based + // Validate that the anchor element exists + var slideParts = GetSlideParts().ToList(); + if (slideIdx < 1 || slideIdx > slideParts.Count) + throw new ArgumentException($"Anchor slide not found: {anchorPath} (total slides: {slideParts.Count})"); + var anchorShapeTree = GetSlide(slideParts[slideIdx - 1]).CommonSlideData?.ShapeTree; + if (anchorShapeTree != null) + { + var contentChildren = anchorShapeTree.ChildElements + .Where(e => e is not NonVisualGroupShapeProperties && e is not GroupShapeProperties) + .ToList(); + if (elemIdx < 0 || elemIdx >= contentChildren.Count) + throw new ArgumentException($"Anchor element not found: {anchorPath} (total elements on slide: {contentChildren.Count})"); + } + if (position.After != null) + return elemIdx + 1; // InsertAtPosition handles bounds + else + return elemIdx; + } + + throw new ArgumentException($"Cannot resolve anchor path: {anchorPath}"); + } + + /// + /// Resolve @id= and @name= attribute selectors in a PPT path to positional indices. + /// E.g. /slide[1]/shape[@id=5] → /slide[1]/shape[N] where N is the positional index of shape with cNvPr.Id=5. + /// + private string ResolveIdPath(string path) + { + // Quick check: if no [@, nothing to resolve + if (!path.Contains("[@")) + return path; + + return Regex.Replace(path, @"(\w+)\[@(id|name)=([^\]]+)\]", m => + { + var elementType = m.Groups[1].Value.ToLowerInvariant(); + var attrName = m.Groups[2].Value.ToLowerInvariant(); + var attrValue = m.Groups[3].Value.Trim('"', '\'', ' '); + + // Extract slide index from the path prefix before this match + var prefix = path[..m.Index]; + var slideMatch = Regex.Match(prefix, @"/slide\[(\d+)\]"); + if (!slideMatch.Success) + throw new ArgumentException($"Cannot resolve @{attrName}= outside of a slide context: {path}"); + var slideIdx = int.Parse(slideMatch.Groups[1].Value); + + var slideParts = GetSlideParts().ToList(); + if (slideIdx < 1 || slideIdx > slideParts.Count) + throw new ArgumentException($"Slide {slideIdx} not found (total: {slideParts.Count})"); + var slidePart = slideParts[slideIdx - 1]; + var shapeTree = GetSlide(slidePart).CommonSlideData?.ShapeTree; + if (shapeTree == null) + throw new ArgumentException($"Slide {slideIdx} has no shape tree"); + + var positionalIdx = FindElementByAttr(shapeTree, elementType, attrName, attrValue); + return $"{m.Groups[1].Value}[{positionalIdx}]"; + }); + } + + /// + /// Find the 1-based positional index of an element within its type group by @id= or @name=. + /// + private static int FindElementByAttr(ShapeTree shapeTree, string elementType, string attrName, string attrValue) + { + var elements = elementType switch + { + "shape" or "textbox" or "title" or "equation" => shapeTree.Elements() + .Select(s => (element: (OpenXmlElement)s, nvPr: s.NonVisualShapeProperties?.NonVisualDrawingProperties)).ToList(), + "picture" or "pic" or "image" => shapeTree.Elements() + .Select(p => (element: (OpenXmlElement)p, nvPr: p.NonVisualPictureProperties?.NonVisualDrawingProperties)).ToList(), + "table" => shapeTree.Elements() + .Where(gf => gf.Descendants().Any()) + .Select(gf => (element: (OpenXmlElement)gf, nvPr: gf.NonVisualGraphicFrameProperties?.NonVisualDrawingProperties)).ToList(), + "chart" => shapeTree.Elements() + .Where(gf => gf.Descendants().Any() || IsExtendedChartFrame(gf)) + .Select(gf => (element: (OpenXmlElement)gf, nvPr: gf.NonVisualGraphicFrameProperties?.NonVisualDrawingProperties)).ToList(), + "connector" or "connection" => shapeTree.Elements() + .Select(c => (element: (OpenXmlElement)c, nvPr: c.NonVisualConnectionShapeProperties?.NonVisualDrawingProperties)).ToList(), + "group" => shapeTree.Elements() + .Select(g => (element: (OpenXmlElement)g, nvPr: g.NonVisualGroupShapeProperties?.NonVisualDrawingProperties)).ToList(), + "video" or "audio" => shapeTree.Elements() + .Select(p => (element: (OpenXmlElement)p, nvPr: p.NonVisualPictureProperties?.NonVisualDrawingProperties)).ToList(), + _ => throw new ArgumentException($"Unknown element type '{elementType}' for @{attrName}= addressing") + }; + + for (int i = 0; i < elements.Count; i++) + { + var nvPr = elements[i].nvPr; + if (nvPr == null) continue; + + if (attrName == "id" && nvPr.Id?.Value.ToString() == attrValue) + return i + 1; + if (attrName == "name" && MatchesShapeName(nvPr.Name?.Value, attrValue)) + return i + 1; + } + + throw new ArgumentException($"No {elementType} found with @{attrName}={attrValue}"); + } + + /// + /// Scan all slides to initialize the global shape ID counter. + /// Called once on document open (editable mode). + /// + private void InitShapeIdCounter() + { + const uint minStartId = 10000; + _usedShapeIds = new HashSet(); + uint maxId = minStartId - 1; + + foreach (var slidePart in GetSlideParts()) + { + var shapeTree = GetSlide(slidePart).CommonSlideData?.ShapeTree; + if (shapeTree == null) continue; + foreach (var nvPr in shapeTree.Descendants()) + { + if (nvPr.Id?.HasValue == true) + { + _usedShapeIds.Add(nvPr.Id.Value); + if (nvPr.Id.Value > maxId) + maxId = nvPr.Id.Value; + } + } + } + + _nextShapeId = maxId + 1; + if (_nextShapeId < maxId) // uint overflow + _nextShapeId = minStartId; + } + + /// + /// Generate a unique deterministic cNvPr.Id across all slides. + /// Uses global instance counter for reproducible, non-repeating IDs. + /// + private uint GenerateUniqueShapeId(ShapeTree shapeTree) + { + const uint minStartId = 10000; + var startId = _nextShapeId; + while (true) + { + var id = _nextShapeId; + _nextShapeId++; + if (_nextShapeId < id) // uint overflow + _nextShapeId = minStartId; + if (_usedShapeIds.Add(id)) + return id; + if (_nextShapeId == startId) + throw new InvalidOperationException("No available shape ID slots"); + } + } + + /// + /// Get the cNvPr.Id for an element, or null if not available. + /// Works for Shape, Picture, GraphicFrame, ConnectionShape, GroupShape. + /// + internal static uint? GetCNvPrId(OpenXmlElement element) + { + return element switch + { + Shape s => s.NonVisualShapeProperties?.NonVisualDrawingProperties?.Id?.Value, + Picture p => p.NonVisualPictureProperties?.NonVisualDrawingProperties?.Id?.Value, + GraphicFrame gf => gf.NonVisualGraphicFrameProperties?.NonVisualDrawingProperties?.Id?.Value, + ConnectionShape c => c.NonVisualConnectionShapeProperties?.NonVisualDrawingProperties?.Id?.Value, + GroupShape g => g.NonVisualGroupShapeProperties?.NonVisualDrawingProperties?.Id?.Value, + _ => null + }; + } + + /// + /// Build a path segment using @id= if the element has a cNvPr.Id, otherwise use positional index. + /// E.g. "shape[@id=5]" or "shape[2]". + /// + internal static string BuildElementPathSegment(string elementType, OpenXmlElement element, int positionalIndex) + { + var id = GetCNvPrId(element); + return id.HasValue + ? $"{elementType}[@id={id.Value}]" + : $"{elementType}[{positionalIndex}]"; + } + /// /// Find existing Transition element or create one, avoiding duplicates with unknown-element transitions. /// @@ -573,6 +801,81 @@ private DocumentNode ZoomToNode(OpenXmlElement acElement, int slideNum, int zoom return node; } + /// + /// Schema order for DrawingML CT_TextCharacterProperties children (a:rPr / a:endParaRPr / a:defRPr). + /// Source: Open-XML-SDK CompositeParticle definition of TextCharacterPropertiesType. + /// Children must appear in this order or OpenXmlValidator emits schema warnings and + /// PowerPoint silently drops the out-of-order ones. + /// + private static readonly (Type type, int order)[] DrawingRunPropChildOrder = new (Type, int)[] + { + (typeof(Drawing.Outline), 1), // ln + (typeof(Drawing.NoFill), 2), // noFill + (typeof(Drawing.SolidFill), 2), // solidFill + (typeof(Drawing.GradientFill), 2), // gradFill + (typeof(Drawing.BlipFill), 2), // blipFill + (typeof(Drawing.PatternFill), 2), // pattFill + (typeof(Drawing.GroupFill), 2), // grpFill + (typeof(Drawing.EffectList), 3), // effectLst + (typeof(Drawing.EffectDag), 3), // effectDag + (typeof(Drawing.Highlight), 4), // highlight + (typeof(Drawing.UnderlineFollowsText), 5), // uLnTx + (typeof(Drawing.Underline), 5), // uLn + (typeof(Drawing.UnderlineFillText), 6), // uFillTx + (typeof(Drawing.UnderlineFill), 6), // uFill + (typeof(Drawing.LatinFont), 7), // latin + (typeof(Drawing.EastAsianFont), 8), // ea + (typeof(Drawing.ComplexScriptFont), 9), // cs + (typeof(Drawing.SymbolFont), 10), // sym + (typeof(Drawing.HyperlinkOnClick), 11), // hlinkClick + (typeof(Drawing.HyperlinkOnMouseOver),12), // hlinkMouseOver + (typeof(Drawing.RightToLeft), 13), // rtl + (typeof(Drawing.ExtensionList), 14), // extLst + }; + + /// + /// Reorder children of a DrawingML RunProperties / EndParagraphRunProperties / + /// DefaultRunProperties element into schema-valid order. + /// Stable within the same order bucket to preserve relative order of existing fills. + /// Unknown child types are pushed to the end (preserved but last). + /// + internal static void ReorderDrawingRunProperties(OpenXmlCompositeElement rPr) + { + if (rPr == null || !rPr.HasChildren) return; + + int OrderOf(OpenXmlElement el) + { + var t = el.GetType(); + foreach (var (type, order) in DrawingRunPropChildOrder) + if (type == t) return order; + return int.MaxValue; + } + + var children = rPr.ChildElements.ToList(); + // Check if already sorted — avoid unnecessary reflows + bool needsReorder = false; + for (int i = 1; i < children.Count; i++) + { + if (OrderOf(children[i]) < OrderOf(children[i - 1])) + { + needsReorder = true; + break; + } + } + if (!needsReorder) return; + + // Stable sort by schema order + var sorted = children + .Select((el, idx) => (el, ord: OrderOf(el), idx)) + .OrderBy(t => t.ord) + .ThenBy(t => t.idx) + .Select(t => t.el) + .ToList(); + + foreach (var c in children) c.Remove(); + foreach (var c in sorted) rPr.AppendChild(c); + } + /// /// Read a GradientFill element and return a string representation (C1-C2[-angle] or radial:C1-C2[-focus]). /// @@ -875,38 +1178,585 @@ private static string ResolveTableStyleId(string value) /// /// Find and replace text across all slides. Returns the number of replacements made. /// - private int FindAndReplace(string find, string replace) + // ==================== Find / Format / Replace ==================== + + /// + /// Build a flat list of (Run, Text, charStart, charEnd) spans for a PPT paragraph. + /// + private static List<(Drawing.Run Run, Drawing.Text TextElement, int Start, int End)> BuildPptRunTexts(Drawing.Paragraph para) { - if (string.IsNullOrEmpty(find)) return 0; - int totalCount = 0; + var runTexts = new List<(Drawing.Run Run, Drawing.Text TextElement, int Start, int End)>(); + int pos = 0; + foreach (var run in para.Descendants()) + { + var text = run.GetFirstChild(); + var len = text?.Text?.Length ?? 0; + if (len > 0) + runTexts.Add((run, text!, pos, pos + len)); + pos += len; + } + return runTexts; + } + + /// + /// Parse a find pattern: plain text or regex (r"..." prefix). + /// + private static (string Pattern, bool IsRegex) ParseFindPattern(string value) + { + if (value.Length >= 3 && value[0] == 'r' && (value[1] == '"' || value[1] == '\'')) + { + var quote = value[1]; + var endIdx = value.LastIndexOf(quote); + if (endIdx > 1) + return (value[2..endIdx], true); + } + return (value, false); + } + + /// + /// Find all match ranges in fullText using either plain text or regex. + /// + private static List<(int Start, int Length)> FindMatchRanges(string fullText, string pattern, bool isRegex) + { + var ranges = new List<(int Start, int Length)>(); + if (isRegex) + { + try + { + foreach (Match m in Regex.Matches(fullText, pattern)) + { + if (m.Length > 0) + ranges.Add((m.Index, m.Length)); + } + } + catch (RegexParseException ex) + { + throw new ArgumentException($"Invalid regex pattern '{pattern}': {ex.Message}", ex); + } + } + else + { + int idx = 0; + while ((idx = fullText.IndexOf(pattern, idx, StringComparison.Ordinal)) >= 0) + { + ranges.Add((idx, pattern.Length)); + idx += pattern.Length; + } + } + return ranges; + } + + /// + /// Split a PPT run at a character offset. Returns the new right-side run. + /// RunProperties are deep-cloned. + /// + private static Drawing.Run SplitPptRunAtOffset(Drawing.Run run, int charOffset) + { + var text = run.GetFirstChild(); + if (text?.Text == null || charOffset <= 0 || charOffset >= text.Text.Length) + return run; + + var leftText = text.Text[..charOffset]; + var rightText = text.Text[charOffset..]; + + // Clone the run for the right side + var rightRun = (Drawing.Run)run.CloneNode(true); + + // Set text + text.Text = leftText; + var rightTextElem = rightRun.GetFirstChild(); + if (rightTextElem != null) rightTextElem.Text = rightText; + + // Insert after original + run.InsertAfterSelf(rightRun); + return rightRun; + } + + /// + /// Split runs in a PPT paragraph so that [charStart, charEnd) is covered by dedicated runs. + /// Returns the runs covering that range. + /// + private static List SplitPptRunsAtRange(Drawing.Paragraph para, int charStart, int charEnd) + { + // Split at charEnd first + var runTexts = BuildPptRunTexts(para); + foreach (var rt in runTexts) + { + if (charEnd > rt.Start && charEnd < rt.End) + { + SplitPptRunAtOffset(rt.Run, charEnd - rt.Start); + break; + } + } + + // Rebuild, then split at charStart + runTexts = BuildPptRunTexts(para); + foreach (var rt in runTexts) + { + if (charStart > rt.Start && charStart < rt.End) + { + SplitPptRunAtOffset(rt.Run, charStart - rt.Start); + break; + } + } + + // Collect runs covering [charStart, charEnd) + runTexts = BuildPptRunTexts(para); + var result = new List(); + foreach (var rt in runTexts) + { + if (rt.Start >= charStart && rt.End <= charEnd) + result.Add(rt.Run); + } + return result; + } + + /// + /// Apply run-level formatting to a PPT run's RunProperties. + /// + private static void ApplyPptRunFormatting(Drawing.Run run, string key, string value, Shape? shape = null) + { + var rPr = run.RunProperties ?? run.PrependChild(new Drawing.RunProperties()); + switch (key.ToLowerInvariant()) + { + case "bold": + rPr.Bold = IsTruthy(value); + break; + case "italic": + rPr.Italic = IsTruthy(value); + break; + case "size": + rPr.FontSize = (int)Math.Round(ParseFontSize(value) * 100, MidpointRounding.AwayFromZero); + break; + case "color": + rPr.RemoveAllChildren(); + rPr.PrependChild(BuildSolidFill(value)); + break; + case "font": + rPr.RemoveAllChildren(); + rPr.RemoveAllChildren(); + rPr.AppendChild(new Drawing.LatinFont { Typeface = value }); + rPr.AppendChild(new Drawing.EastAsianFont { Typeface = value }); + break; + case "underline": + var ulVal = value.ToLowerInvariant() switch + { + "true" or "single" => Drawing.TextUnderlineValues.Single, + "double" => Drawing.TextUnderlineValues.Double, + "heavy" => Drawing.TextUnderlineValues.Heavy, + "false" or "none" => Drawing.TextUnderlineValues.None, + _ => new Drawing.TextUnderlineValues(value) + }; + rPr.Underline = ulVal; + break; + case "strikethrough" or "strike": + var stVal = value.ToLowerInvariant() switch + { + "true" or "single" => Drawing.TextStrikeValues.SingleStrike, + "double" => Drawing.TextStrikeValues.DoubleStrike, + "false" or "none" => Drawing.TextStrikeValues.NoStrike, + _ => new Drawing.TextStrikeValues(value) + }; + rPr.Strike = stVal; + break; + case "superscript": + rPr.Baseline = IsTruthy(value) ? 30000 : 0; + break; + case "subscript": + rPr.Baseline = IsTruthy(value) ? -25000 : 0; + break; + case "charspacing" or "spacing" or "letterspacing": + var csPt = value.EndsWith("pt", StringComparison.OrdinalIgnoreCase) + ? ParseHelpers.SafeParseDouble(value[..^2], "charspacing") + : ParseHelpers.SafeParseDouble(value, "charspacing"); + rPr.Spacing = (int)Math.Round(csPt * 100, MidpointRounding.AwayFromZero); + break; + case "highlight": + rPr.RemoveAllChildren(); + if (!string.Equals(value, "none", StringComparison.OrdinalIgnoreCase) && + !string.Equals(value, "false", StringComparison.OrdinalIgnoreCase)) + { + var hl = new Drawing.Highlight(); + hl.AppendChild(BuildSolidFillColor(value)); + rPr.AppendChild(hl); + } + break; + } + } + + /// + /// Process find in a single PPT paragraph: replace text and/or apply formatting. + /// + private static int ProcessFindInPptParagraph( + Drawing.Paragraph para, + string pattern, + bool isRegex, + string? replace, + Dictionary? formatProps, + Shape? shape = null) + { + var runTexts = BuildPptRunTexts(para); + if (runTexts.Count == 0) return 0; - var presentationPart = _doc.PresentationPart; - if (presentationPart == null) return 0; + var fullText = string.Concat(runTexts.Select(rt => rt.TextElement.Text)); + var matches = FindMatchRanges(fullText, pattern, isRegex); + if (matches.Count == 0) return 0; - foreach (var slidePart in presentationPart.SlideParts) + for (int i = matches.Count - 1; i >= 0; i--) { - var slide = slidePart.Slide; - if (slide == null) continue; + var (matchStart, matchLen) = matches[i]; + var matchEnd = matchStart + matchLen; - foreach (var text in slide.Descendants()) + if (replace != null) { - if (text.Text != null && text.Text.Contains(find, StringComparison.Ordinal)) + // Replace text in affected runs + var currentRunTexts = BuildPptRunTexts(para); + bool first = true; + foreach (var rt in currentRunTexts) { - int count = 0; - int idx = 0; - while ((idx = text.Text.IndexOf(find, idx, StringComparison.Ordinal)) >= 0) + if (rt.End <= matchStart || rt.Start >= matchEnd) + continue; + + var textStr = rt.TextElement.Text ?? ""; + var localStart = Math.Max(0, matchStart - rt.Start); + var localEnd = Math.Min(textStr.Length, matchEnd - rt.Start); + + if (first) + { + rt.TextElement.Text = textStr[..localStart] + replace + textStr[localEnd..]; + first = false; + } + else { - count++; - idx += find.Length; + rt.TextElement.Text = textStr[..Math.Max(0, matchStart - rt.Start)] + textStr[localEnd..]; } - text.Text = text.Text.Replace(find, replace, StringComparison.Ordinal); - totalCount += count; } + + if (formatProps != null && formatProps.Count > 0 && replace.Length > 0) + { + var replacedEnd = matchStart + replace.Length; + var targetRuns = SplitPptRunsAtRange(para, matchStart, replacedEnd); + foreach (var run in targetRuns) + foreach (var (key, value) in formatProps) + ApplyPptRunFormatting(run, key, value, shape); + } + } + else if (formatProps != null && formatProps.Count > 0) + { + var targetRuns = SplitPptRunsAtRange(para, matchStart, matchEnd); + foreach (var run in targetRuns) + foreach (var (key, value) in formatProps) + ApplyPptRunFormatting(run, key, value, shape); } + } + + return matches.Count; + } - slidePart.Slide!.Save(); + /// + /// Unified find across all paragraphs in the resolved scope. + /// + private int ProcessPptFind(string path, string findValue, string? replace, Dictionary formatProps) + { + var (pattern, isRegex) = ParseFindPattern(findValue); + if (string.IsNullOrEmpty(pattern) && !isRegex) return 0; + + int totalCount = 0; + + if (path is "/" or "" or "/presentation") + { + // All slides + foreach (var slidePart in _doc.PresentationPart?.SlideParts ?? Enumerable.Empty()) + { + var slide = slidePart.Slide; + if (slide == null) continue; + foreach (var para in slide.Descendants()) + totalCount += ProcessFindInPptParagraph(para, pattern, isRegex, replace, + formatProps.Count > 0 ? formatProps : null); + slidePart.Slide!.Save(); + } + } + else + { + // Path-scoped: resolve to specific paragraphs + var paragraphs = ResolvePptParagraphsForFind(path); + Shape? contextShape = null; + // Try to resolve shape for color context + var shapeMatch = Regex.Match(path, @"^/slide\[(\d+)\]/(\w+)\[(\d+)\]"); + if (shapeMatch.Success) + { + try + { + var (_, shape) = ResolveShape(int.Parse(shapeMatch.Groups[1].Value), int.Parse(shapeMatch.Groups[3].Value)); + contextShape = shape; + } + catch { } + } + + foreach (var para in paragraphs) + totalCount += ProcessFindInPptParagraph(para, pattern, isRegex, replace, + formatProps.Count > 0 ? formatProps : null, contextShape); + + // Save affected slides + foreach (var slidePart in _doc.PresentationPart?.SlideParts ?? Enumerable.Empty()) + slidePart.Slide?.Save(); } return totalCount; } + + /// + /// Resolve paragraphs from a PPT path for find operations. + /// + private List ResolvePptParagraphsForFind(string path) + { + var paragraphs = new List(); + + // /slide[N]/notes → paragraphs in notes slide + var notesMatch = Regex.Match(path, @"^/slide\[(\d+)\]/notes$", RegexOptions.IgnoreCase); + if (notesMatch.Success) + { + var slideIdx = int.Parse(notesMatch.Groups[1].Value); + var slideParts = GetSlideParts().ToList(); + if (slideIdx >= 1 && slideIdx <= slideParts.Count) + { + var notesPart = slideParts[slideIdx - 1].NotesSlidePart; + if (notesPart?.NotesSlide != null) + paragraphs.AddRange(notesPart.NotesSlide.Descendants()); + } + return paragraphs; + } + + // /slide[N]/table[M]/tr[R]/tc[C] or deeper table paths → paragraphs in table cell + var tableCellMatch = Regex.Match(path, @"^/slide\[(\d+)\]/table\[(\d+)\]/tr\[(\d+)\]/tc\[(\d+)\]"); + if (tableCellMatch.Success) + { + var slideIdx = int.Parse(tableCellMatch.Groups[1].Value); + var tableIdx = int.Parse(tableCellMatch.Groups[2].Value); + var rowIdx = int.Parse(tableCellMatch.Groups[3].Value); + var colIdx = int.Parse(tableCellMatch.Groups[4].Value); + var slideParts = GetSlideParts().ToList(); + if (slideIdx >= 1 && slideIdx <= slideParts.Count) + { + var slide = slideParts[slideIdx - 1].Slide; + var tables = slide?.Descendants().ToList(); + if (tables != null && tableIdx >= 1 && tableIdx <= tables.Count) + { + var rows = tables[tableIdx - 1].Elements().ToList(); + if (rowIdx >= 1 && rowIdx <= rows.Count) + { + var cells = rows[rowIdx - 1].Elements().ToList(); + if (colIdx >= 1 && colIdx <= cells.Count) + paragraphs.AddRange(cells[colIdx - 1].Descendants()); + } + } + } + return paragraphs; + } + + // /slide[N]/table[M] → all paragraphs in table + var tableMatch = Regex.Match(path, @"^/slide\[(\d+)\]/table\[(\d+)\]$"); + if (tableMatch.Success) + { + var slideIdx = int.Parse(tableMatch.Groups[1].Value); + var tableIdx = int.Parse(tableMatch.Groups[2].Value); + var slideParts = GetSlideParts().ToList(); + if (slideIdx >= 1 && slideIdx <= slideParts.Count) + { + var slide = slideParts[slideIdx - 1].Slide; + var tables = slide?.Descendants().ToList(); + if (tables != null && tableIdx >= 1 && tableIdx <= tables.Count) + paragraphs.AddRange(tables[tableIdx - 1].Descendants()); + } + return paragraphs; + } + + // /slide[N]/shape[M] or /slide[N]/placeholder[M] → paragraphs in shape + var shapeMatch = Regex.Match(path, @"^/slide\[(\d+)\]/\w+\[(\d+)\]"); + if (shapeMatch.Success) + { + var slideIdx = int.Parse(shapeMatch.Groups[1].Value); + var shapeIdx = int.Parse(shapeMatch.Groups[2].Value); + try + { + var (_, shape) = ResolveShape(slideIdx, shapeIdx); + if (shape.TextBody != null) + paragraphs.AddRange(shape.TextBody.Elements()); + } + catch { } + return paragraphs; + } + + // /slide[N] → all paragraphs in slide + var slideOnlyMatch = Regex.Match(path, @"^/slide\[(\d+)\]$"); + if (slideOnlyMatch.Success) + { + var slideIdx = int.Parse(slideOnlyMatch.Groups[1].Value); + var slideParts = GetSlideParts().ToList(); + if (slideIdx >= 1 && slideIdx <= slideParts.Count) + { + var slide = slideParts[slideIdx - 1].Slide; + if (slide != null) + paragraphs.AddRange(slide.Descendants()); + } + return paragraphs; + } + + // Fallback: all slides + foreach (var slidePart in _doc.PresentationPart?.SlideParts ?? Enumerable.Empty()) + { + if (slidePart.Slide != null) + paragraphs.AddRange(slidePart.Slide.Descendants()); + } + return paragraphs; + } + + /// + /// Build a color element for PPT highlight from a color value. + /// + private static Drawing.RgbColorModelHex BuildSolidFillColor(string value) + { + var hex = ParseHelpers.NormalizeArgbColor(value); + return new Drawing.RgbColorModelHex { Val = hex }; + } + + /// + /// Add an element at a text-find position within a PPT paragraph. + /// For PPT, this only supports inline types (run) — splits the run at the find position. + /// + private string AddPptAtFindPosition( + string parentPath, + string type, + string findValue, + bool isAfter, + Dictionary properties) + { + // Resolve paragraphs from parent path + var paragraphs = ResolvePptParagraphsForFind(parentPath); + if (paragraphs.Count == 0) + throw new ArgumentException($"No paragraphs found at path: {parentPath}"); + + // Support regex=true prop as alternative to r"..." prefix. + // CONSISTENCY(find-regex): mirror of WordHandler.Set.cs:60-61. grep + // "CONSISTENCY(find-regex)" for every project-wide call site. + if (properties.TryGetValue("regex", out var regexFlag) && ParseHelpers.IsTruthySafe(regexFlag) && !findValue.StartsWith("r\"") && !findValue.StartsWith("r'")) + findValue = $"r\"{findValue}\""; + + var (pattern, isRegex) = ParseFindPattern(findValue); + + // Find first match in any paragraph + Drawing.Paragraph? targetPara = null; + int splitPoint = -1; + + foreach (var para in paragraphs) + { + var runTexts = BuildPptRunTexts(para); + if (runTexts.Count == 0) continue; + var fullText = string.Concat(runTexts.Select(rt => rt.TextElement.Text)); + var matches = FindMatchRanges(fullText, pattern, isRegex); + if (matches.Count > 0) + { + targetPara = para; + var (matchStart, matchLen) = matches[0]; + splitPoint = isAfter ? matchStart + matchLen : matchStart; + break; + } + } + + if (targetPara == null) + throw new ArgumentException($"Text '{findValue}' not found in paragraphs at {parentPath}."); + + // Split run at the position + var rts = BuildPptRunTexts(targetPara); + Drawing.Run? insertAfterRun = null; + + foreach (var rt in rts) + { + if (splitPoint >= rt.Start && splitPoint <= rt.End) + { + if (splitPoint == rt.Start) + insertAfterRun = rt.Run.PreviousSibling(); + else if (splitPoint == rt.End) + insertAfterRun = rt.Run; + else + { + SplitPptRunAtOffset(rt.Run, splitPoint - rt.Start); + insertAfterRun = rt.Run; + } + break; + } + } + + // Build and insert new run directly into targetPara (avoids path-based routing + // that only supports /slide[N]/shape[M] paths, not table cell or other paths). + var newRun = BuildPptRunFromProperties(properties); + + if (insertAfterRun != null) + insertAfterRun.InsertAfterSelf(newRun); + else + { + // Insert at beginning: before first run or end-paragraph props + var firstChild = targetPara.FirstChild; + if (firstChild != null) + firstChild.InsertBeforeSelf(newRun); + else + targetPara.Append(newRun); + } + + // Save all slides + foreach (var slidePart in _doc.PresentationPart?.SlideParts ?? Enumerable.Empty()) + slidePart.Slide?.Save(); + + return parentPath; + } + + /// + /// Build a Drawing.Run from a properties dictionary (text, bold, italic, color, size, font, etc.) + /// + private static Drawing.Run BuildPptRunFromProperties(Dictionary properties) + { + var newRun = new Drawing.Run(); + var rProps = new Drawing.RunProperties { Language = "en-US" }; + + if (properties.TryGetValue("size", out var rSize)) + rProps.FontSize = (int)Math.Round(ParseFontSize(rSize) * 100); + if (properties.TryGetValue("bold", out var rBold)) + rProps.Bold = IsTruthy(rBold); + if (properties.TryGetValue("italic", out var rItalic)) + rProps.Italic = IsTruthy(rItalic); + if (properties.TryGetValue("underline", out var rUnderline)) + rProps.Underline = rUnderline.ToLowerInvariant() switch + { + "true" or "single" or "sng" => Drawing.TextUnderlineValues.Single, + "double" or "dbl" => Drawing.TextUnderlineValues.Double, + "heavy" => Drawing.TextUnderlineValues.Heavy, + "dotted" => Drawing.TextUnderlineValues.Dotted, + "dash" => Drawing.TextUnderlineValues.Dash, + "wavy" => Drawing.TextUnderlineValues.Wavy, + "false" or "none" => Drawing.TextUnderlineValues.None, + _ => throw new ArgumentException($"Invalid underline value: '{rUnderline}'.") + }; + if (properties.TryGetValue("strikethrough", out var rStrike) || properties.TryGetValue("strike", out rStrike)) + rProps.Strike = rStrike.ToLowerInvariant() switch + { + "true" or "single" => Drawing.TextStrikeValues.SingleStrike, + "double" => Drawing.TextStrikeValues.DoubleStrike, + "false" or "none" => Drawing.TextStrikeValues.NoStrike, + _ => throw new ArgumentException($"Invalid strikethrough value: '{rStrike}'.") + }; + if (properties.TryGetValue("color", out var rColor)) + rProps.AppendChild(BuildSolidFill(rColor)); + if (properties.TryGetValue("font", out var rFont)) + { + rProps.Append(new Drawing.LatinFont { Typeface = rFont }); + rProps.Append(new Drawing.EastAsianFont { Typeface = rFont }); + } + if (properties.TryGetValue("spacing", out var rSpacing) || properties.TryGetValue("charspacing", out rSpacing)) + rProps.Spacing = (int)(ParseHelpers.SafeParseDouble(rSpacing, "charspacing") * 100); + + newRun.RunProperties = rProps; + var runText = properties.GetValueOrDefault("text", ""); + newRun.Text = new Drawing.Text { Text = runText.Replace("\\n", "\n") }; + return newRun; + } } diff --git a/src/officecli/Handlers/Pptx/PowerPointHandler.HtmlPreview.Charts.cs b/src/officecli/Handlers/Pptx/PowerPointHandler.HtmlPreview.Charts.cs index 499f9a991..e51c4b7ae 100644 --- a/src/officecli/Handlers/Pptx/PowerPointHandler.HtmlPreview.Charts.cs +++ b/src/officecli/Handlers/Pptx/PowerPointHandler.HtmlPreview.Charts.cs @@ -17,8 +17,9 @@ public partial class PowerPointHandler // Chart text color — set per-chart, also used by SvgPreview private string _chartValueColor = "#D0D8E0"; - private void RenderChart(StringBuilder sb, GraphicFrame gf, SlidePart slidePart, Dictionary themeColors) + private void RenderChart(StringBuilder sb, GraphicFrame gf, SlidePart slidePart, Dictionary themeColors, string? dataPath = null) { + var dataPathAttr = string.IsNullOrEmpty(dataPath) ? "" : $" data-path=\"{HtmlEncode(dataPath)}\""; // Position and size from p:xfrm var pxfrm = gf.GetFirstChild(); var off = pxfrm?.GetFirstChild(); @@ -59,11 +60,12 @@ private void RenderChart(StringBuilder sb, GraphicFrame gf, SlidePart slidePart, // Create renderer with theme-derived colors var renderer = new ChartSvgRenderer { + ThemeAccentColors = ChartSvgRenderer.BuildThemeAccentColors(themeColors), ValueColor = chartTextColor, CatColor = chartTextColor, AxisColor = chartTextColor, - GridColor = isDarkText ? "#ccc" : "#333", - AxisLineColor = isDarkText ? "#aaa" : "#555", + GridColor = info.GridlineColor != null ? $"#{info.GridlineColor}" : (isDarkText ? "#ccc" : "#333"), + AxisLineColor = info.AxisLineColor != null ? $"#{info.AxisLineColor}" : (isDarkText ? "#aaa" : "#555"), ValFontPx = info.ValFontPx, CatFontPx = info.CatFontPx }; @@ -98,7 +100,7 @@ private void RenderChart(StringBuilder sb, GraphicFrame gf, SlidePart slidePart, // Container with chart background var bgStyle = info.ChartFillColor != null ? $"background:#{info.ChartFillColor};" : "background:transparent;"; - sb.AppendLine($"
    "); + sb.AppendLine($"
    "); // Title if (!string.IsNullOrEmpty(info.Title)) diff --git a/src/officecli/Handlers/Pptx/PowerPointHandler.HtmlPreview.Css.cs b/src/officecli/Handlers/Pptx/PowerPointHandler.HtmlPreview.Css.cs index a2cf06b1b..acdb951c9 100644 --- a/src/officecli/Handlers/Pptx/PowerPointHandler.HtmlPreview.Css.cs +++ b/src/officecli/Handlers/Pptx/PowerPointHandler.HtmlPreview.Css.cs @@ -246,7 +246,8 @@ private static (double widthPt, string dashType, string color)? ParseOutline(Dra { if (outline.GetFirstChild() != null) return null; - var color = ResolveFillColor(outline.GetFirstChild(), themeColors) ?? "#000000"; + var color = ResolveFillColor(outline.GetFirstChild(), themeColors) + ?? (themeColors.TryGetValue("dk1", out var dk1Hex) ? $"#{dk1Hex}" : "#000000"); var widthPt = outline.Width?.HasValue == true ? outline.Width.Value / 12700.0 : 1.0; if (widthPt < 0.5) widthPt = 0.5; @@ -335,9 +336,9 @@ private static string EffectListToShadowCss(Drawing.EffectList? effectList, Dict } } - var blurPt = shadow.BlurRadius?.HasValue == true ? shadow.BlurRadius.Value / 12700.0 : 4; - var distPt = shadow.Distance?.HasValue == true ? shadow.Distance.Value / 12700.0 : 3; - var angleDeg = shadow.Direction?.HasValue == true ? shadow.Direction.Value / 60000.0 : 45; + var blurPt = shadow.BlurRadius?.HasValue == true ? shadow.BlurRadius.Value / 12700.0 : 0; + var distPt = shadow.Distance?.HasValue == true ? shadow.Distance.Value / 12700.0 : 0; + var angleDeg = shadow.Direction?.HasValue == true ? shadow.Direction.Value / 60000.0 : 0; var angleRad = angleDeg * Math.PI / 180; var offsetX = distPt * Math.Cos(angleRad); var offsetY = distPt * Math.Sin(angleRad); @@ -380,7 +381,19 @@ private static string EffectListToGlowCss(Drawing.EffectList? effectList, Dictio } else { - color = $"rgba(0,120,215,{opacity:0.##})"; + // No color specified — use theme accent1 or transparent + var acc1 = themeColors.TryGetValue("accent1", out var a1) ? a1 : null; + if (acc1 != null) + { + var r = Convert.ToInt32(acc1[..2], 16); + var g = Convert.ToInt32(acc1[2..4], 16); + var b = Convert.ToInt32(acc1[4..6], 16); + color = $"rgba({r},{g},{b},{opacity:0.##})"; + } + else + { + color = $"rgba(0,0,0,0)"; // transparent — no glow visible + } } } diff --git a/src/officecli/Handlers/Pptx/PowerPointHandler.HtmlPreview.Shapes.cs b/src/officecli/Handlers/Pptx/PowerPointHandler.HtmlPreview.Shapes.cs index 5bc6bdea4..38382e71b 100644 --- a/src/officecli/Handlers/Pptx/PowerPointHandler.HtmlPreview.Shapes.cs +++ b/src/officecli/Handlers/Pptx/PowerPointHandler.HtmlPreview.Shapes.cs @@ -19,8 +19,10 @@ public partial class PowerPointHandler /// with the adjusted coordinates — the original element is NEVER modified. ///
    private static void RenderShape(StringBuilder sb, Shape shape, OpenXmlPart part, - Dictionary themeColors, (long x, long y, long cx, long cy)? overridePos = null) + Dictionary themeColors, (long x, long y, long cx, long cy)? overridePos = null, + string? dataPath = null) { + var dataPathAttr = string.IsNullOrEmpty(dataPath) ? "" : $" data-path=\"{HtmlEncode(dataPath)}\""; var xfrm = shape.ShapeProperties?.Transform2D; long x, y, cx, cy; @@ -183,7 +185,7 @@ private static void RenderShape(StringBuilder sb, Shape shape, OpenXmlPart part, var sp3d = shape.ShapeProperties?.GetFirstChild(); if (sp3d?.BevelTop != null) { - var bevelW = sp3d.BevelTop.Width?.HasValue == true ? sp3d.BevelTop.Width.Value / 12700.0 : 4; + var bevelW = sp3d.BevelTop.Width?.HasValue == true ? sp3d.BevelTop.Width.Value / 12700.0 : 6; // OOXML default 76200 EMU = 6pt var bW = Math.Max(1, bevelW * 0.5); styles.Add($"box-shadow:inset {bW:0.#}px {bW:0.#}px {bW * 1.5:0.#}px rgba(255,255,255,0.25),inset -{bW:0.#}px -{bW:0.#}px {bW * 1.5:0.#}px rgba(0,0,0,0.15)"); } @@ -198,9 +200,9 @@ private static void RenderShape(StringBuilder sb, Shape shape, OpenXmlPart part, long rIns = bodyPr?.RightInset?.Value ?? 91440; long bIns = bodyPr?.BottomInset?.Value ?? 45720; - // For clip-path shapes (non-rectangular), add extra inner padding + // For non-rectangular shapes (clip-path or border-radius), add extra inner padding // so text doesn't appear outside the visible shape area. - if (!string.IsNullOrEmpty(clipPathCss) && presetGeom?.Preset?.HasValue == true) + if ((!string.IsNullOrEmpty(clipPathCss) || !string.IsNullOrEmpty(borderRadiusCss)) && presetGeom?.Preset?.HasValue == true) { var (pctL, pctT, pctR, pctB) = GetShapeTextInsetPercent(presetGeom.Preset!.InnerText!); if (pctL > 0 || pctT > 0 || pctR > 0 || pctB > 0) @@ -252,7 +254,7 @@ private static void RenderShape(StringBuilder sb, Shape shape, OpenXmlPart part, else outerStyles.Add(s); } - sb.Append($"
    "); + sb.Append($"
    "); // Fill layer (clipped) if (fillStyles.Count > 0) sb.Append($"
    "); @@ -272,7 +274,7 @@ private static void RenderShape(StringBuilder sb, Shape shape, OpenXmlPart part, } else { - sb.Append($"
    "); + sb.Append($"
    "); } // Text content @@ -511,6 +513,7 @@ private static (long x, long y, long cx, long cy)? GetDefaultPlaceholderPosition "moon" => (0.15, 0, 0, 0), "cube" => (0, 0.08, 0.08, 0), "donut" => (0.25, 0.25, 0.25, 0.25), + "roundRect" => (0.07, 0.07, 0.07, 0.07), "wedgeRectCallout" or "wedgeRoundRectCallout" or "wedgeEllipseCallout" => (0.08, 0.08, 0.08, 0.08), "curvedRightArrow" or "curvedLeftArrow" or "curvedUpArrow" or "curvedDownArrow" => (0.12, 0.12, 0.12, 0.12), _ => (0, 0, 0, 0) @@ -622,8 +625,10 @@ private static (long x, long y, long cx, long cy)? GetDefaultPlaceholderPosition /// with the adjusted coordinates — the original element is NEVER modified. ///
    private static void RenderPicture(StringBuilder sb, Picture pic, OpenXmlPart slidePart, - Dictionary themeColors, (long x, long y, long cx, long cy)? overridePos = null) + Dictionary themeColors, (long x, long y, long cx, long cy)? overridePos = null, + string? dataPath = null) { + var dataPathAttr = string.IsNullOrEmpty(dataPath) ? "" : $" data-path=\"{HtmlEncode(dataPath)}\""; var xfrm = pic.ShapeProperties?.Transform2D; if (xfrm?.Offset == null || xfrm?.Extents == null) return; @@ -673,7 +678,7 @@ private static void RenderPicture(StringBuilder sb, Picture pic, OpenXmlPart sli styles.Add(geomCss); } - sb.Append($"
    "); + sb.Append($"
    "); // Extract image data var blipFill = pic.BlipFill; @@ -720,7 +725,7 @@ private static void RenderPicture(StringBuilder sb, Picture pic, OpenXmlPart sli // ==================== Connector Rendering ==================== - private static void RenderConnector(StringBuilder sb, ConnectionShape cxn, Dictionary themeColors) + private static void RenderConnector(StringBuilder sb, ConnectionShape cxn, Dictionary themeColors, string? dataPath = null) { var xfrm = cxn.ShapeProperties?.Transform2D; if (xfrm?.Offset == null || xfrm?.Extents == null) return; @@ -834,7 +839,8 @@ private static void RenderConnector(StringBuilder sb, ConnectionShape cxn, Dicti markerDefs = defs.ToString(); } - sb.AppendLine($"
    "); + var dataPathAttr = string.IsNullOrEmpty(dataPath) ? "" : $" data-path=\"{HtmlEncode(dataPath)}\""; + sb.AppendLine($"
    "); sb.AppendLine($" "); if (!string.IsNullOrEmpty(markerDefs)) sb.AppendLine($" {markerDefs}"); @@ -845,7 +851,7 @@ private static void RenderConnector(StringBuilder sb, ConnectionShape cxn, Dicti // ==================== Group Rendering ==================== - private void RenderGroup(StringBuilder sb, GroupShape grp, SlidePart slidePart, Dictionary themeColors) + private void RenderGroup(StringBuilder sb, GroupShape grp, SlidePart slidePart, Dictionary themeColors, string? dataPath = null) { var grpXfrm = grp.GroupShapeProperties?.TransformGroup; if (grpXfrm?.Offset == null || grpXfrm?.Extents == null) return; @@ -863,7 +869,12 @@ private void RenderGroup(StringBuilder sb, GroupShape grp, SlidePart slidePart, var offX = childOff?.X?.Value ?? 0; var offY = childOff?.Y?.Value ?? 0; - sb.AppendLine($"
    "); + // Group is selected as a whole. Children inside the group don't get their own + // data-path because nested @id= addressing isn't currently supported by + // ResolveIdPath — clicks inside walk up via closest('[data-path]') and select + // the group container. + var dataPathAttr = string.IsNullOrEmpty(dataPath) ? "" : $" data-path=\"{HtmlEncode(dataPath)}\""; + sb.AppendLine($"
    "); foreach (var child in grp.ChildElements) { diff --git a/src/officecli/Handlers/Pptx/PowerPointHandler.HtmlPreview.Tables.cs b/src/officecli/Handlers/Pptx/PowerPointHandler.HtmlPreview.Tables.cs index 080c63357..7e4e14958 100644 --- a/src/officecli/Handlers/Pptx/PowerPointHandler.HtmlPreview.Tables.cs +++ b/src/officecli/Handlers/Pptx/PowerPointHandler.HtmlPreview.Tables.cs @@ -14,8 +14,9 @@ public partial class PowerPointHandler { // ==================== Table Rendering ==================== - private static void RenderTable(StringBuilder sb, GraphicFrame gf, Dictionary themeColors) + private static void RenderTable(StringBuilder sb, GraphicFrame gf, Dictionary themeColors, string? dataPath = null) { + var dataPathAttr = string.IsNullOrEmpty(dataPath) ? "" : $" data-path=\"{HtmlEncode(dataPath)}\""; var table = gf.Descendants().FirstOrDefault(); if (table == null) return; @@ -35,7 +36,7 @@ private static void RenderTable(StringBuilder sb, GraphicFrame gf, Dictionary"); + sb.AppendLine($"
    "); sb.AppendLine("
    {HtmlEncode(sheetName)}
    {r}{chartEntry.html}
    {r}
    {r}{r}
    "); // Column widths @@ -107,8 +108,7 @@ private static void RenderTable(StringBuilder sb, GraphicFrame gf, Dictionary()?.Typeface?.Value @@ -135,9 +135,8 @@ private static void RenderTable(StringBuilder sb, GraphicFrame gf, Dictionary{HtmlEncode(bullet)} "); + var buStyles = new List(); + + // Bullet color: explicit buClr > first run color > default (inherit) + var buClrFill = pProps?.GetFirstChild() + ?.GetFirstChild(); + var bulletColor = ResolveFillColor(buClrFill, themeColors); + if (bulletColor == null) + { + // Follow first run text color (same as LibreOffice/POI behavior) + var firstRun = para.Elements().FirstOrDefault(); + var firstRunFill = firstRun?.RunProperties?.GetFirstChild(); + bulletColor = ResolveFillColor(firstRunFill, themeColors); + } + if (bulletColor != null) buStyles.Add($"color:{bulletColor}"); + + // Bullet size: explicit buSzPts/buSzPct > first run size > default size + var buSzPts = pProps?.GetFirstChild(); + var buSzPct = pProps?.GetFirstChild(); + if (buSzPts?.Val?.HasValue == true) + { + buStyles.Add($"font-size:{buSzPts.Val.Value / 100.0:0.##}pt"); + } + else + { + // Determine base font size from first run or default + var firstRun = para.Elements().FirstOrDefault(); + var baseSizeHundredths = firstRun?.RunProperties?.FontSize?.Value ?? defaultFontSizeHundredths; + if (baseSizeHundredths.HasValue) + { + var pct = buSzPct?.Val?.HasValue == true ? buSzPct.Val.Value / 100000.0 : 1.0; + buStyles.Add($"font-size:{baseSizeHundredths.Value / 100.0 * pct:0.##}pt"); + } + } + + var buStyle = buStyles.Count > 0 ? $" style=\"{string.Join(";", buStyles)}\"" : ""; + sb.Append($"{HtmlEncode(bullet)} "); } // Check for OfficeMath (a14:m inside mc:AlternateContent) in paragraph XML diff --git a/src/officecli/Handlers/Pptx/PowerPointHandler.HtmlPreview.cs b/src/officecli/Handlers/Pptx/PowerPointHandler.HtmlPreview.cs index 4340c3fd9..f41259ff3 100644 --- a/src/officecli/Handlers/Pptx/PowerPointHandler.HtmlPreview.cs +++ b/src/officecli/Handlers/Pptx/PowerPointHandler.HtmlPreview.cs @@ -351,28 +351,45 @@ private void RenderSlideElements(StringBuilder sb, SlidePart slidePart, int slid var shapeTree = GetSlide(slidePart).CommonSlideData?.ShapeTree; if (shapeTree == null) return; + // Per-element-type positional counters used to build the data-path of each + // top-level element. We prefer @id= when the element has a cNvPr id (stable + // across edits), and fall back to positional [N] otherwise. + int shapeIdx = 0, picIdx = 0, tableIdx = 0, chartIdx = 0, cxnIdx = 0, groupIdx = 0; + string PathFor(string typeName, OpenXmlElement el, int positional) + => $"/slide[{slideNum}]/{BuildElementPathSegment(typeName, el, positional)}"; + // Collect all content elements in z-order (as they appear in XML) foreach (var element in shapeTree.ChildElements) { switch (element) { case Shape shape: - RenderShape(sb, shape, slidePart, themeColors); + shapeIdx++; + RenderShape(sb, shape, slidePart, themeColors, dataPath: PathFor("shape", shape, shapeIdx)); break; case Picture pic: - RenderPicture(sb, pic, slidePart, themeColors); + picIdx++; + RenderPicture(sb, pic, slidePart, themeColors, dataPath: PathFor("picture", pic, picIdx)); break; case GraphicFrame gf: if (gf.Descendants().Any()) - RenderTable(sb, gf, themeColors); + { + tableIdx++; + RenderTable(sb, gf, themeColors, dataPath: PathFor("table", gf, tableIdx)); + } else if (gf.Descendants().Any(e => e.LocalName == "chart" && e.NamespaceUri.Contains("chart"))) - RenderChart(sb, gf, slidePart, themeColors); + { + chartIdx++; + RenderChart(sb, gf, slidePart, themeColors, dataPath: PathFor("chart", gf, chartIdx)); + } break; case ConnectionShape cxn: - RenderConnector(sb, cxn, themeColors); + cxnIdx++; + RenderConnector(sb, cxn, themeColors, dataPath: PathFor("connector", cxn, cxnIdx)); break; case GroupShape grp: - RenderGroup(sb, grp, slidePart, themeColors); + groupIdx++; + RenderGroup(sb, grp, slidePart, themeColors, dataPath: PathFor("group", grp, groupIdx)); break; default: // mc:AlternateContent — render 3D models, zoom, etc. diff --git a/src/officecli/Handlers/Pptx/PowerPointHandler.Mutations.cs b/src/officecli/Handlers/Pptx/PowerPointHandler.Mutations.cs index e757b0785..2531fea23 100644 --- a/src/officecli/Handlers/Pptx/PowerPointHandler.Mutations.cs +++ b/src/officecli/Handlers/Pptx/PowerPointHandler.Mutations.cs @@ -16,6 +16,7 @@ public partial class PowerPointHandler public string? Remove(string path) { path = NormalizeCellPath(path); + path = ResolveIdPath(path); // Handle /slide[N]/notes path (no index bracket) var notesMatch = Regex.Match(path, @"^/slide\[(\d+)\]/notes$"); @@ -277,8 +278,22 @@ public partial class PowerPointHandler return null; } - public string Move(string sourcePath, string? targetParentPath, int? index) + public string Move(string sourcePath, string? targetParentPath, InsertPosition? position) { + var index = position?.Index; + sourcePath = ResolveIdPath(sourcePath); + if (targetParentPath != null) targetParentPath = ResolveIdPath(targetParentPath); + + // Infer --to from --after/--before full path if not specified + var anchorFullPath = position?.After ?? position?.Before; + if (string.IsNullOrEmpty(targetParentPath) && anchorFullPath != null && anchorFullPath.StartsWith("/")) + { + var resolvedAnchor = ResolveIdPath(anchorFullPath); + var lastSlash = resolvedAnchor.LastIndexOf('/'); + if (lastSlash > 0) + targetParentPath = resolvedAnchor[..lastSlash]; + } + var presentationPart = _doc.PresentationPart ?? throw new InvalidOperationException("Presentation not found"); var slideParts = GetSlideParts().ToList(); @@ -297,9 +312,37 @@ public string Move(string sourcePath, string? targetParentPath, int? index) throw new ArgumentException($"Slide {slideIdx} not found (total: {slideIds.Count})"); var slideId = slideIds[slideIdx - 1]; + + // Resolve after/before anchor BEFORE removing + SlideId? afterAnchor = null, beforeAnchor = null; + if (position?.After != null) + { + var afterMatch = Regex.Match(position.After.StartsWith("/") ? position.After : "/" + position.After, @"/slide\[(\d+)\]"); + if (afterMatch.Success) + { + var ai = int.Parse(afterMatch.Groups[1].Value); + if (ai >= 1 && ai <= slideIds.Count) afterAnchor = slideIds[ai - 1]; + } + if (afterAnchor == null) throw new ArgumentException($"After anchor not found: {position.After}"); + } + else if (position?.Before != null) + { + var beforeMatch = Regex.Match(position.Before.StartsWith("/") ? position.Before : "/" + position.Before, @"/slide\[(\d+)\]"); + if (beforeMatch.Success) + { + var bi = int.Parse(beforeMatch.Groups[1].Value); + if (bi >= 1 && bi <= slideIds.Count) beforeAnchor = slideIds[bi - 1]; + } + if (beforeAnchor == null) throw new ArgumentException($"Before anchor not found: {position.Before}"); + } + slideId.Remove(); - if (index.HasValue) + if (afterAnchor != null) + afterAnchor.InsertAfterSelf(slideId); + else if (beforeAnchor != null) + beforeAnchor.InsertBeforeSelf(slideId); + else if (index.HasValue) { var remaining = slideIdList.Elements().ToList(); if (index.Value >= 0 && index.Value < remaining.Count) @@ -349,13 +392,41 @@ public string Move(string sourcePath, string? targetParentPath, int? index) ?? throw new InvalidOperationException("Slide has no shape tree"); } + // Reject cross-slide move of placeholder shapes (would cause duplicate IDs) + if (srcSlidePart != tgtSlidePart) + { + var nvSpPr = srcElement.Descendants().FirstOrDefault(); + if (nvSpPr?.ApplicationNonVisualDrawingProperties?.PlaceholderShape != null) + throw new ArgumentException("Cannot move placeholder shapes across slides"); + } + // Copy relationships BEFORE removing from source (so rel IDs are still accessible) if (srcSlidePart != tgtSlidePart) CopyRelationships(srcElement, srcSlidePart, tgtSlidePart); + // Resolve after/before anchor for shape-level move + OpenXmlElement? shapeAfterAnchor = null, shapeBeforeAnchor = null; + if (position?.After != null) + { + var anchorPath = ResolveIdPath(position.After); + var (_, anchor) = ResolveSlideElement(anchorPath, slideParts); + shapeAfterAnchor = anchor; + } + else if (position?.Before != null) + { + var anchorPath = ResolveIdPath(position.Before); + var (_, anchor) = ResolveSlideElement(anchorPath, slideParts); + shapeBeforeAnchor = anchor; + } + srcElement.Remove(); - InsertAtPosition(tgtShapeTree, srcElement, index); + if (shapeAfterAnchor != null) + shapeAfterAnchor.InsertAfterSelf(srcElement); + else if (shapeBeforeAnchor != null) + shapeBeforeAnchor.InsertBeforeSelf(srcElement); + else + InsertAtPosition(tgtShapeTree, srcElement, index); GetSlide(srcSlidePart).Save(); if (srcSlidePart != tgtSlidePart) @@ -366,6 +437,8 @@ public string Move(string sourcePath, string? targetParentPath, int? index) public (string NewPath1, string NewPath2) Swap(string path1, string path2) { + path1 = ResolveIdPath(path1); + path2 = ResolveIdPath(path2); var presentationPart = _doc.PresentationPart ?? throw new InvalidOperationException("Presentation not found"); var slideParts = GetSlideParts().ToList(); @@ -451,8 +524,11 @@ internal static void SwapXmlElements(OpenXmlElement a, OpenXmlElement b) } } - public string CopyFrom(string sourcePath, string targetParentPath, int? index) + public string CopyFrom(string sourcePath, string targetParentPath, InsertPosition? position) { + var index = position?.Index; + sourcePath = ResolveIdPath(sourcePath); + targetParentPath = ResolveIdPath(targetParentPath); var slideParts = GetSlideParts().ToList(); // Whole-slide clone: --from /slide[N] to / @@ -465,6 +541,23 @@ public string CopyFrom(string sourcePath, string targetParentPath, int? index) var (srcSlidePart, srcElement) = ResolveSlideElement(sourcePath, slideParts); var clone = srcElement.CloneNode(true); + // Assign new unique cNvPr.Id to the clone to avoid duplicate IDs on the target slide + var cloneNvPr = clone.Descendants().FirstOrDefault(); + if (cloneNvPr != null) + { + var tgtSlideMatchPre = Regex.Match(targetParentPath, @"^/slide\[(\d+)\]$"); + if (tgtSlideMatchPre.Success) + { + var tgtIdx = int.Parse(tgtSlideMatchPre.Groups[1].Value); + if (tgtIdx >= 1 && tgtIdx <= slideParts.Count) + { + var tgtTree = GetSlide(slideParts[tgtIdx - 1]).CommonSlideData?.ShapeTree; + if (tgtTree != null) + cloneNvPr.Id = GenerateUniqueShapeId(tgtTree); + } + } + } + var tgtSlideMatch = Regex.Match(targetParentPath, @"^/slide\[(\d+)\]$"); if (!tgtSlideMatch.Success) throw new ArgumentException($"Target must be a slide: /slide[N]"); @@ -841,6 +934,6 @@ private static string ComputeElementPath(string parentPath, OpenXmlElement eleme .Where(e => e.LocalName == element.LocalName) .ToList().IndexOf(element) + 1; } - return $"{parentPath}/{typeName}[{typeIdx}]"; + return $"{parentPath}/{BuildElementPathSegment(typeName, element, typeIdx)}"; } } diff --git a/src/officecli/Handlers/Pptx/PowerPointHandler.NodeBuilder.cs b/src/officecli/Handlers/Pptx/PowerPointHandler.NodeBuilder.cs index 3a1623f83..b97c5c55c 100644 --- a/src/officecli/Handlers/Pptx/PowerPointHandler.NodeBuilder.cs +++ b/src/officecli/Handlers/Pptx/PowerPointHandler.NodeBuilder.cs @@ -57,9 +57,10 @@ private List GetSlideChildNodes(SlidePart slidePart, int slideNum, { grpIdx++; var grpName = grp.NonVisualGroupShapeProperties?.NonVisualDrawingProperties?.Name?.Value ?? "Group"; + var grpPathSeg = BuildElementPathSegment("group", grp, grpIdx); var grpNode = new DocumentNode { - Path = $"/slide[{slideNum}]/group[{grpIdx}]", + Path = $"/slide[{slideNum}]/{grpPathSeg}", Type = "group", Preview = grpName, ChildCount = grp.Elements().Count() + grp.Elements().Count() @@ -110,15 +111,18 @@ private static DocumentNode TableToNode(GraphicFrame gf, int slideNum, int tblId var cols = rows.FirstOrDefault()?.Elements().Count() ?? 0; var name = gf.NonVisualGraphicFrameProperties?.NonVisualDrawingProperties?.Name?.Value ?? "Table"; + var tblPathSeg = BuildElementPathSegment("table", gf, tblIdx); var node = new DocumentNode { - Path = $"/slide[{slideNum}]/table[{tblIdx}]", + Path = $"/slide[{slideNum}]/{tblPathSeg}", Type = "table", Preview = $"{name} ({rows.Count}x{cols})", ChildCount = rows.Count }; node.Format["name"] = name; + var tblId = GetCNvPrId(gf); + if (tblId.HasValue) node.Format["id"] = tblId.Value; node.Format["rows"] = rows.Count; node.Format["cols"] = cols; @@ -173,7 +177,7 @@ private static DocumentNode TableToNode(GraphicFrame gf, int slideNum, int tblId rIdx++; var rowNode = new DocumentNode { - Path = $"/slide[{slideNum}]/table[{tblIdx}]/tr[{rIdx}]", + Path = $"/slide[{slideNum}]/{tblPathSeg}/tr[{rIdx}]", Type = "tr", ChildCount = row.Elements().Count() }; @@ -191,7 +195,7 @@ private static DocumentNode TableToNode(GraphicFrame gf, int slideNum, int tblId var cellText = cell.TextBody?.InnerText ?? ""; var cellNode = new DocumentNode { - Path = $"/slide[{slideNum}]/table[{tblIdx}]/tr[{rIdx}]/tc[{cIdx}]", + Path = $"/slide[{slideNum}]/{tblPathSeg}/tr[{rIdx}]/tc[{cIdx}]", Type = "tc", Text = cellText }; @@ -207,23 +211,9 @@ private static DocumentNode TableToNode(GraphicFrame gf, int slideNum, int tblId } else if (tcPr?.GetFirstChild() is { } gradFill) { - var stops = gradFill.GradientStopList?.Elements().ToList(); - if (stops != null && stops.Count >= 2) - { - var gc1 = stops[0].GetFirstChild()?.Val?.Value; - var gc2 = stops[^1].GetFirstChild()?.Val?.Value; - if (!string.IsNullOrEmpty(gc1) && !string.IsNullOrEmpty(gc2)) - { - var gc1Fmt = ParseHelpers.FormatHexColor(gc1); - var gc2Fmt = ParseHelpers.FormatHexColor(gc2); - var lin = gradFill.GetFirstChild(); - var deg = lin?.Angle?.Value != null ? lin.Angle.Value / 60000.0 : 0.0; - var degStr = deg % 1 == 0 ? $"{(int)deg}" : $"{deg:0.##}"; - var gradient = $"linear;{gc1Fmt};{gc2Fmt};{degStr}"; - cellNode.Format["gradient"] = gradient; - cellNode.Format["fill"] = deg != 0 ? $"{gc1Fmt}-{gc2Fmt}-{degStr}" : $"{gc1Fmt}-{gc2Fmt}"; - } - } + // Preserve all stops (including intermediate ones) via the shared helper. + cellNode.Format["gradient"] = ReadGradientString(gradFill); + cellNode.Format["fill"] = "gradient"; } else { @@ -319,15 +309,18 @@ private static DocumentNode ShapeToNode(Shape shape, int slideNum, int shapeIdx, && shape.TextBody.Descendants().Any(e => e.LocalName == "oMath" || e.LocalName == "oMathPara" || (e.LocalName == "m" && e.NamespaceUri == "http://schemas.microsoft.com/office/drawing/2010/main")); + var shapePathSeg = BuildElementPathSegment("shape", shape, shapeIdx); var node = new DocumentNode { - Path = $"/slide[{slideNum}]/shape[{shapeIdx}]", + Path = $"/slide[{slideNum}]/{shapePathSeg}", Type = isTitle ? "title" : isEquation ? "equation" : "textbox", Text = text, Preview = string.IsNullOrEmpty(text) ? name : (text.Length > 50 ? text[..50] + "..." : text) }; node.Format["name"] = name; + var shapeId = GetCNvPrId(shape); + if (shapeId.HasValue) node.Format["id"] = shapeId.Value; if (isTitle) node.Format["isTitle"] = true; // Position and size @@ -733,7 +726,7 @@ private static DocumentNode ShapeToNode(Shape shape, int slideNum, int shapeIdx, var paraNode = new DocumentNode { - Path = $"/slide[{slideNum}]/shape[{shapeIdx}]/paragraph[{paraIdx + 1}]", + Path = $"/slide[{slideNum}]/{shapePathSeg}/paragraph[{paraIdx + 1}]", Type = "paragraph", Text = paraText, ChildCount = paraRuns.Count @@ -768,7 +761,7 @@ private static DocumentNode ShapeToNode(Shape shape, int slideNum, int shapeIdx, foreach (var run in paraRuns) { paraNode.Children.Add(RunToNode(run, - $"/slide[{slideNum}]/shape[{shapeIdx}]/paragraph[{paraIdx + 1}]/run[{runIdx + 1}]", part)); + $"/slide[{slideNum}]/{shapePathSeg}/paragraph[{paraIdx + 1}]/run[{runIdx + 1}]", part)); runIdx++; } } @@ -854,14 +847,17 @@ private static DocumentNode PictureToNode(Picture pic, int slideNum, int picIdx, var isAudio = nvPr?.GetFirstChild() != null; var mediaType = isVideo ? "video" : isAudio ? "audio" : "picture"; + var picPathSeg = BuildElementPathSegment("picture", pic, picIdx); var node = new DocumentNode { - Path = $"/slide[{slideNum}]/picture[{picIdx}]", + Path = $"/slide[{slideNum}]/{picPathSeg}", Type = mediaType, Preview = name }; node.Format["name"] = name; + var picId = GetCNvPrId(pic); + if (picId.HasValue) node.Format["id"] = picId.Value; if (!isVideo && !isAudio) { if (!string.IsNullOrEmpty(alt)) node.Format["alt"] = alt; @@ -1011,13 +1007,16 @@ private static Shape CreateTextShape(uint id, string name, string text, bool isT private static DocumentNode ConnectorToNode(ConnectionShape cxn, int slideNum, int cxnIdx) { var name = cxn.NonVisualConnectionShapeProperties?.NonVisualDrawingProperties?.Name?.Value ?? "Connector"; + var cxnPathSeg = BuildElementPathSegment("connector", cxn, cxnIdx); var node = new DocumentNode { - Path = $"/slide[{slideNum}]/connector[{cxnIdx}]", + Path = $"/slide[{slideNum}]/{cxnPathSeg}", Type = "connector", Preview = name }; node.Format["name"] = name; + var cxnId = GetCNvPrId(cxn); + if (cxnId.HasValue) node.Format["id"] = cxnId.Value; var spPr = cxn.ShapeProperties; var xfrm = spPr?.GetFirstChild(); diff --git a/src/officecli/Handlers/Pptx/PowerPointHandler.Query.cs b/src/officecli/Handlers/Pptx/PowerPointHandler.Query.cs index aa0fc5f4b..2b969d56e 100644 --- a/src/officecli/Handlers/Pptx/PowerPointHandler.Query.cs +++ b/src/officecli/Handlers/Pptx/PowerPointHandler.Query.cs @@ -20,6 +20,7 @@ public DocumentNode Get(string path, int depth = 1) if (string.IsNullOrEmpty(path)) throw new ArgumentException("Path cannot be empty."); path = NormalizeCellPath(path); + path = ResolveIdPath(path); if (path == "/") { var node = new DocumentNode { Path = "/", Type = "presentation" }; @@ -203,10 +204,11 @@ public DocumentNode Get(string path, int depth = 1) var shIdx = int.Parse(runPathMatch.Groups[2].Value); var rIdx = int.Parse(runPathMatch.Groups[3].Value); var (runSlidePart, shape) = ResolveShape(sIdx, shIdx); + var shapePathSeg = BuildElementPathSegment("shape", shape, shIdx); var allRuns = GetAllRuns(shape); if (rIdx < 1 || rIdx > allRuns.Count) throw new ArgumentException($"Run {rIdx} not found (shape has {allRuns.Count} runs)"); - return RunToNode(allRuns[rIdx - 1], path, runSlidePart); + return RunToNode(allRuns[rIdx - 1], $"/slide[{sIdx}]/{shapePathSeg}/run[{rIdx}]", runSlidePart); } var paraPathMatch = Regex.Match(path, @"^/slide\[(\d+)\]/shape\[(\d+)\]/paragraph\[(\d+)\](?:/run\[(\d+)\])?$"); @@ -216,6 +218,7 @@ public DocumentNode Get(string path, int depth = 1) var shIdx = int.Parse(paraPathMatch.Groups[2].Value); var pIdx = int.Parse(paraPathMatch.Groups[3].Value); var (paraSlidePart, shape) = ResolveShape(sIdx, shIdx); + var shapePathSeg = BuildElementPathSegment("shape", shape, shIdx); var paragraphs = shape.TextBody?.Elements().ToList() ?? throw new ArgumentException("Shape has no text body"); if (pIdx < 1 || pIdx > paragraphs.Count) @@ -225,20 +228,20 @@ public DocumentNode Get(string path, int depth = 1) if (paraPathMatch.Groups[4].Success) { - // /slide[N]/shape[M]/paragraph[P]/run[K] + // /slide[N]/shape[@id=X]/paragraph[P]/run[K] var rIdx = int.Parse(paraPathMatch.Groups[4].Value); var paraRuns = para.Elements().ToList(); if (rIdx < 1 || rIdx > paraRuns.Count) throw new ArgumentException($"Run {rIdx} not found (paragraph has {paraRuns.Count} runs)"); return RunToNode(paraRuns[rIdx - 1], - $"/slide[{sIdx}]/shape[{shIdx}]/paragraph[{pIdx}]/run[{rIdx}]", paraSlidePart); + $"/slide[{sIdx}]/{shapePathSeg}/paragraph[{pIdx}]/run[{rIdx}]", paraSlidePart); } - // /slide[N]/shape[M]/paragraph[P] + // /slide[N]/shape[@id=X]/paragraph[P] var paraText = string.Join("", para.Elements().Select(r => r.Text?.Text ?? "")); var paraNode = new DocumentNode { - Path = path, + Path = $"/slide[{sIdx}]/{shapePathSeg}/paragraph[{pIdx}]", Type = "paragraph", Text = paraText }; @@ -264,7 +267,7 @@ public DocumentNode Get(string path, int depth = 1) foreach (var run in runs) { paraNode.Children.Add(RunToNode(run, - $"/slide[{sIdx}]/shape[{shIdx}]/paragraph[{pIdx}]/run[{runIdx + 1}]", paraSlidePart)); + $"/slide[{sIdx}]/{shapePathSeg}/paragraph[{pIdx}]/run[{runIdx + 1}]", paraSlidePart)); runIdx++; } } @@ -297,8 +300,9 @@ public DocumentNode Get(string path, int depth = 1) var shIdx = int.Parse(animPathMatch.Groups[2].Value); var aIdx = int.Parse(animPathMatch.Groups[3].Value); var (animSlidePart, animShape) = ResolveShape(sIdx, shIdx); + var animShapePathSeg = BuildElementPathSegment("shape", animShape, shIdx); - var animNode = new DocumentNode { Path = path, Type = "animation" }; + var animNode = new DocumentNode { Path = $"/slide[{sIdx}]/{animShapePathSeg}/animation[{aIdx}]", Type = "animation" }; // Read animation info from timing tree var shapeId = animShape.NonVisualShapeProperties?.NonVisualDrawingProperties?.Id?.Value; @@ -390,6 +394,8 @@ public DocumentNode Get(string path, int depth = 1) var cIdx = int.Parse(tblCellGetMatch.Groups[4].Value); var (slidePart2, table) = ResolveTable(sIdx, tIdx); + var tblGf = table.Ancestors().FirstOrDefault(); + var tblPathSeg = tblGf != null ? BuildElementPathSegment("table", tblGf, tIdx) : $"table[{tIdx}]"; var tableRows = table.Elements().ToList(); if (rIdx < 1 || rIdx > tableRows.Count) throw new ArgumentException($"Row {rIdx} not found (table has {tableRows.Count} rows)"); @@ -401,7 +407,7 @@ public DocumentNode Get(string path, int depth = 1) var cellText = cell.TextBody?.InnerText ?? ""; var cellNode = new DocumentNode { - Path = path, + Path = $"/slide[{sIdx}]/{tblPathSeg}/tr[{rIdx}]/tc[{cIdx}]", Type = "tc", Text = cellText }; @@ -738,7 +744,7 @@ public DocumentNode Get(string path, int depth = 1) var picIdx = allPics.IndexOf(mediaPic) + 1; var node = PictureToNode(mediaPic, slideIdx, picIdx, targetSlidePart); // Override the path to use the media-type-specific path - node.Path = $"/slide[{slideIdx}]/{elementType}[{elementIdx}]"; + node.Path = $"/slide[{slideIdx}]/{BuildElementPathSegment(elementType, mediaPic, elementIdx)}"; return node; } else if (elementType == "connector" || elementType == "connection") @@ -755,7 +761,8 @@ public DocumentNode Get(string path, int depth = 1) throw new ArgumentException($"Group {elementIdx} not found (total: {groups.Count})"); var grp = groups[elementIdx - 1]; var grpName = grp.NonVisualGroupShapeProperties?.NonVisualDrawingProperties?.Name?.Value ?? "Group"; - var grpPath = $"/slide[{slideIdx}]/group[{elementIdx}]"; + var grpPathSeg = BuildElementPathSegment("group", grp, elementIdx); + var grpPath = $"/slide[{slideIdx}]/{grpPathSeg}"; var grpNode = new DocumentNode { Path = grpPath, @@ -780,7 +787,7 @@ public DocumentNode Get(string path, int depth = 1) { memberShapeIdx++; var memberNode = ShapeToNode(memberShape, slideIdx, memberShapeIdx, depth - 1, targetSlidePart); - memberNode.Path = $"{grpPath}/shape[{memberShapeIdx}]"; + memberNode.Path = $"{grpPath}/{BuildElementPathSegment("shape", memberShape, memberShapeIdx)}"; grpNode.Children.Add(memberNode); } int memberPicIdx = 0; @@ -788,7 +795,7 @@ public DocumentNode Get(string path, int depth = 1) { memberPicIdx++; var picNode = PictureToNode(memberPic, slideIdx, memberPicIdx, targetSlidePart); - picNode.Path = $"{grpPath}/picture[{memberPicIdx}]"; + picNode.Path = $"{grpPath}/{BuildElementPathSegment("picture", memberPic, memberPicIdx)}"; grpNode.Children.Add(picNode); } } @@ -917,7 +924,7 @@ public List Query(string selector) if (part != null) { picNode.Format["contentType"] = part.ContentType; - picNode.Format["size"] = part.GetStream().Length; + picNode.Format["fileSize"] = part.GetStream().Length; } } results.Add(picNode); @@ -974,7 +981,7 @@ public List Query(string selector) { results.Add(new DocumentNode { - Path = $"/slide[{slideNum}]/shape[{shapeIdx + 1}]", + Path = $"/slide[{slideNum}]/{BuildElementPathSegment("shape", shape, shapeIdx + 1)}", Type = "equation", Text = latex, Format = { ["mode"] = "display" } @@ -1046,6 +1053,7 @@ public List Query(string selector) var tbl = gf.Descendants().FirstOrDefault(); if (tbl == null) continue; tblIdx2++; + var tblPathSeg2 = BuildElementPathSegment("table", gf, tblIdx2); int rIdx = 0; foreach (var row in tbl.Elements()) { @@ -1055,7 +1063,7 @@ public List Query(string selector) var rowText = string.Join(" | ", row.Elements().Select(c => c.TextBody?.InnerText ?? "")); var rowNode = new DocumentNode { - Path = $"/slide[{slideNum}]/table[{tblIdx2}]/tr[{rIdx}]", + Path = $"/slide[{slideNum}]/{tblPathSeg2}/tr[{rIdx}]", Type = "tr", Text = rowText, ChildCount = row.Elements().Count() @@ -1075,7 +1083,7 @@ public List Query(string selector) var cellText = cell.TextBody?.InnerText ?? ""; var cellNode = new DocumentNode { - Path = $"/slide[{slideNum}]/table[{tblIdx2}]/tr[{rIdx}]/tc[{cIdx}]", + Path = $"/slide[{slideNum}]/{tblPathSeg2}/tr[{rIdx}]/tc[{cIdx}]", Type = "tc", Text = cellText }; @@ -1135,7 +1143,7 @@ public List Query(string selector) var grpName = grp.NonVisualGroupShapeProperties?.NonVisualDrawingProperties?.Name?.Value ?? "Group"; var grpNode = new DocumentNode { - Path = $"/slide[{slideNum}]/group[{grpIdx}]", + Path = $"/slide[{slideNum}]/{BuildElementPathSegment("group", grp, grpIdx)}", Type = "group", Preview = grpName, ChildCount = grp.Elements().Count() + grp.Elements().Count() diff --git a/src/officecli/Handlers/Pptx/PowerPointHandler.Selector.cs b/src/officecli/Handlers/Pptx/PowerPointHandler.Selector.cs index 17ff922e3..265d463da 100644 --- a/src/officecli/Handlers/Pptx/PowerPointHandler.Selector.cs +++ b/src/officecli/Handlers/Pptx/PowerPointHandler.Selector.cs @@ -168,17 +168,21 @@ private static bool MatchesGenericAttributes(DocumentNode node, Dictionary + /// Match shape name with !! morph prefix awareness. + /// "my-box" matches both "my-box" and "!!my-box". + /// "!!my-box" matches both "!!my-box" and "my-box". + /// + private static bool MatchesShapeName(string? actual, string expected) + { + if (actual == null) return false; + if (string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase)) + return true; + // Strip !! prefix from actual name and compare + if (actual.StartsWith("!!") && string.Equals(actual[2..], expected, StringComparison.OrdinalIgnoreCase)) + return true; + // Strip !! prefix from expected and compare + if (expected.StartsWith("!!") && string.Equals(actual, expected[2..], StringComparison.OrdinalIgnoreCase)) + return true; + return false; + } + private static bool MatchesPictureSelector(Picture pic, ShapeSelector selector) { // Only match if looking for pictures/video/audio or no type specified diff --git a/src/officecli/Handlers/Pptx/PowerPointHandler.Set.cs b/src/officecli/Handlers/Pptx/PowerPointHandler.Set.cs index bb82a138a..0b968bfdc 100644 --- a/src/officecli/Handlers/Pptx/PowerPointHandler.Set.cs +++ b/src/officecli/Handlers/Pptx/PowerPointHandler.Set.cs @@ -15,6 +15,7 @@ public partial class PowerPointHandler public List Set(string path, Dictionary properties) { path = NormalizeCellPath(path); + path = ResolveIdPath(path); // Batch Set: if path looks like a selector (not starting with /), Query → Set each if (!string.IsNullOrEmpty(path) && !path.StartsWith("/")) @@ -35,20 +36,33 @@ public List Set(string path, Dictionary properties) if (path.Equals("/theme", StringComparison.OrdinalIgnoreCase)) return SetThemeProperties(properties); + // Unified find: if 'find' key is present, route to ProcessPptFind + if (properties.TryGetValue("find", out var findText)) + { + var replace = properties.TryGetValue("replace", out var r) ? r : null; + var formatProps = new Dictionary(properties, StringComparer.OrdinalIgnoreCase); + formatProps.Remove("find"); + formatProps.Remove("replace"); + formatProps.Remove("scope"); + formatProps.Remove("regex"); + + if (replace == null && formatProps.Count == 0) + throw new ArgumentException("'find' requires either 'replace' and/or format properties (e.g. bold, color, size)."); + + // Support regex=true as an alternative to r"..." prefix. + // CONSISTENCY(find-regex): mirror of WordHandler.Set.cs:60-61. grep + // "CONSISTENCY(find-regex)" for every project-wide call site. + if (properties.TryGetValue("regex", out var regexFlag) && ParseHelpers.IsTruthySafe(regexFlag) && !findText.StartsWith("r\"") && !findText.StartsWith("r'")) + findText = $"r\"{findText}\""; + + var matchCount = ProcessPptFind(path, findText, replace, formatProps); + LastFindMatchCount = matchCount; + return []; + } + // Presentation-level properties: / or /presentation if (path is "/" or "" or "/presentation") { - // Find & Replace: special handling before presentation properties - if (properties.TryGetValue("find", out var findText) && properties.TryGetValue("replace", out var replaceText)) - { - var count = FindAndReplace(findText, replaceText); - var remaining = new Dictionary(properties, StringComparer.OrdinalIgnoreCase); - remaining.Remove("find"); - remaining.Remove("replace"); - if (remaining.Count > 0) - return Set(path, remaining); - return []; - } var presentation = _doc.PresentationPart?.Presentation ?? throw new InvalidOperationException("No presentation"); @@ -758,6 +772,28 @@ public List Set(string path, Dictionary properties) case "height": row.Height = ParseEmu(value); break; + case "text": + { + // Two behaviors based on presence of tab: + // - No tab: broadcast the same text to all cells in the row + // - Tab-delimited: distribute tokens across cells by position + // ("X1\tX2\tX3" → tc[1]="X1", tc[2]="X2", tc[3]="X3") + // Extra tokens beyond cell count are dropped; cells beyond token + // count are left unchanged. + var rowCells = row.Elements().ToList(); + if (value.Contains('\t')) + { + var tokens = value.Split('\t'); + for (int i = 0; i < rowCells.Count && i < tokens.Length; i++) + ReplaceCellText(rowCells[i], tokens[i]); + } + else + { + foreach (var c in rowCells) + ReplaceCellText(c, value); + } + break; + } default: // c1, c2, ... shorthand: set text of specific cell by index if (key.Length >= 2 && key[0] == 'c' && int.TryParse(key.AsSpan(1), out var cIdx)) @@ -765,35 +801,7 @@ public List Set(string path, Dictionary properties) var rowCells = row.Elements().ToList(); if (cIdx < 1 || cIdx > rowCells.Count) throw new ArgumentException($"Cell c{cIdx} out of range (row has {rowCells.Count} cells)"); - var targetCell = rowCells[cIdx - 1]; - // Replace text in first paragraph's first run, or create one - var txBody = targetCell.TextBody; - if (txBody == null) - { - txBody = new Drawing.TextBody( - new Drawing.BodyProperties(), - new Drawing.ListStyle(), - new Drawing.Paragraph()); - targetCell.AppendChild(txBody); - } - var para = txBody.Elements().FirstOrDefault() - ?? txBody.AppendChild(new Drawing.Paragraph()); - para.RemoveAllChildren(); - para.RemoveAllChildren(); - // Remove EndParagraphRunProperties before appending Run, - // then re-add after — schema requires Run before EndParagraphRunProperties - var savedEndParaRPr = para.Elements().FirstOrDefault(); - if (savedEndParaRPr != null) - savedEndParaRPr.Remove(); - if (!string.IsNullOrEmpty(value)) - { - var newRun = new Drawing.Run( - new Drawing.RunProperties { Language = "en-US" }, - new Drawing.Text { Text = value }); - para.AppendChild(newRun); - } - if (savedEndParaRPr != null) - para.AppendChild(savedEndParaRPr); + ReplaceCellText(rowCells[cIdx - 1], value); } else { diff --git a/src/officecli/Handlers/Pptx/PowerPointHandler.ShapeProperties.cs b/src/officecli/Handlers/Pptx/PowerPointHandler.ShapeProperties.cs index c54ae5ccf..4f3243041 100644 --- a/src/officecli/Handlers/Pptx/PowerPointHandler.ShapeProperties.cs +++ b/src/officecli/Handlers/Pptx/PowerPointHandler.ShapeProperties.cs @@ -870,6 +870,40 @@ private static void EnsureTableCellHasRun(Drawing.TableCell cell) para.Append(run); } + /// + /// Replace the text content of a table cell's first paragraph with the given value. + /// Removes any existing runs/breaks and preserves EndParagraphRunProperties ordering + /// (schema requires Run before EndParagraphRunProperties). + /// + private static void ReplaceCellText(Drawing.TableCell cell, string value) + { + var txBody = cell.TextBody; + if (txBody == null) + { + txBody = new Drawing.TextBody( + new Drawing.BodyProperties(), + new Drawing.ListStyle(), + new Drawing.Paragraph()); + cell.AppendChild(txBody); + } + var para = txBody.Elements().FirstOrDefault() + ?? txBody.AppendChild(new Drawing.Paragraph()); + para.RemoveAllChildren(); + para.RemoveAllChildren(); + var savedEndParaRPr = para.Elements().FirstOrDefault(); + if (savedEndParaRPr != null) + savedEndParaRPr.Remove(); + if (!string.IsNullOrEmpty(value)) + { + var newRun = new Drawing.Run( + new Drawing.RunProperties { Language = "en-US" }, + new Drawing.Text { Text = value }); + para.AppendChild(newRun); + } + if (savedEndParaRPr != null) + para.AppendChild(savedEndParaRPr); + } + private static List SetTableCellProperties(Drawing.TableCell cell, Dictionary properties) { var unsupported = new List(); @@ -956,7 +990,8 @@ private static List SetTableCellProperties(Drawing.TableCell cell, Dicti { var rProps = run.RunProperties ?? (run.RunProperties = new Drawing.RunProperties()); rProps.RemoveAllChildren(); - rProps.AppendChild((Drawing.SolidFill)cellColorFill.CloneNode(true)); + rProps.RemoveAllChildren(); + InsertFillInRunProperties(rProps, (Drawing.SolidFill)cellColorFill.CloneNode(true)); } break; } @@ -1535,6 +1570,16 @@ void ApplyBorderLine(OpenXmlCompositeElement lineProps) break; } } + + // Ensure DrawingML CT_TextCharacterProperties child order (B-R9-2 / B-R13-2). + // Our switch arms append children independently (solidFill, latin, ea, ...), + // which produces a mixed order that OpenXmlValidator flags as schema violations + // and PowerPoint silently drops out-of-order elements. Reorder once at the end. + foreach (var rPr in cell.Descendants()) + ReorderDrawingRunProperties(rPr); + foreach (var endRPr in cell.Descendants()) + ReorderDrawingRunProperties(endRPr); + return unsupported; } diff --git a/src/officecli/Handlers/Pptx/PowerPointHandler.SvgPreview.cs b/src/officecli/Handlers/Pptx/PowerPointHandler.SvgPreview.cs index c7ad9ec5e..8cd476289 100644 --- a/src/officecli/Handlers/Pptx/PowerPointHandler.SvgPreview.cs +++ b/src/officecli/Handlers/Pptx/PowerPointHandler.SvgPreview.cs @@ -780,9 +780,11 @@ private void RenderChartSvg(StringBuilder sb, GraphicFrame gf, SlidePart slidePa var innerEnd = svgContent.LastIndexOf("", StringComparison.Ordinal); var innerSvg = svgContent[innerStart..innerEnd]; - // Extract chart title from HTML + // Extract chart title and font-size from HTML var titleMatch = System.Text.RegularExpressions.Regex.Match(chartHtml, @"font-weight:bold[^>]*>([^<]+)<"); var title = titleMatch.Success ? titleMatch.Groups[1].Value : ""; + var titleFsMatch = System.Text.RegularExpressions.Regex.Match(chartHtml, @"font-size:(\d+\.?\d*)pt"); + var titleFontPx = titleFsMatch.Success && double.TryParse(titleFsMatch.Groups[1].Value, out var tfp) ? (int)(tfp * 1.33) : 11; // Embed as nested SVG at the chart position sb.Append($""); @@ -795,7 +797,7 @@ private void RenderChartSvg(StringBuilder sb, GraphicFrame gf, SlidePart slidePa if (!string.IsNullOrEmpty(title)) { titleH = 16; - sb.Append($"{SvgEncode(title)}"); + sb.Append($"{SvgEncode(title)}"); } // Nested SVG for chart content @@ -1292,7 +1294,8 @@ private static void RenderTextBodyFO(StringBuilder sb, OpenXmlElement textBody, // Color var runFill = rp?.GetFirstChild(); - var color = ResolveFillColor(runFill, themeColors) ?? textColorOverride ?? "#000000"; + var color = ResolveFillColor(runFill, themeColors) ?? textColorOverride + ?? (themeColors.TryGetValue("dk1", out var dk1c) ? $"#{dk1c}" : "#000000"); styles.Add($"color:{color}"); // Character spacing diff --git a/src/officecli/Handlers/Pptx/PowerPointHandler.View.cs b/src/officecli/Handlers/Pptx/PowerPointHandler.View.cs index 905dd5d46..fb09db2a1 100644 --- a/src/officecli/Handlers/Pptx/PowerPointHandler.View.cs +++ b/src/officecli/Handlers/Pptx/PowerPointHandler.View.cs @@ -404,7 +404,7 @@ public List ViewAsIssues(string? issueType = null, int? limit = n Id = $"F{++issueNum}", Type = IssueType.Format, Severity = IssueSeverity.Info, - Path = $"/slide[{slideNum}]/shape[{shapeIdx + 1}]", + Path = $"/slide[{slideNum}]/{BuildElementPathSegment("shape", shape, shapeIdx + 1)}", Message = $"Inconsistent fonts in text box: {string.Join(", ", fonts)}" }); } diff --git a/src/officecli/Handlers/Word/WordHandler.Add.Media.cs b/src/officecli/Handlers/Word/WordHandler.Add.Media.cs index bf2865364..4942cee97 100644 --- a/src/officecli/Handlers/Word/WordHandler.Add.Media.cs +++ b/src/officecli/Handlers/Word/WordHandler.Add.Media.cs @@ -209,7 +209,7 @@ private string AddPicture(OpenXmlElement parent, string parentPath, int? index, imgCell.AppendChild(imgPara); } var imgPIdx = imgCell.Elements().ToList().IndexOf(imgPara) + 1; - resultPath = $"{parentPath}/p[{imgPIdx}]"; + resultPath = $"{parentPath}/{BuildParaPathSegment(imgPara, imgPIdx)}"; } else { @@ -220,12 +220,12 @@ private string AddPicture(OpenXmlElement parent, string parentPath, int? index, { var refPara = parent.Elements().ElementAt(index.Value); parent.InsertBefore(imgPara, refPara); - resultPath = $"{parentPath}/p[{index.Value + 1}]"; + resultPath = $"{parentPath}/{BuildParaPathSegment(imgPara, index.Value + 1)}"; } else { AppendToParent(parent, imgPara); - resultPath = $"{parentPath}/p[{imgParaCount + 1}]"; + resultPath = $"{parentPath}/{BuildParaPathSegment(imgPara, imgParaCount + 1)}"; } } return resultPath; diff --git a/src/officecli/Handlers/Word/WordHandler.Add.Misc.cs b/src/officecli/Handlers/Word/WordHandler.Add.Misc.cs index 496c1f1d6..a2a2e2941 100644 --- a/src/officecli/Handlers/Word/WordHandler.Add.Misc.cs +++ b/src/officecli/Handlers/Word/WordHandler.Add.Misc.cs @@ -262,8 +262,9 @@ private string AddHyperlink(OpenXmlElement parent, string parentPath, int? index return resultPath; } - private string AddField(OpenXmlElement parent, string parentPath, int? index, Dictionary properties, string type) + private string AddField(OpenXmlElement parent, string parentPath, int? index, Dictionary? properties, string type) { + properties ??= new Dictionary(); var body = _doc.MainDocumentPart?.Document?.Body ?? throw new InvalidOperationException("Document body not found"); @@ -357,7 +358,7 @@ private string AddField(OpenXmlElement parent, string parentPath, int? index, Di fNewPara.AppendChild(fieldRunEnd); AppendToParent(parent, fNewPara); var fIdx2 = body.Elements().TakeWhile(p => p != fNewPara).Count(); - resultPath = $"/body/p[{fIdx2 + 1}]"; + resultPath = $"/body/{BuildParaPathSegment(fNewPara, fIdx2 + 1)}"; } return resultPath; } @@ -393,7 +394,7 @@ private string AddBreak(OpenXmlElement parent, string parentPath, int? index, Di { brkPara.AppendChild(brkRun); var brkParaIdx = body.Elements().TakeWhile(p => p != brkPara).Count(); - resultPath = $"/body/p[{brkParaIdx + 1}]/r[{GetAllRuns(brkPara).Count}]"; + resultPath = $"/body/{BuildParaPathSegment(brkPara, brkParaIdx + 1)}/r[{GetAllRuns(brkPara).Count}]"; } else { @@ -401,7 +402,7 @@ private string AddBreak(OpenXmlElement parent, string parentPath, int? index, Di var brkNewPara = new Paragraph(brkRun); AppendToParent(parent, brkNewPara); var brkIdx = body.Elements().TakeWhile(p => p != brkNewPara).Count(); - resultPath = $"/body/p[{brkIdx + 1}]"; + resultPath = $"/body/{BuildParaPathSegment(brkNewPara, brkIdx + 1)}"; } return resultPath; } @@ -432,7 +433,8 @@ private string AddSdt(OpenXmlElement parent, string parentPath, int? index, Dict var sdtProps = new SdtProperties(); // ID - sdtProps.AppendChild(new SdtId { Val = (int)(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() % int.MaxValue) }); + var inlineSdtIdVal = NextSdtId(); + sdtProps.AppendChild(new SdtId { Val = inlineSdtIdVal }); if (!string.IsNullOrEmpty(alias)) sdtProps.AppendChild(new SdtAlias { Val = alias }); @@ -507,8 +509,12 @@ private string AddSdt(OpenXmlElement parent, string parentPath, int? index, Dict sdtRun.AppendChild(sdtContent); ((Paragraph)parent).AppendChild(sdtRun); - var sdtParaIdx = body.Elements().TakeWhile(p => p != parent).Count(); - resultPath = $"/body/p[{sdtParaIdx + 1}]/sdt[{((Paragraph)parent).Elements().Count()}]"; + // Build stable @paraId= and @sdtId= based path + var inlineParaId = ((Paragraph)parent).ParagraphId?.Value; + var inlineParaSegment = !string.IsNullOrEmpty(inlineParaId) + ? $"p[@paraId={inlineParaId}]" + : $"p[{body.Elements().TakeWhile(p => p != parent).Count() + 1}]"; + resultPath = $"/body/{inlineParaSegment}/sdt[@sdtId={inlineSdtIdVal}]"; } else { @@ -516,7 +522,7 @@ private string AddSdt(OpenXmlElement parent, string parentPath, int? index, Dict var sdtBlock = new SdtBlock(); var sdtProps = new SdtProperties(); - sdtProps.AppendChild(new SdtId { Val = (int)(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() % int.MaxValue) }); + sdtProps.AppendChild(new SdtId { Val = NextSdtId() }); if (!string.IsNullOrEmpty(alias)) sdtProps.AppendChild(new SdtAlias { Val = alias }); diff --git a/src/officecli/Handlers/Word/WordHandler.Add.Structure.cs b/src/officecli/Handlers/Word/WordHandler.Add.Structure.cs index acbbb584f..95941649f 100644 --- a/src/officecli/Handlers/Word/WordHandler.Add.Structure.cs +++ b/src/officecli/Handlers/Word/WordHandler.Add.Structure.cs @@ -37,8 +37,20 @@ private string AddSection(OpenXmlElement parent, string parentPath, int? index, var sectPr = new SectionProperties(); sectPr.AppendChild(new SectionType { Val = sectType }); - // Copy page size/margins from document section, or use A4 defaults + // Ensure body-level sectPr has pgSz/pgMar (fix for docs created by older versions) var bodySectPr = body.GetFirstChild(); + if (bodySectPr != null && bodySectPr.GetFirstChild() == null) + { + bodySectPr.InsertBefore(new PageSize { Width = 11906, Height = 16838 }, + bodySectPr.GetFirstChild()); + } + if (bodySectPr != null && bodySectPr.GetFirstChild() == null) + { + bodySectPr.InsertBefore(new PageMargin { Top = 1440, Right = 1800U, Bottom = 1440, Left = 1800U }, + bodySectPr.GetFirstChild()); + } + + // Copy page size/margins from document section, or use A4 defaults var srcPageSize = bodySectPr?.GetFirstChild(); sectPr.AppendChild(new PageSize { @@ -268,12 +280,23 @@ private string AddStyle(OpenXmlElement parent, string parentPath, int? index, Di _ => throw new ArgumentException($"Invalid style type: '{properties.GetValueOrDefault("type", "paragraph")}'. Valid values: paragraph, character, table, numbering.") }; + // Built-in styles must not have customStyle=true, or Word won't recognize them + // (e.g. TOC won't find Heading1 if it's marked as custom) + var builtInIds = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "Normal", "Heading1", "Heading2", "Heading3", "Heading4", "Heading5", + "Heading6", "Heading7", "Heading8", "Heading9", "Title", "Subtitle", + "Quote", "IntenseQuote", "ListParagraph", "NoSpacing", "TOCHeading" + }; + var isBuiltIn = builtInIds.Contains(styleId); + var newStyle = new Style { Type = styleType, StyleId = styleId, - CustomStyle = true }; + if (!isBuiltIn) + newStyle.CustomStyle = true; newStyle.AppendChild(new StyleName { Val = styleName }); if ((properties.TryGetValue("basedon", out var basedOn) || properties.TryGetValue("basedOn", out basedOn)) && !string.IsNullOrEmpty(basedOn)) diff --git a/src/officecli/Handlers/Word/WordHandler.Add.Table.cs b/src/officecli/Handlers/Word/WordHandler.Add.Table.cs index 3dd079d94..89ec5c442 100644 --- a/src/officecli/Handlers/Word/WordHandler.Add.Table.cs +++ b/src/officecli/Handlers/Word/WordHandler.Add.Table.cs @@ -178,7 +178,10 @@ private string AddTable(OpenXmlElement parent, string parentPath, int? index, Di table.AppendChild(row); } - AppendToParent(parent, table); + if (index.HasValue) + InsertAtPosition(parent, table, index); + else + AppendToParent(parent, table); var tblCount = parent.Elements
    ().Count(); return $"{parentPath}/tbl[{tblCount}]"; } diff --git a/src/officecli/Handlers/Word/WordHandler.Add.Text.cs b/src/officecli/Handlers/Word/WordHandler.Add.Text.cs index 2e33be3c8..53176728a 100644 --- a/src/officecli/Handlers/Word/WordHandler.Add.Text.cs +++ b/src/officecli/Handlers/Word/WordHandler.Add.Text.cs @@ -133,7 +133,7 @@ private string AddParagraph(OpenXmlElement parent, string parentPath, int? index if (properties.TryGetValue("start", out var sv)) startVal = ParseHelpers.SafeParseInt(sv, "start"); int? levelVal = null; - if (properties.TryGetValue("listLevel", out var ll) || properties.TryGetValue("listlevel", out ll) || properties.TryGetValue("level", out ll)) + if (properties.TryGetValue("listLevel", out var ll) || properties.TryGetValue("listlevel", out ll) || properties.TryGetValue("level", out ll) || properties.TryGetValue("numlevel", out ll)) levelVal = ParseHelpers.SafeParseInt(ll, "listLevel"); ApplyListStyle(para, listStyle, startVal, levelVal); // pProps already appended, skip the append below @@ -240,12 +240,12 @@ private string AddParagraph(OpenXmlElement parent, string parentPath, int? index { var refElement = parent.Elements().ElementAt(index.Value); parent.InsertBefore(para, refElement); - resultPath = $"{parentPath}/p[{index.Value + 1}]"; + resultPath = $"{parentPath}/{BuildParaPathSegment(para, index.Value + 1)}"; } else { AppendToParent(parent, para); - resultPath = $"{parentPath}/p[{paraCount + 1}]"; + resultPath = $"{parentPath}/{BuildParaPathSegment(para, paraCount + 1)}"; } return resultPath; } diff --git a/src/officecli/Handlers/Word/WordHandler.Add.cs b/src/officecli/Handlers/Word/WordHandler.Add.cs index 616237583..c2c0f84a2 100644 --- a/src/officecli/Handlers/Word/WordHandler.Add.cs +++ b/src/officecli/Handlers/Word/WordHandler.Add.cs @@ -15,7 +15,7 @@ namespace OfficeCli.Handlers; public partial class WordHandler { - public string Add(string parentPath, string type, int? index, Dictionary properties) + public string Add(string parentPath, string type, InsertPosition? position, Dictionary properties) { var body = _doc.MainDocumentPart?.Document?.Body ?? throw new InvalidOperationException("Document body not found"); @@ -24,11 +24,10 @@ public string Add(string parentPath, string type, int? index, Dictionary(); stylesPart.Styles ??= new Styles(); @@ -41,6 +40,18 @@ public string Add(string parentPath, string type, int? index, Dictionary AddParagraph(parent, parentPath, index, properties), diff --git a/src/officecli/Handlers/Word/WordHandler.FormFields.cs b/src/officecli/Handlers/Word/WordHandler.FormFields.cs index 24330011a..86002834c 100644 --- a/src/officecli/Handlers/Word/WordHandler.FormFields.cs +++ b/src/officecli/Handlers/Word/WordHandler.FormFields.cs @@ -253,7 +253,7 @@ private string AddFormField(OpenXmlElement parent, string parentPath, int? index para = new Paragraph(); bodyEl.AppendChild(para); var paraIdx = bodyEl.Elements().ToList().IndexOf(para) + 1; - parentPath = $"/body/p[{paraIdx}]"; + parentPath = $"/body/{BuildParaPathSegment(para, paraIdx)}"; } else { diff --git a/src/officecli/Handlers/Word/WordHandler.Helpers.cs b/src/officecli/Handlers/Word/WordHandler.Helpers.cs index 2474d6509..ea01176fb 100644 --- a/src/officecli/Handlers/Word/WordHandler.Helpers.cs +++ b/src/officecli/Handlers/Word/WordHandler.Helpers.cs @@ -98,9 +98,29 @@ private static void AppendToParent(OpenXmlElement parent, OpenXmlElement child) private static double ParseFontSize(string value) => ParseHelpers.ParseFontSize(value); + /// + /// Get footnote/endnote text, skipping the reference mark run and its trailing space. + /// + private static string GetFootnoteText(OpenXmlElement fnOrEn) + { + return string.Join("", fnOrEn.Descendants() + .Where(r => r.GetFirstChild() == null + && r.GetFirstChild() == null) + .SelectMany(r => r.Elements()) + .Select(t => t.Text)).TrimStart(); + } + private static string GetParagraphText(Paragraph para) { - return string.Concat(para.Elements().SelectMany(r => r.Elements()).Select(t => t.Text)); + var sb = new StringBuilder(); + foreach (var child in para.ChildElements) + { + if (child is Run run) + sb.Append(string.Concat(run.Elements().Select(t => t.Text))); + else if (child is Hyperlink hyperlink) + sb.Append(string.Concat(hyperlink.Descendants().Select(t => t.Text))); + } + return sb.ToString(); } /// @@ -190,7 +210,7 @@ private static List GetAllRuns(Paragraph para) { var hasRange = paragraphs[i].Descendants() .Any(rs => rs.Id?.Value == commentId); - if (hasRange) return $"/body/p[{i + 1}]"; + if (hasRange) return $"/body/{BuildParaPathSegment(paragraphs[i], i + 1)}"; } return null; } @@ -483,30 +503,120 @@ private static void ApplyRunFormatting(OpenXmlCompositeElement props, string key break; case "bold": props.RemoveAllChildren(); - if (IsTruthy(value)) props.AppendChild(new Bold()); + if (IsTruthy(value)) InsertRunPropInSchemaOrder(props, new Bold()); break; case "italic": props.RemoveAllChildren(); - if (IsTruthy(value)) props.AppendChild(new Italic()); + if (IsTruthy(value)) InsertRunPropInSchemaOrder(props, new Italic()); break; case "color": props.RemoveAllChildren(); - props.AppendChild(new Color { Val = SanitizeHex(value) }); + InsertRunPropInSchemaOrder(props, new Color { Val = SanitizeHex(value) }); break; case "highlight": props.RemoveAllChildren(); - props.AppendChild(new Highlight { Val = ParseHighlightColor(value) }); + InsertRunPropInSchemaOrder(props, new Highlight { Val = ParseHighlightColor(value) }); break; case "underline": props.RemoveAllChildren(); var ulMapped = value.ToLowerInvariant() switch { "true" => "single", "false" or "none" => "none", _ => value }; - props.AppendChild(new Underline { Val = new UnderlineValues(ulMapped) }); + InsertRunPropInSchemaOrder(props, new Underline { Val = new UnderlineValues(ulMapped) }); break; case "strike": props.RemoveAllChildren(); - if (IsTruthy(value)) props.AppendChild(new Strike()); + if (IsTruthy(value)) InsertRunPropInSchemaOrder(props, new Strike()); + break; + case "charspacing" or "charSpacing" or "letterspacing" or "letterSpacing" or "spacing": + var csPt = value.EndsWith("pt", StringComparison.OrdinalIgnoreCase) + ? ParseHelpers.SafeParseDouble(value[..^2], "charspacing") + : ParseHelpers.SafeParseDouble(value, "charspacing"); + props.RemoveAllChildren(); + InsertRunPropInSchemaOrder(props, new Spacing { Val = (int)Math.Round(csPt * 20, MidpointRounding.AwayFromZero) }); + break; + case "shading" or "shd": + props.RemoveAllChildren(); + var shdParts = value.Split(';'); + if (shdParts.Length == 1) + props.AppendChild(new Shading { Val = ShadingPatternValues.Clear, Fill = SanitizeHex(shdParts[0]) }); + else + { + var shd = new Shading { Val = new ShadingPatternValues(shdParts[0]), Fill = SanitizeHex(shdParts[1]) }; + if (shdParts.Length >= 3) shd.Color = SanitizeHex(shdParts[2]); + props.AppendChild(shd); + } break; + case "superscript": + props.RemoveAllChildren(); + if (IsTruthy(value)) + props.AppendChild(new VerticalTextAlignment { Val = VerticalPositionValues.Superscript }); + break; + case "subscript": + props.RemoveAllChildren(); + if (IsTruthy(value)) + props.AppendChild(new VerticalTextAlignment { Val = VerticalPositionValues.Subscript }); + break; + case "caps": + props.RemoveAllChildren(); + if (IsTruthy(value)) InsertRunPropInSchemaOrder(props, new Caps()); + break; + case "smallcaps": + props.RemoveAllChildren(); + if (IsTruthy(value)) InsertRunPropInSchemaOrder(props, new SmallCaps()); + break; + case "vanish": + props.RemoveAllChildren(); + if (IsTruthy(value)) InsertRunPropInSchemaOrder(props, new Vanish()); + break; + } + } + + /// + /// Insert a run property element in the correct CT_RPr schema position. + /// CT_RPr order: rFonts, b, bCs, i, iCs, caps, smallCaps, strike, dstrike, outline, shadow, + /// emboss, imprint, noProof, snapToGrid, vanish, webHidden, color, spacing, w, kern, position, + /// sz, szCs, highlight, u, effect, ... + /// + private static void InsertRunPropInSchemaOrder(OpenXmlCompositeElement props, OpenXmlElement elem) + { + // Map element types to their position in the CT_RPr schema sequence. + // Only the types we actually use are listed; unlisted types get a high index (appended at end). + static int SchemaIndex(OpenXmlElement e) => e switch + { + RunFonts => 0, + Bold => 1, + BoldComplexScript => 2, + Italic => 3, + ItalicComplexScript => 4, + Caps => 5, + SmallCaps => 6, + Strike => 7, + // dstrike, outline, shadow, emboss, imprint, noProof, snapToGrid + Vanish => 14, + // webHidden = 15 + Color => 16, + Spacing => 17, + // w = 18, kern = 19, position = 20 + FontSize => 21, + FontSizeComplexScript => 22, + Highlight => 23, + Underline => 24, + // effect, ... + _ => 100, + }; + + int targetIdx = SchemaIndex(elem); + + // Find the first existing child whose schema position is after the element we're inserting + foreach (var child in props.ChildElements) + { + if (SchemaIndex(child) > targetIdx) + { + child.InsertBeforeSelf(elem); + return; + } } + // No later element found — append at end + props.AppendChild(elem); } private static string GetBookmarkText(BookmarkStart bkStart) @@ -527,38 +637,280 @@ private static string GetBookmarkText(BookmarkStart bkStart) return sb.ToString(); } + // ==================== Find / Format / Replace ==================== + /// - /// Find and replace text across the document. Returns the number of replacements made. - /// Handles text split across multiple runs within a paragraph. + /// Build a flat list of (Run, Text, charStart, charEnd) spans for a paragraph. + /// Uses Descendants to include runs inside hyperlinks, w:ins, w:del, etc. + /// Shared by ProcessFindInParagraph, SplitRunsAtRange, etc. /// - private int FindAndReplace(string find, string replace, string scope = "all") + private static List<(Run Run, Text TextElement, int Start, int End)> BuildRunTexts(Paragraph para) { - if (string.IsNullOrEmpty(find)) return 0; - int totalCount = 0; + var runTexts = new List<(Run Run, Text TextElement, int Start, int End)>(); + int pos = 0; + foreach (var run in para.Descendants()) + { + foreach (var text in run.Elements()) + { + var len = text.Text?.Length ?? 0; + if (len > 0) + runTexts.Add((run, text, pos, pos + len)); + pos += len; + } + } + return runTexts; + } - // Collect all paragraphs to process based on scope - var paragraphs = new List(); - var mainPart = _doc.MainDocumentPart; + /// + /// Parse a find pattern: plain text or regex (r"..." prefix). + /// Returns (pattern, isRegex). + /// + private static (string Pattern, bool IsRegex) ParseFindPattern(string value) + { + // r"..." or r'...' → regex + if (value.Length >= 3 && value[0] == 'r' && (value[1] == '"' || value[1] == '\'')) + { + var quote = value[1]; + var endIdx = value.LastIndexOf(quote); + if (endIdx > 1) + return (value[2..endIdx], true); + } + return (value, false); + } - if (scope is "all" or "body" or "") + /// + /// Find all match ranges in fullText using either plain text or regex. + /// Returns list of (start, length) pairs, sorted by start ascending. + /// + private static List<(int Start, int Length)> FindMatchRanges(string fullText, string pattern, bool isRegex) + { + var ranges = new List<(int Start, int Length)>(); + if (isRegex) { - if (mainPart?.Document?.Body != null) - paragraphs.AddRange(mainPart.Document.Body.Descendants()); + try + { + foreach (System.Text.RegularExpressions.Match m in + System.Text.RegularExpressions.Regex.Matches(fullText, pattern)) + { + if (m.Length > 0) // skip zero-length matches + ranges.Add((m.Index, m.Length)); + } + } + catch (System.Text.RegularExpressions.RegexParseException ex) + { + throw new ArgumentException($"Invalid regex pattern '{pattern}': {ex.Message}", ex); + } } - if (scope is "all" or "headers") + else + { + int idx = 0; + while ((idx = fullText.IndexOf(pattern, idx, StringComparison.Ordinal)) >= 0) + { + ranges.Add((idx, pattern.Length)); + idx += pattern.Length; + } + } + return ranges; + } + + /// + /// Split a run at a character offset within its text content. + /// Returns the new right-side run (inserted after the original). + /// The original run keeps text [0..charOffset), new run gets [charOffset..). + /// RunProperties are deep-cloned. rsidR is cleared on the new run. + /// + private static Run SplitRunAtOffset(Run run, int charOffset) + { + // Find the Text element containing the split point + int pos = 0; + foreach (var text in run.Elements().ToList()) + { + var len = text.Text?.Length ?? 0; + if (pos + len > charOffset && charOffset > pos) + { + var localOffset = charOffset - pos; + var leftText = text.Text![..localOffset]; + var rightText = text.Text![localOffset..]; + + // Clone the run for the right side + var rightRun = (Run)run.CloneNode(true); + // Clear rsidR on cloned run + rightRun.RsidRunProperties = null; + rightRun.RsidRunAddition = null; + + // Set left run text + text.Text = leftText; + text.Space = SpaceProcessingModeValues.Preserve; + + // Set right run text — find corresponding Text in clone + var rightTexts = rightRun.Elements().ToList(); + // The cloned run has same structure; find the matching Text node + int textIdx = run.Elements().ToList().IndexOf(text); + if (textIdx >= 0 && textIdx < rightTexts.Count) + { + rightTexts[textIdx].Text = rightText; + rightTexts[textIdx].Space = SpaceProcessingModeValues.Preserve; + // Remove any Text elements before the split Text in right run + for (int i = 0; i < textIdx; i++) + rightTexts[i].Text = ""; + } + + // Insert right run after original + run.InsertAfterSelf(rightRun); + return rightRun; + } + pos += len; + } + // charOffset is at boundary — shouldn't normally be called, return run itself + return run; + } + + /// + /// Split runs in a paragraph so that the character range [charStart, charEnd) + /// is covered by dedicated runs. Returns the list of runs covering that range. + /// + private static List SplitRunsAtRange(Paragraph para, int charStart, int charEnd) + { + // Split at charEnd first (so charStart offsets remain valid) + var runTexts = BuildRunTexts(para); + foreach (var rt in runTexts) + { + if (charEnd > rt.Start && charEnd < rt.End) + { + var localOffset = charEnd - rt.Start; + SplitRunAtOffset(rt.Run, localOffset); + break; + } + } + + // Rebuild after split, then split at charStart + runTexts = BuildRunTexts(para); + foreach (var rt in runTexts) + { + if (charStart > rt.Start && charStart < rt.End) + { + var localOffset = charStart - rt.Start; + SplitRunAtOffset(rt.Run, localOffset); + break; + } + } + + // Rebuild and collect runs covering [charStart, charEnd) + runTexts = BuildRunTexts(para); + var result = new List(); + foreach (var rt in runTexts) { - foreach (var hp in mainPart?.HeaderParts ?? Enumerable.Empty()) - if (hp.Header != null) paragraphs.AddRange(hp.Header.Descendants()); + if (rt.Start >= charStart && rt.End <= charEnd) + result.Add(rt.Run); } - if (scope is "all" or "footers") + return result; + } + + /// + /// Unified find operation on a paragraph: replace text and/or apply formatting. + /// Returns the number of matches processed. + /// + private static int ProcessFindInParagraph( + Paragraph para, + string pattern, + bool isRegex, + string? replace, + Dictionary? formatProps) + { + var runTexts = BuildRunTexts(para); + if (runTexts.Count == 0) return 0; + + var fullText = string.Concat(runTexts.Select(rt => rt.TextElement.Text)); + var matches = FindMatchRanges(fullText, pattern, isRegex); + if (matches.Count == 0) return 0; + + // Process from end to start to preserve character offsets + for (int i = matches.Count - 1; i >= 0; i--) { - foreach (var fp in mainPart?.FooterParts ?? Enumerable.Empty()) - if (fp.Footer != null) paragraphs.AddRange(fp.Footer.Descendants()); + var (matchStart, matchLen) = matches[i]; + var matchEnd = matchStart + matchLen; + + if (replace != null) + { + // Step 1: Replace text in affected runs (same logic as old ReplaceInParagraph) + var currentRunTexts = BuildRunTexts(para); + bool first = true; + foreach (var rt in currentRunTexts) + { + if (rt.End <= matchStart || rt.Start >= matchEnd) + continue; + + var textStr = rt.TextElement.Text ?? ""; + var localStart = Math.Max(0, matchStart - rt.Start); + var localEnd = Math.Min(textStr.Length, matchEnd - rt.Start); + + if (first) + { + rt.TextElement.Text = textStr[..localStart] + replace + textStr[localEnd..]; + rt.TextElement.Space = SpaceProcessingModeValues.Preserve; + first = false; + } + else + { + rt.TextElement.Text = textStr[..Math.Max(0, matchStart - rt.Start)] + textStr[localEnd..]; + rt.TextElement.Space = SpaceProcessingModeValues.Preserve; + } + } + + // Step 2: If format props, split at the replaced text position and apply + if (formatProps != null && formatProps.Count > 0) + { + // The replaced text now starts at matchStart with length = replace.Length + var replacedEnd = matchStart + replace.Length; + if (replace.Length > 0) + { + var targetRuns = SplitRunsAtRange(para, matchStart, replacedEnd); + foreach (var run in targetRuns) + { + var rPr = EnsureRunProperties(run); + foreach (var (key, value) in formatProps) + ApplyRunFormatting(rPr, key, value); + } + } + } + } + else if (formatProps != null && formatProps.Count > 0) + { + // No replace, just split and format + var targetRuns = SplitRunsAtRange(para, matchStart, matchEnd); + foreach (var run in targetRuns) + { + var rPr = EnsureRunProperties(run); + foreach (var (key, value) in formatProps) + ApplyRunFormatting(rPr, key, value); + } + } } + return matches.Count; + } + + /// + /// Unified find operation: process find/replace/format across paragraphs resolved from a path. + /// Called from Set when 'find' key is present. + /// Returns (matchCount, unsupportedKeys). + /// + private int ProcessFind( + string path, + string findValue, + string? replace, + Dictionary formatProps) + { + var (pattern, isRegex) = ParseFindPattern(findValue); + if (string.IsNullOrEmpty(pattern) && !isRegex) return 0; + + // Resolve paragraphs from path + var paragraphs = ResolveParagraphsForFind(path); + + int totalCount = 0; foreach (var para in paragraphs) { - var count = ReplaceInParagraph(para, find, replace); + var count = ProcessFindInParagraph(para, pattern, isRegex, replace, formatProps.Count > 0 ? formatProps : null); if (count > 0) para.TextId = GenerateParaId(); totalCount += count; @@ -568,75 +920,297 @@ private int FindAndReplace(string find, string replace, string scope = "all") } /// - /// Replace text within a paragraph, handling text split across multiple runs. + /// Resolve paragraphs for a find operation based on path. + /// "/" or "/body" → body paragraphs; "/header[N]" → header N; "/footer[N]" → footer N; + /// "/paragraph[N]" → specific paragraph; selector → query results. /// - private static int ReplaceInParagraph(Paragraph para, string find, string replace) + private List ResolveParagraphsForFind(string path) { - var runs = para.Elements().ToList(); - if (runs.Count == 0) return 0; + var paragraphs = new List(); + var mainPart = _doc.MainDocumentPart; - // Build concatenated text with run boundaries - var runTexts = new List<(Run Run, Text TextElement, int Start, int End)>(); - int pos = 0; - foreach (var run in runs) + if (path is "/" or "" or "/body") { - foreach (var text in run.Elements()) + if (mainPart?.Document?.Body != null) + paragraphs.AddRange(mainPart.Document.Body.Descendants()); + } + else if (path.StartsWith("/header[", StringComparison.OrdinalIgnoreCase)) + { + var idx = ParseHelpers.SafeParseInt(path.Split('[', ']')[1], "header index") - 1; + var headerPart = mainPart?.HeaderParts.ElementAtOrDefault(idx); + if (headerPart?.Header != null) + paragraphs.AddRange(headerPart.Header.Descendants()); + } + else if (path.StartsWith("/footer[", StringComparison.OrdinalIgnoreCase)) + { + var idx = ParseHelpers.SafeParseInt(path.Split('[', ']')[1], "footer index") - 1; + var footerPart = mainPart?.FooterParts.ElementAtOrDefault(idx); + if (footerPart?.Footer != null) + paragraphs.AddRange(footerPart.Footer.Descendants()); + } + else if (path.StartsWith("/")) + { + // Specific element path — navigate to it and collect its paragraphs + var element = NavigateToElement(ParsePath(path)); + if (element is Paragraph p) + paragraphs.Add(p); + else if (element != null) + paragraphs.AddRange(element.Descendants()); + } + else + { + // Selector — query and resolve each result's paragraphs + var targets = Query(path); + foreach (var target in targets) { - var len = text.Text?.Length ?? 0; - if (len > 0) - runTexts.Add((run, text, pos, pos + len)); - pos += len; + var elem = NavigateToElement(ParsePath(target.Path)); + if (elem is Paragraph tp) + paragraphs.Add(tp); + else if (elem != null) + paragraphs.AddRange(elem.Descendants()); } } - if (runTexts.Count == 0) return 0; + return paragraphs; + } + + // ==================== Add at find position ==================== + + private static readonly HashSet InlineTypes = new(StringComparer.OrdinalIgnoreCase) + { + "run", "r", "picture", "image", "img", "hyperlink", "link", + "field", "pagenum", "pagenumber", "page", "numpages", "date", "author", + "pagebreak", "columnbreak", "break", "footnote", "endnote", + "equation", "formula", "math", "bookmark", "formfield" + }; + + /// + /// Add an element at a text-find position within a paragraph. + /// For inline types: split the run at the find position and insert inline. + /// For block types: split the paragraph at the find position and insert the block element between. + /// + private string AddAtFindPosition( + OpenXmlElement parent, + string parentPath, + string type, + string findValue, + bool isAfter, // true = after-find, false = before-find + InsertPosition? position, + Dictionary properties) + { + // Parent must be a paragraph (or we navigate to one) + Paragraph para; + if (parent is Paragraph p) + para = p; + else + throw new ArgumentException("after=\"find:...\" / before=\"find:...\" requires a paragraph parent path (e.g. /body/p[1]), not a section-level path like /body."); + + // Support regex=true prop as alternative to r"..." prefix + // CONSISTENCY(find-regex): mirror of WordHandler.Set.cs:60-61. grep + // "CONSISTENCY(find-regex)" for every project-wide call site. + if (properties.TryGetValue("regex", out var regexFlag) && ParseHelpers.IsTruthySafe(regexFlag) && !findValue.StartsWith("r\"") && !findValue.StartsWith("r'")) + findValue = $"r\"{findValue}\""; + + var (pattern, isRegex) = ParseFindPattern(findValue); + var runTexts = BuildRunTexts(para); + if (runTexts.Count == 0) + throw new ArgumentException("Paragraph has no text content to search."); + var fullText = string.Concat(runTexts.Select(rt => rt.TextElement.Text)); + var matches = FindMatchRanges(fullText, pattern, isRegex); + if (matches.Count == 0) + throw new ArgumentException($"Text '{findValue}' not found in paragraph."); + + // Use first match + var (matchStart, matchLen) = matches[0]; + var splitPoint = isAfter ? matchStart + matchLen : matchStart; + + bool isInline = InlineTypes.Contains(type); - // Find all occurrences - var indices = new List(); - int idx = 0; - while ((idx = fullText.IndexOf(find, idx, StringComparison.Ordinal)) >= 0) + if (isInline) { - indices.Add(idx); - idx += find.Length; + return AddInlineAtSplitPoint(para, parentPath, splitPoint, type, position, properties); } + else + { + return AddBlockAtSplitPoint(para, parentPath, splitPoint, type, position, properties); + } + } - if (indices.Count == 0) return 0; + /// + /// Insert an inline element at a character split point within a paragraph. + /// Splits the run at the position and inserts the element. + /// + private string AddInlineAtSplitPoint( + Paragraph para, + string parentPath, + int splitPoint, + string type, + InsertPosition? position, + Dictionary properties) + { + // Split runs at the point + var runTexts = BuildRunTexts(para); + Run? insertAfterRun = null; - // Process replacements from end to start to preserve positions - for (int i = indices.Count - 1; i >= 0; i--) + foreach (var rt in runTexts) { - var matchStart = indices[i]; - var matchEnd = matchStart + find.Length; - - // Find which run-texts are affected - bool first = true; - foreach (var rt in runTexts) + if (splitPoint >= rt.Start && splitPoint <= rt.End) { - if (rt.End <= matchStart || rt.Start >= matchEnd) - continue; // not affected - - var textStr = rt.TextElement.Text ?? ""; - var localStart = Math.Max(0, matchStart - rt.Start); - var localEnd = Math.Min(textStr.Length, matchEnd - rt.Start); - - if (first) + if (splitPoint == rt.Start) + { + // Insert before this run — find previous run + insertAfterRun = rt.Run.PreviousSibling(); + } + else if (splitPoint == rt.End) { - // First affected run: replace the matched portion with replacement text - rt.TextElement.Text = textStr[..localStart] + replace + textStr[localEnd..]; - rt.TextElement.Space = SpaceProcessingModeValues.Preserve; - first = false; + // Insert after this run + insertAfterRun = rt.Run; } else { - // Subsequent runs: just remove the matched portion - rt.TextElement.Text = textStr[..Math.Max(0, matchStart - rt.Start)] + textStr[localEnd..]; - rt.TextElement.Space = SpaceProcessingModeValues.Preserve; + // Split the run at the offset + var localOffset = splitPoint - rt.Start; + SplitRunAtOffset(rt.Run, localOffset); + insertAfterRun = rt.Run; // insert after the left portion } + break; + } + } + + // Calculate run-based index for insertion + var runs = para.Elements().ToList(); + int runIndex; + if (insertAfterRun != null) + { + var idx = runs.IndexOf(insertAfterRun); + runIndex = idx >= 0 ? idx + 1 : runs.Count; + } + else + { + runIndex = 0; // insert before all runs + } + + // Delegate to normal Add with calculated run index + return Add(parentPath, type, InsertPosition.AtIndex(runIndex), properties); + } + + /// + /// Insert a block element at a character split point within a paragraph. + /// Splits the paragraph into two and inserts the block element between them. + /// + private string AddBlockAtSplitPoint( + Paragraph para, + string parentPath, + int splitPoint, + string type, + InsertPosition? position, + Dictionary properties) + { + var runTexts = BuildRunTexts(para); + var fullText = string.Concat(runTexts.Select(rt => rt.TextElement.Text)); + + // If split point is at the very end, just insert after the paragraph + if (splitPoint >= fullText.Length) + { + var bodyPath = parentPath.Contains('/') ? parentPath[..parentPath.LastIndexOf('/')] : "/body"; + return Add(bodyPath, type, InsertPosition.AfterElement(parentPath.Split('/').Last()), properties); + } + + // If split point is at the very beginning, just insert before the paragraph + if (splitPoint <= 0) + { + var bodyPath = parentPath.Contains('/') ? parentPath[..parentPath.LastIndexOf('/')] : "/body"; + return Add(bodyPath, type, InsertPosition.BeforeElement(parentPath.Split('/').Last()), properties); + } + + // Split runs at the point + foreach (var rt in runTexts) + { + if (splitPoint > rt.Start && splitPoint < rt.End) + { + var localOffset = splitPoint - rt.Start; + SplitRunAtOffset(rt.Run, localOffset); + break; + } + } + + // Rebuild run list after split + runTexts = BuildRunTexts(para); + fullText = string.Concat(runTexts.Select(rt => rt.TextElement.Text)); + + // Find the first run that starts at or after splitPoint + Run? firstRightRun = null; + foreach (var rt in runTexts) + { + if (rt.Start >= splitPoint) + { + firstRightRun = rt.Run; + break; + } + } + + if (firstRightRun == null) + { + // All text before split — insert after paragraph + var bodyPath = parentPath.Contains('/') ? parentPath[..parentPath.LastIndexOf('/')] : "/body"; + return Add(bodyPath, type, InsertPosition.AfterElement(parentPath.Split('/').Last()), properties); + } + + // Create a new paragraph for the right portion, inheriting paragraph properties + var rightPara = new Paragraph(); + if (para.ParagraphProperties != null) + rightPara.ParagraphProperties = (ParagraphProperties)para.ParagraphProperties.CloneNode(true); + AssignParaId(rightPara); + + // Move runs from firstRightRun onwards to the new paragraph + var runsToMove = new List(); + OpenXmlElement? current = firstRightRun; + while (current != null) + { + runsToMove.Add(current); + current = current.NextSibling(); + // Stop if we hit another paragraph-level structure (shouldn't happen normally) + } + // Filter: only move runs and inline elements, not ParagraphProperties + foreach (var elem in runsToMove) + { + if (elem is ParagraphProperties) continue; + elem.Remove(); + rightPara.AppendChild(elem); + } + + // Collect existing children before Add, so we can find the newly added element + var parentOfPara = para.Parent!; + var childrenBefore = new HashSet(parentOfPara.ChildElements); + + // Insert rightPara after the original paragraph + para.InsertAfterSelf(rightPara); + + // Add the block element via normal Add (appends before sectPr) + var bodyParentPath = parentPath.Contains('/') ? parentPath[..parentPath.LastIndexOf('/')] : "/body"; + var result = Add(bodyParentPath, type, null, properties); + + // Find the newly added element (the one not in childrenBefore and not rightPara) + OpenXmlElement? addedElement = null; + foreach (var child in parentOfPara.ChildElements) + { + if (!childrenBefore.Contains(child) && child != rightPara) + { + addedElement = child; + break; } } - return indices.Count; + // Move it between para and rightPara + if (addedElement != null) + { + addedElement.Remove(); + parentOfPara.InsertAfter(addedElement, para); + } + + _doc.MainDocumentPart?.Document?.Save(); + return result; } /// @@ -1152,16 +1726,33 @@ private bool IsSdtEditable(SdtProperties? sdtProps) /// /// Generate a unique 8-character uppercase hex ID for w14:paraId / w14:textId. + /// OOXML spec requires value < 0x80000000 (MaxExclusive). + /// Uses deterministic increment from _nextParaId, wraps around on overflow, + /// skips IDs already in use. /// - private static string GenerateParaId() + private string GenerateParaId() { - return Guid.NewGuid().ToString("N")[..8].ToUpperInvariant(); + const int maxExclusive = 0x7FFFFFFF; // OOXML spec limit + const int minStartId = 0x100000; + var startId = _nextParaId; + while (true) + { + var id = _nextParaId.ToString("X8"); + _nextParaId++; + if (_nextParaId > maxExclusive) + _nextParaId = minStartId; + if (_usedParaIds.Add(id)) + return id; + // Safety: if we've wrapped all the way around, something is very wrong + if (_nextParaId == startId) + throw new InvalidOperationException("No available paraId slots"); + } } /// /// Assign paraId and textId to a paragraph if not already set. /// - private static void AssignParaId(Paragraph para) + private void AssignParaId(Paragraph para) { if (string.IsNullOrEmpty(para.ParagraphId?.Value)) para.ParagraphId = GenerateParaId(); @@ -1178,7 +1769,7 @@ private void EnsureAllParaIds() var mainPart = _doc.MainDocumentPart; if (mainPart?.Document?.Body == null) return; - var usedIds = new HashSet(StringComparer.OrdinalIgnoreCase); + _usedParaIds = new HashSet(StringComparer.OrdinalIgnoreCase); // Collect all paragraphs from body + headers + footers var allParagraphs = mainPart.Document.Body.Descendants().AsEnumerable(); @@ -1191,31 +1782,83 @@ private void EnsureAllParaIds() var paragraphs = allParagraphs.ToList(); - // Collect existing IDs first to avoid collisions + // Collect existing IDs, detect duplicates, and track max for deterministic increment + var paraIdSeen = new HashSet(StringComparer.OrdinalIgnoreCase); + int maxId = 0; + foreach (var para in paragraphs) { + // Fix duplicate paraId: if already seen, clear it so it gets reassigned below if (!string.IsNullOrEmpty(para.ParagraphId?.Value)) - usedIds.Add(para.ParagraphId.Value); + { + if (!paraIdSeen.Add(para.ParagraphId.Value)) + { + para.ParagraphId = null!; // duplicate — will be reassigned + } + else + { + _usedParaIds.Add(para.ParagraphId.Value); + if (int.TryParse(para.ParagraphId.Value, System.Globalization.NumberStyles.HexNumber, null, out var numId) && numId > maxId) + maxId = numId; + } + } if (!string.IsNullOrEmpty(para.TextId?.Value)) - usedIds.Add(para.TextId.Value); + { + _usedParaIds.Add(para.TextId.Value); + if (int.TryParse(para.TextId.Value, System.Globalization.NumberStyles.HexNumber, null, out var numId) && numId > maxId) + maxId = numId; + } } - // Assign IDs to paragraphs that don't have them + // Start deterministic increment from max+1, minimum 0x100000 to avoid conflicts with small IDs + const int minStartId = 0x100000; + _nextParaId = Math.Max(maxId + 1, minStartId); + if (_nextParaId > 0x7FFFFFFF) _nextParaId = minStartId; + + // Assign IDs to paragraphs that don't have them (including cleared duplicates) foreach (var para in paragraphs) { if (string.IsNullOrEmpty(para.ParagraphId?.Value)) - { - string newId; - do { newId = GenerateParaId(); } while (!usedIds.Add(newId)); - para.ParagraphId = newId; - } + para.ParagraphId = GenerateParaId(); if (string.IsNullOrEmpty(para.TextId?.Value)) + para.TextId = GenerateParaId(); + } + + // Ensure mc:Ignorable includes "w14" so Word 2007 skips w14:paraId/textId attributes + var doc = mainPart.Document; + const string mcNs = "http://schemas.openxmlformats.org/markup-compatibility/2006"; + if (doc.LookupNamespace("mc") == null) + doc.AddNamespaceDeclaration("mc", mcNs); + if (doc.LookupNamespace("w14") == null) + doc.AddNamespaceDeclaration("w14", "http://schemas.microsoft.com/office/word/2010/wordml"); + var ignorable = doc.MCAttributes?.Ignorable?.Value ?? ""; + if (!ignorable.Contains("w14")) + { + doc.MCAttributes ??= new DocumentFormat.OpenXml.MarkupCompatibilityAttributes(); + doc.MCAttributes.Ignorable = string.IsNullOrEmpty(ignorable) ? "w14" : $"{ignorable} w14"; + } + } + + // ==================== SDT IDs (content controls) ==================== + + /// + /// Generate a deterministic unique SdtId by scanning max existing value + 1. + /// + private int NextSdtId() + { + const int overflowReset = 872011; + int maxId = 0; + var body = _doc.MainDocumentPart?.Document?.Body; + if (body != null) + { + foreach (var sdtId in body.Descendants()) { - string newId; - do { newId = GenerateParaId(); } while (!usedIds.Add(newId)); - para.TextId = newId; + if (sdtId.Val?.HasValue == true && sdtId.Val.Value > maxId) + maxId = sdtId.Val.Value; } } + var next = maxId + 1; + return next > int.MaxValue - 1 ? overflowReset : next; } // ==================== DocPr IDs (pictures, charts) ==================== diff --git a/src/officecli/Handlers/Word/WordHandler.HtmlPreview.Charts.cs b/src/officecli/Handlers/Word/WordHandler.HtmlPreview.Charts.cs index bf25b27a4..c24e09c05 100644 --- a/src/officecli/Handlers/Word/WordHandler.HtmlPreview.Charts.cs +++ b/src/officecli/Handlers/Word/WordHandler.HtmlPreview.Charts.cs @@ -38,14 +38,15 @@ private void RenderChartHtml(StringBuilder sb, Drawing drawing, OpenXmlElement c int svgW = extent?.Cx?.Value > 0 ? (int)(extent.Cx.Value / 9525) : 500; int svgH = extent?.Cy?.Value > 0 ? (int)(extent.Cy.Value / 9525) : 300; - // Renderer with light-background colors + // Renderer — use chart XML colors if available, else reasonable defaults var renderer = new ChartSvgRenderer { - CatColor = "#333333", - AxisColor = "#555555", - ValueColor = "#444444", - GridColor = "#ddd", - AxisLineColor = "#999", + ThemeAccentColors = ChartSvgRenderer.BuildThemeAccentColors(GetThemeColors()), + CatColor = info.CatFontColor != null ? $"#{info.CatFontColor}" : "#333333", + AxisColor = info.ValFontColor != null ? $"#{info.ValFontColor}" : "#555555", + ValueColor = info.ValFontColor != null ? $"#{info.ValFontColor}" : "#444444", + GridColor = info.GridlineColor != null ? $"#{info.GridlineColor}" : "#ddd", + AxisLineColor = info.AxisLineColor != null ? $"#{info.AxisLineColor}" : "#999", ValFontPx = info.ValFontPx, CatFontPx = info.CatFontPx }; diff --git a/src/officecli/Handlers/Word/WordHandler.HtmlPreview.Css.cs b/src/officecli/Handlers/Word/WordHandler.HtmlPreview.Css.cs index bce8062c5..e760c4d45 100644 --- a/src/officecli/Handlers/Word/WordHandler.HtmlPreview.Css.cs +++ b/src/officecli/Handlers/Word/WordHandler.HtmlPreview.Css.cs @@ -340,9 +340,9 @@ private string GetParagraphInlineCss(Paragraph para, bool isListItem = false) lineHMult = dlvi / 240.0; var bodyLineH = defSz * lineHMult; var dropCapHeight = lineCount * bodyLineH; - // Read hSpace from framePr (default ~3pt) + // Read hSpace from framePr (OOXML spec default: 0) var hSpaceAttr = framePr.GetAttributes().FirstOrDefault(a => a.LocalName == "hSpace").Value; - var hSpacePt = hSpaceAttr != null && int.TryParse(hSpaceAttr, out var hsTwips) ? hsTwips / 20.0 : 3.0; + var hSpacePt = hSpaceAttr != null && int.TryParse(hSpaceAttr, out var hsTwips) ? hsTwips / 20.0 : 0; parts.Add("float:left"); parts.Add($"line-height:{dropCapHeight:0.#}pt"); parts.Add($"padding-right:{hSpacePt:0.#}pt"); @@ -703,9 +703,216 @@ private string GetRunInlineCss(RunProperties? rProps) if (rProps.RightToLeftText != null && (rProps.RightToLeftText.Val == null || rProps.RightToLeftText.Val.Value)) parts.Add("direction:rtl;unicode-bidi:bidi-override"); + // w14 text effects (textFill, textOutline, glow, shadow, reflection) + AppendW14CssEffects(rProps, parts); + return string.Join(";", parts); } + private static string HexToRgba(string hexColor, double opacity) + { + if (hexColor.Length == 7 && int.TryParse(hexColor.AsSpan(1), + System.Globalization.NumberStyles.HexNumber, null, out var rgb)) + return $"rgba({(rgb >> 16) & 0xFF},{(rgb >> 8) & 0xFF},{rgb & 0xFF},{opacity:0.##})"; + return hexColor; + } + + private static void AppendW14CssEffects(RunProperties rProps, List parts) + { + var textShadows = new List(); + + foreach (var child in rProps.ChildElements) + { + if (child.NamespaceUri != W14Ns) continue; + + switch (child.LocalName) + { + case "textFill": + { + var innerXml = child.InnerXml; + if (innerXml.Contains("gradFill")) + { + var colors = new List(); + foreach (System.Text.RegularExpressions.Match m in + System.Text.RegularExpressions.Regex.Matches(innerXml, @"val=""([0-9A-Fa-f]{6})""")) + colors.Add($"#{m.Groups[1].Value}"); + + if (colors.Count >= 2) + { + var isRadial = innerXml.Contains(" p.StartsWith("color:")); + + if (isRadial) + { + parts.Add($"background:radial-gradient(circle,{colors[0]},{colors[1]})"); + } + else + { + // OOXML: 0°=left→right, 90°=top→bottom + // CSS: 0°=bottom→top, 90°=left→right, 180°=top→bottom + var cssAngle = angle + 90; + parts.Add($"background:linear-gradient({cssAngle:0.##}deg,{colors[0]},{colors[1]})"); + } + parts.Add("-webkit-background-clip:text"); + parts.Add("background-clip:text"); + parts.Add("-webkit-text-fill-color:transparent"); + } + else if (colors.Count == 1) + { + parts.RemoveAll(p => p.StartsWith("color:")); + parts.Add($"color:{colors[0]}"); + } + } + else if (innerXml.Contains("solidFill")) + { + var colorMatch = System.Text.RegularExpressions.Regex.Match( + innerXml, @"val=""([0-9A-Fa-f]{6})"""); + if (colorMatch.Success) + { + parts.RemoveAll(p => p.StartsWith("color:")); + parts.Add($"color:#{colorMatch.Groups[1].Value}"); + } + } + break; + } + case "textOutline": + { + var wAttr = child.GetAttributes().FirstOrDefault(a => a.LocalName == "w"); + var widthEmu = long.TryParse(wAttr.Value, out var w) ? w : 0; + var widthPt = Math.Max(0.5, widthEmu / 12700.0); + var colorMatch = System.Text.RegularExpressions.Regex.Match( + child.InnerXml, @"val=""([0-9A-Fa-f]{6})"""); + var color = colorMatch.Success ? $"#{colorMatch.Groups[1].Value}" : "currentColor"; + parts.Add($"-webkit-text-stroke:{widthPt:0.##}pt {color}"); + break; + } + case "shadow": + { + var attrs = child.GetAttributes().ToDictionary(a => a.LocalName, a => a.Value); + var colorMatch = System.Text.RegularExpressions.Regex.Match( + child.InnerXml, @"val=""([0-9A-Fa-f]{6})"""); + var color = colorMatch.Success ? $"#{colorMatch.Groups[1].Value}" : "#000000"; + var blurEmu = attrs.TryGetValue("blurRad", out var br) && long.TryParse(br, out var blurVal) ? blurVal : 0; + var blurPx = blurEmu / 12700.0 * 1.333; + var distEmu = attrs.TryGetValue("dist", out var dist) && long.TryParse(dist, out var distLong) ? distLong : 0; + var dirVal = attrs.TryGetValue("dir", out var dir) && long.TryParse(dir, out var dirLong) ? dirLong : 0; + var angleRad = dirVal / 60000.0 * Math.PI / 180.0; + var distPx = distEmu / 12700.0 * 1.333; + var xPx = distPx * Math.Sin(angleRad); + var yPx = distPx * Math.Cos(angleRad); + var alphaMatch = System.Text.RegularExpressions.Regex.Match( + child.InnerXml, @"alpha[^>]*val=""(\d+)"""); + if (alphaMatch.Success && double.TryParse(alphaMatch.Groups[1].Value, out var alphaVal) && alphaVal < 100000) + color = HexToRgba(color, alphaVal / 100000.0); + textShadows.Add($"{xPx:0.#}px {yPx:0.#}px {blurPx:0.#}px {color}"); + break; + } + case "glow": + { + var radAttr = child.GetAttributes().FirstOrDefault(a => a.LocalName == "rad"); + var radiusEmu = long.TryParse(radAttr.Value, out var r) ? r : 0; + var radiusPx = radiusEmu / 12700.0 * 1.333; + var colorMatch = System.Text.RegularExpressions.Regex.Match( + child.InnerXml, @"val=""([0-9A-Fa-f]{6})"""); + var color = colorMatch.Success ? $"#{colorMatch.Groups[1].Value}" : "#000000"; + var alphaMatch = System.Text.RegularExpressions.Regex.Match( + child.InnerXml, @"alpha[^>]*val=""(\d+)"""); + var alpha = alphaMatch.Success && double.TryParse(alphaMatch.Groups[1].Value, out var av) ? av / 100000.0 : 1.0; + // Multiple stacked text-shadow layers to approximate Word glow spread + // Word glow is a soft halo that extends from text edges; simulate with + // tight + medium + wide shadow layers at decreasing opacity + var c1 = HexToRgba(color, Math.Min(1.0, alpha * 0.9)); + var c2 = HexToRgba(color, Math.Min(1.0, alpha * 0.8)); + var c3 = HexToRgba(color, Math.Min(1.0, alpha * 0.5)); + var c4 = HexToRgba(color, Math.Min(1.0, alpha * 0.25)); + textShadows.Add($"0 0 {Math.Max(1, radiusPx * 0.15):0.#}px {c1}"); + textShadows.Add($"0 0 {Math.Max(2, radiusPx * 0.5):0.#}px {c2}"); + textShadows.Add($"0 0 {Math.Max(4, radiusPx * 1.0):0.#}px {c3}"); + textShadows.Add($"0 0 {Math.Max(8, radiusPx * 2.0):0.#}px {c4}"); + break; + } + case "reflection": + // Reflection handled at paragraph level via GetW14ReflectionCss() + // because -webkit-box-reflect on inline spans overlaps content below + break; + } + } + + if (textShadows.Count > 0) + parts.Add($"text-shadow:{string.Join(",", textShadows)}"); + } + + private static bool HasW14Reflection(Paragraph para) + { + foreach (var run in para.Elements()) + { + var rProps = run.RunProperties; + if (rProps == null) continue; + if (rProps.ChildElements.Any(c => c.NamespaceUri == W14Ns && c.LocalName == "reflection")) + return true; + } + return false; + } + + /// + /// If any run in the paragraph has w14:reflection, appends a flipped duplicate + /// block element below the original to simulate the reflection effect. + /// This approach reserves proper layout space (unlike -webkit-box-reflect). + /// + private void AppendW14ReflectionBlock(StringBuilder sb, Paragraph para, string tag, string? baseStyle) + { + // Find the first run with w14:reflection + OpenXmlElement? reflectionEl = null; + foreach (var run in para.Elements()) + { + var rProps = run.RunProperties; + if (rProps == null) continue; + foreach (var child in rProps.ChildElements) + { + if (child.NamespaceUri == W14Ns && child.LocalName == "reflection") + { reflectionEl = child; break; } + } + if (reflectionEl != null) break; + } + if (reflectionEl == null) return; + + var attrs = reflectionEl.GetAttributes().ToDictionary(a => a.LocalName, a => a.Value); + var stA = attrs.TryGetValue("stA", out var sa) && int.TryParse(sa, out var saVal) ? saVal / 1000.0 : 50.0; + var endA = attrs.TryGetValue("endA", out var ea) && int.TryParse(ea, out var eaVal) ? eaVal / 1000.0 : 0.0; + var endPos = attrs.TryGetValue("endPos", out var ep) && int.TryParse(ep, out var epVal) ? epVal / 1000.0 : 90.0; + var distEmu = attrs.TryGetValue("dist", out var d) && long.TryParse(d, out var dVal) ? dVal : 0; + var blurEmu = attrs.TryGetValue("blurRad", out var br) && long.TryParse(br, out var brVal) ? brVal : 0; + var distPx = distEmu / 12700.0 * 1.333; + var blurPx = blurEmu / 12700.0 * 1.333; + + // Build the reflection element: flipped, fading, non-interactive + var reflectStyle = new List(); + if (!string.IsNullOrEmpty(baseStyle)) reflectStyle.Add(baseStyle); + reflectStyle.Add("transform:scaleY(-1)"); + reflectStyle.Add("margin:0"); + reflectStyle.Add($"padding-top:{distPx:0.#}px"); + reflectStyle.Add("overflow:hidden"); + reflectStyle.Add("pointer-events:none"); + reflectStyle.Add("user-select:none"); + reflectStyle.Add("text-shadow:none"); + // Gradient mask: opaque at bottom (nearest to original text) → transparent at top + // Since the element is scaleY(-1) with transform-origin:top, the visual top is the + // reflected bottom of the text (closest to original). Mask goes from fully opaque + // at bottom to transparent at top in the element's own coordinate space. + var maskPct = 100.0 - endPos; // where full transparency starts + reflectStyle.Add($"-webkit-mask-image:linear-gradient(to top,rgba(0,0,0,{stA / 100.0:0.##}) {maskPct:0.#}%,rgba(0,0,0,{endA / 100.0:0.###}) 100%)"); + reflectStyle.Add($"mask-image:linear-gradient(to top,rgba(0,0,0,{stA / 100.0:0.##}) {maskPct:0.#}%,rgba(0,0,0,{endA / 100.0:0.###}) 100%)"); + if (blurPx > 0) + reflectStyle.Add($"filter:blur({blurPx:0.#}px)"); + + sb.Append($"<{tag} aria-hidden=\"true\" style=\"{string.Join(";", reflectStyle)}\">"); + RenderParagraphContentHtml(sb, para); + sb.AppendLine($""); + } + private string GetTableCellInlineCss(TableCell cell, bool tableBordersNone, TableBorders? tblBorders = null, Dictionary? condFormats = null, List? condTypes = null, int rowIdx = 0, int colIdx = 0, int totalRows = 1, int totalCols = 1) @@ -998,6 +1205,43 @@ private string ResolveParaFontForLineHeight(Paragraph para) return null; } + private string? ResolveStyleColor(string styleId) + { + var visited = new HashSet(); + var current = styleId; + while (current != null && visited.Add(current)) + { + var style = _doc.MainDocumentPart?.StyleDefinitionsPart?.Styles + ?.Elements